Skip to main content

sofie_mail/web/
events.rs

1use ring::hmac;
2use serde_json::Value;
3use sofie_core::encoding::{base64, hex};
4use sofie_core::{Error, Result};
5use sofie_db::{Read, Resolve, Writes};
6use sofie_http::{Delivery, Routes, Verify, Web};
7use sofie_values::Secret;
8
9use super::Suppressed;
10use crate::suppression::{self, Reason, Scope};
11
12/// Mailgun signs a timestamp; one older than this is refused as a replay.
13const FRESH_SECONDS: i64 = 15 * 60;
14const UNVERIFIED: &str = "the report could not be verified";
15
16#[derive(Default)]
17pub struct Events {
18    /// The `user:password` Postmark's webhook URL carries as basic authentication.
19    pub postmark: Option<Secret<String>>,
20    pub mailgun: Option<Secret<String>>,
21    /// For any other provider: an HMAC-SHA256 key over the body, sent as hex in
22    /// `X-Sofie-Signature`, the body being `{"id", "event": "bounce"|"complaint", "email"}`.
23    pub signed: Option<Secret<String>>,
24}
25
26impl Events {
27    /// `SOFIE_MAIL_POSTMARK_WEBHOOK_AUTH`, `SOFIE_MAIL_MAILGUN_SIGNING_KEY` and
28    /// `SOFIE_MAIL_WEBHOOK_SECRET`; a provider left unset gets no route.
29    pub fn from_environment() -> Result<Self> {
30        let read = |name| Ok::<_, Error>(sofie_core::environment_variable(name)?.map(Secret::new));
31        Ok(Self {
32            postmark: read("SOFIE_MAIL_POSTMARK_WEBHOOK_AUTH")?,
33            mailgun: read("SOFIE_MAIL_MAILGUN_SIGNING_KEY")?,
34            signed: read("SOFIE_MAIL_WEBHOOK_SECRET")?,
35        })
36    }
37
38    pub(super) fn routes(&self, routes: &mut Routes<Web>) {
39        if let Some(auth) = &self.postmark {
40            routes
41                .webhook("/mail/events/postmark", postmark)
42                .verify(Postmark(sofie_auth::digest(&[auth.expose().as_bytes()])));
43        }
44        if let Some(key) = &self.mailgun {
45            routes
46                .webhook("/mail/events/mailgun", mailgun)
47                .verify(Mailgun(hmac_key(key)));
48        }
49        if let Some(key) = &self.signed {
50            routes
51                .webhook("/mail/events/signed", signed)
52                .verify(Signed(hmac_key(key)));
53        }
54    }
55}
56
57fn hmac_key(key: &Secret<String>) -> hmac::Key {
58    hmac::Key::new(hmac::HMAC_SHA256, key.expose().as_bytes())
59}
60
61fn json(body: &[u8]) -> Result<Value> {
62    serde_json::from_slice(body).map_err(|_| Error::BadRequest("the report is not JSON".to_owned()))
63}
64
65fn unverified() -> Error {
66    Error::BadRequest(UNVERIFIED.to_owned())
67}
68
69/// Compared by digest, so the time taken tells nothing of the secret.
70struct Postmark(String);
71
72impl Verify for Postmark {
73    fn verify(&self, delivery: &Delivery<'_>) -> Result<String> {
74        let given = delivery
75            .header("authorization")
76            .and_then(|value| value.strip_prefix("Basic "))
77            .and_then(|encoded| base64::decode(&base64::STANDARD, encoded))
78            .ok_or_else(unverified)?;
79        if sofie_auth::digest(&[&given]) != self.0 {
80            return Err(unverified());
81        }
82        report_id(delivery.body(), "ID")
83    }
84}
85
86struct Mailgun(hmac::Key);
87
88impl Verify for Mailgun {
89    fn verify(&self, delivery: &Delivery<'_>) -> Result<String> {
90        let report = json(delivery.body())?;
91        let signature = &report["signature"];
92        let (Some(timestamp), Some(token), Some(signed)) = (
93            signature["timestamp"].as_str(),
94            signature["token"].as_str(),
95            signature["signature"].as_str().and_then(hex::decode),
96        ) else {
97            return Err(unverified());
98        };
99        let fresh = timestamp
100            .parse::<i64>()
101            .is_ok_and(|at| (delivery.now().as_second() - at).abs() <= FRESH_SECONDS);
102        let message = format!("{timestamp}{token}");
103        if !fresh || hmac::verify(&self.0, message.as_bytes(), &signed).is_err() {
104            return Err(unverified());
105        }
106        Ok(token.to_owned())
107    }
108}
109
110struct Signed(hmac::Key);
111
112impl Verify for Signed {
113    fn verify(&self, delivery: &Delivery<'_>) -> Result<String> {
114        let signed = delivery
115            .header("x-sofie-signature")
116            .and_then(hex::decode)
117            .ok_or_else(unverified)?;
118        hmac::verify(&self.0, delivery.body(), &signed).map_err(|_| unverified())?;
119        report_id(delivery.body(), "id")
120    }
121}
122
123fn report_id(body: &[u8], key: &str) -> Result<String> {
124    match &json(body)?[key] {
125        Value::String(id) if !id.is_empty() => Ok(id.clone()),
126        Value::Number(id) => Ok(id.to_string()),
127        _ => Err(Error::BadRequest(format!("the report has no `{key}`"))),
128    }
129}
130
131pub(crate) struct Report(Value);
132
133impl<C: Writes> Resolve<C> for Report {
134    fn resolve(context: &mut C) -> impl Future<Output = Result<Self>> + Send {
135        std::future::ready(context.input().map(Self))
136    }
137}
138
139async fn suppressed(
140    read: &Read,
141    address: Option<&str>,
142    scope: Option<(Scope, Reason)>,
143) -> Result<Suppressed> {
144    let (Some(address), Some((scope, reason))) = (address, scope) else {
145        return Ok(Suppressed(None));
146    };
147    suppression::suppress(read, address, scope, reason)
148        .await
149        .map(Suppressed)
150}
151
152/// A soft bounce or any other record type changes nothing.
153async fn postmark(report: Report, read: Read) -> Result<Suppressed> {
154    let report = report.0;
155    let scope = match (report["RecordType"].as_str(), report["Type"].as_str()) {
156        (Some("Bounce"), Some("HardBounce" | "BadEmailAddress")) => {
157            Some((Scope::All, Reason::Bounce))
158        }
159        (Some("SpamComplaint"), _) => Some((Scope::Bulk, Reason::Complaint)),
160        _ => None,
161    };
162    suppressed(&read, report["Email"].as_str(), scope).await
163}
164
165async fn mailgun(report: Report, read: Read) -> Result<Suppressed> {
166    let event = &report.0["event-data"];
167    let scope = match (event["event"].as_str(), event["severity"].as_str()) {
168        (Some("failed"), Some("permanent")) => Some((Scope::All, Reason::Bounce)),
169        (Some("complained"), _) => Some((Scope::Bulk, Reason::Complaint)),
170        _ => None,
171    };
172    suppressed(&read, event["recipient"].as_str(), scope).await
173}
174
175async fn signed(report: Report, read: Read) -> Result<Suppressed> {
176    let report = report.0;
177    let scope = match report["event"].as_str() {
178        Some("bounce") => Some((Scope::All, Reason::Bounce)),
179        Some("complaint") => Some((Scope::Bulk, Reason::Complaint)),
180        _ => None,
181    };
182    suppressed(&read, report["email"].as_str(), scope).await
183}