//! Creator activity, platform notices, and issue tracking email templates. use crate::email::EmailClient; use crate::error::Result; // ── Creator activity ── impl EmailClient { /// Notify a creator that someone bought their content. pub async fn send_sale_notification( &self, to_email: &str, to_name: Option<&str>, buyer_username: &str, item_title: &str, price: &str, unsub_url: Option<&str>, ) -> Result<()> { let subject = format!("New sale: {}", item_title); let body = format!( r#"Hi{name}, {buyer} just purchased {item} for {price}. View your sales from your dashboard. - Makenotwork"#, name = crate::email::greeting(to_name), buyer = buyer_username, item = item_title, price = price, ); self.transport.send_email_with_unsub(to_email, &subject, &body, unsub_url).await } /// Notify a creator that someone followed them or their project. pub async fn send_follower_notification( &self, to_email: &str, to_name: Option<&str>, follower_username: &str, context: &str, unsub_url: Option<&str>, ) -> Result<()> { let subject = "New follower"; let body = format!( r#"Hi{name}, {follower} is now following {context}. - Makenotwork"#, name = crate::email::greeting(to_name), follower = follower_username, context = context, ); self.transport.send_email_with_unsub(to_email, subject, &body, unsub_url).await } /// Send a creator's broadcast message to a follower. pub async fn send_broadcast( &self, to_email: &str, to_name: Option<&str>, creator_name: &str, subject: &str, body_text: &str, unsub_url: Option<&str>, ) -> Result<()> { let subject = format!("{} -- from {}", subject, creator_name); let body = format!( r#"Hi{name}, {body} -- You received this because you follow {creator} on Makenotwork. - Makenotwork"#, name = crate::email::greeting(to_name), body = body_text, creator = creator_name, ); self.transport.send_email_broadcast_with_unsub(to_email, &subject, &body, unsub_url).await } /// Notify followers about a new release. pub async fn send_release_announcement( &self, to_email: &str, to_name: Option<&str>, creator_name: &str, item_title: &str, item_url: &str, unsub_url: Option<&str>, ) -> Result<()> { let subject = format!("New release: {} by {}", item_title, creator_name); let body = format!( r#"Hi{name}, {creator} just published something new: {item} Check it out: {url} - Makenotwork"#, name = crate::email::greeting(to_name), creator = creator_name, item = item_title, url = item_url, ); self.transport.send_email_broadcast_with_unsub(to_email, &subject, &body, unsub_url).await } /// Notify subscribers about a new blog post. pub async fn send_blog_post_announcement( &self, to_email: &str, to_name: Option<&str>, creator_name: &str, post_title: &str, post_url: &str, unsub_url: Option<&str>, ) -> Result<()> { let subject = format!("New post: {} by {}", post_title, creator_name); let body = format!( r#"Hi{name}, {creator} just published a new post: {title} Read it here: {url} - Makenotwork"#, name = crate::email::greeting(to_name), creator = creator_name, title = post_title, url = post_url, ); self.transport.send_email_broadcast_with_unsub(to_email, &subject, &body, unsub_url).await } /// Notify a creator that their invite code was redeemed by a new user. pub async fn send_invite_redeemed( &self, to_email: &str, to_name: Option<&str>, invitee_username: &str, ) -> Result<()> { let subject = "Your invite was used"; let body = format!( r#"Hi{name}, {invitee} just signed up using one of your invite codes. Their account is pending admin approval. - Makenotwork"#, name = crate::email::greeting(to_name), invitee = invitee_username ); self.transport.send_email(to_email, subject, &body).await } // ── Platform notices ── /// Send a policy warning to a user (no suspension, informational only) pub async fn send_policy_warning( &self, to_email: &str, to_name: Option<&str>, reason: &str, ) -> Result<()> { let subject = "Policy notice regarding your account"; let body = format!( r#"Hi{name}, We're writing to let you know about an issue with your account or content on Makenotwork. Issue: {reason} 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. If you have questions or believe this was sent in error, reply to this email or contact info@makenot.work. - Makenotwork"#, name = crate::email::greeting(to_name), reason = reason ); self.transport.send_email(to_email, subject, &body).await } /// Send a suspension notification to a user pub async fn send_suspension_notification( &self, to_email: &str, to_name: Option<&str>, reason: &str, ) -> Result<()> { let subject = "Your account has been suspended"; let body = format!( r#"Hi{name}, Your Makenotwork account has been suspended. Reason: {reason} You can appeal this decision from your dashboard. You can also export your data at any time. Log in to your dashboard to submit an appeal or export your data. - Makenotwork"#, name = crate::email::greeting(to_name), reason = reason ); self.transport.send_email(to_email, subject, &body).await } /// Send an appeal decision notification to a user pub async fn send_appeal_decision( &self, to_email: &str, to_name: Option<&str>, decision: &str, response: &str, ) -> Result<()> { let outcome = if decision == "approved" { "Your account has been reinstated" } else { "Your appeal has been denied" }; let subject = "Your appeal has been reviewed"; let body = format!( r#"Hi{name}, {outcome}. Response from the review team: {response} Log in to your dashboard for more details. - Makenotwork"#, name = crate::email::greeting(to_name), outcome = outcome, response = response ); self.transport.send_email(to_email, subject, &body).await } /// Notify a creator that their item was removed by an admin. pub async fn send_content_removal( &self, to_email: &str, to_name: Option<&str>, item_title: &str, reason: &str, ) -> Result<()> { let subject = format!("Content removed: {}", item_title); let body = format!( r#"Hi{name}, Your item "{item_title}" has been removed from public access. Reason: {reason} 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. - Makenotwork"#, name = crate::email::greeting(to_name), item_title = item_title, reason = reason, ); self.transport.send_email(to_email, &subject, &body).await } /// Notify a creator that their previously removed item has been restored. pub async fn send_content_restored( &self, to_email: &str, to_name: Option<&str>, item_title: &str, ) -> Result<()> { let subject = format!("Content restored: {}", item_title); let body = format!( r#"Hi{name}, Your item "{item_title}" has been restored. You can now re-publish it from your dashboard. - Makenotwork"#, name = crate::email::greeting(to_name), item_title = item_title, ); self.transport.send_email(to_email, &subject, &body).await } /// Notify a user that their account has been permanently terminated. pub async fn send_account_termination( &self, to_email: &str, to_name: Option<&str>, ) -> Result<()> { let subject = "Your Makenot.work account has been terminated"; let body = format!( r#"Hi{name}, Your Makenot.work account has been permanently terminated for repeated or serious policy violations. You have 30 days from today to export your data: - Log in at makenot.work - Go to Dashboard > Export - Download your content and transaction records After 30 days, your account and all associated data will be permanently deleted. If you believe this was a mistake, you can reply to this email. - Makenotwork"#, name = crate::email::greeting(to_name), ); self.transport.send_email(to_email, subject, &body).await } /// Send a platform shutdown notice to a user pub async fn send_shutdown_notice( &self, to_email: &str, to_name: Option<&str>, shutdown_date: &str, ) -> Result<()> { let subject = "Important: Makenot.work is shutting down"; let body = format!( r#"Hi{name}, We are writing to let you know that Makenot.work will be shutting down on {shutdown_date}. 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. To export your data, log in and visit your dashboard export page. 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. - Makenotwork"#, name = crate::email::greeting(to_name), shutdown_date = shutdown_date ); self.transport.send_email(to_email, subject, &body).await } // ── Issue tracking ── /// Notify a repo owner that someone opened a new issue. #[allow(clippy::too_many_arguments)] pub async fn send_new_issue_notification( &self, to_email: &str, to_name: Option<&str>, repo_owner: &str, repo_name: &str, issue_number: i32, issue_title: &str, author_username: &str, issue_url: &str, unsub_url: Option<&str>, reply_to: Option<&str>, message_id: Option<&str>, ) -> Result<()> { let subject = format!("New issue on {}/{}: {}", repo_owner, repo_name, issue_title); let body = format!( r#"Hi{name}, {author} opened issue #{number} on {owner}/{repo}: {title} View it here: {url} Reply to this email to comment on this issue. - Makenotwork"#, name = crate::email::greeting(to_name), author = author_username, number = issue_number, owner = repo_owner, repo = repo_name, title = issue_title, url = issue_url, ); let mut headers: Vec<(&str, String)> = Vec::new(); if let Some(rt) = reply_to { headers.push(("Reply-To", rt.to_string())); } if let Some(mid) = message_id { headers.push(("Message-ID", mid.to_string())); } self.transport.send_email_with_headers_and_unsub(to_email, &subject, &body, &headers, unsub_url).await } /// Notify about a new comment or status change on an issue. #[allow(clippy::too_many_arguments)] pub async fn send_issue_comment_notification( &self, to_email: &str, to_name: Option<&str>, repo_owner: &str, repo_name: &str, issue_number: i32, issue_title: &str, commenter_username: &str, comment_preview: &str, issue_url: &str, unsub_url: Option<&str>, reply_to: Option<&str>, message_id: Option<&str>, in_reply_to: Option<&str>, ) -> Result<()> { let subject = format!("Re: New issue on {}/{}: {}", repo_owner, repo_name, issue_title); let body = format!( r#"Hi{name}, {commenter} commented on issue #{number} ({title}) in {owner}/{repo}: {preview} View it here: {url} Reply to this email to comment on this issue. - Makenotwork"#, name = crate::email::greeting(to_name), commenter = commenter_username, number = issue_number, title = issue_title, owner = repo_owner, repo = repo_name, preview = comment_preview, url = issue_url, ); let mut headers: Vec<(&str, String)> = Vec::new(); if let Some(rt) = reply_to { headers.push(("Reply-To", rt.to_string())); } if let Some(mid) = message_id { headers.push(("Message-ID", mid.to_string())); } if let Some(irt) = in_reply_to { headers.push(("In-Reply-To", irt.to_string())); headers.push(("References", irt.to_string())); } self.transport.send_email_with_headers_and_unsub(to_email, &subject, &body, &headers, unsub_url).await } /// Notify a creator that someone tipped them. pub async fn send_tip_notification( &self, to_email: &str, to_name: Option<&str>, tipper_name: &str, price: &str, message: Option<&str>, unsub_url: Option<&str>, ) -> Result<()> { let subject = format!("{} tipped you {}", tipper_name, price); let body = match message { Some(msg) => format!( r#"Hi{name}, {tipper} tipped you {price} with a message: "{msg}" View your tips from your dashboard. - Makenotwork"#, name = crate::email::greeting(to_name), tipper = tipper_name, price = price, msg = msg, ), None => format!( r#"Hi{name}, {tipper} tipped you {price}. View your tips from your dashboard. - Makenotwork"#, name = crate::email::greeting(to_name), tipper = tipper_name, price = price, ), }; self.transport.send_email_with_unsub(to_email, &subject, &body, unsub_url).await } /// Notify a buyer that a creator they purchased from is leaving the platform. /// Sent by the platform (not the creator) so it bypasses contact sharing preferences. pub async fn send_creator_departure_notification( &self, to_email: &str, to_name: Option<&str>, creator_name: &str, ) -> Result<()> { let subject = format!("{} is leaving Makenot.work — download your purchases", creator_name); let body = format!( r#"Hi{name}, {creator} has deleted their creator account on Makenot.work. Content you purchased from {creator} will remain available for 90 days. After that, it will be permanently removed from the platform. To download your purchases, log in and visit your library: https://makenot.work/dashboard#tab-library Your transaction receipts are preserved indefinitely regardless. - Makenotwork"#, name = crate::email::greeting(to_name), creator = creator_name, ); self.transport.send_email(to_email, &subject, &body).await } /// Send a platform status change notification to an opted-in user. pub async fn send_status_notification( &self, to_email: &str, to_name: Option<&str>, status: &str, previous: &str, unsub_url: &str, ) -> Result<()> { let subject = match status { "operational" => "Makenot.work recovered — all services operational", "degraded" => "Makenot.work — partial service degradation", _ => "Makenot.work — service disruption", }; let body = format!( r#"Hi{name}, Platform status changed: {previous} -> {status}. Current status: {status} Previous status: {previous} Check live status at https://makenot.work/health Your content remains accessible to fans. If you experience issues, they should resolve as the platform recovers. - Makenotwork"#, name = crate::email::greeting(to_name), status = status, previous = previous, ); self.transport .send_email_with_unsub(to_email, subject, &body, Some(unsub_url)) .await } pub async fn send_alert(&self, to: &str, subject: &str, body: &str) -> Result<()> { self.transport.send_email(to, subject, body).await } /// Warn an app owner that they're approaching (or have hit) a SyncKit cap. /// /// `dimension` is `"storage"`, `"storage_per_key"`, or `"egress"`. `pct` /// is 75/90/100. At 100% the next request to that dimension will be /// hard-blocked with a 402. `key` is `Some(_)` only when /// `dimension == "storage_per_key"` — it names the SDK key whose /// allotment tripped, so the developer knows which workspace to nudge. #[allow(clippy::too_many_arguments)] pub async fn send_synckit_usage_warning( &self, to_email: &str, app_name: &str, dimension: &str, key: Option<&str>, pct: i16, used_bytes: i64, limit_bytes: i64, billing_url: &str, ) -> Result<()> { fn fmt_gb(bytes: i64) -> String { let gb = bytes as f64 / (1024.0 * 1024.0 * 1024.0); if gb >= 10.0 { format!("{gb:.0} GB") } else { format!("{gb:.2} GB") } } let dim_human = match dimension { "storage" => "storage".to_string(), "storage_per_key" => match key { Some(k) => format!("storage for key \"{k}\""), None => "per-key storage".to_string(), }, "egress" => "monthly egress".to_string(), other => other.to_string(), }; let subject = if pct >= 100 { format!("{app_name}: {dim_human} cap reached") } else { format!("{app_name}: {pct}% of {dim_human} cap used") }; let pct_msg = if pct >= 100 { format!( "Your SyncKit app \"{app_name}\" has reached its {dim_human} cap.\n\ Further {dim_human} requests will be rejected (HTTP 402) until the\n\ cap is raised or the period rolls over." ) } else { format!( "Your SyncKit app \"{app_name}\" has used {pct}% of its {dim_human}\n\ cap. At 100% further requests are hard-blocked (HTTP 402).", ) }; let body = format!( r#"{pct_msg} Used: {used} Limit: {limit} Adjust caps or review usage: {url} - Makenotwork"#, used = fmt_gb(used_bytes), limit = fmt_gb(limit_bytes), url = billing_url, ); self.transport.send_email(to_email, &subject, &body).await } } #[cfg(test)] mod tests { use super::*; use crate::email::EmailTransport; use std::sync::{Arc, Mutex}; /// One captured email: (to, subject, html_body, text_body). type SentEmail = (String, String, String, Option); /// In-memory transport that captures sent emails for assertion in tests. struct CapturingTransport { sent: Mutex>, } impl CapturingTransport { fn new() -> Self { Self { sent: Mutex::new(Vec::new()) } } fn last(&self) -> (String, String, String, Option) { self.sent.lock().unwrap().last().cloned().expect("no email captured") } } #[async_trait::async_trait] impl EmailTransport for CapturingTransport { async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { self.sent.lock().unwrap().push((to.to_string(), subject.to_string(), body.to_string(), None)); Ok(()) } async fn send_email_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, ) -> Result<()> { self.sent.lock().unwrap().push(( to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from), )); Ok(()) } async fn send_email_with_headers_and_unsub( &self, to: &str, subject: &str, body: &str, _extra_headers: &[(&str, String)], unsub_url: Option<&str>, ) -> Result<()> { self.sent.lock().unwrap().push(( to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from), )); Ok(()) } async fn send_email_broadcast_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, ) -> Result<()> { self.sent.lock().unwrap().push(( to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from), )); Ok(()) } } fn client_with_capture() -> (EmailClient, Arc) { let transport = Arc::new(CapturingTransport::new()); let client = EmailClient::with_transport(transport.clone()); (client, transport) } // ── Creator activity ── #[tokio::test] async fn sale_notification_carries_buyer_item_price() { let (client, captured) = client_with_capture(); client .send_sale_notification("seller@example.com", Some("Sasha"), "buyer42", "Cool Album", "$10.00", Some("https://x/unsub")) .await .unwrap(); let (to, subject, body, unsub) = captured.last(); assert_eq!(to, "seller@example.com"); assert!(subject.contains("New sale")); assert!(subject.contains("Cool Album")); assert!(body.contains("Hi Sasha")); assert!(body.contains("buyer42")); assert!(body.contains("Cool Album")); assert!(body.contains("$10.00")); assert_eq!(unsub.as_deref(), Some("https://x/unsub")); } #[tokio::test] async fn sale_notification_handles_none_name() { // greeting(None) → empty; body should still build coherently. let (client, captured) = client_with_capture(); client.send_sale_notification("s@x", None, "buyer", "Item", "$5", None).await.unwrap(); let (_, _, body, unsub) = captured.last(); assert!(body.starts_with("Hi,") || body.starts_with("Hi "), "body: {body}"); assert!(unsub.is_none()); } #[tokio::test] async fn tip_notification_with_message_includes_quoted_message() { let (client, captured) = client_with_capture(); client.send_tip_notification("c@x", None, "Alex", "$3", Some("Loved it!"), None).await.unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("Alex tipped you $3")); assert!(body.contains("Loved it!")); assert!(body.contains("$3")); } #[tokio::test] async fn tip_notification_without_message_omits_quote_block() { // Pins the `match message { Some => ..., None => ... }` arm split — // without-message branch must NOT include the "with a message:" preamble. let (client, captured) = client_with_capture(); client.send_tip_notification("c@x", None, "Alex", "$3", None, None).await.unwrap(); let (_, _, body, _) = captured.last(); assert!(!body.contains("with a message"), "without-message branch leaked: {body}"); assert!(body.contains("$3")); } // ── Platform notices: suspension / appeal / termination / shutdown ── #[tokio::test] async fn suspension_includes_reason() { let (client, captured) = client_with_capture(); client.send_suspension_notification("u@x", Some("Sam"), "Spam reports").await.unwrap(); let (_, subject, body, _) = captured.last(); assert_eq!(subject, "Your account has been suspended"); assert!(body.contains("Hi Sam")); assert!(body.contains("Reason: Spam reports")); assert!(body.contains("appeal")); assert!(body.contains("export your data")); } #[tokio::test] async fn appeal_decision_approved_uses_reinstated_outcome() { // Pins the `if decision == "approved"` branch. let (client, captured) = client_with_capture(); client.send_appeal_decision("u@x", None, "approved", "Reviewed and reversed.").await.unwrap(); let (_, _, body, _) = captured.last(); assert!(body.contains("Your account has been reinstated"), "approved branch should say reinstated: {body}"); assert!(!body.contains("Your appeal has been denied"), "approved branch must NOT also say denied: {body}"); assert!(body.contains("Reviewed and reversed.")); } #[tokio::test] async fn appeal_decision_denied_uses_denied_outcome() { let (client, captured) = client_with_capture(); client.send_appeal_decision("u@x", None, "denied", "Reviewed and upheld.").await.unwrap(); let (_, _, body, _) = captured.last(); assert!(body.contains("Your appeal has been denied")); assert!(!body.contains("Your account has been reinstated")); } #[tokio::test] async fn appeal_decision_anything_other_than_approved_is_denied() { // Pins `decision == "approved"` (exact match, case-sensitive). let (client, captured) = client_with_capture(); client.send_appeal_decision("u@x", None, "APPROVED", "uppercase").await.unwrap(); let (_, _, body, _) = captured.last(); assert!(body.contains("Your appeal has been denied"), "case-sensitive `approved` — uppercase must NOT pass: {body}"); } #[tokio::test] async fn content_removal_subjects_with_title() { let (client, captured) = client_with_capture(); client.send_content_removal("c@x", Some("Dev"), "Beat Pack 1", "Copyright claim").await.unwrap(); let (_, subject, body, _) = captured.last(); assert_eq!(subject, "Content removed: Beat Pack 1"); assert!(body.contains("Hi Dev")); assert!(body.contains("Beat Pack 1")); assert!(body.contains("Reason: Copyright claim")); assert!(body.contains("appeal")); } #[tokio::test] async fn content_restored_subjects_with_title() { let (client, captured) = client_with_capture(); client.send_content_restored("c@x", None, "Beat Pack 1").await.unwrap(); let (_, subject, body, _) = captured.last(); assert_eq!(subject, "Content restored: Beat Pack 1"); assert!(body.contains("Beat Pack 1")); assert!(body.contains("restored")); } #[tokio::test] async fn account_termination_has_30_day_window_message() { let (client, captured) = client_with_capture(); client.send_account_termination("u@x", Some("Pat")).await.unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("terminated")); assert!(body.contains("Hi Pat")); assert!(body.contains("30 days")); assert!(body.contains("export your data")); } #[tokio::test] async fn shutdown_notice_includes_date() { let (client, captured) = client_with_capture(); client.send_shutdown_notice("u@x", None, "2027-06-15").await.unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("shutting down")); assert!(body.contains("2027-06-15")); assert!(body.contains("90 days")); assert!(body.contains("no lock-in")); } #[tokio::test] async fn creator_departure_mentions_creator_and_90_days() { let (client, captured) = client_with_capture(); client.send_creator_departure_notification("buyer@x", None, "Alex").await.unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("Alex")); assert!(subject.contains("leaving")); assert!(body.contains("Alex")); assert!(body.contains("90 days")); assert!(body.contains("library")); } // ── Issue tracking ── #[tokio::test] async fn new_issue_notification_includes_repo_path_and_url() { let (client, captured) = client_with_capture(); client .send_new_issue_notification( "owner@x", Some("Jordan"), "alex", "audio-tools", 42, "Crash on startup", "bob", "https://makenot.work/p/alex/audio-tools/issues/42", Some("https://unsub"), Some("reply@x"), Some(""), ) .await .unwrap(); let (_, subject, body, unsub) = captured.last(); assert_eq!(subject, "New issue on alex/audio-tools: Crash on startup"); assert!(body.contains("Hi Jordan")); assert!(body.contains("bob opened issue #42")); assert!(body.contains("alex/audio-tools")); assert!(body.contains("Crash on startup")); assert!(body.contains("https://makenot.work/p/alex/audio-tools/issues/42")); assert_eq!(unsub.as_deref(), Some("https://unsub")); } #[tokio::test] async fn issue_comment_subject_uses_re_prefix() { // Pins the "Re: " prefix that threads the email reply. let (client, captured) = client_with_capture(); client .send_issue_comment_notification( "owner@x", None, "alex", "audio-tools", 42, "Crash on startup", "carol", "Looked into this, see PR #5", "https://makenot.work/p/alex/audio-tools/issues/42", None, None, None, None, ) .await .unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.starts_with("Re: "), "comment must be Re:-prefixed: {subject}"); assert!(body.contains("carol commented on issue #42")); assert!(body.contains("Looked into this")); } // ── Status notifications: per-status subject mapping ── #[tokio::test] async fn status_notification_operational_subject() { let (client, captured) = client_with_capture(); client.send_status_notification("u@x", None, "operational", "degraded", "https://unsub").await.unwrap(); let (_, subject, _, _) = captured.last(); assert!(subject.contains("recovered")); assert!(subject.contains("all services operational")); } #[tokio::test] async fn status_notification_degraded_subject() { let (client, captured) = client_with_capture(); client.send_status_notification("u@x", None, "degraded", "operational", "https://unsub").await.unwrap(); let (_, subject, _, _) = captured.last(); assert!(subject.contains("partial service degradation")); } #[tokio::test] async fn status_notification_unknown_falls_back_to_disruption() { // Pins the `_ => "...service disruption"` arm. let (client, captured) = client_with_capture(); client.send_status_notification("u@x", None, "outage", "operational", "https://unsub").await.unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("service disruption")); // Body interpolates the actual status string regardless. assert!(body.contains("outage")); } }