Oregami
Repositories/oxedyne/fe2o3

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
11use oxedyne_fe2o3_core::prelude::*;
12use oxedyne_fe2o3_mail::{
13 maildir::MaildirStore,
14 outbound::OutboundSpool,
15 passwd::PasswdFileUserStore,
16};
17use 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
30use 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)]
41pub 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
52impl 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
61impl 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.
206pub 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
247fn short_id() -> String {
248 use oxedyne_fe2o3_core::rand::Rand;
249 Rand::generate_random_string(8, "abcdefghijklmnopqrstuvwxyz0123456789")
250}
251
252fn 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
261fn 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
280fn 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}