Mail: a service panel, and email that looks like OxygenHub cd46e56b1709
Andy (2026-10-14) wanted mail set up by picking a service, generic for any installation, and messages that look good. Administration › Mail is a panel (#181): pick Resend (its API key), Amazon SES (region and SES SMTP credentials), Mailgun (us or eu and the SMTP login), another SMTP server (server, port, security, user, password), an SMTP URL for anything else (file:// for development), or Off. Every service is SMTP underneath: the panel stores one standard SMTP URL, so any provider works. Keys are never shown again; left empty the saved one stays while service, server and user do. An smtp:// URL that says nothing of TLS now requires STARTTLS (lettre's default was none), except to a relay on this machine. Every message goes as plain text and HTML (#182): the Oxygen band with the mark sent inline (cid:logo, shows without loading images), the subject as heading, the body with links, the message's action as a button where its link stood (link beneath for clients hiding buttons), and a footer of Explore, Help, Notification settings and Stop these emails. Email-safe tables and inline styles with dark-mode colours. Verification, reset, notification and test messages each get a button. Tested: services compose and read back their URLs (keys kept, never carried to another service); the panel saves Resend and SES and turns off; the HTML card has the mark, the button once, escaped text and the site's links; STARTTLS by default; the recovery tests read the MIME messages. closes #181, closes #182
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 10:53 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
cd46e56b1709a01b405ac538ae4092a3fb12b36d6f6847690892fa0f7956aeed- Depends on
- 18e381ea88c2 19bb31ff1f5c 34beb1a7e376 3556d08e17f9 93be9ce4653e d23464c0ac60 e8e7158bbc73
- Depended on by
- 262ec9c51a5c 737de0fac722 a7aa76ac52d7 2bbdc0268c23 b91e2d81b3f1
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 35 atoms, 1 tree operation, 9 files
Browse the tree at this change
added assets/icons/oxygenhub-mail.png +0 −0
No line changes.
modified crates/hub/src/mail.rs +445 −10
| … | 47 unchanged lines |
||
| 48 | 48 | !cfg.smtp.is_empty() && !cfg.from.is_empty() |
|
| 49 | 49 | } |
|
| 50 | 50 | ||
| 51 | - | /// Send a plain-text message. The subject gets the site's name in brackets. |
|
| 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. |
|
| 52 | 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> { |
|
| 53 | 77 | if !enabled(app) { |
|
| 54 | 78 | return Err("this hub does not send mail".into()); |
|
| 55 | 79 | } |
|
| 56 | 80 | let cfg = settings(app); |
|
| 57 | - | let subject = format!("[{}] {subject}", app.config.site.name); |
|
| 58 | - | if let Some(dir) = cfg.smtp.strip_prefix("file://") { |
|
| 59 | - | let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4)); |
|
| 60 | - | let text = format!("From: {}\nTo: {to_name} <{to_addr}>\nSubject: {subject}\n\n{body}", cfg.from); |
|
| 61 | - | return tokio::fs::write(std::path::Path::new(dir).join(name), text).await.map_err(|e| format!("write mail to {dir}: {e}")); |
|
| 62 | - | } |
|
| 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}"); |
|
| 63 | 84 | use lettre::message::header::ContentType; |
|
| 85 | + | use lettre::message::{Attachment, MultiPart, SinglePart}; |
|
| 64 | 86 | use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; |
|
| 65 | 87 | let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?; |
|
| 66 | 88 | let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?; |
|
| 89 | + | let png = ContentType::parse("image/png").map_err(|e| e.to_string())?; |
|
| 67 | 90 | let message = Message::builder() |
|
| 68 | 91 | .from(from) |
|
| 69 | 92 | .to(to) |
|
| 70 | 93 | .subject(subject) |
|
| 71 | - | .header(ContentType::TEXT_PLAIN) |
|
| 72 | - | .body(body.to_string()) |
|
| 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 | + | ) |
|
| 73 | 101 | .map_err(|e| e.to_string())?; |
|
| 74 | - | let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&cfg.smtp).map_err(|e| format!("[email] smtp: {e}"))?.build(); |
|
| 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(); |
|
| 75 | 109 | transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}")) |
|
| 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
|
| 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#" · <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);"> </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> · <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> |
|
| 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("<b>Not</b> 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 | + | } |
|
| 76 | 511 | } |
modified crates/hub/src/notify/mod.rs +4 −2
| … | 73 unchanged lines |
||
| 74 | 74 | ||
| 75 | 75 | async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) { |
|
| 76 | 76 | let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key); |
|
| 77 | - | let body = format!("{actor}: {title}\n\n{}{url}\n\nStop these emails: {unsubscribe}\n", app.config.base_url()); |
|
| 78 | - | if let Err(e) = crate::mail::send(app, to.name(), &to.email, title, &body).await { |
|
| 77 | + | let link = format!("{}{url}", app.config.base_url()); |
|
| 78 | + | let body = format!("{actor}: {title}\n\n{link}\n\nStop these emails: {unsubscribe}\n"); |
|
| 79 | + | let action = crate::mail::Action { label: "Open it", url: &link }; |
|
| 80 | + | if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, title, &body, Some(action), Some(&unsubscribe)).await { |
|
| 79 | 81 | tracing::warn!("email to {}: {e}", to.email); |
|
| 80 | 82 | } |
|
| 81 | 83 | } |
|
| … | 11 unchanged lines |
modified crates/hub/src/web/admin.rs +58 −5
| … | 26 unchanged lines |
||
| 27 | 27 | mail_smtp: String, |
|
| 28 | 28 | mail_from: String, |
|
| 29 | 29 | mail_enabled: bool, |
|
| 30 | + | /// The saved mail settings as the panel shows them (oxygenhub#181). |
|
| 31 | + | mail: crate::mail::Choice, |
|
| 30 | 32 | my_email: String, |
|
| 31 | 33 | /// The audit log's next page: rows older than this id (#110). |
|
| 32 | 34 | audit_older: Option<i64>, |
|
| … | 57 unchanged lines |
||
| 90 | 92 | audit, |
|
| 91 | 93 | repos, |
|
| 92 | 94 | notice, |
|
| 95 | + | mail: crate::mail::describe(&mail.smtp), |
|
| 93 | 96 | mail_smtp: crate::mail::masked(&mail.smtp), |
|
| 94 | 97 | mail_from: mail.from.clone(), |
|
| 95 | 98 | mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(), |
|
| … | 2 unchanged lines |
||
| 98 | 101 | })) |
|
| 99 | 102 | } |
|
| 100 | 103 | ||
| 104 | + | impl AdminPage { |
|
| 105 | + | fn mail_services(&self) -> &'static [(&'static str, &'static str)] { |
|
| 106 | + | crate::mail::SERVICES |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | ||
| 101 | 110 | #[derive(Deserialize)] |
|
| 102 | 111 | pub struct MailForm { |
|
| 103 | 112 | #[serde(rename = "_csrf")] |
|
| … | 2 unchanged lines |
||
| 106 | 115 | smtp: String, |
|
| 107 | 116 | #[serde(default)] |
|
| 108 | 117 | from: String, |
|
| 118 | + | /// The panel's service (oxygenhub#181); absent, `smtp` is the URL as |
|
| 119 | + | /// before. |
|
| 120 | + | #[serde(default)] |
|
| 121 | + | service: String, |
|
| 122 | + | #[serde(default)] |
|
| 123 | + | region: String, |
|
| 124 | + | #[serde(default)] |
|
| 125 | + | user: String, |
|
| 126 | + | #[serde(default)] |
|
| 127 | + | secret: String, |
|
| 128 | + | #[serde(default)] |
|
| 129 | + | host: String, |
|
| 130 | + | #[serde(default)] |
|
| 131 | + | port: String, |
|
| 132 | + | #[serde(default)] |
|
| 133 | + | security: String, |
|
| 109 | 134 | } |
|
| 110 | 135 | ||
| 111 | 136 | /// `POST /admin/mail`: the SMTP URL and From address (#101). The URL |
|
| … | 4 unchanged lines |
||
| 116 | 141 | let me = require_admin(&viewer, &shell)?; |
|
| 117 | 142 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 118 | 143 | let current = crate::mail::settings(&app); |
|
| 119 | - | let smtp = f.smtp.trim(); |
|
| 120 | - | let smtp = if !current.smtp.is_empty() && smtp == crate::mail::masked(¤t.smtp) { current.smtp.clone() } else { smtp.to_string() }; |
|
| 144 | + | // A service the panel knows: it makes the SMTP URL (oxygenhub#181). |
|
| 145 | + | let smtp = match f.service.as_str() { |
|
| 146 | + | "off" => String::new(), |
|
| 147 | + | "" | "url" => { |
|
| 148 | + | let smtp = f.smtp.trim(); |
|
| 149 | + | if !current.smtp.is_empty() && smtp == crate::mail::masked(¤t.smtp) { |
|
| 150 | + | current.smtp.clone() |
|
| 151 | + | } else { |
|
| 152 | + | smtp.to_string() |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | _ => { |
|
| 156 | + | let asked = crate::mail::Asked { |
|
| 157 | + | service: f.service.clone(), |
|
| 158 | + | region: f.region.clone(), |
|
| 159 | + | user: f.user.clone(), |
|
| 160 | + | secret: f.secret.trim().to_string(), |
|
| 161 | + | host: f.host.clone(), |
|
| 162 | + | port: f.port.clone(), |
|
| 163 | + | security: f.security.clone(), |
|
| 164 | + | }; |
|
| 165 | + | match crate::mail::compose(&asked, ¤t.smtp) { |
|
| 166 | + | Ok(url) => url, |
|
| 167 | + | Err(why) => return admin_page(&app, shell, Some(why)).await, |
|
| 168 | + | } |
|
| 169 | + | } |
|
| 170 | + | }; |
|
| 121 | 171 | let from = f.from.trim().to_string(); |
|
| 122 | 172 | if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) { |
|
| 123 | 173 | return admin_page( |
|
| … | 4 unchanged lines |
||
| 128 | 178 | .await; |
|
| 129 | 179 | } |
|
| 130 | 180 | if !smtp.is_empty() && from.is_empty() { |
|
| 131 | - | return admin_page(&app, shell, Some("A From address is needed: OxygenHub <hub@example.org>.".into())).await; |
|
| 181 | + | return admin_page(&app, shell, Some("A From address is needed: OxygenHub <noreply@example.org>.".into())).await; |
|
| 132 | 182 | } |
|
| 133 | 183 | crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 134 | 184 | accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok(); |
|
| … | 15 unchanged lines |
||
| 150 | 200 | if me.email.is_empty() { |
|
| 151 | 201 | return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await; |
|
| 152 | 202 | } |
|
| 153 | - | let notice = match crate::mail::send( |
|
| 203 | + | let base = app.config.base_url().to_string(); |
|
| 204 | + | let notice = match crate::mail::send_letter( |
|
| 154 | 205 | &app, |
|
| 155 | 206 | me.name(), |
|
| 156 | 207 | &me.email, |
|
| 157 | 208 | "Test message", |
|
| 158 | - | "This is a test message from your hub's mail settings. If you can read it, mail works.", |
|
| 209 | + | "This is a test message from your hub's mail settings. If you can read it, mail works: verification links, password resets and notifications will reach people.", |
|
| 210 | + | Some(crate::mail::Action { label: "Open the hub", url: &base }), |
|
| 211 | + | None, |
|
| 159 | 212 | ) |
|
| 160 | 213 | .await |
|
| 161 | 214 | { |
|
| … | 299 unchanged lines |
modified crates/hub/src/web/recovery.rs +4 −2
| … | 60 unchanged lines |
||
| 61 | 61 | account.username, |
|
| 62 | 62 | app.config.site.name |
|
| 63 | 63 | ); |
|
| 64 | - | crate::mail::send(app, account.name(), &account.email, "Confirm your email address", &body).await |
|
| 64 | + | let action = crate::mail::Action { label: "Confirm my email address", url: &url }; |
|
| 65 | + | crate::mail::send_letter(app, account.name(), &account.email, "Confirm your email address", &body, Some(action), None).await |
|
| 65 | 66 | } |
|
| 66 | 67 | ||
| 67 | 68 | /// `GET /verify/{secret}`. |
|
| … | 53 unchanged lines |
||
| 121 | 122 | // Sent in the background, so the answer takes as long either way. |
|
| 122 | 123 | let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone()); |
|
| 123 | 124 | tokio::spawn(async move { |
|
| 124 | - | if let Err(err) = crate::mail::send(&mailer, &name, &addr, "Reset your password", &body).await { |
|
| 125 | + | let action = crate::mail::Action { label: "Choose a new password", url: &url }; |
|
| 126 | + | if let Err(err) = crate::mail::send_letter(&mailer, &name, &addr, "Reset your password", &body, Some(action), None).await { |
|
| 125 | 127 | tracing::warn!("reset mail for {user}: {err}"); |
|
| 126 | 128 | } |
|
| 127 | 129 | }); |
|
| … | 74 unchanged lines |
modified crates/hub/tests/accounts.rs +21 −0
| … | 630 unchanged lines |
||
| 631 | 631 | // Not for everyone. |
|
| 632 | 632 | let r = post(app, "/admin/mail", None, &format!("_csrf={csrf}&smtp=&from=")).await; |
|
| 633 | 633 | assert_ne!(r.status, StatusCode::OK); |
|
| 634 | + | ||
| 635 | + | // The service panel (oxygenhub#181): Resend asks only for its key, |
|
| 636 | + | // which is never shown again and is kept when left empty. |
|
| 637 | + | let from = "from=OxygenHub+%3Cnoreply%40example.org%3E"; |
|
| 638 | + | let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=resend&secret=&{from}")).await; |
|
| 639 | + | assert!(r.body.contains("Give the Resend API key"), "{}", r.body); |
|
| 640 | + | let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=resend&secret=re_test_key_1&{from}")).await; |
|
| 641 | + | assert!(r.body.contains("Mail settings saved") && !r.body.contains("re_test_key_1"), "{}", r.body); |
|
| 642 | + | assert!(r.body.contains(r#"<option value="resend" selected>"#) && r.body.contains("saved: leave empty to keep it"), "{}", r.body); |
|
| 643 | + | assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://resend:[email protected]:465"); |
|
| 644 | + | let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=resend&secret=&{from}")).await; |
|
| 645 | + | assert!(r.body.contains("Mail settings saved"), "{}", r.body); |
|
| 646 | + | assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://resend:[email protected]:465", "kept"); |
|
| 647 | + | // Amazon SES in London, then off. |
|
| 648 | + | let r = |
|
| 649 | + | post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=ses®ion=eu-west-2&user=AKIAEXAMPLE&secret=s3cret&{from}")) |
|
| 650 | + | .await; |
|
| 651 | + | assert!(r.body.contains(r#"<option value="ses" selected>"#), "{}", r.body); |
|
| 652 | + | assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://AKIAEXAMPLE:[email protected]:465"); |
|
| 653 | + | let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&service=off&{from}")).await; |
|
| 654 | + | assert!(r.body.contains("Mail is off"), "{}", r.body); |
|
| 634 | 655 | } |
|
| 635 | 656 | ||
| 636 | 657 | /// A stand-in chat-completions endpoint: answers "OK from mock" and keeps |
|
| … | 369 unchanged lines |
modified crates/hub/tests/recovery.rs +3 −1
| … | 82 unchanged lines |
||
| 83 | 83 | } |
|
| 84 | 84 | ||
| 85 | 85 | /// Every message so far, oldest first, as text. |
|
| 86 | + | /// Each message written to `dir`, as a reader sees it: MIME's |
|
| 87 | + | /// quoted-printable soft breaks joined and `=3D` decoded (#182). |
|
| 86 | 88 | fn mail(dir: &Path) -> Vec<String> { |
|
| 87 | 89 | let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).collect(); |
|
| 88 | 90 | names.sort(); |
|
| 89 | - | names.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect() |
|
| 91 | + | names.iter().map(|p| std::fs::read_to_string(p).unwrap().replace("=\r\n", "").replace("=\n", "").replace("=3D", "=")).collect() |
|
| 90 | 92 | } |
|
| 91 | 93 | ||
| 92 | 94 | /// Mail is sent in the background for resets; wait for `n` messages. |
|
| … | 129 unchanged lines |
modified docs/spec.md +1 −1
| … | 173 unchanged lines |
||
| 174 | 174 | - **Reset by email.** `/login/reset` takes a username or a verified email. When it matches an active account with a verified email and the hub can send mail, a link valid for one hour goes to that address, at most one every two minutes per account. The page answers the same way whether or not anything matched, so it cannot be used to find accounts. |
|
| 175 | 175 | - **Reset by an administrator.** When the hub cannot send mail, or the account has no verified email, an administrator issues a reset link from the admin page (valid 24 hours), shown once to hand over by other means. The audit log records who issued it. |
|
| 176 | 176 | - **Using a reset link** sets a new password (the same rules as registration), rotates the session salt so every existing session ends, voids the account's other reset links, and records `password.reset` in the audit log. It does not turn off TOTP: a reset account with two-factor sign-in still needs its code. A banned account cannot be reset. |
|
| 177 | - | - **Mail.** `[email] smtp` is an `smtp://` or `smtps://` URL, or `file:///path` for development, which writes each message to that directory as a file instead of sending it. Administration has a Mail form (#101) whose SMTP URL and From address, once saved, override `[email]`; the password is shown masked, an empty URL turns mail off, and a button sends a test message to the administrator's own address. |
|
| 177 | + | - **Mail.** `[email] smtp` is an `smtp://` or `smtps://` URL, or `file:///path` for development, which writes each message to that directory as a file instead of sending it. Administration has a Mail panel (#101, #181) whose settings, once saved, override `[email]`: pick a service and give only what it needs (Resend: its API key; Amazon SES: the region and SES SMTP credentials; Mailgun: `us` or `eu` and the SMTP login; another SMTP server: server, port, security, user and password; or an SMTP URL, for anything else and `file://`), and the panel stores one standard SMTP URL, so every service is SMTP underneath and any provider works. Keys and passwords are never shown again; left empty, the saved one stays while the service, server and user do. An `smtp://` URL that says nothing of TLS requires STARTTLS (`?tls=opportunistic` or `?tls=none` say otherwise; a relay on this machine needs none). Off turns mail off, and a button sends a test message to the administrator's own address. Every message goes as plain text and as HTML in the site's look (#182): the Oxygen band with the mark, sent inline so it shows without loading anything, the subject as heading, the body with its links, the message's action as a button with its link beneath, and a footer of the site's links (Explore, Help, Notification settings, and Stop these emails on notifications). |
|
| 178 | 178 | ||
| 179 | 179 | ### 5.2 Organisations and teams |
|
| 180 | 180 | ||
| … | 536 unchanged lines |
modified templates/admin.html +29 −6
| … | 69 unchanged lines |
||
| 70 | 70 | </section> |
|
| 71 | 71 | <section class="panel" id="mail"> |
|
| 72 | 72 | <h2>Mail</h2> |
|
| 73 | - | <form class="card" method="post" action="/admin/mail"> |
|
| 73 | + | <form class="card mail-panel" method="post" action="/admin/mail"> |
|
| 74 | 74 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 75 | - | <label for="smtp">SMTP URL</label> |
|
| 76 | - | <input type="text" id="smtp" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465"> |
|
| 77 | - | <label for="mailfrom">From</label> |
|
| 78 | - | <input type="text" id="mailfrom" name="from" value="{{ mail_from }}" placeholder="OxygenHub <[email protected]>"> |
|
| 75 | + | <label for="mailservice">Service</label> |
|
| 76 | + | <select id="mailservice" name="service" data-mail-service>{% for (v, label) in mail_services() %}<option value="{{ v }}"{% if mail.service == *v %} selected{% endif %}>{{ label }}</option>{% endfor %}</select> |
|
| 77 | + | <p class="small mute" data-for="resend">Resend sends through <code>smtp.resend.com</code>; the login is <code>resend</code> and the password your API key. Verify your domain in Resend first.</p> |
|
| 78 | + | <p class="small mute" data-for="ses">Amazon SES: the region your identity is verified in, and the SMTP credentials from SES › SMTP settings (not an AWS access key).</p> |
|
| 79 | + | <p class="small mute" data-for="mailgun">Mailgun: <code>us</code> or <code>eu</code>, and the domain's SMTP login and password.</p> |
|
| 80 | + | <div data-for="ses mailgun"><label for="mailregion">Region</label><input type="text" id="mailregion" name="region" value="{{ mail.region }}" placeholder="eu-west-2 for SES; us or eu for Mailgun"></div> |
|
| 81 | + | <div data-for="smtp"><label for="mailhost">Server</label><input type="text" id="mailhost" name="host" value="{{ mail.host }}" placeholder="mail.example.org"> |
|
| 82 | + | <label for="mailport">Port</label><input type="text" id="mailport" name="port" value="{{ mail.port }}" placeholder="587"> |
|
| 83 | + | <label for="mailsecurity">Security</label><select id="mailsecurity" name="security"><option value="starttls"{% if mail.security == "starttls" %} selected{% endif %}>STARTTLS (usually port 587)</option><option value="tls"{% if mail.security == "tls" %} selected{% endif %}>TLS from the start (usually port 465)</option><option value="none"{% if mail.security == "none" %} selected{% endif %}>None (a relay on this machine)</option></select></div> |
|
| 84 | + | <div data-for="ses mailgun smtp"><label for="mailuser">User name</label><input type="text" id="mailuser" name="user" value="{% if mail.service != "resend" %}{{ mail.user }}{% endif %}" autocomplete="off"></div> |
|
| 85 | + | <div data-for="resend ses mailgun smtp"><label for="mailsecret">{% if mail.service == "resend" %}API key{% else %}Password or API key{% endif %}</label><input type="password" id="mailsecret" name="secret" value="" autocomplete="new-password" placeholder="{% if mail.has_secret %}saved: leave empty to keep it{% else %}{% endif %}"></div> |
|
| 86 | + | <div data-for="url"><label for="smtp">SMTP URL</label><input type="text" id="smtp" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465"></div> |
|
| 87 | + | <div data-for="resend ses mailgun smtp url"><label for="mailfrom">From</label><input type="text" id="mailfrom" name="from" value="{{ mail_from }}" placeholder="OxygenHub <[email protected]>"></div> |
|
| 79 | 88 | <button type="submit">SAVE</button> |
|
| 80 | 89 | </form> |
|
| 90 | + | <script> |
|
| 91 | + | /* Mail panel (oxygenhub#181): show the fields the chosen service asks. */ |
|
| 92 | + | (function () { |
|
| 93 | + | var sel = document.querySelector("[data-mail-service]"); |
|
| 94 | + | if (!sel) return; |
|
| 95 | + | var show = function () { |
|
| 96 | + | document.querySelectorAll(".mail-panel [data-for]").forEach(function (el) { |
|
| 97 | + | el.hidden = el.getAttribute("data-for").split(" ").indexOf(sel.value) < 0; |
|
| 98 | + | }); |
|
| 99 | + | }; |
|
| 100 | + | sel.addEventListener("change", show); |
|
| 101 | + | show(); |
|
| 102 | + | })(); |
|
| 103 | + | </script> |
|
| 81 | 104 | <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form> |
|
| 82 | - | <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p> |
|
| 105 | + | <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} Off turns it off. Passwords and keys stay on the server and are never shown again. Every service is SMTP underneath, so any provider works: <code>smtps://USER:PASSWORD@HOST:465</code> for TLS from the start, or <code>smtp://USER:PASSWORD@HOST:587</code>, which the hub sends with STARTTLS required (add <code>?tls=opportunistic</code> or <code>?tls=none</code> to say otherwise; a relay on this machine needs none).</p> |
|
| 83 | 106 | </section> |
|
| 84 | 107 | {% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %} |
|
| 85 | 108 | {% call se::panel("/admin", secrets, shell.csrf, "Every repository's jobs may use these, unless an organisation or repository sets the same name.") %} |
|
| … | 11 unchanged lines |