OXYGENHUB
# 4e2cad00a484 oxygenhub / crates / hub / src / mail.rs
3.3 KiB · Rust · last changed Mail settings on the admin page, with a test message 1 month ago source blame history raw
1
//! Outgoing mail (hub spec §5.1.1, §10): SMTP through lettre, or, for
2
//! development and tests, `file:///dir`, which writes each message to that
3
//! directory instead of sending it.
4
5
use crate::config::Email;
6
use crate::Shared;
7
8
pub const SETTING_SMTP: &str = "mail.smtp";
9
pub const SETTING_FROM: &str = "mail.from";
10
11
/// The mail settings in force: what the admin page saved (#101), else
12
/// hub.toml's `[email]`.
13
pub async fn load(db: &crate::db::Pool, from_config: &Email) -> Email {
14
    let mut mail = from_config.clone();
15
    if let Ok(Some(s)) = crate::db::accounts::setting(db, SETTING_SMTP).await {
16
        mail.smtp = s;
17
        mail.from = crate::db::accounts::setting(db, SETTING_FROM).await.ok().flatten().unwrap_or_default();
18
    }
19
    mail
20
}
21
22
pub fn settings(app: &Shared) -> Email {
23
    app.mail.read().unwrap_or_else(|p| p.into_inner()).clone()
24
}
25
26
/// Save the settings and use them from now on. An empty URL turns mail
27
/// off.
28
pub async fn save(app: &Shared, smtp: &str, from: &str) -> sqlx::Result<()> {
29
    crate::db::accounts::set_setting(&app.db, SETTING_SMTP, smtp).await?;
30
    crate::db::accounts::set_setting(&app.db, SETTING_FROM, from).await?;
31
    *app.mail.write().unwrap_or_else(|p| p.into_inner()) = Email { smtp: smtp.to_string(), from: from.to_string() };
32
    Ok(())
33
}
34
35
/// The SMTP URL with its password hidden, for the page.
36
pub fn masked(smtp: &str) -> String {
37
    let Some((scheme, rest)) = smtp.split_once("://") else { return smtp.to_string() };
38
    let Some((userinfo, host)) = rest.rsplit_once('@') else { return smtp.to_string() };
39
    match userinfo.split_once(':') {
40
        Some((user, _)) => format!("{scheme}://{user}:***@{host}"),
41
        None => smtp.to_string(),
42
    }
43
}
44
45
/// Whether the hub can send mail at all.
46
pub fn enabled(app: &Shared) -> bool {
47
    let cfg = settings(app);
48
    !cfg.smtp.is_empty() && !cfg.from.is_empty()
49
}
50
51
/// Send a plain-text message. The subject gets the site's name in brackets.
52
pub async fn send(app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str) -> Result<(), String> {
53
    if !enabled(app) {
54
        return Err("this hub does not send mail".into());
55
    }
56
    let cfg = settings(app);
57
    let subject = format!("[{}] {subject}", app.config.site.name);
58
    if let Some(dir) = cfg.smtp.strip_prefix("file://") {
59
        let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4));
60
        let text = format!("From: {}\nTo: {to_name} <{to_addr}>\nSubject: {subject}\n\n{body}", cfg.from);
61
        return tokio::fs::write(std::path::Path::new(dir).join(name), text).await.map_err(|e| format!("write mail to {dir}: {e}"));
62
    }
63
    use lettre::message::header::ContentType;
64
    use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
65
    let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?;
66
    let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?;
67
    let message = Message::builder()
68
        .from(from)
69
        .to(to)
70
        .subject(subject)
71
        .header(ContentType::TEXT_PLAIN)
72
        .body(body.to_string())
73
        .map_err(|e| e.to_string())?;
74
    let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&cfg.smtp).map_err(|e| format!("[email] smtp: {e}"))?.build();
75
    transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}"))
76
}