1use anyhow::{Context, Result, ensure};
6
7use crate::config::{Account, AuthKind};
8use crate::maildir;
9use crate::net::{self, Conn};
10
11#[derive(Debug, Clone, Default, PartialEq)]
15pub struct Envelope {
16 pub sender: Option<String>,
19 pub notify: Option<String>,
21 pub ret: Option<String>,
23}
24
25pub 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 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 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
102fn envelope_address(addr: &str) -> bool {
105 !addr.is_empty()
106 && !addr
107 .chars()
108 .any(|c| c <= ' ' || c == '\x7f' || c == '<' || c == '>')
109}
110
111fn dsn_value(value: &str) -> bool {
113 !value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == ',')
114}
115
116fn 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
132fn 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 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
171fn 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
196fn 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 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 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}