OXYGENHUB

Coverage / crates/hub/src/mail.rs 88.7%

315 of 355 lines run, 53 of 93 functions · run #524 on main · run missed

1
//! Outgoing mail (hub spec §5.1.1, §10): SMTP through lettre, or, for
2
//! development and tests, `file:///dir`, which writes each message to that
3
//! directory instead of sending it.
4
5
use crate::config::Email;
6
use crate::Shared;
7
8
pub const SETTING_SMTP: &str = "mail.smtp";
9
pub const SETTING_FROM: &str = "mail.from";
10
11
/// The mail settings in force: what the admin page saved (#101), else
12
/// hub.toml's `[email]`.
13201×
pub async fn load(db: &crate::db::Pool, from_config: &Email) -> Email {
14201×
    let mut mail = from_config.clone();
15201×
    if let Ok(Some(s)) = crate::db::accounts::setting(db, SETTING_SMTP).await {
160×
        mail.smtp = s;
170×
        mail.from = crate::db::accounts::setting(db, SETTING_FROM).await.ok().flatten().unwrap_or_default();
18201×
    }
19201×
    mail
20201×
}
21
22304×
pub fn settings(app: &Shared) -> Email {
23304×
    app.mail.read().unwrap_or_else(|p| p.into_inner()).clone()
24304×
}
25
26
/// Save the settings and use them from now on. An empty URL turns mail
27
/// off.
288×
pub async fn save(app: &Shared, smtp: &str, from: &str) -> sqlx::Result<()> {
298×
    crate::db::accounts::set_setting(&app.db, SETTING_SMTP, smtp).await?;
308×
    crate::db::accounts::set_setting(&app.db, SETTING_FROM, from).await?;
318×
    *app.mail.write().unwrap_or_else(|p| p.into_inner()) = Email { smtp: smtp.to_string(), from: from.to_string() };
328×
    Ok(())
338×
}
34
35
/// The SMTP URL with its password hidden, for the page.
36100×
pub fn masked(smtp: &str) -> String {
37100×
    let Some((scheme, rest)) = smtp.split_once("://") else { return smtp.to_string() };
3814×
    let Some((userinfo, host)) = rest.rsplit_once('@') else { return smtp.to_string() };
399×
    match userinfo.split_once(':') {
409×
        Some((user, _)) => format!("{scheme}://{user}:***@{host}"),
410×
        None => smtp.to_string(),
42
    }
43100×
}
44
45
/// Whether the hub can send mail at all.
46188×
pub fn enabled(app: &Shared) -> bool {
47188×
    let cfg = settings(app);
48188×
    !cfg.smtp.is_empty() && !cfg.from.is_empty()
49188×
}
50
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.
600×
pub async fn send(app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str) -> Result<(), String> {
610×
    send_letter(app, to_name, to_addr, subject, body, None, None).await
620×
}
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.
686×
pub async fn send_letter(
696×
    app: &Shared,
706×
    to_name: &str,
716×
    to_addr: &str,
726×
    subject: &str,
736×
    body: &str,
746×
    action: Option<Action<'_>>,
756×
    unsubscribe: Option<&str>,
766×
) -> Result<(), String> {
776×
    if !enabled(app) {
780×
        return Err("this hub does not send mail".into());
796×
    }
806×
    let sent = deliver(app, to_name, to_addr, subject, body, action, unsubscribe).await;
816×
    let counter = if sent.is_ok() { &crate::metrics::MAIL_SENT } else { &crate::metrics::MAIL_FAILED };
826×
    counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
836×
    sent
846×
}
85
866×
async fn deliver(
876×
    app: &Shared,
886×
    to_name: &str,
896×
    to_addr: &str,
906×
    subject: &str,
916×
    body: &str,
926×
    action: Option<Action<'_>>,
936×
    unsubscribe: Option<&str>,
946×
) -> Result<(), String> {
956×
    let cfg = settings(app);
966×
    let site = sender_name(&cfg.from).unwrap_or_else(|| app.config.site.name.clone());
976×
    let html = html(&site, app.config.base_url(), subject, body, action, unsubscribe);
986×
    let subject = format!("[{site}] {subject}");
99
    use lettre::message::header::ContentType;
100
    use lettre::message::{Attachment, MultiPart, SinglePart};
101
    use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
1026×
    let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?;
1036×
    let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?;
1046×
    let png = ContentType::parse("image/png").map_err(|e| e.to_string())?;
1056×
    let message = Message::builder()
1066×
        .from(from)
1076×
        .to(to)
1086×
        .subject(subject)
1096×
        .multipart(
1106×
            MultiPart::alternative().singlepart(SinglePart::plain(body.to_string())).multipart(
1116×
                MultiPart::related()
1126×
                    .singlepart(SinglePart::html(html))
1136×
                    .singlepart(Attachment::new_inline("logo".to_string()).body(LOGO.to_vec(), png)),
114
            ),
115
        )
1166×
        .map_err(|e| e.to_string())?;
1176×
    if let Some(dir) = cfg.smtp.strip_prefix("file://") {
1186×
        let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4));
1196×
        return tokio::fs::write(std::path::Path::new(dir).join(name), message.formatted())
1206×
            .await
1216×
            .map_err(|e| format!("write mail to {dir}: {e}"));
1220×
    }
1230×
    let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&secured(&cfg.smtp)).map_err(|e| format!("[email] smtp: {e}"))?.build();
1240×
    transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}"))
1256×
}
126
127
/// The name mail goes by: the From address's display name
128
/// (`OxygenHub <noreply@…>`), in the subject's tag and the footer, so a
129
/// site's mail can read `[OxygenHub]` while the site keeps its own name
130
/// (which also names its apt repository, where a change makes every
131
/// `apt update` ask). `None` without one.
13210×
fn sender_name(from: &str) -> Option<String> {
13310×
    let (name, _) = from.split_once('<')?;
1349×
    let name = name.trim().trim_matches('"').trim();
1359×
    (!name.is_empty()).then(|| name.to_string())
13610×
}
137
138
/// The Oxygen mark, sent inline with each message (`cid:logo`), so it
139
/// shows without loading anything from the web.
140
const LOGO: &[u8] = include_bytes!("../../../assets/icons/oxygenhub-mail.png");
141
14249×
fn esc(s: &str) -> String {
14349×
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
14449×
}
145
146
/// A paragraph's text as HTML: escaped, its web addresses linked.
14713×
fn linked(text: &str) -> String {
14813×
    let re = regex::Regex::new(r"https?://[^\s<>]+[^\s<>.,;:!?)]").expect("a valid pattern");
14913×
    let mut out = String::new();
15013×
    let mut last = 0;
15113×
    for m in re.find_iter(text) {
1520×
        out.push_str(&esc(&text[last..m.start()]));
1530×
        let u = esc(m.as_str());
1540×
        out.push_str(&format!(r#"<a href="{u}" style="color:#0969da;text-decoration:underline;word-break:break-all;">{u}</a>"#));
1550×
        last = m.end();
1560×
    }
15713×
    out.push_str(&esc(&text[last..]));
15813×
    out.replace('\n', "<br>")
15913×
}
160
161
/// The HTML part of a message (oxygenhub#182): a card on a light ground,
162
/// the Oxygen band with the mark and wordmark, the subject as its heading,
163
/// the body's paragraphs, `action` as a button with its link beneath for
164
/// clients that hide buttons, and a footer of the site's links. Tables
165
/// and inline styles, as mail clients need; dark-mode clients get their
166
/// own colours.
1677×
pub fn html(site: &str, base: &str, subject: &str, body: &str, action: Option<Action<'_>>, unsubscribe: Option<&str>) -> String {
1687×
    let base = base.trim_end_matches('/');
1697×
    let host = base.split("://").nth(1).unwrap_or(base);
1707×
    let button = action
1717×
        .map(|a| {
1727×
            let (label, url) = (esc(a.label), esc(a.url));
1737×
            format!(
174
                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);">
175
<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>
176
<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>"#
177
            )
1787×
        })
1797×
        .unwrap_or_default();
180
    // The action's address becomes its button, where it stood in the text;
181
    // the footer says how to stop.
1827×
    let mut placed = false;
1837×
    let mut paras = String::new();
18419×
    for p in body.split("\n\n").map(str::trim).filter(|p| !p.is_empty()) {
18519×
        if action.is_some_and(|a| p == a.url) {
1866×
            if !placed {
1876×
                paras.push_str(&button);
1886×
                placed = true;
1896×
            }
1906×
            continue;
19113×
        }
19213×
        if unsubscribe.is_some_and(|u| p.contains(u)) {
1930×
            continue;
19413×
        }
19513×
        paras.push_str(&format!(
19613×
            r#"<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#1f2328;" class="ox-text">{}</p>"#,
19713×
            linked(p)
19813×
        ));
199
    }
2007×
    let button = if placed { String::new() } else { button };
2017×
    let stop =
2027×
        unsubscribe.map(|u| format!(r#" &middot; <a href="{}" style="color:#6e7781;">Stop these emails</a>"#, esc(u))).unwrap_or_default();
2037×
    let (site_e, subject_e, host_e) = (esc(site), esc(subject), esc(host));
2047×
    format!(
205
        r#"<!doctype html>
206
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
207
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
208
<title>{subject_e}</title>
209
<style>
210
@media (prefers-color-scheme: dark) {{
211
  .ox-ground {{ background-color:#0b1020 !important; }}
212
  .ox-card {{ background-color:#111a2e !important; border-color:#22304d !important; }}
213
  .ox-text, .ox-head {{ color:#e6edf3 !important; }}
214
  .ox-mute {{ color:#8b98ad !important; }}
215
}}
216
@media (max-width:600px) {{ .ox-pad {{ padding-left:20px !important; padding-right:20px !important; }} }}
217
</style></head>
218
<body style="margin:0;padding:0;background-color:#eef2f8;" class="ox-ground">
219
<div style="display:none;max-height:0;overflow:hidden;">{subject_e}</div>
220
<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;">
221
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:720px;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">
222
<tr><td style="background-color:#070c18;padding:20px 32px;" class="ox-pad">
223
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
224
<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>
225
<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>
226
</tr></table></td></tr>
227
<tr><td style="height:4px;line-height:4px;font-size:0;background-color:#0072FF;background-image:linear-gradient(90deg,#00F0FF,#0072FF);">&nbsp;</td></tr>
228
<tr><td style="padding:32px 32px 12px;" class="ox-pad">
229
<h1 style="margin:0 0 18px;font-size:21px;line-height:1.3;font-weight:650;color:#0b1220;" class="ox-head">{subject_e}</h1>
230
{paras}{button}
231
</td></tr>
232
<tr><td style="padding:18px 32px 26px;border-top:1px solid #e6ebf2;" class="ox-pad">
233
<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>
234
<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>
235
</td></tr>
236
</table></td></tr></table>
237
</body></html>
238
"#
239
    )
2407×
}
241
242
#[cfg(test)]
243
mod html_tests {
244
    use super::*;
245
246
    #[test]
2471×
    fn a_message_is_a_card_with_its_button_and_the_site_s_links() {
2481×
        let body =
2491×
            "Confirm that [email protected] is yours:\n\nhttps://hub.example/verify/abc\n\nThe link works for 48 hours. <b>Not</b> HTML.";
2501×
        let h = html(
2511×
            "OxygenHub",
2521×
            "https://hub.example/",
2531×
            "Confirm your email address",
2541×
            body,
2551×
            Some(Action { label: "Confirm my email address", url: "https://hub.example/verify/abc" }),
2561×
            Some("https://hub.example/settings/notifications?unsubscribe=k"),
257
        );
2581×
        assert!(h.contains(r#"<img src="cid:logo""#), "the mark, inline");
2591×
        assert!(h.contains(">Confirm my email address</a>") && h.contains(r#"href="https://hub.example/verify/abc""#));
2601×
        assert_eq!(
2611×
            h.matches("https://hub.example/verify/abc</a>").count(),
262
            1,
263
            "the address once, under the button, not as a paragraph too"
264
        );
2651×
        assert!(h.contains("&lt;b&gt;Not&lt;/b&gt; HTML."), "the body is escaped");
2661×
        assert!(h.contains(r#"href="https://hub.example/explore""#) && h.contains("Stop these emails"));
2671×
        assert!(h.contains("at hub.example,"));
2681×
    }
269
}
270
271
/// An SMTP URL as it is sent with: `smtp://` that says nothing of TLS
272
/// requires STARTTLS, so a password never crosses in the clear (lettre's
273
/// own default is none), except to a relay on this machine. `smtps://`
274
/// (TLS from the start, port 465) and an explicit `?tls=` stay as written.
2756×
pub fn secured(url: &str) -> String {
2766×
    let Some(rest) = url.strip_prefix("smtp://") else { return url.to_string() };
2774×
    if url.contains("tls=") {
2781×
        return url.to_string();
2793×
    }
2803×
    let host = rest.rsplit('@').next().unwrap_or(rest);
2813×
    let host = host.split(['/', '?']).next().unwrap_or(host);
2823×
    let host = host.rsplit_once(':').map_or(host, |(h, _)| h).trim_matches(['[', ']']);
2833×
    if matches!(host, "localhost" | "127.0.0.1" | "::1") {
2842×
        return url.to_string();
2851×
    }
2861×
    format!("{url}{}tls=required", if url.contains('?') { "&" } else { "?" })
2876×
}
288
289
#[cfg(test)]
290
mod secured_tests {
291
    use super::secured;
292
293
    #[test]
2941×
    fn smtp_urls_require_starttls_unless_they_say_otherwise() {
2951×
        assert_eq!(secured("smtp://resend:[email protected]:587"), "smtp://resend:[email protected]:587?tls=required");
2961×
        assert_eq!(secured("smtps://resend:[email protected]:465"), "smtps://resend:[email protected]:465");
2971×
        assert_eq!(secured("smtp://relay.example:25?tls=opportunistic"), "smtp://relay.example:25?tls=opportunistic");
2981×
        assert_eq!(secured("smtp://localhost:25"), "smtp://localhost:25", "a relay on this machine");
2991×
        assert_eq!(secured("smtp://u:[email protected]:1025"), "smtp://u:[email protected]:1025");
3001×
        assert_eq!(secured("file:///tmp/mail"), "file:///tmp/mail");
3011×
    }
302
}
303
304
/// The services the Mail panel knows (oxygenhub#181): each is SMTP
305
/// underneath, so the hub stores and sends one standard SMTP URL; the
306
/// panel only fills in the server and the login a service expects.
307
pub const SERVICES: &[(&str, &str)] = &[
308
    ("off", "Off"),
309
    ("resend", "Resend"),
310
    ("ses", "Amazon SES"),
311
    ("mailgun", "Mailgun"),
312
    ("smtp", "Another SMTP server"),
313
    ("url", "An SMTP URL (advanced)"),
314
];
315
316
/// A stored SMTP URL read back for the panel: which service, and its parts
317
/// but the secret.
318
#[derive(Clone, Debug, Default, PartialEq, Eq)]
319
pub struct Choice {
320
    pub service: String,
321
    /// SES's AWS region, or Mailgun's `us` or `eu`.
322
    pub region: String,
323
    pub user: String,
324
    pub host: String,
325
    pub port: String,
326
    /// `tls` (from the start), `starttls` or `none`.
327
    pub security: String,
328
    /// Whether a password or key is saved.
329
    pub has_secret: bool,
330
}
331
332
/// Percent-encoding for a URL's user and password: all but unreserved
333
/// characters (lettre decodes them).
33414×
fn enc(s: &str) -> String {
33514×
    s.bytes()
336115×
        .map(|b| match b {
337112×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
3383×
            _ => format!("%{b:02X}"),
339115×
        })
34014×
        .collect()
34114×
}
342
34313×
fn dec(s: &str) -> String {
34413×
    let b = s.as_bytes();
34513×
    let mut out = Vec::new();
34613×
    let mut i = 0;
347113×
    while i < b.len() {
348100×
        if b[i] == b'%' && i + 2 < b.len() {
3491×
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
3501×
                out.push(v);
3511×
                i += 3;
3521×
                continue;
3530×
            }
35499×
        }
35599×
        out.push(b[i]);
35699×
        i += 1;
357
    }
35813×
    String::from_utf8_lossy(&out).into_owned()
35913×
}
360
361
/// The parts of an SMTP URL: scheme, user, password (as stored, encoded),
362
/// host, port, query.
36322×
fn parts(url: &str) -> Option<(&str, &str, &str, &str, &str, &str)> {
36422×
    let (scheme, rest) = url.split_once("://")?;
36519×
    let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
36619×
    let (userinfo, hostport) = rest.rsplit_once('@').unwrap_or(("", rest));
36719×
    let (user, pass) = userinfo.split_once(':').unwrap_or((userinfo, ""));
36819×
    let hostport = hostport.trim_end_matches('/');
36919×
    let (host, port) = hostport.rsplit_once(':').unwrap_or((hostport, ""));
37019×
    Some((scheme, user, pass, host, port, query))
37122×
}
372
373
/// How the panel shows a stored SMTP URL.
374103×
pub fn describe(url: &str) -> Choice {
375103×
    if url.is_empty() {
37687×
        return Choice { service: "off".into(), ..Default::default() };
37716×
    }
37816×
    let Some((scheme, user, pass, host, port, query)) = parts(url).filter(|p| p.0 == "smtp" || p.0 == "smtps") else {
3795×
        return Choice { service: "url".into(), ..Default::default() };
380
    };
38111×
    let mut c = Choice {
38211×
        user: dec(user),
38311×
        host: host.to_string(),
38411×
        port: port.to_string(),
38511×
        has_secret: !pass.is_empty(),
38611×
        security: if scheme == "smtps" {
38710×
            "tls".into()
3881×
        } else if query.contains("tls=none") {
3890×
            "none".into()
390
        } else {
3911×
            "starttls".into()
392
        },
39311×
        ..Default::default()
394
    };
39511×
    c.service = if host == "smtp.resend.com" {
3963×
        "resend".into()
3978×
    } else if let Some(region) = host.strip_prefix("email-smtp.").and_then(|h| h.strip_suffix(".amazonaws.com")) {
3983×
        c.region = region.to_string();
3993×
        "ses".into()
4005×
    } else if host == "smtp.mailgun.org" || host == "smtp.eu.mailgun.org" {
4011×
        c.region = if host.contains(".eu.") { "eu".into() } else { "us".into() };
4021×
        "mailgun".into()
4034×
    } else if query.contains("tls=opportunistic") {
4040×
        "url".into()
405
    } else {
4064×
        "smtp".into()
407
    };
40811×
    c
409103×
}
410
411
/// What the panel asks for a service.
412
#[derive(Clone, Debug, Default)]
413
pub struct Asked {
414
    pub service: String,
415
    pub region: String,
416
    pub user: String,
417
    /// A new password or key; empty keeps the saved one where it still
418
    /// fits (the same service, server and user).
419
    pub secret: String,
420
    pub host: String,
421
    pub port: String,
422
    pub security: String,
423
}
424
425
/// The SMTP URL for what the panel asked, given the URL saved now; or what
426
/// is missing, said plainly.
42712×
pub fn compose(a: &Asked, current: &str) -> Result<String, String> {
42812×
    let region = a.region.trim();
42912×
    let (scheme, user, host, port, query): (&str, String, String, String, &str) = match a.service.as_str() {
43012×
        "resend" => ("smtps", "resend".into(), "smtp.resend.com".into(), "465".into(), ""),
4316×
        "ses" => {
43227×
            if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
4330×
                return Err("Give the SES region, such as eu-west-2.".into());
4343×
            }
4353×
            if a.user.trim().is_empty() {
4360×
                return Err("Give the SES SMTP user name (from SES › SMTP settings; not your AWS access key).".into());
4373×
            }
4383×
            ("smtps", a.user.trim().to_string(), format!("email-smtp.{region}.amazonaws.com"), "465".into(), "")
439
        }
4403×
        "mailgun" => {
4411×
            let host = match region {
4421×
                "eu" => "smtp.eu.mailgun.org",
4430×
                "us" | "" => "smtp.mailgun.org",
4440×
                _ => return Err("Mailgun's region is us or eu.".into()),
445
            };
4461×
            if a.user.trim().is_empty() {
4470×
                return Err("Give the Mailgun SMTP login, such as [email protected].".into());
4481×
            }
4491×
            ("smtps", a.user.trim().to_string(), host.into(), "465".into(), "")
450
        }
4512×
        "smtp" => {
4522×
            let host = a.host.trim();
4532×
            if host.is_empty() || host.contains(['/', '@', ' ']) {
4540×
                return Err("Give the SMTP server's host name.".into());
4552×
            }
4562×
            let port = a.port.trim();
4572×
            if !port.is_empty() && port.parse::<u16>().is_err() {
4580×
                return Err("The port is a number, such as 465 or 587.".into());
4592×
            }
4602×
            match a.security.as_str() {
4612×
                "tls" => ("smtps", a.user.trim().to_string(), host.into(), if port.is_empty() { "465".into() } else { port.into() }, ""),
4622×
                "none" => {
4631×
                    ("smtp", a.user.trim().to_string(), host.into(), if port.is_empty() { "25".into() } else { port.into() }, "tls=none")
464
                }
465
                _ => (
4661×
                    "smtp",
4671×
                    a.user.trim().to_string(),
4681×
                    host.into(),
4691×
                    if port.is_empty() { "587".into() } else { port.into() },
4701×
                    "tls=required",
471
                ),
472
            }
473
        }
4740×
        other => return Err(format!("{other:?} is not a mail service this panel knows")),
475
    };
47612×
    let secret = if !a.secret.is_empty() {
4776×
        enc(&a.secret)
478
    } else {
4796×
        match parts(current) {
4803×
            Some((_, u, p, h, _, _)) if !p.is_empty() && h == host && dec(u) == user => p.to_string(),
4814×
            _ if a.service == "smtp" && user.is_empty() => String::new(),
482
            _ => {
4833×
                return Err(match a.service.as_str() {
4843×
                    "resend" => "Give the Resend API key (re_…).".into(),
4851×
                    _ => "Give the password.".into(),
486
                })
487
            }
488
        }
489
    };
4909×
    let login = match (user.is_empty(), secret.is_empty()) {
4911×
        (true, _) => String::new(),
4920×
        (false, true) => format!("{}@", enc(&user)),
4938×
        (false, false) => format!("{}:{secret}@", enc(&user)),
494
    };
4959×
    let query = if query.is_empty() { String::new() } else { format!("?{query}") };
4969×
    Ok(format!("{scheme}://{login}{host}:{port}{query}"))
49712×
}
498
499
#[cfg(test)]
500
mod panel_tests {
501
    use super::*;
502
5038×
    fn asked(service: &str) -> Asked {
5048×
        Asked { service: service.into(), ..Default::default() }
5058×
    }
506
507
    #[test]
5081×
    fn services_compose_their_smtp_urls_and_read_back() {
5091×
        let resend = compose(&Asked { secret: "re_test_123".into(), ..asked("resend") }, "").unwrap();
5101×
        assert_eq!(resend, "smtps://resend:[email protected]:465");
5111×
        let c = describe(&resend);
5121×
        assert_eq!((c.service.as_str(), c.has_secret), ("resend", true));
513
        // Left empty, the saved key stays.
5141×
        assert_eq!(compose(&asked("resend"), &resend).unwrap(), resend);
5151×
        assert!(compose(&asked("resend"), "").unwrap_err().contains("API key"));
5161×
        let ses =
5171×
            compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), secret: "p@ss/word".into(), ..asked("ses") }, "")
5181×
                .unwrap();
5191×
        assert_eq!(ses, "smtps://AKIAEXAMPLE:p%40ss%[email protected]:465");
5201×
        assert_eq!((describe(&ses).service.as_str(), describe(&ses).region.as_str()), ("ses", "eu-west-2"));
521
        // Another service's key does not carry over.
5221×
        assert!(compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), ..asked("ses") }, &resend).is_err());
5231×
        let mg =
5241×
            compose(&Asked { region: "eu".into(), user: "[email protected]".into(), secret: "s".into(), ..asked("mailgun") }, "")
5251×
                .unwrap();
5261×
        assert_eq!(mg, "smtps://postmaster%40mg.example.org:[email protected]:465");
5271×
        assert_eq!(describe(&mg).user, "[email protected]");
5281×
        let plain =
5291×
            compose(&Asked { host: "mail.example.org".into(), user: "hub".into(), secret: "x".into(), ..asked("smtp") }, "").unwrap();
5301×
        assert_eq!(plain, "smtp://hub:[email protected]:587?tls=required");
5311×
        assert_eq!(describe(&plain).security, "starttls");
5321×
        let relay = compose(&Asked { host: "localhost".into(), port: "25".into(), security: "none".into(), ..asked("smtp") }, "").unwrap();
5331×
        assert_eq!(relay, "smtp://localhost:25?tls=none");
5341×
        assert_eq!(describe("file:///tmp/mail").service, "url");
5351×
        assert_eq!(describe("").service, "off");
5361×
    }
537
}
538
539
#[cfg(test)]
540
mod tests {
541
    use super::*;
542
543
    #[test]
5441×
    fn mail_goes_by_the_from_address_s_name() {
5451×
        assert_eq!(sender_name("OxygenHub <[email protected]>").as_deref(), Some("OxygenHub"));
5461×
        assert_eq!(sender_name("\"Oxygen Hub\" <[email protected]>").as_deref(), Some("Oxygen Hub"));
5471×
        assert_eq!(sender_name("[email protected]"), None);
5481×
        assert_eq!(sender_name(" <[email protected]>"), None);
5491×
    }
550
}