Skip to main content

max / makenotwork

40.9 KB · 1382 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 /// Tell a creator that a send was refused because it would cross their
907 /// monthly mail allowance.
908 ///
909 /// The refusal happens inside a fire-and-forget fan-out, where there is no
910 /// request left to answer, so this mail is the whole of the creator's
911 /// notice. Without it the announcement would look sent and would not be,
912 /// which is the silent-loss failure the cap is designed not to be. Same
913 /// `UsageLimit` class as the SyncKit cap warning: a limit the recipient
914 /// cannot opt out of hearing about, because past it their requests are
915 /// refused.
916 pub async fn send_mail_cap_refusal(
917 &self,
918 to_email: &str,
919 to_name: Option<&str>,
920 what: &str,
921 explanation: &str,
922 dashboard_url: &str,
923 ) -> Result<()> {
924 let subject = "Your announcement was not sent: monthly email allowance".to_string();
925 let body = format!(
926 r"Hi{name},
927
928 {what} was not emailed to your subscribers.
929
930 {explanation}
931
932 Nothing else about the post changed: it is published and it is on your page.
933 Only the email went unsent, and you can send it once the allowance resets.
934
935 Your allowance and where it resets:
936 {url}
937
938 - Makenotwork",
939 name = crate::email::greeting(to_name),
940 url = dashboard_url,
941 );
942
943 self.dispatch(
944 EmailClass::Operational(OperationalKind::UsageLimit),
945 Audience::Address(to_email),
946 &subject,
947 &body,
948 Delivery::default(),
949 )
950 .await
951 }
952 }
953
954 #[cfg(test)]
955 mod tests {
956 use super::*;
957 use crate::email::EmailTransport;
958 use std::sync::{Arc, Mutex};
959
960 /// One captured email: (to, subject, html_body, text_body).
961 type SentEmail = (String, String, String, Option<String>);
962
963 /// In-memory transport that captures sent emails for assertion in tests.
964 struct CapturingTransport {
965 sent: Mutex<Vec<SentEmail>>,
966 }
967
968 impl CapturingTransport {
969 fn new() -> Self {
970 Self {
971 sent: Mutex::new(Vec::new()),
972 }
973 }
974 fn last(&self) -> (String, String, String, Option<String>) {
975 self.sent
976 .lock()
977 .unwrap()
978 .last()
979 .cloned()
980 .expect("no email captured")
981 }
982 }
983
984 #[async_trait::async_trait]
985 impl EmailTransport for CapturingTransport {
986 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
987 self.sent.lock().unwrap().push((
988 to.to_string(),
989 subject.to_string(),
990 body.to_string(),
991 None,
992 ));
993 Ok(())
994 }
995 async fn send_email_with_headers_and_unsub(
996 &self,
997 to: &str,
998 subject: &str,
999 body: &str,
1000 _extra_headers: &[(&str, String)],
1001 unsub_url: Option<&str>,
1002 ) -> Result<()> {
1003 self.sent.lock().unwrap().push((
1004 to.to_string(),
1005 subject.to_string(),
1006 body.to_string(),
1007 unsub_url.map(String::from),
1008 ));
1009 Ok(())
1010 }
1011 async fn send_email_broadcast_with_unsub(
1012 &self,
1013 to: &str,
1014 subject: &str,
1015 body: &str,
1016 unsub_url: Option<&str>,
1017 ) -> Result<()> {
1018 self.sent.lock().unwrap().push((
1019 to.to_string(),
1020 subject.to_string(),
1021 body.to_string(),
1022 unsub_url.map(String::from),
1023 ));
1024 Ok(())
1025 }
1026 }
1027
1028 /// A stand-in recipient id for the Optional senders. `client_with_capture`
1029 /// builds a pool-less client, so `dispatch` sends without consulting a
1030 /// preference and these tests stay about the composed message.
1031 fn recipient_id() -> crate::db::UserId {
1032 crate::db::UserId::from(uuid::Uuid::nil())
1033 }
1034
1035 fn client_with_capture() -> (EmailClient, Arc<CapturingTransport>) {
1036 let transport = Arc::new(CapturingTransport::new());
1037 let client = EmailClient::with_transport(transport.clone());
1038 (client, transport)
1039 }
1040
1041 // ── Creator activity ──
1042
1043 #[tokio::test]
1044 async fn sale_notification_carries_buyer_item_price() {
1045 let (client, captured) = client_with_capture();
1046 client
1047 .send_sale_notification(
1048 recipient_id(),
1049 "seller@example.com",
1050 Some("Sasha"),
1051 "buyer42",
1052 "Cool Album",
1053 "$10.00",
1054 Some("https://x/unsub"),
1055 )
1056 .await
1057 .unwrap();
1058 let (to, subject, body, unsub) = captured.last();
1059 assert_eq!(to, "seller@example.com");
1060 assert!(subject.contains("New sale"));
1061 assert!(subject.contains("Cool Album"));
1062 assert!(body.contains("Hi Sasha"));
1063 assert!(body.contains("buyer42"));
1064 assert!(body.contains("Cool Album"));
1065 assert!(body.contains("$10.00"));
1066 assert_eq!(unsub.as_deref(), Some("https://x/unsub"));
1067 }
1068
1069 #[tokio::test]
1070 async fn sale_notification_handles_none_name() {
1071 // greeting(None) → empty; body should still build coherently.
1072 let (client, captured) = client_with_capture();
1073 client
1074 .send_sale_notification(recipient_id(), "s@x", None, "buyer", "Item", "$5", None)
1075 .await
1076 .unwrap();
1077 let (_, _, body, unsub) = captured.last();
1078 assert!(
1079 body.starts_with("Hi,") || body.starts_with("Hi "),
1080 "body: {body}"
1081 );
1082 assert!(unsub.is_none());
1083 }
1084
1085 #[tokio::test]
1086 async fn tip_notification_with_message_includes_quoted_message() {
1087 let (client, captured) = client_with_capture();
1088 client
1089 .send_tip_notification(
1090 recipient_id(),
1091 "c@x",
1092 None,
1093 "Alex",
1094 "$3",
1095 Some("Loved it!"),
1096 None,
1097 )
1098 .await
1099 .unwrap();
1100 let (_, subject, body, _) = captured.last();
1101 assert!(subject.contains("Alex tipped you $3"));
1102 assert!(body.contains("Loved it!"));
1103 assert!(body.contains("$3"));
1104 }
1105
1106 #[tokio::test]
1107 async fn tip_notification_without_message_omits_quote_block() {
1108 // Pins the `match message { Some => ..., None => ... }` arm split,
1109 // without-message branch must NOT include the "with a message:" preamble.
1110 let (client, captured) = client_with_capture();
1111 client
1112 .send_tip_notification(recipient_id(), "c@x", None, "Alex", "$3", None, None)
1113 .await
1114 .unwrap();
1115 let (_, _, body, _) = captured.last();
1116 assert!(
1117 !body.contains("with a message"),
1118 "without-message branch leaked: {body}"
1119 );
1120 assert!(body.contains("$3"));
1121 }
1122
1123 // ── Platform notices: suspension / appeal / termination / shutdown ──
1124
1125 #[tokio::test]
1126 async fn suspension_includes_reason() {
1127 let (client, captured) = client_with_capture();
1128 client
1129 .send_suspension_notification("u@x", Some("Sam"), "Spam reports")
1130 .await
1131 .unwrap();
1132 let (_, subject, body, _) = captured.last();
1133 assert_eq!(subject, "Your account has been suspended");
1134 assert!(body.contains("Hi Sam"));
1135 assert!(body.contains("Reason: Spam reports"));
1136 assert!(body.contains("appeal"));
1137 assert!(body.contains("export your data"));
1138 }
1139
1140 #[tokio::test]
1141 async fn appeal_decision_approved_uses_reinstated_outcome() {
1142 // Pins the `if decision == "approved"` branch.
1143 let (client, captured) = client_with_capture();
1144 client
1145 .send_appeal_decision("u@x", None, "approved", "Reviewed and reversed.")
1146 .await
1147 .unwrap();
1148 let (_, _, body, _) = captured.last();
1149 assert!(
1150 body.contains("Your account has been reinstated"),
1151 "approved branch should say reinstated: {body}"
1152 );
1153 assert!(
1154 !body.contains("Your appeal has been denied"),
1155 "approved branch must NOT also say denied: {body}"
1156 );
1157 assert!(body.contains("Reviewed and reversed."));
1158 }
1159
1160 #[tokio::test]
1161 async fn appeal_decision_denied_uses_denied_outcome() {
1162 let (client, captured) = client_with_capture();
1163 client
1164 .send_appeal_decision("u@x", None, "denied", "Reviewed and upheld.")
1165 .await
1166 .unwrap();
1167 let (_, _, body, _) = captured.last();
1168 assert!(body.contains("Your appeal has been denied"));
1169 assert!(!body.contains("Your account has been reinstated"));
1170 }
1171
1172 #[tokio::test]
1173 async fn appeal_decision_anything_other_than_approved_is_denied() {
1174 // Pins `decision == "approved"` (exact match, case-sensitive).
1175 let (client, captured) = client_with_capture();
1176 client
1177 .send_appeal_decision("u@x", None, "APPROVED", "uppercase")
1178 .await
1179 .unwrap();
1180 let (_, _, body, _) = captured.last();
1181 assert!(
1182 body.contains("Your appeal has been denied"),
1183 "case-sensitive `approved`, uppercase must NOT pass: {body}"
1184 );
1185 }
1186
1187 #[tokio::test]
1188 async fn content_removal_subjects_with_title() {
1189 let (client, captured) = client_with_capture();
1190 client
1191 .send_content_removal("c@x", Some("Dev"), "Beat Pack 1", "Copyright claim")
1192 .await
1193 .unwrap();
1194 let (_, subject, body, _) = captured.last();
1195 assert_eq!(subject, "Content removed: Beat Pack 1");
1196 assert!(body.contains("Hi Dev"));
1197 assert!(body.contains("Beat Pack 1"));
1198 assert!(body.contains("Reason: Copyright claim"));
1199 assert!(body.contains("appeal"));
1200 }
1201
1202 #[tokio::test]
1203 async fn content_restored_subjects_with_title() {
1204 let (client, captured) = client_with_capture();
1205 client
1206 .send_content_restored("c@x", None, "Beat Pack 1")
1207 .await
1208 .unwrap();
1209 let (_, subject, body, _) = captured.last();
1210 assert_eq!(subject, "Content restored: Beat Pack 1");
1211 assert!(body.contains("Beat Pack 1"));
1212 assert!(body.contains("restored"));
1213 }
1214
1215 #[tokio::test]
1216 async fn account_termination_has_30_day_window_message() {
1217 let (client, captured) = client_with_capture();
1218 client
1219 .send_account_termination("u@x", Some("Pat"))
1220 .await
1221 .unwrap();
1222 let (_, subject, body, _) = captured.last();
1223 assert!(subject.contains("terminated"));
1224 assert!(body.contains("Hi Pat"));
1225 assert!(body.contains("30 days"));
1226 assert!(body.contains("export your data"));
1227 }
1228
1229 #[tokio::test]
1230 async fn shutdown_notice_includes_date() {
1231 let (client, captured) = client_with_capture();
1232 client
1233 .send_shutdown_notice("u@x", None, "2027-06-15")
1234 .await
1235 .unwrap();
1236 let (_, subject, body, _) = captured.last();
1237 assert!(subject.contains("shutting down"));
1238 assert!(body.contains("2027-06-15"));
1239 assert!(body.contains("90 days"));
1240 assert!(body.contains("no lock-in"));
1241 }
1242
1243 #[tokio::test]
1244 async fn creator_departure_mentions_creator_and_90_days() {
1245 let (client, captured) = client_with_capture();
1246 client
1247 .send_creator_departure_notification("buyer@x", None, "Alex")
1248 .await
1249 .unwrap();
1250 let (_, subject, body, _) = captured.last();
1251 assert!(subject.contains("Alex"));
1252 assert!(subject.contains("leaving"));
1253 assert!(body.contains("Alex"));
1254 assert!(body.contains("90 days"));
1255 assert!(body.contains("library"));
1256 }
1257
1258 // ── Issue tracking ──
1259
1260 #[tokio::test]
1261 async fn new_issue_notification_includes_repo_path_and_url() {
1262 let (client, captured) = client_with_capture();
1263 client
1264 .send_new_issue_notification(
1265 recipient_id(),
1266 "owner@x",
1267 Some("Jordan"),
1268 "alex",
1269 "audio-tools",
1270 42,
1271 "Crash on startup",
1272 "bob",
1273 "https://makenot.work/p/alex/audio-tools/issues/42",
1274 Some("https://unsub"),
1275 Some("reply@x"),
1276 Some("<msgid@x>"),
1277 )
1278 .await
1279 .unwrap();
1280 let (_, subject, body, unsub) = captured.last();
1281 assert_eq!(subject, "New issue on alex/audio-tools: Crash on startup");
1282 assert!(body.contains("Hi Jordan"));
1283 assert!(body.contains("bob opened issue #42"));
1284 assert!(body.contains("alex/audio-tools"));
1285 assert!(body.contains("Crash on startup"));
1286 assert!(body.contains("https://makenot.work/p/alex/audio-tools/issues/42"));
1287 assert_eq!(unsub.as_deref(), Some("https://unsub"));
1288 }
1289
1290 #[tokio::test]
1291 async fn issue_comment_subject_uses_re_prefix() {
1292 // Pins the "Re: " prefix that threads the email reply.
1293 let (client, captured) = client_with_capture();
1294 client
1295 .send_issue_comment_notification(
1296 recipient_id(),
1297 "owner@x",
1298 None,
1299 "alex",
1300 "audio-tools",
1301 42,
1302 "Crash on startup",
1303 "carol",
1304 "Looked into this, see PR #5",
1305 "https://makenot.work/p/alex/audio-tools/issues/42",
1306 None,
1307 None,
1308 None,
1309 None,
1310 )
1311 .await
1312 .unwrap();
1313 let (_, subject, body, _) = captured.last();
1314 assert!(
1315 subject.starts_with("Re: "),
1316 "comment must be Re:-prefixed: {subject}"
1317 );
1318 assert!(body.contains("carol commented on issue #42"));
1319 assert!(body.contains("Looked into this"));
1320 }
1321
1322 // ── Status notifications: per-status subject mapping ──
1323
1324 #[tokio::test]
1325 async fn status_notification_operational_subject() {
1326 let (client, captured) = client_with_capture();
1327 client
1328 .send_status_notification(
1329 recipient_id(),
1330 "u@x",
1331 None,
1332 "operational",
1333 "degraded",
1334 "https://unsub",
1335 )
1336 .await
1337 .unwrap();
1338 let (_, subject, _, _) = captured.last();
1339 assert!(subject.contains("recovered"));
1340 assert!(subject.contains("all services operational"));
1341 }
1342
1343 #[tokio::test]
1344 async fn status_notification_degraded_subject() {
1345 let (client, captured) = client_with_capture();
1346 client
1347 .send_status_notification(
1348 recipient_id(),
1349 "u@x",
1350 None,
1351 "degraded",
1352 "operational",
1353 "https://unsub",
1354 )
1355 .await
1356 .unwrap();
1357 let (_, subject, _, _) = captured.last();
1358 assert!(subject.contains("partial service degradation"));
1359 }
1360
1361 #[tokio::test]
1362 async fn status_notification_unknown_falls_back_to_disruption() {
1363 // Pins the `_ => "...service disruption"` arm.
1364 let (client, captured) = client_with_capture();
1365 client
1366 .send_status_notification(
1367 recipient_id(),
1368 "u@x",
1369 None,
1370 "outage",
1371 "operational",
1372 "https://unsub",
1373 )
1374 .await
1375 .unwrap();
1376 let (_, subject, body, _) = captured.last();
1377 assert!(subject.contains("service disruption"));
1378 // Body interpolates the actual status string regardless.
1379 assert!(body.contains("outage"));
1380 }
1381 }
1382