Skip to main content

isb_apps/notify/
smtp.rs

1//! A minimal SMTP submission client: one message, plain text, over STARTTLS
2//! or implicit TLS (rustls, the same stack as the rest of isb), AUTH PLAIN or
3//! LOGIN. Enough for notifications; not a mail library.
4
5use std::io::{Read, Write};
6use std::net::TcpStream;
7
8use serde::{Deserialize, Serialize};
9
10use super::net::{Net, SendError};
11
12/// How the connection is secured.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum SmtpTls {
16    /// Plain, then STARTTLS (submission, port 587). Fails if not offered.
17    #[default]
18    Starttls,
19    /// TLS from the first byte (port 465).
20    Tls,
21    /// No TLS at all; no password is ever sent this way.
22    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
35/// One message to send.
36pub 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    /// Unix seconds, for the Date header.
47    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
86/// A reply: its code and its lines' text.
87struct 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    /// Send a command and expect one of `ok`. `shown` replaces the command
161    /// in errors (for credentials).
162    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        // 4xx is temporary by definition; 5xx is not.
176        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
205/// A mailbox fit for a header and an envelope: no CR/LF, no angle brackets.
206pub 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
220/// Send one message.
221pub 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        // Anything the server sent past its 220 would be read as if it came
250        // over TLS (the STARTTLS injection attack): refuse.
251        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
305/// The message: headers, then the body dot-stuffed with CRLF line ends,
306/// then the terminating `.`.
307pub 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
329/// A header value: as is when printable ASCII, else an RFC 2047 encoded
330/// word. CR and LF never survive (header injection).
331fn 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
343/// `Sat, 03 Oct 2026 08:04:13 +0000`.
344pub 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    // 1970-01-01 was a Thursday.
349    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
361/// Days since the epoch to (year, month, day): Howard Hinnant's algorithm.
362fn 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    /// A scripted SMTP server: answers each command it is sent with the
406    /// next canned reply, and records the conversation.
407    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                // Byte by byte, so nothing past the line is consumed (before
432                // a TLS handshake, or between loop turns).
433                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        // Dot-stuffing.
535        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        // A throwaway CA and a certificate for 127.0.0.1.
586        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}