Skip to main content

max / makenotwork

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