Skip to main content

cratefield_core/ports/
mailer.rs

1//! The `Mailer` port (architecture section 5). The Resend adapter is the
2//! reference implementation (issue #6).
3
4use async_trait::async_trait;
5use serde::Serialize;
6use std::time::Duration;
7
8/// An outbound mail. `text` is always sent alongside `html`.
9///
10/// `#[non_exhaustive]`: build one with [`Message::new`] and the builder
11/// methods rather than a struct literal. A channel that has to set a
12/// header — RFC 8058 one-click unsubscribe, say — should not be a
13/// breaking change for every other caller, and before this it was.
14#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub struct Message {
17    pub to: String,
18    pub from: String,
19    pub reply_to: Option<String>,
20    pub subject: String,
21    pub html: String,
22    pub text: String,
23    /// Passed through as an `Idempotency-Key` where the provider supports it.
24    pub idempotency_key: Option<String>,
25    pub tags: Vec<String>,
26    /// Extra RFC 5322 headers, in order.
27    ///
28    /// For headers the shape of `Message` does not name and should not:
29    /// `List-Unsubscribe` and `List-Unsubscribe-Post` are the reason this
30    /// exists, because Gmail and Yahoo have required one-click
31    /// unsubscribe of bulk senders since 2024. An adapter that cannot
32    /// send custom headers must say so rather than drop them silently.
33    pub headers: Vec<(String, String)>,
34}
35
36impl Message {
37    /// The five parts every mail has. Everything else is a builder method.
38    #[must_use]
39    pub fn new(
40        to: impl Into<String>,
41        from: impl Into<String>,
42        subject: impl Into<String>,
43        text: impl Into<String>,
44        html: impl Into<String>,
45    ) -> Self {
46        Self {
47            to: to.into(),
48            from: from.into(),
49            reply_to: None,
50            subject: subject.into(),
51            html: html.into(),
52            text: text.into(),
53            idempotency_key: None,
54            tags: Vec::new(),
55            headers: Vec::new(),
56        }
57    }
58
59    /// Where a reply goes, when it is not `from`.
60    #[must_use]
61    pub fn reply_to(mut self, address: impl Into<String>) -> Self {
62        self.reply_to = Some(address.into());
63        self
64    }
65
66    /// The key a provider that supports it uses to collapse a retry.
67    #[must_use]
68    pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
69        self.idempotency_key = Some(key.into());
70        self
71    }
72
73    /// Labels for the provider's own reporting.
74    #[must_use]
75    pub fn tags<I, T>(mut self, tags: I) -> Self
76    where
77        I: IntoIterator<Item = T>,
78        T: Into<String>,
79    {
80        self.tags = tags.into_iter().map(Into::into).collect();
81        self
82    }
83
84    /// One extra RFC 5322 header. Repeatable; order is preserved.
85    #[must_use]
86    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
87        self.headers.push((name.into(), value.into()));
88        self
89    }
90}
91
92/// Result of a send attempt.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum SendOutcome {
95    /// Sent; carries the provider message id.
96    Sent { id: String },
97    /// The adapter is not configured (no API key / unverified sending
98    /// domain). Whether the caller sees it is the endpoint's choice: the
99    /// orgs invitation endpoints turn it into `503 mail-not-configured`,
100    /// while the waitlist logs it and still answers `202`.
101    NotConfigured,
102}
103
104/// Mailer failures, mapped by the adapter from provider responses.
105///
106/// Every variant that carries provider text is sanitized in `Display`,
107/// the same way [`DbError`](crate::DbError)'s is (issue #235). "Never
108/// includes the API key" was too weak a promise: the text an adapter
109/// wraps is the provider's own response, and a `422` from Resend quotes
110/// the field it objected to — which for a send is the **recipient
111/// address**. `Display` therefore runs it through
112/// [`crate::logging::scrub_text`], so every `tracing` field, problem
113/// detail, report line and `format!` that renders a `MailError` gets the
114/// sanitized text rather than each call site remembering to. `Debug`
115/// still shows the raw string for tests; the logging formatters scrub
116/// `{:?}` output too.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum MailError {
119    /// The API key is missing, wrong or revoked.
120    Unauthorized,
121    /// The sending domain is not verified with the provider, so nothing
122    /// from it will be accepted until somebody adds the DNS records.
123    DomainNotVerified { domain: String },
124    /// The provider refused the message itself. `detail` is **its**
125    /// wording, so it can quote the recipient — see the type docs.
126    Invalid { detail: String },
127    /// Too many sends; retry no earlier than `retry_after` when the
128    /// provider named one.
129    RateLimited { retry_after: Option<Duration> },
130    /// The provider failed on its side (a `5xx`, an unparseable body).
131    Upstream(String),
132    /// The request never got an answer: a socket, a DNS failure, a
133    /// timeout.
134    Transport(String),
135}
136
137impl std::fmt::Display for MailError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        let scrub = crate::logging::scrub_text;
140        match self {
141            Self::Unauthorized => {
142                f.write_str("mailer rejected the request as unauthorized (check the API key)")
143            }
144            Self::DomainNotVerified { domain } => write!(
145                f,
146                "sending domain {:?} is not verified with the mailer",
147                scrub(domain)
148            ),
149            Self::Invalid { detail } => {
150                write!(
151                    f,
152                    "mailer rejected the message as invalid: {}",
153                    scrub(detail)
154                )
155            }
156            Self::RateLimited { retry_after } => write!(
157                f,
158                "mailer rate limited the request; retry after {retry_after:?}"
159            ),
160            Self::Upstream(message) => write!(f, "mailer upstream error: {}", scrub(message)),
161            Self::Transport(message) => write!(f, "mailer transport error: {}", scrub(message)),
162        }
163    }
164}
165
166impl std::error::Error for MailError {}
167
168/// One mail provider behind a [`Mailer`], as reported to `/__health`.
169///
170/// Name and two verdicts, never a credential: a health report that leaked an
171/// API key would be a secret published on an unauthenticated endpoint.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173pub struct MailProvider {
174    pub name: String,
175    pub configured: bool,
176    /// `None` when the adapter carries no probe, i.e. "unknown", not
177    /// "unhealthy" — the key is then omitted from the JSON entirely.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub healthy: Option<bool>,
180}
181
182impl MailProvider {
183    /// A provider the adapter can name and configure but not probe.
184    #[must_use]
185    pub fn new(name: impl Into<String>, configured: bool) -> Self {
186        Self {
187            name: name.into(),
188            configured,
189            healthy: None,
190        }
191    }
192}
193
194#[async_trait]
195pub trait Mailer: Send + Sync {
196    async fn send(&self, message: Message) -> Result<SendOutcome, MailError>;
197
198    /// The providers this mailer sends through, for `/__health`.
199    ///
200    /// The default reports one anonymous `"mailer"`, `configured: true`,
201    /// because the port being wired in *is* today's `"mailer": "configured"`
202    /// (issue #793) — an adapter that knows nothing about itself should still
203    /// show up in the listing rather than drop out of it. An adapter that
204    /// carries its own key overrides this to say whether it has one; neither
205    /// this nor the listing exposes a secret.
206    fn providers(&self) -> Vec<MailProvider> {
207        vec![MailProvider::new("mailer", true)]
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn display_sanitizes_the_provider_text() {
217        // The finding (issue #235): `Invalid { detail }` is set from the
218        // provider's own response — and Resend's 422 quotes the field it
219        // objected to, which for a send is the recipient address. The
220        // adapter's fallback is `body.to_string()`, the whole body.
221        let error = MailError::Invalid {
222            detail: "validation_error: `to` must be a valid address: alice@example.test".to_owned(),
223        };
224        let text = error.to_string();
225        assert!(!text.contains('@'), "{text}");
226        assert!(!text.contains("alice"), "{text}");
227        assert!(text.contains("[subject_hash:"), "{text}");
228
229        // An upstream body echoing the request URL must not disclose the
230        // query it carried.
231        let error = MailError::Upstream(
232            "POST https://api.resend.com/emails?key=live-abcdef failed".to_owned(),
233        );
234        let text = error.to_string();
235        assert!(!text.contains("live-abcdef"), "{text}");
236        assert!(text.contains("?[redacted]"), "{text}");
237
238        // The variants with nothing to hide read exactly as before.
239        assert_eq!(
240            MailError::Unauthorized.to_string(),
241            "mailer rejected the request as unauthorized (check the API key)"
242        );
243        assert_eq!(
244            MailError::RateLimited { retry_after: None }.to_string(),
245            "mailer rate limited the request; retry after None"
246        );
247
248        // `Debug` still shows the raw string, which is what a failing
249        // test needs to be readable; the log formatters scrub `{:?}` too.
250        assert!(format!("{error:?}").contains("live-abcdef"));
251    }
252
253    #[test]
254    fn a_domain_that_is_an_address_is_still_scrubbed() {
255        // `DomainNotVerified` looks safe — a sending domain is not
256        // personal data — but adapters set it from whatever the provider
257        // named, and Resend names the whole `from` when it objects.
258        let error = MailError::DomainNotVerified {
259            domain: "no-reply@send.example.test".to_owned(),
260        };
261        let text = error.to_string();
262        assert!(!text.contains('@'), "{text}");
263        assert!(text.contains("[subject_hash:"), "{text}");
264    }
265}