Skip to main content

rmut_core/
smtp.rs

1//! Minimal SMTP submission client: EHLO, STARTTLS or implicit TLS,
2//! AUTH PLAIN, then MAIL/RCPT/DATA with dot-stuffing. An alternative
3//! to handing mail to sendmail(1).
4
5use anyhow::{Context, Result, ensure};
6
7use crate::config::{Account, AuthKind};
8use crate::maildir;
9use crate::net::{self, Conn};
10
11/// What a submission says beside the message: mutt's
12/// $use_envelope_from / $envelope_from_address and $dsn_notify /
13/// $dsn_return. The default asks for nothing.
14#[derive(Debug, Clone, Default, PartialEq)]
15pub struct Envelope {
16    /// The envelope sender to insist on: sendmail's `-f`, SMTP's MAIL
17    /// FROM in place of the message's From.
18    pub sender: Option<String>,
19    /// DSN NOTIFY, e.g. "failure,delay".
20    pub notify: Option<String>,
21    /// DSN RET, "hdrs" or "full".
22    pub ret: Option<String>,
23}
24
25/// Submit `body` (any line endings; normalized to CRLF on the wire)
26/// for delivery to `rcpts`, authenticating as the account's user.
27/// The DSN requests go along only to a server that offers DSN, as in
28/// mutt; one that does not would refuse the command.
29pub fn send(
30    account: &Account,
31    password: &str,
32    from: &str,
33    rcpts: &[String],
34    body: &[u8],
35    envelope: &Envelope,
36) -> Result<()> {
37    let host = account
38        .smtp_host
39        .as_deref()
40        .with_context(|| format!("account {} has no smtp_host", account.name))?;
41    ensure!(!rcpts.is_empty(), "no recipients");
42    // Each goes into a command line of its own: a CR or LF (say from an
43    // encoded word in a crafted Reply-To) would start another command.
44    let from = envelope.sender.as_deref().unwrap_or(from);
45    ensure!(
46        from.is_empty() || envelope_address(from),
47        "unusable envelope sender: {from:?}"
48    );
49    for rcpt in rcpts {
50        ensure!(
51            envelope_address(rcpt),
52            "unusable recipient address: {rcpt:?}"
53        );
54    }
55    for dsn in [&envelope.notify, &envelope.ret].into_iter().flatten() {
56        ensure!(dsn_value(dsn), "unusable DSN value: {dsn:?}");
57    }
58    // Port 465 is TLS from the first byte; anything else negotiates
59    // STARTTLS (unless smtp_tls = false, for tests).
60    let implicit_tls = account.smtp_tls && account.smtp_port == 465;
61    let mut conn = Conn::new(
62        net::connect(
63            host,
64            account.smtp_port,
65            implicit_tls,
66            &net::Cutoff::default(),
67        )?,
68        format!("{host}:{}", account.smtp_port),
69    );
70    expect(&mut conn, 220).context("SMTP greeting")?;
71    let mut caps = ehlo(&mut conn)?;
72    if account.smtp_tls && !implicit_tls {
73        command(&mut conn, "STARTTLS", 220)?;
74        let tcp = conn.into_stream().into_tcp()?;
75        conn = Conn::new(
76            net::wrap_tls(tcp, host)?,
77            format!("{host}:{}", account.smtp_port),
78        );
79        caps = ehlo(&mut conn)?;
80    }
81    authenticate(&mut conn, &caps, account, password).context("SMTP authentication")?;
82    let dsn = offers(&caps, "DSN");
83    let mut mail_from = format!("MAIL FROM:<{from}>");
84    if let Some(ret) = envelope.ret.as_deref().filter(|_| dsn) {
85        mail_from += &format!(" RET={}", ret.to_ascii_uppercase());
86    }
87    command(&mut conn, &mail_from, 250)?;
88    for rcpt in rcpts {
89        let mut rcpt_to = format!("RCPT TO:<{rcpt}>");
90        if let Some(notify) = envelope.notify.as_deref().filter(|_| dsn) {
91            rcpt_to += &format!(" NOTIFY={}", notify.to_ascii_uppercase());
92        }
93        command(&mut conn, &rcpt_to, 250).with_context(|| format!("recipient {rcpt}"))?;
94    }
95    command(&mut conn, "DATA", 354)?;
96    conn.write_all(&dot_stuff(body))?;
97    expect(&mut conn, 250).context("message rejected after DATA")?;
98    let _ = conn.write_all(b"QUIT\r\n");
99    Ok(())
100}
101
102/// An address that fits in `MAIL FROM:<...>` / `RCPT TO:<...>`: no
103/// spaces, controls or angle brackets. UTF-8 passes, as it did.
104fn envelope_address(addr: &str) -> bool {
105    !addr.is_empty()
106        && !addr
107            .chars()
108            .any(|c| c <= ' ' || c == '\x7f' || c == '<' || c == '>')
109}
110
111/// A DSN keyword list such as "failure,delay" or "hdrs".
112fn dsn_value(value: &str) -> bool {
113    !value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == ',')
114}
115
116/// Whether the EHLO reply (its lines joined by "; ", each starting
117/// with the "250-"/"250 " code) names this extension.
118fn offers(caps: &str, extension: &str) -> bool {
119    caps.split("; ")
120        .filter_map(|line| line.get(4..))
121        .any(|line| {
122            line.split_whitespace()
123                .next()
124                .is_some_and(|word| word.eq_ignore_ascii_case(extension))
125        })
126}
127
128fn ehlo(conn: &mut Conn) -> Result<String> {
129    command(conn, &format!("EHLO {}", maildir::hostname()), 250)
130}
131
132/// AUTH PLAIN (or LOGIN when the server's EHLO offered only that) with
133/// the password; the OAuth kinds run their SASL mechanism with the
134/// access token in `secret`.
135fn authenticate(conn: &mut Conn, caps: &str, account: &Account, secret: &str) -> Result<()> {
136    let user = &account.user;
137    match account.auth_kind()? {
138        AuthKind::Password => {}
139        kind => {
140            let host = account.smtp_host.as_deref().unwrap_or_default();
141            let initial = kind.initial_response(user, secret, host, account.smtp_port);
142            command(conn, &format!("AUTH {}", kind.sasl_name()), 334)?;
143            command(conn, &b64(initial.as_bytes()), 235)?;
144            return Ok(());
145        }
146    }
147    // `caps` is the EHLO reply with its lines joined by "; ", each
148    // starting with the "250-"/"250 " code.
149    let caps = caps.to_ascii_uppercase();
150    let mechanisms = caps
151        .split("; ")
152        .filter_map(|line| line.get(4..))
153        .find_map(|line| line.trim_start().strip_prefix("AUTH "));
154    let login_only = mechanisms.is_some_and(|m| m.contains("LOGIN") && !m.contains("PLAIN"));
155    if login_only {
156        command(conn, "AUTH LOGIN", 334)?;
157        command(conn, &b64(user.as_bytes()), 334)?;
158        command(conn, &b64(secret.as_bytes()), 235)?;
159    } else {
160        let token = b64(format!("\0{user}\0{secret}").as_bytes());
161        command(conn, &format!("AUTH PLAIN {token}"), 235)?;
162    }
163    Ok(())
164}
165
166fn command(conn: &mut Conn, cmd: &str, want: u16) -> Result<String> {
167    conn.write_all(format!("{cmd}\r\n").as_bytes())?;
168    expect(conn, want)
169}
170
171/// Read one (possibly multi-line) reply and require the given code;
172/// 251 passes for 250 (forwarded recipient).
173fn expect(conn: &mut Conn, want: u16) -> Result<String> {
174    let mut text = String::new();
175    loop {
176        let line = conn.read_text_line()?;
177        ensure!(line.len() >= 3, "short SMTP reply: {line}");
178        let code: u16 = line[..3]
179            .parse()
180            .with_context(|| format!("malformed SMTP reply: {line}"))?;
181        if !text.is_empty() {
182            text.push_str("; ");
183        }
184        text.push_str(&line);
185        if line.as_bytes().get(3) == Some(&b'-') {
186            continue;
187        }
188        ensure!(
189            code == want || (want == 250 && code == 251),
190            "server said: {text}"
191        );
192        return Ok(text);
193    }
194}
195
196/// CRLF-normalize, escape leading dots, and add the `.` terminator.
197fn dot_stuff(body: &[u8]) -> Vec<u8> {
198    let mut lines: Vec<&[u8]> = body.split(|&b| b == b'\n').collect();
199    if lines.last() == Some(&&b""[..]) {
200        lines.pop();
201    }
202    let mut out = Vec::with_capacity(body.len() + 8);
203    for line in lines {
204        let line = line.strip_suffix(b"\r").unwrap_or(line);
205        if line.first() == Some(&b'.') {
206            out.push(b'.');
207        }
208        out.extend_from_slice(line);
209        out.extend_from_slice(b"\r\n");
210    }
211    out.extend_from_slice(b".\r\n");
212    out
213}
214
215const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
216
217pub fn b64(input: &[u8]) -> String {
218    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
219    for chunk in input.chunks(3) {
220        let b = [
221            chunk[0],
222            *chunk.get(1).unwrap_or(&0),
223            *chunk.get(2).unwrap_or(&0),
224        ];
225        let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
226        for i in 0..4 {
227            if i <= chunk.len() {
228                out.push(B64_ALPHABET[(n >> (18 - 6 * i)) as usize & 0x3f] as char);
229            } else {
230                out.push('=');
231            }
232        }
233    }
234    out
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::testserver::{self, Expect};
241
242    #[test]
243    fn envelope_values_cannot_carry_a_second_command() {
244        assert!(envelope_address("jane@example.org"));
245        assert!(envelope_address("jürgen@example.de"));
246        assert!(!envelope_address(""));
247        assert!(!envelope_address("a\r\nDATA@x.org"));
248        assert!(!envelope_address("a@x.org>\nRCPT TO:<evil@y.org"));
249        assert!(!envelope_address("a b@x.org"));
250        assert!(dsn_value("failure,delay"));
251        assert!(!dsn_value("hdrs\r\nRSET"));
252    }
253
254    #[test]
255    fn b64_matches_known_vectors() {
256        assert_eq!(b64(b""), "");
257        assert_eq!(b64(b"f"), "Zg==");
258        assert_eq!(b64(b"fo"), "Zm8=");
259        assert_eq!(b64(b"foo"), "Zm9v");
260        assert_eq!(b64(b"\0jane\0secret"), "AGphbmUAc2VjcmV0");
261    }
262
263    #[test]
264    fn dot_stuff_escapes_and_terminates() {
265        assert_eq!(
266            dot_stuff(b"hi\n.dot\nend\n"),
267            b"hi\r\n..dot\r\nend\r\n.\r\n"
268        );
269        assert_eq!(
270            dot_stuff(b"already\r\ncrlf\r\n"),
271            b"already\r\ncrlf\r\n.\r\n"
272        );
273        assert_eq!(
274            dot_stuff(b"no trailing newline"),
275            b"no trailing newline\r\n.\r\n"
276        );
277        assert_eq!(dot_stuff(b""), b".\r\n");
278    }
279
280    #[test]
281    fn session_against_scripted_server() {
282        let (port, handle, log) = testserver::smtp(vec![
283            Expect::new(
284                "EHLO",
285                "250-test.example\r\n250 AUTH PLAIN LOGIN\r\n".into(),
286            ),
287            Expect::new("AUTH PLAIN AGphbmUAc2VjcmV0", "235 ok\r\n".into()),
288            Expect::new("MAIL FROM:<jane@example.com>", "250 ok\r\n".into()),
289            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
290            Expect::new("RCPT TO:<carol@example.org>", "251 forwarded\r\n".into()),
291            Expect::new("DATA", "354 go\r\n".into()),
292            Expect::new("QUIT", "221 bye\r\n".into()),
293        ]);
294        let account = crate::config::Account {
295            name: "t".into(),
296            user: "jane".into(),
297            password_command: None,
298            password: None,
299            imap_host: None,
300            imap_port: 993,
301            imap_tls: true,
302            smtp_host: Some("127.0.0.1".into()),
303            smtp_port: port,
304            smtp_tls: false,
305            auth: None,
306            token_command: None,
307            sent_folder: "Sent".into(),
308            identity: None,
309        };
310        send(
311            &account,
312            "secret",
313            "jane@example.com",
314            &["bob@example.org".into(), "carol@example.org".into()],
315            b"Subject: hi\n\n.leading dot\nbye\n",
316            &Envelope::default(),
317        )
318        .unwrap();
319        handle.join().unwrap();
320        let log = log.lock().unwrap();
321        let payload = log.iter().find(|l| l.contains("Subject")).unwrap();
322        assert!(payload.contains("..leading dot"));
323    }
324
325    #[test]
326    fn xoauth2_runs_the_sasl_exchange() {
327        let (port, handle, _log) = testserver::smtp(vec![
328            Expect::new("EHLO", "250-x\r\n250 AUTH XOAUTH2\r\n".into()),
329            Expect::new("AUTH XOAUTH2", "334 \r\n".into()),
330            // XOAUTH2 for user=jane token=tok, precomputed base64.
331            Expect::new("dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB", "235 ok\r\n".into()),
332            Expect::new("MAIL FROM:<jane@example.com>", "250 ok\r\n".into()),
333            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
334            Expect::new("DATA", "354 go\r\n".into()),
335            Expect::new("QUIT", "221 bye\r\n".into()),
336        ]);
337        let account = crate::config::Account {
338            auth: Some("xoauth2".into()),
339            ..oauth_test_account(port)
340        };
341        send(
342            &account,
343            "tok",
344            "jane@example.com",
345            &["bob@example.org".into()],
346            b"Subject: hi\n\nbody\n",
347            &Envelope::default(),
348        )
349        .unwrap();
350        handle.join().unwrap();
351    }
352
353    #[test]
354    fn envelope_sender_and_dsn_when_offered() {
355        let (port, handle, log) = testserver::smtp(vec![
356            Expect::new("EHLO", "250-x\r\n250-DSN\r\n250 AUTH PLAIN\r\n".into()),
357            Expect::new("AUTH PLAIN", "235 ok\r\n".into()),
358            Expect::new("MAIL FROM:<bounces@example.com>", "250 ok\r\n".into()),
359            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
360            Expect::new("DATA", "354 go\r\n".into()),
361            Expect::new("QUIT", "221 bye\r\n".into()),
362        ]);
363        let envelope = Envelope {
364            sender: Some("bounces@example.com".into()),
365            notify: Some("failure,delay".into()),
366            ret: Some("hdrs".into()),
367        };
368        send(
369            &oauth_test_account(port),
370            "secret",
371            "jane@example.com",
372            &["bob@example.org".into()],
373            b"Subject: hi\n\nbody\n",
374            &envelope,
375        )
376        .unwrap();
377        handle.join().unwrap();
378        let log = log.lock().unwrap();
379        assert!(log.contains(&"MAIL FROM:<bounces@example.com> RET=HDRS".to_string()));
380        assert!(log.contains(&"RCPT TO:<bob@example.org> NOTIFY=FAILURE,DELAY".to_string()));
381    }
382
383    #[test]
384    fn no_dsn_parameters_to_a_server_without_it() {
385        let (port, handle, log) = testserver::smtp(vec![
386            Expect::new("EHLO", "250-x\r\n250 AUTH PLAIN\r\n".into()),
387            Expect::new("AUTH PLAIN", "235 ok\r\n".into()),
388            Expect::new("MAIL FROM:<jane@example.com>", "250 ok\r\n".into()),
389            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
390            Expect::new("DATA", "354 go\r\n".into()),
391            Expect::new("QUIT", "221 bye\r\n".into()),
392        ]);
393        let envelope = Envelope {
394            sender: None,
395            notify: Some("never".into()),
396            ret: Some("full".into()),
397        };
398        send(
399            &oauth_test_account(port),
400            "secret",
401            "jane@example.com",
402            &["bob@example.org".into()],
403            b"Subject: hi\n\nbody\n",
404            &envelope,
405        )
406        .unwrap();
407        handle.join().unwrap();
408        let log = log.lock().unwrap();
409        assert!(log.contains(&"MAIL FROM:<jane@example.com>".to_string()));
410        assert!(log.contains(&"RCPT TO:<bob@example.org>".to_string()));
411    }
412
413    fn oauth_test_account(port: u16) -> crate::config::Account {
414        crate::config::Account {
415            name: "t".into(),
416            user: "jane".into(),
417            password_command: None,
418            password: None,
419            imap_host: None,
420            imap_port: 993,
421            imap_tls: true,
422            smtp_host: Some("127.0.0.1".into()),
423            smtp_port: port,
424            smtp_tls: false,
425            auth: None,
426            token_command: None,
427            sent_folder: "Sent".into(),
428            identity: None,
429        }
430    }
431
432    #[test]
433    fn falls_back_to_auth_login() {
434        let (port, handle, _log) = testserver::smtp(vec![
435            Expect::new("EHLO", "250-fake\r\n250 AUTH LOGIN\r\n".into()),
436            Expect::new("AUTH LOGIN", "334 VXNlcm5hbWU6\r\n".into()),
437            Expect::new(b64_static(b"jane"), "334 UGFzc3dvcmQ6\r\n".into()),
438            Expect::new(b64_static(b"secret"), "235 ok\r\n".into()),
439            Expect::new("MAIL FROM", "250 ok\r\n".into()),
440            Expect::new("RCPT TO", "250 ok\r\n".into()),
441            Expect::new("DATA", "354 go\r\n".into()),
442            Expect::new("QUIT", "221 bye\r\n".into()),
443        ]);
444        let account = crate::config::Account {
445            name: "t".into(),
446            user: "jane".into(),
447            password_command: None,
448            password: None,
449            imap_host: None,
450            imap_port: 993,
451            imap_tls: true,
452            smtp_host: Some("127.0.0.1".into()),
453            smtp_port: port,
454            smtp_tls: false,
455            auth: None,
456            token_command: None,
457            sent_folder: "Sent".into(),
458            identity: None,
459        };
460        send(
461            &account,
462            "secret",
463            "jane@x",
464            &["bob@y".into()],
465            b"hi\n",
466            &Envelope::default(),
467        )
468        .unwrap();
469        handle.join().unwrap();
470    }
471
472    // Leak a b64 value so Expect's &'static str signature is satisfied.
473    fn b64_static(input: &[u8]) -> &'static str {
474        Box::leak(b64(input).into_boxed_str())
475    }
476
477    #[test]
478    fn rejected_recipient_is_an_error() {
479        let (port, handle, _log) = testserver::smtp(vec![
480            Expect::new("EHLO", "250 test.example\r\n".into()),
481            Expect::new("AUTH PLAIN", "235 ok\r\n".into()),
482            Expect::new("MAIL FROM", "250 ok\r\n".into()),
483            Expect::new("RCPT TO", "550 no such user\r\n".into()),
484        ]);
485        let account = crate::config::Account {
486            name: "t".into(),
487            user: "jane".into(),
488            password_command: None,
489            password: None,
490            imap_host: None,
491            imap_port: 993,
492            imap_tls: true,
493            smtp_host: Some("127.0.0.1".into()),
494            smtp_port: port,
495            smtp_tls: false,
496            auth: None,
497            token_command: None,
498            sent_folder: "Sent".into(),
499            identity: None,
500        };
501        let err = send(
502            &account,
503            "s",
504            "jane@x",
505            &["bob@y".into()],
506            b"hi",
507            &Envelope::default(),
508        )
509        .unwrap_err();
510        assert!(format!("{err:#}").contains("no such user"));
511        handle.join().unwrap();
512    }
513}