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    /// When `true`, rendered HTML is passed through a CSS inliner that
32    /// resolves rules from `<style>` blocks into per-element `style=""`
33    /// attributes. `<style>` is retained so `@media` rules (dark mode,
34    /// mobile) still apply on clients that honour them. Default: `true`.
35    pub inline_css: bool,
36    /// SMTP connection settings.
37    pub smtp: SmtpConfig,
38}
39
40impl Default for EmailConfig {
41    fn default() -> Self {
42        Self {
43            templates_path: "emails".into(),
44            layouts_path: "emails/layouts".into(),
45            default_from_name: String::new(),
46            default_from_email: String::new(),
47            default_reply_to: None,
48            default_locale: "en".into(),
49            cache_templates: true,
50            template_cache_size: 100,
51            inline_css: true,
52            smtp: SmtpConfig::default(),
53        }
54    }
55}
56
57/// SMTP connection settings nested under [`EmailConfig`].
58#[non_exhaustive]
59#[derive(Debug, Clone, Deserialize)]
60#[serde(default)]
61pub struct SmtpConfig {
62    /// SMTP server hostname. Default: `"localhost"`.
63    pub host: String,
64    /// SMTP server port. Default: `587`.
65    pub port: u16,
66    /// SMTP authentication username. Must be paired with `password`.
67    pub username: Option<String>,
68    /// SMTP authentication password. Must be paired with `username`.
69    pub password: Option<String>,
70    /// TLS mode for the SMTP connection. Default: [`SmtpSecurity::StartTls`].
71    pub security: SmtpSecurity,
72}
73
74impl Default for SmtpConfig {
75    fn default() -> Self {
76        Self {
77            host: "localhost".into(),
78            port: 587,
79            username: None,
80            password: None,
81            security: SmtpSecurity::default(),
82        }
83    }
84}
85
86/// TLS security mode for the SMTP connection.
87#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
88#[serde(rename_all = "lowercase")]
89pub enum SmtpSecurity {
90    /// Upgrade a plain connection to TLS via STARTTLS (default).
91    #[default]
92    StartTls,
93    /// Connect directly over TLS (implicit TLS / port 465).
94    Tls,
95    /// No encryption — use only in development or with a local relay.
96    None,
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn email_config_defaults() {
105        let config = EmailConfig::default();
106        assert_eq!(config.templates_path, "emails");
107        assert_eq!(config.layouts_path, "emails/layouts");
108        assert_eq!(config.default_from_name, "");
109        assert_eq!(config.default_from_email, "");
110        assert!(config.default_reply_to.is_none());
111        assert_eq!(config.default_locale, "en");
112        assert!(config.cache_templates);
113        assert_eq!(config.template_cache_size, 100);
114        assert!(config.inline_css);
115    }
116
117    #[test]
118    fn smtp_config_defaults() {
119        let config = SmtpConfig::default();
120        assert_eq!(config.host, "localhost");
121        assert_eq!(config.port, 587);
122        assert!(config.username.is_none());
123        assert!(config.password.is_none());
124        assert_eq!(config.security, SmtpSecurity::StartTls);
125    }
126
127    #[test]
128    fn email_config_from_yaml() {
129        let yaml = r#"
130            templates_path: custom/emails
131            default_from_name: TestApp
132            default_from_email: test@example.com
133            default_reply_to: reply@example.com
134            default_locale: uk
135            cache_templates: false
136            template_cache_size: 50
137            inline_css: false
138            smtp:
139              host: smtp.example.com
140              port: 465
141              username: user
142              password: pass
143              security: tls
144        "#;
145        let config: EmailConfig = serde_yaml_ng::from_str(yaml).unwrap();
146        assert_eq!(config.templates_path, "custom/emails");
147        assert_eq!(config.default_from_name, "TestApp");
148        assert_eq!(config.default_from_email, "test@example.com");
149        assert_eq!(
150            config.default_reply_to.as_deref(),
151            Some("reply@example.com")
152        );
153        assert_eq!(config.default_locale, "uk");
154        assert!(!config.cache_templates);
155        assert_eq!(config.template_cache_size, 50);
156        assert!(!config.inline_css);
157        assert_eq!(config.smtp.host, "smtp.example.com");
158        assert_eq!(config.smtp.port, 465);
159        assert_eq!(config.smtp.username.as_deref(), Some("user"));
160        assert_eq!(config.smtp.password.as_deref(), Some("pass"));
161        assert_eq!(config.smtp.security, SmtpSecurity::Tls);
162    }
163
164    #[test]
165    fn email_config_partial_yaml_uses_defaults() {
166        let yaml = r#"
167            default_from_email: noreply@app.com
168        "#;
169        let config: EmailConfig = serde_yaml_ng::from_str(yaml).unwrap();
170        assert_eq!(config.templates_path, "emails");
171        assert_eq!(config.default_from_email, "noreply@app.com");
172        assert_eq!(config.smtp.host, "localhost");
173        assert_eq!(config.smtp.port, 587);
174    }
175
176    #[test]
177    fn smtp_security_none_variant() {
178        let yaml = r#"security: none"#;
179        let config: SmtpConfig = serde_yaml_ng::from_str(yaml).unwrap();
180        assert_eq!(config.security, SmtpSecurity::None);
181    }
182
183    #[test]
184    fn email_config_inline_css_default_true() {
185        let config = EmailConfig::default();
186        assert!(config.inline_css);
187    }
188
189    #[test]
190    fn email_config_inline_css_from_yaml() {
191        let yaml = "inline_css: false";
192        let config: EmailConfig = serde_yaml_ng::from_str(yaml).unwrap();
193        assert!(!config.inline_css);
194    }
195
196    #[test]
197    fn email_config_inline_css_omitted_uses_default() {
198        let yaml = "default_from_email: noreply@app.com";
199        let config: EmailConfig = serde_yaml_ng::from_str(yaml).unwrap();
200        assert!(config.inline_css);
201    }
202}