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