OXYGENHUB

Mail: a service panel, and email that looks like OxygenHub cd46e56b1709

Andy (2026-10-14) wanted mail set up by picking a service, generic for
any installation, and messages that look good.

Administration › Mail is a panel (#181): pick Resend (its API key),
Amazon SES (region and SES SMTP credentials), Mailgun (us or eu and the
SMTP login), another SMTP server (server, port, security, user,
password), an SMTP URL for anything else (file:// for development), or
Off. Every service is SMTP underneath: the panel stores one standard
SMTP URL, so any provider works. Keys are never shown again; left empty
the saved one stays while service, server and user do. An smtp:// URL
that says nothing of TLS now requires STARTTLS (lettre's default was
none), except to a relay on this machine.

Every message goes as plain text and HTML (#182): the Oxygen band with
the mark sent inline (cid:logo, shows without loading images), the
subject as heading, the body with links, the message's action as a
button where its link stood (link beneath for clients hiding buttons),
and a footer of Explore, Help, Notification settings and Stop these
emails. Email-safe tables and inline styles with dark-mode colours.
Verification, reset, notification and test messages each get a button.

Tested: services compose and read back their URLs (keys kept, never
carried to another service); the panel saves Resend and SES and turns
off; the HTML card has the mark, the button once, escaped text and the
site's links; STARTTLS by default; the recovery tests read the MIME
messages.

closes #181, closes #182
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 10:53 UTC
Signature
✔ signed by Andy McCall
Hash
cd46e56b1709a01b405ac538ae4092a3fb12b36d6f6847690892fa0f7956aeed
Depends on
18e381ea88c2 19bb31ff1f5c 34beb1a7e376 3556d08e17f9 93be9ce4653e d23464c0ac60 e8e7158bbc73
Depended on by
262ec9c51a5c 737de0fac722 a7aa76ac52d7 2bbdc0268c23 b91e2d81b3f1
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
35 atoms, 1 tree operation, 9 files

Browse the tree at this change

added assets/icons/oxygenhub-mail.png +0 −0

No line changes.

modified crates/hub/src/mail.rs +445 −10
…
47 unchanged lines
48 48
    !cfg.smtp.is_empty() && !cfg.from.is_empty()
49 49
}
50 50
51 -
/// Send a plain-text message. The subject gets the site's name in brackets.
51 +
/// The one thing a message asks of its reader, shown as its button.
52 +
#[derive(Clone, Copy, Debug)]
53 +
pub struct Action<'a> {
54 +
    pub label: &'a str,
55 +
    pub url: &'a str,
56 +
}
57 +
58 +
/// Send a message with no button. The subject gets the site's name in
59 +
/// brackets.
52 60
pub async fn send(app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str) -> Result<(), String> {
61 +
    send_letter(app, to_name, to_addr, subject, body, None, None).await
62 +
}
63 +
64 +
/// Send a message as plain text and as HTML in the site's look
65 +
/// (oxygenhub#182): the Oxygen mark, `action` as a button, and the site's
66 +
/// links (with `unsubscribe`, when given) in the footer. The text part is
67 +
/// `body` as written.
68 +
pub async fn send_letter(
69 +
    app: &Shared,
70 +
    to_name: &str,
71 +
    to_addr: &str,
72 +
    subject: &str,
73 +
    body: &str,
74 +
    action: Option<Action<'_>>,
75 +
    unsubscribe: Option<&str>,
76 +
) -> Result<(), String> {
53 77
    if !enabled(app) {
54 78
        return Err("this hub does not send mail".into());
55 79
    }
56 80
    let cfg = settings(app);
57 -
    let subject = format!("[{}] {subject}", app.config.site.name);
58 -
    if let Some(dir) = cfg.smtp.strip_prefix("file://") {
59 -
        let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4));
60 -
        let text = format!("From: {}\nTo: {to_name} <{to_addr}>\nSubject: {subject}\n\n{body}", cfg.from);
61 -
        return tokio::fs::write(std::path::Path::new(dir).join(name), text).await.map_err(|e| format!("write mail to {dir}: {e}"));
62 -
    }
81 +
    let site = app.config.site.name.clone();
82 +
    let html = html(&site, app.config.base_url(), subject, body, action, unsubscribe);
83 +
    let subject = format!("[{site}] {subject}");
63 84
    use lettre::message::header::ContentType;
85 +
    use lettre::message::{Attachment, MultiPart, SinglePart};
64 86
    use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
65 87
    let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?;
66 88
    let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?;
89 +
    let png = ContentType::parse("image/png").map_err(|e| e.to_string())?;
67 90
    let message = Message::builder()
68 91
        .from(from)
69 92
        .to(to)
70 93
        .subject(subject)
71 -
        .header(ContentType::TEXT_PLAIN)
72 -
        .body(body.to_string())
94 +
        .multipart(
95 +
            MultiPart::alternative().singlepart(SinglePart::plain(body.to_string())).multipart(
96 +
                MultiPart::related()
97 +
                    .singlepart(SinglePart::html(html))
98 +
                    .singlepart(Attachment::new_inline("logo".to_string()).body(LOGO.to_vec(), png)),
99 +
            ),
100 +
        )
73 101
        .map_err(|e| e.to_string())?;
74 -
    let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&cfg.smtp).map_err(|e| format!("[email] smtp: {e}"))?.build();
102 +
    if let Some(dir) = cfg.smtp.strip_prefix("file://") {
103 +
        let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4));
104 +
        return tokio::fs::write(std::path::Path::new(dir).join(name), message.formatted())
105 +
            .await
106 +
            .map_err(|e| format!("write mail to {dir}: {e}"));
107 +
    }
108 +
    let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&secured(&cfg.smtp)).map_err(|e| format!("[email] smtp: {e}"))?.build();
75 109
    transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}"))
110 +
}
111 +
112 +
/// The Oxygen mark, sent inline with each message (`cid:logo`), so it
113 +
/// shows without loading anything from the web.
114 +
const LOGO: &[u8] = include_bytes!("../../../assets/icons/oxygenhub-mail.png");
115 +
116 +
fn esc(s: &str) -> String {
117 +
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
118 +
}
119 +
120 +
/// A paragraph's text as HTML: escaped, its web addresses linked.
121 +
fn linked(text: &str) -> String {
122 +
    let re = regex::Regex::new(r"https?://[^\s<>]+[^\s<>.,;:!?)]").expect("a valid pattern");
123 +
    let mut out = String::new();
124 +
    let mut last = 0;
125 +
    for m in re.find_iter(text) {
126 +
        out.push_str(&esc(&text[last..m.start()]));
127 +
        let u = esc(m.as_str());
128 +
        out.push_str(&format!(r#"<a href="{u}" style="color:#0969da;text-decoration:underline;word-break:break-all;">{u}</a>"#));
129 +
        last = m.end();
130 +
    }
131 +
    out.push_str(&esc(&text[last..]));
132 +
    out.replace('\n', "<br>")
133 +
}
134 +
135 +
/// The HTML part of a message (oxygenhub#182): a card on a light ground,
136 +
/// the Oxygen band with the mark and wordmark, the subject as its heading,
137 +
/// the body's paragraphs, `action` as a button with its link beneath for
138 +
/// clients that hide buttons, and a footer of the site's links. Tables
139 +
/// and inline styles, as mail clients need; dark-mode clients get their
140 +
/// own colours.
141 +
pub fn html(site: &str, base: &str, subject: &str, body: &str, action: Option<Action<'_>>, unsubscribe: Option<&str>) -> String {
142 +
    let base = base.trim_end_matches('/');
143 +
    let host = base.split("://").nth(1).unwrap_or(base);
144 +
    let button = action
145 +
        .map(|a| {
146 +
            let (label, url) = (esc(a.label), esc(a.url));
147 +
            format!(
148 +
                r#"<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 12px;"><tr><td style="border-radius:8px;background-color:#0072FF;background-image:linear-gradient(90deg,#00C8F0,#0072FF);">
149 +
<a href="{url}" style="display:inline-block;padding:13px 26px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{label}</a></td></tr></table>
150 +
<p style="margin:0 0 20px;font-size:12px;line-height:1.5;color:#6e7781;" class="ox-mute">Or open this link: <a href="{url}" style="color:#0969da;word-break:break-all;">{url}</a></p>"#
151 +
            )
152 +
        })
153 +
        .unwrap_or_default();
154 +
    // The action's address becomes its button, where it stood in the text;
155 +
    // the footer says how to stop.
156 +
    let mut placed = false;
157 +
    let mut paras = String::new();
158 +
    for p in body.split("\n\n").map(str::trim).filter(|p| !p.is_empty()) {
159 +
        if action.is_some_and(|a| p == a.url) {
160 +
            if !placed {
161 +
                paras.push_str(&button);
162 +
                placed = true;
163 +
            }
164 +
            continue;
165 +
        }
166 +
        if unsubscribe.is_some_and(|u| p.contains(u)) {
167 +
            continue;
168 +
        }
169 +
        paras.push_str(&format!(
170 +
            r#"<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#1f2328;" class="ox-text">{}</p>"#,
171 +
            linked(p)
172 +
        ));
173 +
    }
174 +
    let button = if placed { String::new() } else { button };
175 +
    let stop =
176 +
        unsubscribe.map(|u| format!(r#" &middot; <a href="{}" style="color:#6e7781;">Stop these emails</a>"#, esc(u))).unwrap_or_default();
177 +
    let (site_e, subject_e, host_e) = (esc(site), esc(subject), esc(host));
178 +
    format!(
179 +
        r#"<!doctype html>
180 +
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
181 +
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
182 +
<title>{subject_e}</title>
183 +
<style>
184 +
@media (prefers-color-scheme: dark) {{
185 +
  .ox-ground {{ background-color:#0b1020 !important; }}
186 +
  .ox-card {{ background-color:#111a2e !important; border-color:#22304d !important; }}
187 +
  .ox-text, .ox-head {{ color:#e6edf3 !important; }}
188 +
  .ox-mute {{ color:#8b98ad !important; }}
189 +
}}
190 +
@media (max-width:600px) {{ .ox-pad {{ padding-left:20px !important; padding-right:20px !important; }} }}
191 +
</style></head>
192 +
<body style="margin:0;padding:0;background-color:#eef2f8;" class="ox-ground">
193 +
<div style="display:none;max-height:0;overflow:hidden;">{subject_e}</div>
194 +
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eef2f8;" class="ox-ground"><tr><td align="center" style="padding:32px 12px;">
195 +
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;background-color:#ffffff;border:1px solid #dfe5ee;border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;" class="ox-card">
196 +
<tr><td style="background-color:#070c18;padding:20px 32px;" class="ox-pad">
197 +
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
198 +
<td style="vertical-align:middle;"><a href="{base}"><img src="cid:logo" width="40" height="40" alt="" style="display:block;border:0;border-radius:9px;"></a></td>
199 +
<td style="vertical-align:middle;padding-left:12px;font-size:20px;letter-spacing:0.2px;"><a href="{base}" style="text-decoration:none;color:#ffffff;">Oxygen<span style="color:#00F0FF;font-weight:700;">Hub</span></a></td>
200 +
</tr></table></td></tr>
201 +
<tr><td style="height:4px;line-height:4px;font-size:0;background-color:#0072FF;background-image:linear-gradient(90deg,#00F0FF,#0072FF);">&nbsp;</td></tr>
202 +
<tr><td style="padding:32px 32px 12px;" class="ox-pad">
203 +
<h1 style="margin:0 0 18px;font-size:21px;line-height:1.3;font-weight:650;color:#0b1220;" class="ox-head">{subject_e}</h1>
204 +
{paras}{button}
205 +
</td></tr>
206 +
<tr><td style="padding:18px 32px 26px;border-top:1px solid #e6ebf2;" class="ox-pad">
207 +
<p style="margin:0 0 6px;font-size:12px;line-height:1.6;color:#6e7781;" class="ox-mute"><a href="{base}" style="color:#6e7781;">{site_e}</a> &middot; <a href="{base}/explore" style="color:#6e7781;">Explore</a> &middot; <a href="{base}/help" style="color:#6e7781;">Help</a> &middot; <a href="{base}/settings/notifications" style="color:#6e7781;">Notification settings</a>{stop}</p>
208 +
<p style="margin:0;font-size:12px;line-height:1.6;color:#8b949e;" class="ox-mute">Sent by {site_e} at {host_e}, where your work is signed and yours.</p>
209 +
</td></tr>
210 +
</table></td></tr></table>
211 +
</body></html>
212 +
"#
213 +
    )
214 +
}
215 +
216 +
#[cfg(test)]
217 +
mod html_tests {
218 +
    use super::*;
219 +
220 +
    #[test]
221 +
    fn a_message_is_a_card_with_its_button_and_the_site_s_links() {
222 +
        let body =
223 +
            "Confirm that [email protected] is yours:\n\nhttps://hub.example/verify/abc\n\nThe link works for 48 hours. <b>Not</b> HTML.";
224 +
        let h = html(
225 +
            "OxygenHub",
226 +
            "https://hub.example/",
227 +
            "Confirm your email address",
228 +
            body,
229 +
            Some(Action { label: "Confirm my email address", url: "https://hub.example/verify/abc" }),
230 +
            Some("https://hub.example/settings/notifications?unsubscribe=k"),
231 +
        );
232 +
        assert!(h.contains(r#"<img src="cid:logo""#), "the mark, inline");
233 +
        assert!(h.contains(">Confirm my email address</a>") && h.contains(r#"href="https://hub.example/verify/abc""#));
234 +
        assert_eq!(
235 +
            h.matches("https://hub.example/verify/abc</a>").count(),
236 +
            1,
237 +
            "the address once, under the button, not as a paragraph too"
238 +
        );
239 +
        assert!(h.contains("&lt;b&gt;Not&lt;/b&gt; HTML."), "the body is escaped");
240 +
        assert!(h.contains(r#"href="https://hub.example/explore""#) && h.contains("Stop these emails"));
241 +
        assert!(h.contains("at hub.example,"));
242 +
    }
243 +
}
244 +
245 +
/// An SMTP URL as it is sent with: `smtp://` that says nothing of TLS
246 +
/// requires STARTTLS, so a password never crosses in the clear (lettre's
247 +
/// own default is none), except to a relay on this machine. `smtps://`
248 +
/// (TLS from the start, port 465) and an explicit `?tls=` stay as written.
249 +
pub fn secured(url: &str) -> String {
250 +
    let Some(rest) = url.strip_prefix("smtp://") else { return url.to_string() };
251 +
    if url.contains("tls=") {
252 +
        return url.to_string();
253 +
    }
254 +
    let host = rest.rsplit('@').next().unwrap_or(rest);
255 +
    let host = host.split(['/', '?']).next().unwrap_or(host);
256 +
    let host = host.rsplit_once(':').map_or(host, |(h, _)| h).trim_matches(['[', ']']);
257 +
    if matches!(host, "localhost" | "127.0.0.1" | "::1") {
258 +
        return url.to_string();
259 +
    }
260 +
    format!("{url}{}tls=required", if url.contains('?') { "&" } else { "?" })
261 +
}
262 +
263 +
#[cfg(test)]
264 +
mod secured_tests {
265 +
    use super::secured;
266 +
267 +
    #[test]
268 +
    fn smtp_urls_require_starttls_unless_they_say_otherwise() {
269 +
        assert_eq!(secured("smtp://resend:[email protected]:587"), "smtp://resend:[email protected]:587?tls=required");
270 +
        assert_eq!(secured("smtps://resend:[email protected]:465"), "smtps://resend:[email protected]:465");
271 +
        assert_eq!(secured("smtp://relay.example:25?tls=opportunistic"), "smtp://relay.example:25?tls=opportunistic");
272 +
        assert_eq!(secured("smtp://localhost:25"), "smtp://localhost:25", "a relay on this machine");
273 +
        assert_eq!(secured("smtp://u:[email protected]:1025"), "smtp://u:[email protected]:1025");
274 +
        assert_eq!(secured("file:///tmp/mail"), "file:///tmp/mail");
275 +
    }
276 +
}
277 +
278 +
/// The services the Mail panel knows (oxygenhub#181): each is SMTP
279 +
/// underneath, so the hub stores and sends one standard SMTP URL; the
280 +
/// panel only fills in the server and the login a service expects.
281 +
pub const SERVICES: &[(&str, &str)] = &[
282 +
    ("off", "Off"),
283 +
    ("resend", "Resend"),
284 +
    ("ses", "Amazon SES"),
285 +
    ("mailgun", "Mailgun"),
286 +
    ("smtp", "Another SMTP server"),
287 +
    ("url", "An SMTP URL (advanced)"),
288 +
];
289 +
290 +
/// A stored SMTP URL read back for the panel: which service, and its parts
291 +
/// but the secret.
292 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
293 +
pub struct Choice {
294 +
    pub service: String,
295 +
    /// SES's AWS region, or Mailgun's `us` or `eu`.
296 +
    pub region: String,
297 +
    pub user: String,
298 +
    pub host: String,
299 +
    pub port: String,
300 +
    /// `tls` (from the start), `starttls` or `none`.
301 +
    pub security: String,
302 +
    /// Whether a password or key is saved.
303 +
    pub has_secret: bool,
304 +
}
305 +
306 +
/// Percent-encoding for a URL's user and password: all but unreserved
307 +
/// characters (lettre decodes them).
308 +
fn enc(s: &str) -> String {
309 +
    s.bytes()
310 +
        .map(|b| match b {
311 +
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
312 +
            _ => format!("%{b:02X}"),
313 +
        })
314 +
        .collect()
315 +
}
316 +
317 +
fn dec(s: &str) -> String {
318 +
    let b = s.as_bytes();
319 +
    let mut out = Vec::new();
320 +
    let mut i = 0;
321 +
    while i < b.len() {
322 +
        if b[i] == b'%' && i + 2 < b.len() {
323 +
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
324 +
                out.push(v);
325 +
                i += 3;
326 +
                continue;
327 +
            }
328 +
        }
329 +
        out.push(b[i]);
330 +
        i += 1;
331 +
    }
332 +
    String::from_utf8_lossy(&out).into_owned()
333 +
}
334 +
335 +
/// The parts of an SMTP URL: scheme, user, password (as stored, encoded),
336 +
/// host, port, query.
337 +
fn parts(url: &str) -> Option<(&str, &str, &str, &str, &str, &str)> {
338 +
    let (scheme, rest) = url.split_once("://")?;
339 +
    let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
340 +
    let (userinfo, hostport) = rest.rsplit_once('@').unwrap_or(("", rest));
341 +
    let (user, pass) = userinfo.split_once(':').unwrap_or((userinfo, ""));
342 +
    let hostport = hostport.trim_end_matches('/');
343 +
    let (host, port) = hostport.rsplit_once(':').unwrap_or((hostport, ""));
344 +
    Some((scheme, user, pass, host, port, query))
345 +
}
346 +
347 +
/// How the panel shows a stored SMTP URL.
348 +
pub fn describe(url: &str) -> Choice {
349 +
    if url.is_empty() {
350 +
        return Choice { service: "off".into(), ..Default::default() };
351 +
    }
352 +
    let Some((scheme, user, pass, host, port, query)) = parts(url).filter(|p| p.0 == "smtp" || p.0 == "smtps") else {
353 +
        return Choice { service: "url".into(), ..Default::default() };
354 +
    };
355 +
    let mut c = Choice {
356 +
        user: dec(user),
357 +
        host: host.to_string(),
358 +
        port: port.to_string(),
359 +
        has_secret: !pass.is_empty(),
360 +
        security: if scheme == "smtps" {
361 +
            "tls".into()
362 +
        } else if query.contains("tls=none") {
363 +
            "none".into()
364 +
        } else {
365 +
            "starttls".into()
366 +
        },
367 +
        ..Default::default()
368 +
    };
369 +
    c.service = if host == "smtp.resend.com" {
370 +
        "resend".into()
371 +
    } else if let Some(region) = host.strip_prefix("email-smtp.").and_then(|h| h.strip_suffix(".amazonaws.com")) {
372 +
        c.region = region.to_string();
373 +
        "ses".into()
374 +
    } else if host == "smtp.mailgun.org" || host == "smtp.eu.mailgun.org" {
375 +
        c.region = if host.contains(".eu.") { "eu".into() } else { "us".into() };
376 +
        "mailgun".into()
377 +
    } else if query.contains("tls=opportunistic") {
378 +
        "url".into()
379 +
    } else {
380 +
        "smtp".into()
381 +
    };
382 +
    c
383 +
}
384 +
385 +
/// What the panel asks for a service.
386 +
#[derive(Clone, Debug, Default)]
387 +
pub struct Asked {
388 +
    pub service: String,
389 +
    pub region: String,
390 +
    pub user: String,
391 +
    /// A new password or key; empty keeps the saved one where it still
392 +
    /// fits (the same service, server and user).
393 +
    pub secret: String,
394 +
    pub host: String,
395 +
    pub port: String,
396 +
    pub security: String,
397 +
}
398 +
399 +
/// The SMTP URL for what the panel asked, given the URL saved now; or what
400 +
/// is missing, said plainly.
401 +
pub fn compose(a: &Asked, current: &str) -> Result<String, String> {
402 +
    let region = a.region.trim();
403 +
    let (scheme, user, host, port, query): (&str, String, String, String, &str) = match a.service.as_str() {
404 +
        "resend" => ("smtps", "resend".into(), "smtp.resend.com".into(), "465".into(), ""),
405 +
        "ses" => {
406 +
            if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
407 +
                return Err("Give the SES region, such as eu-west-2.".into());
408 +
            }
409 +
            if a.user.trim().is_empty() {
410 +
                return Err("Give the SES SMTP user name (from SES › SMTP settings; not your AWS access key).".into());
411 +
            }
412 +
            ("smtps", a.user.trim().to_string(), format!("email-smtp.{region}.amazonaws.com"), "465".into(), "")
413 +
        }
414 +
        "mailgun" => {
415 +
            let host = match region {
416 +
                "eu" => "smtp.eu.mailgun.org",
417 +
                "us" | "" => "smtp.mailgun.org",
418 +
                _ => return Err("Mailgun's region is us or eu.".into()),
419 +
            };
420 +
            if a.user.trim().is_empty() {
421 +
                return Err("Give the Mailgun SMTP login, such as [email protected].".into());
422 +
            }
423 +
            ("smtps", a.user.trim().to_string(), host.into(), "465".into(), "")
424 +
        }
425 +
        "smtp" => {
426 +
            let host = a.host.trim();
427 +
            if host.is_empty() || host.contains(['/', '@', ' ']) {
428 +
                return Err("Give the SMTP server's host name.".into());
429 +
            }
430 +
            let port = a.port.trim();
431 +
            if !port.is_empty() && port.parse::<u16>().is_err() {
432 +
                return Err("The port is a number, such as 465 or 587.".into());
433 +
            }
434 +
            match a.security.as_str() {
435 +
                "tls" => ("smtps", a.user.trim().to_string(), host.into(), if port.is_empty() { "465".into() } else { port.into() }, ""),
436 +
                "none" => {
437 +
                    ("smtp", a.user.trim().to_string(), host.into(), if port.is_empty() { "25".into() } else { port.into() }, "tls=none")
438 +
                }
439 +
                _ => (
440 +
                    "smtp",
441 +
                    a.user.trim().to_string(),
442 +
                    host.into(),
443 +
                    if port.is_empty() { "587".into() } else { port.into() },
444 +
                    "tls=required",
445 +
                ),
446 +
            }
447 +
        }
448 +
        other => return Err(format!("{other:?} is not a mail service this panel knows")),
449 +
    };
450 +
    let secret = if !a.secret.is_empty() {
451 +
        enc(&a.secret)
452 +
    } else {
453 +
        match parts(current) {
454 +
            Some((_, u, p, h, _, _)) if !p.is_empty() && h == host && dec(u) == user => p.to_string(),
455 +
            _ if a.service == "smtp" && user.is_empty() => String::new(),
456 +
            _ => {
457 +
                return Err(match a.service.as_str() {
458 +
                    "resend" => "Give the Resend API key (re_…).".into(),
459 +
                    _ => "Give the password.".into(),
460 +
                })
461 +
            }
462 +
        }
463 +
    };
464 +
    let login = match (user.is_empty(), secret.is_empty()) {
465 +
        (true, _) => String::new(),
466 +
        (false, true) => format!("{}@", enc(&user)),
467 +
        (false, false) => format!("{}:{secret}@", enc(&user)),
468 +
    };
469 +
    let query = if query.is_empty() { String::new() } else { format!("?{query}") };
470 +
    Ok(format!("{scheme}://{login}{host}:{port}{query}"))
471 +
}
472 +
473 +
#[cfg(test)]
474 +
mod panel_tests {
475 +
    use super::*;
476 +
477 +
    fn asked(service: &str) -> Asked {
478 +
        Asked { service: service.into(), ..Default::default() }
479 +
    }
480 +
481 +
    #[test]
482 +
    fn services_compose_their_smtp_urls_and_read_back() {
483 +
        let resend = compose(&Asked { secret: "re_test_123".into(), ..asked("resend") }, "").unwrap();
484 +
        assert_eq!(resend, "smtps://resend:[email protected]:465");
485 +
        let c = describe(&resend);
486 +
        assert_eq!((c.service.as_str(), c.has_secret), ("resend", true));
487 +
        // Left empty, the saved key stays.
488 +
        assert_eq!(compose(&asked("resend"), &resend).unwrap(), resend);
489 +
        assert!(compose(&asked("resend"), "").unwrap_err().contains("API key"));
490 +
        let ses =
491 +
            compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), secret: "p@ss/word".into(), ..asked("ses") }, "")
492 +
                .unwrap();
493 +
        assert_eq!(ses, "smtps://AKIAEXAMPLE:p%40ss%[email protected]:465");
494 +
        assert_eq!((describe(&ses).service.as_str(), describe(&ses).region.as_str()), ("ses", "eu-west-2"));
495 +
        // Another service's key does not carry over.
496 +
        assert!(compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), ..asked("ses") }, &resend).is_err());
497 +
        let mg =
498 +
            compose(&Asked { region: "eu".into(), user: "[email protected]".into(), secret: "s".into(), ..asked("mailgun") }, "")
499 +
                .unwrap();
500 +
        assert_eq!(mg, "smtps://postmaster%40mg.example.org:[email protected]:465");
501 +
        assert_eq!(describe(&mg).user, "[email protected]");
502 +
        let plain =
503 +
            compose(&Asked { host: "mail.example.org".into(), user: "hub".into(), secret: "x".into(), ..asked("smtp") }, "").unwrap();
504 +
        assert_eq!(plain, "smtp://hub:[email protected]:587?tls=required");
505 +
        assert_eq!(describe(&plain).security, "starttls");
506 +
        let relay = compose(&Asked { host: "localhost".into(), port: "25".into(), security: "none".into(), ..asked("smtp") }, "").unwrap();
507 +
        assert_eq!(relay, "smtp://localhost:25?tls=none");
508 +
        assert_eq!(describe("file:///tmp/mail").service, "url");
509 +
        assert_eq!(describe("").service, "off");
510 +
    }
76 511
}
modified crates/hub/src/notify/mod.rs +4 −2
…
73 unchanged lines
74 74
75 75
async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) {
76 76
    let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key);
77 -
    let body = format!("{actor}: {title}\n\n{}{url}\n\nStop these emails: {unsubscribe}\n", app.config.base_url());
78 -
    if let Err(e) = crate::mail::send(app, to.name(), &to.email, title, &body).await {
77 +
    let link = format!("{}{url}", app.config.base_url());
78 +
    let body = format!("{actor}: {title}\n\n{link}\n\nStop these emails: {unsubscribe}\n");
79 +
    let action = crate::mail::Action { label: "Open it", url: &link };
80 +
    if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, title, &body, Some(action), Some(&unsubscribe)).await {
79 81
        tracing::warn!("email to {}: {e}", to.email);
80 82
    }
81 83
}
…
11 unchanged lines
modified crates/hub/src/web/admin.rs +58 −5
…
26 unchanged lines
27 27
    mail_smtp: String,
28 28
    mail_from: String,
29 29
    mail_enabled: bool,
30 +
    /// The saved mail settings as the panel shows them (oxygenhub#181).
31 +
    mail: crate::mail::Choice,
30 32
    my_email: String,
31 33
    /// The audit log's next page: rows older than this id (#110).
32 34
    audit_older: Option<i64>,
…
57 unchanged lines
90 92
        audit,
91 93
        repos,
92 94
        notice,
95 +
        mail: crate::mail::describe(&mail.smtp),
93 96
        mail_smtp: crate::mail::masked(&mail.smtp),
94 97
        mail_from: mail.from.clone(),
95 98
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
…
2 unchanged lines
98 101
    }))
99 102
}
100 103
104 +
impl AdminPage {
105 +
    fn mail_services(&self) -> &'static [(&'static str, &'static str)] {
106 +
        crate::mail::SERVICES
107 +
    }
108 +
}
109 +
101 110
#[derive(Deserialize)]
102 111
pub struct MailForm {
103 112
    #[serde(rename = "_csrf")]
…
2 unchanged lines
106 115
    smtp: String,
107 116
    #[serde(default)]
108 117
    from: String,
118 +
    /// The panel's service (oxygenhub#181); absent, `smtp` is the URL as
119 +
    /// before.
120 +
    #[serde(default)]
121 +
    service: String,
122 +
    #[serde(default)]
123 +
    region: String,
124 +
    #[serde(default)]
125 +
    user: String,
126 +
    #[serde(default)]
127 +
    secret: String,
128 +
    #[serde(default)]
129 +
    host: String,
130 +
    #[serde(default)]
131 +
    port: String,
132 +
    #[serde(default)]
133 +
    security: String,
109 134
}
110 135
111 136
/// `POST /admin/mail`: the SMTP URL and From address (#101). The URL
…
4 unchanged lines
116 141
    let me = require_admin(&viewer, &shell)?;
117 142
    csrf(&viewer, &f.csrf_token, &shell)?;
118 143
    let current = crate::mail::settings(&app);
119 -
    let smtp = f.smtp.trim();
120 -
    let smtp = if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) { current.smtp.clone() } else { smtp.to_string() };
144 +
    // A service the panel knows: it makes the SMTP URL (oxygenhub#181).
145 +
    let smtp = match f.service.as_str() {
146 +
        "off" => String::new(),
147 +
        "" | "url" => {
148 +
            let smtp = f.smtp.trim();
149 +
            if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) {
150 +
                current.smtp.clone()
151 +
            } else {
152 +
                smtp.to_string()
153 +
            }
154 +
        }
155 +
        _ => {
156 +
            let asked = crate::mail::Asked {
157 +
                service: f.service.clone(),
158 +
                region: f.region.clone(),
159 +
                user: f.user.clone(),
160 +
                secret: f.secret.trim().to_string(),
161 +
                host: f.host.clone(),
162 +
                port: f.port.clone(),
163 +
                security: f.security.clone(),
164 +
            };
165 +
            match crate::mail::compose(&asked, &current.smtp) {
166 +
                Ok(url) => url,
167 +
                Err(why) => return admin_page(&app, shell, Some(why)).await,
168 +
            }
169 +
        }
170 +
    };
121 171
    let from = f.from.trim().to_string();
122 172
    if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) {
123 173
        return admin_page(
…
4 unchanged lines
128 178
        .await;
129 179
    }
130 180
    if !smtp.is_empty() && from.is_empty() {
131 -
        return admin_page(&app, shell, Some("A From address is needed: OxygenHub <hub@example.org>.".into())).await;
181 +
        return admin_page(&app, shell, Some("A From address is needed: OxygenHub <noreply@example.org>.".into())).await;
132 182
    }
133 183
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
134 184
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
…
15 unchanged lines
150 200
    if me.email.is_empty() {
151 201
        return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await;
152 202
    }
153 -
    let notice = match crate::mail::send(
203 +
    let base = app.config.base_url().to_string();
204 +
    let notice = match crate::mail::send_letter(
154 205
        &app,
155 206
        me.name(),
156 207
        &me.email,
157 208
        "Test message",
158 -
        "This is a test message from your hub's mail settings. If you can read it, mail works.",
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 +
        Some(crate::mail::Action { label: "Open the hub", url: &base }),
211 +
        None,
159 212
    )
160 213
    .await
161 214
    {
…
299 unchanged lines
modified crates/hub/src/web/recovery.rs +4 −2
…
60 unchanged lines
61 61
        account.username,
62 62
        app.config.site.name
63 63
    );
64 -
    crate::mail::send(app, account.name(), &account.email, "Confirm your email address", &body).await
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 66
}
66 67
67 68
/// `GET /verify/{secret}`.
…
53 unchanged lines
121 122
            // Sent in the background, so the answer takes as long either way.
122 123
            let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone());
123 124
            tokio::spawn(async move {
124 -
                if let Err(err) = crate::mail::send(&mailer, &name, &addr, "Reset your password", &body).await {
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 {
125 127
                    tracing::warn!("reset mail for {user}: {err}");
126 128
                }
127 129
            });
…
74 unchanged lines
modified crates/hub/tests/accounts.rs +21 −0
…
630 unchanged lines
631 631
    // Not for everyone.
632 632
    let r = post(app, "/admin/mail", None, &format!("_csrf={csrf}&smtp=&from=")).await;
633 633
    assert_ne!(r.status, StatusCode::OK);
634 +
635 +
    // The service panel (oxygenhub#181): Resend asks only for its key,
636 +
    // which is never shown again and is kept when left empty.
637 +
    let from = "from=OxygenHub+%3Cnoreply%40example.org%3E";
638 +
    let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=resend&secret=&{from}")).await;
639 +
    assert!(r.body.contains("Give the Resend API key"), "{}", r.body);
640 +
    let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=resend&secret=re_test_key_1&{from}")).await;
641 +
    assert!(r.body.contains("Mail settings saved") && !r.body.contains("re_test_key_1"), "{}", r.body);
642 +
    assert!(r.body.contains(r#"<option value="resend" selected>"#) && r.body.contains("saved: leave empty to keep it"), "{}", r.body);
643 +
    assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://resend:[email protected]:465");
644 +
    let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=resend&secret=&{from}")).await;
645 +
    assert!(r.body.contains("Mail settings saved"), "{}", r.body);
646 +
    assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://resend:[email protected]:465", "kept");
647 +
    // Amazon SES in London, then off.
648 +
    let r =
649 +
        post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=ses&region=eu-west-2&user=AKIAEXAMPLE&secret=s3cret&{from}"))
650 +
            .await;
651 +
    assert!(r.body.contains(r#"<option value="ses" selected>"#), "{}", r.body);
652 +
    assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://AKIAEXAMPLE:[email protected]:465");
653 +
    let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=off&{from}")).await;
654 +
    assert!(r.body.contains("Mail is off"), "{}", r.body);
634 655
}
635 656
636 657
/// A stand-in chat-completions endpoint: answers "OK from mock" and keeps
…
369 unchanged lines
modified crates/hub/tests/recovery.rs +3 −1
…
82 unchanged lines
83 83
}
84 84
85 85
/// Every message so far, oldest first, as text.
86 +
/// Each message written to `dir`, as a reader sees it: MIME's
87 +
/// quoted-printable soft breaks joined and `=3D` decoded (#182).
86 88
fn mail(dir: &Path) -> Vec<String> {
87 89
    let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).collect();
88 90
    names.sort();
89 -
    names.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect()
91 +
    names.iter().map(|p| std::fs::read_to_string(p).unwrap().replace("=\r\n", "").replace("=\n", "").replace("=3D", "=")).collect()
90 92
}
91 93
92 94
/// Mail is sent in the background for resets; wait for `n` messages.
…
129 unchanged lines
modified docs/spec.md +1 −1
…
173 unchanged lines
174 174
- **Reset by email.** `/login/reset` takes a username or a verified email. When it matches an active account with a verified email and the hub can send mail, a link valid for one hour goes to that address, at most one every two minutes per account. The page answers the same way whether or not anything matched, so it cannot be used to find accounts.
175 175
- **Reset by an administrator.** When the hub cannot send mail, or the account has no verified email, an administrator issues a reset link from the admin page (valid 24 hours), shown once to hand over by other means. The audit log records who issued it.
176 176
- **Using a reset link** sets a new password (the same rules as registration), rotates the session salt so every existing session ends, voids the account's other reset links, and records `password.reset` in the audit log. It does not turn off TOTP: a reset account with two-factor sign-in still needs its code. A banned account cannot be reset.
177 -
- **Mail.** `[email] smtp` is an `smtp://` or `smtps://` URL, or `file:///path` for development, which writes each message to that directory as a file instead of sending it. Administration has a Mail form (#101) whose SMTP URL and From address, once saved, override `[email]`; the password is shown masked, an empty URL turns mail off, and a button sends a test message to the administrator's own address.
177 +
- **Mail.** `[email] smtp` is an `smtp://` or `smtps://` URL, or `file:///path` for development, which writes each message to that directory as a file instead of sending it. Administration has a Mail panel (#101, #181) whose settings, once saved, override `[email]`: pick a service and give only what it needs (Resend: its API key; Amazon SES: the region and SES SMTP credentials; Mailgun: `us` or `eu` and the SMTP login; another SMTP server: server, port, security, user and password; or an SMTP URL, for anything else and `file://`), and the panel stores one standard SMTP URL, so every service is SMTP underneath and any provider works. Keys and passwords are never shown again; left empty, the saved one stays while the service, server and user do. An `smtp://` URL that says nothing of TLS requires STARTTLS (`?tls=opportunistic` or `?tls=none` say otherwise; a relay on this machine needs none). Off turns mail off, and a button sends a test message to the administrator's own address. Every message goes as plain text and as HTML in the site's look (#182): the Oxygen band with the mark, sent inline so it shows without loading anything, the subject as heading, the body with its links, the message's action as a button with its link beneath, and a footer of the site's links (Explore, Help, Notification settings, and Stop these emails on notifications).
178 178
179 179
### 5.2 Organisations and teams
180 180
…
536 unchanged lines
modified templates/admin.html +29 −6
…
69 unchanged lines
70 70
</section>
71 71
<section class="panel" id="mail">
72 72
  <h2>Mail</h2>
73 -
  <form class="card" method="post" action="/admin/mail">
73 +
  <form class="card mail-panel" method="post" action="/admin/mail">
74 74
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
75 -
    <label for="smtp">SMTP URL</label>
76 -
    <input type="text" id="smtp" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465">
77 -
    <label for="mailfrom">From</label>
78 -
    <input type="text" id="mailfrom" name="from" value="{{ mail_from }}" placeholder="OxygenHub &lt;[email protected]&gt;">
75 +
    <label for="mailservice">Service</label>
76 +
    <select id="mailservice" name="service" data-mail-service>{% for (v, label) in mail_services() %}<option value="{{ v }}"{% if mail.service == *v %} selected{% endif %}>{{ label }}</option>{% endfor %}</select>
77 +
    <p class="small mute" data-for="resend">Resend sends through <code>smtp.resend.com</code>; the login is <code>resend</code> and the password your API key. Verify your domain in Resend first.</p>
78 +
    <p class="small mute" data-for="ses">Amazon SES: the region your identity is verified in, and the SMTP credentials from SES › SMTP settings (not an AWS access key).</p>
79 +
    <p class="small mute" data-for="mailgun">Mailgun: <code>us</code> or <code>eu</code>, and the domain's SMTP login and password.</p>
80 +
    <div data-for="ses mailgun"><label for="mailregion">Region</label><input type="text" id="mailregion" name="region" value="{{ mail.region }}" placeholder="eu-west-2 for SES; us or eu for Mailgun"></div>
81 +
    <div data-for="smtp"><label for="mailhost">Server</label><input type="text" id="mailhost" name="host" value="{{ mail.host }}" placeholder="mail.example.org">
82 +
      <label for="mailport">Port</label><input type="text" id="mailport" name="port" value="{{ mail.port }}" placeholder="587">
83 +
      <label for="mailsecurity">Security</label><select id="mailsecurity" name="security"><option value="starttls"{% if mail.security == "starttls" %} selected{% endif %}>STARTTLS (usually port 587)</option><option value="tls"{% if mail.security == "tls" %} selected{% endif %}>TLS from the start (usually port 465)</option><option value="none"{% if mail.security == "none" %} selected{% endif %}>None (a relay on this machine)</option></select></div>
84 +
    <div data-for="ses mailgun smtp"><label for="mailuser">User name</label><input type="text" id="mailuser" name="user" value="{% if mail.service != "resend" %}{{ mail.user }}{% endif %}" autocomplete="off"></div>
85 +
    <div data-for="resend ses mailgun smtp"><label for="mailsecret">{% if mail.service == "resend" %}API key{% else %}Password or API key{% endif %}</label><input type="password" id="mailsecret" name="secret" value="" autocomplete="new-password" placeholder="{% if mail.has_secret %}saved: leave empty to keep it{% else %}{% endif %}"></div>
86 +
    <div data-for="url"><label for="smtp">SMTP URL</label><input type="text" id="smtp" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465"></div>
87 +
    <div data-for="resend ses mailgun smtp url"><label for="mailfrom">From</label><input type="text" id="mailfrom" name="from" value="{{ mail_from }}" placeholder="OxygenHub &lt;[email protected]&gt;"></div>
79 88
    <button type="submit">SAVE</button>
80 89
  </form>
90 +
  <script>
91 +
  /* Mail panel (oxygenhub#181): show the fields the chosen service asks. */
92 +
  (function () {
93 +
    var sel = document.querySelector("[data-mail-service]");
94 +
    if (!sel) return;
95 +
    var show = function () {
96 +
      document.querySelectorAll(".mail-panel [data-for]").forEach(function (el) {
97 +
        el.hidden = el.getAttribute("data-for").split(" ").indexOf(sel.value) < 0;
98 +
      });
99 +
    };
100 +
    sel.addEventListener("change", show);
101 +
    show();
102 +
  })();
103 +
  </script>
81 104
  <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form>
82 -
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p>
105 +
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} Off turns it off. Passwords and keys stay on the server and are never shown again. Every service is SMTP underneath, so any provider works: <code>smtps://USER:PASSWORD@HOST:465</code> for TLS from the start, or <code>smtp://USER:PASSWORD@HOST:587</code>, which the hub sends with STARTTLS required (add <code>?tls=opportunistic</code> or <code>?tls=none</code> to say otherwise; a relay on this machine needs none).</p>
83 106
</section>
84 107
{% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %}
85 108
{% call se::panel("/admin", secrets, shell.csrf, "Every repository's jobs may use these, unless an organisation or repository sets the same name.") %}
…
11 unchanged lines