Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/src/postman.rs

//! **The main's own mail** (S10, owner 2026-10-11 05:53Z): a sold estate's RESEND sign-in
//! link is minted by the estate and mailed by the estate — monetize only queues the signed
//! `sign_in_again` order and never holds the link's secret. So the main that mints the invite
//! is the box that sends the mail, through its own relay.
//!
//! The relay is configured the way the console's invite mail is (gunnar-ui-server
//! `auth/mail.rs`): SMTP submission with STARTTLS (Proton: `smtp.protonmail.ch:587`, the
//! username is the address itself, the password a submission token), rustls — never
//! native-tls/OpenSSL. Nothing configured is the ordinary state: such a main refuses a resend
//! by name BEFORE it mints anything. A partial configuration is an error at start.

use std::future::Future;
use std::pin::Pin;

use gunnar_repair::sign_in::SignInMail;
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Certificate, Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};

/// `smtp.protonmail.ch`, when [`HOST_ENV`] says nothing.
const DEFAULT_HOST: &str = "smtp.protonmail.ch";
/// Submission, not 25.
const DEFAULT_PORT: u16 = 587;

/// The relay's address.
pub const HOST_ENV: &str = "GUNNAR_SMTP_HOST";
/// Its port.
pub const PORT_ENV: &str = "GUNNAR_SMTP_PORT";
/// The address mail is sent AS, and the submission username.
pub const FROM_ENV: &str = "GUNNAR_SMTP_FROM";
/// The submission token, inline.
pub const TOKEN_ENV: &str = "GUNNAR_SMTP_TOKEN";
/// The submission token, as a path — preferred: a secret in a file has a mode.
pub const TOKEN_FILE_ENV: &str = "GUNNAR_SMTP_TOKEN_FILE";

/// One send's outcome: `Err` says why in words (never the body).
pub type Posting<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;

/// **How this box mails**: one plain-text mail to one address.
pub trait Postman: Send + Sync {
    fn post<'a>(&'a self, mail: &'a SignInMail) -> Posting<'a>;
    /// **Can it mail now?** Asked BEFORE anything is minted: `Err` says why in words. A
    /// relay that is configured is ready; one that waits for the installer's feed is not yet.
    fn ready(&self) -> Result<(), String> {
        Ok(())
    }
}

/// **The relay the installer FED this box** (P1, S10 resend on a SOLD estate, 2026-10-11):
/// the shop feeds a sold estate's main and twin `secret.smtp_url` (`smtp://` STARTTLS or
/// `smtps://` implicit TLS, `user:token@host[:port]`) and `secret.smtp_from` — read at each
/// mail from the feed in force, so a feed that arrives (or changes) after the start is the
/// one used.
pub struct FedRelay<F: Fn() -> Option<crate::feed::Fed> + Send + Sync> {
    pub fed: F,
}

impl<F: Fn() -> Option<crate::feed::Fed> + Send + Sync> FedRelay<F> {
    fn smtp(&self) -> Result<Smtp, String> {
        let fed = (self.fed)().unwrap_or_default();
        match (fed.smtp_url, fed.smtp_from) {
            (Some(url), Some(from)) => {
                Smtp::from_url(&url.0, &from)?.with_ca(fed.smtp_ca.as_deref().unwrap_or(""))
            }
            (None, _) => Err(format!(
                "the installer fed this box no relay ({})",
                crate::feed::FEED_SMTP_URL
            )),
            (Some(_), None) => Err(format!(
                "the installer fed a relay and no sender ({})",
                crate::feed::FEED_SMTP_FROM
            )),
        }
    }
}

impl<F: Fn() -> Option<crate::feed::Fed> + Send + Sync> Postman for FedRelay<F> {
    fn post<'a>(&'a self, mail: &'a SignInMail) -> Posting<'a> {
        Box::pin(async move {
            let smtp = self.smtp()?;
            smtp.post(mail).await
        })
    }
    fn ready(&self) -> Result<(), String> {
        self.smtp().map(|_| ())
    }
}

/// A configured SMTP relay. Its `Debug` withholds the token.
#[derive(Clone)]
pub struct Smtp {
    host: String,
    port: u16,
    from: Mailbox,
    token: String,
    /// The submission username; empty: the sender's address (Proton's rule).
    user: String,
    /// TLS from the first byte (`smtps://`), else STARTTLS.
    implicit_tls: bool,
    /// One more root the relay's certificate is trusted under (PEM, `secret.smtp_ca`): a relay
    /// behind a private CA (RA5-23). `None`: the public roots alone.
    ca: Option<String>,
}

/// `%XX` decoded (a URL's user or token).
fn percent_decoded(s: &str) -> Result<String, String> {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' {
            let h = s
                .get(i + 1..i + 3)
                .and_then(|h| u8::from_str_radix(h, 16).ok())
                .ok_or("a `%` escape that is not two hex digits")?;
            out.push(h);
            i += 3;
        } else {
            out.push(b[i]);
            i += 1;
        }
    }
    String::from_utf8(out).map_err(|_| "not UTF-8 once decoded".to_string())
}

impl std::fmt::Debug for Smtp {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Smtp {{ host: {:?}, port: {}, from: {:?}, token: <withheld> }}",
            self.host,
            self.port,
            self.from.email.to_string()
        )
    }
}

impl Smtp {
    /// **A relay from its URL** (`smtp://` or `smtps://`, `user:token@host[:port]`, user and
    /// token percent-decoded) and the address mail is sent AS.
    pub fn from_url(url: &str, from: &str) -> Result<Smtp, String> {
        let (scheme, rest) = url
            .trim()
            .split_once("://")
            .ok_or("the relay is not a URL (smtp:// or smtps://)")?;
        let (implicit_tls, default_port) = match scheme {
            "smtp" => (false, DEFAULT_PORT),
            "smtps" => (true, 465),
            other => {
                return Err(format!(
                    "the relay's scheme is {other:?}: smtp:// (STARTTLS) or smtps:// (TLS)"
                ))
            }
        };
        let rest = rest.trim_end_matches('/');
        let (cred, hostport) = rest
            .rsplit_once('@')
            .ok_or("the relay names no user and token (user:token@host)")?;
        let (user, token) = cred
            .split_once(':')
            .ok_or("the relay names no token (user:token@host)")?;
        let (user, token) = (percent_decoded(user)?, percent_decoded(token)?);
        if token.is_empty() {
            return Err("the relay names no token (user:token@host)".into());
        }
        let (host, port) = match hostport.rsplit_once(':') {
            Some((h, p)) => (
                h.to_string(),
                p.parse()
                    .map_err(|e| format!("the relay's port is not a port: {e}"))?,
            ),
            None => (hostport.to_string(), default_port),
        };
        if host.is_empty() {
            return Err("the relay names no host".into());
        }
        let from: Mailbox = from
            .trim()
            .parse()
            .map_err(|e| format!("the fed sender is not an address: {e}"))?;
        Ok(Smtp {
            host,
            port,
            from,
            token,
            user,
            implicit_tls,
            ca: None,
        })
    }

    /// **The relay's own root** (`secret.smtp_ca`, PEM): its TLS is checked under it beside the
    /// public roots. Blank: none. One that is no PEM certificate is refused by name.
    pub fn with_ca(mut self, pem: &str) -> Result<Smtp, String> {
        let pem = pem.trim();
        if pem.is_empty() {
            return Ok(self);
        }
        if !pem.contains("-----BEGIN CERTIFICATE-----") {
            return Err("secret.smtp_ca holds no PEM certificate".into());
        }
        Certificate::from_pem(pem.as_bytes())
            .map_err(|e| format!("secret.smtp_ca does not read as a PEM certificate: {e}"))?;
        self.ca = Some(pem.to_string());
        Ok(self)
    }

    /// The TLS the relay `name` is checked under: the public roots, and [`Smtp::with_ca`]'s.
    fn tls(&self, name: &str) -> Result<TlsParameters, String> {
        let mut builder = TlsParameters::builder(name.to_string());
        if let Some(pem) = &self.ca {
            builder = builder.add_root_certificate(
                Certificate::from_pem(pem.as_bytes())
                    .map_err(|e| format!("secret.smtp_ca: {e}"))?,
            );
        }
        builder
            .build_rustls()
            .map_err(|e| format!("{name}: no TLS for it: {e}"))
    }

    /// The relay's host, port and whether it speaks TLS from the first byte (smtps).
    pub fn endpoint(&self) -> (&str, u16, bool) {
        (&self.host, self.port, self.implicit_tls)
    }

    /// The submission username.
    pub fn user(&self) -> &str {
        &self.user
    }

    /// **The relay, or `None` when this box sends no mail** — from the environment.
    pub fn from_env() -> Result<Option<Smtp>, String> {
        Smtp::from_lookup(|k| std::env::var(k).ok())
    }

    /// [`Smtp::from_env`] over any lookup.
    pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Option<Smtp>, String> {
        let get = |k: &str| {
            get(k)
                .map(|v| v.trim().to_string())
                .filter(|v| !v.is_empty())
        };
        let from = get(FROM_ENV);
        let token = match get(TOKEN_FILE_ENV) {
            Some(path) => {
                let raw = std::fs::read_to_string(&path).map_err(|e| {
                    format!("{TOKEN_FILE_ENV} names {path}, which could not be read: {e}")
                })?;
                let token = raw.trim().to_string();
                if token.is_empty() {
                    return Err(format!("{TOKEN_FILE_ENV} names {path}, which is empty"));
                }
                Some(token)
            }
            None => get(TOKEN_ENV),
        };
        let (from, token) = match (from, token) {
            (None, None) => return Ok(None),
            (None, Some(_)) => return Err(format!("a mail token is set but {FROM_ENV} is not, so there is no address to send as")),
            (Some(_), None) => return Err(format!("{FROM_ENV} is set but no token is: set {TOKEN_FILE_ENV} (preferred) or {TOKEN_ENV}")),
            (Some(f), Some(t)) => (f, t),
        };
        let from: Mailbox = from
            .parse()
            .map_err(|e| format!("{FROM_ENV} is not an address: {e}"))?;
        let host = get(HOST_ENV).unwrap_or_else(|| DEFAULT_HOST.to_string());
        let port = match get(PORT_ENV) {
            None => DEFAULT_PORT,
            Some(raw) => raw
                .parse()
                .map_err(|e| format!("{PORT_ENV} is not a port: {e}"))?,
        };
        Ok(Some(Smtp {
            host,
            port,
            from,
            token,
            user: String::new(),
            implicit_tls: false,
            ca: None,
        }))
    }
}

impl Postman for Smtp {
    fn post<'a>(&'a self, mail: &'a SignInMail) -> Posting<'a> {
        Box::pin(async move {
            let to: Mailbox = mail
                .to
                .parse()
                .map_err(|e| format!("{:?} is not an address: {e}", mail.to))?;
            let message = Message::builder()
                .from(self.from.clone())
                .to(to)
                .subject(mail.subject.clone())
                .body(mail.body.clone())
                .map_err(|e| format!("the message would not build: {e}"))?;
            let user = if self.user.is_empty() {
                self.from.email.to_string()
            } else {
                self.user.clone()
            };
            // **A box behind its front** (the lean estate, A19 row 13): lettre speaks no proxy,
            // so it dials a loopback forward carried through the front's egress door, and names
            // the RELAY in its TLS (its certificate is checked as ever). Held until the mail is
            // sent.
            let forward = match nordisk_pairing::egress::proxy() {
                Some(door) => {
                    let (door, host, port) = (door.to_string(), self.host.clone(), self.port);
                    let opened = tokio::task::spawn_blocking(move || {
                        nordisk_pairing::egress::Forward::open(&door, &host, port)
                    })
                    .await
                    .map_err(|e| format!("the egress door's dial: {e}"))?;
                    Some(opened.map_err(|e| {
                        format!(
                            "{}:{} through the front's egress door: {e}",
                            self.host, self.port
                        )
                    })?)
                }
                None => None,
            };
            // The relay's name in its TLS, checked under the public roots and the fed
            // `secret.smtp_ca` (RA5-23), whichever road the connection takes.
            let tls = self.tls(&self.host)?;
            let tls = if self.implicit_tls {
                Tls::Wrapper(tls)
            } else {
                Tls::Required(tls)
            };
            let builder = match &forward {
                Some(f) => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(
                    f.addr().ip().to_string(),
                )
                .port(f.addr().port())
                .tls(tls),
                None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(self.host.clone())
                    .port(self.port)
                    .tls(tls),
            };
            let transport = builder
                .credentials(Credentials::new(user, self.token.clone()))
                .build();
            let sent = transport
                .send(message)
                .await
                .map(|_| ())
                .map_err(|e| format!("{}:{} did not take it: {e}", self.host, self.port));
            drop(forward);
            sent
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;

    fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
        let m: BTreeMap<String, String> = pairs
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();
        move |k| m.get(k).cloned()
    }

    #[test]
    fn no_relay_is_none_a_partial_one_is_an_error_and_the_token_never_shows() {
        assert!(Smtp::from_lookup(lookup(&[])).unwrap().is_none());
        assert!(
            Smtp::from_lookup(lookup(&[(FROM_ENV, "  ")]))
                .unwrap()
                .is_none(),
            "blank is absent"
        );
        assert!(
            Smtp::from_lookup(lookup(&[(FROM_ENV, "estate@acme.example")]))
                .unwrap_err()
                .contains(TOKEN_FILE_ENV)
        );
        assert!(Smtp::from_lookup(lookup(&[(TOKEN_ENV, "tok")]))
            .unwrap_err()
            .contains(FROM_ENV));
        assert!(
            Smtp::from_lookup(lookup(&[(FROM_ENV, "not an address"), (TOKEN_ENV, "tok")]))
                .unwrap_err()
                .contains(FROM_ENV)
        );
        let s = Smtp::from_lookup(lookup(&[
            (FROM_ENV, "estate@acme.example"),
            (TOKEN_ENV, "s3cret-token"),
            (PORT_ENV, "2525"),
        ]))
        .unwrap()
        .unwrap();
        assert_eq!((s.host.as_str(), s.port), (DEFAULT_HOST, 2525));
        assert!(!format!("{s:?}").contains("s3cret-token"), "{s:?}");
        let e = Smtp::from_lookup(lookup(&[
            (FROM_ENV, "estate@acme.example"),
            (TOKEN_FILE_ENV, "/nonexistent/s10-token"),
        ]))
        .unwrap_err();
        assert!(e.contains("/nonexistent/s10-token"), "{e}");
    }

    /// ★ **P1 (S10 resend on a SOLD estate): the relay the shop feeds** — `secret.smtp_url`
    /// read as a submission URL: `smtp://` is STARTTLS (587 by default), `smtps://` TLS from
    /// the first byte (465); the user and token percent-decoded; the token never shown. A URL
    /// without a token, or of another scheme, is refused by name.
    #[test]
    fn a_fed_relay_url_is_read_as_a_submission_url_and_its_token_never_shows() {
        let s = Smtp::from_url(
            "smtp://estate%40acme.example:s3cret%2Ftok@smtp.example:2525",
            "Gunnar <estate@acme.example>",
        )
        .unwrap();
        assert_eq!(s.endpoint(), ("smtp.example", 2525, false));
        assert_eq!(s.user(), "estate@acme.example");
        assert_eq!(s.token, "s3cret/tok");
        assert!(!format!("{s:?}").contains("s3cret"), "{s:?}");
        let t = Smtp::from_url("smtps://u:p@relay.example", "u@relay.example").unwrap();
        assert_eq!(t.endpoint(), ("relay.example", 465, true));
        assert_eq!(
            Smtp::from_url("smtp://u:p@relay.example", "u@relay.example")
                .unwrap()
                .endpoint(),
            ("relay.example", 587, false)
        );
        assert!(Smtp::from_url("smtp://u@relay.example", "u@relay.example")
            .unwrap_err()
            .contains("token"));
        assert!(
            Smtp::from_url("http://u:p@relay.example", "u@relay.example")
                .unwrap_err()
                .contains("smtp")
        );
        assert!(Smtp::from_url("smtp://u:p@relay.example", "not an address").is_err());
    }

    /// The fed relay is READY only once the feed names both halves — asked before a resend
    /// mints anything.
    #[test]
    fn the_fed_relay_is_ready_only_when_the_feed_names_it() {
        let none = FedRelay { fed: || None };
        assert!(none.ready().unwrap_err().contains("secret.smtp_url"));
        let half = FedRelay {
            fed: || {
                Some(crate::feed::Fed {
                    smtp_url: Some(crate::feed::Withheld("smtp://u:p@h.example".into())),
                    ..Default::default()
                })
            },
        };
        assert!(half.ready().is_err());
        let both = FedRelay {
            fed: || {
                Some(crate::feed::Fed {
                    smtp_url: Some(crate::feed::Withheld("smtp://u:p@h.example".into())),
                    smtp_from: Some("u@h.example".into()),
                    ..Default::default()
                })
            },
        };
        both.ready().expect("fed: ready");
        assert!(
            include_str!("serve.rs").contains("crate::postman::FedRelay"),
            "a main with no relay in its environment mails through the fed one"
        );
    }
}