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}