1use std::fmt;
2use std::str::FromStr;
3
4use super::network::parse_network_url;
5use super::validation::{optional_text, redacted};
6use super::{ConfigError, Result, variables};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum EmailTransport {
11 Auto,
13 #[default]
15 Log,
16 MailerSend,
18 Smtp,
20}
21
22impl FromStr for EmailTransport {
23 type Err = ConfigError;
24
25 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
26 match value.to_ascii_lowercase().as_str() {
27 "auto" => Ok(Self::Auto),
28 "log" => Ok(Self::Log),
29 "mailersend" => Ok(Self::MailerSend),
30 "smtp" => Ok(Self::Smtp),
31 _ => Err(ConfigError::validation(
32 "email.transport",
33 "must be one of `auto`, `log`, `mailersend`, or `smtp`",
34 )),
35 }
36 }
37}
38
39impl EmailTransport {
40 pub const fn as_str(self) -> &'static str {
42 match self {
43 Self::Auto => "auto",
44 Self::Log => "log",
45 Self::MailerSend => "mailersend",
46 Self::Smtp => "smtp",
47 }
48 }
49}
50
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53pub enum EmailSmtpSecurity {
54 #[default]
56 None,
57 StartTls,
59 Tls,
61}
62
63impl FromStr for EmailSmtpSecurity {
64 type Err = ConfigError;
65
66 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
67 match value.to_ascii_lowercase().as_str() {
68 "none" => Ok(Self::None),
69 "starttls" => Ok(Self::StartTls),
70 "tls" => Ok(Self::Tls),
71 _ => Err(ConfigError::validation(
72 "email.smtp.security",
73 "must be one of `none`, `starttls`, or `tls`",
74 )),
75 }
76 }
77}
78
79impl EmailSmtpSecurity {
80 pub const fn as_str(self) -> &'static str {
82 match self {
83 Self::None => "none",
84 Self::StartTls => "starttls",
85 Self::Tls => "tls",
86 }
87 }
88}
89
90#[derive(Clone, Debug, Default, PartialEq, Eq)]
92pub struct EmailConfig {
93 pub transport: EmailTransport,
95 pub from_email: String,
97 pub from_name: String,
99 pub smtp: EmailSmtpConfig,
101 pub mailersend: EmailMailerSendConfig,
103}
104
105#[derive(Clone, Default, PartialEq, Eq)]
107pub struct EmailSmtpConfig {
108 pub host: String,
110 pub port: u16,
112 pub security: EmailSmtpSecurity,
114 pub username: Option<String>,
116 pub password: Option<String>,
118}
119
120impl fmt::Debug for EmailSmtpConfig {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 formatter
123 .debug_struct("EmailSmtpConfig")
124 .field("host", &self.host)
125 .field("port", &self.port)
126 .field("security", &self.security)
127 .field("username", &self.username.as_deref().map(redacted))
128 .field("password", &self.password.as_deref().map(redacted))
129 .finish()
130 }
131}
132
133#[derive(Clone, Default, PartialEq, Eq)]
135pub struct EmailMailerSendConfig {
136 pub api_url: String,
138}
139
140impl std::fmt::Debug for EmailMailerSendConfig {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.debug_struct("EmailMailerSendConfig")
143 .field("api_url", &redacted(&self.api_url))
144 .finish()
145 }
146}
147
148impl EmailConfig {
149 pub fn mailersend(from_email: impl Into<String>, from_name: impl Into<String>) -> Self {
151 Self {
152 transport: EmailTransport::MailerSend,
153 from_email: from_email.into(),
154 from_name: from_name.into(),
155 smtp: EmailSmtpConfig::default(),
156 mailersend: EmailMailerSendConfig {
157 api_url: "https://api.mailersend.com/v1/email".to_owned(),
158 },
159 }
160 }
161
162 pub fn smtp(
164 from_email: impl Into<String>,
165 from_name: impl Into<String>,
166 host: impl Into<String>,
167 port: u16,
168 security: EmailSmtpSecurity,
169 ) -> Self {
170 Self {
171 transport: EmailTransport::Smtp,
172 from_email: from_email.into(),
173 from_name: from_name.into(),
174 smtp: EmailSmtpConfig {
175 host: host.into(),
176 port,
177 security,
178 username: None,
179 password: None,
180 },
181 mailersend: EmailMailerSendConfig {
182 api_url: "https://api.mailersend.com/v1/email".to_owned(),
183 },
184 }
185 }
186
187 pub(crate) fn apply_environment(
188 &mut self,
189 value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
190 ) -> Result<()> {
191 if let Some(transport) = variables::parsed(
192 "EMAIL_TRANSPORT",
193 value("EMAIL_TRANSPORT")?,
194 "must be one of `auto`, `log`, `mailersend`, or `smtp`",
195 )? {
196 self.transport = transport;
197 }
198 if let Some(from_email) = value("EMAIL_FROM_EMAIL")? {
199 self.from_email = from_email;
200 }
201 if let Some(from_name) = value("EMAIL_FROM_NAME")? {
202 self.from_name = from_name;
203 }
204 if let Some(host) = value("SMTP_HOST")? {
205 self.smtp.host = host;
206 }
207 if let Some(port) = variables::parsed(
208 "SMTP_PORT",
209 value("SMTP_PORT")?,
210 "must be an integer TCP port",
211 )? {
212 self.smtp.port = port;
213 }
214 if let Some(security) = variables::parsed(
215 "SMTP_SECURITY",
216 value("SMTP_SECURITY")?,
217 "must be one of `none`, `starttls`, or `tls`",
218 )? {
219 self.smtp.security = security;
220 }
221 if let Some(username) = value("SMTP_USERNAME")? {
222 self.smtp.username = Some(username);
223 }
224 if let Some(password) = value("SMTP_PASSWORD")? {
225 self.smtp.password = Some(password);
226 }
227 if let Some(api_url) = value("MAILERSEND_API_URL")? {
228 self.mailersend.api_url = api_url;
229 }
230 Ok(())
231 }
232
233 pub fn is_configured(&self) -> bool {
235 self.transport != EmailTransport::Auto
236 || !self.from_email.is_empty()
237 || !self.from_name.is_empty()
238 || self.smtp != EmailSmtpConfig::default()
239 || self.mailersend != EmailMailerSendConfig::default()
240 }
241
242 pub fn validate(&self) -> Result<()> {
244 if !self.from_email.is_empty() {
245 let address = self.from_email.trim();
246 if address.is_empty()
247 || email_address::EmailAddress::parse_with_options(address, Default::default())
248 .is_err()
249 {
250 return Err(ConfigError::validation(
251 "email.from_email",
252 "must be a valid email address",
253 ));
254 }
255 }
256
257 optional_text(
258 "email.mailersend.api_url",
259 (!self.mailersend.api_url.is_empty()).then_some(self.mailersend.api_url.as_str()),
260 )?;
261 if !self.mailersend.api_url.is_empty() {
262 parse_network_url(
263 "email.mailersend.api_url",
264 &self.mailersend.api_url,
265 &["https"],
266 false,
267 "must be an HTTPS URL with a host and no embedded credentials",
268 )?;
269 }
270
271 if !self.is_configured() || self.transport == EmailTransport::Log {
272 return Ok(());
273 }
274 if self.from_email.trim().is_empty() {
275 return Err(ConfigError::validation(
276 "email.from_email",
277 "must not be empty for message delivery",
278 ));
279 }
280 if self.from_name.trim().is_empty() {
281 return Err(ConfigError::validation(
282 "email.from_name",
283 "must not be empty for message delivery",
284 ));
285 }
286 match self.transport {
287 EmailTransport::Auto | EmailTransport::Log => Ok(()),
288 EmailTransport::MailerSend => {
289 if self.mailersend.api_url.trim().is_empty() {
290 Err(ConfigError::validation(
291 "email.mailersend.api_url",
292 "must not be empty for MailerSend delivery",
293 ))
294 } else {
295 Ok(())
296 }
297 }
298 EmailTransport::Smtp => {
299 if self.smtp.host.trim().is_empty() {
300 return Err(ConfigError::validation(
301 "email.smtp.host",
302 "must not be empty for SMTP delivery",
303 ));
304 }
305 if self.smtp.port == 0 {
306 return Err(ConfigError::validation(
307 "email.smtp.port",
308 "must be greater than zero",
309 ));
310 }
311 optional_text("email.smtp.username", self.smtp.username.as_deref())?;
312 optional_text("email.smtp.password", self.smtp.password.as_deref())?;
313 if self.smtp.username.is_some() != self.smtp.password.is_some() {
314 return Err(ConfigError::validation(
315 "email.smtp",
316 "username and password must be provided together",
317 ));
318 }
319 Ok(())
320 }
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn mailersend_config(from_email: &str, api_url: &str) -> EmailConfig {
330 EmailConfig {
331 transport: EmailTransport::MailerSend,
332 from_email: from_email.to_owned(),
333 from_name: "Example".to_owned(),
334 smtp: EmailSmtpConfig::default(),
335 mailersend: EmailMailerSendConfig {
336 api_url: api_url.to_owned(),
337 },
338 }
339 }
340
341 #[test]
342 fn validates_sender_address_syntax() {
343 for invalid in ["not-an-address", "missing-domain@", "a b@example.com"] {
344 let error = mailersend_config(invalid, "https://api.example.invalid/v1/email")
345 .validate()
346 .expect_err("invalid sender must fail");
347 assert!(error.to_string().contains("email.from_email"));
348 assert!(!error.to_string().contains(invalid));
349 }
350
351 mailersend_config(
352 "sender+tag@example.invalid",
353 "https://api.example.invalid/v1/email",
354 )
355 .validate()
356 .expect("valid sender should pass");
357 }
358
359 #[test]
360 fn mailersend_requires_https_host_without_credentials() {
361 for invalid in [
362 "http://api.example.invalid/v1/email",
363 "https:///v1/email",
364 "https://user:secret@api.example.invalid/v1/email",
365 ] {
366 let error = mailersend_config("sender@example.invalid", invalid)
367 .validate()
368 .expect_err("invalid endpoint must fail");
369 assert!(error.to_string().contains("email.mailersend.api_url"));
370 assert!(!error.to_string().contains(invalid));
371 }
372 }
373
374 #[test]
375 fn inactive_configured_mailersend_endpoint_is_still_validated() {
376 let mut config = EmailConfig::default();
377 config.mailersend.api_url = "http://api.example.invalid".to_owned();
378 assert!(config.validate().is_err());
379 }
380}