OXYGENHUB
◆ main oxygenhub / crates / hub / src / mail.rs
24.4 KiB · Rust · last changed Mail reads [OxygenHub], in a wider message (closes #334) 9 days ago source blame history raw
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]`.
13
pub async fn load(db: &crate::db::Pool, from_config: &Email) -> Email {
14
    let mut mail = from_config.clone();
15
    if let Ok(Some(s)) = crate::db::accounts::setting(db, SETTING_SMTP).await {
16
        mail.smtp = s;
17
        mail.from = crate::db::accounts::setting(db, SETTING_FROM).await.ok().flatten().unwrap_or_default();
18
    }
19
    mail
20
}
21
22
pub fn settings(app: &Shared) -> Email {
23
    app.mail.read().unwrap_or_else(|p| p.into_inner()).clone()
24
}
25
26
/// Save the settings and use them from now on. An empty URL turns mail
27
/// off.
28
pub async fn save(app: &Shared, smtp: &str, from: &str) -> sqlx::Result<()> {
29
    crate::db::accounts::set_setting(&app.db, SETTING_SMTP, smtp).await?;
30
    crate::db::accounts::set_setting(&app.db, SETTING_FROM, from).await?;
31
    *app.mail.write().unwrap_or_else(|p| p.into_inner()) = Email { smtp: smtp.to_string(), from: from.to_string() };
32
    Ok(())
33
}
34
35
/// The SMTP URL with its password hidden, for the page.
36
pub fn masked(smtp: &str) -> String {
37
    let Some((scheme, rest)) = smtp.split_once("://") else { return smtp.to_string() };
38
    let Some((userinfo, host)) = rest.rsplit_once('@') else { return smtp.to_string() };
39
    match userinfo.split_once(':') {
40
        Some((user, _)) => format!("{scheme}://{user}:***@{host}"),
41
        None => smtp.to_string(),
42
    }
43
}
44
45
/// Whether the hub can send mail at all.
46
pub fn enabled(app: &Shared) -> bool {
47
    let cfg = settings(app);
48
    !cfg.smtp.is_empty() && !cfg.from.is_empty()
49
}
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.
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> {
77
    if !enabled(app) {
78
        return Err("this hub does not send mail".into());
79
    }
80
    let sent = deliver(app, to_name, to_addr, subject, body, action, unsubscribe).await;
81
    let counter = if sent.is_ok() { &crate::metrics::MAIL_SENT } else { &crate::metrics::MAIL_FAILED };
82
    counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
83
    sent
84
}
85
86
async fn deliver(
87
    app: &Shared,
88
    to_name: &str,
89
    to_addr: &str,
90
    subject: &str,
91
    body: &str,
92
    action: Option<Action<'_>>,
93
    unsubscribe: Option<&str>,
94
) -> Result<(), String> {
95
    let cfg = settings(app);
96
    let site = sender_name(&cfg.from).unwrap_or_else(|| app.config.site.name.clone());
97
    let html = html(&site, app.config.base_url(), subject, body, action, unsubscribe);
98
    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};
102
    let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?;
103
    let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?;
104
    let png = ContentType::parse("image/png").map_err(|e| e.to_string())?;
105
    let message = Message::builder()
106
        .from(from)
107
        .to(to)
108
        .subject(subject)
109
        .multipart(
110
            MultiPart::alternative().singlepart(SinglePart::plain(body.to_string())).multipart(
111
                MultiPart::related()
112
                    .singlepart(SinglePart::html(html))
113
                    .singlepart(Attachment::new_inline("logo".to_string()).body(LOGO.to_vec(), png)),
114
            ),
115
        )
116
        .map_err(|e| e.to_string())?;
117
    if let Some(dir) = cfg.smtp.strip_prefix("file://") {
118
        let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4));
119
        return tokio::fs::write(std::path::Path::new(dir).join(name), message.formatted())
120
            .await
121
            .map_err(|e| format!("write mail to {dir}: {e}"));
122
    }
123
    let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&secured(&cfg.smtp)).map_err(|e| format!("[email] smtp: {e}"))?.build();
124
    transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}"))
125
}
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.
132
fn sender_name(from: &str) -> Option<String> {
133
    let (name, _) = from.split_once('<')?;
134
    let name = name.trim().trim_matches('"').trim();
135
    (!name.is_empty()).then(|| name.to_string())
136
}
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
142
fn esc(s: &str) -> String {
143
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
144
}
145
146
/// A paragraph's text as HTML: escaped, its web addresses linked.
147
fn linked(text: &str) -> String {
148
    let re = regex::Regex::new(r"https?://[^\s<>]+[^\s<>.,;:!?)]").expect("a valid pattern");
149
    let mut out = String::new();
150
    let mut last = 0;
151
    for m in re.find_iter(text) {
152
        out.push_str(&esc(&text[last..m.start()]));
153
        let u = esc(m.as_str());
154
        out.push_str(&format!(r#"<a href="{u}" style="color:#0969da;text-decoration:underline;word-break:break-all;">{u}</a>"#));
155
        last = m.end();
156
    }
157
    out.push_str(&esc(&text[last..]));
158
    out.replace('\n', "<br>")
159
}
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.
167
pub fn html(site: &str, base: &str, subject: &str, body: &str, action: Option<Action<'_>>, unsubscribe: Option<&str>) -> String {
168
    let base = base.trim_end_matches('/');
169
    let host = base.split("://").nth(1).unwrap_or(base);
170
    let button = action
171
        .map(|a| {
172
            let (label, url) = (esc(a.label), esc(a.url));
173
            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
            )
178
        })
179
        .unwrap_or_default();
180
    // The action's address becomes its button, where it stood in the text;
181
    // the footer says how to stop.
182
    let mut placed = false;
183
    let mut paras = String::new();
184
    for p in body.split("\n\n").map(str::trim).filter(|p| !p.is_empty()) {
185
        if action.is_some_and(|a| p == a.url) {
186
            if !placed {
187
                paras.push_str(&button);
188
                placed = true;
189
            }
190
            continue;
191
        }
192
        if unsubscribe.is_some_and(|u| p.contains(u)) {
193
            continue;
194
        }
195
        paras.push_str(&format!(
196
            r#"<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#1f2328;" class="ox-text">{}</p>"#,
197
            linked(p)
198
        ));
199
    }
200
    let button = if placed { String::new() } else { button };
201
    let stop =
202
        unsubscribe.map(|u| format!(r#" &middot; <a href="{}" style="color:#6e7781;">Stop these emails</a>"#, esc(u))).unwrap_or_default();
203
    let (site_e, subject_e, host_e) = (esc(site), esc(subject), esc(host));
204
    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
    )
240
}
241
242
#[cfg(test)]
243
mod html_tests {
244
    use super::*;
245
246
    #[test]
247
    fn a_message_is_a_card_with_its_button_and_the_site_s_links() {
248
        let body =
249
            "Confirm that [email protected] is yours:\n\nhttps://hub.example/verify/abc\n\nThe link works for 48 hours. <b>Not</b> HTML.";
250
        let h = html(
251
            "OxygenHub",
252
            "https://hub.example/",
253
            "Confirm your email address",
254
            body,
255
            Some(Action { label: "Confirm my email address", url: "https://hub.example/verify/abc" }),
256
            Some("https://hub.example/settings/notifications?unsubscribe=k"),
257
        );
258
        assert!(h.contains(r#"<img src="cid:logo""#), "the mark, inline");
259
        assert!(h.contains(">Confirm my email address</a>") && h.contains(r#"href="https://hub.example/verify/abc""#));
260
        assert_eq!(
261
            h.matches("https://hub.example/verify/abc</a>").count(),
262
            1,
263
            "the address once, under the button, not as a paragraph too"
264
        );
265
        assert!(h.contains("&lt;b&gt;Not&lt;/b&gt; HTML."), "the body is escaped");
266
        assert!(h.contains(r#"href="https://hub.example/explore""#) && h.contains("Stop these emails"));
267
        assert!(h.contains("at hub.example,"));
268
    }
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.
275
pub fn secured(url: &str) -> String {
276
    let Some(rest) = url.strip_prefix("smtp://") else { return url.to_string() };
277
    if url.contains("tls=") {
278
        return url.to_string();
279
    }
280
    let host = rest.rsplit('@').next().unwrap_or(rest);
281
    let host = host.split(['/', '?']).next().unwrap_or(host);
282
    let host = host.rsplit_once(':').map_or(host, |(h, _)| h).trim_matches(['[', ']']);
283
    if matches!(host, "localhost" | "127.0.0.1" | "::1") {
284
        return url.to_string();
285
    }
286
    format!("{url}{}tls=required", if url.contains('?') { "&" } else { "?" })
287
}
288
289
#[cfg(test)]
290
mod secured_tests {
291
    use super::secured;
292
293
    #[test]
294
    fn smtp_urls_require_starttls_unless_they_say_otherwise() {
295
        assert_eq!(secured("smtp://resend:[email protected]:587"), "smtp://resend:[email protected]:587?tls=required");
296
        assert_eq!(secured("smtps://resend:[email protected]:465"), "smtps://resend:[email protected]:465");
297
        assert_eq!(secured("smtp://relay.example:25?tls=opportunistic"), "smtp://relay.example:25?tls=opportunistic");
298
        assert_eq!(secured("smtp://localhost:25"), "smtp://localhost:25", "a relay on this machine");
299
        assert_eq!(secured("smtp://u:[email protected]:1025"), "smtp://u:[email protected]:1025");
300
        assert_eq!(secured("file:///tmp/mail"), "file:///tmp/mail");
301
    }
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).
334
fn enc(s: &str) -> String {
335
    s.bytes()
336
        .map(|b| match b {
337
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
338
            _ => format!("%{b:02X}"),
339
        })
340
        .collect()
341
}
342
343
fn dec(s: &str) -> String {
344
    let b = s.as_bytes();
345
    let mut out = Vec::new();
346
    let mut i = 0;
347
    while i < b.len() {
348
        if b[i] == b'%' && i + 2 < b.len() {
349
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
350
                out.push(v);
351
                i += 3;
352
                continue;
353
            }
354
        }
355
        out.push(b[i]);
356
        i += 1;
357
    }
358
    String::from_utf8_lossy(&out).into_owned()
359
}
360
361
/// The parts of an SMTP URL: scheme, user, password (as stored, encoded),
362
/// host, port, query.
363
fn parts(url: &str) -> Option<(&str, &str, &str, &str, &str, &str)> {
364
    let (scheme, rest) = url.split_once("://")?;
365
    let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
366
    let (userinfo, hostport) = rest.rsplit_once('@').unwrap_or(("", rest));
367
    let (user, pass) = userinfo.split_once(':').unwrap_or((userinfo, ""));
368
    let hostport = hostport.trim_end_matches('/');
369
    let (host, port) = hostport.rsplit_once(':').unwrap_or((hostport, ""));
370
    Some((scheme, user, pass, host, port, query))
371
}
372
373
/// How the panel shows a stored SMTP URL.
374
pub fn describe(url: &str) -> Choice {
375
    if url.is_empty() {
376
        return Choice { service: "off".into(), ..Default::default() };
377
    }
378
    let Some((scheme, user, pass, host, port, query)) = parts(url).filter(|p| p.0 == "smtp" || p.0 == "smtps") else {
379
        return Choice { service: "url".into(), ..Default::default() };
380
    };
381
    let mut c = Choice {
382
        user: dec(user),
383
        host: host.to_string(),
384
        port: port.to_string(),
385
        has_secret: !pass.is_empty(),
386
        security: if scheme == "smtps" {
387
            "tls".into()
388
        } else if query.contains("tls=none") {
389
            "none".into()
390
        } else {
391
            "starttls".into()
392
        },
393
        ..Default::default()
394
    };
395
    c.service = if host == "smtp.resend.com" {
396
        "resend".into()
397
    } else if let Some(region) = host.strip_prefix("email-smtp.").and_then(|h| h.strip_suffix(".amazonaws.com")) {
398
        c.region = region.to_string();
399
        "ses".into()
400
    } else if host == "smtp.mailgun.org" || host == "smtp.eu.mailgun.org" {
401
        c.region = if host.contains(".eu.") { "eu".into() } else { "us".into() };
402
        "mailgun".into()
403
    } else if query.contains("tls=opportunistic") {
404
        "url".into()
405
    } else {
406
        "smtp".into()
407
    };
408
    c
409
}
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.
427
pub fn compose(a: &Asked, current: &str) -> Result<String, String> {
428
    let region = a.region.trim();
429
    let (scheme, user, host, port, query): (&str, String, String, String, &str) = match a.service.as_str() {
430
        "resend" => ("smtps", "resend".into(), "smtp.resend.com".into(), "465".into(), ""),
431
        "ses" => {
432
            if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
433
                return Err("Give the SES region, such as eu-west-2.".into());
434
            }
435
            if a.user.trim().is_empty() {
436
                return Err("Give the SES SMTP user name (from SES › SMTP settings; not your AWS access key).".into());
437
            }
438
            ("smtps", a.user.trim().to_string(), format!("email-smtp.{region}.amazonaws.com"), "465".into(), "")
439
        }
440
        "mailgun" => {
441
            let host = match region {
442
                "eu" => "smtp.eu.mailgun.org",
443
                "us" | "" => "smtp.mailgun.org",
444
                _ => return Err("Mailgun's region is us or eu.".into()),
445
            };
446
            if a.user.trim().is_empty() {
447
                return Err("Give the Mailgun SMTP login, such as [email protected].".into());
448
            }
449
            ("smtps", a.user.trim().to_string(), host.into(), "465".into(), "")
450
        }
451
        "smtp" => {
452
            let host = a.host.trim();
453
            if host.is_empty() || host.contains(['/', '@', ' ']) {
454
                return Err("Give the SMTP server's host name.".into());
455
            }
456
            let port = a.port.trim();
457
            if !port.is_empty() && port.parse::<u16>().is_err() {
458
                return Err("The port is a number, such as 465 or 587.".into());
459
            }
460
            match a.security.as_str() {
461
                "tls" => ("smtps", a.user.trim().to_string(), host.into(), if port.is_empty() { "465".into() } else { port.into() }, ""),
462
                "none" => {
463
                    ("smtp", a.user.trim().to_string(), host.into(), if port.is_empty() { "25".into() } else { port.into() }, "tls=none")
464
                }
465
                _ => (
466
                    "smtp",
467
                    a.user.trim().to_string(),
468
                    host.into(),
469
                    if port.is_empty() { "587".into() } else { port.into() },
470
                    "tls=required",
471
                ),
472
            }
473
        }
474
        other => return Err(format!("{other:?} is not a mail service this panel knows")),
475
    };
476
    let secret = if !a.secret.is_empty() {
477
        enc(&a.secret)
478
    } else {
479
        match parts(current) {
480
            Some((_, u, p, h, _, _)) if !p.is_empty() && h == host && dec(u) == user => p.to_string(),
481
            _ if a.service == "smtp" && user.is_empty() => String::new(),
482
            _ => {
483
                return Err(match a.service.as_str() {
484
                    "resend" => "Give the Resend API key (re_…).".into(),
485
                    _ => "Give the password.".into(),
486
                })
487
            }
488
        }
489
    };
490
    let login = match (user.is_empty(), secret.is_empty()) {
491
        (true, _) => String::new(),
492
        (false, true) => format!("{}@", enc(&user)),
493
        (false, false) => format!("{}:{secret}@", enc(&user)),
494
    };
495
    let query = if query.is_empty() { String::new() } else { format!("?{query}") };
496
    Ok(format!("{scheme}://{login}{host}:{port}{query}"))
497
}
498
499
#[cfg(test)]
500
mod panel_tests {
501
    use super::*;
502
503
    fn asked(service: &str) -> Asked {
504
        Asked { service: service.into(), ..Default::default() }
505
    }
506
507
    #[test]
508
    fn services_compose_their_smtp_urls_and_read_back() {
509
        let resend = compose(&Asked { secret: "re_test_123".into(), ..asked("resend") }, "").unwrap();
510
        assert_eq!(resend, "smtps://resend:[email protected]:465");
511
        let c = describe(&resend);
512
        assert_eq!((c.service.as_str(), c.has_secret), ("resend", true));
513
        // Left empty, the saved key stays.
514
        assert_eq!(compose(&asked("resend"), &resend).unwrap(), resend);
515
        assert!(compose(&asked("resend"), "").unwrap_err().contains("API key"));
516
        let ses =
517
            compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), secret: "p@ss/word".into(), ..asked("ses") }, "")
518
                .unwrap();
519
        assert_eq!(ses, "smtps://AKIAEXAMPLE:p%40ss%[email protected]:465");
520
        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.
522
        assert!(compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), ..asked("ses") }, &resend).is_err());
523
        let mg =
524
            compose(&Asked { region: "eu".into(), user: "[email protected]".into(), secret: "s".into(), ..asked("mailgun") }, "")
525
                .unwrap();
526
        assert_eq!(mg, "smtps://postmaster%40mg.example.org:[email protected]:465");
527
        assert_eq!(describe(&mg).user, "[email protected]");
528
        let plain =
529
            compose(&Asked { host: "mail.example.org".into(), user: "hub".into(), secret: "x".into(), ..asked("smtp") }, "").unwrap();
530
        assert_eq!(plain, "smtp://hub:[email protected]:587?tls=required");
531
        assert_eq!(describe(&plain).security, "starttls");
532
        let relay = compose(&Asked { host: "localhost".into(), port: "25".into(), security: "none".into(), ..asked("smtp") }, "").unwrap();
533
        assert_eq!(relay, "smtp://localhost:25?tls=none");
534
        assert_eq!(describe("file:///tmp/mail").service, "url");
535
        assert_eq!(describe("").service, "off");
536
    }
537
}
538
539
#[cfg(test)]
540
mod tests {
541
    use super::*;
542
543
    #[test]
544
    fn mail_goes_by_the_from_address_s_name() {
545
        assert_eq!(sender_name("OxygenHub <[email protected]>").as_deref(), Some("OxygenHub"));
546
        assert_eq!(sender_name("\"Oxygen Hub\" <[email protected]>").as_deref(), Some("Oxygen Hub"));
547
        assert_eq!(sender_name("[email protected]"), None);
548
        assert_eq!(sender_name(" <[email protected]>"), None);
549
    }
550
}