1use axum::extract::State;
5use axum::response::{IntoResponse, Redirect, Response};
6use axum::Extension;
7use laterite_auth::AuthenticatedUser;
8use laterite_core::config::MailConfig;
9use laterite_core::crypt::{Crypt, FIELD_PURPOSE, MASK};
10use laterite_core::t;
11use serde::{Deserialize, Serialize};
12
13use super::{SettingsItem, SettingsModel};
14use crate::form::FormField;
15use crate::session::{FlashLevel, SessionHandle};
16use crate::AdminState;
17
18pub const PERMISSION: &str = "backend.manage_mail";
20
21pub const TEST_PATH: &str = "/settings/mail/test";
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
26pub struct MailSetting {
27 #[serde(default)]
28 pub from_name: String,
29 #[serde(default)]
30 pub from_address: String,
31 #[serde(default)]
33 pub driver: String,
34 #[serde(default)]
35 pub smtp_host: String,
36 #[serde(default)]
37 pub smtp_port: String,
38 #[serde(default)]
39 pub smtp_encryption: String,
40 #[serde(default)]
41 pub smtp_username: String,
42 #[serde(default)]
44 pub smtp_password: String,
45 #[serde(default)]
47 pub to_override: String,
48}
49
50impl SettingsModel for MailSetting {
51 const CODE: &'static str = "laterite.mail";
52}
53
54pub fn settings_item() -> SettingsItem {
56 SettingsItem::new(
57 MailSetting::CODE,
58 t!("Mail"),
59 vec![
60 FormField::text("from_name", t!("Sender name")).help(t!("Shown as the sender; blank keeps the configured name.")),
61 FormField::text("from_address", t!("Sender address")).help(t!("Blank keeps the configured address.")),
62 FormField::select(
63 "driver",
64 t!("Send through"),
65 vec![("", "Configured driver"), ("smtp", "SMTP"), ("log", "Mail log (nothing leaves)"), ("stub", "Nowhere (tests)")],
66 )
67 .help(t!("A driver a module registered is named by its code in the configuration.")),
68 FormField::text("smtp_host", t!("SMTP host")),
69 FormField::text("smtp_port", t!("SMTP port")).help(t!("587 for STARTTLS, 465 for TLS, 25 or 1025 for none.")),
70 FormField::select(
71 "smtp_encryption",
72 t!("Encryption"),
73 vec![("", "Configured"), ("starttls", "STARTTLS"), ("tls", "TLS"), ("none", "None")],
74 ),
75 FormField::text("smtp_username", t!("SMTP username")),
76 FormField::of("smtp_password", t!("SMTP password"), "sensitive")
77 .options(serde_json::json!({ "reveal": false })),
78 FormField::text("to_override", t!("Send everything to")).help(t!(
79 "While set, every message goes to this address instead of its recipients. For development."
80 )),
81 ],
82 )
83 .description(t!("The sender, the transport and a test message."))
84 .hint(t!("Blank fields keep the values from the configuration file. Save, then send a test message to yourself."))
85 .category(t!("System"))
86 .slot(laterite_core::arrange::slot::SYSTEM)
87 .order(14)
88 .icon("mail")
89 .permission(PERMISSION)
90 .action(t!("Send a test message to me"), TEST_PATH)
91}
92
93pub fn effective(config: &MailConfig, setting: &MailSetting, crypt: &Crypt) -> MailConfig {
96 let mut out = config.clone();
97 let set = |target: &mut String, value: &str| {
98 if !value.trim().is_empty() {
99 *target = value.trim().to_string();
100 }
101 };
102 if !setting.from_address.trim().is_empty() {
103 let name = if setting.from_name.trim().is_empty() {
104 laterite_core::mail::Address::parse(&config.from)
105 .and_then(|a| a.name)
106 .unwrap_or_default()
107 } else {
108 setting.from_name.trim().to_string()
109 };
110 out.from = if name.is_empty() {
111 setting.from_address.trim().to_string()
112 } else {
113 format!("{name} <{}>", setting.from_address.trim())
114 };
115 } else if !setting.from_name.trim().is_empty() {
116 if let Some(address) = laterite_core::mail::Address::parse(&config.from) {
117 out.from = format!("{} <{}>", setting.from_name.trim(), address.email);
118 }
119 }
120 set(&mut out.driver, &setting.driver);
121 set(&mut out.smtp.host, &setting.smtp_host);
122 if let Ok(port) = setting.smtp_port.trim().parse::<u16>() {
123 out.smtp.port = port;
124 }
125 set(&mut out.smtp.encryption, &setting.smtp_encryption);
126 set(&mut out.smtp.username, &setting.smtp_username);
127 let sealed = setting.smtp_password.trim();
128 if !sealed.is_empty() && sealed != MASK {
129 match crypt.decrypt(FIELD_PURPOSE, b"smtp_password", sealed) {
130 Ok(plain) => out.smtp.password = String::from_utf8_lossy(&plain).to_string(),
131 Err(_) => {
132 tracing::warn!("the saved SMTP password does not open under the current keys")
133 }
134 }
135 }
136 if !setting.to_override.trim().is_empty() {
137 out.to = Some(setting.to_override.trim().to_string());
138 }
139 out
140}
141
142pub(crate) async fn send_test(
144 State(state): State<AdminState>,
145 Extension(session): Extension<SessionHandle>,
146 Extension(user): Extension<AuthenticatedUser>,
147) -> Response {
148 let back = format!("{}/settings/laterite/mail", state.admin_path);
149 let to = user.user.email.clone();
150 match state.mail.send_test(&to).await {
151 Ok(()) => {
152 crate::audit::record(
153 &state,
154 &user,
155 "backend.mail.test",
156 Some("mail"),
157 Some(&to),
158 None,
159 Some(state.mail.driver_name()),
160 )
161 .await;
162 session.push_flash(
163 FlashLevel::Success,
164 laterite_core::t!(
165 "A test message went to {address} through {driver}.",
166 address = to,
167 driver = state.mail.driver_name()
168 ),
169 );
170 }
171 Err(e) => {
172 tracing::warn!(error = %e, "test message failed");
173 session.push_flash(
174 FlashLevel::Error,
175 laterite_core::t!(
176 "The test message could not be sent: {error}",
177 error = e.to_string()
178 ),
179 );
180 }
181 }
182 Redirect::to(&back).into_response()
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn settings_lay_over_the_configuration() {
191 let crypt = Crypt::ephemeral();
192 let config = MailConfig {
193 driver: "log".into(),
194 from: "Acme <no-reply@acme.example>".into(),
195 ..MailConfig::default()
196 };
197 let sealed = crypt
198 .encrypt(FIELD_PURPOSE, b"smtp_password", b"hunter2")
199 .unwrap();
200 let setting = MailSetting {
201 from_name: "Acme Mail".into(),
202 driver: "smtp".into(),
203 smtp_host: "smtp.acme.example".into(),
204 smtp_port: "465".into(),
205 smtp_encryption: "tls".into(),
206 smtp_username: "u".into(),
207 smtp_password: sealed,
208 ..MailSetting::default()
209 };
210 let out = effective(&config, &setting, &crypt);
211 assert_eq!(out.from, "Acme Mail <no-reply@acme.example>");
212 assert_eq!(out.driver, "smtp");
213 assert_eq!(out.smtp.host, "smtp.acme.example");
214 assert_eq!(out.smtp.port, 465);
215 assert_eq!(out.smtp.encryption, "tls");
216 assert_eq!(out.smtp.password, "hunter2");
217 assert_eq!(out.to, None);
218 let blank = effective(&config, &MailSetting::default(), &crypt);
219 assert_eq!(blank.driver, "log");
220 assert_eq!(blank.from, config.from);
221 }
222}