gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **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::{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),
            (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,
}

/// `%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 })
    }

    /// 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 }))
    }
}

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,
            };
            let builder = match &forward {
                Some(f) => {
                    let tls = TlsParameters::new(self.host.clone()).map_err(|e| format!("{}: no TLS for it: {e}", self.host))?;
                    AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(f.addr().ip().to_string()).port(f.addr().port()).tls(if self.implicit_tls { Tls::Wrapper(tls) } else { Tls::Required(tls) })
                }
                None => {
                    let relay = if self.implicit_tls { AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host) } else { AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.host) };
                    relay.map_err(|e| format!("{}:{} could not be dialled: {e}", self.host, self.port))?.port(self.port)
                }
            };
            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");
    }
}