backbone-digest 0.11.1

KPI digests — the periodic KPI-email engine: a declarative name-keyed KPI registry other modules extend, per-recipient fenced rendering with declared (never row-count) KPI drops, the anti-spam slowdown ladder, a shared tips carousel, and a daily plain-pull cron whose due-date column is the queue (Odoo digest port)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! The RFC 8058 one-click unsubscribe engine (hand-written; user-owned).
//!
//! The sanctioned HMAC action-link class (ADR-0019): POST-form, Tier A
//! token, idempotent. The token is the capability — `HMAC-SHA256` over
//! `(digest_id, user_id, expiry)` with a `digest-unsubscribe` domain
//! separator, verified constant-time (`consteq` semantics via
//! `hmac`'s `verify_slice`), carried as `{digest}.{user}.{exp}.{mac}`.
//!
//! PORTED DEVIATIONS (recorded):
//!
//! 1. **Spelling** — upstream's shipped route is `unsubscribe_oneclik`
//!    (the typo is API; every mailed link carries it). This port uses the
//!    CORRECT RFC 8058 spelling (`/digest/unsubscribe`): no mailed link
//!    predates this module, so nothing can break.
//! 2. **Expiry** — upstream tokens never expire (EBB-20 family). Tier A
//!    tokens here carry a 180-day expiry: the slowest ladder rung is
//!    quarterly, so a token comfortably outlives the gap between any two
//!    mails, while a leaked link eventually dies. An EXPIRED or FORGED
//!    token gets the SAME bare 200 with NO action as a valid one — no
//!    oracle, no bounce (RFC 8058 §3 wants 2xx from the MUA path).
//! 3. **DKIM** — one-click is only enforceable when the mail is DKIM-
//!    signed on a domain the receiver trusts. That is an OPS dependency
//!    (the sending domain's DKIM/SPF/DMARC posture), named here per the
//!    binding condition; this module cannot provide it.
//!
//! Idempotency: the subscription row IS the fence — `digest.digest_subscriptions`
//! carries a `subscribed`/`unsubscribed` state with one row per
//! (digest, user), so the first valid POST flips the row to the
//! `unsubscribed` tombstone (stamping `unsubscribed_at`); every re-POST
//! (the MUA re-fires, the user re-clicks) finds the row already
//! tombstoned and no-ops, still 200, and the audit fact is written only
//! on the performed edge (never duplicated by replays).

use chrono::{Duration, NaiveDateTime, Utc};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

use crate::application::service::digest_error::DigestError;

/// Env var holding the HMAC secret for unsubscribe tokens.
pub const DIGEST_TOKEN_SECRET_ENV: &str = "DIGEST_TOKEN_SECRET";

/// Token lifetime: 180 days (see the deviations note above).
pub const TOKEN_TTL_DAYS: i64 = 180;

/// The HMAC domain separator (upstream's `'digest-unsubscribe'` key).
const DOMAIN: &str = "digest-unsubscribe";

type HmacSha256 = Hmac<Sha256>;

/// The public unsubscribe token: `{digest_id}.{user_id}.{exp}.{mac_hex}`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnsubscribeToken {
    pub digest_id: Uuid,
    pub user_id: Uuid,
    pub exp: i64,
    pub mac: String,
}

/// The parsed `UnsubscribeToken` verdict for the public surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenVerdict {
    Valid,
    /// A genuine link past its expiry: the recipient is told the link has
    /// expired. Only a token carrying the right MAC can read as expired, so
    /// this says nothing a forger could use.
    Expired,
    /// Wrong MAC or malformed.
    Refused,
}

/// What an acting unsubscribe request came to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnsubscribeOutcome {
    /// This request unsubscribed the recipient.
    Unsubscribed,
    /// The link was genuine and the recipient was already unsubscribed.
    AlreadyUnsubscribed,
    /// A genuine link past its expiry: nothing changed.
    Expired,
    /// Not a link this service minted: nothing changed.
    Refused,
}

/// Throttle knobs for the verify path (the survey Tier-B precedent,
/// flattened): a failed verify locks the (digest, user) key out briefly so
/// a forger cannot grind the MAC.
pub const VERIFY_MAX_FAILURES: i32 = 5;
pub const VERIFY_LOCK_SECONDS: i64 = 60;

/// The verify-failure book (in-memory per composing service; a
/// multi-instance host fronts it with a shared limiter — the family
/// trade). Each key holds its failure count and the moment of its latest
/// failure; a lock lapses `VERIFY_LOCK_SECONDS` after that moment.
#[derive(Debug, Default)]
pub struct VerifyFailureBook {
    entries: Mutex<HashMap<String, (i32, std::time::Instant)>>,
}

impl VerifyFailureBook {
    pub fn new() -> Self {
        Self::default()
    }

    fn key(digest_id: Uuid, user_id: Uuid) -> String {
        format!("digest-unsub|{digest_id}|{user_id}")
    }

    fn window() -> std::time::Duration {
        std::time::Duration::from_secs(VERIFY_LOCK_SECONDS as u64)
    }

    /// Is this (digest, user) pair currently locked out? A lock lapses once
    /// its window has passed since the latest failure.
    pub fn locked(&self, digest_id: Uuid, user_id: Uuid) -> bool {
        let mut guard = self.entries.lock().unwrap_or_else(|e| e.into_inner());
        let key = Self::key(digest_id, user_id);
        match guard.get(&key) {
            Some((_, at)) if at.elapsed() >= Self::window() => {
                guard.remove(&key);
                false
            }
            Some((n, _)) => *n >= VERIFY_MAX_FAILURES,
            None => false,
        }
    }

    pub fn register_failure(&self, digest_id: Uuid, user_id: Uuid) {
        let mut guard = self.entries.lock().unwrap_or_else(|e| e.into_inner());
        let entry = guard.entry(Self::key(digest_id, user_id)).or_insert((0, std::time::Instant::now()));
        if entry.1.elapsed() >= Self::window() {
            entry.0 = 0;
        }
        entry.0 += 1;
        entry.1 = std::time::Instant::now();
    }

    pub fn reset(&self, digest_id: Uuid, user_id: Uuid) {
        self.entries
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .remove(&Self::key(digest_id, user_id));
    }
}

/// The unsubscribe engine: mint, verify (throttled), apply (idempotent).
pub struct UnsubscribeService {
    pool: PgPool,
    secret: Vec<u8>,
    failures: Arc<VerifyFailureBook>,
}

fn compute_mac(secret: &[u8], digest_id: &Uuid, user_id: &Uuid, exp: i64) -> String {
    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
    mac.update(format!("{DOMAIN}|{digest_id}|{user_id}|{exp}").as_bytes());
    mac.finalize()
        .into_bytes()
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect()
}

/// Constant-time comparison (the `consteq` requirement): the candidate
/// MAC is checked with `verify_slice`, which compares in constant time.
fn verify_mac(secret: &[u8], token: &UnsubscribeToken) -> bool {
    let Some(bytes) = hex_bytes(&token.mac) else {
        return false;
    };
    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
    mac.update(format!("{DOMAIN}|{}|{}|{}", token.digest_id, token.user_id, token.exp).as_bytes());
    mac.verify_slice(&bytes).is_ok()
}

fn hex_bytes(s: &str) -> Option<Vec<u8>> {
    if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
        .collect()
}

impl UnsubscribeService {
    /// From the builder's explicit secret.
    pub fn with_secret(pool: PgPool, secret: &[u8]) -> Self {
        Self {
            pool,
            secret: secret.to_vec(),
            failures: Arc::new(VerifyFailureBook::new()),
        }
    }

    /// From the environment (`DIGEST_TOKEN_SECRET`); errors loudly when
    /// unset or empty (a silent default or empty secret would let anyone
    /// mint a capability).
    pub fn from_env(pool: PgPool) -> Result<Self, DigestError> {
        let secret = std::env::var(DIGEST_TOKEN_SECRET_ENV)
            .map_err(|_| DigestError::SecretNotConfigured)?;
        if secret.trim().is_empty() {
            return Err(DigestError::SecretNotConfigured);
        }
        Ok(Self::with_secret(pool, secret.as_bytes()))
    }

    pub fn failure_book(&self) -> Arc<VerifyFailureBook> {
        self.failures.clone()
    }

    /// Mint the token for one (digest, user) at `now`.
    pub fn mint(&self, digest_id: Uuid, user_id: Uuid, now: impl Into<NaiveDateTime>) -> UnsubscribeToken {
        let now: NaiveDateTime = now.into();
        let exp_ts = (now + Duration::days(TOKEN_TTL_DAYS)).and_utc().timestamp();
        UnsubscribeToken {
            digest_id,
            user_id,
            exp: exp_ts,
            mac: compute_mac(&self.secret, &digest_id, &user_id, exp_ts),
        }
    }

    /// The mailed link's query value (`t=`).
    pub fn mint_link_token(&self, digest_id: Uuid, user_id: Uuid) -> String {
        let t = self.mint(digest_id, user_id, Utc::now().naive_utc());
        format!("{}.{}.{}.{}", t.digest_id, t.user_id, t.exp, t.mac)
    }

    /// Parse `{digest}.{user}.{exp}.{mac}` — `None` when malformed.
    pub fn parse(token: &str) -> Option<UnsubscribeToken> {
        let mut parts = token.split('.');
        let digest_id = Uuid::parse_str(parts.next()?).ok()?;
        let user_id = Uuid::parse_str(parts.next()?).ok()?;
        let exp: i64 = parts.next()?.parse().ok()?;
        let mac = parts.next()?.to_string();
        if mac.len() != 64 || !mac.bytes().all(|b| b.is_ascii_hexdigit()) {
            return None;
        }
        if parts.next().is_some() {
            return None;
        }
        Some(UnsubscribeToken { digest_id, user_id, exp, mac })
    }

    /// The (throttled) verdict for a candidate token at `now`. PURE-ish:
    /// touches only the in-memory failure book.
    ///
    /// The MAC is checked first: a token carrying the right MAC proves it
    /// was minted for this pair, so it is honoured (within its expiry)
    /// whatever forged requests did to the pair's failure count. Only a
    /// token that fails its MAC meets the lock and adds to it, so forgeries
    /// can never lock the real recipient out. An empty secret verifies
    /// nothing: anyone could mint a token for it.
    pub fn verify(&self, token: &UnsubscribeToken, now: i64) -> TokenVerdict {
        if self.secret.is_empty() {
            return TokenVerdict::Refused;
        }
        if verify_mac(&self.secret, token) {
            if now <= token.exp {
                self.failures.reset(token.digest_id, token.user_id);
                return TokenVerdict::Valid;
            }
            return TokenVerdict::Expired;
        }
        self.failures.register_failure(token.digest_id, token.user_id);
        TokenVerdict::Refused
    }

    /// Apply a VERIFIED unsubscribe, idempotently: flip the subscription
    /// row to the `unsubscribed` tombstone (stamping `unsubscribed_at`).
    /// Returns whether THIS call performed the unsubscribe (`false` =
    /// already unsubscribed, or no subscription row — both still a
    /// success for the route; RFC 8058 wants no error from the MUA path).
    /// The audit fact `digest_unsubscribed` is recorded only on the
    /// performed edge.
    pub async fn apply(&self, digest_id: Uuid, user_id: Uuid) -> Result<bool, DigestError> {
        let performed = sqlx::query(
            r#"UPDATE digest.digest_subscriptions
               SET state = 'unsubscribed', unsubscribed_at = NOW()
               WHERE digest_id = $1 AND user_id = $2
                 AND state = 'subscribed'
                 AND (metadata->>'deleted_at') IS NULL"#,
        )
        .bind(digest_id)
        .bind(user_id)
        .execute(&self.pool)
        .await?
        .rows_affected()
            == 1;
        if performed {
            tracing::info!(
                target: "digest::audit",
                event = "digest_unsubscribed",
                digest_id = %digest_id,
                user_id = %user_id,
                channel = "rfc8058_one_click"
            );
        }
        Ok(performed)
    }

    /// The declared reverse edge (the subscription machine's
    /// `resubscribe`): flip the tombstone back to `subscribed` and clear
    /// `unsubscribed_at`. Idempotent the same way — a re-run on a live
    /// row is a no-op success.
    pub async fn resubscribe(&self, digest_id: Uuid, user_id: Uuid) -> Result<bool, DigestError> {
        let performed = sqlx::query(
            r#"UPDATE digest.digest_subscriptions
               SET state = 'subscribed', unsubscribed_at = NULL
               WHERE digest_id = $1 AND user_id = $2
                 AND state = 'unsubscribed'
                 AND (metadata->>'deleted_at') IS NULL"#,
        )
        .bind(digest_id)
        .bind(user_id)
        .execute(&self.pool)
        .await?
        .rows_affected()
            == 1;
        Ok(performed)
    }

    /// The verdict for a link as it arrives, applying nothing: what the
    /// confirm page shows before the recipient acts.
    pub fn check_token(&self, token_str: &str, now: i64) -> TokenVerdict {
        match Self::parse(token_str) {
            Some(token) => self.verify(&token, now),
            None => {
                // Malformed: count it against the zero-uuid bucket so garbage
                // floods still hit a lock.
                self.failures.register_failure(Uuid::nil(), Uuid::nil());
                TokenVerdict::Refused
            }
        }
    }

    /// The full acting path: parse → verify → idempotent apply.
    pub async fn unsubscribe_by_token(&self, token_str: &str, now: i64) -> Result<UnsubscribeOutcome, DigestError> {
        let Some(token) = Self::parse(token_str) else {
            self.failures.register_failure(Uuid::nil(), Uuid::nil());
            return Ok(UnsubscribeOutcome::Refused);
        };
        Ok(match self.verify(&token, now) {
            TokenVerdict::Valid => {
                if self.apply(token.digest_id, token.user_id).await? {
                    UnsubscribeOutcome::Unsubscribed
                } else {
                    UnsubscribeOutcome::AlreadyUnsubscribed
                }
            }
            TokenVerdict::Expired => UnsubscribeOutcome::Expired,
            TokenVerdict::Refused => UnsubscribeOutcome::Refused,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn svc() -> UnsubscribeService {
        UnsubscribeService::with_secret(PgPool::connect_lazy("postgres://x").expect("lazy"), b"probe-secret")
    }

    // The pools above are lazy (no I/O) but sqlx still requires a Tokio
    // context to build one — hence tokio::test on these seats.

    #[tokio::test]
    async fn mint_parse_roundtrip() {
        let s = svc();
        let d = Uuid::new_v4();
        let u = Uuid::new_v4();
        let t = s.mint(d, u, Utc::now().naive_utc());
        let link = format!("{}.{}.{}.{}", t.digest_id, t.user_id, t.exp, t.mac);
        let parsed = UnsubscribeService::parse(&link).expect("parse");
        assert_eq!(parsed, t);
    }

    #[tokio::test]
    async fn verify_accepts_fresh_and_refuses_expired() {
        let s = svc();
        let d = Uuid::new_v4();
        let u = Uuid::new_v4();
        let now = Utc::now().timestamp();
        let t = s.mint(d, u, Utc::now().naive_utc());
        assert_eq!(s.verify(&t, now), TokenVerdict::Valid);
        assert_eq!(s.verify(&t, t.exp + 1), TokenVerdict::Expired);
    }

    #[tokio::test]
    async fn forged_mac_is_refused_and_locks() {
        let s = svc();
        let d = Uuid::new_v4();
        let u = Uuid::new_v4();
        let now = Utc::now().timestamp();
        let mut t = s.mint(d, u, Utc::now().naive_utc());
        t.mac = "0".repeat(64);
        for _ in 0..VERIFY_MAX_FAILURES {
            assert_eq!(s.verify(&t, now), TokenVerdict::Refused);
        }
        assert!(s.failures.locked(d, u));
        // The genuine token still verifies: forgeries cannot lock the
        // recipient out of their own link.
        let good = s.mint(d, u, Utc::now().naive_utc());
        assert_eq!(s.verify(&good, now), TokenVerdict::Valid);
    }

    #[tokio::test]
    async fn an_empty_secret_verifies_nothing() {
        let s = UnsubscribeService::with_secret(PgPool::connect_lazy("postgres://x").unwrap(), b"");
        let t = s.mint(Uuid::new_v4(), Uuid::new_v4(), Utc::now().naive_utc());
        assert_eq!(s.verify(&t, Utc::now().timestamp()), TokenVerdict::Refused, "anyone could mint this token");
    }

    #[tokio::test]
    async fn wrong_secret_is_refused() {
        let a = UnsubscribeService::with_secret(PgPool::connect_lazy("postgres://x").unwrap(), b"one");
        let b = UnsubscribeService::with_secret(PgPool::connect_lazy("postgres://x").unwrap(), b"two");
        let t = a.mint(Uuid::new_v4(), Uuid::new_v4(), Utc::now().naive_utc());
        assert_eq!(b.verify(&t, Utc::now().timestamp()), TokenVerdict::Refused);
    }
}