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 wallet Worker
6//! (`apps/wallet`), 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. [`RelayMailApi`] is a bearer-authed
9//! HTTP client for that Worker's internal send endpoint
10//! (`POST /internal/send-verification-email`); this crate never talks to a
11//! mail provider directly, and carries no other polychrome-internal
12//! dependency.
13//!
14//! [`OutboundMail`] is deliberately a trait, not [`RelayMailApi`] itself, so
15//! a caller (the control plane's `email_link` module) can test against an
16//! in-memory double instead of a live HTTP relay.
17
18use async_trait::async_trait;
19use serde::Serialize;
20use std::time::Duration;
21
22/// Bounds the TCP/TLS connect so a dead/terminating peer fails fast (as a
23/// transport error) instead of blackholing the SYN for `tcp_syn_retries`.
24const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
25
26/// A fresh (non-threaded) mail message to send.
27///
28/// One recipient, one subject, one plaintext body. There is no inbound
29/// message to thread against — this transport exists solely for the
30/// email-verification magic-link ceremony.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OutboundReply {
33 /// Recipient address.
34 pub to: String,
35 /// Subject line.
36 pub subject: String,
37 /// Plaintext body.
38 pub text: String,
39}
40
41/// Error sending an [`OutboundReply`].
42///
43/// Both variants are treated as retryable by the caller: a transport failure
44/// or a non-2xx from the relay means the message was not delivered.
45#[derive(Debug, thiserror::Error)]
46pub enum SendError {
47 /// Network or TLS error.
48 #[error("http: {0}")]
49 Http(#[from] reqwest::Error),
50 /// The relay returned a non-success status. `body` is its diagnostic
51 /// response, when present.
52 #[error("mail relay status {code}: {body}")]
53 Status {
54 /// HTTP status code the relay returned.
55 code: u16,
56 /// Response body (empty when the relay sent none).
57 body: String,
58 },
59}
60
61/// Outbound-mail transport, seamed behind a trait so a caller can test
62/// against an in-memory double instead of a live HTTP relay.
63#[async_trait]
64pub trait OutboundMail: Send + Sync {
65 /// Send `reply`.
66 ///
67 /// # Errors
68 ///
69 /// Returns [`SendError`] when the transport fails or the relay rejects
70 /// the send. The caller treats any error as retryable.
71 async fn send(&self, reply: &OutboundReply) -> Result<(), SendError>;
72}
73
74/// The JSON body [`RelayMailApi`] posts to the wallet Worker's internal send
75/// endpoint.
76#[derive(Debug, Serialize)]
77struct SendRequest<'a> {
78 to: &'a str,
79 subject: &'a str,
80 text: &'a str,
81}
82
83/// [`OutboundMail`] implementation over this deployment's wallet Worker.
84///
85/// One bearer-authed JSON `POST /internal/send-verification-email` against
86/// the Worker (`apps/wallet/worker/index.ts`), which owns the actual
87/// outbound send capability and its provider credential.
88///
89/// `base_url` is the Worker's own origin (no trailing slash); `token` is the
90/// shared secret both sides authenticate the call with — the Worker fails
91/// the request closed if it does not match (or is unset on either side).
92#[derive(Clone)]
93pub struct RelayMailApi {
94 http: reqwest::Client,
95 base_url: String,
96 token: String,
97}
98
99impl RelayMailApi {
100 /// Build a client against the wallet Worker's `base_url`, authenticating
101 /// with the shared `token`.
102 ///
103 /// # Panics
104 ///
105 /// Panics if `reqwest`'s default TLS backend fails to initialise — that
106 /// only happens on a misconfigured target (missing CA roots in the
107 /// container, etc.) and is treated as a startup-time programmer error
108 /// rather than a recoverable runtime condition.
109 #[must_use]
110 pub fn with_base_url(base_url: impl Into<String>, token: impl Into<String>) -> Self {
111 let http = reqwest::Client::builder()
112 .timeout(Duration::from_secs(10))
113 .connect_timeout(CONNECT_TIMEOUT)
114 .build()
115 .expect("build reqwest client");
116 Self {
117 http,
118 base_url: base_url.into(),
119 token: token.into(),
120 }
121 }
122}
123
124#[async_trait]
125impl OutboundMail for RelayMailApi {
126 async fn send(&self, reply: &OutboundReply) -> Result<(), SendError> {
127 let url = format!(
128 "{base}/internal/send-verification-email",
129 base = self.base_url.trim_end_matches('/')
130 );
131 let payload = SendRequest {
132 to: &reply.to,
133 subject: &reply.subject,
134 text: &reply.text,
135 };
136
137 let resp = self
138 .http
139 .post(&url)
140 .bearer_auth(&self.token)
141 .json(&payload)
142 .send()
143 .await?;
144
145 let status = resp.status();
146 if status.is_success() {
147 Ok(())
148 } else {
149 let body = resp.text().await.unwrap_or_default();
150 Err(SendError::Status {
151 code: status.as_u16(),
152 body,
153 })
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
161
162 use super::*;
163
164 #[test]
165 fn send_error_display_includes_status_and_body() {
166 let err = SendError::Status {
167 code: 422,
168 body: "bad subject".to_owned(),
169 };
170 let rendered = err.to_string();
171 assert!(rendered.contains("422"));
172 assert!(rendered.contains("bad subject"));
173 }
174}