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    // Port 465 is TLS from the first byte; anything else negotiates
43    // STARTTLS (unless smtp_tls = false, for tests).
44    let implicit_tls = account.smtp_tls && account.smtp_port == 465;
45    let mut conn = Conn::new(
46        net::connect(
47            host,
48            account.smtp_port,
49            implicit_tls,
50            &net::Cutoff::default(),
51        )?,
52        format!("{host}:{}", account.smtp_port),
53    );
54    expect(&mut conn, 220).context("SMTP greeting")?;
55    let mut caps = ehlo(&mut conn)?;
56    if account.smtp_tls && !implicit_tls {
57        command(&mut conn, "STARTTLS", 220)?;
58        let tcp = conn.into_stream().into_tcp()?;
59        conn = Conn::new(
60            net::wrap_tls(tcp, host)?,
61            format!("{host}:{}", account.smtp_port),
62        );
63        caps = ehlo(&mut conn)?;
64    }
65    authenticate(&mut conn, &caps, account, password).context("SMTP authentication")?;
66    let from = envelope.sender.as_deref().unwrap_or(from);
67    let dsn = offers(&caps, "DSN");
68    let mut mail_from = format!("MAIL FROM:<{from}>");
69    if let Some(ret) = envelope.ret.as_deref().filter(|_| dsn) {
70        mail_from += &format!(" RET={}", ret.to_ascii_uppercase());
71    }
72    command(&mut conn, &mail_from, 250)?;
73    for rcpt in rcpts {
74        let mut rcpt_to = format!("RCPT TO:<{rcpt}>");
75        if let Some(notify) = envelope.notify.as_deref().filter(|_| dsn) {
76            rcpt_to += &format!(" NOTIFY={}", notify.to_ascii_uppercase());
77        }
78        command(&mut conn, &rcpt_to, 250).with_context(|| format!("recipient {rcpt}"))?;
79    }
80    command(&mut conn, "DATA", 354)?;
81    conn.write_all(&dot_stuff(body))?;
82    expect(&mut conn, 250).context("message rejected after DATA")?;
83    let _ = conn.write_all(b"QUIT\r\n");
84    Ok(())
85}
86
87/// Whether the EHLO reply (its lines joined by "; ", each starting
88/// with the "250-"/"250 " code) names this extension.
89fn offers(caps: &str, extension: &str) -> bool {
90    caps.split("; ")
91        .filter_map(|line| line.get(4..))
92        .any(|line| {
93            line.split_whitespace()
94                .next()
95                .is_some_and(|word| word.eq_ignore_ascii_case(extension))
96        })
97}
98
99fn ehlo(conn: &mut Conn) -> Result<String> {
100    command(conn, &format!("EHLO {}", maildir::hostname()), 250)
101}
102
103/// AUTH PLAIN (or LOGIN when the server's EHLO offered only that) with
104/// the password; the OAuth kinds run their SASL mechanism with the
105/// access token in `secret`.
106fn authenticate(conn: &mut Conn, caps: &str, account: &Account, secret: &str) -> Result<()> {
107    let user = &account.user;
108    match account.auth_kind()? {
109        AuthKind::Password => {}
110        kind => {
111            let host = account.smtp_host.as_deref().unwrap_or_default();
112            let initial = kind.initial_response(user, secret, host, account.smtp_port);
113            command(conn, &format!("AUTH {}", kind.sasl_name()), 334)?;
114            command(conn, &b64(initial.as_bytes()), 235)?;
115            return Ok(());
116        }
117    }
118    // `caps` is the EHLO reply with its lines joined by "; ", each
119    // starting with the "250-"/"250 " code.
120    let caps = caps.to_ascii_uppercase();
121    let mechanisms = caps
122        .split("; ")
123        .filter_map(|line| line.get(4..))
124        .find_map(|line| line.trim_start().strip_prefix("AUTH "));
125    let login_only = mechanisms.is_some_and(|m| m.contains("LOGIN") && !m.contains("PLAIN"));
126    if login_only {
127        command(conn, "AUTH LOGIN", 334)?;
128        command(conn, &b64(user.as_bytes()), 334)?;
129        command(conn, &b64(secret.as_bytes()), 235)?;
130    } else {
131        let token = b64(format!("\0{user}\0{secret}").as_bytes());
132        command(conn, &format!("AUTH PLAIN {token}"), 235)?;
133    }
134    Ok(())
135}
136
137fn command(conn: &mut Conn, cmd: &str, want: u16) -> Result<String> {
138    conn.write_all(format!("{cmd}\r\n").as_bytes())?;
139    expect(conn, want)
140}
141
142/// Read one (possibly multi-line) reply and require the given code;
143/// 251 passes for 250 (forwarded recipient).
144fn expect(conn: &mut Conn, want: u16) -> Result<String> {
145    let mut text = String::new();
146    loop {
147        let line = conn.read_text_line()?;
148        ensure!(line.len() >= 3, "short SMTP reply: {line}");
149        let code: u16 = line[..3]
150            .parse()
151            .with_context(|| format!("malformed SMTP reply: {line}"))?;
152        if !text.is_empty() {
153            text.push_str("; ");
154        }
155        text.push_str(&line);
156        if line.as_bytes().get(3) == Some(&b'-') {
157            continue;
158        }
159        ensure!(
160            code == want || (want == 250 && code == 251),
161            "server said: {text}"
162        );
163        return Ok(text);
164    }
165}
166
167/// CRLF-normalize, escape leading dots, and add the `.` terminator.
168fn dot_stuff(body: &[u8]) -> Vec<u8> {
169    let mut lines: Vec<&[u8]> = body.split(|&b| b == b'\n').collect();
170    if lines.last() == Some(&&b""[..]) {
171        lines.pop();
172    }
173    let mut out = Vec::with_capacity(body.len() + 8);
174    for line in lines {
175        let line = line.strip_suffix(b"\r").unwrap_or(line);
176        if line.first() == Some(&b'.') {
177            out.push(b'.');
178        }
179        out.extend_from_slice(line);
180        out.extend_from_slice(b"\r\n");
181    }
182    out.extend_from_slice(b".\r\n");
183    out
184}
185
186const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
187
188pub(crate) fn b64(input: &[u8]) -> String {
189    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
190    for chunk in input.chunks(3) {
191        let b = [
192            chunk[0],
193            *chunk.get(1).unwrap_or(&0),
194            *chunk.get(2).unwrap_or(&0),
195        ];
196        let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
197        for i in 0..4 {
198            if i <= chunk.len() {
199                out.push(B64_ALPHABET[(n >> (18 - 6 * i)) as usize & 0x3f] as char);
200            } else {
201                out.push('=');
202            }
203        }
204    }
205    out
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::testserver::{self, Expect};
212
213    #[test]
214    fn b64_matches_known_vectors() {
215        assert_eq!(b64(b""), "");
216        assert_eq!(b64(b"f"), "Zg==");
217        assert_eq!(b64(b"fo"), "Zm8=");
218        assert_eq!(b64(b"foo"), "Zm9v");
219        assert_eq!(b64(b"\0jane\0secret"), "AGphbmUAc2VjcmV0");
220    }
221
222    #[test]
223    fn dot_stuff_escapes_and_terminates() {
224        assert_eq!(
225            dot_stuff(b"hi\n.dot\nend\n"),
226            b"hi\r\n..dot\r\nend\r\n.\r\n"
227        );
228        assert_eq!(
229            dot_stuff(b"already\r\ncrlf\r\n"),
230            b"already\r\ncrlf\r\n.\r\n"
231        );
232        assert_eq!(
233            dot_stuff(b"no trailing newline"),
234            b"no trailing newline\r\n.\r\n"
235        );
236        assert_eq!(dot_stuff(b""), b".\r\n");
237    }
238
239    #[test]
240    fn session_against_scripted_server() {
241        let (port, handle, log) = testserver::smtp(vec![
242            Expect::new(
243                "EHLO",
244                "250-test.example\r\n250 AUTH PLAIN LOGIN\r\n".into(),
245            ),
246            Expect::new("AUTH PLAIN AGphbmUAc2VjcmV0", "235 ok\r\n".into()),
247            Expect::new("MAIL FROM:<jane@example.com>", "250 ok\r\n".into()),
248            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
249            Expect::new("RCPT TO:<carol@example.org>", "251 forwarded\r\n".into()),
250            Expect::new("DATA", "354 go\r\n".into()),
251            Expect::new("QUIT", "221 bye\r\n".into()),
252        ]);
253        let account = crate::config::Account {
254            name: "t".into(),
255            user: "jane".into(),
256            password_command: None,
257            password: None,
258            imap_host: None,
259            imap_port: 993,
260            imap_tls: true,
261            smtp_host: Some("127.0.0.1".into()),
262            smtp_port: port,
263            smtp_tls: false,
264            auth: None,
265            token_command: None,
266            sent_folder: "Sent".into(),
267            identity: None,
268        };
269        send(
270            &account,
271            "secret",
272            "jane@example.com",
273            &["bob@example.org".into(), "carol@example.org".into()],
274            b"Subject: hi\n\n.leading dot\nbye\n",
275            &Envelope::default(),
276        )
277        .unwrap();
278        handle.join().unwrap();
279        let log = log.lock().unwrap();
280        let payload = log.iter().find(|l| l.contains("Subject")).unwrap();
281        assert!(payload.contains("..leading dot"));
282    }
283
284    #[test]
285    fn xoauth2_runs_the_sasl_exchange() {
286        let (port, handle, _log) = testserver::smtp(vec![
287            Expect::new("EHLO", "250-x\r\n250 AUTH XOAUTH2\r\n".into()),
288            Expect::new("AUTH XOAUTH2", "334 \r\n".into()),
289            // XOAUTH2 for user=jane token=tok, precomputed base64.
290            Expect::new("dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB", "235 ok\r\n".into()),
291            Expect::new("MAIL FROM:<jane@example.com>", "250 ok\r\n".into()),
292            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
293            Expect::new("DATA", "354 go\r\n".into()),
294            Expect::new("QUIT", "221 bye\r\n".into()),
295        ]);
296        let account = crate::config::Account {
297            auth: Some("xoauth2".into()),
298            ..oauth_test_account(port)
299        };
300        send(
301            &account,
302            "tok",
303            "jane@example.com",
304            &["bob@example.org".into()],
305            b"Subject: hi\n\nbody\n",
306            &Envelope::default(),
307        )
308        .unwrap();
309        handle.join().unwrap();
310    }
311
312    #[test]
313    fn envelope_sender_and_dsn_when_offered() {
314        let (port, handle, log) = testserver::smtp(vec![
315            Expect::new("EHLO", "250-x\r\n250-DSN\r\n250 AUTH PLAIN\r\n".into()),
316            Expect::new("AUTH PLAIN", "235 ok\r\n".into()),
317            Expect::new("MAIL FROM:<bounces@example.com>", "250 ok\r\n".into()),
318            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
319            Expect::new("DATA", "354 go\r\n".into()),
320            Expect::new("QUIT", "221 bye\r\n".into()),
321        ]);
322        let envelope = Envelope {
323            sender: Some("bounces@example.com".into()),
324            notify: Some("failure,delay".into()),
325            ret: Some("hdrs".into()),
326        };
327        send(
328            &oauth_test_account(port),
329            "secret",
330            "jane@example.com",
331            &["bob@example.org".into()],
332            b"Subject: hi\n\nbody\n",
333            &envelope,
334        )
335        .unwrap();
336        handle.join().unwrap();
337        let log = log.lock().unwrap();
338        assert!(log.contains(&"MAIL FROM:<bounces@example.com> RET=HDRS".to_string()));
339        assert!(log.contains(&"RCPT TO:<bob@example.org> NOTIFY=FAILURE,DELAY".to_string()));
340    }
341
342    #[test]
343    fn no_dsn_parameters_to_a_server_without_it() {
344        let (port, handle, log) = testserver::smtp(vec![
345            Expect::new("EHLO", "250-x\r\n250 AUTH PLAIN\r\n".into()),
346            Expect::new("AUTH PLAIN", "235 ok\r\n".into()),
347            Expect::new("MAIL FROM:<jane@example.com>", "250 ok\r\n".into()),
348            Expect::new("RCPT TO:<bob@example.org>", "250 ok\r\n".into()),
349            Expect::new("DATA", "354 go\r\n".into()),
350            Expect::new("QUIT", "221 bye\r\n".into()),
351        ]);
352        let envelope = Envelope {
353            sender: None,
354            notify: Some("never".into()),
355            ret: Some("full".into()),
356        };
357        send(
358            &oauth_test_account(port),
359            "secret",
360            "jane@example.com",
361            &["bob@example.org".into()],
362            b"Subject: hi\n\nbody\n",
363            &envelope,
364        )
365        .unwrap();
366        handle.join().unwrap();
367        let log = log.lock().unwrap();
368        assert!(log.contains(&"MAIL FROM:<jane@example.com>".to_string()));
369        assert!(log.contains(&"RCPT TO:<bob@example.org>".to_string()));
370    }
371
372    fn oauth_test_account(port: u16) -> crate::config::Account {
373        crate::config::Account {
374            name: "t".into(),
375            user: "jane".into(),
376            password_command: None,
377            password: None,
378            imap_host: None,
379            imap_port: 993,
380            imap_tls: true,
381            smtp_host: Some("127.0.0.1".into()),
382            smtp_port: port,
383            smtp_tls: false,
384            auth: None,
385            token_command: None,
386            sent_folder: "Sent".into(),
387            identity: None,
388        }
389    }
390
391    #[test]
392    fn falls_back_to_auth_login() {
393        let (port, handle, _log) = testserver::smtp(vec![
394            Expect::new("EHLO", "250-fake\r\n250 AUTH LOGIN\r\n".into()),
395            Expect::new("AUTH LOGIN", "334 VXNlcm5hbWU6\r\n".into()),
396            Expect::new(b64_static(b"jane"), "334 UGFzc3dvcmQ6\r\n".into()),
397            Expect::new(b64_static(b"secret"), "235 ok\r\n".into()),
398            Expect::new("MAIL FROM", "250 ok\r\n".into()),
399            Expect::new("RCPT TO", "250 ok\r\n".into()),
400            Expect::new("DATA", "354 go\r\n".into()),
401            Expect::new("QUIT", "221 bye\r\n".into()),
402        ]);
403        let account = crate::config::Account {
404            name: "t".into(),
405            user: "jane".into(),
406            password_command: None,
407            password: None,
408            imap_host: None,
409            imap_port: 993,
410            imap_tls: true,
411            smtp_host: Some("127.0.0.1".into()),
412            smtp_port: port,
413            smtp_tls: false,
414            auth: None,
415            token_command: None,
416            sent_folder: "Sent".into(),
417            identity: None,
418        };
419        send(
420            &account,
421            "secret",
422            "jane@x",
423            &["bob@y".into()],
424            b"hi\n",
425            &Envelope::default(),
426        )
427        .unwrap();
428        handle.join().unwrap();
429    }
430
431    // Leak a b64 value so Expect's &'static str signature is satisfied.
432    fn b64_static(input: &[u8]) -> &'static str {
433        Box::leak(b64(input).into_boxed_str())
434    }
435
436    #[test]
437    fn rejected_recipient_is_an_error() {
438        let (port, handle, _log) = testserver::smtp(vec![
439            Expect::new("EHLO", "250 test.example\r\n".into()),
440            Expect::new("AUTH PLAIN", "235 ok\r\n".into()),
441            Expect::new("MAIL FROM", "250 ok\r\n".into()),
442            Expect::new("RCPT TO", "550 no such user\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        let err = send(
461            &account,
462            "s",
463            "jane@x",
464            &["bob@y".into()],
465            b"hi",
466            &Envelope::default(),
467        )
468        .unwrap_err();
469        assert!(format!("{err:#}").contains("no such user"));
470        handle.join().unwrap();
471    }
472}