rustlavel_mail/smtp/
mod.rs1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Encryption {
23 #[default]
26 Auto,
27 StartTls,
29 Tls,
31 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#[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 pub timeout: Duration,
72 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 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 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 .field("password", &"<redacted>")
143 .field("encryption", &self.encryption)
144 .field("timeout", &self.timeout)
145 .field("hello", &self.hello)
146 .finish()
147 }
148}
149
150pub 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}