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 pub fn is_configured(&self) -> bool {
52 !self.smtp_host.trim().is_empty() && !self.from_address.trim().is_empty()
53 }
54
55 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#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MailConfigError {
68 message: String,
69}
70
71impl MailConfigError {
72 pub fn new(message: impl Into<String>) -> Self {
74 Self {
75 message: message.into(),
76 }
77 }
78
79 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
93pub type MailConfigResult<T> = std::result::Result<T, MailConfigError>;
95
96pub fn parse_smtp_port(value: &str) -> Option<u16> {
98 value.trim().parse::<u16>().ok().filter(|port| *port > 0)
99}
100
101pub 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
116pub 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
126pub 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
138pub 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
149pub 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
159pub 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
179pub 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}