Skip to main content

ch_config/config/
email.rs

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/// Email transport selection.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum EmailTransport {
11    /// Select a transport at the application edge.
12    Auto,
13    /// Write messages to application logs.
14    #[default]
15    Log,
16    /// Deliver through the MailerSend API.
17    MailerSend,
18    /// Deliver through SMTP.
19    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    /// Stable configuration spelling.
41    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/// SMTP transport security mode.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53pub enum EmailSmtpSecurity {
54    /// No transport encryption.
55    #[default]
56    None,
57    /// Upgrade the connection with STARTTLS.
58    StartTls,
59    /// Use TLS from connection establishment.
60    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    /// Stable configuration spelling.
81    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/// Email delivery settings.
91#[derive(Clone, Debug, Default, PartialEq, Eq)]
92pub struct EmailConfig {
93    /// Selected transport.
94    pub transport: EmailTransport,
95    /// Sender address.
96    pub from_email: String,
97    /// Sender display name.
98    pub from_name: String,
99    /// SMTP-specific settings.
100    pub smtp: EmailSmtpConfig,
101    /// MailerSend-specific settings.
102    pub mailersend: EmailMailerSendConfig,
103}
104
105/// SMTP connection and authentication settings.
106#[derive(Clone, Default, PartialEq, Eq)]
107pub struct EmailSmtpConfig {
108    /// SMTP server hostname.
109    pub host: String,
110    /// SMTP server port.
111    pub port: u16,
112    /// Transport security mode.
113    pub security: EmailSmtpSecurity,
114    /// Optional authentication username.
115    pub username: Option<String>,
116    /// Optional authentication password.
117    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/// MailerSend API settings.
134#[derive(Clone, Default, PartialEq, Eq)]
135pub struct EmailMailerSendConfig {
136    /// Email API endpoint.
137    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    /// Creates MailerSend delivery defaults for an application-owned sender.
150    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    /// Creates SMTP delivery defaults for an application-owned sender.
163    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    /// Reports whether email delivery settings are present.
234    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    /// Validates the selected delivery transport.
243    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}