Skip to main content

rustlavel_mail/smtp/
mod.rs

1//! SMTP, written here rather than taken from a crate.
2//!
3//! The protocol is a line conversation: the server greets, the client says
4//! `EHLO` and learns what the server can do, they may upgrade to TLS, the
5//! client authenticates, and then each message is an envelope (`MAIL FROM`,
6//! one `RCPT TO` per recipient) followed by `DATA`.
7
8pub mod client;
9pub mod reply;
10pub mod stream;
11
12pub use client::SmtpClient;
13pub use reply::Reply;
14pub use stream::SmtpStream;
15
16use rustlavel_core::{Error, Result};
17use std::fmt;
18use std::time::Duration;
19
20/// How the connection is protected.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Encryption {
23    /// Implicit TLS on port 465, STARTTLS everywhere else when the server
24    /// offers it. The right answer almost always, and the default.
25    #[default]
26    Auto,
27    /// STARTTLS, and refuse to continue if the server does not offer it.
28    StartTls,
29    /// TLS from the first byte, the way port 465 works.
30    Tls,
31    /// Plaintext. Only sane against a mail catcher on localhost.
32    None,
33}
34
35impl Encryption {
36    pub fn parse(value: &str) -> Result<Encryption> {
37        match value.trim().to_ascii_lowercase().as_str() {
38            "" | "auto" => Ok(Encryption::Auto),
39            "tls" | "ssl" | "implicit" => Ok(Encryption::Tls),
40            "starttls" => Ok(Encryption::StartTls),
41            "none" | "off" | "null" => Ok(Encryption::None),
42            other => Err(Error::msg(format!(
43                "`{other}` is not an encryption mode. Use one of: auto, starttls, tls, none."
44            ))),
45        }
46    }
47
48    pub fn name(self) -> &'static str {
49        match self {
50            Encryption::Auto => "auto",
51            Encryption::StartTls => "starttls",
52            Encryption::Tls => "tls",
53            Encryption::None => "none",
54        }
55    }
56}
57
58/// Everything needed to reach one SMTP server.
59///
60/// `Debug` is written by hand: a config struct ends up in log lines and in
61/// error contexts, and a password that reaches either is a password that has
62/// to be rotated.
63#[derive(Clone)]
64pub struct SmtpConfig {
65    pub host: String,
66    pub port: u16,
67    pub username: String,
68    pub password: String,
69    pub encryption: Encryption,
70    /// How long any single read or write may take.
71    pub timeout: Duration,
72    /// The name announced in `EHLO`.
73    pub hello: String,
74}
75
76impl Default for SmtpConfig {
77    fn default() -> Self {
78        SmtpConfig {
79            host: "127.0.0.1".into(),
80            port: 587,
81            username: String::new(),
82            password: String::new(),
83            encryption: Encryption::Auto,
84            timeout: Duration::from_secs(30),
85            hello: "localhost".into(),
86        }
87    }
88}
89
90impl SmtpConfig {
91    pub fn new(host: impl Into<String>, port: u16) -> SmtpConfig {
92        SmtpConfig { host: host.into(), port, ..SmtpConfig::default() }
93    }
94
95    pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
96        self.username = username.into();
97        self.password = password.into();
98        self
99    }
100
101    pub fn encryption(mut self, encryption: Encryption) -> Self {
102        self.encryption = encryption;
103        self
104    }
105
106    pub fn address(&self) -> String {
107        format!("{}:{}", self.host, self.port)
108    }
109
110    /// Whether TLS is negotiated before the greeting, as port 465 requires.
111    pub fn implicit_tls(&self) -> bool {
112        match self.encryption {
113            Encryption::Tls => true,
114            Encryption::Auto => self.port == 465,
115            _ => false,
116        }
117    }
118
119    pub fn wants_starttls(&self) -> bool {
120        !self.implicit_tls() && matches!(self.encryption, Encryption::Auto | Encryption::StartTls)
121    }
122
123    pub fn requires_starttls(&self) -> bool {
124        !self.implicit_tls() && self.encryption == Encryption::StartTls
125    }
126
127    /// Safe to describe in a log line or an error.
128    pub fn describe(&self) -> String {
129        let user = if self.username.is_empty() { "anonymous" } else { self.username.as_str() };
130        format!("{}:{} as {} ({})", self.host, self.port, user, self.encryption.name())
131    }
132}
133
134impl fmt::Debug for SmtpConfig {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.debug_struct("SmtpConfig")
137            .field("host", &self.host)
138            .field("port", &self.port)
139            .field("username", &self.username)
140            // Never the password, not even redacted per character: a length is
141            // information too.
142            .field("password", &"<redacted>")
143            .field("encryption", &self.encryption)
144            .field("timeout", &self.timeout)
145            .field("hello", &self.hello)
146            .finish()
147    }
148}
149
150/// Whether credentials may be sent over this connection.
151///
152/// `AUTH PLAIN` is base64, not encryption: sending it over a cleartext socket
153/// hands the password to anything between here and the server. Refused unless
154/// the server is on this machine, where there is no wire to listen to.
155pub fn may_authenticate(encrypted: bool, host: &str) -> bool {
156    encrypted || is_loopback(host)
157}
158
159fn is_loopback(host: &str) -> bool {
160    matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") || host.starts_with("127.")
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn encryption_modes_parse_by_the_names_people_write_in_env_files() {
169        assert_eq!(Encryption::parse("").unwrap(), Encryption::Auto);
170        assert_eq!(Encryption::parse("SSL").unwrap(), Encryption::Tls);
171        assert_eq!(Encryption::parse(" starttls ").unwrap(), Encryption::StartTls);
172        assert!(Encryption::parse("maybe").unwrap_err().to_string().contains("auto, starttls"));
173    }
174
175    #[test]
176    fn port_465_means_tls_before_the_greeting() {
177        assert!(SmtpConfig::new("smtp.example.com", 465).implicit_tls());
178        assert!(!SmtpConfig::new("smtp.example.com", 587).implicit_tls());
179        assert!(SmtpConfig::new("smtp.example.com", 587).wants_starttls());
180        assert!(!SmtpConfig::new("smtp.example.com", 465).wants_starttls());
181
182        let off = SmtpConfig::new("localhost", 1025).encryption(Encryption::None);
183        assert!(!off.wants_starttls() && !off.implicit_tls());
184    }
185
186    #[test]
187    fn a_password_never_appears_in_debug_output_or_a_description() {
188        let config = SmtpConfig::new("smtp.example.com", 587).credentials("ada", "hunter2");
189
190        let debugged = format!("{config:?}");
191        assert!(!debugged.contains("hunter2"), "{debugged}");
192        assert!(debugged.contains("<redacted>"));
193
194        let described = config.describe();
195        assert!(!described.contains("hunter2"), "{described}");
196        assert!(described.contains("smtp.example.com:587 as ada"));
197    }
198
199    #[test]
200    fn credentials_are_only_sent_over_tls_or_to_this_machine() {
201        assert!(may_authenticate(true, "smtp.example.com"));
202        assert!(!may_authenticate(false, "smtp.example.com"));
203        assert!(may_authenticate(false, "localhost"));
204        assert!(may_authenticate(false, "127.0.0.1"));
205    }
206}