| 1 |
|
| 2 |
|
| 3 |
|
| 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 |
|
| 12 |
|
| 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 |
|
| 27 |
|
| 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 |
|
| 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 |
|
| 46 |
pub fn enabled(app: &Shared) -> bool { |
| 47 |
let cfg = settings(app); |
| 48 |
!cfg.smtp.is_empty() && !cfg.from.is_empty() |
| 49 |
} |
| 50 |
|
| 51 |
|
| 52 |
#[derive(Clone, Copy, Debug)] |
| 53 |
pub struct Action<'a> { |
| 54 |
pub label: &'a str, |
| 55 |
pub url: &'a str, |
| 56 |
} |
| 57 |
|
| 58 |
|
| 59 |
|
| 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 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 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 |
|
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 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 |
|
| 139 |
|
| 140 |
const LOGO: &[u8] = include_bytes!("../../../assets/icons/oxygenhub-mail.png"); |
| 141 |
|
| 142 |
fn esc(s: &str) -> String { |
| 143 |
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
| 144 |
} |
| 145 |
|
| 146 |
|
| 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 |
|
| 162 |
|
| 163 |
|
| 164 |
|
| 165 |
|
| 166 |
|
| 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 |
|
| 181 |
|
| 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#" · <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);"> </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> · <a href="{base}/explore" style="color:#6e7781;">Explore</a> · <a href="{base}/help" style="color:#6e7781;">Help</a> · <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("<b>Not</b> 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 |
|
| 272 |
|
| 273 |
|
| 274 |
|
| 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 |
|
| 305 |
|
| 306 |
|
| 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 |
|
| 317 |
|
| 318 |
#[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 319 |
pub struct Choice { |
| 320 |
pub service: String, |
| 321 |
|
| 322 |
pub region: String, |
| 323 |
pub user: String, |
| 324 |
pub host: String, |
| 325 |
pub port: String, |
| 326 |
|
| 327 |
pub security: String, |
| 328 |
|
| 329 |
pub has_secret: bool, |
| 330 |
} |
| 331 |
|
| 332 |
|
| 333 |
|
| 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 |
|
| 362 |
|
| 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 |
|
| 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 |
|
| 412 |
#[derive(Clone, Debug, Default)] |
| 413 |
pub struct Asked { |
| 414 |
pub service: String, |
| 415 |
pub region: String, |
| 416 |
pub user: String, |
| 417 |
|
| 418 |
|
| 419 |
pub secret: String, |
| 420 |
pub host: String, |
| 421 |
pub port: String, |
| 422 |
pub security: String, |
| 423 |
} |
| 424 |
|
| 425 |
|
| 426 |
|
| 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 |
|
| 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 |
|
| 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 |
} |