Skip to main content

polyc_mail/
lib.rs

1//! Outbound-mail transport for the control plane's email-verification
2//! magic-link ceremony (issue #962).
3//!
4//! The control plane holds no mail-provider credential of its own. Sending a
5//! verification link is delegated to this deployment's web Worker
6//! (`apps/web`), which owns a native outbound-mail send capability behind
7//! its own binding — a much smaller credential footprint in the cluster than
8//! a full mail-provider account token. [`EmailRelayClient`] dials the
9//! Worker's `EmailRelayService` over Connect RPC — the mirror image of every
10//! other control-plane/Worker service in this repo (there, the control plane
11//! is the server; here, it is the client and the Worker is the callee).
12//! This crate never talks to a mail provider directly, and depends on
13//! nothing but the shared wire types (`polyc-proto`).
14//!
15//! [`OutboundMail`] is deliberately a trait, not [`EmailRelayClient`] itself,
16//! so a caller (the control plane's `email_link` module) can test against an
17//! in-memory double instead of a live Connect client.
18
19use std::sync::Arc;
20use std::time::Duration;
21
22use async_trait::async_trait;
23use connectrpc::client::{CallOptions, ClientConfig, HttpClient};
24use polyc_proto::proto::polychrome::email_relay::v1::{
25    EmailRelayServiceClient, SendVerificationEmailRequest,
26};
27
28/// Bounds the TCP/TLS connect so a dead/terminating peer fails fast (as a
29/// transport error) instead of blackholing the SYN for `tcp_syn_retries`.
30const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
31
32/// Per-call deadline for a verification-email send. Short: this runs inline
33/// in a Slack/Telegram turn, so a stuck relay must fail fast rather than
34/// hang the turn.
35const SEND_TIMEOUT: Duration = Duration::from_secs(10);
36
37/// Path prefix the web Worker (`apps/web`) mounts `EmailRelayService` under.
38///
39/// See `apps/web/src/pages/_api/api/wallet/internal-api/[...path].ts`.
40/// Appended to the configured relay origin so the generated Connect
41/// procedure path lands on that route.
42pub const RELAY_MOUNT_PREFIX: &str = "/api/wallet/internal-api";
43
44/// A fresh (non-threaded) mail message to send.
45///
46/// One recipient, one subject, one plaintext body. There is no inbound
47/// message to thread against — this transport exists solely for the
48/// email-verification magic-link ceremony.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct OutboundReply {
51    /// Recipient address.
52    pub to: String,
53    /// Subject line.
54    pub subject: String,
55    /// Plaintext body.
56    pub text: String,
57}
58
59/// Error sending an [`OutboundReply`]. Treated as retryable by the caller: a
60/// transport failure or a relay-side rejection both mean the message was not
61/// delivered.
62#[derive(Debug, thiserror::Error)]
63pub enum SendError {
64    /// Connect-level error from `EmailRelayService.SendVerificationEmail`.
65    #[error(transparent)]
66    Connect(#[from] connectrpc::ConnectError),
67}
68
69/// Building an [`EmailRelayClient`] failed — a startup-time misconfiguration,
70/// never a runtime condition.
71#[derive(Debug, thiserror::Error)]
72pub enum RelayConfigError {
73    /// The configured relay origin does not parse as a URI.
74    #[error("invalid mail-relay url {url:?}: {source}")]
75    InvalidUrl {
76        /// The URL string that failed to parse.
77        url: String,
78        /// The underlying URI parse error.
79        #[source]
80        source: http::uri::InvalidUri,
81    },
82    /// The configured bearer token is not a valid HTTP header value (e.g. it
83    /// contains a control character). Caught eagerly here because
84    /// `ClientConfig::with_default_header` silently drops an invalid value
85    /// instead of erroring, which would otherwise turn a bad token into an
86    /// unexplained `unauthenticated` on every send.
87    #[error("mail-relay bearer token is not a valid http header value")]
88    InvalidToken,
89    /// Building the TLS client for an `https://` relay origin failed (e.g. no
90    /// process-default crypto provider). Fails closed rather than silently
91    /// downgrading to plaintext.
92    #[error("tls setup failed for mail relay: {0}")]
93    Tls(String),
94}
95
96/// Outbound-mail transport, seamed behind a trait so a caller can test
97/// against an in-memory double instead of a live Connect client.
98#[async_trait]
99pub trait OutboundMail: Send + Sync {
100    /// Send `reply`.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`SendError`] when the transport fails or the relay rejects
105    /// the send. The caller treats any error as retryable.
106    async fn send(&self, reply: &OutboundReply) -> Result<(), SendError>;
107}
108
109/// Build the Connect HTTP transport for `uri`, honoring its scheme: `https`
110/// dials over TLS (OS trust store); anything else (incl. a scheme-less
111/// `host:port`) uses plaintext. An `https` address is **never** silently
112/// downgraded — a TLS build failure surfaces as [`RelayConfigError::Tls`].
113/// Mirrors `polyc_rpc_client`'s `http_client_for`.
114fn http_client_for(uri: &http::Uri) -> Result<HttpClient, RelayConfigError> {
115    if uri.scheme_str() == Some("https") {
116        use rustls_platform_verifier::ConfigVerifierExt;
117        let tls = rustls::ClientConfig::with_platform_verifier()
118            .map_err(|e| RelayConfigError::Tls(e.to_string()))?;
119        Ok(HttpClient::builder()
120            .connect_timeout(CONNECT_TIMEOUT)
121            .with_tls(Arc::new(tls)))
122    } else {
123        Ok(HttpClient::builder()
124            .connect_timeout(CONNECT_TIMEOUT)
125            .plaintext())
126    }
127}
128
129/// [`OutboundMail`] over this deployment's web Worker (`apps/web`), which
130/// hosts `EmailRelayService` and owns the actual mail-provider credential.
131///
132/// `base_url` is the Worker's own origin (no trailing slash; [`RELAY_MOUNT_PREFIX`]
133/// is appended to reach the service); `token` is the shared secret both sides
134/// authenticate the call with — the Worker fails the request closed if it
135/// does not match (or is unset on either side).
136#[derive(Clone)]
137pub struct EmailRelayClient {
138    client: Arc<EmailRelayServiceClient<HttpClient>>,
139}
140
141impl std::fmt::Debug for EmailRelayClient {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("EmailRelayClient").finish_non_exhaustive()
144    }
145}
146
147impl EmailRelayClient {
148    /// Build a client against the web Worker's `base_url`, authenticating
149    /// with the shared `token`.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`RelayConfigError`] when `base_url` doesn't parse, `token`
154    /// isn't a valid header value, or the TLS transport fails to build for
155    /// an `https` origin.
156    pub fn new(base_url: &str, token: &str) -> Result<Self, RelayConfigError> {
157        let uri: http::Uri = format!(
158            "{base}{RELAY_MOUNT_PREFIX}",
159            base = base_url.trim_end_matches('/')
160        )
161        .parse()
162        .map_err(|source| RelayConfigError::InvalidUrl {
163            url: base_url.to_owned(),
164            source,
165        })?;
166        let http = http_client_for(&uri)?;
167        let header_value = http::HeaderValue::try_from(format!("Bearer {token}"))
168            .map_err(|_| RelayConfigError::InvalidToken)?;
169        let config = ClientConfig::new(uri)
170            .with_default_timeout(SEND_TIMEOUT)
171            .with_default_header(http::header::AUTHORIZATION, header_value);
172        Ok(Self {
173            client: Arc::new(EmailRelayServiceClient::new(http, config)),
174        })
175    }
176}
177
178#[async_trait]
179impl OutboundMail for EmailRelayClient {
180    async fn send(&self, reply: &OutboundReply) -> Result<(), SendError> {
181        let request = SendVerificationEmailRequest {
182            to: reply.to.clone(),
183            subject: reply.subject.clone(),
184            text: reply.text.clone(),
185            ..Default::default()
186        };
187        self.client
188            .send_verification_email_with_options(request, CallOptions::default())
189            .await?;
190        Ok(())
191    }
192}