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    #[must_use]
52    pub fn is_configured(&self) -> bool {
53        !self.smtp_host.trim().is_empty() && !self.from_address.trim().is_empty()
54    }
55
56    /// Returns whether settings are ready for a delivery attempt.
57    ///
58    /// The SMTP auth fields are intentionally all-or-nothing so products do not
59    /// accidentally attempt passwordless auth or send a password without a user.
60    #[must_use]
61    pub fn is_ready_for_delivery(&self) -> bool {
62        self.is_configured()
63            && self.smtp_username.trim().is_empty() == self.smtp_password.trim().is_empty()
64    }
65}
66
67/// Error returned when mail configuration normalization fails.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct MailConfigError {
70    message: String,
71}
72
73impl MailConfigError {
74    /// Creates a mail configuration validation error.
75    pub fn new(message: impl Into<String>) -> Self {
76        Self {
77            message: message.into(),
78        }
79    }
80
81    /// Returns the validation failure message.
82    #[must_use]
83    pub fn message(&self) -> &str {
84        &self.message
85    }
86}
87
88impl fmt::Display for MailConfigError {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter.write_str(&self.message)
91    }
92}
93
94impl Error for MailConfigError {}
95
96/// Result type returned by shared mail configuration helpers.
97pub type MailConfigResult<T> = std::result::Result<T, MailConfigError>;
98
99/// Parses an SMTP port from a storage string.
100#[must_use]
101pub fn parse_smtp_port(value: &str) -> Option<u16> {
102    value.trim().parse::<u16>().ok().filter(|port| *port > 0)
103}
104
105/// Normalizes an SMTP host value.
106///
107/// Empty values are allowed so products can represent "mail is not configured"
108/// without introducing product-specific sentinel values.
109///
110/// # Errors
111///
112/// Returns [`MailConfigError`] when the supplied value violates the corresponding mail setting contract.
113pub fn normalize_smtp_host_config_value(value: &str) -> MailConfigResult<String> {
114    let normalized = value.trim().to_ascii_lowercase();
115    if normalized.is_empty() {
116        return Ok(String::new());
117    }
118    if normalized.contains(char::is_whitespace) {
119        return Err(MailConfigError::new("mail_smtp_host cannot contain spaces"));
120    }
121    Ok(normalized)
122}
123
124/// Normalizes an SMTP port value.
125///
126/// # Errors
127///
128/// Returns [`MailConfigError`] when the supplied value violates the corresponding mail setting contract.
129pub fn normalize_smtp_port_config_value(value: &str) -> MailConfigResult<String> {
130    let Some(port) = parse_smtp_port(value) else {
131        return Err(MailConfigError::new(
132            "mail_smtp_port must be an integer between 1 and 65535",
133        ));
134    };
135    Ok(port.to_string())
136}
137
138/// Normalizes a sender email address value.
139///
140/// Empty values are allowed so products can leave outbound mail disabled until
141/// an operator configures both SMTP host and sender address.
142///
143/// # Errors
144///
145/// Returns [`MailConfigError`] when the supplied value violates the corresponding mail setting contract.
146pub fn normalize_mail_address_config_value(value: &str) -> MailConfigResult<String> {
147    let normalized = value.trim().to_ascii_lowercase();
148    if normalized.is_empty() {
149        return Ok(String::new());
150    }
151    normalize_email(&normalized).map_err(|error| MailConfigError::new(error.to_string()))
152}
153
154/// Normalizes a sender display name value.
155///
156/// # Errors
157///
158/// Returns [`MailConfigError`] when the supplied value violates the corresponding mail setting contract.
159pub fn normalize_mail_name_config_value(value: &str) -> MailConfigResult<String> {
160    let normalized = value.trim();
161    if normalized.len() > 128 {
162        return Err(MailConfigError::new(
163            "mail_from_name must be at most 128 characters",
164        ));
165    }
166    Ok(normalized.to_string())
167}
168
169/// Normalizes a bool-like mail security config value.
170///
171/// # Errors
172///
173/// Returns [`MailConfigError`] when the supplied value violates the corresponding mail setting contract.
174pub fn normalize_mail_security_config_value(value: &str) -> MailConfigResult<String> {
175    match parse_bool_like(value) {
176        Some(value) => Ok(if value { "true" } else { "false" }.to_string()),
177        None => Err(MailConfigError::new(
178            "mail_security must be 'true' or 'false'",
179        )),
180    }
181}
182
183/// Normalizes a mail template subject.
184///
185/// # Errors
186///
187/// Returns [`MailConfigError`] when the supplied value violates the corresponding mail setting contract.
188pub fn normalize_mail_template_subject_config_value(
189    key: &str,
190    value: &str,
191) -> MailConfigResult<String> {
192    let normalized = value.trim();
193    if normalized.is_empty() {
194        return Err(MailConfigError::new(format!("{key} cannot be empty")));
195    }
196    if normalized.contains(['\r', '\n']) {
197        return Err(MailConfigError::new(format!("{key} must be a single line")));
198    }
199    if normalized.len() > MAIL_TEMPLATE_MAX_SUBJECT_LEN {
200        return Err(MailConfigError::new(format!(
201            "{key} must be at most {MAIL_TEMPLATE_MAX_SUBJECT_LEN} characters",
202        )));
203    }
204    Ok(normalized.to_string())
205}
206
207/// Normalizes a mail template HTML body.
208///
209/// # Errors
210///
211/// Returns [`MailConfigError`] when the supplied value violates the corresponding mail setting contract.
212pub fn normalize_mail_template_body_config_value(
213    key: &str,
214    value: &str,
215) -> MailConfigResult<String> {
216    let normalized = normalize_multiline(value);
217    if normalized.trim().is_empty() {
218        return Err(MailConfigError::new(format!("{key} cannot be empty")));
219    }
220    if normalized.len() > MAIL_TEMPLATE_MAX_BODY_LEN {
221        return Err(MailConfigError::new(format!(
222            "{key} must be at most {MAIL_TEMPLATE_MAX_BODY_LEN} characters",
223        )));
224    }
225    Ok(normalized)
226}
227
228fn normalize_multiline(value: &str) -> String {
229    value.replace("\r\n", "\n").replace('\r', "\n")
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{
235        DEFAULT_MAIL_SECURITY, DEFAULT_MAIL_SMTP_PORT, MailRuntimeSettings,
236        normalize_mail_address_config_value, normalize_mail_name_config_value,
237        normalize_mail_security_config_value, normalize_mail_template_body_config_value,
238        normalize_mail_template_subject_config_value, normalize_smtp_host_config_value,
239        normalize_smtp_port_config_value, parse_smtp_port,
240    };
241
242    #[test]
243    fn smtp_host_normalizer_allows_empty_and_rejects_spaces() {
244        assert_eq!(normalize_smtp_host_config_value("  ").unwrap(), "");
245        assert_eq!(
246            normalize_smtp_host_config_value(" SMTP.Example.COM ").unwrap(),
247            "smtp.example.com"
248        );
249        assert!(normalize_smtp_host_config_value("smtp example.com").is_err());
250    }
251
252    #[test]
253    fn smtp_port_normalizer_accepts_valid_ports_only() {
254        assert_eq!(parse_smtp_port("587"), Some(587));
255        assert_eq!(parse_smtp_port("0"), None);
256        assert_eq!(parse_smtp_port("65536"), None);
257        assert_eq!(normalize_smtp_port_config_value(" 465 ").unwrap(), "465");
258        assert!(normalize_smtp_port_config_value("0").is_err());
259    }
260
261    #[test]
262    fn mail_runtime_settings_report_readiness() {
263        let mut settings = MailRuntimeSettings {
264            smtp_host: "smtp.example.com".to_string(),
265            smtp_port: DEFAULT_MAIL_SMTP_PORT,
266            smtp_username: String::new(),
267            smtp_password: String::new(),
268            from_address: "ops@example.com".to_string(),
269            from_name: "Aster Ops".to_string(),
270            encryption_enabled: DEFAULT_MAIL_SECURITY,
271        };
272        assert!(settings.is_configured());
273        assert!(settings.is_ready_for_delivery());
274
275        settings.smtp_password = "secret".to_string();
276        assert!(!settings.is_ready_for_delivery());
277
278        settings.smtp_username = "ops".to_string();
279        assert!(settings.is_ready_for_delivery());
280
281        settings.smtp_host.clear();
282        assert!(!settings.is_configured());
283        assert!(!settings.is_ready_for_delivery());
284    }
285
286    #[test]
287    fn mail_address_normalizer_allows_empty_and_validates_email_shape() {
288        assert_eq!(normalize_mail_address_config_value("  ").unwrap(), "");
289        assert_eq!(
290            normalize_mail_address_config_value(" Ops@Example.COM ").unwrap(),
291            "ops@example.com"
292        );
293        assert!(normalize_mail_address_config_value("ops@example").is_err());
294    }
295
296    #[test]
297    fn mail_name_normalizer_trims_and_limits_length() {
298        assert_eq!(
299            normalize_mail_name_config_value("  Aster Ops  ").unwrap(),
300            "Aster Ops"
301        );
302        assert!(normalize_mail_name_config_value(&"a".repeat(129)).is_err());
303    }
304
305    #[test]
306    fn mail_security_normalizer_accepts_bool_like_values() {
307        assert_eq!(
308            normalize_mail_security_config_value(" yes ").unwrap(),
309            "true"
310        );
311        assert_eq!(
312            normalize_mail_security_config_value("OFF").unwrap(),
313            "false"
314        );
315        assert!(normalize_mail_security_config_value("sometimes").is_err());
316    }
317
318    #[test]
319    fn template_subject_normalizer_rejects_empty_multiline_and_long_values() {
320        assert_eq!(
321            normalize_mail_template_subject_config_value("subject", "  Hello  ").unwrap(),
322            "Hello"
323        );
324        assert!(normalize_mail_template_subject_config_value("subject", "  ").is_err());
325        assert!(normalize_mail_template_subject_config_value("subject", "hello\nworld").is_err());
326        assert!(normalize_mail_template_subject_config_value("subject", &"a".repeat(256)).is_err());
327    }
328
329    #[test]
330    fn template_body_normalizer_converts_crlf_and_enforces_limits() {
331        assert_eq!(
332            normalize_mail_template_body_config_value("body", "line1\r\nline2").unwrap(),
333            "line1\nline2"
334        );
335        assert!(normalize_mail_template_body_config_value("body", "  ").is_err());
336        assert!(
337            normalize_mail_template_body_config_value("body", &"a".repeat(64 * 1024 + 1)).is_err()
338        );
339    }
340}