backend-manager 5.6.2 → 5.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/docs/consent.md +29 -4
- package/docs/email-system.md +1 -1
- package/package.json +1 -1
- package/src/manager/libraries/email/data/disposable-domains.json +6 -0
- package/src/manager/libraries/email/marketing/consent-gate.test.js +249 -0
- package/src/manager/libraries/email/marketing/index.js +67 -2
- package/src/manager/libraries/email/validation-provider-neverbounce.js +28 -9
- package/src/manager/libraries/email/validation.js +1 -1
- package/src/manager/libraries/email/validation.test.js +31 -1
- package/src/manager/routes/marketing/contact/delete.js +58 -0
- package/src/manager/routes/marketing/email-preferences/post.js +32 -3
- package/.claude/scheduled_tasks.lock +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.6.3] - 2026-06-11
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **NeverBounce single-check result parsing — every signup mailbox check has failed since v5.5.1.** `validation-provider-neverbounce.js` compared `data.result` against numeric codes (`data.result === 0 || 3 || 4`), but the NeverBounce v4 single/check API returns string textcodes (`"valid"`, `"invalid"`, ...) — so every completed check returned `{ valid: false, status: 'unknown' }`, including deliverable mailboxes, and the signup route silently skipped marketing sync for ALL signups. Production-confirmed via GCF logs: somiibo June 7 = 30 synced / 0 failed; June 9–11 = 0 synced / 93 failed; first failure 25 minutes after the v5.5.1 deploy (which switched signup to `ALL_CHECKS` and exposed the latent v5.5.0 bug). New `parseResult()` handles textcodes (canonical), tolerates numeric codes, and normalizes `catch-all` → `catchall`; allowed results stay valid/catchall/unknown. 11 regression parse cases added to `validation.test.js` (suite now 69 cases); fix live-validated against the real API. ZeroBounce provider audited — already string-based, no change.
|
|
21
|
+
- **Library-level marketing consent gate.** `Marketing.sync()`/`Marketing.add()` (`src/manager/libraries/email/marketing/index.js`) now skip users whose `consent.marketing.status` is the literal string `'revoked'` and return `{ blocked: 'consent', email }` (mirroring the `{ blocked: 'validation', ... }` shape) — BEFORE validation and provider calls. Previously the gate existed only at SOME call sites, so the payments on-write sync (`events/firestore/payments-webhooks/on-write.js`) and the admin PUT re-sync re-added users who had unsubscribed. `sync()` gates after the user doc is resolved (doc or uid); `add()` looks up the user by email first (same `auth.email` query as the webhook processors) — no user doc or lookup failure proceeds (fail open). ONLY `'revoked'` blocks: missing/null consent proceeds, so legacy users without a `consent` field keep syncing. `remove()` stays ungated. Covers all callers including the legacy API-command twins (`add-marketing-contact.js`). Gate logic unit-tested in `src/manager/libraries/email/marketing/consent-gate.test.js` (28 plain-node cases, no emulator).
|
|
22
|
+
- **Anonymous HMAC unsubscribe now removes the contact from Beehiiv too** (`routes/marketing/email-preferences/post.js`). The email-footer one-click unsubscribe only suppressed the SendGrid ASM group, leaving the contact live on Beehiiv — a compliance bug. The route now also calls `mailer.remove(email)` (best-effort, after the ASM call succeeds).
|
|
23
|
+
- **Anonymous HMAC re-subscribe now actually re-adds the contact.** Previously it only lifted the ASM suppression and wrote consent `granted` — the contact was never re-added to providers. `mirrorAnonymousToUserDoc` now returns the matched uid (or null); the route calls `mailer.sync(uid)` for matched users (the mirror writes `granted` first, so the library consent gate passes — no bypass flags) or `mailer.add({ email, source: 'resubscribe' })` for pure newsletter contacts. Best-effort, same testing guard as the ASM call.
|
|
24
|
+
- **Admin `DELETE /marketing/contact` now sticks** (`routes/marketing/contact/delete.js`). The route removed the contact from providers but left `consent.marketing.status = 'granted'`, so any later sync re-added the contact. After the provider removal it now mirrors `consent.marketing.status = 'revoked'` (`revokedAt` with `source: 'admin'`, same write shape as the webhook processors) to the matching user doc — best-effort, silent when the email maps to no user.
|
|
25
|
+
- **Stale docs/comments corrected in the same change set:** `docs/consent.md` now describes the shipped library gate, cross-provider HMAC unsubscribe, re-subscribe re-add, and admin-DELETE revoke mirror (new capture point 5); `validation.js` header no longer claims signup uses "disposable check only" (it runs `ALL_CHECKS` before marketing sync); `marketing/index.js` header no longer attributes the signup sync to the "Auth on-create handler" (it's the `/user/signup` route).
|
|
26
|
+
|
|
17
27
|
# [5.6.2] - 2026-06-11
|
|
18
28
|
|
|
19
29
|
### Fixed
|
package/CLAUDE.md
CHANGED
|
@@ -156,7 +156,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
156
156
|
- [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + resize at ingest + `@post/` rewriting). Also the publish target for the Ghostii article engine (`libraries/content/ghostii.js`).
|
|
157
157
|
- [docs/payment-system.md](docs/payment-system.md) — full payment pipeline: Intent → Webhook → On-Write → Transition; subscription model, statuses, `resolveSubscription()`, transition handlers, processor interface, product config, test processor
|
|
158
158
|
- [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), newsletter-driven blog article (`content.article.enabled`), template-owned schemas, asset hosting, seed campaigns
|
|
159
|
-
- [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), migration script template
|
|
159
|
+
- [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link (cross-provider unsub + re-add on resubscribe), admin contact-DELETE revoke mirror, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), library-level consent gate in `email.add()`/`email.sync()` (revoked-only skip), migration script template
|
|
160
160
|
- [docs/mcp.md](docs/mcp.md) — Model Context Protocol server: 19 tools, stdio + HTTP transports, OAuth, Claude Chat/Code configuration
|
|
161
161
|
|
|
162
162
|
### Subsystems & Libraries
|
package/README.md
CHANGED
|
@@ -451,10 +451,10 @@ GDPR/CASL-compliant consent capture and cross-provider unsubscribe sync.
|
|
|
451
451
|
- **Canonical user-doc shape** — `consent.{legal,marketing}.{status, grantedAt, revokedAt}` with full audit metadata (timestamp, source, IP, exact label text)
|
|
452
452
|
- **Server-authoritative timestamps** — client timestamps ignored, defending against clock manipulation
|
|
453
453
|
- **Account-page toggle** — `/account` notifications section lets logged-in users opt in/out, hits both SendGrid + Beehiiv
|
|
454
|
-
- **HMAC unsubscribe links** — email-footer one-click flow continues to work
|
|
454
|
+
- **HMAC unsubscribe links** — email-footer one-click flow continues to work; unsubscribe removes the contact from ALL providers (not just the SendGrid ASM group), re-subscribe re-adds the contact
|
|
455
455
|
- **Provider webhook receivers** — `POST /marketing/webhook?provider=sendgrid|beehiiv&key=X` catches unsubscribe / spam / bounce events from SendGrid and Beehiiv, writes the user doc + syncs to the OTHER provider
|
|
456
456
|
- **Parent forwarder** — single public webhook endpoint (`/marketing/webhook/forward`) on the parent BEM fans out to every brand's child BEM so each one updates its own Firestore
|
|
457
|
-
- **
|
|
457
|
+
- **Library-level consent gate** — `email.add()` and `email.sync()` skip users whose `consent.marketing.status === 'revoked'` (covers every call site: payment syncs, admin re-syncs, newsletter form); admin contact DELETE mirrors `revoked` back to the user doc so removals stick
|
|
458
458
|
|
|
459
459
|
See [docs/consent.md](docs/consent.md) for the full architecture, source enum reference, migration script template, and provider configuration steps.
|
|
460
460
|
|
package/docs/consent.md
CHANGED
|
@@ -9,7 +9,7 @@ This doc covers the **server-side** (BEM) part of the system. The matching front
|
|
|
9
9
|
1. **Capture explicit, affirmative consent** at signup with separate checkboxes for legal terms (required) and marketing communications (optional). Store the exact label text the user agreed to.
|
|
10
10
|
2. **Let users withdraw consent at any time** via the account-page toggle or the email-footer unsubscribe link.
|
|
11
11
|
3. **Stay in sync with provider-side actions** — when a user clicks unsubscribe in a SendGrid or Beehiiv email, the user doc updates AND the OTHER provider is also notified.
|
|
12
|
-
4. **Never re-add an unsubscribed user** — `email.add()` and `email.sync()` short-circuit when `consent.marketing.status === 'revoked'
|
|
12
|
+
4. **Never re-add an unsubscribed user** — `email.add()` and `email.sync()` short-circuit in the library itself when `consent.marketing.status === 'revoked'`, so every call site (payment-event syncs, admin re-syncs, public newsletter form, legacy API commands) is covered. See "Email library consent gate" below.
|
|
13
13
|
|
|
14
14
|
## Canonical user-doc shape
|
|
15
15
|
|
|
@@ -51,7 +51,7 @@ consent: {
|
|
|
51
51
|
|
|
52
52
|
## Capture points
|
|
53
53
|
|
|
54
|
-
There are
|
|
54
|
+
There are five places where consent gets recorded or updated. All five converge on the same canonical shape.
|
|
55
55
|
|
|
56
56
|
### 1. Signup form (Phase B)
|
|
57
57
|
|
|
@@ -110,6 +110,8 @@ Body: { email, asmId, sig, action: 'subscribe' | 'unsubscribe' }
|
|
|
110
110
|
- `sig = HMAC-SHA256(email, UNSUBSCRIBE_HMAC_KEY)` — proves we generated the link.
|
|
111
111
|
- IP-rate-limited (5/day per IP).
|
|
112
112
|
- **Also writes the user doc** if the email maps to a user — `consent.marketing.{status, revokedAt}` with `source: 'sendgrid'` (since HMAC links only appear in SendGrid email footers).
|
|
113
|
+
- **Unsubscribe is cross-provider** — besides the SendGrid ASM suppression, the route calls `mailer.remove(email)` (best-effort) so the contact is removed from Beehiiv too. ASM suppression alone only stops SendGrid sends.
|
|
114
|
+
- **Subscribe actually re-adds the contact** — the user-doc mirror writes `granted` BEFORE the provider calls, then the route calls `mailer.sync(uid)` when the email maps to a user (the fresh grant passes the library consent gate — no bypass flags) or `mailer.add({ email, source: 'resubscribe' })` when it doesn't. Best-effort, same testing guard as the ASM call.
|
|
113
115
|
- Backward-compatible — old in-flight email links continue to work.
|
|
114
116
|
|
|
115
117
|
### 4. Provider webhooks (Phase E)
|
|
@@ -145,6 +147,17 @@ Each processor's `handleEvent` does the same shape of work:
|
|
|
145
147
|
|
|
146
148
|
**Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against `beehiivProvider.getPublicationId()`, which reads `Manager.config.marketing.newsletter.publicationId` (populated at brand-onboarding time by OMEGA's `beehiiv/ensure/publication.js`). Mismatch → silent skip. This is how shared-publication events (e.g. devbeans shared by 6 brands) get routed correctly — each brand processes only events matching its own publication. Brands without `publicationId` in config silently skip all Beehiiv webhook events. The same convention applies to SendGrid: `marketing.campaigns.listId` is populated by OMEGA's `sendgrid/ensure/list.js`.
|
|
147
149
|
|
|
150
|
+
### 5. Admin contact removal
|
|
151
|
+
|
|
152
|
+
[src/manager/routes/marketing/contact/delete.js](../src/manager/routes/marketing/contact/delete.js) — admin-only endpoint.
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
DELETE /backend-manager/marketing/contact
|
|
156
|
+
Body: { email }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
After removing the contact from all providers, the route mirrors `consent.marketing.status = 'revoked'` to the user doc when the email maps to a user — `revokedAt` from server time with `source: 'admin'`, same write shape as the webhook processors (`grantedAt` preserved as audit history). Without this mirror, the next `sync()` (payment event, admin re-sync) would re-add the contact the admin just removed. Best-effort and silent when no user matches.
|
|
160
|
+
|
|
148
161
|
## Parent forwarder (Phase E)
|
|
149
162
|
|
|
150
163
|
[src/manager/routes/marketing/webhook/forward/post.js](../src/manager/routes/marketing/webhook/forward/post.js)
|
|
@@ -187,11 +200,22 @@ The parent BEM has its own brand (e.g. `itw-creative-works`) with its own users.
|
|
|
187
200
|
4. The 6 brands sharing the devbeans publication: `getPublicationId()` matches, they each look up the user, only the brand(s) with the user write the doc and call `mailer.remove`.
|
|
188
201
|
5. The brands with dedicated publications: `getPublicationId()` mismatch, silent skip.
|
|
189
202
|
|
|
190
|
-
## Email library
|
|
203
|
+
## Email library consent gate
|
|
191
204
|
|
|
192
205
|
[src/manager/libraries/email/marketing/index.js](../src/manager/libraries/email/marketing/index.js)
|
|
193
206
|
|
|
194
|
-
`email.add()` and `email.sync()` check the user's `consent.marketing.status` before
|
|
207
|
+
`email.add()` and `email.sync()` check the user's `consent.marketing.status` IN THE LIBRARY, before validation and provider calls. Because the gate lives in the library rather than at call sites, every caller is covered: the signup route, the email-preferences toggle, payment-event syncs ([events/firestore/payments-webhooks/on-write.js](../src/manager/events/firestore/payments-webhooks/on-write.js)), the admin PUT re-sync (`routes/marketing/contact/put.js`), the public newsletter form (`routes/marketing/contact/post.js`), and the legacy API-command twins (`functions/core/actions/api/general/add-marketing-contact.js`).
|
|
208
|
+
|
|
209
|
+
**Semantics:**
|
|
210
|
+
|
|
211
|
+
- **Revoked-only skip.** ONLY the literal string `'revoked'` blocks. Missing consent, `null`, or any other value proceeds — legacy users have no `consent` field and must keep syncing.
|
|
212
|
+
- **`sync()`** — after the user doc is resolved (whether passed as a doc or fetched by uid), a revoked doc logs a warn and returns `{ blocked: 'consent', email }` (mirrors the `{ blocked: 'validation', ... }` shape) BEFORE validation and provider calls.
|
|
213
|
+
- **`add()`** — looks up the user doc by email first (same `auth.email` equality query the webhook processors use). Doc found + revoked → `{ blocked: 'consent', email }`. No doc found → proceed (pure newsletter contact). Lookup failure → proceed (fail open, logged) — a rare duplicate add is recoverable; silently dropping contacts is not.
|
|
214
|
+
- **`remove()` is never gated** — removal is always safe.
|
|
215
|
+
|
|
216
|
+
The signup route ADDITIONALLY gates at its call site (`userRecord.consent.marketing.status === 'granted'` before calling `mailer.sync(uid)`) — that check runs before the route's paid NeverBounce/ZeroBounce mailbox validation, so declined users never trigger a paid check. The library gate is the safety net for everyone else.
|
|
217
|
+
|
|
218
|
+
The gate logic is unit-tested without Firestore in [src/manager/libraries/email/marketing/consent-gate.test.js](../src/manager/libraries/email/marketing/consent-gate.test.js) — run with `node src/manager/libraries/email/marketing/consent-gate.test.js`.
|
|
195
219
|
|
|
196
220
|
## Configuration
|
|
197
221
|
|
|
@@ -304,6 +328,7 @@ After the migration: optionally run a re-opt-in drip campaign to legally recover
|
|
|
304
328
|
- [test/routes/marketing/webhook.js](../test/routes/marketing/webhook.js) — 15+ tests covering SendGrid + Beehiiv processors against the emulator
|
|
305
329
|
- [test/routes/marketing/webhook-forward.js](../test/routes/marketing/webhook-forward.js) — verifies the forwarder route returns 404 on non-parent BEMs
|
|
306
330
|
- [test/helpers/webhook-forward.js](../test/helpers/webhook-forward.js) — 12 unit-style tests with mocked admin + fetch, covering fan-out, URL derivation, failure isolation, self-inclusion, edge cases
|
|
331
|
+
- [src/manager/libraries/email/marketing/consent-gate.test.js](../src/manager/libraries/email/marketing/consent-gate.test.js) — 28 plain-node cases for the library consent gate (revoked-only skip semantics, `{ blocked: 'consent' }` returns from `sync()`/`add()`, by-email lookup query + normalization, fail-open on lookup errors); runs directly with `node`, no emulator
|
|
307
332
|
|
|
308
333
|
**Total: 75+ tests across the consent system.**
|
|
309
334
|
|
package/docs/email-system.md
CHANGED
|
@@ -58,7 +58,7 @@ After `build()`, `send()` delivers via SendGrid, handles scheduled sends (>71h
|
|
|
58
58
|
|
|
59
59
|
### Email Validation Pipeline (`validation.js`)
|
|
60
60
|
|
|
61
|
-
All marketing contact operations (`add`, `sync`) pass through `validate()` before reaching providers. Checks run in order; the first failure short-circuits.
|
|
61
|
+
All marketing contact operations (`add`, `sync`) pass through `validate()` before reaching providers. Checks run in order; the first failure short-circuits. (The library consent gate runs even earlier — a user with `consent.marketing.status === 'revoked'` returns `{ blocked: 'consent', email }` before validation; see [consent.md](consent.md#email-library-consent-gate).)
|
|
62
62
|
|
|
63
63
|
| # | Check | What it catches | Cost | Default |
|
|
64
64
|
|---|---|---|---|---|
|
package/package.json
CHANGED
|
@@ -1256,6 +1256,7 @@
|
|
|
1256
1256
|
"cloudgen.world",
|
|
1257
1257
|
"cloudsign.in",
|
|
1258
1258
|
"cloudwhitespace.cc.cd",
|
|
1259
|
+
"cloudwhitezone.cc.cd",
|
|
1259
1260
|
"clout.wiki",
|
|
1260
1261
|
"clowmail.com",
|
|
1261
1262
|
"clrmail.com",
|
|
@@ -2406,6 +2407,7 @@
|
|
|
2406
2407
|
"gdmail.top",
|
|
2407
2408
|
"gdqoe.net",
|
|
2408
2409
|
"gdxs.cc.cd",
|
|
2410
|
+
"gear3.pro",
|
|
2409
2411
|
"gebrauchtwarencenter.com",
|
|
2410
2412
|
"gedmail.win",
|
|
2411
2413
|
"geekforex.com",
|
|
@@ -2624,6 +2626,7 @@
|
|
|
2624
2626
|
"gpt-mail-free-6419.dynv6.net",
|
|
2625
2627
|
"gpt-mail-free-6603.dynv6.net",
|
|
2626
2628
|
"gpt-mail-free-6664.dynv6.net",
|
|
2629
|
+
"gpt-mail-free-6691.dynv6.net",
|
|
2627
2630
|
"gpt-mail-free-6779.dynv6.net",
|
|
2628
2631
|
"gpt-mail-free-6812.dynv6.net",
|
|
2629
2632
|
"gpt-mail-free-6925.dynv6.net",
|
|
@@ -3412,6 +3415,7 @@
|
|
|
3412
3415
|
"kruay.com",
|
|
3413
3416
|
"krypton.tk",
|
|
3414
3417
|
"krysentra.cfd",
|
|
3418
|
+
"kskblzdjdwkzkbl.top",
|
|
3415
3419
|
"ksmtrck.tk",
|
|
3416
3420
|
"kuhaku.indevs.in",
|
|
3417
3421
|
"kuhrap.com",
|
|
@@ -5818,6 +5822,7 @@
|
|
|
5818
5822
|
"tempalias.com",
|
|
5819
5823
|
"tempblockchain.com",
|
|
5820
5824
|
"tempe-mail.com",
|
|
5825
|
+
"tempebossok.my.id",
|
|
5821
5826
|
"tempemail.biz",
|
|
5822
5827
|
"tempemail.co.za",
|
|
5823
5828
|
"tempemail.com",
|
|
@@ -6656,6 +6661,7 @@
|
|
|
6656
6661
|
"x24.com",
|
|
6657
6662
|
"x80.dpdns.org",
|
|
6658
6663
|
"x80la.shop",
|
|
6664
|
+
"x9ad1.my",
|
|
6659
6665
|
"xagloo.co",
|
|
6660
6666
|
"xagloo.com",
|
|
6661
6667
|
"xandoria.cfd",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent gate test — verifies Marketing.add()/sync() skip users whose
|
|
3
|
+
* consent.marketing.status is the literal string 'revoked' (and ONLY then).
|
|
4
|
+
*
|
|
5
|
+
* Plain-node control-flow test (no emulator, no providers, no network):
|
|
6
|
+
* - isMarketingRevoked() — the pure gate semantic (revoked-only skip).
|
|
7
|
+
* - sync() — gate fires after doc resolution, BEFORE validation/providers, so a
|
|
8
|
+
* revoked doc returns { blocked: 'consent', email } with zero I/O. Proceed cases
|
|
9
|
+
* stop at the testing-mode provider guard (assistant.isTesting() → true here).
|
|
10
|
+
* - add() — by-email lookup runs through a minimal in-memory firestore stand-in to
|
|
11
|
+
* exercise add()'s wiring (block on revoked, proceed on no-user, fail open on
|
|
12
|
+
* lookup error). The emulator suites (test/routes/marketing/*) remain the
|
|
13
|
+
* integration surface for the real Firestore paths.
|
|
14
|
+
*
|
|
15
|
+
* Run: node src/manager/libraries/email/marketing/consent-gate.test.js
|
|
16
|
+
*/
|
|
17
|
+
const Marketing = require('./index.js');
|
|
18
|
+
|
|
19
|
+
const { isMarketingRevoked } = Marketing;
|
|
20
|
+
|
|
21
|
+
// Proceed cases must stop at the testing-mode provider guard — never let an ambient
|
|
22
|
+
// TEST_EXTENDED_MODE (or provider API keys) in the shell turn this into a live call.
|
|
23
|
+
delete process.env.TEST_EXTENDED_MODE;
|
|
24
|
+
|
|
25
|
+
// ─── isMarketingRevoked(): ONLY the literal 'revoked' blocks ───
|
|
26
|
+
const GATE_CASES = [
|
|
27
|
+
{ name: 'status revoked', doc: { consent: { marketing: { status: 'revoked' } } }, expect: true },
|
|
28
|
+
{ name: 'status granted', doc: { consent: { marketing: { status: 'granted' } } }, expect: false },
|
|
29
|
+
{ name: 'status null', doc: { consent: { marketing: { status: null } } }, expect: false },
|
|
30
|
+
{ name: 'status missing', doc: { consent: { marketing: {} } }, expect: false },
|
|
31
|
+
{ name: 'marketing missing', doc: { consent: {} }, expect: false },
|
|
32
|
+
{ name: 'consent missing (legacy user)', doc: { auth: { email: 'legacy@gmail.com' } }, expect: false },
|
|
33
|
+
{ name: 'empty doc', doc: {}, expect: false },
|
|
34
|
+
{ name: 'null doc (no user)', doc: null, expect: false },
|
|
35
|
+
{ name: 'undefined doc', doc: undefined, expect: false },
|
|
36
|
+
{ name: 'status REVOKED (case-sensitive literal)', doc: { consent: { marketing: { status: 'REVOKED' } } }, expect: false },
|
|
37
|
+
{ name: 'status pending (future enum value)', doc: { consent: { marketing: { status: 'pending' } } }, expect: false },
|
|
38
|
+
{ name: 'status true (non-string)', doc: { consent: { marketing: { status: true } } }, expect: false },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Minimal assistant — just what the Marketing constructor + gate paths read.
|
|
43
|
+
* isTesting() → true so proceed cases stop at the provider guard (no network).
|
|
44
|
+
*/
|
|
45
|
+
function buildAssistant(admin) {
|
|
46
|
+
const calls = { logs: [], warns: [], errors: [] };
|
|
47
|
+
|
|
48
|
+
const assistant = {
|
|
49
|
+
Manager: {
|
|
50
|
+
libraries: { admin },
|
|
51
|
+
config: {},
|
|
52
|
+
},
|
|
53
|
+
isTesting: () => true,
|
|
54
|
+
log: (...args) => calls.logs.push(args),
|
|
55
|
+
warn: (...args) => calls.warns.push(args),
|
|
56
|
+
error: (...args) => calls.errors.push(args),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return { assistant, calls };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Minimal in-memory firestore stand-in for add()'s by-email lookup and sync()'s
|
|
64
|
+
* by-uid fetch. `userDoc: null` → empty query result / nonexistent doc.
|
|
65
|
+
* `failLookup: true` → the query get() rejects (exercises the fail-open path).
|
|
66
|
+
*/
|
|
67
|
+
function buildAdmin({ userDoc = null, failLookup = false } = {}) {
|
|
68
|
+
const captured = { queries: [], docPaths: [] };
|
|
69
|
+
|
|
70
|
+
const admin = {
|
|
71
|
+
firestore: () => ({
|
|
72
|
+
collection: (name) => ({
|
|
73
|
+
where: (field, op, value) => {
|
|
74
|
+
captured.queries.push({ collection: name, field, op, value });
|
|
75
|
+
return {
|
|
76
|
+
limit: () => ({
|
|
77
|
+
get: async () => {
|
|
78
|
+
if (failLookup) {
|
|
79
|
+
throw new Error('firestore unavailable');
|
|
80
|
+
}
|
|
81
|
+
return userDoc
|
|
82
|
+
? { empty: false, docs: [{ id: 'test-uid', data: () => userDoc }] }
|
|
83
|
+
: { empty: true, docs: [] };
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
doc: (path) => {
|
|
90
|
+
captured.docPaths.push(path);
|
|
91
|
+
return {
|
|
92
|
+
get: async () => ({
|
|
93
|
+
exists: !!userDoc,
|
|
94
|
+
data: () => userDoc,
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return { admin, captured };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const REVOKED_DOC = {
|
|
105
|
+
auth: { email: 'revoked.user@gmail.com' },
|
|
106
|
+
consent: { marketing: { status: 'revoked' } },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const GRANTED_DOC = {
|
|
110
|
+
auth: { email: 'granted.user@gmail.com' },
|
|
111
|
+
consent: { marketing: { status: 'granted' } },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const LEGACY_DOC = {
|
|
115
|
+
auth: { email: 'legacy.user@gmail.com' },
|
|
116
|
+
// No consent field at all — pre-consent-system user, must keep syncing
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
async function run() {
|
|
120
|
+
let passed = 0;
|
|
121
|
+
let failed = 0;
|
|
122
|
+
|
|
123
|
+
function check(ok, name, detail) {
|
|
124
|
+
if (ok) {
|
|
125
|
+
passed++;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
failed++;
|
|
130
|
+
console.log(` ✗ ${name}`);
|
|
131
|
+
if (detail) {
|
|
132
|
+
console.log(` ${detail}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── 1. isMarketingRevoked() semantics ───
|
|
137
|
+
for (const { name, doc, expect } of GATE_CASES) {
|
|
138
|
+
const actual = isMarketingRevoked(doc);
|
|
139
|
+
check(actual === expect, `isMarketingRevoked: ${name}`, `Expected ${expect}, got ${actual}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── 2. sync() blocks a revoked doc (gate fires before validation/providers) ───
|
|
143
|
+
{
|
|
144
|
+
const { assistant, calls } = buildAssistant(null);
|
|
145
|
+
const result = await new Marketing(assistant).sync(REVOKED_DOC);
|
|
146
|
+
|
|
147
|
+
check(result.blocked === 'consent', 'sync(): blocks revoked doc', `Expected blocked='consent', got ${JSON.stringify(result)}`);
|
|
148
|
+
check(result.email === 'revoked.user@gmail.com', 'sync(): blocked result includes email', `Got ${JSON.stringify(result)}`);
|
|
149
|
+
check(calls.warns.length === 1, 'sync(): revoked skip logs a warn', `Got ${calls.warns.length} warns`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── 3. sync() proceeds on missing consent (legacy user) ───
|
|
153
|
+
{
|
|
154
|
+
const { assistant } = buildAssistant(null);
|
|
155
|
+
const result = await new Marketing(assistant).sync(LEGACY_DOC);
|
|
156
|
+
|
|
157
|
+
check(result.blocked === undefined, 'sync(): proceeds on missing consent', `Expected no block, got ${JSON.stringify(result)}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── 4. sync() proceeds on granted consent ───
|
|
161
|
+
{
|
|
162
|
+
const { assistant } = buildAssistant(null);
|
|
163
|
+
const result = await new Marketing(assistant).sync(GRANTED_DOC);
|
|
164
|
+
|
|
165
|
+
check(result.blocked === undefined, 'sync(): proceeds on granted consent', `Expected no block, got ${JSON.stringify(result)}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── 5. sync() by uid blocks when the fetched doc is revoked ───
|
|
169
|
+
{
|
|
170
|
+
const { admin, captured } = buildAdmin({ userDoc: REVOKED_DOC });
|
|
171
|
+
const { assistant } = buildAssistant(admin);
|
|
172
|
+
const result = await new Marketing(assistant).sync('revoked-uid');
|
|
173
|
+
|
|
174
|
+
check(result.blocked === 'consent', 'sync(uid): blocks revoked fetched doc', `Expected blocked='consent', got ${JSON.stringify(result)}`);
|
|
175
|
+
check(captured.docPaths[0] === 'users/revoked-uid', 'sync(uid): fetches users/{uid}', `Got ${JSON.stringify(captured.docPaths)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── 6. add() blocks when the email maps to a revoked user ───
|
|
179
|
+
{
|
|
180
|
+
const { admin, captured } = buildAdmin({ userDoc: REVOKED_DOC });
|
|
181
|
+
const { assistant, calls } = buildAssistant(admin);
|
|
182
|
+
const result = await new Marketing(assistant).add({ email: 'revoked.user@gmail.com' });
|
|
183
|
+
|
|
184
|
+
check(result.blocked === 'consent', 'add(): blocks revoked user by email', `Expected blocked='consent', got ${JSON.stringify(result)}`);
|
|
185
|
+
check(result.email === 'revoked.user@gmail.com', 'add(): blocked result includes email', `Got ${JSON.stringify(result)}`);
|
|
186
|
+
check(calls.warns.length === 1, 'add(): revoked skip logs a warn', `Got ${calls.warns.length} warns`);
|
|
187
|
+
|
|
188
|
+
const query = captured.queries[0];
|
|
189
|
+
check(
|
|
190
|
+
query
|
|
191
|
+
&& query.collection === 'users'
|
|
192
|
+
&& query.field === 'auth.email'
|
|
193
|
+
&& query.op === '=='
|
|
194
|
+
&& query.value === 'revoked.user@gmail.com',
|
|
195
|
+
'add(): looks up users by auth.email (webhook-processor query)',
|
|
196
|
+
`Got ${JSON.stringify(query)}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── 7. add() normalizes the email for the lookup ───
|
|
201
|
+
{
|
|
202
|
+
const { admin, captured } = buildAdmin({ userDoc: null });
|
|
203
|
+
const { assistant } = buildAssistant(admin);
|
|
204
|
+
await new Marketing(assistant).add({ email: ' Revoked.User@GMAIL.com ' });
|
|
205
|
+
|
|
206
|
+
check(
|
|
207
|
+
captured.queries[0]?.value === 'revoked.user@gmail.com',
|
|
208
|
+
'add(): lookup trims + lowercases the email',
|
|
209
|
+
`Got ${JSON.stringify(captured.queries[0])}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── 8. add() proceeds when no user doc exists (pure newsletter contact) ───
|
|
214
|
+
{
|
|
215
|
+
const { admin } = buildAdmin({ userDoc: null });
|
|
216
|
+
const { assistant } = buildAssistant(admin);
|
|
217
|
+
const result = await new Marketing(assistant).add({ email: 'newsletter.reader@gmail.com' });
|
|
218
|
+
|
|
219
|
+
check(result.blocked === undefined, 'add(): proceeds when no user doc exists', `Expected no block, got ${JSON.stringify(result)}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── 9. add() proceeds when the matched user has no consent field (legacy) ───
|
|
223
|
+
{
|
|
224
|
+
const { admin } = buildAdmin({ userDoc: LEGACY_DOC });
|
|
225
|
+
const { assistant } = buildAssistant(admin);
|
|
226
|
+
const result = await new Marketing(assistant).add({ email: 'legacy.user@gmail.com' });
|
|
227
|
+
|
|
228
|
+
check(result.blocked === undefined, 'add(): proceeds on legacy user (no consent field)', `Expected no block, got ${JSON.stringify(result)}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── 10. add() fails open when the lookup errors ───
|
|
232
|
+
{
|
|
233
|
+
const { admin } = buildAdmin({ failLookup: true });
|
|
234
|
+
const { assistant, calls } = buildAssistant(admin);
|
|
235
|
+
const result = await new Marketing(assistant).add({ email: 'someone@gmail.com' });
|
|
236
|
+
|
|
237
|
+
check(result.blocked === undefined, 'add(): fails open on lookup error', `Expected no block, got ${JSON.stringify(result)}`);
|
|
238
|
+
check(calls.errors.length === 1, 'add(): lookup error is logged', `Got ${calls.errors.length} errors`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log('');
|
|
242
|
+
console.log(`${passed} passed, ${failed} failed`);
|
|
243
|
+
|
|
244
|
+
if (failed > 0) {
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
run();
|
|
@@ -20,10 +20,15 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Used by:
|
|
22
22
|
* - routes/marketing/contact (add)
|
|
23
|
-
* -
|
|
23
|
+
* - routes/user/signup (sync on signup — call site also gates on consent to skip the paid mailbox check)
|
|
24
24
|
* - Payment transition handlers (sync on subscription change)
|
|
25
25
|
* - Auth on-delete handler (remove contact)
|
|
26
26
|
* - Campaign cron jobs (send campaigns)
|
|
27
|
+
*
|
|
28
|
+
* Consent gate: add() and sync() skip users whose consent.marketing.status is the
|
|
29
|
+
* literal string 'revoked' and return { blocked: 'consent', email }. Missing consent,
|
|
30
|
+
* null, or any other value proceeds — legacy users have no consent field and must
|
|
31
|
+
* keep syncing. remove() is always safe and stays ungated.
|
|
27
32
|
*/
|
|
28
33
|
const _ = require('lodash');
|
|
29
34
|
|
|
@@ -52,9 +57,52 @@ function Marketing(assistant) {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
// ============================================================
|
|
55
|
-
// Contact management (add / sync / remove)
|
|
60
|
+
// Contact management (add / sync / remove)
|
|
56
61
|
// ============================================================
|
|
57
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Consent gate — true ONLY when consent.marketing.status is the literal string 'revoked'.
|
|
65
|
+
* Missing consent, null, or any other value proceeds — legacy users have no consent field
|
|
66
|
+
* and must keep syncing.
|
|
67
|
+
*
|
|
68
|
+
* @param {object|null|undefined} userDoc - User doc data (or null/undefined when no user exists)
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
function isMarketingRevoked(userDoc) {
|
|
72
|
+
return userDoc?.consent?.marketing?.status === 'revoked';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Look up a user doc by email (same query the marketing webhook processors use).
|
|
77
|
+
* Returns the doc data, or null when no user matches OR the lookup fails (fail open —
|
|
78
|
+
* a rare duplicate add is recoverable; silently dropping contacts is not).
|
|
79
|
+
*
|
|
80
|
+
* @param {object} admin - firebase-admin instance
|
|
81
|
+
* @param {object} assistant - Assistant (for logging)
|
|
82
|
+
* @param {string} email - Email address
|
|
83
|
+
* @returns {Promise<object|null>}
|
|
84
|
+
*/
|
|
85
|
+
async function findUserByEmail(admin, assistant, email) {
|
|
86
|
+
const snapshot = await admin.firestore().collection('users')
|
|
87
|
+
.where('auth.email', '==', email.trim().toLowerCase())
|
|
88
|
+
.limit(1)
|
|
89
|
+
.get()
|
|
90
|
+
.catch((e) => {
|
|
91
|
+
assistant.error('Marketing: User lookup by email failed (proceeding without consent gate):', e);
|
|
92
|
+
return null;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!snapshot || snapshot.empty) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return snapshot.docs[0].data();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Exposed as statics for plain-node unit tests (consent-gate.test.js)
|
|
103
|
+
Marketing.isMarketingRevoked = isMarketingRevoked;
|
|
104
|
+
Marketing.findUserByEmail = findUserByEmail;
|
|
105
|
+
|
|
58
106
|
Marketing.prototype.add = async function (options) {
|
|
59
107
|
const self = this;
|
|
60
108
|
const assistant = self.assistant;
|
|
@@ -65,6 +113,16 @@ Marketing.prototype.add = async function (options) {
|
|
|
65
113
|
return {};
|
|
66
114
|
}
|
|
67
115
|
|
|
116
|
+
// Consent gate — if this email maps to a user who revoked marketing consent, never
|
|
117
|
+
// re-add them. No user doc → proceed (pure newsletter contact). Lookup failure →
|
|
118
|
+
// proceed (fail open, logged in findUserByEmail).
|
|
119
|
+
const userDoc = await findUserByEmail(self.admin, assistant, email);
|
|
120
|
+
|
|
121
|
+
if (isMarketingRevoked(userDoc)) {
|
|
122
|
+
assistant.warn(`Marketing.add(): Consent revoked, skipping: ${email}`);
|
|
123
|
+
return { blocked: 'consent', email };
|
|
124
|
+
}
|
|
125
|
+
|
|
68
126
|
const validation = await validate(email);
|
|
69
127
|
if (!validation.valid) {
|
|
70
128
|
assistant.warn(`Marketing.add(): Validation failed, skipping: ${email}`, validation.checks);
|
|
@@ -140,6 +198,13 @@ Marketing.prototype.sync = async function (userDocOrUid) {
|
|
|
140
198
|
return {};
|
|
141
199
|
}
|
|
142
200
|
|
|
201
|
+
// Consent gate — never re-add a user who revoked marketing consent (payment-event
|
|
202
|
+
// syncs, admin re-syncs, and batch syncs all funnel through here).
|
|
203
|
+
if (isMarketingRevoked(userDoc)) {
|
|
204
|
+
assistant.warn(`Marketing.sync(): Consent revoked, skipping: ${email}`);
|
|
205
|
+
return { blocked: 'consent', email };
|
|
206
|
+
}
|
|
207
|
+
|
|
143
208
|
const validation = await validate(email);
|
|
144
209
|
if (!validation.valid) {
|
|
145
210
|
assistant.warn(`Marketing.sync(): Validation failed, skipping: ${email}`, validation.checks);
|
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
const fetch = require('wonderful-fetch');
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// NeverBounce numeric → textcode map. The v4 single/check API returns `result`
|
|
4
|
+
// as a textcode STRING ('valid', 'invalid', ...); numeric codes only appear in
|
|
5
|
+
// other response modes — tolerate both so a representation change can't silently
|
|
6
|
+
// fail every check again (that exact bug skipped marketing sync for all signups
|
|
7
|
+
// between BEM 5.5.1 and 5.6.1).
|
|
8
|
+
const NUMERIC_RESULT_MAP = {
|
|
4
9
|
0: 'valid',
|
|
5
10
|
1: 'invalid',
|
|
6
11
|
2: 'disposable',
|
|
7
|
-
3: '
|
|
12
|
+
3: 'catchall',
|
|
8
13
|
4: 'unknown',
|
|
9
14
|
};
|
|
10
15
|
|
|
16
|
+
// valid, catchall, unknown are allowed; invalid, disposable are blocked
|
|
17
|
+
const ALLOWED_RESULTS = new Set(['valid', 'catchall', 'unknown']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a NeverBounce single-check `result` into { valid, status }.
|
|
21
|
+
* Accepts textcode strings (canonical) or numeric codes; 'catch-all' → 'catchall'.
|
|
22
|
+
*
|
|
23
|
+
* @param {string|number} result - NeverBounce `result` field
|
|
24
|
+
* @returns {{ valid: boolean, status: string }}
|
|
25
|
+
*/
|
|
26
|
+
function parseResult(result) {
|
|
27
|
+
const status = typeof result === 'number'
|
|
28
|
+
? (NUMERIC_RESULT_MAP[result] || 'unknown')
|
|
29
|
+
: String(result).toLowerCase().replace('-', '');
|
|
30
|
+
|
|
31
|
+
return { valid: ALLOWED_RESULTS.has(status), status };
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
/**
|
|
12
35
|
* @param {string} email
|
|
13
36
|
* @returns {Promise<{ valid: boolean, status?: string, subStatus?: string, error?: string, provider: string }>}
|
|
@@ -29,14 +52,10 @@ async function verify(email) {
|
|
|
29
52
|
return { valid: true, error: 'Unexpected response format', provider: 'neverbounce' };
|
|
30
53
|
}
|
|
31
54
|
|
|
32
|
-
const status =
|
|
33
|
-
// 0=valid, 3=catch-all, 4=unknown are allowed; 1=invalid, 2=disposable are blocked
|
|
34
|
-
const nbValid = data.result === 0
|
|
35
|
-
|| data.result === 3
|
|
36
|
-
|| data.result === 4;
|
|
55
|
+
const { valid, status } = parseResult(data.result);
|
|
37
56
|
|
|
38
57
|
return {
|
|
39
|
-
valid
|
|
58
|
+
valid,
|
|
40
59
|
status,
|
|
41
60
|
subStatus: data.flags?.length ? data.flags.join(',') : null,
|
|
42
61
|
provider: 'neverbounce',
|
|
@@ -47,4 +66,4 @@ async function verify(email) {
|
|
|
47
66
|
}
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
module.exports = { verify };
|
|
69
|
+
module.exports = { verify, parseResult };
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Used by:
|
|
19
19
|
* - routes/marketing/contact/post.js
|
|
20
20
|
* - functions/core/actions/api/general/add-marketing-contact.js
|
|
21
|
-
* - routes/user/signup/post.js (
|
|
21
|
+
* - routes/user/signup/post.js (ALL_CHECKS before marketing sync; isDisposable() for affiliate fraud prevention)
|
|
22
22
|
* - libraries/email/marketing/index.js (safety net before Beehiiv/SendGrid add/sync)
|
|
23
23
|
*/
|
|
24
24
|
const path = require('path');
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email validation test — verifies all free checks (format, disposable, corporate, localPart, typo, dns)
|
|
3
|
+
* plus NeverBounce result parsing (no API calls).
|
|
3
4
|
*
|
|
4
5
|
* Run: node src/manager/libraries/email/validation.test.js
|
|
5
6
|
*/
|
|
6
7
|
const { validate } = require('./validation.js');
|
|
8
|
+
const { parseResult } = require('./validation-provider-neverbounce.js');
|
|
7
9
|
|
|
8
10
|
const FREE_CHECKS = ['format', 'disposable', 'corporate', 'localPart', 'typo', 'dns'];
|
|
9
11
|
|
|
@@ -85,10 +87,38 @@ const CASES = [
|
|
|
85
87
|
{ email: 'someone@example.com', expect: 'fail', check: 'dns' },
|
|
86
88
|
];
|
|
87
89
|
|
|
90
|
+
// NeverBounce single-check `result` parsing — the API returns STRING textcodes.
|
|
91
|
+
// Regression: BEM 5.5.1–5.6.1 compared against numbers, failing every mailbox
|
|
92
|
+
// check and silently skipping marketing sync for all signups.
|
|
93
|
+
const NB_PARSE_CASES = [
|
|
94
|
+
{ result: 'valid', expectValid: true, expectStatus: 'valid' },
|
|
95
|
+
{ result: 'catchall', expectValid: true, expectStatus: 'catchall' },
|
|
96
|
+
{ result: 'catch-all', expectValid: true, expectStatus: 'catchall' },
|
|
97
|
+
{ result: 'unknown', expectValid: true, expectStatus: 'unknown' },
|
|
98
|
+
{ result: 'invalid', expectValid: false, expectStatus: 'invalid' },
|
|
99
|
+
{ result: 'disposable', expectValid: false, expectStatus: 'disposable' },
|
|
100
|
+
{ result: 0, expectValid: true, expectStatus: 'valid' },
|
|
101
|
+
{ result: 1, expectValid: false, expectStatus: 'invalid' },
|
|
102
|
+
{ result: 2, expectValid: false, expectStatus: 'disposable' },
|
|
103
|
+
{ result: 3, expectValid: true, expectStatus: 'catchall' },
|
|
104
|
+
{ result: 4, expectValid: true, expectStatus: 'unknown' },
|
|
105
|
+
];
|
|
106
|
+
|
|
88
107
|
async function run() {
|
|
89
108
|
let passed = 0;
|
|
90
109
|
let failed = 0;
|
|
91
110
|
|
|
111
|
+
for (const { result, expectValid, expectStatus } of NB_PARSE_CASES) {
|
|
112
|
+
const parsed = parseResult(result);
|
|
113
|
+
if (parsed.valid === expectValid && parsed.status === expectStatus) {
|
|
114
|
+
passed++;
|
|
115
|
+
} else {
|
|
116
|
+
failed++;
|
|
117
|
+
console.log(` ✗ parseResult(${JSON.stringify(result)})`);
|
|
118
|
+
console.log(` Expected: valid=${expectValid} status=${expectStatus}, Got: valid=${parsed.valid} status=${parsed.status}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
92
122
|
for (const { email, expect: expected, check: expectedCheck } of CASES) {
|
|
93
123
|
const result = await validate(email, { checks: FREE_CHECKS });
|
|
94
124
|
const actualPass = result.valid;
|
|
@@ -115,7 +145,7 @@ async function run() {
|
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
console.log('');
|
|
118
|
-
console.log(`${passed} passed, ${failed} failed out of ${CASES.length} cases`);
|
|
148
|
+
console.log(`${passed} passed, ${failed} failed out of ${CASES.length + NB_PARSE_CASES.length} cases`);
|
|
119
149
|
|
|
120
150
|
if (failed > 0) {
|
|
121
151
|
process.exit(1);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DELETE /marketing/contact - Remove marketing contact
|
|
3
3
|
* Admin-only endpoint to unsubscribe from newsletter
|
|
4
|
+
*
|
|
5
|
+
* Also mirrors consent.marketing.status = 'revoked' (source: 'admin') to the user doc
|
|
6
|
+
* when the email maps to a user — otherwise the next sync (payment event, admin re-sync)
|
|
7
|
+
* would re-add the contact the admin just removed.
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
@@ -28,6 +32,11 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
28
32
|
const mailer = Manager.Email(assistant);
|
|
29
33
|
const providerResults = await mailer.remove(email);
|
|
30
34
|
|
|
35
|
+
// Mirror the removal to the user doc's consent so future syncs hit the email
|
|
36
|
+
// library's consent gate instead of silently re-adding the contact
|
|
37
|
+
// (best-effort, silent when the email maps to no user)
|
|
38
|
+
await mirrorRevokedConsent({ assistant, Manager, email });
|
|
39
|
+
|
|
31
40
|
// Log result
|
|
32
41
|
assistant.log('marketing/contact delete result:', {
|
|
33
42
|
email,
|
|
@@ -42,3 +51,52 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
42
51
|
providers: providerResults,
|
|
43
52
|
});
|
|
44
53
|
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write consent.marketing.status = 'revoked' (source: 'admin') to the user doc that
|
|
57
|
+
* matches the removed email. Same lookup + write shape as the marketing webhook
|
|
58
|
+
* processors' revoke write. Silent when no user matches.
|
|
59
|
+
*/
|
|
60
|
+
async function mirrorRevokedConsent({ assistant, Manager, email }) {
|
|
61
|
+
const { admin } = Manager.libraries;
|
|
62
|
+
|
|
63
|
+
const snapshot = await admin.firestore().collection('users')
|
|
64
|
+
.where('auth.email', '==', email)
|
|
65
|
+
.limit(1)
|
|
66
|
+
.get()
|
|
67
|
+
.catch((e) => {
|
|
68
|
+
assistant.error('marketing/contact delete: Failed to look up user by email:', e);
|
|
69
|
+
return null;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!snapshot || snapshot.empty) {
|
|
73
|
+
return; // Silent — email may not map to a current user
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const uid = snapshot.docs[0].id;
|
|
77
|
+
const timestamp = assistant.meta.startTime.timestamp;
|
|
78
|
+
const timestampUNIX = assistant.meta.startTime.timestampUNIX;
|
|
79
|
+
|
|
80
|
+
// Write consent.marketing.status = 'revoked' (preserve grantedAt — informational audit trail)
|
|
81
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
82
|
+
consent: {
|
|
83
|
+
marketing: {
|
|
84
|
+
status: 'revoked',
|
|
85
|
+
revokedAt: {
|
|
86
|
+
timestamp,
|
|
87
|
+
timestampUNIX,
|
|
88
|
+
source: 'admin',
|
|
89
|
+
ip: null,
|
|
90
|
+
text: null,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
metadata: Manager.Metadata().set({ tag: 'marketing/contact:delete' }),
|
|
95
|
+
}, { merge: true })
|
|
96
|
+
.then(() => {
|
|
97
|
+
assistant.log(`marketing/contact delete: Mirrored revoked consent to user ${uid} (${email})`);
|
|
98
|
+
})
|
|
99
|
+
.catch((e) => {
|
|
100
|
+
assistant.error(`marketing/contact delete: Failed to mirror revoked consent to ${uid}:`, e);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* - HMAC validates the link was generated by us.
|
|
15
15
|
* - Hits SendGrid ASM directly (legacy behavior — preserves existing one-click unsub).
|
|
16
16
|
* - Also writes canonical consent.marketing to user doc when email maps to a user.
|
|
17
|
+
* - Then cross-syncs via the email library (best-effort): unsubscribe removes the
|
|
18
|
+
* contact from ALL providers (Beehiiv too — compliance); subscribe re-adds the
|
|
19
|
+
* contact (sync when the email maps to a user, add when it doesn't).
|
|
17
20
|
*/
|
|
18
21
|
const fetch = require('wonderful-fetch');
|
|
19
22
|
const crypto = require('crypto');
|
|
@@ -148,8 +151,10 @@ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
|
|
|
148
151
|
usage.increment('email-preferences');
|
|
149
152
|
await usage.update();
|
|
150
153
|
|
|
151
|
-
// Mirror to the user doc if this email maps to a user (best-effort, silent on miss)
|
|
152
|
-
|
|
154
|
+
// Mirror to the user doc if this email maps to a user (best-effort, silent on miss).
|
|
155
|
+
// Runs BEFORE the provider calls below — on subscribe it writes consent granted first,
|
|
156
|
+
// so the email library's consent gate passes when we re-sync.
|
|
157
|
+
const uid = await mirrorAnonymousToUserDoc({ assistant, Manager, email, action });
|
|
153
158
|
|
|
154
159
|
// Call SendGrid ASM (legacy behavior)
|
|
155
160
|
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
@@ -184,6 +189,26 @@ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
|
|
|
184
189
|
return assistant.respond('Failed to process your request', { code: 500 });
|
|
185
190
|
}
|
|
186
191
|
|
|
192
|
+
// Cross-provider sync via the email library (best-effort — ASM suppression above
|
|
193
|
+
// already succeeded). Unsubscribe must remove the contact from Beehiiv too
|
|
194
|
+
// (compliance); subscribe must actually re-add the contact: sync the matched user
|
|
195
|
+
// (the mirror above already wrote consent granted, so the library consent gate
|
|
196
|
+
// passes) or add a bare newsletter contact when no user matched.
|
|
197
|
+
const mailer = Manager.Email(assistant);
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
if (action === 'unsubscribe') {
|
|
201
|
+
await mailer.remove(email);
|
|
202
|
+
} else if (uid) {
|
|
203
|
+
await mailer.sync(uid);
|
|
204
|
+
} else {
|
|
205
|
+
await mailer.add({ email, source: 'resubscribe' });
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
assistant.error(`email-preferences (anon) provider sync failed:`, e);
|
|
209
|
+
// Doc + ASM are already updated — provider sync is best-effort. Don't fail the request.
|
|
210
|
+
}
|
|
211
|
+
|
|
187
212
|
assistant.log('email-preferences (anon) result:', { email, asmId, action });
|
|
188
213
|
analytics.event('marketing/email-preferences', { action, mode: 'anonymous' });
|
|
189
214
|
|
|
@@ -194,6 +219,8 @@ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
|
|
|
194
219
|
* Anonymous HMAC unsub also writes to the user doc (if found) so consent stays in sync.
|
|
195
220
|
* Silent if the email doesn't map to a user. Source is recorded as 'sendgrid' since the
|
|
196
221
|
* HMAC link only fires from SendGrid email footers.
|
|
222
|
+
*
|
|
223
|
+
* @returns {Promise<string|null>} The matched user's uid, or null when no user matches.
|
|
197
224
|
*/
|
|
198
225
|
async function mirrorAnonymousToUserDoc({ assistant, Manager, email, action }) {
|
|
199
226
|
const { admin } = Manager.libraries;
|
|
@@ -208,7 +235,7 @@ async function mirrorAnonymousToUserDoc({ assistant, Manager, email, action }) {
|
|
|
208
235
|
});
|
|
209
236
|
|
|
210
237
|
if (!snapshot || snapshot.empty) {
|
|
211
|
-
return; // Silent — email may not map to a current user
|
|
238
|
+
return null; // Silent — email may not map to a current user
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
const userDoc = snapshot.docs[0];
|
|
@@ -233,4 +260,6 @@ async function mirrorAnonymousToUserDoc({ assistant, Manager, email, action }) {
|
|
|
233
260
|
.catch((e) => {
|
|
234
261
|
assistant.error(`email-preferences (anon): Failed to mirror to user doc ${uid}:`, e);
|
|
235
262
|
});
|
|
263
|
+
|
|
264
|
+
return uid;
|
|
236
265
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"sessionId":"c926cd91-919f-483f-bb49-db72f77f9e2e","pid":30061,"procStart":"Thu Jun 11 11:57:09 2026","acquiredAt":1781179809978}
|