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 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}