Skip to main content

aster_forge_mail/
config.rs

1//! Product-neutral mail runtime configuration normalization.
2//!
3//! Product crates still own the concrete configuration keys, default values, runtime config
4//! reading, and API error mapping. This module keeps recurring validation and normalization rules
5//! for mail-related config values, plus the product-neutral runtime settings model used by shared
6//! sender implementations.
7
8use std::error::Error;
9use std::fmt;
10
11use aster_forge_utils::bool_like::parse_bool_like;
12use aster_forge_validation::email::normalize_email;
13
14/// Maximum subject length accepted by the shared mail template normalizer.
15pub const MAIL_TEMPLATE_MAX_SUBJECT_LEN: usize = 255;
16
17/// Maximum HTML body length accepted by the shared mail template normalizer.
18pub const MAIL_TEMPLATE_MAX_BODY_LEN: usize = 64 * 1024;
19
20/// Default SMTP port used by Aster services when runtime config is absent.
21pub const DEFAULT_MAIL_SMTP_PORT: u16 = 587;
22
23/// Default SMTP encryption policy used by Aster services when runtime config is absent.
24pub const DEFAULT_MAIL_SECURITY: bool = true;
25
26/// Runtime SMTP settings shared by Aster service mail senders.
27///
28/// Product crates still own config keys, persistence, validation error mapping,
29/// and transport error mapping. This struct only keeps the repeated SMTP
30/// readiness rules and sender envelope values in one place.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct MailRuntimeSettings {
33    /// SMTP relay host.
34    pub smtp_host: String,
35    /// SMTP relay port.
36    pub smtp_port: u16,
37    /// Optional SMTP username.
38    pub smtp_username: String,
39    /// Optional SMTP password.
40    pub smtp_password: String,
41    /// Sender email address.
42    pub from_address: String,
43    /// Sender display name.
44    pub from_name: String,
45    /// Whether TLS/STARTTLS transport should be used.
46    pub encryption_enabled: bool,
47}
48
49impl MailRuntimeSettings {
50    /// Returns whether the minimum outbound mail settings are configured.
51    pub fn is_configured(&self) -> bool {
52        !self.smtp_host.trim().is_empty() && !self.from_address.trim().is_empty()
53    }
54
55    /// Returns whether settings are ready for a delivery attempt.
56    ///
57    /// The SMTP auth fields are intentionally all-or-nothing so products do not
58    /// accidentally attempt passwordless auth or send a password without a user.
59    pub fn is_ready_for_delivery(&self) -> bool {
60        self.is_configured()
61            && self.smtp_username.trim().is_empty() == self.smtp_password.trim().is_empty()
62    }
63}
64
65/// Error returned when mail configuration normalization fails.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MailConfigError {
68    message: String,
69}
70
71impl MailConfigError {
72    /// Creates a mail configuration validation error.
73    pub fn new(message: impl Into<String>) -> Self {
74        Self {
75            message: message.into(),
76        }
77    }
78
79    /// Returns the validation failure message.
80    pub fn message(&self) -> &str {
81        &self.message
82    }
83}
84
85impl fmt::Display for MailConfigError {
86    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87        formatter.write_str(&self.message)
88    }
89}
90
91impl Error for MailConfigError {}
92
93/// Result type returned by shared mail configuration helpers.
94pub type MailConfigResult<T> = std::result::Result<T, MailConfigError>;
95
96/// Parses an SMTP port from a storage string.
97pub fn parse_smtp_port(value: &str) -> Option<u16> {
98    value.trim().parse::<u16>().ok().filter(|port| *port > 0)
99}
100
101/// Normalizes an SMTP host value.
102///
103/// Empty values are allowed so products can represent "mail is not configured"
104/// without introducing product-specific sentinel values.
105pub fn normalize_smtp_host_config_value(value: &str) -> MailConfigResult<String> {
106    let normalized = value.trim().to_ascii_lowercase();
107    if normalized.is_empty() {
108        return Ok(String::new());
109    }
110    if normalized.contains(char::is_whitespace) {
111        return Err(MailConfigError::new("mail_smtp_host cannot contain spaces"));
112    }
113    Ok(normalized)
114}
115
116/// Normalizes an SMTP port value.
117pub fn normalize_smtp_port_config_value(value: &str) -> MailConfigResult<String> {
118    let Some(port) = parse_smtp_port(value) else {
119        return Err(MailConfigError::new(
120            "mail_smtp_port must be an integer between 1 and 65535",
121        ));
122    };
123    Ok(port.to_string())
124}
125
126/// Normalizes a sender email address value.
127///
128/// Empty values are allowed so products can leave outbound mail disabled until
129/// an operator configures both SMTP host and sender address.
130pub fn normalize_mail_address_config_value(value: &str) -> MailConfigResult<String> {
131    let normalized = value.trim().to_ascii_lowercase();
132    if normalized.is_empty() {
133        return Ok(String::new());
134    }
135    normalize_email(&normalized).map_err(|error| MailConfigError::new(error.to_string()))
136}
137
138/// Normalizes a sender display name value.
139pub fn normalize_mail_name_config_value(value: &str) -> MailConfigResult<String> {
140    let normalized = value.trim();
141    if normalized.len() > 128 {
142        return Err(MailConfigError::new(
143            "mail_from_name must be at most 128 characters",
144        ));
145    }
146    Ok(normalized.to_string())
147}
148
149/// Normalizes a bool-like mail security config value.
150pub fn normalize_mail_security_config_value(value: &str) -> MailConfigResult<String> {
151    match parse_bool_like(value) {
152        Some(value) => Ok(if value { "true" } else { "false" }.to_string()),
153        None => Err(MailConfigError::new(
154            "mail_security must be 'true' or 'false'",
155        )),
156    }
157}
158
159/// Normalizes a mail template subject.
160pub fn normalize_mail_template_subject_config_value(
161    key: &str,
162    value: &str,
163) -> MailConfigResult<String> {
164    let normalized = value.trim();
165    if normalized.is_empty() {
166        return Err(MailConfigError::new(format!("{key} cannot be empty")));
167    }
168    if normalized.contains(['\r', '\n']) {
169        return Err(MailConfigError::new(format!("{key} must be a single line")));
170    }
171    if normalized.len() > MAIL_TEMPLATE_MAX_SUBJECT_LEN {
172        return Err(MailConfigError::new(format!(
173            "{key} must be at most {MAIL_TEMPLATE_MAX_SUBJECT_LEN} characters",
174        )));
175    }
176    Ok(normalized.to_string())
177}
178
179/// Normalizes a mail template HTML body.
180pub fn normalize_mail_template_body_config_value(
181    key: &str,
182    value: &str,
183) -> MailConfigResult<String> {
184    let normalized = normalize_multiline(value);
185    if normalized.trim().is_empty() {
186        return Err(MailConfigError::new(format!("{key} cannot be empty")));
187    }
188    if normalized.len() > MAIL_TEMPLATE_MAX_BODY_LEN {
189        return Err(MailConfigError::new(format!(
190            "{key} must be at most {MAIL_TEMPLATE_MAX_BODY_LEN} characters",
191        )));
192    }
193    Ok(normalized)
194}
195
196fn normalize_multiline(value: &str) -> String {
197    value.replace("\r\n", "\n").replace('\r', "\n")
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{
203        DEFAULT_MAIL_SECURITY, DEFAULT_MAIL_SMTP_PORT, MailRuntimeSettings,
204        normalize_mail_address_config_value, normalize_mail_name_config_value,
205        normalize_mail_security_config_value, normalize_mail_template_body_config_value,
206        normalize_mail_template_subject_config_value, normalize_smtp_host_config_value,
207        normalize_smtp_port_config_value, parse_smtp_port,
208    };
209
210    #[test]
211    fn smtp_host_normalizer_allows_empty_and_rejects_spaces() {
212        assert_eq!(normalize_smtp_host_config_value("  ").unwrap(), "");
213        assert_eq!(
214            normalize_smtp_host_config_value(" SMTP.Example.COM ").unwrap(),
215            "smtp.example.com"
216        );
217        assert!(normalize_smtp_host_config_value("smtp example.com").is_err());
218    }
219
220    #[test]
221    fn smtp_port_normalizer_accepts_valid_ports_only() {
222        assert_eq!(parse_smtp_port("587"), Some(587));
223        assert_eq!(parse_smtp_port("0"), None);
224        assert_eq!(parse_smtp_port("65536"), None);
225        assert_eq!(normalize_smtp_port_config_value(" 465 ").unwrap(), "465");
226        assert!(normalize_smtp_port_config_value("0").is_err());
227    }
228
229    #[test]
230    fn mail_runtime_settings_report_readiness() {
231        let mut settings = MailRuntimeSettings {
232            smtp_host: "smtp.example.com".to_string(),
233            smtp_port: DEFAULT_MAIL_SMTP_PORT,
234            smtp_username: String::new(),
235            smtp_password: String::new(),
236            from_address: "ops@example.com".to_string(),
237            from_name: "Aster Ops".to_string(),
238            encryption_enabled: DEFAULT_MAIL_SECURITY,
239        };
240        assert!(settings.is_configured());
241        assert!(settings.is_ready_for_delivery());
242
243        settings.smtp_password = "secret".to_string();
244        assert!(!settings.is_ready_for_delivery());
245
246        settings.smtp_username = "ops".to_string();
247        assert!(settings.is_ready_for_delivery());
248
249        settings.smtp_host.clear();
250        assert!(!settings.is_configured());
251        assert!(!settings.is_ready_for_delivery());
252    }
253
254    #[test]
255    fn mail_address_normalizer_allows_empty_and_validates_email_shape() {
256        assert_eq!(normalize_mail_address_config_value("  ").unwrap(), "");
257        assert_eq!(
258            normalize_mail_address_config_value(" Ops@Example.COM ").unwrap(),
259            "ops@example.com"
260        );
261        assert!(normalize_mail_address_config_value("ops@example").is_err());
262    }
263
264    #[test]
265    fn mail_name_normalizer_trims_and_limits_length() {
266        assert_eq!(
267            normalize_mail_name_config_value("  Aster Ops  ").unwrap(),
268            "Aster Ops"
269        );
270        assert!(normalize_mail_name_config_value(&"a".repeat(129)).is_err());
271    }
272
273    #[test]
274    fn mail_security_normalizer_accepts_bool_like_values() {
275        assert_eq!(
276            normalize_mail_security_config_value(" yes ").unwrap(),
277            "true"
278        );
279        assert_eq!(
280            normalize_mail_security_config_value("OFF").unwrap(),
281            "false"
282        );
283        assert!(normalize_mail_security_config_value("sometimes").is_err());
284    }
285
286    #[test]
287    fn template_subject_normalizer_rejects_empty_multiline_and_long_values() {
288        assert_eq!(
289            normalize_mail_template_subject_config_value("subject", "  Hello  ").unwrap(),
290            "Hello"
291        );
292        assert!(normalize_mail_template_subject_config_value("subject", "  ").is_err());
293        assert!(normalize_mail_template_subject_config_value("subject", "hello\nworld").is_err());
294        assert!(normalize_mail_template_subject_config_value("subject", &"a".repeat(256)).is_err());
295    }
296
297    #[test]
298    fn template_body_normalizer_converts_crlf_and_enforces_limits() {
299        assert_eq!(
300            normalize_mail_template_body_config_value("body", "line1\r\nline2").unwrap(),
301            "line1\nline2"
302        );
303        assert!(normalize_mail_template_body_config_value("body", "  ").is_err());
304        assert!(
305            normalize_mail_template_body_config_value("body", &"a".repeat(64 * 1024 + 1)).is_err()
306        );
307    }
308}