max / makenotwork
20 files changed,
+656 insertions,
-89 deletions
| @@ -3453,7 +3453,7 @@ | |||
| 3453 | 3453 | ||
| 3454 | 3454 | [[package]] | |
| 3455 | 3455 | name = "makenotwork" | |
| 3456 | - | version = "0.1.7" | |
| 3456 | + | version = "0.1.9" | |
| 3457 | 3457 | dependencies = [ | |
| 3458 | 3458 | "ammonia", | |
| 3459 | 3459 | "anyhow", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.1.8" | |
| 3 | + | version = "0.1.9" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -13,7 +13,7 @@ | |||
| 13 | 13 | ## Repository | |
| 14 | 14 | ||
| 15 | 15 | ``` | |
| 16 | - | makenot.work/git/maxmj/ | |
| 16 | + | makenot.work/git/max/ | |
| 17 | 17 | ``` | |
| 18 | 18 | ||
| 19 | 19 | Available for review: |
| @@ -170,7 +170,7 @@ | |||
| 170 | 170 | - Check for vulnerabilities | |
| 171 | 171 | - Report issues responsibly | |
| 172 | 172 | ||
| 173 | - | Repository: [makenot.work/git/maxmj/](https://makenot.work/git/maxmj/) | |
| 173 | + | Repository: [makenot.work/git/max/](https://makenot.work/git/max/) | |
| 174 | 174 | ||
| 175 | 175 | --- | |
| 176 | 176 |
| @@ -2,8 +2,8 @@ | |||
| 2 | 2 | # Setup SSH git push access on the production VPS. | |
| 3 | 3 | # | |
| 4 | 4 | # Creates a `git` system user with git-shell, home directory at /opt/git/ | |
| 5 | - | # so that `git@makenot.work:maxmj/makenotwork.git` resolves to | |
| 6 | - | # /opt/git/maxmj/makenotwork.git. | |
| 5 | + | # so that `git@makenot.work:max/makenotwork.git` resolves to | |
| 6 | + | # /opt/git/max/makenotwork.git. | |
| 7 | 7 | # | |
| 8 | 8 | # Run as root on the production VPS. | |
| 9 | 9 | ||
| @@ -62,7 +62,7 @@ | |||
| 62 | 62 | echo "=== Git SSH setup complete ===" | |
| 63 | 63 | echo "" | |
| 64 | 64 | echo "Test with:" | |
| 65 | - | echo " git clone git@makenot.work:maxmj/makenotwork.git" | |
| 65 | + | echo " git clone git@makenot.work:max/makenotwork.git" | |
| 66 | 66 | echo " cd makenotwork && git push" | |
| 67 | 67 | echo "" | |
| 68 | 68 | echo "To add more SSH keys, edit: $GIT_HOME/.ssh/authorized_keys" |
| @@ -561,6 +561,96 @@ | |||
| 561 | 561 | self.send_email(to, subject, body).await | |
| 562 | 562 | } | |
| 563 | 563 | ||
| 564 | + | // ── Onboarding email sequence ── | |
| 565 | + | ||
| 566 | + | /// Step 1: Welcome email sent immediately after signup. | |
| 567 | + | pub async fn send_onboarding_welcome( | |
| 568 | + | &self, | |
| 569 | + | to_email: &str, | |
| 570 | + | to_name: Option<&str>, | |
| 571 | + | host_url: &str, | |
| 572 | + | ) -> Result<()> { | |
| 573 | + | let subject = "Welcome to Makenot.work"; | |
| 574 | + | let body = format!( | |
| 575 | + | r#"Hi{name}, | |
| 576 | + | ||
| 577 | + | Welcome to Makenot.work — the fair creator platform. | |
| 578 | + | ||
| 579 | + | Here's what makes us different: | |
| 580 | + | - 0% platform fee. You keep everything (minus Stripe's ~3% processing). | |
| 581 | + | - No lock-in. Export your data, cancel anytime. | |
| 582 | + | - Source-available. You can see how everything works. | |
| 583 | + | ||
| 584 | + | To get started, head to your dashboard: | |
| 585 | + | {host_url}/dashboard | |
| 586 | + | ||
| 587 | + | You'll find a checklist there to help you set up your profile, connect Stripe, create your first project, and publish your first item. | |
| 588 | + | ||
| 589 | + | If you have questions, reply to this email — it goes straight to a human. | |
| 590 | + | ||
| 591 | + | - Makenotwork"#, | |
| 592 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 593 | + | host_url = host_url, | |
| 594 | + | ); | |
| 595 | + | ||
| 596 | + | self.send_email(to_email, subject, &body).await | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | /// Step 2: Profile setup tips (sent ~24h after signup). | |
| 600 | + | pub async fn send_onboarding_profile( | |
| 601 | + | &self, | |
| 602 | + | to_email: &str, | |
| 603 | + | to_name: Option<&str>, | |
| 604 | + | host_url: &str, | |
| 605 | + | ) -> Result<()> { | |
| 606 | + | let subject = "Set up your creator profile"; | |
| 607 | + | let body = format!( | |
| 608 | + | r#"Hi{name}, | |
| 609 | + | ||
| 610 | + | A quick tip: creators with a display name and bio get more attention from fans. | |
| 611 | + | ||
| 612 | + | Head to your account settings to add them: | |
| 613 | + | {host_url}/dashboard (Account tab) | |
| 614 | + | ||
| 615 | + | Your display name appears on all your projects and items. Your bio tells fans who you are and what you create. | |
| 616 | + | ||
| 617 | + | Once your profile is set, create your first project — that's where your items live. | |
| 618 | + | ||
| 619 | + | - Makenotwork"#, | |
| 620 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 621 | + | host_url = host_url, | |
| 622 | + | ); | |
| 623 | + | ||
| 624 | + | self.send_email(to_email, subject, &body).await | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | /// Step 3: Stripe connection guide (sent ~72h after signup). | |
| 628 | + | pub async fn send_onboarding_stripe( | |
| 629 | + | &self, | |
| 630 | + | to_email: &str, | |
| 631 | + | to_name: Option<&str>, | |
| 632 | + | host_url: &str, | |
| 633 | + | ) -> Result<()> { | |
| 634 | + | let subject = "Start receiving payments"; | |
| 635 | + | let body = format!( | |
| 636 | + | r#"Hi{name}, | |
| 637 | + | ||
| 638 | + | To sell on Makenot.work, connect your Stripe account. It takes about 5 minutes. | |
| 639 | + | ||
| 640 | + | {host_url}/dashboard (Payments tab) | |
| 641 | + | ||
| 642 | + | Stripe handles payment processing at ~3% per transaction. That's the only fee — Makenot.work takes 0%. | |
| 643 | + | ||
| 644 | + | Once connected, you can set prices on your items and start selling immediately. | |
| 645 | + | ||
| 646 | + | - Makenotwork"#, | |
| 647 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 648 | + | host_url = host_url, | |
| 649 | + | ); | |
| 650 | + | ||
| 651 | + | self.send_email(to_email, subject, &body).await | |
| 652 | + | } | |
| 653 | + | ||
| 564 | 654 | /// Send an email (implementation) | |
| 565 | 655 | async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { | |
| 566 | 656 | self.send_email_inner(to, subject, body, &[], None).await |
| @@ -65,6 +65,59 @@ | |||
| 65 | 65 | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | /// Process the getting-started email drip sequence. | |
| 69 | + | /// | |
| 70 | + | /// Step 1 (welcome) is sent at signup in the auth handler. | |
| 71 | + | /// Step 2 (profile tips) fires 24h after welcome, skipped if display_name is set. | |
| 72 | + | /// Step 3 (Stripe guide) fires 72h after welcome, skipped if Stripe is connected. | |
| 73 | + | async fn send_onboarding_emails(state: &AppState) { | |
| 74 | + | let host_url = &state.config.host_url; | |
| 75 | + | ||
| 76 | + | // Step 1→2: profile tips (24h after welcome) | |
| 77 | + | if let Ok(users) = | |
| 78 | + | db::users::get_onboarding_candidates(&state.db, 1, chrono::Duration::hours(24)).await | |
| 79 | + | { | |
| 80 | + | for user in users { | |
| 81 | + | // Skip if they already set a display name | |
| 82 | + | if user.display_name.is_some() { | |
| 83 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 2).await; | |
| 84 | + | continue; | |
| 85 | + | } | |
| 86 | + | if let Err(e) = state | |
| 87 | + | ||
| 88 | + | .send_onboarding_profile(&user.email, user.display_name.as_deref(), host_url) | |
| 89 | + | .await | |
| 90 | + | { | |
| 91 | + | tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding profile email"); | |
| 92 | + | continue; | |
| 93 | + | } | |
| 94 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 2).await; | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Step 2→3: Stripe guide (72h after welcome) | |
| 99 | + | if let Ok(users) = | |
| 100 | + | db::users::get_onboarding_candidates(&state.db, 2, chrono::Duration::hours(48)).await | |
| 101 | + | { | |
| 102 | + | for user in users { | |
| 103 | + | // Skip if they already connected Stripe | |
| 104 | + | if user.stripe_account_id.is_some() { | |
| 105 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 3).await; | |
| 106 | + | continue; | |
| 107 | + | } | |
| 108 | + | if let Err(e) = state | |
| 109 | + | ||
| 110 | + | .send_onboarding_stripe(&user.email, user.display_name.as_deref(), host_url) | |
| 111 | + | .await | |
| 112 | + | { | |
| 113 | + | tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding stripe email"); | |
| 114 | + | continue; | |
| 115 | + | } | |
| 116 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 3).await; | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 68 | 121 | /// Spawn the background scheduler loop. Drop `shutdown_tx` to stop it. | |
| 69 | 122 | pub fn spawn_scheduler( | |
| 70 | 123 | state: AppState, | |
| @@ -104,6 +157,9 @@ | |||
| 104 | 157 | } | |
| 105 | 158 | } | |
| 106 | 159 | ||
| 160 | + | // Send onboarding drip emails | |
| 161 | + | send_onboarding_emails(&state).await; | |
| 162 | + | ||
| 107 | 163 | // Publish scheduled blog posts | |
| 108 | 164 | match db::blog_posts::publish_scheduled_blog_posts(&state.db).await { | |
| 109 | 165 | Ok(posts) => { |