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 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
87fn 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
103fn 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 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
142fn 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
167fn 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 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 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}