aster_forge_mail/
config.rs1use std::error::Error;
9use std::fmt;
10
11use aster_forge_utils::bool_like::parse_bool_like;
12use aster_forge_validation::email::normalize_email;
13
14pub const MAIL_TEMPLATE_MAX_SUBJECT_LEN: usize = 255;
16
17pub const MAIL_TEMPLATE_MAX_BODY_LEN: usize = 64 * 1024;
19
20pub const DEFAULT_MAIL_SMTP_PORT: u16 = 587;
22
23pub const DEFAULT_MAIL_SECURITY: bool = true;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct MailRuntimeSettings {
33 pub smtp_host: String,
35 pub smtp_port: u16,
37 pub smtp_username: String,
39 pub smtp_password: String,
41 pub from_address: String,
43 pub from_name: String,
45 pub encryption_enabled: bool,
47}
48
49impl MailRuntimeSettings {
50 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct MailConfigError {
70 message: String,
71}
72
73impl MailConfigError {
74 pub fn new(message: impl Into<String>) -> Self {
76 Self {
77 message: message.into(),
78 }
79 }
80
81 #[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
96pub type MailConfigResult<T> = std::result::Result<T, MailConfigError>;
98
99#[must_use]
101pub fn parse_smtp_port(value: &str) -> Option<u16> {
102 value.trim().parse::<u16>().ok().filter(|port| *port > 0)
103}
104
105pub 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
124pub 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
138pub 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
154pub 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
169pub 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
183pub 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
207pub 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}