mailery 0.13.0 → 0.15.0

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/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as MailTesterFeedback, d as Mailer, T as TemplateDoc, e as SuppressionScope, D as DeliveryWindow, F as FlowStep, f as SegmentFilter, P as Predicate } from './null-DDg3ojHe.cjs';
2
- export { g as AuditLogDoc, B as BroadcastDoc, h as BroadcastStatus, i as CircuitBreakerThresholds, j as Collections, k as ContactTagDoc, E as EventDoc, l as FlowDoc, m as FlowGoal, n as FlowRunDoc, o as FlowRunStatus, p as FlowVersionDoc, H as HealthDoc, q as HealthStatus, L as LeadDoc, r as MailerConfig, s as NullProvider, O as OutboxDoc, t as RESERVED_VAR_KEYS, u as RedisOptions, v as SegmentDefinition, w as SendDoc, x as SendStatus, y as SenderDomainConfig, z as SenderDomainRegistry, G as SenderDomainValidation, I as SubscriptionDoc, J as SubscriptionStatus, K as SuppressionDoc, Q as SuppressionReason, U as TemplateKind, V as TemplateVersionDoc, W as VarsAdapter, X as VarsResolveInfo, Y as WebhookEventDoc, Z as defineVars, _ as ensureIndexes, $ as getCollections, a0 as validateSenderDomain, a1 as varsJsonSchema } from './null-DDg3ojHe.cjs';
1
+ import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as MailTesterFeedback, d as Mailer, T as TemplateDoc, e as SuppressionScope, D as DeliveryWindow, F as FlowStep, f as SegmentFilter, P as Predicate } from './null-CnhsvKvy.cjs';
2
+ export { g as AuditLogDoc, B as BotFilterConfig, h as BroadcastDoc, i as BroadcastStatus, j as CircuitBreakerThresholds, k as Collections, l as ContactTagDoc, E as EventDoc, m as FlowDoc, n as FlowGoal, o as FlowRunDoc, p as FlowRunStatus, q as FlowVersionDoc, H as HealthDoc, r as HealthStatus, L as LeadDoc, s as MailerConfig, t as NullProvider, O as OutboxDoc, u as RESERVED_VAR_KEYS, v as RedisOptions, w as SegmentDefinition, x as SendDoc, y as SendStatus, z as SenderDomainConfig, G as SenderDomainRegistry, I as SenderDomainValidation, J as SubscriptionDoc, K as SubscriptionStatus, Q as SuppressionDoc, U as SuppressionReason, V as TemplateKind, W as TemplateVersionDoc, X as VarsAdapter, Y as VarsResolveInfo, Z as WebhookEventDoc, _ as defineVars, $ as ensureIndexes, a0 as getCollections, a1 as validateSenderDomain, a2 as varsJsonSchema } from './null-CnhsvKvy.cjs';
3
3
  import { ObjectId, Db, Filter } from 'mongodb';
4
4
  import { Request, Router } from 'express';
5
5
  import Handlebars from 'handlebars';
@@ -54,6 +54,67 @@ declare function sweepStrandedFlowRuns(ctx: RunnerContext): Promise<void>;
54
54
 
55
55
  declare function applyWebhookEvent(event: NormalizedEvent, ctx: RunnerContext): Promise<void>;
56
56
 
57
+ /**
58
+ * The pending-unsubscribe drain — the second half of INVARIANT 8.
59
+ *
60
+ * `POST /m/unsub/:token` journals an opt-out to disk when Mongo is unreachable
61
+ * (`src/server/unsub-journal.ts`). This replays those entries. It runs from
62
+ * `runTick`, so it needs no scheduling of its own and inherits the tick's
63
+ * cadence (`tickIntervalSeconds`, default 60) and its per-runner error
64
+ * isolation. It is also exported so a host can run it from a script or a
65
+ * one-shot process.
66
+ *
67
+ * ## Where the drain runs, and why only here
68
+ *
69
+ * The tick, not a CLI command. `src/cli/` is a DNS/provider setup tool: it has
70
+ * no Mongo connection, no contact adapter and no config loader, so a
71
+ * `mailery drain-unsubs` command would have to invent a whole configuration
72
+ * story to reach the database. Every deployment that can journal an opt-out is
73
+ * by definition already running the library, and most run the tick;
74
+ * `drainPendingUnsubscribes` is exported for the ones that don't.
75
+ *
76
+ * ## Guarantees
77
+ *
78
+ * - **Idempotent.** Replay goes through `applyUnsubscribe`, whose suppression
79
+ * write is a `$setOnInsert` upsert and whose subscription write is a `$set`
80
+ * to a terminal state. Draining the same entry twice is indistinguishable
81
+ * from draining it once.
82
+ * - **Nothing is lost to a crash.** A pass claims a batch by renaming the
83
+ * journal aside; if it dies, the claim file stays on disk and a later pass
84
+ * adopts it. Entries it could not apply are appended back to the live
85
+ * journal *before* the claim is unlinked, so the failure window duplicates
86
+ * rather than drops.
87
+ * - **Concurrency-safe.** Claiming and adopting are `rename`, which is atomic
88
+ * within a directory. Two processes draining the same journal cannot both
89
+ * own the same batch.
90
+ * - **Fails soft.** A Mongo write failure aborts the pass immediately — the
91
+ * database being down is exactly why these entries exist, and hammering it
92
+ * with the rest of the batch achieves nothing.
93
+ */
94
+
95
+ interface DrainPendingUnsubsResult {
96
+ /** False when no `pendingUnsubsPath` is configured — the journal is opt-in. */
97
+ enabled: boolean;
98
+ applied: number;
99
+ /** Entries handed back to the journal for a later pass. */
100
+ deferred: number;
101
+ /** Lines that could never be applied (torn by a crash, or hand-edited). */
102
+ malformed: number;
103
+ /** Claim files processed, including ones adopted from a dead pass. */
104
+ batches: number;
105
+ }
106
+ interface DrainPendingUnsubsOptions {
107
+ /** Overrides `config.pendingUnsubsPath`. Mostly for tests. */
108
+ path?: string;
109
+ maxEntries?: number;
110
+ /** Structured sink for diagnostics. Defaults to `console`. */
111
+ log?: {
112
+ warn?: (fields: Record<string, unknown>, msg?: string) => void;
113
+ error?: (fields: Record<string, unknown>, msg?: string) => void;
114
+ };
115
+ }
116
+ declare function drainPendingUnsubscribes(ctx: RunnerContext, opts?: DrainPendingUnsubsOptions): Promise<DrainPendingUnsubsResult>;
117
+
57
118
  /**
58
119
  * MongoContactAdapter — reads contacts directly from the host's `users`
59
120
  * collection (read-mostly; optional narrow tag writes). Mailer never
@@ -109,6 +170,12 @@ declare class MongoContactAdapter implements ContactAdapter {
109
170
  private removeTagsImpl;
110
171
  }
111
172
 
173
+ /**
174
+ * Per-provider tolerance option: seconds, or `0` / `false` to disable the
175
+ * freshness check entirely.
176
+ */
177
+ type WebhookToleranceOption = number | false;
178
+
112
179
  /**
113
180
  * SendGridProvider — sends mail through @sendgrid/mail, verifies the Event
114
181
  * Webhook signature with the ECDSA public key configured in SendGrid, and
@@ -125,6 +192,16 @@ interface SendGridProviderOptions {
125
192
  apiKey: string;
126
193
  /** ECDSA public key (PEM) from SendGrid → Settings → Mail Settings → Signed Event Webhook. */
127
194
  webhookVerificationKey?: string;
195
+ /**
196
+ * Replay window for the signed webhook timestamp, in seconds.
197
+ * Defaults to 300 (5 minutes). A signed payload older — or more than this
198
+ * far in the future — than the window is rejected, so a captured request
199
+ * can't be replayed indefinitely.
200
+ *
201
+ * Set `0` or `false` to disable the check, for hosts whose proxy or queue
202
+ * legitimately delays webhook delivery past the window.
203
+ */
204
+ webhookToleranceSeconds?: WebhookToleranceOption;
128
205
  /** Send-rate cap per second (BullMQ group limiter consults this). */
129
206
  sendRatePerSecond?: number;
130
207
  /** Sandbox mode bypasses actual delivery — useful in dev/test. */
@@ -134,6 +211,8 @@ declare class SendGridProvider implements MailProvider {
134
211
  private readonly opts;
135
212
  readonly name = "sendgrid";
136
213
  readonly sendRatePerSecond: number;
214
+ /** Resolved replay window in seconds; `0` means the check is disabled. */
215
+ readonly webhookToleranceSeconds: number;
137
216
  constructor(opts: SendGridProviderOptions);
138
217
  send(args: SendArgs): Promise<SendResult>;
139
218
  verifyWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<boolean>;
@@ -193,6 +272,167 @@ interface AdminRouterOptions {
193
272
  }
194
273
  declare function createAdminRouter(mailer: Mailer, opts?: AdminRouterOptions): Router;
195
274
 
275
+ /**
276
+ * Async handler wrapper for the *public* router.
277
+ *
278
+ * The admin router's `asyncHandler` (`api/admin.ts`) is a plain `.catch(next)`,
279
+ * which is right there: every admin handler computes first and responds last, so
280
+ * a rejection always arrives before any header is written and Express can turn
281
+ * it into a clean JSON 500.
282
+ *
283
+ * The public router cannot use that. `/open`, `/click` and `/webhooks` respond
284
+ * *first* and do their work after — never make a mail client or a provider
285
+ * wait on our database. By the time those handlers can reject,
286
+ * `res.headersSent` is already true, and forwarding post-headers makes Express
287
+ * destroy the socket underneath a response the client has already been
288
+ * promised.
289
+ *
290
+ * (`POST /unsub` used to be in that list. It now awaits its write before
291
+ * answering, because a response sent first can only ever report success —
292
+ * see INVARIANT 8 and the route's own comment. It still needs this wrapper for
293
+ * the pre-headers branch.)
294
+ *
295
+ * So this is a log-and-swallow wrapper, ported from featureboard's
296
+ * `src/server/routes/wrap.js`, with one deliberate difference: featureboard
297
+ * forwards with `next(err)` on the `headersSent` branch, and here that branch
298
+ * only logs. See issue #5 — every public route is unauthenticated, and the
299
+ * failure mode being fixed is precisely "a rejected promise in a public route
300
+ * takes down the host process".
301
+ */
302
+
303
+ /**
304
+ * Minimal structured-logger shape, compatible with pino/bunyan-style loggers
305
+ * (`logger.error(fields, message)`). Every method is optional, so `{}` is a
306
+ * valid silent logger and a bare `console` is not accidentally accepted with
307
+ * mismatched argument order.
308
+ */
309
+ interface RouteLogger {
310
+ error?: (fields: Record<string, unknown>, msg?: string) => void;
311
+ warn?: (fields: Record<string, unknown>, msg?: string) => void;
312
+ /**
313
+ * Routine, high-volume telemetry — currently only "an unsigned (pre-signing)
314
+ * tracking URL was accepted in grace mode", which is one line per legacy open
315
+ * and is not a fault. Kept off `warn` deliberately so it can be routed or
316
+ * dropped separately from things that are.
317
+ */
318
+ info?: (fields: Record<string, unknown>, msg?: string) => void;
319
+ }
320
+
321
+ /**
322
+ * Inbound DMARC aggregate-report webhook.
323
+ *
324
+ * DMARC RUA reports arrive as email. The usual way to turn that email into an
325
+ * HTTP request is SendGrid Inbound Parse, and until v0.15 the documented setup
326
+ * pointed Inbound Parse at `/admin/mailer/api/dmarc/upload` — a route behind
327
+ * the host's `requireAdmin` guard, which Inbound Parse cannot satisfy. So the
328
+ * documented setup either did not work, or worked because somebody removed the
329
+ * guard from an admin router. This route replaces that advice.
330
+ *
331
+ * ## Read this before enabling it
332
+ *
333
+ * **Inbound Parse does not sign its payloads.** The SendGrid *Event Webhook*
334
+ * does — see `providers/sendgrid.ts`, where signature verification and the
335
+ * replay window live — but Inbound Parse is a different product and offers no
336
+ * signature, no HMAC and no verifiable identity. There is nothing to verify.
337
+ *
338
+ * So this is, structurally, an unauthenticated file-accepting endpoint on a
339
+ * public router, and the design has to own that rather than dress it up:
340
+ *
341
+ * 1. **Off unless configured.** No `dmarcInbound.secret`, no route — not a
342
+ * 404 handler, no route registered at all. An endpoint like this appearing
343
+ * on an upgrade because someone shipped a default would be indefensible.
344
+ * 2. **A shared secret, checked first.** The secret is compared with
345
+ * `timingSafeEqual` before multer is allowed to read a single byte of the
346
+ * body, so an unauthenticated caller cannot make us buffer 10MB.
347
+ * 3. **Hard size and count limits**, matching the admin upload's shape.
348
+ * 4. **A domain cross-check.** A report whose `<policy_published><domain>` is
349
+ * not a domain this deployment sends from is rejected. If the secret leaks,
350
+ * the attacker gets to inject rows about *your* domains, which is bounded
351
+ * and visible, instead of arbitrary garbage.
352
+ * 5. **The existing parser.** `runner/dmarc.ts` already caps decompressed
353
+ * bytes, validates declared-vs-actual sizes, checks compression ratio and
354
+ * guards zip-slip. This route does not parse anything itself.
355
+ *
356
+ * ## Why a shared secret, and where it goes
357
+ *
358
+ * The three options that don't need a signature:
359
+ *
360
+ * - **Source IP allowlist.** Rejected as the primary control. SendGrid does
361
+ * not publish a stable Inbound Parse egress range, and behind a proxy
362
+ * `req.ip` is the proxy's address unless the host has set `trust proxy`
363
+ * correctly — which this library cannot verify and must not assume. An
364
+ * allowlist that silently matches the wrong address either locks out the
365
+ * real sender or admits everyone. Put an allowlist in your ingress if you
366
+ * want one; it is a good second layer and a bad only layer.
367
+ * - **Secret in the path.** Works, and is the fallback, but a URL path is
368
+ * logged by every proxy, load balancer and access log between SendGrid and
369
+ * you. Supported implicitly: nothing stops you making `path` unguessable.
370
+ * - **Secret in the `Authorization` header.** The default and the
371
+ * recommendation. Inbound Parse cannot set custom headers, but its
372
+ * destination URL accepts embedded basic-auth credentials
373
+ * (`https://mailery:SECRET@example.com/m/inbound/dmarc`), which travel as an
374
+ * `Authorization` header rather than in the request line. `Bearer` is
375
+ * accepted too, for hosts forwarding from something other than SendGrid.
376
+ *
377
+ * A leaked secret gets an attacker exactly one capability: inserting DMARC
378
+ * report rows for domains you already send from. No mail is sent, no contact
379
+ * data is touched, nothing is deleted. Rotate it by changing the config value
380
+ * and the Inbound Parse destination URL.
381
+ */
382
+
383
+ /** One attachment lifted off an inbound request by a `parseInbound` seam. */
384
+ interface InboundAttachment {
385
+ filename: string;
386
+ buffer: Buffer;
387
+ }
388
+ /**
389
+ * Pull DMARC attachments out of a provider's inbound-email payload.
390
+ *
391
+ * The seam exists so Mailgun's and Postmark's inbound routes can be added
392
+ * without touching this route's auth, limits or ingest path. Only the SendGrid
393
+ * shape is implemented today.
394
+ */
395
+ type InboundParser = (req: Request) => InboundAttachment[];
396
+ interface DmarcInboundOptions {
397
+ /**
398
+ * Shared secret the caller must present. **Absent or empty → the route is
399
+ * not mounted at all.**
400
+ *
401
+ * Accepted as HTTP Basic (any username; the password is compared) or as
402
+ * `Authorization: Bearer <secret>`. Basic is the one to use with SendGrid
403
+ * Inbound Parse, since it can be embedded in the destination URL and so
404
+ * stays out of access logs.
405
+ */
406
+ secret?: string;
407
+ /** Sub-path on the public router. Default `/inbound/dmarc`. */
408
+ path?: string;
409
+ /** Per-file cap. Default 10MB, matching the admin upload. */
410
+ maxFileSizeBytes?: number;
411
+ /** Attachments accepted per request. Default 10. */
412
+ maxFiles?: number;
413
+ /**
414
+ * Domains whose reports are accepted. Defaults to every domain in
415
+ * `senderDomains` plus the `fromDefaults` / `transactionalFromDefaults`
416
+ * addresses. Reports for anything else are rejected.
417
+ *
418
+ * If none of those are configured there is nothing to check against, and the
419
+ * route mounts with the domain gate disabled and a warning — configure
420
+ * `senderDomains` to get it back.
421
+ */
422
+ allowedDomains?: string[];
423
+ /** Override the inbound payload shape. Defaults to SendGrid Inbound Parse. */
424
+ parseInbound?: InboundParser;
425
+ }
426
+ /**
427
+ * SendGrid Inbound Parse: `multipart/form-data` with the message's fields
428
+ * (`headers`, `from`, `subject`, `text`, ...) plus one file part per
429
+ * attachment, named `attachment1`, `attachment2`, and so on.
430
+ *
431
+ * Read through `multer.any()`, so the field names are not trusted — every file
432
+ * part is a candidate and the extension gate below decides.
433
+ */
434
+ declare const sendgridInboundParser: InboundParser;
435
+
196
436
  /**
197
437
  * Public router — endpoints that must be reachable by email clients and
198
438
  * provider webhooks. Mount under your tracking base path (default `/m`).
@@ -200,19 +440,49 @@ declare function createAdminRouter(mailer: Mailer, opts?: AdminRouterOptions): R
200
440
  * app.use('/m', createPublicRouter(mailer))
201
441
  *
202
442
  * Routes:
203
- * GET /open/:sendId.png — open pixel (records open, returns 1×1 PNG)
204
- * GET /click/:sendId/:linkId — click redirect (records click, 302 → target)
205
- * GET /unsub/:token — confirmation page (one-click POST link)
206
- * POST /unsub/:token — RFC 8058 one-click unsubscribe
207
- * POST /webhooks/:provider — inbound provider event webhook
443
+ * GET /open/:sendId.:sig.png — open pixel (records open, returns 1×1 PNG)
444
+ * GET /click/:sendId/:linkId/:sig — click redirect (records click, 302 → target)
445
+ * GET /unsub/:token — confirmation page (one-click POST link)
446
+ * POST /unsub/:token — RFC 8058 one-click unsubscribe
447
+ * POST /webhooks/:provider — inbound provider event webhook
448
+ * POST /inbound/dmarc — inbound DMARC report (opt-in; see below)
449
+ *
450
+ * Every route here is unauthenticated by necessity — mail clients and provider
451
+ * webhook servers cannot present credentials. The one exception is
452
+ * `/inbound/dmarc`, which is **not mounted at all** unless a shared secret is
453
+ * configured; see `api/dmarc-inbound.ts`.
454
+ *
455
+ * `:sig` is a 12-character truncated HMAC issued by `applyTracking`. It is
456
+ * syntactically optional on both tracking routes so that mail delivered before
457
+ * signing existed keeps working — see `checkTrackingSignature`.
208
458
  */
209
459
 
210
460
  interface PublicRouterOptions {
211
461
  /**
212
- * Path on disk where unsubscribe events fall back to when Mongo is degraded.
213
- * Defaults to /tmp/mailery-pending-unsubs.jsonl.
462
+ * Overrides `MailerConfig.pendingUnsubsPath` for this router only.
463
+ *
464
+ * @deprecated Set `pendingUnsubsPath` in `MailerConfig` instead. The tick
465
+ * drain (`drainPendingUnsubscribes`) reads the *config* value, so a path set
466
+ * only here is written but never replayed — which is precisely the bug
467
+ * INVARIANT 8 was carrying. Setting it here without also setting it in
468
+ * config logs a warning at construction time.
214
469
  */
215
470
  pendingUnsubsPath?: string;
471
+ /**
472
+ * Structured logger for public-route failures, pino-style
473
+ * (`logger.error(fields, message)`).
474
+ *
475
+ * Defaults to a `console`-backed logger that reproduces the output this
476
+ * package emitted before the option existed. Pass `{}` to silence.
477
+ */
478
+ logger?: RouteLogger;
479
+ /**
480
+ * Inbound DMARC aggregate-report webhook (SendGrid Inbound Parse and
481
+ * friends). **Off unless `secret` is set** — see `api/dmarc-inbound.ts` for
482
+ * why an endpoint that accepts unsigned file uploads must never appear on an
483
+ * upgrade by itself.
484
+ */
485
+ dmarcInbound?: DmarcInboundOptions;
216
486
  }
217
487
  declare function createPublicRouter(mailer: Mailer, opts?: PublicRouterOptions): Router;
218
488
 
@@ -295,6 +565,16 @@ interface TrackingOptions {
295
565
  trackClicks: boolean;
296
566
  /** URL that must NOT be rewritten (e.g. the resolved unsubscribe URL). */
297
567
  preserveUrls?: string[];
568
+ /**
569
+ * HMAC key for tracking-URL signatures — pass `config.unsubscribeSecret`.
570
+ *
571
+ * When set, every generated `/m/open` and `/m/click` URL carries a truncated
572
+ * HMAC so it cannot be forged or enumerated from a neighbouring ObjectId.
573
+ * When omitted the legacy unsigned shape is emitted; that path exists for
574
+ * preview/test renders that never reach the tracking endpoints, not as a
575
+ * supported production mode.
576
+ */
577
+ signingSecret?: string;
298
578
  }
299
579
  interface TrackingResult {
300
580
  html: string;
@@ -305,9 +585,15 @@ interface TrackingResult {
305
585
  }>;
306
586
  }
307
587
  /**
308
- * Rewrite `<a href>` in `html` to /m/click/<sendId>/<linkId>?... and append an
309
- * open pixel. Returns the modified HTML plus the link map to persist on the
310
- * send document.
588
+ * Rewrite `<a href>` in `html` to /m/click/<sendId>/<linkId>/<sig> and append
589
+ * an open pixel at /m/open/<sendId>.<sig>.png. Returns the modified HTML plus
590
+ * the link map to persist on the send document.
591
+ *
592
+ * The `<sig>` components are 12-character truncated HMACs (see
593
+ * `signTrackingToken`) and are present whenever `opts.signingSecret` is set.
594
+ * Without them a Mongo ObjectId is the only thing standing between an attacker
595
+ * and a forged open — and ObjectIds are a timestamp plus a per-process counter,
596
+ * so one received email hands out its neighbours.
311
597
  */
312
598
  declare function applyTracking(html: string, opts: TrackingOptions): TrackingResult;
313
599
 
@@ -328,11 +614,71 @@ interface UnsubscribeTokenPayload {
328
614
  }
329
615
  declare function signUnsubscribeToken(payload: UnsubscribeTokenPayload, secret: string): string;
330
616
  declare function verifyUnsubscribeToken(token: string, secret: string, now?: Date): UnsubscribeTokenPayload | null;
617
+ /**
618
+ * Scopes a tracking signature can be issued for. An allowlist, mirroring the
619
+ * `k: 'doi'` discriminator on the DOI token: the scope is part of the signed
620
+ * message, so an `/m/open` signature cannot be replayed as an `/m/click` one
621
+ * even for the same send.
622
+ */
623
+ type TrackingScope = 'open' | 'click';
624
+ /**
625
+ * Signature length in base64url characters. 12 chars = 72 bits of the
626
+ * HMAC-SHA256 digest.
627
+ *
628
+ * Truncation is deliberate. These signatures are embedded in every link of
629
+ * every email, and the pixel URL in particular is parsed by mail clients with
630
+ * their own length quirks, so bytes are not free. 72 bits is far beyond what an
631
+ * online forgery attack can reach: an attacker gets no oracle beyond "the open
632
+ * was counted", each guess is a live HTTP request, and there is no offline
633
+ * verification step. Nothing here is a bearer credential for anything except
634
+ * "this recipient was sent this mail".
635
+ */
636
+ declare const TRACKING_SIG_LENGTH = 12;
637
+ interface TrackingTokenParams {
638
+ sendId: string;
639
+ /** Present for `click` scope, absent for `open`. */
640
+ linkId?: string;
641
+ }
642
+ /**
643
+ * Sign a tracking URL. Returns `TRACKING_SIG_LENGTH` base64url characters.
644
+ *
645
+ * Keyed with `unsubscribeSecret` — the same secret the unsubscribe and DOI
646
+ * tokens use, so there is exactly one secret to provision
647
+ * (`MAILER_UNSUBSCRIBE_SECRET`) and one to rotate. The scope prefix keeps the
648
+ * key domains separate.
649
+ */
650
+ declare function signTrackingToken(scope: TrackingScope, params: TrackingTokenParams, secret: string): string;
651
+ /**
652
+ * Verify a tracking-URL signature in constant time.
653
+ *
654
+ * The length pre-check before `timingSafeEqual` is required, not defensive:
655
+ * `crypto.timingSafeEqual` throws on mismatched lengths, and a thrown
656
+ * verification is a rejection that leaks length through a different channel.
657
+ * Same shape as `verifyUnsubscribeToken`.
658
+ */
659
+ declare function verifyTrackingToken(token: unknown, scope: TrackingScope, params: TrackingTokenParams, secret: string): boolean;
331
660
  /**
332
661
  * sha256 hex digest, used for storing email hashes (GDPR forget) and dedup helpers.
333
662
  */
334
663
  declare function sha256Hex(input: string): string;
335
664
 
665
+ /**
666
+ * Predicate evaluator. Resolves all Predicate variants from `shared/types.ts`
667
+ * against a Contact (host-side fields/tags) + mailer state (events, sends,
668
+ * subscription).
669
+ *
670
+ * Network calls hit Mongo. Cache the Contact at the call site if you evaluate
671
+ * many predicates in a row.
672
+ */
673
+
674
+ /**
675
+ * User agents treated as automated unless the host overrides
676
+ * `botFilter.userAgentPattern`. Deliberately short: these are the scanners that
677
+ * announce themselves. Anything that impersonates a browser is not catchable
678
+ * from the UA string and is out of scope here — see INVARIANT 7.
679
+ */
680
+ declare const DEFAULT_BOT_UA_RE: RegExp;
681
+
336
682
  /**
337
683
  * Delivery-window math. Pure — no I/O, no Date.now(); callers pass `now`.
338
684
  *
@@ -404,4 +750,4 @@ declare const DEDUPE_POLICIES: readonly DedupePolicyOption[];
404
750
  */
405
751
  declare const VERSION: string;
406
752
 
407
- export { AdapterFilter, type AdminRouterOptions, Contact, ContactAdapter, DEDUPE_POLICIES, type DedupePolicyOption, DeliveryWindow, FLOW_STEP_KINDS, FlowStep, type FlowStepKindOption, MailProvider, Mailer, MongoContactAdapter, type MongoContactAdapterOptions, NormalizedEvent, PREDICATE_KINDS, Predicate, type PredicateKind, type PredicateKindOption, type PublicRouterOptions, SEGMENT_FILTER_KINDS, SegmentFilter, type SegmentFilterKind, type SegmentFilterKindOption, SendArgs, SendGridProvider, type SendGridProviderOptions, SendResult, SuppressionScope, TemplateDoc, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, verifyUnsubscribeToken };
753
+ export { AdapterFilter, type AdminRouterOptions, Contact, ContactAdapter, DEDUPE_POLICIES, DEFAULT_BOT_UA_RE, type DedupePolicyOption, DeliveryWindow, type DmarcInboundOptions, type DrainPendingUnsubsOptions, type DrainPendingUnsubsResult, FLOW_STEP_KINDS, FlowStep, type FlowStepKindOption, type InboundAttachment, type InboundParser, MailProvider, Mailer, MongoContactAdapter, type MongoContactAdapterOptions, NormalizedEvent, PREDICATE_KINDS, Predicate, type PredicateKind, type PredicateKindOption, type PublicRouterOptions, type RouteLogger, SEGMENT_FILTER_KINDS, SegmentFilter, type SegmentFilterKind, type SegmentFilterKindOption, SendArgs, SendGridProvider, type SendGridProviderOptions, SendResult, SuppressionScope, TRACKING_SIG_LENGTH, TemplateDoc, type TrackingScope, type TrackingTokenParams, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, drainPendingUnsubscribes, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sendgridInboundParser, sha256Hex, signTrackingToken, signUnsubscribeToken, sweepStrandedFlowRuns, verifyTrackingToken, verifyUnsubscribeToken };