polyc-mail 2026.8.3

Outbound-mail transport: a ConnectRPC client the control plane uses to send a fresh (non-threaded) email through this deployment's web Worker (EmailRelayService).
//! 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 web Worker
//! (`apps/web`), 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. [`EmailRelayClient`] dials the
//! Worker's `EmailRelayService` over Connect RPC — the mirror image of every
//! other control-plane/Worker service in this repo (there, the control plane
//! is the server; here, it is the client and the Worker is the callee).
//! This crate never talks to a mail provider directly, and depends on
//! nothing but the shared wire types (`polyc-proto`).
//!
//! [`OutboundMail`] is deliberately a trait, not [`EmailRelayClient`] itself,
//! so a caller (the control plane's `email_link` module) can test against an
//! in-memory double instead of a live Connect client.

use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use connectrpc::client::{CallOptions, ClientConfig, HttpClient};
use polyc_proto::proto::polychrome::email_relay::v1::{
    EmailRelayServiceClient, SendVerificationEmailRequest,
};

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

/// Per-call deadline for a verification-email send. Short: this runs inline
/// in a Slack/Telegram turn, so a stuck relay must fail fast rather than
/// hang the turn.
const SEND_TIMEOUT: Duration = Duration::from_secs(10);

/// Path prefix the web Worker (`apps/web`) mounts `EmailRelayService` under.
///
/// See `apps/web/src/pages/_api/api/wallet/internal-api/[...path].ts`.
/// Appended to the configured relay origin so the generated Connect
/// procedure path lands on that route.
pub const RELAY_MOUNT_PREFIX: &str = "/api/wallet/internal-api";

/// 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`]. Treated as retryable by the caller: a
/// transport failure or a relay-side rejection both mean the message was not
/// delivered.
#[derive(Debug, thiserror::Error)]
pub enum SendError {
    /// Connect-level error from `EmailRelayService.SendVerificationEmail`.
    #[error(transparent)]
    Connect(#[from] connectrpc::ConnectError),
}

/// Building an [`EmailRelayClient`] failed — a startup-time misconfiguration,
/// never a runtime condition.
#[derive(Debug, thiserror::Error)]
pub enum RelayConfigError {
    /// The configured relay origin does not parse as a URI.
    #[error("invalid mail-relay url {url:?}: {source}")]
    InvalidUrl {
        /// The URL string that failed to parse.
        url: String,
        /// The underlying URI parse error.
        #[source]
        source: http::uri::InvalidUri,
    },
    /// The configured bearer token is not a valid HTTP header value (e.g. it
    /// contains a control character). Caught eagerly here because
    /// `ClientConfig::with_default_header` silently drops an invalid value
    /// instead of erroring, which would otherwise turn a bad token into an
    /// unexplained `unauthenticated` on every send.
    #[error("mail-relay bearer token is not a valid http header value")]
    InvalidToken,
    /// Building the TLS client for an `https://` relay origin failed (e.g. no
    /// process-default crypto provider). Fails closed rather than silently
    /// downgrading to plaintext.
    #[error("tls setup failed for mail relay: {0}")]
    Tls(String),
}

/// Outbound-mail transport, seamed behind a trait so a caller can test
/// against an in-memory double instead of a live Connect client.
#[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>;
}

/// Build the Connect HTTP transport for `uri`, honoring its scheme: `https`
/// dials over TLS (OS trust store); anything else (incl. a scheme-less
/// `host:port`) uses plaintext. An `https` address is **never** silently
/// downgraded — a TLS build failure surfaces as [`RelayConfigError::Tls`].
/// Mirrors `polyc_rpc_client`'s `http_client_for`.
fn http_client_for(uri: &http::Uri) -> Result<HttpClient, RelayConfigError> {
    if uri.scheme_str() == Some("https") {
        use rustls_platform_verifier::ConfigVerifierExt;
        let tls = rustls::ClientConfig::with_platform_verifier()
            .map_err(|e| RelayConfigError::Tls(e.to_string()))?;
        Ok(HttpClient::builder()
            .connect_timeout(CONNECT_TIMEOUT)
            .with_tls(Arc::new(tls)))
    } else {
        Ok(HttpClient::builder()
            .connect_timeout(CONNECT_TIMEOUT)
            .plaintext())
    }
}

/// [`OutboundMail`] over this deployment's web Worker (`apps/web`), which
/// hosts `EmailRelayService` and owns the actual mail-provider credential.
///
/// `base_url` is the Worker's own origin (no trailing slash; [`RELAY_MOUNT_PREFIX`]
/// is appended to reach the service); `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 EmailRelayClient {
    client: Arc<EmailRelayServiceClient<HttpClient>>,
}

impl std::fmt::Debug for EmailRelayClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EmailRelayClient").finish_non_exhaustive()
    }
}

impl EmailRelayClient {
    /// Build a client against the web Worker's `base_url`, authenticating
    /// with the shared `token`.
    ///
    /// # Errors
    ///
    /// Returns [`RelayConfigError`] when `base_url` doesn't parse, `token`
    /// isn't a valid header value, or the TLS transport fails to build for
    /// an `https` origin.
    pub fn new(base_url: &str, token: &str) -> Result<Self, RelayConfigError> {
        let uri: http::Uri = format!(
            "{base}{RELAY_MOUNT_PREFIX}",
            base = base_url.trim_end_matches('/')
        )
        .parse()
        .map_err(|source| RelayConfigError::InvalidUrl {
            url: base_url.to_owned(),
            source,
        })?;
        let http = http_client_for(&uri)?;
        let header_value = http::HeaderValue::try_from(format!("Bearer {token}"))
            .map_err(|_| RelayConfigError::InvalidToken)?;
        let config = ClientConfig::new(uri)
            .with_default_timeout(SEND_TIMEOUT)
            .with_default_header(http::header::AUTHORIZATION, header_value);
        Ok(Self {
            client: Arc::new(EmailRelayServiceClient::new(http, config)),
        })
    }
}

#[async_trait]
impl OutboundMail for EmailRelayClient {
    async fn send(&self, reply: &OutboundReply) -> Result<(), SendError> {
        let request = SendVerificationEmailRequest {
            to: reply.to.clone(),
            subject: reply.subject.clone(),
            text: reply.text.clone(),
            ..Default::default()
        };
        self.client
            .send_verification_email_with_options(request, CallOptions::default())
            .await?;
        Ok(())
    }
}