Skip to main content

laterite_admin/settings/
mail.rs

1//! The Mail settings: the sender and the transport, saved over `[mail]`,
2//! with a Send test that mails the signed-in operator.
3
4use 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
18/// The permission the Mail settings and the Send test require.
19pub const PERMISSION: &str = "backend.manage_mail";
20
21/// Where the Send test posts, under the admin root.
22pub const TEST_PATH: &str = "/settings/mail/test";
23
24/// The saved mail settings. A blank field leaves `[mail]`'s value in force.
25#[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    /// `smtp`, `log`, `stub` or a registered driver; blank keeps the config.
32    #[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    /// Sealed by the `sensitive` field type.
43    #[serde(default)]
44    pub smtp_password: String,
45    /// A development override: every message goes here while set.
46    #[serde(default)]
47    pub to_override: String,
48}
49
50impl SettingsModel for MailSetting {
51    const CODE: &'static str = "laterite.mail";
52}
53
54/// The settings screen under System.
55pub 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
93/// The configuration in force: `config` with every non-blank setting laid
94/// over it, the password opened with `crypt`.
95pub 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
142/// Sends the built-in test template to the signed-in operator.
143pub(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}