Skip to main content

max / makenotwork

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