1use std::io::{Read, Write};
6use std::net::TcpStream;
7
8use serde::{Deserialize, Serialize};
9
10use super::net::{Net, SendError};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum SmtpTls {
16 #[default]
18 Starttls,
19 Tls,
21 None,
23}
24
25impl SmtpTls {
26 pub fn default_port(self) -> u16 {
27 match self {
28 SmtpTls::Starttls => 587,
29 SmtpTls::Tls => 465,
30 SmtpTls::None => 25,
31 }
32 }
33}
34
35pub struct Mail<'a> {
37 pub host: &'a str,
38 pub port: u16,
39 pub tls: SmtpTls,
40 pub username: Option<&'a str>,
41 pub password: Option<&'a str>,
42 pub from: &'a str,
43 pub to: &'a [String],
44 pub subject: &'a str,
45 pub body: &'a str,
46 pub date: u64,
48 pub message_id: &'a str,
49}
50
51enum Stream {
52 Plain(TcpStream),
53 Tls(Box<rustls::StreamOwned<rustls::ClientConnection, TcpStream>>),
54}
55
56impl Read for Stream {
57 fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
58 match self {
59 Stream::Plain(s) => s.read(b),
60 Stream::Tls(s) => s.read(b),
61 }
62 }
63}
64
65impl Write for Stream {
66 fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
67 match self {
68 Stream::Plain(s) => s.write(b),
69 Stream::Tls(s) => s.write(b),
70 }
71 }
72 fn flush(&mut self) -> std::io::Result<()> {
73 match self {
74 Stream::Plain(s) => s.flush(),
75 Stream::Tls(s) => s.flush(),
76 }
77 }
78}
79
80struct Session<'a> {
81 s: Option<Stream>,
82 buf: Vec<u8>,
83 host: &'a str,
84}
85
86struct Reply {
88 code: u16,
89 lines: Vec<String>,
90}
91
92impl Session<'_> {
93 fn stream(&mut self) -> &mut Stream {
94 self.s.as_mut().expect("a stream")
95 }
96
97 fn io(&self, e: std::io::Error) -> SendError {
98 SendError::transient(format!("SMTP {}: {e}", self.host))
99 }
100
101 fn line(&mut self) -> Result<String, SendError> {
102 loop {
103 if let Some(p) = self.buf.windows(2).position(|w| w == b"\r\n") {
104 let l: Vec<u8> = self.buf.drain(..p + 2).collect();
105 return Ok(String::from_utf8_lossy(&l[..p]).into_owned());
106 }
107 if self.buf.len() > 4096 {
108 return Err(SendError::transient(format!(
109 "SMTP {}: an overlong reply line",
110 self.host
111 )));
112 }
113 let mut b = [0u8; 1024];
114 let n = match self.stream().read(&mut b) {
115 Ok(n) => n,
116 Err(e) => return Err(self.io(e)),
117 };
118 if n == 0 {
119 return Err(SendError::transient(format!(
120 "SMTP {}: the server closed the connection",
121 self.host
122 )));
123 }
124 self.buf.extend_from_slice(&b[..n]);
125 }
126 }
127
128 fn reply(&mut self) -> Result<Reply, SendError> {
129 let mut lines = Vec::new();
130 loop {
131 let l = self.line()?;
132 let code = l
133 .get(..3)
134 .and_then(|c| c.parse::<u16>().ok())
135 .ok_or_else(|| {
136 SendError::transient(format!("SMTP {}: a bad reply line", self.host))
137 })?;
138 let more = l.as_bytes().get(3) == Some(&b'-');
139 lines.push(l.get(4..).unwrap_or_default().to_string());
140 if lines.len() > 100 {
141 return Err(SendError::transient(format!(
142 "SMTP {}: an overlong reply",
143 self.host
144 )));
145 }
146 if !more {
147 return Ok(Reply { code, lines });
148 }
149 }
150 }
151
152 fn send(&mut self, data: &[u8]) -> Result<(), SendError> {
153 let r = self
154 .stream()
155 .write_all(data)
156 .and_then(|_| self.stream().flush());
157 r.map_err(|e| self.io(e))
158 }
159
160 fn cmd(&mut self, c: &str, ok: &[u16], shown: Option<&str>) -> Result<Reply, SendError> {
163 self.send(format!("{c}\r\n").as_bytes())?;
164 self.expect(ok, shown.unwrap_or(c))
165 }
166
167 fn expect(&mut self, ok: &[u16], what: &str) -> Result<Reply, SendError> {
168 let r = self.reply()?;
169 if ok.contains(&r.code) {
170 return Ok(r);
171 }
172 let text = r.lines.join(" ");
173 let text: String = text.chars().take(200).collect();
174 let m = format!("SMTP {}: {what}: {} {text}", self.host, r.code);
175 Err(if (400..500).contains(&r.code) {
177 SendError::transient(m)
178 } else {
179 SendError::permanent(m)
180 })
181 }
182}
183
184fn ehlo_has(r: &Reply, ext: &str) -> bool {
185 r.lines.iter().any(|l| {
186 l.split_whitespace()
187 .next()
188 .is_some_and(|w| w.eq_ignore_ascii_case(ext))
189 })
190}
191
192fn auth_mechs(r: &Reply) -> Vec<String> {
193 r.lines
194 .iter()
195 .filter_map(|l| {
196 let mut w = l.split_whitespace();
197 w.next()
198 .filter(|x| x.eq_ignore_ascii_case("AUTH"))
199 .map(|_| w.map(|m| m.to_ascii_uppercase()).collect::<Vec<_>>())
200 })
201 .flatten()
202 .collect()
203}
204
205pub fn check_address(a: &str) -> Result<(), String> {
207 let ok = a.len() <= 254
208 && a.split_once('@')
209 .is_some_and(|(l, d)| !l.is_empty() && !d.is_empty() && !d.contains('@'))
210 && !a
211 .chars()
212 .any(|c| c.is_control() || c.is_whitespace() || "<>,;\"()[]\\".contains(c));
213 if ok {
214 Ok(())
215 } else {
216 Err(format!("{a:?} is not an email address"))
217 }
218}
219
220pub fn send(net: &Net, m: &Mail) -> Result<(), SendError> {
222 check_address(m.from).map_err(SendError::permanent)?;
223 for t in m.to {
224 check_address(t).map_err(SendError::permanent)?;
225 }
226 if m.to.is_empty() {
227 return Err(SendError::permanent("no recipients"));
228 }
229 let tcp = super::net::connect(m.host, m.port, net.allow_private)?;
230 let first = match m.tls {
231 SmtpTls::Tls => Stream::Tls(Box::new(super::net::tls(net, m.host, tcp)?)),
232 _ => Stream::Plain(tcp),
233 };
234 let mut s = Session {
235 s: Some(first),
236 buf: Vec::new(),
237 host: m.host,
238 };
239 s.expect(&[220], "greeting")?;
240 let mut ehlo = s.cmd("EHLO isb", &[250], None)?;
241 if m.tls == SmtpTls::Starttls {
242 if !ehlo_has(&ehlo, "STARTTLS") {
243 return Err(SendError::permanent(format!(
244 "SMTP {}: the server does not offer STARTTLS",
245 m.host
246 )));
247 }
248 s.cmd("STARTTLS", &[220], None)?;
249 if !s.buf.is_empty() {
252 return Err(SendError::permanent(format!(
253 "SMTP {}: data after STARTTLS's reply",
254 m.host
255 )));
256 }
257 let Some(Stream::Plain(tcp)) = s.s.take() else {
258 unreachable!("STARTTLS starts from a plain stream")
259 };
260 s.s = Some(Stream::Tls(Box::new(super::net::tls(net, m.host, tcp)?)));
261 ehlo = s.cmd("EHLO isb", &[250], None)?;
262 }
263 if let (Some(user), Some(pass)) = (m.username, m.password) {
264 if m.tls == SmtpTls::None {
265 return Err(SendError::permanent(format!(
266 "SMTP {}: refusing to send a password without TLS (use starttls or tls)",
267 m.host
268 )));
269 }
270 let mechs = auth_mechs(&ehlo);
271 if mechs.iter().any(|x| x == "PLAIN") {
272 let token = crate::rpc::b64_encode(format!("\0{user}\0{pass}").as_bytes());
273 s.cmd(&format!("AUTH PLAIN {token}"), &[235], Some("AUTH PLAIN"))?;
274 } else if mechs.iter().any(|x| x == "LOGIN") {
275 s.cmd("AUTH LOGIN", &[334], None)?;
276 s.cmd(
277 &crate::rpc::b64_encode(user.as_bytes()),
278 &[334],
279 Some("AUTH LOGIN user"),
280 )?;
281 s.cmd(
282 &crate::rpc::b64_encode(pass.as_bytes()),
283 &[235],
284 Some("AUTH LOGIN password"),
285 )?;
286 } else {
287 return Err(SendError::permanent(format!(
288 "SMTP {}: no supported AUTH mechanism (PLAIN, LOGIN) offered",
289 m.host
290 )));
291 }
292 }
293 s.cmd(&format!("MAIL FROM:<{}>", m.from), &[250], None)?;
294 for t in m.to {
295 s.cmd(&format!("RCPT TO:<{t}>"), &[250, 251], None)?;
296 }
297 s.cmd("DATA", &[354], None)?;
298 let msg = message(m);
299 s.send(&msg)?;
300 s.expect(&[250], "message")?;
301 let _ = s.cmd("QUIT", &[221], None);
302 Ok(())
303}
304
305pub fn message(m: &Mail) -> Vec<u8> {
308 let mut out = String::new();
309 out.push_str(&format!("From: {}\r\n", m.from));
310 out.push_str(&format!("To: {}\r\n", m.to.join(", ")));
311 out.push_str(&format!("Subject: {}\r\n", encode_header(m.subject)));
312 out.push_str(&format!("Date: {}\r\n", rfc2822(m.date)));
313 out.push_str(&format!("Message-ID: <{}>\r\n", m.message_id));
314 out.push_str("MIME-Version: 1.0\r\n");
315 out.push_str("Content-Type: text/plain; charset=utf-8\r\n");
316 out.push_str("Content-Transfer-Encoding: 8bit\r\n");
317 out.push_str("Auto-Submitted: auto-generated\r\n\r\n");
318 for line in m.body.replace("\r\n", "\n").split('\n') {
319 if line.starts_with('.') {
320 out.push('.');
321 }
322 out.push_str(line);
323 out.push_str("\r\n");
324 }
325 out.push_str(".\r\n");
326 out.into_bytes()
327}
328
329fn encode_header(s: &str) -> String {
332 let s: String = s
333 .chars()
334 .map(|c| if c.is_control() { ' ' } else { c })
335 .collect();
336 if s.is_ascii() {
337 s
338 } else {
339 format!("=?utf-8?B?{}?=", crate::rpc::b64_encode(s.as_bytes()))
340 }
341}
342
343pub fn rfc2822(secs: u64) -> String {
345 let days = (secs / 86400) as i64;
346 let rem = secs % 86400;
347 let (y, mo, d) = civil(days);
348 let wd = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"][(days % 7) as usize];
350 let mon = [
351 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
352 ][(mo - 1) as usize];
353 format!(
354 "{wd}, {d:02} {mon} {y} {:02}:{:02}:{:02} +0000",
355 rem / 3600,
356 rem / 60 % 60,
357 rem % 60
358 )
359}
360
361fn civil(z: i64) -> (i64, u32, u32) {
363 let z = z + 719_468;
364 let era = z.div_euclid(146_097);
365 let doe = z.rem_euclid(146_097);
366 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
367 let y = yoe + era * 400;
368 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
369 let mp = (5 * doy + 2) / 153;
370 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
371 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
372 (if m <= 2 { y + 1 } else { y }, m, d)
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use std::net::TcpListener;
379
380 #[test]
381 fn dates() {
382 assert_eq!(rfc2822(0), "Thu, 01 Jan 1970 00:00:00 +0000");
383 assert_eq!(rfc2822(1_791_014_653), "Sat, 03 Oct 2026 08:04:13 +0000");
384 assert_eq!(rfc2822(951_782_400), "Tue, 29 Feb 2000 00:00:00 +0000");
385 }
386
387 #[test]
388 fn addresses_and_headers() {
389 assert!(check_address("ops@example.com").is_ok());
390 for bad in [
391 "x",
392 "@x",
393 "x@",
394 "a@b@c",
395 "a b@c",
396 "a@c\r\nRCPT TO:<evil@x>",
397 "<a@b>",
398 ] {
399 assert!(check_address(bad).is_err(), "{bad}");
400 }
401 assert_eq!(encode_header("a\r\nBcc: x"), "a Bcc: x");
402 assert_eq!(encode_header("é"), "=?utf-8?B?w6k=?=");
403 }
404
405 fn fake(
408 script: Vec<(&'static str, &'static str)>,
409 tls: Option<Arc<rustls::ServerConfig>>,
410 ) -> (u16, std::thread::JoinHandle<Vec<String>>) {
411 let l = TcpListener::bind("127.0.0.1:0").unwrap();
412 let port = l.local_addr().unwrap().port();
413 let h = std::thread::spawn(move || {
414 let (s, _) = l.accept().unwrap();
415 s.set_read_timeout(Some(std::time::Duration::from_secs(10)))
416 .unwrap();
417 let mut log = Vec::new();
418 let mut plain = Some(s);
419 let mut tls_stream: Option<rustls::StreamOwned<rustls::ServerConnection, TcpStream>> =
420 None;
421 let w = |data: &[u8], plain: &mut Option<TcpStream>, t: &mut Option<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>| {
422 match t {
423 Some(t) => { t.write_all(data).unwrap(); t.flush().unwrap(); }
424 None => plain.as_mut().unwrap().write_all(data).unwrap(),
425 }
426 };
427 w(b"220 fake ESMTP\r\n", &mut plain, &mut tls_stream);
428 let mut in_data = false;
429 let mut script = script.into_iter();
430 loop {
431 let mut line = String::new();
434 let mut n = 0;
435 let mut b = [0u8; 1];
436 loop {
437 let r = match &mut tls_stream {
438 Some(t) => t.read(&mut b),
439 None => plain.as_mut().unwrap().read(&mut b),
440 };
441 if !matches!(r, Ok(1)) {
442 break;
443 }
444 line.push(b[0] as char);
445 n += 1;
446 if b[0] == b'\n' {
447 break;
448 }
449 }
450 if n == 0 {
451 break;
452 }
453 let l = line.trim_end_matches(['\r', '\n']).to_string();
454 log.push(l.clone());
455 if in_data {
456 if l == "." {
457 in_data = false;
458 w(b"250 queued\r\n", &mut plain, &mut tls_stream);
459 }
460 continue;
461 }
462 let Some((expect, reply)) = script.next() else {
463 break;
464 };
465 assert!(l.starts_with(expect), "expected {expect}, got {l}");
466 w(
467 format!("{reply}\r\n").as_bytes(),
468 &mut plain,
469 &mut tls_stream,
470 );
471 if expect == "DATA" {
472 in_data = true;
473 }
474 if expect == "STARTTLS" {
475 let conn = rustls::ServerConnection::new(tls.clone().unwrap()).unwrap();
476 tls_stream = Some(rustls::StreamOwned::new(conn, plain.take().unwrap()));
477 }
478 if expect == "QUIT" {
479 break;
480 }
481 }
482 log
483 });
484 (port, h)
485 }
486
487 use std::sync::Arc;
488
489 fn mail<'a>(
490 port: u16,
491 tls: SmtpTls,
492 to: &'a [String],
493 user: Option<&'a str>,
494 pass: Option<&'a str>,
495 ) -> Mail<'a> {
496 Mail {
497 host: "127.0.0.1",
498 port,
499 tls,
500 username: user,
501 password: pass,
502 from: "isb@example.com",
503 to,
504 subject: "[isb] deploy.failed",
505 body: "line one\n.dot line\nend",
506 date: 0,
507 message_id: "1@isb",
508 }
509 }
510
511 #[test]
512 fn plain_conversation() {
513 let (port, h) = fake(
514 vec![
515 ("EHLO isb", "250-fake\r\n250 8BITMIME"),
516 ("MAIL FROM:<isb@example.com>", "250 ok"),
517 ("RCPT TO:<a@example.com>", "250 ok"),
518 ("RCPT TO:<b@example.com>", "251 forwarded"),
519 ("DATA", "354 go"),
520 ("QUIT", "221 bye"),
521 ],
522 None,
523 );
524 let to = vec!["a@example.com".to_string(), "b@example.com".to_string()];
525 send(&Net::new(true), &mail(port, SmtpTls::None, &to, None, None)).unwrap();
526 let log = h.join().unwrap();
527 let i = log.iter().position(|l| l == "DATA").unwrap();
528 let msg = &log[i + 1..];
529 assert!(
530 msg.contains(&"Subject: [isb] deploy.failed".to_string()),
531 "{msg:?}"
532 );
533 assert!(msg.contains(&"To: a@example.com, b@example.com".to_string()));
534 assert!(msg.contains(&"..dot line".to_string()), "{msg:?}");
536 assert_eq!(msg.last().map(String::as_str), Some("QUIT"));
537 }
538
539 #[test]
540 fn no_password_without_tls_and_errors_hide_it() {
541 let (port, h) = fake(vec![("EHLO isb", "250-fake\r\n250 AUTH PLAIN")], None);
542 let to = vec!["a@example.com".to_string()];
543 let e = send(
544 &Net::new(true),
545 &mail(port, SmtpTls::None, &to, Some("u"), Some("hunter2")),
546 )
547 .unwrap_err();
548 assert!(e.message.contains("without TLS"), "{e}");
549 assert!(!e.message.contains("hunter2"));
550 drop(h);
551 }
552
553 #[test]
554 fn starttls_required_when_asked_for() {
555 let (port, _h) = fake(vec![("EHLO isb", "250 fake")], None);
556 let to = vec!["a@example.com".to_string()];
557 let e = send(
558 &Net::new(true),
559 &mail(port, SmtpTls::Starttls, &to, None, None),
560 )
561 .unwrap_err();
562 assert!(e.message.contains("does not offer STARTTLS"), "{e}");
563 }
564
565 #[test]
566 fn rejected_recipient_is_permanent_and_busy_is_transient() {
567 let (port, _h) = fake(
568 vec![
569 ("EHLO isb", "250 fake"),
570 ("MAIL FROM", "250 ok"),
571 ("RCPT TO", "550 no such user"),
572 ],
573 None,
574 );
575 let to = vec!["a@example.com".to_string()];
576 let e = send(&Net::new(true), &mail(port, SmtpTls::None, &to, None, None)).unwrap_err();
577 assert!(!e.retryable && e.message.contains("550"), "{e}");
578 let (port, _h) = fake(vec![("EHLO isb", "421 busy")], None);
579 let e = send(&Net::new(true), &mail(port, SmtpTls::None, &to, None, None)).unwrap_err();
580 assert!(e.retryable, "{e}");
581 }
582
583 #[test]
584 fn starttls_and_auth_plain() {
585 let ca_key = rcgen::KeyPair::generate().unwrap();
587 let ca = || {
588 let mut ca = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
589 ca.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
590 ca
591 };
592 let ca_cert = ca().self_signed(&ca_key).unwrap();
593 let issuer = rcgen::Issuer::new(ca(), &ca_key);
594 let key = rcgen::KeyPair::generate().unwrap();
595 let leaf = rcgen::CertificateParams::new(vec!["127.0.0.1".to_string()])
596 .unwrap()
597 .signed_by(&key, &issuer)
598 .unwrap();
599 let provider = Arc::new(rustls::crypto::ring::default_provider());
600 let server = rustls::ServerConfig::builder_with_provider(provider)
601 .with_safe_default_protocol_versions()
602 .unwrap()
603 .with_no_client_auth()
604 .with_single_cert(
605 vec![leaf.der().clone()],
606 rustls::pki_types::PrivateKeyDer::Pkcs8(key.serialize_der().into()),
607 )
608 .unwrap();
609 let mut roots = rustls::RootCertStore::empty();
610 roots.add(ca_cert.der().clone()).unwrap();
611 let net = Net {
612 allow_private: true,
613 tls: super::super::net::tls_with_roots(roots),
614 };
615 let (port, h) = fake(
616 vec![
617 ("EHLO isb", "250-fake\r\n250 STARTTLS"),
618 ("STARTTLS", "220 go ahead"),
619 ("EHLO isb", "250-fake\r\n250 AUTH LOGIN PLAIN"),
620 ("AUTH PLAIN AHUAaHVudGVyMg==", "235 ok"),
621 ("MAIL FROM", "250 ok"),
622 ("RCPT TO", "250 ok"),
623 ("DATA", "354 go"),
624 ("QUIT", "221 bye"),
625 ],
626 Some(Arc::new(server)),
627 );
628 let to = vec!["a@example.com".to_string()];
629 send(
630 &net,
631 &mail(port, SmtpTls::Starttls, &to, Some("u"), Some("hunter2")),
632 )
633 .unwrap();
634 let log = h.join().unwrap();
635 assert!(log.iter().any(|l| l.starts_with("AUTH PLAIN")));
636 }
637}