OXYGENHUB
◆ v0.5.1 oxygenhub / crates / hub / src / mail.rs
22.9 KiB · Rust · last changed Mail: a service panel, and email that looks like OxygenHub 22 days ago source blame history raw
34beb1a7 Andy McCall ↶ 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
3556d08e Andy McCall ↶ 5
use crate::config::Email;
34beb1a7 Andy McCall ↶ 6
use crate::Shared;
7
3556d08e Andy McCall ↶ 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
34beb1a7 Andy McCall ↶ 45
/// Whether the hub can send mail at all.
46
pub fn enabled(app: &Shared) -> bool {
3556d08e Andy McCall ↶ 47
    let cfg = settings(app);
34beb1a7 Andy McCall ↶ 48
    !cfg.smtp.is_empty() && !cfg.from.is_empty()
49
}
50
cd46e56b Andy McCall ↶ 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.
34beb1a7 Andy McCall ↶ 60
pub async fn send(app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str) -> Result<(), String> {
cd46e56b Andy McCall ↶ 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> {
34beb1a7 Andy McCall ↶ 77
    if !enabled(app) {
78
        return Err("this hub does not send mail".into());
79
    }
3556d08e Andy McCall ↶ 80
    let cfg = settings(app);
cd46e56b Andy McCall ↶ 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}");
34beb1a7 Andy McCall ↶ 84
    use lettre::message::header::ContentType;
cd46e56b Andy McCall ↶ 85
    use lettre::message::{Attachment, MultiPart, SinglePart};
34beb1a7 Andy McCall ↶ 86
    use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
87
    let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?;
88
    let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?;
cd46e56b Andy McCall ↶ 89
    let png = ContentType::parse("image/png").map_err(|e| e.to_string())?;
34beb1a7 Andy McCall ↶ 90
    let message = Message::builder()
91
        .from(from)
92
        .to(to)
93
        .subject(subject)
cd46e56b Andy McCall ↶ 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
        )
34beb1a7 Andy McCall ↶ 101
        .map_err(|e| e.to_string())?;
cd46e56b Andy McCall ↶ 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();
34beb1a7 Andy McCall ↶ 109
    transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}"))
cd46e56b Andy McCall ↶ 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
    }
34beb1a7 Andy McCall ↶ 511
}