Email subjects say what happened, with an emoji b91e2d81b3f1
Notification emails lead with a label for their kind (🎉 Build Success, 💥 Build Broken, ⚠️ Backup Failed, ✅ Backup Recovered, 📦 Daily Backup Summary, 🔀 New Proposal, 👋 New Sign-up and the rest); account emails read ✉️ Confirm your email address and 🔑 Reset your password, and the admin test message 🧪 Test message. Closes #186
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 12:39 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b91e2d81b3f1ae44682844af0ef18eed1173035d81ecd68bd77ec47b036c5d8c- Depends on
- 18e381ea88c2 19bb31ff1f5c 34beb1a7e376 3556d08e17f9 bff9106edcba cd46e56b1709
- Depended on by
- ca0c443c30cf 2bbdc0268c23
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 13 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/hub/src/notify/mod.rs +43 −3
| … | 87 unchanged lines |
||
| 88 | 88 | if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { |
|
| 89 | 89 | // Only to addresses the account has proven it reads (hub spec §5.1.1). |
|
| 90 | 90 | if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) { |
|
| 91 | - | email(app, &a, title, url, actor).await; |
|
| 91 | + | email(app, &a, kind, title, url, actor).await; |
|
| 92 | 92 | } |
|
| 93 | 93 | } |
|
| 94 | 94 | } |
|
| 95 | 95 | } |
|
| 96 | 96 | ||
| 97 | - | async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) { |
|
| 97 | + | /// What a kind of notification is called in an email's subject, with its |
|
| 98 | + | /// emoji, so a glance at the inbox says what happened (Andy, 2026-10-14). |
|
| 99 | + | pub fn label(kind: &str) -> Option<&'static str> { |
|
| 100 | + | Some(match kind { |
|
| 101 | + | "flow.failed" => "💥 Build Broken", |
|
| 102 | + | "flow.fixed" => "🎉 Build Success", |
|
| 103 | + | "flow.runner_drained" => "🛑 Runner Drained", |
|
| 104 | + | "backup" => "⚠️ Backup Failed", |
|
| 105 | + | "backup.recovered" => "✅ Backup Recovered", |
|
| 106 | + | "backup.summary" => "📦 Daily Backup Summary", |
|
| 107 | + | "proposal.opened" => "🔀 New Proposal", |
|
| 108 | + | "proposal.review_requested" => "👀 Review Requested", |
|
| 109 | + | "proposal.review" => "📝 New Review", |
|
| 110 | + | "proposal.landed" => "🚀 Proposal Landed", |
|
| 111 | + | "proposal.comment" | "issue.comment" => "💬 New Comment", |
|
| 112 | + | "issue.opened" => "🐛 New Issue", |
|
| 113 | + | "issue.assigned" => "📌 Issue Assigned", |
|
| 114 | + | "issue.closed" => "✔️ Issue Closed", |
|
| 115 | + | "account.registered" => "👋 New Sign-up", |
|
| 116 | + | _ => return None, |
|
| 117 | + | }) |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | /// A notification's email subject: its label, then what happened. |
|
| 121 | + | pub fn subject(kind: &str, title: &str) -> String { |
|
| 122 | + | match label(kind) { |
|
| 123 | + | Some(l) => format!("{l}: {title}"), |
|
| 124 | + | None => title.to_string(), |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | async fn email(app: &Shared, to: &Account, kind: &str, title: &str, url: &str, actor: &str) { |
|
| 98 | 129 | let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key); |
|
| 99 | 130 | let link = format!("{}{url}", app.config.base_url()); |
|
| 100 | 131 | let body = format!("{actor}: {title}\n\n{link}\n\nStop these emails: {unsubscribe}\n"); |
|
| 101 | 132 | let action = crate::mail::Action { label: "Open it", url: &link }; |
|
| 102 | - | if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, title, &body, Some(action), Some(&unsubscribe)).await { |
|
| 133 | + | let subject = subject(kind, title); |
|
| 134 | + | if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, &subject, &body, Some(action), Some(&unsubscribe)).await { |
|
| 103 | 135 | tracing::warn!("email to {}: {e}", to.email); |
|
| 104 | 136 | } |
|
| 105 | 137 | } |
|
| … | 1 unchanged lines |
||
| 107 | 139 | #[cfg(test)] |
|
| 108 | 140 | mod tests { |
|
| 109 | 141 | use super::*; |
|
| 142 | + | ||
| 143 | + | #[test] |
|
| 144 | + | fn subjects_say_what_happened() { |
|
| 145 | + | assert_eq!(subject("flow.failed", "andy/demo: run #4 on main failed (test)"), "💥 Build Broken: andy/demo: run #4 on main failed (test)"); |
|
| 146 | + | assert_eq!(subject("flow.fixed", "andy/demo: run #5 on main passed: fixed"), "🎉 Build Success: andy/demo: run #5 on main passed: fixed"); |
|
| 147 | + | assert_eq!(subject("backup.summary", "x"), "📦 Daily Backup Summary: x"); |
|
| 148 | + | assert_eq!(subject("something.new", "x"), "x", "a kind without a label keeps its title"); |
|
| 149 | + | } |
|
| 110 | 150 | ||
| 111 | 151 | #[test] |
|
| 112 | 152 | fn finds_mentions() { |
|
| … | 4 unchanged lines |
modified crates/hub/src/web/admin.rs +1 −1
| … | 204 unchanged lines |
||
| 205 | 205 | &app, |
|
| 206 | 206 | me.name(), |
|
| 207 | 207 | &me.email, |
|
| 208 | - | "Test message", |
|
| 208 | + | "🧪 Test message", |
|
| 209 | 209 | "This is a test message from your hub's mail settings. If you can read it, mail works: verification links, password resets and notifications will reach people.", |
|
| 210 | 210 | Some(crate::mail::Action { label: "Open the hub", url: &base }), |
|
| 211 | 211 | None, |
|
| … | 314 unchanged lines |
modified crates/hub/src/web/recovery.rs +2 −2
| … | 61 unchanged lines |
||
| 62 | 62 | app.config.site.name |
|
| 63 | 63 | ); |
|
| 64 | 64 | let action = crate::mail::Action { label: "Confirm my email address", url: &url }; |
|
| 65 | - | crate::mail::send_letter(app, account.name(), &account.email, "Confirm your email address", &body, Some(action), None).await |
|
| 65 | + | crate::mail::send_letter(app, account.name(), &account.email, "✉️ Confirm your email address", &body, Some(action), None).await |
|
| 66 | 66 | } |
|
| 67 | 67 | ||
| 68 | 68 | /// `GET /verify/{secret}`. |
|
| … | 54 unchanged lines |
||
| 123 | 123 | let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone()); |
|
| 124 | 124 | tokio::spawn(async move { |
|
| 125 | 125 | let action = crate::mail::Action { label: "Choose a new password", url: &url }; |
|
| 126 | - | if let Err(err) = crate::mail::send_letter(&mailer, &name, &addr, "Reset your password", &body, Some(action), None).await { |
|
| 126 | + | if let Err(err) = crate::mail::send_letter(&mailer, &name, &addr, "🔑 Reset your password", &body, Some(action), None).await { |
|
| 127 | 127 | tracing::warn!("reset mail for {user}: {err}"); |
|
| 128 | 128 | } |
|
| 129 | 129 | }); |
|
| … | 74 unchanged lines |
modified crates/hub/tests/recovery.rs +2 −2
| … | 123 unchanged lines |
||
| 124 | 124 | // Registration sends a verification link; until it is used, no reset mail. |
|
| 125 | 125 | let m = mail(&h.mailbox); |
|
| 126 | 126 | assert_eq!(m.len(), 1); |
|
| 127 | - | assert!(m[0].contains("To: andy <[email protected]>") && m[0].contains("Subject: [OxygenHub] Confirm your email address"), "{}", m[0]); |
|
| 127 | + | assert!(m[0].contains("To: andy <[email protected]>") && m[0].contains("Subject: [OxygenHub] =?utf-8?b?4pyJ77iP?= Confirm your email address"), "{}", m[0]); |
|
| 128 | 128 | let verify = link(&m[0], "/verify/"); |
|
| 129 | 129 | assert!(!account(app, "andy").await.email_verified); |
|
| 130 | 130 | let settings = get(app, "/settings", Some(&old_session)).await; |
|
| … | 15 unchanged lines |
||
| 146 | 146 | assert_eq!(unknown.body, known.body, "the answer does not reveal accounts"); |
|
| 147 | 147 | let m = mail_count(&h.mailbox, 2).await; |
|
| 148 | 148 | assert_eq!(m.len(), 2); |
|
| 149 | - | assert!(m[1].contains("Subject: [OxygenHub] Reset your password")); |
|
| 149 | + | assert!(m[1].contains("Subject: [OxygenHub] =?utf-8?b?") && m[1].contains("?= Reset your password"), "an emoji, then the subject"); |
|
| 150 | 150 | let reset = link(&m[1], "/login/reset/"); |
|
| 151 | 151 | post(app, "/login/reset", None, "who=andy").await; |
|
| 152 | 152 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
|
| … | 71 unchanged lines |