OXYGENHUB

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