Skip to main content

max / makenotwork

12.1 KB · 334 lines History Blame Raw
1 //! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues.
2
3 mod auth_check;
4 mod issues;
5 mod patches;
6
7 use auth_check::inbound_sender_trusted;
8
9 use axum::{
10 Json,
11 extract::State,
12 http::{HeaderMap, StatusCode},
13 response::IntoResponse,
14 };
15 use serde::Deserialize;
16
17 use crate::{
18 AppState,
19 config::Config,
20 csrf::{CsrfRouter, post_csrf_skip},
21 db::{self, mail_attribution::IncidentKind},
22 email::EmailClient,
23 };
24 use sqlx::PgPool;
25
26 /// Subset of a Postmark webhook payload we care about.
27 #[derive(Debug, Deserialize)]
28 #[serde(rename_all = "PascalCase")]
29 struct PostmarkWebhookPayload {
30 record_type: String,
31 #[serde(default)]
32 email: String,
33 /// Bounce sub-type (e.g. "HardBounce", "SoftBounce"). Only present for bounces.
34 #[serde(rename = "Type")]
35 bounce_type: Option<String>,
36 /// What the send carried, handed back (`93f23f00`). Postmark echoes the
37 /// `Metadata` object the message was sent with; the one key is `send`, and
38 /// everything else about the fan-out hangs off the row it names.
39 ///
40 /// Absent for transactional mail, which belongs to no fan-out, and for a
41 /// record type that carries no metadata. Untyped strings because that is
42 /// what the wire is: the parse into an id happens below, where a value that
43 /// is not one can be logged and ignored rather than failing the webhook.
44 #[serde(default)]
45 metadata: std::collections::HashMap<String, String>,
46 }
47
48 impl PostmarkWebhookPayload {
49 /// The fan-out this message belonged to, where it said so.
50 ///
51 /// A value that will not parse is a message Postmark handed back with
52 /// something else in the key, which is a bug in whatever sent it rather
53 /// than a reason to refuse the webhook: the suppression still has to
54 /// happen, so this is `None` and the incident is recorded unattributed.
55 fn send(&self) -> Option<db::EmailSendId> {
56 let raw = self.metadata.get("send")?;
57 match raw.parse::<uuid::Uuid>() {
58 Ok(id) => Some(db::EmailSendId::from(id)),
59 Err(_) => {
60 tracing::warn!(value = %raw, "Postmark metadata carried an unparseable send id");
61 None
62 }
63 }
64 }
65 }
66
67 /// Postmark inbound email webhook payload.
68 #[derive(Debug, Deserialize)]
69 #[serde(rename_all = "PascalCase")]
70 pub(super) struct PostmarkInboundPayload {
71 pub from_full: PostmarkAddress,
72 pub to: String,
73 pub subject: String,
74 #[serde(rename = "TextBody")]
75 pub text_body: String,
76 #[serde(rename = "MessageID")]
77 pub message_id: String,
78 #[serde(default)]
79 pub headers: Vec<PostmarkHeader>,
80 }
81
82 #[derive(Debug, Deserialize)]
83 #[serde(rename_all = "PascalCase")]
84 pub(super) struct PostmarkAddress {
85 pub email: String,
86 pub name: String,
87 }
88
89 #[derive(Debug, Deserialize)]
90 #[serde(rename_all = "PascalCase")]
91 pub(super) struct PostmarkHeader {
92 pub name: String,
93 pub value: String,
94 }
95
96 /// The outcome of an inbound Postmark handler, encoding whether Postmark should
97 /// redeliver.
98 ///
99 /// The inbound handlers have no local retry queue (unlike the Stripe webhook), so
100 /// acking a *transient* failure, a DB or MT-service error, a write that couldn't
101 /// persist, permanently drops the message. Returning a typed outcome makes that
102 /// mistake unwritable: a transient error branch yields [`HandlerOutcome::Transient`]
103 /// (mapped to 5xx so Postmark retries), and only genuinely terminal outcomes (bad
104 /// address, sender not found, unverified sender, retrying can't help) return a 2xx.
105 pub(super) enum HandlerOutcome {
106 /// A definitive result; Postmark should not redeliver.
107 Terminal(StatusCode),
108 /// A transient, retryable failure; return 5xx so Postmark redelivers.
109 Transient(anyhow::Error),
110 }
111
112 impl IntoResponse for HandlerOutcome {
113 fn into_response(self) -> axum::response::Response {
114 match self {
115 HandlerOutcome::Terminal(code) => code.into_response(),
116 HandlerOutcome::Transient(e) => {
117 tracing::error!(error = ?e, "inbound webhook transient failure; returning 503 for Postmark redelivery");
118 StatusCode::SERVICE_UNAVAILABLE.into_response()
119 }
120 }
121 }
122 }
123
124 /// Verify the bearer token from the Authorization header.
125 pub(super) fn verify_token(headers: &HeaderMap, expected: &str) -> bool {
126 headers
127 .get("authorization")
128 .and_then(|v| v.to_str().ok())
129 .and_then(|v| v.strip_prefix("Bearer "))
130 .is_some_and(|token| crate::helpers::constant_time_compare(token, expected))
131 }
132
133 /// Handle Postmark bounce/complaint webhooks.
134 ///
135 /// - `Bounce` with `Type: "HardBounce"` -> add to suppression list
136 /// - `SpamComplaint` -> add to suppression list
137 /// - Everything else -> log and return 200
138 #[tracing::instrument(skip_all, name = "postmark::postmark_webhook")]
139 async fn postmark_webhook(
140 State(db): State<PgPool>,
141 State(email): State<EmailClient>,
142 State(config): State<Config>,
143 headers: HeaderMap,
144 Json(payload): Json<PostmarkWebhookPayload>,
145 ) -> HandlerOutcome {
146 // Authenticate: accept either transactional or broadcast webhook token
147 let transactional_ok = config
148 .email_webhooks
149 .webhook_token
150 .as_deref()
151 .is_some_and(|t| verify_token(&headers, t));
152 let broadcast_ok = config
153 .email_webhooks
154 .broadcast_webhook_token
155 .as_deref()
156 .is_some_and(|t| verify_token(&headers, t));
157
158 if !transactional_ok && !broadcast_ok {
159 if config.email_webhooks.webhook_token.is_none()
160 && config.email_webhooks.broadcast_webhook_token.is_none()
161 {
162 tracing::warn!("Postmark webhook received but no webhook tokens configured");
163 } else {
164 tracing::warn!("Postmark webhook: invalid bearer token");
165 }
166 return HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED);
167 }
168
169 match payload.record_type.as_str() {
170 "Bounce" => {
171 let is_hard = payload.bounce_type.as_deref() == Some("HardBounce");
172 if is_hard {
173 tracing::info!(email = %payload.email, "Postmark hard bounce, adding to suppression list");
174 // A failed suppression write is transient: return 5xx so Postmark
175 // redelivers, rather than leaving a hard-bounced address un-suppressed
176 // (deliverability/compliance drift).
177 if let Err(e) =
178 db::email_suppressions::add_suppression(&db, &payload.email, "HardBounce").await
179 {
180 return HandlerOutcome::Transient(
181 anyhow::Error::new(e).context("add hard-bounce suppression"),
182 );
183 }
184 record_incident(&db, &email, &payload, IncidentKind::HardBounce).await;
185 } else {
186 tracing::info!(
187 email = %payload.email,
188 bounce_type = ?payload.bounce_type,
189 "Postmark soft bounce, ignoring"
190 );
191 }
192 }
193 "SpamComplaint" => {
194 tracing::info!(email = %payload.email, "Postmark spam complaint, adding to suppression list");
195 if let Err(e) =
196 db::email_suppressions::add_suppression(&db, &payload.email, "SpamComplaint").await
197 {
198 return HandlerOutcome::Transient(
199 anyhow::Error::new(e).context("add spam-complaint suppression"),
200 );
201 }
202 record_incident(&db, &email, &payload, IncidentKind::Complaint).await;
203 }
204 other => {
205 tracing::debug!(record_type = %other, "Postmark webhook: unhandled record type");
206 }
207 }
208
209 HandlerOutcome::Terminal(StatusCode::OK)
210 }
211
212 /// Count this bounce or complaint against whoever sent the mail.
213 ///
214 /// `93f23f00`. After the suppression and never in front of it: the suppression
215 /// is what stops the next mail and is the thing Postmark is told about by the
216 /// 200, whereas this is bookkeeping for a rate nobody reads in the next second.
217 ///
218 /// A failure here is logged and swallowed rather than returned. Returning would
219 /// make Postmark redeliver, and a redelivery re-runs the suppression insert --
220 /// which is idempotent -- and then this one, which is not: the second attempt
221 /// would count the same complaint twice and inflate the rate. An
222 /// under-counted incident is the safer failure.
223 ///
224 /// A complaint is also the moment a creator's complaint rate changes, so it is
225 /// where the rate is judged and an operator told (`db::mail_caps`). Nothing
226 /// about the creator's allowance moves; the check only notifies.
227 async fn record_incident(
228 db: &PgPool,
229 email: &EmailClient,
230 payload: &PostmarkWebhookPayload,
231 kind: IncidentKind,
232 ) {
233 let attribution =
234 match db::mail_attribution::record_incident(db, payload.send(), &payload.email, kind).await
235 {
236 Ok(attribution) => attribution,
237 Err(error) => {
238 tracing::warn!(
239 error = ?error, email = %payload.email, kind = kind.as_str(),
240 "suppressed the address but could not attribute the incident"
241 );
242 return;
243 }
244 };
245
246 let Some(attribution) = attribution.filter(|_| kind == IncidentKind::Complaint) else {
247 return;
248 };
249
250 if let Err(error) =
251 db::mail_caps::notify_operator_of_complaint_rate(db, email, attribution).await
252 {
253 tracing::warn!(
254 error = ?error, creator_id = %attribution.creator_id,
255 "recorded the complaint but could not review the creator's complaint rate"
256 );
257 }
258 }
259
260 /// Register Postmark webhook routes.
261 pub fn postmark_routes() -> CsrfRouter<AppState> {
262 CsrfRouter::new()
263 .route(
264 "/postmark/webhook",
265 post_csrf_skip(
266 "webhook: postmark signature verified in handler",
267 postmark_webhook,
268 ),
269 )
270 .route(
271 "/postmark/inbound",
272 post_csrf_skip(
273 "webhook: postmark inbound, signature verified in handler",
274 patches::postmark_inbound,
275 ),
276 )
277 .route(
278 "/postmark/inbound-issues",
279 post_csrf_skip(
280 "webhook: postmark inbound, signature verified in handler",
281 issues::postmark_inbound_issues,
282 ),
283 )
284 }
285
286 #[cfg(test)]
287 mod tests {
288 use super::*;
289
290 fn payload(json: &str) -> PostmarkWebhookPayload {
291 serde_json::from_str(json).expect("Postmark sends this shape")
292 }
293
294 /// `93f23f00`. The id goes out on the mail and comes back on the complaint,
295 /// which is the whole mechanism.
296 #[test]
297 fn a_complaint_carries_back_the_send_it_came_from() {
298 let sent = uuid::Uuid::new_v4();
299 let complaint = payload(&format!(
300 r#"{{"RecordType":"SpamComplaint","Email":"a@example.com",
301 "Metadata":{{"send":"{sent}"}}}}"#
302 ));
303
304 assert_eq!(complaint.send().map(uuid::Uuid::from), Some(sent));
305 }
306
307 /// Transactional mail belongs to no fan-out, and a record type that carries
308 /// no metadata at all still has to parse: the suppression is the part that
309 /// matters and it must not be lost to an attribution that was never there.
310 #[test]
311 fn mail_with_no_fan_out_attributes_nothing_and_still_parses() {
312 let bare =
313 payload(r#"{"RecordType":"Bounce","Email":"a@example.com","Type":"HardBounce"}"#);
314 assert!(bare.send().is_none());
315 assert_eq!(bare.bounce_type.as_deref(), Some("HardBounce"));
316
317 let empty =
318 payload(r#"{"RecordType":"SpamComplaint","Email":"a@example.com","Metadata":{}}"#);
319 assert!(empty.send().is_none());
320 }
321
322 /// Something else in the key is a bug in whatever sent the mail, not a
323 /// reason to refuse the webhook. The address still has to be suppressed, so
324 /// the incident is recorded unattributed rather than dropped.
325 #[test]
326 fn an_unparseable_send_id_is_ignored_rather_than_fatal() {
327 let wrong = payload(
328 r#"{"RecordType":"SpamComplaint","Email":"a@example.com",
329 "Metadata":{"send":"not-a-uuid"}}"#,
330 );
331 assert!(wrong.send().is_none());
332 }
333 }
334