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