1use super::reply::{Reply, complete_reply_end};
8use super::stream::{self, SmtpStream};
9use super::{Encryption, SmtpConfig, may_authenticate};
10use crate::encode::base64_encode;
11use rustlavel_core::{Error, Result};
12use std::time::Duration;
13use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
14
15const MAX_REPLY_BYTES: usize = 64 * 1024;
17
18pub struct SmtpClient<S> {
20 stream: S,
21 buffer: Vec<u8>,
23 timeout: Duration,
24 capabilities: Vec<String>,
26 server: String,
29}
30
31impl<S: AsyncRead + AsyncWrite + Unpin> SmtpClient<S> {
32 pub async fn open(stream: S, server: impl Into<String>, timeout: Duration) -> Result<Self> {
34 let mut client = SmtpClient {
35 stream,
36 buffer: Vec::with_capacity(4096),
37 timeout,
38 capabilities: Vec::new(),
39 server: server.into(),
40 };
41
42 let greeting = client.read_reply().await?;
43 client.require(greeting, "the connection", &[220])?;
44 Ok(client)
45 }
46
47 pub async fn ehlo(&mut self, name: &str) -> Result<()> {
52 let reply = self.command(&format!("EHLO {name}")).await?;
53
54 if reply.is_positive() {
55 self.capabilities =
57 reply.lines.iter().skip(1).map(|line| line.trim().to_uppercase()).collect();
58 return Ok(());
59 }
60
61 let fallback = self.command(&format!("HELO {name}")).await?;
62 self.require(fallback, "HELO", &[250])?;
63 self.capabilities.clear();
64 Ok(())
65 }
66
67 pub fn capabilities(&self) -> &[String] {
69 &self.capabilities
70 }
71
72 pub fn supports(&self, capability: &str) -> bool {
74 let capability = capability.to_uppercase();
75 self.capabilities
76 .iter()
77 .any(|line| line == &capability || line.starts_with(&format!("{capability} ")))
78 }
79
80 pub fn auth_mechanisms(&self) -> Vec<String> {
82 self.capabilities
83 .iter()
84 .filter_map(|line| line.strip_prefix("AUTH").map(|rest| rest.trim_start_matches('=')))
85 .flat_map(|rest| rest.split_whitespace())
86 .map(str::to_string)
87 .collect()
88 }
89
90 pub async fn authenticate(&mut self, username: &str, password: &str) -> Result<()> {
95 let mechanisms = self.auth_mechanisms();
96 let use_login = mechanisms.iter().any(|m| m == "LOGIN")
97 && !mechanisms.iter().any(|m| m == "PLAIN");
98
99 if use_login {
100 self.auth_login(username, password).await
101 } else {
102 self.auth_plain(username, password).await
103 }
104 }
105
106 async fn auth_plain(&mut self, username: &str, password: &str) -> Result<()> {
107 let secret = base64_encode(format!("\0{username}\0{password}").as_bytes());
109 let reply = self.command_hiding_payload("AUTH PLAIN", &secret).await?;
110 self.require(reply, "AUTH PLAIN", &[235])?;
111 Ok(())
112 }
113
114 async fn auth_login(&mut self, username: &str, password: &str) -> Result<()> {
115 let prompt = self.command("AUTH LOGIN").await?;
116 self.require(prompt, "AUTH LOGIN", &[334])?;
117
118 let user_reply = self.command_hiding_payload("", &base64_encode(username.as_bytes())).await?;
119 self.require(user_reply, "AUTH LOGIN (username)", &[334])?;
120
121 let final_reply =
122 self.command_hiding_payload("", &base64_encode(password.as_bytes())).await?;
123 self.require(final_reply, "AUTH LOGIN (password)", &[235])?;
124 Ok(())
125 }
126
127 pub async fn send_message(
132 &mut self,
133 from: &str,
134 recipients: &[String],
135 data: &str,
136 ) -> Result<()> {
137 let mail_from = self.command(&format!("MAIL FROM:<{from}>")).await?;
138 self.require(mail_from, &format!("MAIL FROM:<{from}>"), &[250, 251])?;
139
140 for recipient in recipients {
141 let reply = self.command(&format!("RCPT TO:<{recipient}>")).await?;
142 self.require(reply, &format!("RCPT TO:<{recipient}>"), &[250, 251])?;
143 }
144
145 let ready = self.command("DATA").await?;
146 self.require(ready, "DATA", &[354])?;
147
148 self.write_data(data).await?;
149 let accepted = self.read_reply().await?;
150 self.require(accepted, "the message body", &[250])?;
151 Ok(())
152 }
153
154 pub async fn quit(mut self) {
157 let _ = self.write_line("QUIT").await;
158 let _ = tokio::time::timeout(self.timeout, self.read_reply()).await;
159 let _ = self.stream.shutdown().await;
160 }
161
162 pub async fn command(&mut self, line: &str) -> Result<Reply> {
164 if !line.is_empty() {
165 rustlavel_core::debug!("smtp > {line}");
166 }
167 self.write_line(line).await?;
168 self.read_reply().await
169 }
170
171 async fn command_hiding_payload(&mut self, prefix: &str, secret: &str) -> Result<Reply> {
176 rustlavel_core::debug!("smtp > {} <credentials>", if prefix.is_empty() { "AUTH" } else { prefix });
177 let line =
178 if prefix.is_empty() { secret.to_string() } else { format!("{prefix} {secret}") };
179 self.write_line(&line).await?;
180 self.read_reply().await
181 }
182
183 async fn write_line(&mut self, line: &str) -> Result<()> {
184 self.write_all(format!("{line}\r\n").as_bytes()).await
185 }
186
187 async fn write_data(&mut self, data: &str) -> Result<()> {
189 self.write_all(data.as_bytes()).await?;
190 let terminator = if data.ends_with("\r\n") { ".\r\n" } else { "\r\n.\r\n" };
193 self.write_all(terminator.as_bytes()).await
194 }
195
196 async fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
197 let write = async {
198 self.stream.write_all(bytes).await?;
199 self.stream.flush().await
200 };
201
202 tokio::time::timeout(self.timeout, write)
203 .await
204 .map_err(|_| Error::msg(format!("timed out writing to {}", self.server)))?
205 .map_err(|e| Error::msg(format!("cannot write to {}: {e}", self.server)))
206 }
207
208 pub async fn read_reply(&mut self) -> Result<Reply> {
210 loop {
211 if let Some(end) = complete_reply_end(&self.buffer) {
212 let raw = String::from_utf8_lossy(&self.buffer[..end]).into_owned();
213 self.buffer.drain(..end);
214 let reply = Reply::parse(&raw)?;
215 rustlavel_core::debug!("smtp < {} {}", reply.code, reply.text());
216 return Ok(reply);
217 }
218
219 if self.buffer.len() > MAX_REPLY_BYTES {
220 return Err(Error::Protocol(format!(
221 "{} sent more than {MAX_REPLY_BYTES} bytes without finishing a reply",
222 self.server
223 )));
224 }
225
226 let mut chunk = [0u8; 4096];
227 let read = tokio::time::timeout(self.timeout, self.stream.read(&mut chunk))
228 .await
229 .map_err(|_| {
230 Error::msg(format!(
231 "timed out waiting for a reply from {}. Is mail.host right, and is \
232 anything filtering port traffic?",
233 self.server
234 ))
235 })?
236 .map_err(|e| Error::msg(format!("cannot read from {}: {e}", self.server)))?;
237
238 if read == 0 {
239 return Err(Error::Protocol(format!(
240 "{} closed the connection without replying",
241 self.server
242 )));
243 }
244 self.buffer.extend_from_slice(&chunk[..read]);
245 }
246 }
247
248 fn require(&self, reply: Reply, doing: &str, accepted: &[u16]) -> Result<Reply> {
250 if accepted.contains(&reply.code) {
251 return Ok(reply);
252 }
253
254 let base = format!(
255 "{} rejected {doing} with {} {}",
256 self.server,
257 reply.code,
258 reply.text()
259 );
260
261 match advice(reply.code) {
262 Some(advice) => Err(Error::msg(format!("{base}\n {advice}"))),
263 None => Err(Error::msg(base)),
264 }
265 }
266}
267
268impl SmtpClient<SmtpStream> {
269 pub async fn connect(config: &SmtpConfig) -> Result<SmtpClient<SmtpStream>> {
272 let stream =
273 stream::connect(&config.host, config.port, config.implicit_tls(), config.timeout)
274 .await?;
275
276 let mut client = SmtpClient::open(stream, config.host.clone(), config.timeout).await?;
277 client.ehlo(&config.hello).await?;
278
279 if config.wants_starttls() {
280 if client.supports("STARTTLS") {
281 client = client.start_tls(&config.host, &config.hello).await?;
282 } else if config.requires_starttls() {
283 return Err(Error::msg(format!(
284 "{} does not offer STARTTLS, but mail.encryption asks for it. Either the \
285 port is wrong (465 for implicit TLS, 587 for STARTTLS) or the server is \
286 not the one you meant.",
287 config.host
288 )));
289 }
290 }
291
292 if !config.username.is_empty() {
293 if !may_authenticate(client.stream.is_encrypted(), &config.host) {
294 return Err(Error::msg(format!(
295 "refusing to send the password for `{}` to {} over an unencrypted \
296 connection. Use port 587 with STARTTLS, or 465, or set mail.encryption to \
297 `none` only for a mail catcher on localhost.",
298 config.username, config.host
299 )));
300 }
301 client.authenticate(&config.username, &config.password).await?;
302 } else if config.encryption == Encryption::StartTls && !client.stream.is_encrypted() {
303 return Err(Error::msg(format!("could not establish TLS with {}", config.host)));
304 }
305
306 Ok(client)
307 }
308
309 pub async fn start_tls(
316 mut self,
317 host: &str,
318 hello: &str,
319 ) -> Result<SmtpClient<SmtpStream>> {
320 let reply = self.command("STARTTLS").await?;
321 self.require(reply, "STARTTLS", &[220])?;
322
323 if !self.buffer.is_empty() {
324 return Err(Error::Protocol(format!(
325 "{host} sent data after agreeing to STARTTLS, before the handshake — refusing \
326 to continue"
327 )));
328 }
329
330 let upgraded = self.stream.upgrade(host).await?;
331 let mut client = SmtpClient {
332 stream: upgraded,
333 buffer: Vec::with_capacity(4096),
334 timeout: self.timeout,
335 capabilities: Vec::new(),
336 server: self.server,
337 };
338
339 client.ehlo(hello).await?;
340 Ok(client)
341 }
342
343 pub fn is_encrypted(&self) -> bool {
344 self.stream.is_encrypted()
345 }
346}
347
348fn advice(code: u16) -> Option<&'static str> {
350 match code {
351 421 => Some("The server is closing the connection — it is overloaded or throttling. Retry later."),
352 450..=452 => Some("A temporary failure: the message was not lost, but was not sent either. Retry."),
353 454 => Some("The server could not start TLS. Check mail.port and mail.encryption."),
354 530 => Some("The server wants authentication first. Set mail.username and mail.password."),
355 534 | 535 => Some("Authentication was refused. Check mail.username and mail.password — a provider may also need an app-specific password rather than the account one."),
356 550 | 551 | 553 => Some("The recipient was refused. Check the address, and whether this sender is allowed to send as that From address."),
357 552 => Some("The message is too large. Reduce the attachments."),
358 554 => Some("The server refused the transaction outright — often a spam rule, or a From address the server will not relay for."),
359 _ => None,
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use tokio::io::{AsyncBufReadExt, BufReader, DuplexStream};
367 use tokio::task::JoinHandle;
368
369 struct Step {
372 expects: &'static str,
373 replies: &'static str,
374 }
375
376 fn step(expects: &'static str, replies: &'static str) -> Step {
377 Step { expects, replies }
378 }
379
380 const BODY: &str = "<body>";
382
383 fn scripted(greeting: &'static str, script: Vec<Step>) -> (DuplexStream, JoinHandle<Vec<String>>) {
390 let (client, server) = tokio::io::duplex(64 * 1024);
391
392 let handle = tokio::spawn(async move {
393 let (reader, mut writer) = tokio::io::split(server);
394 let mut reader = BufReader::new(reader);
395 let mut said: Vec<String> = Vec::new();
396
397 writer.write_all(greeting.as_bytes()).await.expect("greeting");
398
399 for step in script {
400 if step.expects == BODY {
401 let mut body = String::new();
402 loop {
403 let mut line = String::new();
404 if reader.read_line(&mut line).await.unwrap_or(0) == 0 {
405 break;
406 }
407 if line == ".\r\n" || line == ".\n" {
408 break;
409 }
410 body.push_str(&line);
411 }
412 said.push(body);
413 } else {
414 let mut line = String::new();
415 if reader.read_line(&mut line).await.unwrap_or(0) == 0 {
416 break;
417 }
418 said.push(line.trim_end().to_string());
419 }
420 writer.write_all(step.replies.as_bytes()).await.expect("reply");
421 }
422
423 said
424 });
425
426 (client, handle)
427 }
428
429 fn timeout() -> Duration {
430 Duration::from_secs(5)
431 }
432
433 #[tokio::test]
434 async fn a_whole_conversation_runs_from_greeting_to_quit() {
435 let (pipe, server) = scripted(
436 "220 smtp.example.com ESMTP ready\r\n",
437 vec![
438 step(
439 "EHLO app.example.com",
440 "250-smtp.example.com\r\n250-SIZE 35882577\r\n250-AUTH PLAIN LOGIN\r\n250 8BITMIME\r\n",
441 ),
442 step("AUTH PLAIN AGFkYQBodW50ZXIy", "235 2.7.0 Accepted\r\n"),
443 step("MAIL FROM:<ada@example.com>", "250 2.1.0 Ok\r\n"),
444 step("RCPT TO:<grace@example.com>", "250 2.1.5 Ok\r\n"),
445 step("RCPT TO:<alan@example.com>", "250 2.1.5 Ok\r\n"),
446 step("DATA", "354 End data with <CR><LF>.<CR><LF>\r\n"),
447 step(BODY, "250 2.0.0 Ok: queued as 4A2B1\r\n"),
448 step("QUIT", "221 2.0.0 Bye\r\n"),
449 ],
450 );
451
452 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
453 client.ehlo("app.example.com").await.unwrap();
454
455 assert!(client.supports("8BITMIME"));
456 assert!(client.supports("size"));
457 assert!(!client.supports("STARTTLS"));
458 assert_eq!(client.auth_mechanisms(), vec!["PLAIN", "LOGIN"]);
459
460 client.authenticate("ada", "hunter2").await.unwrap();
461 client
462 .send_message(
463 "ada@example.com",
464 &["grace@example.com".into(), "alan@example.com".into()],
465 "Subject: Hi\r\n\r\nbody\r\n",
466 )
467 .await
468 .unwrap();
469 client.quit().await;
470
471 let said = server.await.unwrap();
472 assert_eq!(said[0], "EHLO app.example.com");
473 assert_eq!(said[1], "AUTH PLAIN AGFkYQBodW50ZXIy");
474 assert_eq!(said[2], "MAIL FROM:<ada@example.com>");
475 assert_eq!(said[3], "RCPT TO:<grace@example.com>");
476 assert_eq!(said[4], "RCPT TO:<alan@example.com>");
477 assert_eq!(said[5], "DATA");
478 assert_eq!(said[6], "Subject: Hi\r\n\r\nbody\r\n");
479 assert_eq!(said[7], "QUIT");
480 }
481
482 #[tokio::test]
483 async fn auth_login_is_used_when_it_is_the_only_mechanism_offered() {
484 let (pipe, server) = scripted(
485 "220 ready\r\n",
486 vec![
487 step("EHLO app", "250-hello\r\n250 AUTH LOGIN\r\n"),
488 step("AUTH LOGIN", "334 VXNlcm5hbWU6\r\n"),
489 step("YWRh", "334 UGFzc3dvcmQ6\r\n"),
490 step("aHVudGVyMg==", "235 Accepted\r\n"),
491 ],
492 );
493
494 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
495 client.ehlo("app").await.unwrap();
496 client.authenticate("ada", "hunter2").await.unwrap();
497
498 let said = server.await.unwrap();
499 assert_eq!(said, vec!["EHLO app", "AUTH LOGIN", "YWRh", "aHVudGVyMg=="]);
500 }
501
502 #[tokio::test]
503 async fn a_rejected_password_names_the_code_and_the_server_text_but_not_the_password() {
504 let (pipe, server) = scripted(
505 "220 ready\r\n",
506 vec![
507 step("EHLO app", "250-hello\r\n250 AUTH PLAIN\r\n"),
508 step(
509 "AUTH PLAIN AGFkYQBodW50ZXIy",
510 "535 5.7.8 Error: authentication failed: bad credentials\r\n",
511 ),
512 ],
513 );
514
515 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
516 client.ehlo("app").await.unwrap();
517 let error = client.authenticate("ada", "hunter2").await.unwrap_err().to_string();
518
519 assert!(error.contains("535"), "{error}");
520 assert!(error.contains("authentication failed: bad credentials"), "{error}");
521 assert!(error.contains("smtp.example.com"), "{error}");
522 assert!(error.contains("mail.username"), "{error}");
523 assert!(!error.contains("hunter2"), "{error}");
525 assert!(!error.contains("AGFkYQBodW50ZXIy"), "{error}");
526
527 let _ = server.await;
528 }
529
530 #[tokio::test]
531 async fn a_rejected_recipient_names_the_address_the_server_refused() {
532 let (pipe, server) = scripted(
533 "220 ready\r\n",
534 vec![
535 step("EHLO app", "250 hello\r\n"),
536 step("MAIL FROM:<ada@example.com>", "250 Ok\r\n"),
537 step("RCPT TO:<grace@example.com>", "250 Ok\r\n"),
538 step(
539 "RCPT TO:<nobody@example.com>",
540 "550 5.1.1 <nobody@example.com>: Recipient address rejected: User unknown\r\n",
541 ),
542 ],
543 );
544
545 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
546 client.ehlo("app").await.unwrap();
547
548 let error = client
549 .send_message(
550 "ada@example.com",
551 &["grace@example.com".into(), "nobody@example.com".into()],
552 "body",
553 )
554 .await
555 .unwrap_err()
556 .to_string();
557
558 assert!(error.contains("RCPT TO:<nobody@example.com>"), "{error}");
559 assert!(error.contains("550"), "{error}");
560 assert!(error.contains("User unknown"), "{error}");
561 assert!(error.contains("recipient was refused"), "{error}");
562
563 let _ = server.await;
564 }
565
566 #[tokio::test]
567 async fn a_server_offering_starttls_is_recognised_before_anything_is_sent() {
568 let (pipe, server) = scripted(
572 "220 ready\r\n",
573 vec![step("EHLO app", "250-hello\r\n250-STARTTLS\r\n250 AUTH=LOGIN PLAIN\r\n")],
574 );
575
576 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
577 client.ehlo("app").await.unwrap();
578
579 assert!(client.supports("STARTTLS"));
580 assert_eq!(client.auth_mechanisms(), vec!["LOGIN", "PLAIN"]);
582
583 let _ = server.await;
584 }
585
586 #[tokio::test]
587 async fn a_server_that_refuses_the_greeting_is_reported_as_such() {
588 let (pipe, server) = scripted("554 no service here\r\n", vec![]);
589
590 let message = match SmtpClient::open(pipe, "smtp.example.com", timeout()).await {
591 Err(error) => error.to_string(),
592 Ok(_) => panic!("a 554 greeting is not a usable session"),
593 };
594
595 assert!(message.contains("554"), "{message}");
596 assert!(message.contains("no service here"), "{message}");
597
598 let _ = server.await;
599 }
600
601 #[tokio::test]
602 async fn ehlo_falls_back_to_helo_on_an_old_server() {
603 let (pipe, server) = scripted(
604 "220 ready\r\n",
605 vec![
606 step("EHLO app", "500 5.5.1 Command unrecognized\r\n"),
607 step("HELO app", "250 hello\r\n"),
608 ],
609 );
610
611 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
612 client.ehlo("app").await.unwrap();
613
614 assert!(client.capabilities().is_empty());
615 assert_eq!(server.await.unwrap(), vec!["EHLO app", "HELO app"]);
616 }
617
618 #[tokio::test]
619 async fn a_body_ending_without_crlf_still_gets_a_terminator_of_its_own() {
620 let (pipe, server) = scripted(
621 "220 ready\r\n",
622 vec![
623 step("EHLO app", "250 hello\r\n"),
624 step("MAIL FROM:<ada@example.com>", "250 Ok\r\n"),
625 step("RCPT TO:<grace@example.com>", "250 Ok\r\n"),
626 step("DATA", "354 go\r\n"),
627 step(BODY, "250 queued\r\n"),
628 ],
629 );
630
631 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
632 client.ehlo("app").await.unwrap();
633 client
634 .send_message("ada@example.com", &["grace@example.com".into()], "no trailing newline")
635 .await
636 .unwrap();
637
638 let said = server.await.unwrap();
639 assert_eq!(said[4], "no trailing newline\r\n");
640 }
641
642 #[tokio::test]
643 async fn a_server_that_hangs_up_mid_conversation_says_so() {
644 let (pipe, server) = scripted("220 ready\r\n", vec![step("EHLO app", "")]);
646
647 let mut client = SmtpClient::open(pipe, "smtp.example.com", timeout()).await.unwrap();
648 let error = client.ehlo("app").await.unwrap_err().to_string();
649
650 assert!(error.contains("closed the connection"), "{error}");
651 assert!(error.contains("smtp.example.com"), "{error}");
652 let _ = server.await;
653 }
654
655 #[test]
656 fn advice_covers_the_codes_that_actually_come_up() {
657 assert!(advice(535).unwrap().contains("mail.username"));
658 assert!(advice(552).unwrap().contains("too large"));
659 assert!(advice(250).is_none());
660 }
661}