Skip to main content

modo/email/
config.rs

1use serde::Deserialize;
2
3/// Top-level email configuration.
4///
5/// Deserializes from YAML. All fields have sensible defaults, so only the
6/// fields that differ from defaults need to be specified.
7#[non_exhaustive]
8#[derive(Debug, Clone, Deserialize)]
9#[serde(default)]
10pub struct EmailConfig {
11    /// Directory containing email templates (locale sub-directories allowed).
12    /// Default: `"emails"`.
13    pub templates_path: String,
14    /// Directory containing custom HTML layout files.
15    /// Default: `"emails/layouts"`.
16    pub layouts_path: String,
17    /// Display name used in the `From` header when no [`SenderProfile`](crate::email::SenderProfile) is set.
18    pub default_from_name: String,
19    /// Email address used in the `From` header when no [`SenderProfile`](crate::email::SenderProfile) is set.
20    pub default_from_email: String,
21    /// Optional default `Reply-To` address.
22    pub default_reply_to: Option<String>,
23    /// BCP 47 locale used when a [`SendEmail`](crate::email::SendEmail) carries no explicit locale.
24    /// Default: `"en"`.
25    pub default_locale: String,
26    /// When `true`, templates are stored in an in-process LRU cache after the
27    /// first load. Default: `true`.
28    pub cache_templates: bool,
29    /// Maximum number of entries in the template LRU cache. Default: `100`.
30    pub template_cache_size: usize,
31    /// SMTP connection settings.
32    pub smtp: SmtpConfig,
33}
34
35impl Default for EmailConfig {
36    fn default() -> Self {
37        Self {
38            templates_path: "emails".into(),
39            layouts_path: "emails/layouts".into(),
40            default_from_name: String::new(),
41            default_from_email: String::new(),
42            default_reply_to: None,
43            default_locale: "en".into(),
44            cache_templates: true,
45            template_cache_size: 100,
46            smtp: SmtpConfig::default(),
47        }
48    }
49}
50
51/// SMTP connection settings nested under [`EmailConfig`].
52#[non_exhaustive]
53#[derive(Debug, Clone, Deserialize)]
54#[serde(default)]
55pub struct SmtpConfig {
56    /// SMTP server hostname. Default: `"localhost"`.
57    pub host: String,
58    /// SMTP server port. Default: `587`.
59    pub port: u16,
60    /// SMTP authentication username. Must be paired with `password`.
61    pub username: Option<String>,
62    /// SMTP authentication password. Must be paired with `username`.
63    pub password: Option<String>,
64    /// TLS mode for the SMTP connection. Default: [`SmtpSecurity::StartTls`].
65    pub security: SmtpSecurity,
66}
67
68impl Default for SmtpConfig {
69    fn default() -> Self {
70        Self {
71            host: "localhost".into(),
72            port: 587,
73            username: None,
74            password: None,
75            security: SmtpSecurity::default(),
76        }
77    }
78}
79
80/// TLS security mode for the SMTP connection.
81#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
82#[serde(rename_all = "lowercase")]
83pub enum SmtpSecurity {
84    /// Upgrade a plain connection to TLS via STARTTLS (default).
85    #[default]
86    StartTls,
87    /// Connect directly over TLS (implicit TLS / port 465).
88    Tls,
89    /// No encryption — use only in development or with a local relay.
90    None,
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn email_config_defaults() {
99        let config = EmailConfig::default();
100        assert_eq!(config.templates_path, "emails");
101        assert_eq!(config.layouts_path, "emails/layouts");
102        assert_eq!(config.default_from_name, "");
103        assert_eq!(config.default_from_email, "");
104        assert!(config.default_reply_to.is_none());
105        assert_eq!(config.default_locale, "en");
106        assert!(config.cache_templates);
107        assert_eq!(config.template_cache_size, 100);
108    }
109
110    #[test]
111    fn smtp_config_defaults() {
112        let config = SmtpConfig::default();
113        assert_eq!(config.host, "localhost");
114        assert_eq!(config.port, 587);
115        assert!(config.username.is_none());
116        assert!(config.password.is_none());
117        assert_eq!(config.security, SmtpSecurity::StartTls);
118    }
119
120    #[test]
121    fn email_config_from_yaml() {
122        let yaml = r#"
123            templates_path: custom/emails
124            default_from_name: TestApp
125            default_from_email: test@example.com
126            default_reply_to: reply@example.com
127            default_locale: uk
128            cache_templates: false
129            template_cache_size: 50
130            smtp:
131              host: smtp.example.com
132              port: 465
133              username: user
134              password: pass
135              security: tls
136        "#;
137        let config: EmailConfig = serde_yaml_ng::from_str(yaml).unwrap();
138        assert_eq!(config.templates_path, "custom/emails");
139        assert_eq!(config.default_from_name, "TestApp");
140        assert_eq!(config.default_from_email, "test@example.com");
141        assert_eq!(
142            config.default_reply_to.as_deref(),
143            Some("reply@example.com")
144        );
145        assert_eq!(config.default_locale, "uk");
146        assert!(!config.cache_templates);
147        assert_eq!(config.template_cache_size, 50);
148        assert_eq!(config.smtp.host, "smtp.example.com");
149        assert_eq!(config.smtp.port, 465);
150        assert_eq!(config.smtp.username.as_deref(), Some("user"));
151        assert_eq!(config.smtp.password.as_deref(), Some("pass"));
152        assert_eq!(config.smtp.security, SmtpSecurity::Tls);
153    }
154
155    #[test]
156    fn email_config_partial_yaml_uses_defaults() {
157        let yaml = r#"
158            default_from_email: noreply@app.com
159        "#;
160        let config: EmailConfig = serde_yaml_ng::from_str(yaml).unwrap();
161        assert_eq!(config.templates_path, "emails");
162        assert_eq!(config.default_from_email, "noreply@app.com");
163        assert_eq!(config.smtp.host, "localhost");
164        assert_eq!(config.smtp.port, 587);
165    }
166
167    #[test]
168    fn smtp_security_none_variant() {
169        let yaml = r#"security: none"#;
170        let config: SmtpConfig = serde_yaml_ng::from_str(yaml).unwrap();
171        assert_eq!(config.security, SmtpSecurity::None);
172    }
173}