Skip to main content

max / makenotwork

39.4 KB · 1335 lines History Blame Raw
1 //! Creator activity, platform notices, and issue tracking email templates.
2
3 use crate::db::ListKind;
4 use crate::email::{Audience, Delivery, EmailClass, EmailClient, OperationalKind};
5 use crate::error::Result;
6
7 // ── Creator activity ──
8
9 impl EmailClient {
10 /// Notify a creator that someone bought their content.
11 #[allow(clippy::too_many_arguments)]
12 pub async fn send_sale_notification(
13 &self,
14 to_user_id: crate::db::UserId,
15 to_email: &str,
16 to_name: Option<&str>,
17 buyer_username: &str,
18 item_title: &str,
19 price: &str,
20 unsub_url: Option<&str>,
21 ) -> Result<()> {
22 let subject = format!("New sale: {item_title}");
23 let body = format!(
24 r"Hi{name},
25
26 {buyer} just purchased {item} for {price}.
27
28 View your sales from your dashboard.
29
30 - Makenotwork",
31 name = crate::email::greeting(to_name),
32 buyer = buyer_username,
33 item = item_title,
34 price = price,
35 );
36
37 self.dispatch(
38 EmailClass::Optional(ListKind::Sale),
39 Audience::User(to_user_id, to_email),
40 &subject,
41 &body,
42 Delivery {
43 unsub_url,
44 ..Default::default()
45 },
46 )
47 .await
48 }
49
50 /// Notify a creator that someone followed them or their project.
51 pub async fn send_follower_notification(
52 &self,
53 to_user_id: crate::db::UserId,
54 to_email: &str,
55 to_name: Option<&str>,
56 follower_username: &str,
57 context: &str,
58 unsub_url: Option<&str>,
59 ) -> Result<()> {
60 let subject = "New follower";
61 let body = format!(
62 r"Hi{name},
63
64 {follower} is now following {context}.
65
66 - Makenotwork",
67 name = crate::email::greeting(to_name),
68 follower = follower_username,
69 context = context,
70 );
71
72 self.dispatch(
73 EmailClass::Optional(ListKind::Follower),
74 Audience::User(to_user_id, to_email),
75 subject,
76 &body,
77 Delivery {
78 unsub_url,
79 ..Default::default()
80 },
81 )
82 .await
83 }
84
85 /// Send a creator's broadcast message to a follower.
86 pub async fn send_broadcast(
87 &self,
88 to_email: &str,
89 to_name: Option<&str>,
90 creator_name: &str,
91 subject: &str,
92 body_text: &str,
93 unsub_url: Option<&str>,
94 ) -> Result<()> {
95 let subject = format!("{subject}, from {creator_name}");
96 let body = format!(
97 r"Hi{name},
98
99 {body}
100
101 --
102 You received this because you follow {creator} on Makenotwork.
103
104 - Makenotwork",
105 name = crate::email::greeting(to_name),
106 body = body_text,
107 creator = creator_name,
108 );
109
110 self.dispatch(
111 EmailClass::ListAudience,
112 Audience::Address(to_email),
113 &subject,
114 &body,
115 Delivery {
116 unsub_url,
117 broadcast: true,
118 ..Default::default()
119 },
120 )
121 .await
122 }
123
124 /// Notify followers about a new release.
125 pub async fn send_release_announcement(
126 &self,
127 to_email: &str,
128 to_name: Option<&str>,
129 creator_name: &str,
130 item_title: &str,
131 item_url: &str,
132 unsub_url: Option<&str>,
133 ) -> Result<()> {
134 let subject = format!("New release: {item_title} by {creator_name}");
135 let body = format!(
136 r"Hi{name},
137
138 {creator} just published something new: {item}
139
140 Check it out: {url}
141
142 - Makenotwork",
143 name = crate::email::greeting(to_name),
144 creator = creator_name,
145 item = item_title,
146 url = item_url,
147 );
148
149 self.dispatch(
150 EmailClass::ListAudience,
151 Audience::Address(to_email),
152 &subject,
153 &body,
154 Delivery {
155 unsub_url,
156 broadcast: true,
157 ..Default::default()
158 },
159 )
160 .await
161 }
162
163 /// Notify subscribers about a new blog post.
164 pub async fn send_blog_post_announcement(
165 &self,
166 to_email: &str,
167 to_name: Option<&str>,
168 creator_name: &str,
169 post_title: &str,
170 post_url: &str,
171 unsub_url: Option<&str>,
172 ) -> Result<()> {
173 let subject = format!("New post: {post_title} by {creator_name}");
174 let body = format!(
175 r"Hi{name},
176
177 {creator} just published a new post: {title}
178
179 Read it here: {url}
180
181 - Makenotwork",
182 name = crate::email::greeting(to_name),
183 creator = creator_name,
184 title = post_title,
185 url = post_url,
186 );
187
188 self.dispatch(
189 EmailClass::ListAudience,
190 Audience::Address(to_email),
191 &subject,
192 &body,
193 Delivery {
194 unsub_url,
195 broadcast: true,
196 ..Default::default()
197 },
198 )
199 .await
200 }
201
202 /// Notify a creator that their invite code was redeemed by a new user.
203 pub async fn send_invite_redeemed(
204 &self,
205 to_user_id: crate::db::UserId,
206 to_email: &str,
207 to_name: Option<&str>,
208 invitee_username: &str,
209 unsub_url: Option<&str>,
210 ) -> Result<()> {
211 let subject = "Your invite was used";
212 let body = format!(
213 r"Hi{name},
214
215 {invitee} just signed up using one of your invite codes. Their account is pending admin approval.
216
217 - Makenotwork",
218 name = crate::email::greeting(to_name),
219 invitee = invitee_username
220 );
221
222 self.dispatch(
223 EmailClass::Optional(ListKind::Invite),
224 Audience::User(to_user_id, to_email),
225 subject,
226 &body,
227 Delivery {
228 unsub_url,
229 ..Default::default()
230 },
231 )
232 .await
233 }
234
235 /// Ask someone to confirm they have read an alert, and keep asking.
236 ///
237 /// [`OperationalKind::AcknowledgementRequired`] rather than a preference:
238 /// the message exists because the sender needs evidence a human saw it, and
239 /// a version of that which can be switched off answers no question at all.
240 /// It is the narrowest kind of unstoppable mail, because the recipient ends
241 /// it themselves by pressing the button.
242 ///
243 /// `attempt` is how many have already gone out, so the repeats can say so
244 /// rather than reading as the same message failing to arrive.
245 pub async fn send_acknowledgement_required(
246 &self,
247 to_email: &str,
248 to_name: Option<&str>,
249 title: &str,
250 detail: &str,
251 ack_url: &str,
252 attempt: i32,
253 ) -> Result<()> {
254 let subject = if attempt > 0 {
255 format!("Still waiting: {title}")
256 } else {
257 title.to_string()
258 };
259 let repeat_line = if attempt > 0 {
260 format!(
261 "\nThis is the {} time we have written about this. We keep asking because \
262 nothing else tells us the message reached a person.\n",
263 match attempt {
264 1 => "second".to_string(),
265 2 => "third".to_string(),
266 n => format!("{}th", n + 1),
267 }
268 )
269 } else {
270 String::new()
271 };
272
273 let body = format!(
274 r"Hi{name},
275
276 {detail}
277 {repeat_line}
278 Confirm you have read this:
279
280 {ack_url}
281
282 That link stops these messages. Nothing else does, and we would rather write
283 four times than have you find out from a customer.
284
285 - Makenotwork",
286 name = crate::email::greeting(to_name),
287 );
288
289 self.dispatch(
290 EmailClass::Operational(OperationalKind::AcknowledgementRequired),
291 Audience::Address(to_email),
292 &subject,
293 &body,
294 Delivery::default(),
295 )
296 .await
297 }
298
299 // ── Platform notices ──
300
301 /// Send a policy warning to a user (no suspension, informational only)
302 pub async fn send_policy_warning(
303 &self,
304 to_email: &str,
305 to_name: Option<&str>,
306 reason: &str,
307 ) -> Result<()> {
308 let subject = "Policy notice regarding your account";
309 let body = format!(
310 r"Hi{name},
311
312 We're writing to let you know about an issue with your account or content on Makenotwork.
313
314 Issue: {reason}
315
316 No action has been taken against your account. This is informational. We want to give you a chance to address this before it becomes a problem.
317
318 If you have questions or believe this was sent in error, reply to this email or contact info@makenot.work.
319
320 - Makenotwork",
321 name = crate::email::greeting(to_name),
322 reason = reason
323 );
324
325 self.dispatch(
326 EmailClass::Operational(OperationalKind::PolicyWarning),
327 Audience::Address(to_email),
328 subject,
329 &body,
330 Delivery::default(),
331 )
332 .await
333 }
334
335 /// Send a suspension notification to a user
336 pub async fn send_suspension_notification(
337 &self,
338 to_email: &str,
339 to_name: Option<&str>,
340 reason: &str,
341 ) -> Result<()> {
342 let subject = "Your account has been suspended";
343 let body = format!(
344 r"Hi{name},
345
346 Your Makenotwork account has been suspended.
347
348 Reason: {reason}
349
350 You can appeal this decision from your dashboard. You can also export your data at any time.
351
352 Log in to your dashboard to submit an appeal or export your data.
353
354 - Makenotwork",
355 name = crate::email::greeting(to_name),
356 reason = reason
357 );
358
359 self.dispatch(
360 EmailClass::Operational(OperationalKind::Suspension),
361 Audience::Address(to_email),
362 subject,
363 &body,
364 Delivery::default(),
365 )
366 .await
367 }
368
369 /// Send an appeal decision notification to a user
370 pub async fn send_appeal_decision(
371 &self,
372 to_email: &str,
373 to_name: Option<&str>,
374 decision: &str,
375 response: &str,
376 ) -> Result<()> {
377 let outcome = if decision == "approved" {
378 "Your account has been reinstated"
379 } else {
380 "Your appeal has been denied"
381 };
382
383 let subject = "Your appeal has been reviewed";
384 let body = format!(
385 r"Hi{name},
386
387 {outcome}.
388
389 Response from the review team:
390
391 {response}
392
393 Log in to your dashboard for more details.
394
395 - Makenotwork",
396 name = crate::email::greeting(to_name),
397 outcome = outcome,
398 response = response
399 );
400
401 self.dispatch(
402 EmailClass::Operational(OperationalKind::AppealDecision),
403 Audience::Address(to_email),
404 subject,
405 &body,
406 Delivery::default(),
407 )
408 .await
409 }
410
411 /// Notify a creator that their item was removed by an admin.
412 pub async fn send_content_removal(
413 &self,
414 to_email: &str,
415 to_name: Option<&str>,
416 item_title: &str,
417 reason: &str,
418 ) -> Result<()> {
419 let subject = format!("Content removed: {item_title}");
420 let body = format!(
421 r#"Hi{name},
422
423 Your item "{item_title}" has been removed from public access.
424
425 Reason: {reason}
426
427 Your account remains active. You can still access the item in your dashboard. If you believe this was a mistake, you can reply to this email or submit an appeal from your dashboard.
428
429 - Makenotwork"#,
430 name = crate::email::greeting(to_name),
431 item_title = item_title,
432 reason = reason,
433 );
434
435 self.dispatch(
436 EmailClass::Operational(OperationalKind::ContentRemoval),
437 Audience::Address(to_email),
438 &subject,
439 &body,
440 Delivery::default(),
441 )
442 .await
443 }
444
445 /// Notify a creator that their previously removed item has been restored.
446 pub async fn send_content_restored(
447 &self,
448 to_email: &str,
449 to_name: Option<&str>,
450 item_title: &str,
451 ) -> Result<()> {
452 let subject = format!("Content restored: {item_title}");
453 let body = format!(
454 r#"Hi{name},
455
456 Your item "{item_title}" has been restored. You can now re-publish it from your dashboard.
457
458 - Makenotwork"#,
459 name = crate::email::greeting(to_name),
460 item_title = item_title,
461 );
462
463 self.dispatch(
464 EmailClass::Operational(OperationalKind::ContentRestored),
465 Audience::Address(to_email),
466 &subject,
467 &body,
468 Delivery::default(),
469 )
470 .await
471 }
472
473 /// Notify a user that their account has been permanently terminated.
474 pub async fn send_account_termination(
475 &self,
476 to_email: &str,
477 to_name: Option<&str>,
478 ) -> Result<()> {
479 let subject = "Your Makenotwork account has been terminated";
480 let body = format!(
481 r"Hi{name},
482
483 Your Makenotwork account has been permanently terminated for repeated or serious policy violations.
484
485 You have 30 days from today to export your data:
486
487 - Log in at makenot.work
488 - Go to Dashboard > Export
489 - Download your content and transaction records
490
491 After 30 days, your account and all associated data will be permanently deleted.
492
493 If you believe this was a mistake, you can reply to this email.
494
495 - Makenotwork",
496 name = crate::email::greeting(to_name),
497 );
498
499 self.dispatch(
500 EmailClass::Operational(OperationalKind::AccountTermination),
501 Audience::Address(to_email),
502 subject,
503 &body,
504 Delivery::default(),
505 )
506 .await
507 }
508
509 /// Send a platform shutdown notice to a user
510 pub async fn send_shutdown_notice(
511 &self,
512 to_email: &str,
513 to_name: Option<&str>,
514 shutdown_date: &str,
515 ) -> Result<()> {
516 let subject = "Important: Makenotwork is shutting down";
517 let body = format!(
518 r"Hi{name},
519
520 We are writing to let you know that Makenotwork will be shutting down on {shutdown_date}.
521
522 You have at least 90 days from today to export all of your data. Your projects, content, sales history, and follower data can all be exported from your dashboard.
523
524 To export your data, log in and visit your dashboard export page.
525
526 We built Makenotwork on the principle of no lock-in, and we intend to honor that through the end. Thank you for being part of this.
527
528 - Makenotwork",
529 name = crate::email::greeting(to_name),
530 shutdown_date = shutdown_date
531 );
532
533 self.dispatch(
534 EmailClass::Operational(OperationalKind::PlatformShutdown),
535 Audience::Address(to_email),
536 subject,
537 &body,
538 Delivery::default(),
539 )
540 .await
541 }
542
543 // ── Issue tracking ──
544
545 /// Notify a repo owner that someone opened a new issue.
546 #[allow(clippy::too_many_arguments)]
547 pub async fn send_new_issue_notification(
548 &self,
549 to_user_id: crate::db::UserId,
550 to_email: &str,
551 to_name: Option<&str>,
552 repo_owner: &str,
553 repo_name: &str,
554 issue_number: i32,
555 issue_title: &str,
556 author_username: &str,
557 issue_url: &str,
558 unsub_url: Option<&str>,
559 reply_to: Option<&str>,
560 message_id: Option<&str>,
561 ) -> Result<()> {
562 let subject = format!("New issue on {repo_owner}/{repo_name}: {issue_title}");
563 let body = format!(
564 r"Hi{name},
565
566 {author} opened issue #{number} on {owner}/{repo}:
567
568 {title}
569
570 View it here: {url}
571
572 Reply to this email to comment on this issue.
573
574 - Makenotwork",
575 name = crate::email::greeting(to_name),
576 author = author_username,
577 number = issue_number,
578 owner = repo_owner,
579 repo = repo_name,
580 title = issue_title,
581 url = issue_url,
582 );
583
584 let mut headers: Vec<(&str, String)> = Vec::new();
585 if let Some(rt) = reply_to {
586 headers.push(("Reply-To", rt.to_string()));
587 }
588 if let Some(mid) = message_id {
589 headers.push(("Message-ID", mid.to_string()));
590 }
591
592 self.dispatch(
593 EmailClass::Optional(ListKind::Issues),
594 Audience::User(to_user_id, to_email),
595 &subject,
596 &body,
597 Delivery {
598 headers: &headers,
599 unsub_url,
600 ..Default::default()
601 },
602 )
603 .await
604 }
605
606 /// Notify about a new comment or status change on an issue.
607 #[allow(clippy::too_many_arguments)]
608 pub async fn send_issue_comment_notification(
609 &self,
610 to_user_id: crate::db::UserId,
611 to_email: &str,
612 to_name: Option<&str>,
613 repo_owner: &str,
614 repo_name: &str,
615 issue_number: i32,
616 issue_title: &str,
617 commenter_username: &str,
618 comment_preview: &str,
619 issue_url: &str,
620 unsub_url: Option<&str>,
621 reply_to: Option<&str>,
622 message_id: Option<&str>,
623 in_reply_to: Option<&str>,
624 ) -> Result<()> {
625 let subject = format!("Re: New issue on {repo_owner}/{repo_name}: {issue_title}");
626 let body = format!(
627 r"Hi{name},
628
629 {commenter} commented on issue #{number} ({title}) in {owner}/{repo}:
630
631 {preview}
632
633 View it here: {url}
634
635 Reply to this email to comment on this issue.
636
637 - Makenotwork",
638 name = crate::email::greeting(to_name),
639 commenter = commenter_username,
640 number = issue_number,
641 title = issue_title,
642 owner = repo_owner,
643 repo = repo_name,
644 preview = comment_preview,
645 url = issue_url,
646 );
647
648 let mut headers: Vec<(&str, String)> = Vec::new();
649 if let Some(rt) = reply_to {
650 headers.push(("Reply-To", rt.to_string()));
651 }
652 if let Some(mid) = message_id {
653 headers.push(("Message-ID", mid.to_string()));
654 }
655 if let Some(irt) = in_reply_to {
656 headers.push(("In-Reply-To", irt.to_string()));
657 headers.push(("References", irt.to_string()));
658 }
659
660 self.dispatch(
661 EmailClass::Optional(ListKind::Issues),
662 Audience::User(to_user_id, to_email),
663 &subject,
664 &body,
665 Delivery {
666 headers: &headers,
667 unsub_url,
668 ..Default::default()
669 },
670 )
671 .await
672 }
673
674 /// Notify a creator that someone tipped them.
675 #[allow(clippy::too_many_arguments)]
676 pub async fn send_tip_notification(
677 &self,
678 to_user_id: crate::db::UserId,
679 to_email: &str,
680 to_name: Option<&str>,
681 tipper_name: &str,
682 price: &str,
683 message: Option<&str>,
684 unsub_url: Option<&str>,
685 ) -> Result<()> {
686 let subject = format!("{tipper_name} tipped you {price}");
687 let body = match message {
688 Some(msg) => format!(
689 r#"Hi{name},
690
691 {tipper} tipped you {price} with a message:
692
693 "{msg}"
694
695 View your tips from your dashboard.
696
697 - Makenotwork"#,
698 name = crate::email::greeting(to_name),
699 tipper = tipper_name,
700 price = price,
701 msg = msg,
702 ),
703 None => format!(
704 r"Hi{name},
705
706 {tipper} tipped you {price}.
707
708 View your tips from your dashboard.
709
710 - Makenotwork",
711 name = crate::email::greeting(to_name),
712 tipper = tipper_name,
713 price = price,
714 ),
715 };
716
717 self.dispatch(
718 EmailClass::Optional(ListKind::Tip),
719 Audience::User(to_user_id, to_email),
720 &subject,
721 &body,
722 Delivery {
723 unsub_url,
724 ..Default::default()
725 },
726 )
727 .await
728 }
729
730 /// Notify a buyer that a creator they purchased from is leaving the platform.
731 /// Sent by the platform (not the creator) so it bypasses contact sharing preferences.
732 pub async fn send_creator_departure_notification(
733 &self,
734 to_email: &str,
735 to_name: Option<&str>,
736 creator_name: &str,
737 ) -> Result<()> {
738 let subject = format!("{creator_name} is leaving Makenotwork, download your purchases");
739 let body = format!(
740 r"Hi{name},
741
742 {creator} has deleted their creator account on Makenotwork.
743
744 Content you purchased from {creator} will remain available for 90 days. After that, it will be permanently removed from the platform.
745
746 To download your purchases, log in and visit your library:
747
748 https://makenot.work/library
749
750 Your transaction receipts are preserved indefinitely regardless.
751
752 - Makenotwork",
753 name = crate::email::greeting(to_name),
754 creator = creator_name,
755 );
756
757 self.dispatch(
758 EmailClass::Operational(OperationalKind::CreatorDeparture),
759 Audience::Address(to_email),
760 &subject,
761 &body,
762 Delivery::default(),
763 )
764 .await
765 }
766
767 /// Send a platform status change notification to an opted-in user.
768 pub async fn send_status_notification(
769 &self,
770 to_user_id: crate::db::UserId,
771 to_email: &str,
772 to_name: Option<&str>,
773 status: &str,
774 previous: &str,
775 unsub_url: &str,
776 ) -> Result<()> {
777 let subject = match status {
778 "operational" => "Makenotwork recovered, all services operational",
779 "degraded" => "Makenotwork: partial service degradation",
780 _ => "Makenotwork: service disruption",
781 };
782
783 let body = format!(
784 r"Hi{name},
785
786 Platform status changed: {previous} -> {status}.
787
788 Current status: {status}
789 Previous status: {previous}
790
791 Check live status at https://makenot.work/health
792
793 Your content remains accessible to fans. If you experience issues, they should resolve as the platform recovers.
794
795 - Makenotwork",
796 name = crate::email::greeting(to_name),
797 status = status,
798 previous = previous,
799 );
800
801 self.dispatch(
802 EmailClass::Optional(ListKind::Status),
803 Audience::User(to_user_id, to_email),
804 subject,
805 &body,
806 Delivery {
807 unsub_url: Some(unsub_url),
808 ..Default::default()
809 },
810 )
811 .await
812 }
813
814 pub async fn send_alert(&self, to: &str, subject: &str, body: &str) -> Result<()> {
815 self.dispatch(
816 EmailClass::Operational(OperationalKind::OperatorAlert),
817 Audience::Address(to),
818 subject,
819 body,
820 Delivery::default(),
821 )
822 .await
823 }
824
825 /// Warn an app owner that they're approaching (or have hit) a SyncKit cap.
826 ///
827 /// `dimension` is `"storage"`, `"storage_per_key"`, or `"egress"`. `pct`
828 /// is 75/90/100. At 100% the next request to that dimension will be
829 /// hard-blocked with a 402. `key` is `Some(_)` only when
830 /// `dimension == "storage_per_key"`, it names the SDK key whose
831 /// allotment tripped, so the developer knows which workspace to nudge.
832 #[allow(clippy::too_many_arguments)]
833 pub async fn send_synckit_usage_warning(
834 &self,
835 to_email: &str,
836 app_name: &str,
837 dimension: &str,
838 key: Option<&str>,
839 pct: i16,
840 used_bytes: i64,
841 limit_bytes: i64,
842 billing_url: &str,
843 ) -> Result<()> {
844 fn fmt_gb(bytes: i64) -> String {
845 let gb = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
846 if gb >= 10.0 {
847 format!("{gb:.0} GB")
848 } else {
849 format!("{gb:.2} GB")
850 }
851 }
852
853 let dim_human = match dimension {
854 "storage" => "storage".to_string(),
855 "storage_per_key" => match key {
856 Some(k) => format!("storage for key \"{k}\""),
857 None => "per-key storage".to_string(),
858 },
859 "egress" => "monthly egress".to_string(),
860 other => other.to_string(),
861 };
862 let subject = if pct >= 100 {
863 format!("{app_name}: {dim_human} cap reached")
864 } else {
865 format!("{app_name}: {pct}% of {dim_human} cap used")
866 };
867
868 let pct_msg = if pct >= 100 {
869 format!(
870 "Your SyncKit app \"{app_name}\" has reached its {dim_human} cap.\n\
871 Further {dim_human} requests will be rejected (HTTP 402) until the\n\
872 cap is raised or the period rolls over."
873 )
874 } else {
875 format!(
876 "Your SyncKit app \"{app_name}\" has used {pct}% of its {dim_human}\n\
877 cap. At 100% further requests are hard-blocked (HTTP 402).",
878 )
879 };
880
881 let body = format!(
882 r"{pct_msg}
883
884 Used: {used}
885 Limit: {limit}
886
887 Adjust caps or review usage:
888 {url}
889
890 - Makenotwork",
891 used = fmt_gb(used_bytes),
892 limit = fmt_gb(limit_bytes),
893 url = billing_url,
894 );
895
896 self.dispatch(
897 EmailClass::Operational(OperationalKind::UsageLimit),
898 Audience::Address(to_email),
899 &subject,
900 &body,
901 Delivery::default(),
902 )
903 .await
904 }
905 }
906
907 #[cfg(test)]
908 mod tests {
909 use super::*;
910 use crate::email::EmailTransport;
911 use std::sync::{Arc, Mutex};
912
913 /// One captured email: (to, subject, html_body, text_body).
914 type SentEmail = (String, String, String, Option<String>);
915
916 /// In-memory transport that captures sent emails for assertion in tests.
917 struct CapturingTransport {
918 sent: Mutex<Vec<SentEmail>>,
919 }
920
921 impl CapturingTransport {
922 fn new() -> Self {
923 Self {
924 sent: Mutex::new(Vec::new()),
925 }
926 }
927 fn last(&self) -> (String, String, String, Option<String>) {
928 self.sent
929 .lock()
930 .unwrap()
931 .last()
932 .cloned()
933 .expect("no email captured")
934 }
935 }
936
937 #[async_trait::async_trait]
938 impl EmailTransport for CapturingTransport {
939 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
940 self.sent.lock().unwrap().push((
941 to.to_string(),
942 subject.to_string(),
943 body.to_string(),
944 None,
945 ));
946 Ok(())
947 }
948 async fn send_email_with_headers_and_unsub(
949 &self,
950 to: &str,
951 subject: &str,
952 body: &str,
953 _extra_headers: &[(&str, String)],
954 unsub_url: Option<&str>,
955 ) -> Result<()> {
956 self.sent.lock().unwrap().push((
957 to.to_string(),
958 subject.to_string(),
959 body.to_string(),
960 unsub_url.map(String::from),
961 ));
962 Ok(())
963 }
964 async fn send_email_broadcast_with_unsub(
965 &self,
966 to: &str,
967 subject: &str,
968 body: &str,
969 unsub_url: Option<&str>,
970 ) -> Result<()> {
971 self.sent.lock().unwrap().push((
972 to.to_string(),
973 subject.to_string(),
974 body.to_string(),
975 unsub_url.map(String::from),
976 ));
977 Ok(())
978 }
979 }
980
981 /// A stand-in recipient id for the Optional senders. `client_with_capture`
982 /// builds a pool-less client, so `dispatch` sends without consulting a
983 /// preference and these tests stay about the composed message.
984 fn recipient_id() -> crate::db::UserId {
985 crate::db::UserId::from(uuid::Uuid::nil())
986 }
987
988 fn client_with_capture() -> (EmailClient, Arc<CapturingTransport>) {
989 let transport = Arc::new(CapturingTransport::new());
990 let client = EmailClient::with_transport(transport.clone());
991 (client, transport)
992 }
993
994 // ── Creator activity ──
995
996 #[tokio::test]
997 async fn sale_notification_carries_buyer_item_price() {
998 let (client, captured) = client_with_capture();
999 client
1000 .send_sale_notification(
1001 recipient_id(),
1002 "seller@example.com",
1003 Some("Sasha"),
1004 "buyer42",
1005 "Cool Album",
1006 "$10.00",
1007 Some("https://x/unsub"),
1008 )
1009 .await
1010 .unwrap();
1011 let (to, subject, body, unsub) = captured.last();
1012 assert_eq!(to, "seller@example.com");
1013 assert!(subject.contains("New sale"));
1014 assert!(subject.contains("Cool Album"));
1015 assert!(body.contains("Hi Sasha"));
1016 assert!(body.contains("buyer42"));
1017 assert!(body.contains("Cool Album"));
1018 assert!(body.contains("$10.00"));
1019 assert_eq!(unsub.as_deref(), Some("https://x/unsub"));
1020 }
1021
1022 #[tokio::test]
1023 async fn sale_notification_handles_none_name() {
1024 // greeting(None) → empty; body should still build coherently.
1025 let (client, captured) = client_with_capture();
1026 client
1027 .send_sale_notification(recipient_id(), "s@x", None, "buyer", "Item", "$5", None)
1028 .await
1029 .unwrap();
1030 let (_, _, body, unsub) = captured.last();
1031 assert!(
1032 body.starts_with("Hi,") || body.starts_with("Hi "),
1033 "body: {body}"
1034 );
1035 assert!(unsub.is_none());
1036 }
1037
1038 #[tokio::test]
1039 async fn tip_notification_with_message_includes_quoted_message() {
1040 let (client, captured) = client_with_capture();
1041 client
1042 .send_tip_notification(
1043 recipient_id(),
1044 "c@x",
1045 None,
1046 "Alex",
1047 "$3",
1048 Some("Loved it!"),
1049 None,
1050 )
1051 .await
1052 .unwrap();
1053 let (_, subject, body, _) = captured.last();
1054 assert!(subject.contains("Alex tipped you $3"));
1055 assert!(body.contains("Loved it!"));
1056 assert!(body.contains("$3"));
1057 }
1058
1059 #[tokio::test]
1060 async fn tip_notification_without_message_omits_quote_block() {
1061 // Pins the `match message { Some => ..., None => ... }` arm split,
1062 // without-message branch must NOT include the "with a message:" preamble.
1063 let (client, captured) = client_with_capture();
1064 client
1065 .send_tip_notification(recipient_id(), "c@x", None, "Alex", "$3", None, None)
1066 .await
1067 .unwrap();
1068 let (_, _, body, _) = captured.last();
1069 assert!(
1070 !body.contains("with a message"),
1071 "without-message branch leaked: {body}"
1072 );
1073 assert!(body.contains("$3"));
1074 }
1075
1076 // ── Platform notices: suspension / appeal / termination / shutdown ──
1077
1078 #[tokio::test]
1079 async fn suspension_includes_reason() {
1080 let (client, captured) = client_with_capture();
1081 client
1082 .send_suspension_notification("u@x", Some("Sam"), "Spam reports")
1083 .await
1084 .unwrap();
1085 let (_, subject, body, _) = captured.last();
1086 assert_eq!(subject, "Your account has been suspended");
1087 assert!(body.contains("Hi Sam"));
1088 assert!(body.contains("Reason: Spam reports"));
1089 assert!(body.contains("appeal"));
1090 assert!(body.contains("export your data"));
1091 }
1092
1093 #[tokio::test]
1094 async fn appeal_decision_approved_uses_reinstated_outcome() {
1095 // Pins the `if decision == "approved"` branch.
1096 let (client, captured) = client_with_capture();
1097 client
1098 .send_appeal_decision("u@x", None, "approved", "Reviewed and reversed.")
1099 .await
1100 .unwrap();
1101 let (_, _, body, _) = captured.last();
1102 assert!(
1103 body.contains("Your account has been reinstated"),
1104 "approved branch should say reinstated: {body}"
1105 );
1106 assert!(
1107 !body.contains("Your appeal has been denied"),
1108 "approved branch must NOT also say denied: {body}"
1109 );
1110 assert!(body.contains("Reviewed and reversed."));
1111 }
1112
1113 #[tokio::test]
1114 async fn appeal_decision_denied_uses_denied_outcome() {
1115 let (client, captured) = client_with_capture();
1116 client
1117 .send_appeal_decision("u@x", None, "denied", "Reviewed and upheld.")
1118 .await
1119 .unwrap();
1120 let (_, _, body, _) = captured.last();
1121 assert!(body.contains("Your appeal has been denied"));
1122 assert!(!body.contains("Your account has been reinstated"));
1123 }
1124
1125 #[tokio::test]
1126 async fn appeal_decision_anything_other_than_approved_is_denied() {
1127 // Pins `decision == "approved"` (exact match, case-sensitive).
1128 let (client, captured) = client_with_capture();
1129 client
1130 .send_appeal_decision("u@x", None, "APPROVED", "uppercase")
1131 .await
1132 .unwrap();
1133 let (_, _, body, _) = captured.last();
1134 assert!(
1135 body.contains("Your appeal has been denied"),
1136 "case-sensitive `approved`, uppercase must NOT pass: {body}"
1137 );
1138 }
1139
1140 #[tokio::test]
1141 async fn content_removal_subjects_with_title() {
1142 let (client, captured) = client_with_capture();
1143 client
1144 .send_content_removal("c@x", Some("Dev"), "Beat Pack 1", "Copyright claim")
1145 .await
1146 .unwrap();
1147 let (_, subject, body, _) = captured.last();
1148 assert_eq!(subject, "Content removed: Beat Pack 1");
1149 assert!(body.contains("Hi Dev"));
1150 assert!(body.contains("Beat Pack 1"));
1151 assert!(body.contains("Reason: Copyright claim"));
1152 assert!(body.contains("appeal"));
1153 }
1154
1155 #[tokio::test]
1156 async fn content_restored_subjects_with_title() {
1157 let (client, captured) = client_with_capture();
1158 client
1159 .send_content_restored("c@x", None, "Beat Pack 1")
1160 .await
1161 .unwrap();
1162 let (_, subject, body, _) = captured.last();
1163 assert_eq!(subject, "Content restored: Beat Pack 1");
1164 assert!(body.contains("Beat Pack 1"));
1165 assert!(body.contains("restored"));
1166 }
1167
1168 #[tokio::test]
1169 async fn account_termination_has_30_day_window_message() {
1170 let (client, captured) = client_with_capture();
1171 client
1172 .send_account_termination("u@x", Some("Pat"))
1173 .await
1174 .unwrap();
1175 let (_, subject, body, _) = captured.last();
1176 assert!(subject.contains("terminated"));
1177 assert!(body.contains("Hi Pat"));
1178 assert!(body.contains("30 days"));
1179 assert!(body.contains("export your data"));
1180 }
1181
1182 #[tokio::test]
1183 async fn shutdown_notice_includes_date() {
1184 let (client, captured) = client_with_capture();
1185 client
1186 .send_shutdown_notice("u@x", None, "2027-06-15")
1187 .await
1188 .unwrap();
1189 let (_, subject, body, _) = captured.last();
1190 assert!(subject.contains("shutting down"));
1191 assert!(body.contains("2027-06-15"));
1192 assert!(body.contains("90 days"));
1193 assert!(body.contains("no lock-in"));
1194 }
1195
1196 #[tokio::test]
1197 async fn creator_departure_mentions_creator_and_90_days() {
1198 let (client, captured) = client_with_capture();
1199 client
1200 .send_creator_departure_notification("buyer@x", None, "Alex")
1201 .await
1202 .unwrap();
1203 let (_, subject, body, _) = captured.last();
1204 assert!(subject.contains("Alex"));
1205 assert!(subject.contains("leaving"));
1206 assert!(body.contains("Alex"));
1207 assert!(body.contains("90 days"));
1208 assert!(body.contains("library"));
1209 }
1210
1211 // ── Issue tracking ──
1212
1213 #[tokio::test]
1214 async fn new_issue_notification_includes_repo_path_and_url() {
1215 let (client, captured) = client_with_capture();
1216 client
1217 .send_new_issue_notification(
1218 recipient_id(),
1219 "owner@x",
1220 Some("Jordan"),
1221 "alex",
1222 "audio-tools",
1223 42,
1224 "Crash on startup",
1225 "bob",
1226 "https://makenot.work/p/alex/audio-tools/issues/42",
1227 Some("https://unsub"),
1228 Some("reply@x"),
1229 Some("<msgid@x>"),
1230 )
1231 .await
1232 .unwrap();
1233 let (_, subject, body, unsub) = captured.last();
1234 assert_eq!(subject, "New issue on alex/audio-tools: Crash on startup");
1235 assert!(body.contains("Hi Jordan"));
1236 assert!(body.contains("bob opened issue #42"));
1237 assert!(body.contains("alex/audio-tools"));
1238 assert!(body.contains("Crash on startup"));
1239 assert!(body.contains("https://makenot.work/p/alex/audio-tools/issues/42"));
1240 assert_eq!(unsub.as_deref(), Some("https://unsub"));
1241 }
1242
1243 #[tokio::test]
1244 async fn issue_comment_subject_uses_re_prefix() {
1245 // Pins the "Re: " prefix that threads the email reply.
1246 let (client, captured) = client_with_capture();
1247 client
1248 .send_issue_comment_notification(
1249 recipient_id(),
1250 "owner@x",
1251 None,
1252 "alex",
1253 "audio-tools",
1254 42,
1255 "Crash on startup",
1256 "carol",
1257 "Looked into this, see PR #5",
1258 "https://makenot.work/p/alex/audio-tools/issues/42",
1259 None,
1260 None,
1261 None,
1262 None,
1263 )
1264 .await
1265 .unwrap();
1266 let (_, subject, body, _) = captured.last();
1267 assert!(
1268 subject.starts_with("Re: "),
1269 "comment must be Re:-prefixed: {subject}"
1270 );
1271 assert!(body.contains("carol commented on issue #42"));
1272 assert!(body.contains("Looked into this"));
1273 }
1274
1275 // ── Status notifications: per-status subject mapping ──
1276
1277 #[tokio::test]
1278 async fn status_notification_operational_subject() {
1279 let (client, captured) = client_with_capture();
1280 client
1281 .send_status_notification(
1282 recipient_id(),
1283 "u@x",
1284 None,
1285 "operational",
1286 "degraded",
1287 "https://unsub",
1288 )
1289 .await
1290 .unwrap();
1291 let (_, subject, _, _) = captured.last();
1292 assert!(subject.contains("recovered"));
1293 assert!(subject.contains("all services operational"));
1294 }
1295
1296 #[tokio::test]
1297 async fn status_notification_degraded_subject() {
1298 let (client, captured) = client_with_capture();
1299 client
1300 .send_status_notification(
1301 recipient_id(),
1302 "u@x",
1303 None,
1304 "degraded",
1305 "operational",
1306 "https://unsub",
1307 )
1308 .await
1309 .unwrap();
1310 let (_, subject, _, _) = captured.last();
1311 assert!(subject.contains("partial service degradation"));
1312 }
1313
1314 #[tokio::test]
1315 async fn status_notification_unknown_falls_back_to_disruption() {
1316 // Pins the `_ => "...service disruption"` arm.
1317 let (client, captured) = client_with_capture();
1318 client
1319 .send_status_notification(
1320 recipient_id(),
1321 "u@x",
1322 None,
1323 "outage",
1324 "operational",
1325 "https://unsub",
1326 )
1327 .await
1328 .unwrap();
1329 let (_, subject, body, _) = captured.last();
1330 assert!(subject.contains("service disruption"));
1331 // Body interpolates the actual status string regardless.
1332 assert!(body.contains("outage"));
1333 }
1334 }
1335