oxedyne/fe2o3/fe2o3_steel/src/app/mail.rs
10.4 KiB, 31 runs
created by r1870400018:9931, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Steel-side mail handler glue. |
| 2 | //! |
| 3 | //! Wires the SMTP and IMAP servers in `oxedyne_fe2o3_net` to the |
| 4 | //! Maildir + passwd-file implementations in `oxedyne_fe2o3_mail` and |
| 5 | //! drives a small background worker that flushes the outbound spool |
| 6 | //! through the SMTP client. |
| 7 | //! |
| 8 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 9 | //! Anthropic Claude |
| 10 | |
| 11 | use oxedyne_fe2o3_core::prelude::*; |
| 12 | use oxedyne_fe2o3_mail::{ |
| 13 | maildir::MaildirStore, |
| 14 | outbound::OutboundSpool, |
| 15 | passwd::PasswdFileUserStore, |
| 16 | }; |
| 17 | use oxedyne_fe2o3_net::{ |
| 18 | dkim::DkimSigner, |
| 19 | mail::user::UserStore, |
| 20 | smtp::{ |
| 21 | client::OutboundClient, |
| 22 | handler::{ |
| 23 | HandlerOutcome, |
| 24 | SmtpHandler, |
| 25 | SmtpTransaction, |
| 26 | }, |
| 27 | }, |
| 28 | }; |
| 29 | |
| 30 | use std::{ |
| 31 | sync::Arc, |
| 32 | time::{ |
| 33 | SystemTime, |
| 34 | UNIX_EPOCH, |
| 35 | }, |
| 36 | }; |
| 37 | |
| 38 | |
| 39 | /// Cloneable handler shared across every SMTP listener. |
| 40 | #[derive(Clone)] |
| 41 | pub struct AppMailHandler { |
| 42 | pub store: MaildirStore, |
| 43 | pub users: PasswdFileUserStore, |
| 44 | pub spool: OutboundSpool, |
| 45 | // Each signer prepends its own `DKIM-Signature` header; a receiver verifies |
| 46 | // whichever it understands and ignores the rest. Empty means outbound mail |
| 47 | // goes unsigned. |
| 48 | pub dkim: Vec<Arc<DkimSigner>>, |
| 49 | pub local_domains: Arc<Vec<String>>, |
| 50 | } |
| 51 | |
| 52 | impl std::fmt::Debug for AppMailHandler { |
| 53 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 54 | f.debug_struct("AppMailHandler") |
| 55 | .field("local_domains", &self.local_domains) |
| 56 | .field("dkim", &self.dkim.len()) |
| 57 | .finish() |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | impl SmtpHandler for AppMailHandler { |
| 62 | |
| 63 | fn deliver_inbound(&self, txn: SmtpTransaction) -> Outcome<HandlerOutcome> { |
| 64 | // For each recipient resolve the local mailbox and append. |
| 65 | let mut accepted = 0u32; |
| 66 | for rcpt in &txn.rcpt_to { |
| 67 | let user = match self.users.lookup(rcpt) { |
| 68 | Ok(Some(u)) => u, |
| 69 | _ => { |
| 70 | warn!("Inbound delivery: unknown recipient {}", rcpt); |
| 71 | continue; |
| 72 | } |
| 73 | }; |
| 74 | // Materialise the bytes the way the IMAP server will read |
| 75 | // them: prepend a Received header so the audit trail is |
| 76 | // useful and the message is a valid RFC 5322 document. |
| 77 | let received = build_received_header(&txn); |
| 78 | let mut bytes = received.into_bytes(); |
| 79 | bytes.extend_from_slice(&txn.raw_message); |
| 80 | use oxedyne_fe2o3_net::mail::store::{FolderName, MailStore, MessageFlags}; |
| 81 | let flags = MessageFlags { recent: true, ..Default::default() }; |
| 82 | let internal = Some(SystemTime::now()); |
| 83 | let result = self.store.append( |
| 84 | &user, |
| 85 | &FolderName::new("INBOX"), |
| 86 | &bytes, |
| 87 | flags, |
| 88 | internal, |
| 89 | ); |
| 90 | match result { |
| 91 | Ok(_uid) => { accepted += 1; } |
| 92 | Err(e) => { |
| 93 | error!(err!(e, |
| 94 | "Failed to append inbound mail for {}.", rcpt; |
| 95 | IO)); |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | if accepted == 0 { |
| 100 | return Ok(HandlerOutcome::RejectPermanent( |
| 101 | "No recipients accepted".to_string())); |
| 102 | } |
| 103 | Ok(HandlerOutcome::Accepted(fmt!("inbound-{}", short_id()))) |
| 104 | } |
| 105 | |
| 106 | fn submit_outbound(&self, txn: SmtpTransaction) -> Outcome<HandlerOutcome> { |
| 107 | // DKIM-sign first, if a key is configured. Signed bytes are |
| 108 | // used both for local delivery (so the IMAP-fetched message |
| 109 | // shows the DKIM-Signature header) and for remote delivery. |
| 110 | // Sign with every configured key, each prepending its own header. |
| 111 | // The signatures are independent: a DKIM-Signature field is not itself |
| 112 | // among the covered headers, and the body is untouched, so signing the |
| 113 | // already-signed bytes does not disturb the earlier signature. |
| 114 | // |
| 115 | // A key that fails to sign is skipped rather than fatal. One bad |
| 116 | // signature is worth less than no mail at all, and the remaining key |
| 117 | // still carries the message. |
| 118 | let now = SystemTime::now() |
| 119 | .duration_since(UNIX_EPOCH) |
| 120 | .map(|d| d.as_secs()) |
| 121 | .unwrap_or(0); |
| 122 | let mut signed_bytes = txn.raw_message.clone(); |
| 123 | for signer in &self.dkim { |
| 124 | match signer.sign(&signed_bytes, &[], now) { |
| 125 | Ok(b) => signed_bytes = b, |
| 126 | Err(e) => warn!("DKIM signing with the {} key for selector \ |
| 127 | '{}' failed; continuing without it: {}", |
| 128 | signer.algorithm(), signer.selector(), e), |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Split recipients into local (deliver directly into the |
| 133 | // mailbox) and remote (enqueue for SMTP outbound). This |
| 134 | // short-circuit avoids a self-loop through the public MX for |
| 135 | // intra-domain mail and removes a real cert/PTR/SPF surface |
| 136 | // from the local delivery path. |
| 137 | let mut local: Vec<String> = Vec::new(); |
| 138 | let mut remote: Vec<String> = Vec::new(); |
| 139 | for r in &txn.rcpt_to { |
| 140 | let domain = match r.rfind('@') { |
| 141 | Some(i) => r[i + 1..].to_lowercase(), |
| 142 | None => { remote.push(r.clone()); continue; } |
| 143 | }; |
| 144 | if self.local_domains.iter().any(|d| d.eq_ignore_ascii_case(&domain)) { |
| 145 | local.push(r.clone()); |
| 146 | } else { |
| 147 | remote.push(r.clone()); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // Local delivery first. |
| 152 | for rcpt in &local { |
| 153 | let user = match self.users.lookup(rcpt) { |
| 154 | Ok(Some(u)) => u, |
| 155 | _ => { |
| 156 | warn!("Submission: local recipient {} not in user store", rcpt); |
| 157 | continue; |
| 158 | } |
| 159 | }; |
| 160 | use oxedyne_fe2o3_net::mail::store::{FolderName, MailStore, MessageFlags}; |
| 161 | let flags = MessageFlags { recent: true, ..Default::default() }; |
| 162 | let internal = Some(SystemTime::now()); |
| 163 | if let Err(e) = self.store.append( |
| 164 | &user, |
| 165 | &FolderName::new("INBOX"), |
| 166 | &signed_bytes, |
| 167 | flags, |
| 168 | internal, |
| 169 | ) { |
| 170 | error!(err!(e, |
| 171 | "Local delivery to {} failed.", rcpt; |
| 172 | IO)); |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // Remote delivery via the spool, only if there is anything to |
| 177 | // send to the outside world. |
| 178 | if remote.is_empty() { |
| 179 | return Ok(HandlerOutcome::Accepted(fmt!("local-{}", short_id()))); |
| 180 | } |
| 181 | let qid = res!(self.spool.enqueue( |
| 182 | &txn.mail_from, |
| 183 | &remote, |
| 184 | &signed_bytes, |
| 185 | )); |
| 186 | Ok(HandlerOutcome::Accepted(qid)) |
| 187 | } |
| 188 | |
| 189 | fn rcpt_acceptable(&self, address: &str) -> bool { |
| 190 | // Pure local-domain check first, then a UserStore lookup as |
| 191 | // fallback for explicit aliases. |
| 192 | let domain = match address.rfind('@') { |
| 193 | Some(i) => address[i + 1..].to_lowercase(), |
| 194 | None => return false, |
| 195 | }; |
| 196 | if self.local_domains.iter().any(|d| d.eq_ignore_ascii_case(&domain)) { |
| 197 | return true; |
| 198 | } |
| 199 | matches!(self.users.lookup(address), Ok(Some(_))) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | |
| 204 | /// Polls the spool and pushes each message through the outbound SMTP client. |
| 205 | /// Runs forever. |
| 206 | pub async fn run_outbound_worker( |
| 207 | spool: OutboundSpool, |
| 208 | client: OutboundClient, |
| 209 | ) |
| 210 | -> Outcome<()> |
| 211 | { |
| 212 | use std::time::Duration; |
| 213 | loop { |
| 214 | // Drain the spool. |
| 215 | match spool.list() { |
| 216 | Ok(messages) => { |
| 217 | for msg in messages { |
| 218 | info!("Outbound: delivering {} ({} rcpt)", |
| 219 | msg.filename, msg.rcpt_to.len()); |
| 220 | let result = client.deliver( |
| 221 | &msg.mail_from, |
| 222 | &msg.rcpt_to, |
| 223 | &msg.body, |
| 224 | ).await; |
| 225 | match result { |
| 226 | Ok(qid) => { |
| 227 | info!("Outbound: {} delivered (remote: {})", |
| 228 | msg.filename, qid); |
| 229 | if let Err(e) = spool.remove(&msg.filename) { |
| 230 | warn!("Failed to remove spool file {}: {}", |
| 231 | msg.filename, e); |
| 232 | } |
| 233 | } |
| 234 | Err(e) => { |
| 235 | warn!("Outbound: {} failed: {}", msg.filename, e); |
| 236 | // Leave on disk for next sweep. |
| 237 | } |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | Err(e) => warn!("Spool list error: {}", e), |
| 242 | } |
| 243 | tokio::time::sleep(Duration::from_secs(30)).await; |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | fn short_id() -> String { |
| 248 | use oxedyne_fe2o3_core::rand::Rand; |
| 249 | Rand::generate_random_string(8, "abcdefghijklmnopqrstuvwxyz0123456789") |
| 250 | } |
| 251 | |
| 252 | fn build_received_header(txn: &SmtpTransaction) -> String { |
| 253 | fmt!( |
| 254 | "Received: from {} ([{}])\r\n\tby Hematite Steel; {}\r\n", |
| 255 | txn.helo_domain, |
| 256 | txn.peer.ip(), |
| 257 | format_now_rfc5322(), |
| 258 | ) |
| 259 | } |
| 260 | |
| 261 | fn format_now_rfc5322() -> String { |
| 262 | let secs = SystemTime::now() |
| 263 | .duration_since(UNIX_EPOCH) |
| 264 | .map(|d| d.as_secs()) |
| 265 | .unwrap_or(0); |
| 266 | let (y, mo, d, h, mi, s) = unix_to_civil(secs); |
| 267 | let months = [ |
| 268 | "Jan","Feb","Mar","Apr","May","Jun", |
| 269 | "Jul","Aug","Sep","Oct","Nov","Dec", |
| 270 | ]; |
| 271 | let days = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]; |
| 272 | // Day of week from days since 1970-01-01 (Thursday). |
| 273 | let dow = ((secs / 86_400 + 4) % 7) as usize; |
| 274 | fmt!( |
| 275 | "{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000", |
| 276 | days[dow], d, months[(mo as usize - 1).min(11)], y, h, mi, s, |
| 277 | ) |
| 278 | } |
| 279 | |
| 280 | fn unix_to_civil(secs: u64) -> (i32, u32, u32, u32, u32, u32) { |
| 281 | let days = (secs / 86_400) as i64; |
| 282 | let rem = (secs % 86_400) as u32; |
| 283 | let h = rem / 3_600; |
| 284 | let mi = (rem / 60) % 60; |
| 285 | let s = rem % 60; |
| 286 | let z = days + 719_468; |
| 287 | let era = if z >= 0 { z } else { z - 146_096 } / 146_097; |
| 288 | let doe = (z - era * 146_097) as u32; |
| 289 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; |
| 290 | let y = yoe as i64 + era * 400; |
| 291 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
| 292 | let mp = (5 * doy + 2) / 153; |
| 293 | let d = doy - (153 * mp + 2) / 5 + 1; |
| 294 | let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
| 295 | let y = if m <= 2 { y + 1 } else { y }; |
| 296 | (y as i32, m, d, h, mi, s) |
| 297 | } |