Skip to main content

rustlavel_mail/smtp/
client.rs

1//! The SMTP conversation itself.
2//!
3//! Generic over the stream, so the same code that talks to a real server over
4//! TLS is exercised in tests against `tokio::io::duplex` — the entire protocol
5//! is covered without a socket, a port, or a server anywhere.
6
7use 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
15/// A reply longer than this is a server misbehaving, not a reply.
16const MAX_REPLY_BYTES: usize = 64 * 1024;
17
18/// One SMTP session.
19pub struct SmtpClient<S> {
20    stream: S,
21    /// Bytes read from the socket but not yet consumed as a reply.
22    buffer: Vec<u8>,
23    timeout: Duration,
24    /// The capability lines `EHLO` returned, upper-cased.
25    capabilities: Vec<String>,
26    /// The server's name, for error messages — the only thing that makes an
27    /// SMTP failure actionable when three services send mail.
28    server: String,
29}
30
31impl<S: AsyncRead + AsyncWrite + Unpin> SmtpClient<S> {
32    /// Take over an open stream and read the server's greeting.
33    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    /// Say hello and learn what the server supports.
48    ///
49    /// Falls back to `HELO` when `EHLO` is refused: a server old enough to
50    /// reject it has no capabilities to report anyway.
51    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            // The first line is the greeting text, not a capability.
56            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    /// Every capability line, as the server sent them.
68    pub fn capabilities(&self) -> &[String] {
69        &self.capabilities
70    }
71
72    /// Whether a capability such as `STARTTLS` or `8BITMIME` was advertised.
73    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    /// The mechanisms listed on the `AUTH` capability line.
81    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    /// Authenticate, preferring `AUTH PLAIN` and falling back to `AUTH LOGIN`.
91    ///
92    /// Both send the password base64-encoded, which is not encryption — the
93    /// caller is responsible for having a TLS connection first.
94    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        // The PLAIN mechanism is authorize-id NUL authenticate-id NUL password.
108        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    /// Send one message: envelope, then data.
128    ///
129    /// Every recipient is offered separately, and a rejection names the address
130    /// the server refused — "send failed" tells nobody anything at 2am.
131    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    /// Say goodbye and close. A failure here is not worth reporting: the
155    /// message was already accepted.
156    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    /// Send one command and read its reply.
163    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    /// Send `prefix` followed by a secret, logging only the prefix.
172    ///
173    /// The base64 in an `AUTH` line is the password. It must never reach a log
174    /// file, an event, or an error message.
175    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    /// Write the message body and the terminating `.` line.
188    async fn write_data(&mut self, data: &str) -> Result<()> {
189        self.write_all(data.as_bytes()).await?;
190        // The terminator is CRLF `.` CRLF; if the body already ends in CRLF,
191        // adding another would put an empty line at the end of every message.
192        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    /// Read exactly one complete reply, however many lines it takes.
209    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    /// Accept the reply, or turn it into an error that says what to do.
249    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    /// Connect, greet, upgrade to TLS, and authenticate — everything up to the
270    /// point where a message can be sent.
271    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    /// Upgrade the connection in place, then greet again over TLS.
310    ///
311    /// Consumes the client because the stream changes type. Everything the
312    /// server said before the handshake is discarded: capabilities learned in
313    /// cleartext are not trustworthy, which is exactly the STARTTLS stripping
314    /// attack this re-`EHLO` defends against.
315    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
348/// What to do about the codes that actually come up.
349fn 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    /// One step of a scripted conversation: what the server expects to be told,
370    /// and what it answers.
371    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    /// The marker for "read a whole DATA block, not a single line".
381    const BODY: &str = "<body>";
382
383    /// A scripted SMTP server on an in-memory pipe.
384    ///
385    /// Returns the client end plus a handle that yields everything the client
386    /// said, so assertions happen on the test's thread where a failure is
387    /// readable. Each test gets its own pipe — there is no shared fixture and
388    /// no port, so tests can run concurrently without coordinating.
389    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        // The password, and its base64, must not be anywhere in the message.
524        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        // The handshake itself needs a TLS server and is not exercised offline;
569        // what is testable here is the decision that leads to it, which is
570        // where a misconfigured port actually goes wrong.
571        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        // Some servers still write the old `AUTH=` form.
581        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        // The server reads the EHLO, answers nothing, and drops the pipe.
645        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}