polyc-mail 2026.7.1

Outbound-mail transport: a bearer-authed relay client the control plane uses to send a fresh (non-threaded) email through this deployment's mail-sending relay.
Documentation
//! Outbound-mail transport for the control plane's email-verification
//! magic-link ceremony (issue #962).
//!
//! The control plane holds no mail-provider credential of its own. Sending a
//! verification link is delegated to this deployment's wallet Worker
//! (`apps/wallet`), which owns a native outbound-mail send capability behind
//! its own binding — a much smaller credential footprint in the cluster than
//! a full mail-provider account token. [`RelayMailApi`] is a bearer-authed
//! HTTP client for that Worker's internal send endpoint
//! (`POST /internal/send-verification-email`); this crate never talks to a
//! mail provider directly, and carries no other polychrome-internal
//! dependency.
//!
//! [`OutboundMail`] is deliberately a trait, not [`RelayMailApi`] itself, so
//! a caller (the control plane's `email_link` module) can test against an
//! in-memory double instead of a live HTTP relay.

use async_trait::async_trait;
use serde::Serialize;
use std::time::Duration;

/// Bounds the TCP/TLS connect so a dead/terminating peer fails fast (as a
/// transport error) instead of blackholing the SYN for `tcp_syn_retries`.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);

/// A fresh (non-threaded) mail message to send.
///
/// One recipient, one subject, one plaintext body. There is no inbound
/// message to thread against — this transport exists solely for the
/// email-verification magic-link ceremony.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboundReply {
    /// Recipient address.
    pub to: String,
    /// Subject line.
    pub subject: String,
    /// Plaintext body.
    pub text: String,
}

/// Error sending an [`OutboundReply`].
///
/// Both variants are treated as retryable by the caller: a transport failure
/// or a non-2xx from the relay means the message was not delivered.
#[derive(Debug, thiserror::Error)]
pub enum SendError {
    /// Network or TLS error.
    #[error("http: {0}")]
    Http(#[from] reqwest::Error),
    /// The relay returned a non-success status. `body` is its diagnostic
    /// response, when present.
    #[error("mail relay status {code}: {body}")]
    Status {
        /// HTTP status code the relay returned.
        code: u16,
        /// Response body (empty when the relay sent none).
        body: String,
    },
}

/// Outbound-mail transport, seamed behind a trait so a caller can test
/// against an in-memory double instead of a live HTTP relay.
#[async_trait]
pub trait OutboundMail: Send + Sync {
    /// Send `reply`.
    ///
    /// # Errors
    ///
    /// Returns [`SendError`] when the transport fails or the relay rejects
    /// the send. The caller treats any error as retryable.
    async fn send(&self, reply: &OutboundReply) -> Result<(), SendError>;
}

/// The JSON body [`RelayMailApi`] posts to the wallet Worker's internal send
/// endpoint.
#[derive(Debug, Serialize)]
struct SendRequest<'a> {
    to: &'a str,
    subject: &'a str,
    text: &'a str,
}

/// [`OutboundMail`] implementation over this deployment's wallet Worker.
///
/// One bearer-authed JSON `POST /internal/send-verification-email` against
/// the Worker (`apps/wallet/worker/index.ts`), which owns the actual
/// outbound send capability and its provider credential.
///
/// `base_url` is the Worker's own origin (no trailing slash); `token` is the
/// shared secret both sides authenticate the call with — the Worker fails
/// the request closed if it does not match (or is unset on either side).
#[derive(Clone)]
pub struct RelayMailApi {
    http: reqwest::Client,
    base_url: String,
    token: String,
}

impl RelayMailApi {
    /// Build a client against the wallet Worker's `base_url`, authenticating
    /// with the shared `token`.
    ///
    /// # Panics
    ///
    /// Panics if `reqwest`'s default TLS backend fails to initialise — that
    /// only happens on a misconfigured target (missing CA roots in the
    /// container, etc.) and is treated as a startup-time programmer error
    /// rather than a recoverable runtime condition.
    #[must_use]
    pub fn with_base_url(base_url: impl Into<String>, token: impl Into<String>) -> Self {
        let http = reqwest::Client::builder()
            .timeout(Duration::from_secs(10))
            .connect_timeout(CONNECT_TIMEOUT)
            .build()
            .expect("build reqwest client");
        Self {
            http,
            base_url: base_url.into(),
            token: token.into(),
        }
    }
}

#[async_trait]
impl OutboundMail for RelayMailApi {
    async fn send(&self, reply: &OutboundReply) -> Result<(), SendError> {
        let url = format!(
            "{base}/internal/send-verification-email",
            base = self.base_url.trim_end_matches('/')
        );
        let payload = SendRequest {
            to: &reply.to,
            subject: &reply.subject,
            text: &reply.text,
        };

        let resp = self
            .http
            .post(&url)
            .bearer_auth(&self.token)
            .json(&payload)
            .send()
            .await?;

        let status = resp.status();
        if status.is_success() {
            Ok(())
        } else {
            let body = resp.text().await.unwrap_or_default();
            Err(SendError::Status {
                code: status.as_u16(),
                body,
            })
        }
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;

    #[test]
    fn send_error_display_includes_status_and_body() {
        let err = SendError::Status {
            code: 422,
            body: "bad subject".to_owned(),
        };
        let rendered = err.to_string();
        assert!(rendered.contains("422"));
        assert!(rendered.contains("bad subject"));
    }
}