Skip to main content

max / makenotwork

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