Skip to main content

max / makenotwork

31.1 KB · 940 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 pub async fn send_synckit_usage_warning(
592 &self,
593 to_email: &str,
594 app_name: &str,
595 dimension: &str,
596 key: Option<&str>,
597 pct: i16,
598 used_bytes: i64,
599 limit_bytes: i64,
600 billing_url: &str,
601 ) -> Result<()> {
602 fn fmt_gb(bytes: i64) -> String {
603 let gb = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
604 if gb >= 10.0 { format!("{gb:.0} GB") } else { format!("{gb:.2} GB") }
605 }
606
607 let dim_human = match dimension {
608 "storage" => "storage".to_string(),
609 "storage_per_key" => match key {
610 Some(k) => format!("storage for key \"{k}\""),
611 None => "per-key storage".to_string(),
612 },
613 "egress" => "monthly egress".to_string(),
614 other => other.to_string(),
615 };
616 let subject = if pct >= 100 {
617 format!("{app_name}: {dim_human} cap reached")
618 } else {
619 format!("{app_name}: {pct}% of {dim_human} cap used")
620 };
621
622 let pct_msg = if pct >= 100 {
623 format!(
624 "Your SyncKit app \"{app_name}\" has reached its {dim_human} cap.\n\
625 Further {dim_human} requests will be rejected (HTTP 402) until the\n\
626 cap is raised or the period rolls over."
627 )
628 } else {
629 format!(
630 "Your SyncKit app \"{app_name}\" has used {pct}% of its {dim_human}\n\
631 cap. At 100% further requests are hard-blocked (HTTP 402).",
632 )
633 };
634
635 let body = format!(
636 r#"{pct_msg}
637
638 Used: {used}
639 Limit: {limit}
640
641 Adjust caps or review usage:
642 {url}
643
644 - Makenotwork"#,
645 used = fmt_gb(used_bytes),
646 limit = fmt_gb(limit_bytes),
647 url = billing_url,
648 );
649
650 self.transport.send_email(to_email, &subject, &body).await
651 }
652 }
653
654 #[cfg(test)]
655 mod tests {
656 use super::*;
657 use crate::email::EmailTransport;
658 use std::sync::{Arc, Mutex};
659
660 /// In-memory transport that captures sent emails for assertion in tests.
661 struct CapturingTransport {
662 sent: Mutex<Vec<(String, String, String, Option<String>)>>,
663 }
664
665 impl CapturingTransport {
666 fn new() -> Self {
667 Self { sent: Mutex::new(Vec::new()) }
668 }
669 fn last(&self) -> (String, String, String, Option<String>) {
670 self.sent.lock().unwrap().last().cloned().expect("no email captured")
671 }
672 }
673
674 #[async_trait::async_trait]
675 impl EmailTransport for CapturingTransport {
676 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
677 self.sent.lock().unwrap().push((to.to_string(), subject.to_string(), body.to_string(), None));
678 Ok(())
679 }
680 async fn send_email_with_unsub(
681 &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>,
682 ) -> Result<()> {
683 self.sent.lock().unwrap().push((
684 to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from),
685 ));
686 Ok(())
687 }
688 async fn send_email_with_headers_and_unsub(
689 &self, to: &str, subject: &str, body: &str,
690 _extra_headers: &[(&str, String)], unsub_url: Option<&str>,
691 ) -> Result<()> {
692 self.sent.lock().unwrap().push((
693 to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from),
694 ));
695 Ok(())
696 }
697 async fn send_email_broadcast_with_unsub(
698 &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>,
699 ) -> Result<()> {
700 self.sent.lock().unwrap().push((
701 to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from),
702 ));
703 Ok(())
704 }
705 }
706
707 fn client_with_capture() -> (EmailClient, Arc<CapturingTransport>) {
708 let transport = Arc::new(CapturingTransport::new());
709 let client = EmailClient::with_transport(transport.clone());
710 (client, transport)
711 }
712
713 // ── Creator activity ──
714
715 #[tokio::test]
716 async fn sale_notification_carries_buyer_item_price() {
717 let (client, captured) = client_with_capture();
718 client
719 .send_sale_notification("seller@example.com", Some("Sasha"), "buyer42", "Cool Album", "$10.00", Some("https://x/unsub"))
720 .await
721 .unwrap();
722 let (to, subject, body, unsub) = captured.last();
723 assert_eq!(to, "seller@example.com");
724 assert!(subject.contains("New sale"));
725 assert!(subject.contains("Cool Album"));
726 assert!(body.contains("Hi Sasha"));
727 assert!(body.contains("buyer42"));
728 assert!(body.contains("Cool Album"));
729 assert!(body.contains("$10.00"));
730 assert_eq!(unsub.as_deref(), Some("https://x/unsub"));
731 }
732
733 #[tokio::test]
734 async fn sale_notification_handles_none_name() {
735 // greeting(None) → empty; body should still build coherently.
736 let (client, captured) = client_with_capture();
737 client.send_sale_notification("s@x", None, "buyer", "Item", "$5", None).await.unwrap();
738 let (_, _, body, unsub) = captured.last();
739 assert!(body.starts_with("Hi,") || body.starts_with("Hi "), "body: {body}");
740 assert!(unsub.is_none());
741 }
742
743 #[tokio::test]
744 async fn tip_notification_with_message_includes_quoted_message() {
745 let (client, captured) = client_with_capture();
746 client.send_tip_notification("c@x", None, "Alex", "$3", Some("Loved it!"), None).await.unwrap();
747 let (_, subject, body, _) = captured.last();
748 assert!(subject.contains("Alex tipped you $3"));
749 assert!(body.contains("Loved it!"));
750 assert!(body.contains("$3"));
751 }
752
753 #[tokio::test]
754 async fn tip_notification_without_message_omits_quote_block() {
755 // Pins the `match message { Some => ..., None => ... }` arm split —
756 // without-message branch must NOT include the "with a message:" preamble.
757 let (client, captured) = client_with_capture();
758 client.send_tip_notification("c@x", None, "Alex", "$3", None, None).await.unwrap();
759 let (_, _, body, _) = captured.last();
760 assert!(!body.contains("with a message"), "without-message branch leaked: {body}");
761 assert!(body.contains("$3"));
762 }
763
764 // ── Platform notices: suspension / appeal / termination / shutdown ──
765
766 #[tokio::test]
767 async fn suspension_includes_reason() {
768 let (client, captured) = client_with_capture();
769 client.send_suspension_notification("u@x", Some("Sam"), "Spam reports").await.unwrap();
770 let (_, subject, body, _) = captured.last();
771 assert_eq!(subject, "Your account has been suspended");
772 assert!(body.contains("Hi Sam"));
773 assert!(body.contains("Reason: Spam reports"));
774 assert!(body.contains("appeal"));
775 assert!(body.contains("export your data"));
776 }
777
778 #[tokio::test]
779 async fn appeal_decision_approved_uses_reinstated_outcome() {
780 // Pins the `if decision == "approved"` branch.
781 let (client, captured) = client_with_capture();
782 client.send_appeal_decision("u@x", None, "approved", "Reviewed and reversed.").await.unwrap();
783 let (_, _, body, _) = captured.last();
784 assert!(body.contains("Your account has been reinstated"),
785 "approved branch should say reinstated: {body}");
786 assert!(!body.contains("Your appeal has been denied"),
787 "approved branch must NOT also say denied: {body}");
788 assert!(body.contains("Reviewed and reversed."));
789 }
790
791 #[tokio::test]
792 async fn appeal_decision_denied_uses_denied_outcome() {
793 let (client, captured) = client_with_capture();
794 client.send_appeal_decision("u@x", None, "denied", "Reviewed and upheld.").await.unwrap();
795 let (_, _, body, _) = captured.last();
796 assert!(body.contains("Your appeal has been denied"));
797 assert!(!body.contains("Your account has been reinstated"));
798 }
799
800 #[tokio::test]
801 async fn appeal_decision_anything_other_than_approved_is_denied() {
802 // Pins `decision == "approved"` (exact match, case-sensitive).
803 let (client, captured) = client_with_capture();
804 client.send_appeal_decision("u@x", None, "APPROVED", "uppercase").await.unwrap();
805 let (_, _, body, _) = captured.last();
806 assert!(body.contains("Your appeal has been denied"),
807 "case-sensitive `approved` — uppercase must NOT pass: {body}");
808 }
809
810 #[tokio::test]
811 async fn content_removal_subjects_with_title() {
812 let (client, captured) = client_with_capture();
813 client.send_content_removal("c@x", Some("Dev"), "Beat Pack 1", "Copyright claim").await.unwrap();
814 let (_, subject, body, _) = captured.last();
815 assert_eq!(subject, "Content removed: Beat Pack 1");
816 assert!(body.contains("Hi Dev"));
817 assert!(body.contains("Beat Pack 1"));
818 assert!(body.contains("Reason: Copyright claim"));
819 assert!(body.contains("appeal"));
820 }
821
822 #[tokio::test]
823 async fn content_restored_subjects_with_title() {
824 let (client, captured) = client_with_capture();
825 client.send_content_restored("c@x", None, "Beat Pack 1").await.unwrap();
826 let (_, subject, body, _) = captured.last();
827 assert_eq!(subject, "Content restored: Beat Pack 1");
828 assert!(body.contains("Beat Pack 1"));
829 assert!(body.contains("restored"));
830 }
831
832 #[tokio::test]
833 async fn account_termination_has_30_day_window_message() {
834 let (client, captured) = client_with_capture();
835 client.send_account_termination("u@x", Some("Pat")).await.unwrap();
836 let (_, subject, body, _) = captured.last();
837 assert!(subject.contains("terminated"));
838 assert!(body.contains("Hi Pat"));
839 assert!(body.contains("30 days"));
840 assert!(body.contains("export your data"));
841 }
842
843 #[tokio::test]
844 async fn shutdown_notice_includes_date() {
845 let (client, captured) = client_with_capture();
846 client.send_shutdown_notice("u@x", None, "2027-06-15").await.unwrap();
847 let (_, subject, body, _) = captured.last();
848 assert!(subject.contains("shutting down"));
849 assert!(body.contains("2027-06-15"));
850 assert!(body.contains("90 days"));
851 assert!(body.contains("no lock-in"));
852 }
853
854 #[tokio::test]
855 async fn creator_departure_mentions_creator_and_90_days() {
856 let (client, captured) = client_with_capture();
857 client.send_creator_departure_notification("buyer@x", None, "Alex").await.unwrap();
858 let (_, subject, body, _) = captured.last();
859 assert!(subject.contains("Alex"));
860 assert!(subject.contains("leaving"));
861 assert!(body.contains("Alex"));
862 assert!(body.contains("90 days"));
863 assert!(body.contains("library"));
864 }
865
866 // ── Issue tracking ──
867
868 #[tokio::test]
869 async fn new_issue_notification_includes_repo_path_and_url() {
870 let (client, captured) = client_with_capture();
871 client
872 .send_new_issue_notification(
873 "owner@x", Some("Jordan"),
874 "alex", "audio-tools", 42, "Crash on startup",
875 "bob", "https://makenot.work/p/alex/audio-tools/issues/42",
876 Some("https://unsub"), Some("reply@x"), Some("<msgid@x>"),
877 )
878 .await
879 .unwrap();
880 let (_, subject, body, unsub) = captured.last();
881 assert_eq!(subject, "New issue on alex/audio-tools: Crash on startup");
882 assert!(body.contains("Hi Jordan"));
883 assert!(body.contains("bob opened issue #42"));
884 assert!(body.contains("alex/audio-tools"));
885 assert!(body.contains("Crash on startup"));
886 assert!(body.contains("https://makenot.work/p/alex/audio-tools/issues/42"));
887 assert_eq!(unsub.as_deref(), Some("https://unsub"));
888 }
889
890 #[tokio::test]
891 async fn issue_comment_subject_uses_re_prefix() {
892 // Pins the "Re: " prefix that threads the email reply.
893 let (client, captured) = client_with_capture();
894 client
895 .send_issue_comment_notification(
896 "owner@x", None,
897 "alex", "audio-tools", 42, "Crash on startup",
898 "carol", "Looked into this, see PR #5",
899 "https://makenot.work/p/alex/audio-tools/issues/42",
900 None, None, None, None,
901 )
902 .await
903 .unwrap();
904 let (_, subject, body, _) = captured.last();
905 assert!(subject.starts_with("Re: "), "comment must be Re:-prefixed: {subject}");
906 assert!(body.contains("carol commented on issue #42"));
907 assert!(body.contains("Looked into this"));
908 }
909
910 // ── Status notifications: per-status subject mapping ──
911
912 #[tokio::test]
913 async fn status_notification_operational_subject() {
914 let (client, captured) = client_with_capture();
915 client.send_status_notification("u@x", None, "operational", "degraded", "https://unsub").await.unwrap();
916 let (_, subject, _, _) = captured.last();
917 assert!(subject.contains("recovered"));
918 assert!(subject.contains("all services operational"));
919 }
920
921 #[tokio::test]
922 async fn status_notification_degraded_subject() {
923 let (client, captured) = client_with_capture();
924 client.send_status_notification("u@x", None, "degraded", "operational", "https://unsub").await.unwrap();
925 let (_, subject, _, _) = captured.last();
926 assert!(subject.contains("partial service degradation"));
927 }
928
929 #[tokio::test]
930 async fn status_notification_unknown_falls_back_to_disruption() {
931 // Pins the `_ => "...service disruption"` arm.
932 let (client, captured) = client_with_capture();
933 client.send_status_notification("u@x", None, "outage", "operational", "https://unsub").await.unwrap();
934 let (_, subject, body, _) = captured.last();
935 assert!(subject.contains("service disruption"));
936 // Body interpolates the actual status string regardless.
937 assert!(body.contains("outage"));
938 }
939 }
940