mailery 0.14.0 → 0.16.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/README.md +1 -0
- package/dist/admin/spa/index-B88z7X-U.css +1 -0
- package/dist/admin/spa/index-m4wK04gU.js +9 -0
- package/dist/admin/spa/index-m4wK04gU.js.map +1 -0
- package/dist/admin/spa/index.html +2 -2
- package/dist/admin/spa/template-editor-CKQLM0Me.js +156 -0
- package/dist/admin/spa/{template-editor-pGcCFnJ7.js.map → template-editor-CKQLM0Me.js.map} +1 -1
- package/dist/cli.cjs +5 -10
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +2315 -153
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +669 -20
- package/dist/index.d.ts +669 -20
- package/dist/index.js +2310 -168
- package/dist/index.js.map +1 -1
- package/dist/{null-DDg3ojHe.d.cts → null-CDlseQxO.d.cts} +136 -6
- package/dist/{null-DDg3ojHe.d.ts → null-CDlseQxO.d.ts} +136 -6
- package/dist/testing.cjs +590 -190
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +589 -191
- package/dist/testing.js.map +1 -1
- package/package.json +15 -10
- package/dist/admin/spa/index-B6riKdpB.css +0 -1
- package/dist/admin/spa/index-l7m1PyMb.js +0 -41
- package/dist/admin/spa/index-l7m1PyMb.js.map +0 -1
- package/dist/admin/spa/template-editor-pGcCFnJ7.js +0 -502
package/dist/index.d.ts
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
|
|
2
|
-
export {
|
|
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, F as FlowStep, e as Collections, f as FlowDoc, g as SuppressionScope, D as DeliveryWindow, h as SegmentFilter, P as Predicate } from './null-CDlseQxO.js';
|
|
2
|
+
export { i as AuditLogDoc, B as BotFilterConfig, j as BroadcastDoc, k as BroadcastStatus, l as CircuitBreakerThresholds, m as ContactTagDoc, E as EventDoc, 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-CDlseQxO.js';
|
|
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>;
|
|
@@ -192,29 +271,60 @@ interface AdminRouterOptions {
|
|
|
192
271
|
mailTesterClient?: MailTesterClient;
|
|
193
272
|
}
|
|
194
273
|
declare function createAdminRouter(mailer: Mailer, opts?: AdminRouterOptions): Router;
|
|
274
|
+
/**
|
|
275
|
+
* The JSON API alone, without the SPA shell or the static assets. Exported
|
|
276
|
+
* so `createAgentRouter` can offer the same endpoints under bearer-token
|
|
277
|
+
* auth; hosts mounting the SPA should keep using `createAdminRouter`.
|
|
278
|
+
*
|
|
279
|
+
* Expects `(req as any).actor` to be set by whatever sits in front of it.
|
|
280
|
+
*/
|
|
281
|
+
declare function createAdminApiRouter(mailer: Mailer, opts?: AdminRouterOptions): Router;
|
|
195
282
|
|
|
196
283
|
/**
|
|
197
|
-
*
|
|
198
|
-
* provider webhooks. Mount under your tracking base path (default `/m`).
|
|
284
|
+
* Async handler wrapper for the *public* router.
|
|
199
285
|
*
|
|
200
|
-
*
|
|
286
|
+
* The admin router's `asyncHandler` (`api/admin.ts`) is a plain `.catch(next)`,
|
|
287
|
+
* which is right there: every admin handler computes first and responds last, so
|
|
288
|
+
* a rejection always arrives before any header is written and Express can turn
|
|
289
|
+
* it into a clean JSON 500.
|
|
201
290
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
291
|
+
* The public router cannot use that. `/open`, `/click` and `/webhooks` respond
|
|
292
|
+
* *first* and do their work after — never make a mail client or a provider
|
|
293
|
+
* wait on our database. By the time those handlers can reject,
|
|
294
|
+
* `res.headersSent` is already true, and forwarding post-headers makes Express
|
|
295
|
+
* destroy the socket underneath a response the client has already been
|
|
296
|
+
* promised.
|
|
297
|
+
*
|
|
298
|
+
* (`POST /unsub` used to be in that list. It now awaits its write before
|
|
299
|
+
* answering, because a response sent first can only ever report success —
|
|
300
|
+
* see INVARIANT 8 and the route's own comment. It still needs this wrapper for
|
|
301
|
+
* the pre-headers branch.)
|
|
302
|
+
*
|
|
303
|
+
* So this is a log-and-swallow wrapper, ported from featureboard's
|
|
304
|
+
* `src/server/routes/wrap.js`, with one deliberate difference: featureboard
|
|
305
|
+
* forwards with `next(err)` on the `headersSent` branch, and here that branch
|
|
306
|
+
* only logs. See issue #5 — every public route is unauthenticated, and the
|
|
307
|
+
* failure mode being fixed is precisely "a rejected promise in a public route
|
|
308
|
+
* takes down the host process".
|
|
208
309
|
*/
|
|
209
310
|
|
|
210
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Minimal structured-logger shape, compatible with pino/bunyan-style loggers
|
|
313
|
+
* (`logger.error(fields, message)`). Every method is optional, so `{}` is a
|
|
314
|
+
* valid silent logger and a bare `console` is not accidentally accepted with
|
|
315
|
+
* mismatched argument order.
|
|
316
|
+
*/
|
|
317
|
+
interface RouteLogger {
|
|
318
|
+
error?: (fields: Record<string, unknown>, msg?: string) => void;
|
|
319
|
+
warn?: (fields: Record<string, unknown>, msg?: string) => void;
|
|
211
320
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
321
|
+
* Routine, high-volume telemetry — currently only "an unsigned (pre-signing)
|
|
322
|
+
* tracking URL was accepted in grace mode", which is one line per legacy open
|
|
323
|
+
* and is not a fault. Kept off `warn` deliberately so it can be routed or
|
|
324
|
+
* dropped separately from things that are.
|
|
214
325
|
*/
|
|
215
|
-
|
|
326
|
+
info?: (fields: Record<string, unknown>, msg?: string) => void;
|
|
216
327
|
}
|
|
217
|
-
declare function createPublicRouter(mailer: Mailer, opts?: PublicRouterOptions): Router;
|
|
218
328
|
|
|
219
329
|
/**
|
|
220
330
|
* Template render pipeline.
|
|
@@ -295,6 +405,16 @@ interface TrackingOptions {
|
|
|
295
405
|
trackClicks: boolean;
|
|
296
406
|
/** URL that must NOT be rewritten (e.g. the resolved unsubscribe URL). */
|
|
297
407
|
preserveUrls?: string[];
|
|
408
|
+
/**
|
|
409
|
+
* HMAC key for tracking-URL signatures — pass `config.unsubscribeSecret`.
|
|
410
|
+
*
|
|
411
|
+
* When set, every generated `/m/open` and `/m/click` URL carries a truncated
|
|
412
|
+
* HMAC so it cannot be forged or enumerated from a neighbouring ObjectId.
|
|
413
|
+
* When omitted the legacy unsigned shape is emitted; that path exists for
|
|
414
|
+
* preview/test renders that never reach the tracking endpoints, not as a
|
|
415
|
+
* supported production mode.
|
|
416
|
+
*/
|
|
417
|
+
signingSecret?: string;
|
|
298
418
|
}
|
|
299
419
|
interface TrackingResult {
|
|
300
420
|
html: string;
|
|
@@ -305,12 +425,481 @@ interface TrackingResult {
|
|
|
305
425
|
}>;
|
|
306
426
|
}
|
|
307
427
|
/**
|
|
308
|
-
* Rewrite `<a href>` in `html` to /m/click/<sendId>/<linkId
|
|
309
|
-
* open pixel. Returns the modified HTML plus
|
|
310
|
-
* send document.
|
|
428
|
+
* Rewrite `<a href>` in `html` to /m/click/<sendId>/<linkId>/<sig> and append
|
|
429
|
+
* an open pixel at /m/open/<sendId>.<sig>.png. Returns the modified HTML plus
|
|
430
|
+
* the link map to persist on the send document.
|
|
431
|
+
*
|
|
432
|
+
* The `<sig>` components are 12-character truncated HMACs (see
|
|
433
|
+
* `signTrackingToken`) and are present whenever `opts.signingSecret` is set.
|
|
434
|
+
* Without them a Mongo ObjectId is the only thing standing between an attacker
|
|
435
|
+
* and a forged open — and ObjectIds are a timestamp plus a per-process counter,
|
|
436
|
+
* so one received email hands out its neighbours.
|
|
311
437
|
*/
|
|
312
438
|
declare function applyTracking(html: string, opts: TrackingOptions): TrackingResult;
|
|
313
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Agent router — the mailery surface for automation.
|
|
442
|
+
*
|
|
443
|
+
* Everything an operator does by hand in the admin SPA to take an email
|
|
444
|
+
* program from "deployed" to "safely on" — render a template as a real
|
|
445
|
+
* contact and check it, send it through the real pipeline and watch delivery,
|
|
446
|
+
* ask what a flow would do to a contact, walk a canary run step by step,
|
|
447
|
+
* gate and arm a flow — is reachable here as JSON, behind a bearer token,
|
|
448
|
+
* with no browser session. It is built for an AI agent or a CI job to drive,
|
|
449
|
+
* which shapes three things:
|
|
450
|
+
*
|
|
451
|
+
* - Every answer is structured. A verification is a list of named checks
|
|
452
|
+
* with pass/warn/fail, not a rendered page to squint at.
|
|
453
|
+
* - Every operation that could touch a real person is guarded by the
|
|
454
|
+
* `testContacts` pattern: test sends, event firing, run stepping and
|
|
455
|
+
* resets only apply to contacts whose email matches it. A router with no
|
|
456
|
+
* pattern configured refuses those routes outright rather than assuming.
|
|
457
|
+
* - Nothing here can enable a flow without stamping its trigger watermark
|
|
458
|
+
* (see runner/arm.ts), so an agent cannot replay a month of signups.
|
|
459
|
+
*
|
|
460
|
+
* Mount it beside the admin router, on a path the host's session middleware
|
|
461
|
+
* does not cover, and give it the same JSON body the SPA would:
|
|
462
|
+
*
|
|
463
|
+
* app.use('/admin/mailer/agent', createAgentRouter(mailer, {
|
|
464
|
+
* tokens: [{ token: process.env.MAILERY_AGENT_TOKEN!, actor: 'agent:claude' }],
|
|
465
|
+
* testContacts: /^qa\+.*@example\.com$/i,
|
|
466
|
+
* }))
|
|
467
|
+
*
|
|
468
|
+
* `GET /` describes every route so a client can discover the surface. The
|
|
469
|
+
* full admin JSON API (docs/reference/admin-api.md) is mounted under `/api`
|
|
470
|
+
* with the token's actor, so reads and existing operations need no second
|
|
471
|
+
* auth path.
|
|
472
|
+
*/
|
|
473
|
+
|
|
474
|
+
interface AgentToken {
|
|
475
|
+
/** The bearer token. At least 24 characters; generate it, never type it. */
|
|
476
|
+
token: string;
|
|
477
|
+
/** Audit actor recorded for everything done with this token, e.g. `agent:claude`. */
|
|
478
|
+
actor: string;
|
|
479
|
+
}
|
|
480
|
+
interface AgentRouterOptions {
|
|
481
|
+
/** Required. The router refuses to construct without at least one token. */
|
|
482
|
+
tokens: AgentToken[];
|
|
483
|
+
/**
|
|
484
|
+
* Which contacts may be test-sent to, stepped through flows, fired events
|
|
485
|
+
* for, or reset. A regular expression over the email address, or a
|
|
486
|
+
* predicate. Routes that need it answer 403 when it is not configured —
|
|
487
|
+
* the safe default for a surface an automated caller drives.
|
|
488
|
+
*/
|
|
489
|
+
testContacts?: RegExp | ((email: string) => boolean);
|
|
490
|
+
/** Structured logger for failures. Defaults to console. */
|
|
491
|
+
logger?: RouteLogger;
|
|
492
|
+
/** Passed through to the admin JSON API (tests inject a stub). */
|
|
493
|
+
mailTesterClient?: AdminRouterOptions['mailTesterClient'];
|
|
494
|
+
}
|
|
495
|
+
declare const MIN_AGENT_TOKEN_LENGTH = 24;
|
|
496
|
+
type Check = {
|
|
497
|
+
id: string;
|
|
498
|
+
status: 'pass' | 'warn' | 'fail';
|
|
499
|
+
detail?: unknown;
|
|
500
|
+
};
|
|
501
|
+
declare function createAgentRouter(mailer: Mailer, opts: AgentRouterOptions): Router;
|
|
502
|
+
interface VerifyOptions {
|
|
503
|
+
eventProperties?: Record<string, unknown>;
|
|
504
|
+
vars?: Record<string, unknown>;
|
|
505
|
+
includeRendered?: boolean;
|
|
506
|
+
varsSchema?: Record<string, unknown> | null;
|
|
507
|
+
}
|
|
508
|
+
interface VerifyReport {
|
|
509
|
+
ok: boolean;
|
|
510
|
+
template: {
|
|
511
|
+
slug: string;
|
|
512
|
+
kind: TemplateDoc['kind'];
|
|
513
|
+
name: string;
|
|
514
|
+
};
|
|
515
|
+
contact: {
|
|
516
|
+
externalId: string;
|
|
517
|
+
email: string;
|
|
518
|
+
};
|
|
519
|
+
checks: Check[];
|
|
520
|
+
links: {
|
|
521
|
+
total: number;
|
|
522
|
+
sample: string[];
|
|
523
|
+
};
|
|
524
|
+
rendered: {
|
|
525
|
+
subject: string;
|
|
526
|
+
preheader: string;
|
|
527
|
+
htmlBytes: number;
|
|
528
|
+
textLength: number;
|
|
529
|
+
fromEmail: string;
|
|
530
|
+
} | {
|
|
531
|
+
subject: string;
|
|
532
|
+
preheader: string;
|
|
533
|
+
html: string;
|
|
534
|
+
plainText: string;
|
|
535
|
+
fromEmail: string;
|
|
536
|
+
fromName: string;
|
|
537
|
+
replyTo: string | null;
|
|
538
|
+
} | null;
|
|
539
|
+
}
|
|
540
|
+
declare function verifyTemplate(mailer: Mailer, tpl: TemplateDoc, contact: Contact, opts?: VerifyOptions): Promise<VerifyReport>;
|
|
541
|
+
interface RenderForContactOptions {
|
|
542
|
+
reason: 'preview' | 'test';
|
|
543
|
+
eventProperties?: Record<string, unknown>;
|
|
544
|
+
vars?: Record<string, unknown>;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Render a published template for one real contact the way a send would:
|
|
548
|
+
* host vars resolved through the varsAdapter, the contact at the root, a
|
|
549
|
+
* genuinely signed unsubscribe URL. The one difference from a send is that
|
|
550
|
+
* no tracking is applied — nothing here is queued.
|
|
551
|
+
*/
|
|
552
|
+
declare function renderForContact(mailer: Mailer, tpl: TemplateDoc, contact: Contact, opts: RenderForContactOptions): Promise<{
|
|
553
|
+
rendered: RenderedTemplate;
|
|
554
|
+
resolved: Record<string, unknown>;
|
|
555
|
+
unsubscribeUrl: string;
|
|
556
|
+
/** The exact object the template was rendered against. */
|
|
557
|
+
context: Record<string, unknown>;
|
|
558
|
+
}>;
|
|
559
|
+
/**
|
|
560
|
+
* Every dotted path a Handlebars source references outside `#each`/`#with`
|
|
561
|
+
* blocks (whose paths are relative to the iterated item and cannot be
|
|
562
|
+
* resolved against the root context) and outside HTML comments. Helper
|
|
563
|
+
* names, literals, hash keys, `@data` variables and Handlebars comments are
|
|
564
|
+
* skipped.
|
|
565
|
+
*/
|
|
566
|
+
declare function referencedPaths(source: string, helperNames?: Iterable<string>): string[];
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Enabling a flow, done safely — and running it against test contacts only.
|
|
570
|
+
*
|
|
571
|
+
* The trigger scan starts from `flow.lastTriggerScanAt ?? flow.createdAt`
|
|
572
|
+
* (triggers.ts). A flow that has never been enabled has a null watermark, so a
|
|
573
|
+
* bare `enabled: true` replays every matching event since the flow document
|
|
574
|
+
* was created: every signup of the past month gets the welcome email in one
|
|
575
|
+
* tick, a week or more late. That is the single most dangerous write in the
|
|
576
|
+
* admin surface, and until 0.16 it was one click (publish, or resume).
|
|
577
|
+
*
|
|
578
|
+
* `armFlow` is the path that flips `enabled` on. It stamps the watermark
|
|
579
|
+
* (default: now) in the SAME update, reports how many events it is choosing
|
|
580
|
+
* to skip, and writes an audit row. `publish` and `resume` in the admin API
|
|
581
|
+
* go through `stampWatermarkIfNull` for the same guarantee.
|
|
582
|
+
*
|
|
583
|
+
* `gateFlow` publishes a canary version whose first step exits anyone
|
|
584
|
+
* without a tag, so the real runner can exercise the real steps in
|
|
585
|
+
* production against test contacts while every real contact enters and
|
|
586
|
+
* exits at step 0 with no send. `ungateFlow` restores the newest ungated
|
|
587
|
+
* version. Runs pin their version, so a contact mid-canary finishes on it.
|
|
588
|
+
*/
|
|
589
|
+
|
|
590
|
+
/** A typed failure the HTTP layer can map to a status code without guessing. */
|
|
591
|
+
declare class FlowOperationError extends Error {
|
|
592
|
+
readonly code: string;
|
|
593
|
+
readonly status: number;
|
|
594
|
+
constructor(code: string, message: string, status?: number);
|
|
595
|
+
}
|
|
596
|
+
interface ArmFlowOptions {
|
|
597
|
+
/** Audit actor, e.g. `agent:claude` or `human:jeff@example.com`. */
|
|
598
|
+
actor: string;
|
|
599
|
+
/**
|
|
600
|
+
* Watermark to stamp. Defaults to now, which means only events fired AFTER
|
|
601
|
+
* this call enter the flow. Pass an earlier instant only when you mean for
|
|
602
|
+
* the events after it to enter — the result says how many that is.
|
|
603
|
+
*/
|
|
604
|
+
since?: Date;
|
|
605
|
+
}
|
|
606
|
+
interface ArmFlowResult {
|
|
607
|
+
slug: string;
|
|
608
|
+
version: number;
|
|
609
|
+
/** True when this call flipped `enabled` on. */
|
|
610
|
+
armed: boolean;
|
|
611
|
+
/** True when the flow was already enabled; nothing was written. */
|
|
612
|
+
alreadyEnabled: boolean;
|
|
613
|
+
/** The watermark now on the flow. */
|
|
614
|
+
watermark: Date | null;
|
|
615
|
+
eventName: string | null;
|
|
616
|
+
/** Events for the trigger name that fired before the watermark and will never enter. */
|
|
617
|
+
skippedEvents: number;
|
|
618
|
+
/**
|
|
619
|
+
* Events that WILL enter on the next tick: everything after the watermark,
|
|
620
|
+
* plus anything created inside the scanner's 30-second overlap window just
|
|
621
|
+
* before it.
|
|
622
|
+
*/
|
|
623
|
+
pendingEvents: number;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Stamp `lastTriggerScanAt` when it is null. Used by every code path that
|
|
627
|
+
* turns `enabled` on so a first enable never replays history. A flow that
|
|
628
|
+
* has scanned before keeps its watermark: pausing and resuming deliberately
|
|
629
|
+
* lets the events fired during the pause enter.
|
|
630
|
+
*/
|
|
631
|
+
declare function stampWatermarkIfNull(collections: Collections, flow: FlowDoc, now?: Date): Promise<Date | null>;
|
|
632
|
+
declare function armFlow(mailer: Mailer, slug: string, opts: ArmFlowOptions): Promise<ArmFlowResult>;
|
|
633
|
+
/** The inverse: `enabled: false`. In-flight runs continue (that is what pause means). */
|
|
634
|
+
declare function disarmFlow(mailer: Mailer, slug: string, actor: string): Promise<{
|
|
635
|
+
slug: string;
|
|
636
|
+
disarmed: boolean;
|
|
637
|
+
}>;
|
|
638
|
+
/**
|
|
639
|
+
* The gate step. `canaryGate` is a marker the runner ignores (it evaluates
|
|
640
|
+
* the condition like any other) and this module uses to recognise its own
|
|
641
|
+
* work when removing it.
|
|
642
|
+
*/
|
|
643
|
+
type CanaryGateStep = Extract<FlowStep, {
|
|
644
|
+
type: 'condition';
|
|
645
|
+
}> & {
|
|
646
|
+
canaryGate: true;
|
|
647
|
+
};
|
|
648
|
+
declare function isCanaryGate(step: unknown): step is CanaryGateStep;
|
|
649
|
+
interface GateFlowResult {
|
|
650
|
+
slug: string;
|
|
651
|
+
version: number;
|
|
652
|
+
tag: string;
|
|
653
|
+
enabled: boolean;
|
|
654
|
+
}
|
|
655
|
+
declare function gateFlow(mailer: Mailer, slug: string, opts: {
|
|
656
|
+
tag: string;
|
|
657
|
+
actor: string;
|
|
658
|
+
}): Promise<GateFlowResult>;
|
|
659
|
+
interface UngateFlowResult {
|
|
660
|
+
slug: string;
|
|
661
|
+
version: number;
|
|
662
|
+
/** The version whose steps were restored. */
|
|
663
|
+
restoredFrom: number;
|
|
664
|
+
enabled: boolean;
|
|
665
|
+
}
|
|
666
|
+
declare function ungateFlow(mailer: Mailer, slug: string, opts: {
|
|
667
|
+
actor: string;
|
|
668
|
+
}): Promise<UngateFlowResult>;
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Flow simulation: "what would happen to this contact if the trigger fired
|
|
672
|
+
* now?" — answered by walking the published steps against the contact's real
|
|
673
|
+
* state (tags, fields, events, sends, subscription) with a virtual clock and
|
|
674
|
+
* WITHOUT writing anything.
|
|
675
|
+
*
|
|
676
|
+
* This is the dry run an operator wants before arming a flow: it shows the
|
|
677
|
+
* branch a contact takes, every gate's verdict, the sends and the wall-clock
|
|
678
|
+
* moment each would go out (waits and delivery windows applied), and where
|
|
679
|
+
* the run ends. It reads the same predicate evaluator the runner uses, so a
|
|
680
|
+
* gate that passes here passes in production — for the state as it is at the
|
|
681
|
+
* moment of the call. Events that would arrive during the run are of course
|
|
682
|
+
* not known, which is why `path[].at` is labelled a projection.
|
|
683
|
+
*/
|
|
684
|
+
|
|
685
|
+
interface SimulateOptions {
|
|
686
|
+
/** Virtual "now" the simulated run enters at. Defaults to the real now. */
|
|
687
|
+
at?: Date;
|
|
688
|
+
/** Properties of the simulated trigger event ({{event.*}}, triggerProperty* predicates). */
|
|
689
|
+
eventProperties?: Record<string, unknown>;
|
|
690
|
+
/** Steps to walk. Defaults to the flow's live steps. */
|
|
691
|
+
steps?: FlowStep[];
|
|
692
|
+
}
|
|
693
|
+
interface SimulatedStep {
|
|
694
|
+
/** Projected wall-clock moment the step is reached. */
|
|
695
|
+
at: Date;
|
|
696
|
+
stepIndex: number;
|
|
697
|
+
branchPath: Array<number | 'true' | 'false'>;
|
|
698
|
+
type: FlowStep['type'];
|
|
699
|
+
outcome: 'waited' | 'passed' | 'skipped_next' | 'exited' | 'branch_true' | 'branch_false' | 'send' | 'send_deferred' | 'tagged' | 'event_fired' | 'webhook' | 'completed';
|
|
700
|
+
detail?: Record<string, unknown>;
|
|
701
|
+
}
|
|
702
|
+
interface SimulationResult {
|
|
703
|
+
flow: {
|
|
704
|
+
slug: string;
|
|
705
|
+
version: number;
|
|
706
|
+
enabled: boolean;
|
|
707
|
+
};
|
|
708
|
+
contact: {
|
|
709
|
+
externalId: string;
|
|
710
|
+
email: string;
|
|
711
|
+
};
|
|
712
|
+
enteredAt: Date;
|
|
713
|
+
/** Whether the trigger scan would create a run at all, and why not. */
|
|
714
|
+
wouldEnter: {
|
|
715
|
+
ok: boolean;
|
|
716
|
+
reasons: string[];
|
|
717
|
+
};
|
|
718
|
+
path: SimulatedStep[];
|
|
719
|
+
sends: Array<{
|
|
720
|
+
templateSlug: string;
|
|
721
|
+
at: Date;
|
|
722
|
+
stepIndex: number;
|
|
723
|
+
branchPath: Array<number | 'true' | 'false'>;
|
|
724
|
+
}>;
|
|
725
|
+
terminal: {
|
|
726
|
+
kind: 'completed' | 'exited' | 'truncated';
|
|
727
|
+
reason: string;
|
|
728
|
+
at: Date;
|
|
729
|
+
};
|
|
730
|
+
/** Projected time from entry to the terminal step. */
|
|
731
|
+
durationMs: number;
|
|
732
|
+
}
|
|
733
|
+
declare function simulateFlow(flow: FlowDoc, contact: Contact, ctx: RunnerContext, opts?: SimulateOptions): Promise<SimulationResult>;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Inbound DMARC aggregate-report webhook.
|
|
737
|
+
*
|
|
738
|
+
* DMARC RUA reports arrive as email. The usual way to turn that email into an
|
|
739
|
+
* HTTP request is SendGrid Inbound Parse, and until v0.15 the documented setup
|
|
740
|
+
* pointed Inbound Parse at `/admin/mailer/api/dmarc/upload` — a route behind
|
|
741
|
+
* the host's `requireAdmin` guard, which Inbound Parse cannot satisfy. So the
|
|
742
|
+
* documented setup either did not work, or worked because somebody removed the
|
|
743
|
+
* guard from an admin router. This route replaces that advice.
|
|
744
|
+
*
|
|
745
|
+
* ## Read this before enabling it
|
|
746
|
+
*
|
|
747
|
+
* **Inbound Parse does not sign its payloads.** The SendGrid *Event Webhook*
|
|
748
|
+
* does — see `providers/sendgrid.ts`, where signature verification and the
|
|
749
|
+
* replay window live — but Inbound Parse is a different product and offers no
|
|
750
|
+
* signature, no HMAC and no verifiable identity. There is nothing to verify.
|
|
751
|
+
*
|
|
752
|
+
* So this is, structurally, an unauthenticated file-accepting endpoint on a
|
|
753
|
+
* public router, and the design has to own that rather than dress it up:
|
|
754
|
+
*
|
|
755
|
+
* 1. **Off unless configured.** No `dmarcInbound.secret`, no route — not a
|
|
756
|
+
* 404 handler, no route registered at all. An endpoint like this appearing
|
|
757
|
+
* on an upgrade because someone shipped a default would be indefensible.
|
|
758
|
+
* 2. **A shared secret, checked first.** The secret is compared with
|
|
759
|
+
* `timingSafeEqual` before multer is allowed to read a single byte of the
|
|
760
|
+
* body, so an unauthenticated caller cannot make us buffer 10MB.
|
|
761
|
+
* 3. **Hard size and count limits**, matching the admin upload's shape.
|
|
762
|
+
* 4. **A domain cross-check.** A report whose `<policy_published><domain>` is
|
|
763
|
+
* not a domain this deployment sends from is rejected. If the secret leaks,
|
|
764
|
+
* the attacker gets to inject rows about *your* domains, which is bounded
|
|
765
|
+
* and visible, instead of arbitrary garbage.
|
|
766
|
+
* 5. **The existing parser.** `runner/dmarc.ts` already caps decompressed
|
|
767
|
+
* bytes, validates declared-vs-actual sizes, checks compression ratio and
|
|
768
|
+
* guards zip-slip. This route does not parse anything itself.
|
|
769
|
+
*
|
|
770
|
+
* ## Why a shared secret, and where it goes
|
|
771
|
+
*
|
|
772
|
+
* The three options that don't need a signature:
|
|
773
|
+
*
|
|
774
|
+
* - **Source IP allowlist.** Rejected as the primary control. SendGrid does
|
|
775
|
+
* not publish a stable Inbound Parse egress range, and behind a proxy
|
|
776
|
+
* `req.ip` is the proxy's address unless the host has set `trust proxy`
|
|
777
|
+
* correctly — which this library cannot verify and must not assume. An
|
|
778
|
+
* allowlist that silently matches the wrong address either locks out the
|
|
779
|
+
* real sender or admits everyone. Put an allowlist in your ingress if you
|
|
780
|
+
* want one; it is a good second layer and a bad only layer.
|
|
781
|
+
* - **Secret in the path.** Works, and is the fallback, but a URL path is
|
|
782
|
+
* logged by every proxy, load balancer and access log between SendGrid and
|
|
783
|
+
* you. Supported implicitly: nothing stops you making `path` unguessable.
|
|
784
|
+
* - **Secret in the `Authorization` header.** The default and the
|
|
785
|
+
* recommendation. Inbound Parse cannot set custom headers, but its
|
|
786
|
+
* destination URL accepts embedded basic-auth credentials
|
|
787
|
+
* (`https://mailery:SECRET@example.com/m/inbound/dmarc`), which travel as an
|
|
788
|
+
* `Authorization` header rather than in the request line. `Bearer` is
|
|
789
|
+
* accepted too, for hosts forwarding from something other than SendGrid.
|
|
790
|
+
*
|
|
791
|
+
* A leaked secret gets an attacker exactly one capability: inserting DMARC
|
|
792
|
+
* report rows for domains you already send from. No mail is sent, no contact
|
|
793
|
+
* data is touched, nothing is deleted. Rotate it by changing the config value
|
|
794
|
+
* and the Inbound Parse destination URL.
|
|
795
|
+
*/
|
|
796
|
+
|
|
797
|
+
/** One attachment lifted off an inbound request by a `parseInbound` seam. */
|
|
798
|
+
interface InboundAttachment {
|
|
799
|
+
filename: string;
|
|
800
|
+
buffer: Buffer;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Pull DMARC attachments out of a provider's inbound-email payload.
|
|
804
|
+
*
|
|
805
|
+
* The seam exists so Mailgun's and Postmark's inbound routes can be added
|
|
806
|
+
* without touching this route's auth, limits or ingest path. Only the SendGrid
|
|
807
|
+
* shape is implemented today.
|
|
808
|
+
*/
|
|
809
|
+
type InboundParser = (req: Request) => InboundAttachment[];
|
|
810
|
+
interface DmarcInboundOptions {
|
|
811
|
+
/**
|
|
812
|
+
* Shared secret the caller must present. **Absent or empty → the route is
|
|
813
|
+
* not mounted at all.**
|
|
814
|
+
*
|
|
815
|
+
* Accepted as HTTP Basic (any username; the password is compared) or as
|
|
816
|
+
* `Authorization: Bearer <secret>`. Basic is the one to use with SendGrid
|
|
817
|
+
* Inbound Parse, since it can be embedded in the destination URL and so
|
|
818
|
+
* stays out of access logs.
|
|
819
|
+
*/
|
|
820
|
+
secret?: string;
|
|
821
|
+
/** Sub-path on the public router. Default `/inbound/dmarc`. */
|
|
822
|
+
path?: string;
|
|
823
|
+
/** Per-file cap. Default 10MB, matching the admin upload. */
|
|
824
|
+
maxFileSizeBytes?: number;
|
|
825
|
+
/** Attachments accepted per request. Default 10. */
|
|
826
|
+
maxFiles?: number;
|
|
827
|
+
/**
|
|
828
|
+
* Domains whose reports are accepted. Defaults to every domain in
|
|
829
|
+
* `senderDomains` plus the `fromDefaults` / `transactionalFromDefaults`
|
|
830
|
+
* addresses. Reports for anything else are rejected.
|
|
831
|
+
*
|
|
832
|
+
* If none of those are configured there is nothing to check against, and the
|
|
833
|
+
* route mounts with the domain gate disabled and a warning — configure
|
|
834
|
+
* `senderDomains` to get it back.
|
|
835
|
+
*/
|
|
836
|
+
allowedDomains?: string[];
|
|
837
|
+
/** Override the inbound payload shape. Defaults to SendGrid Inbound Parse. */
|
|
838
|
+
parseInbound?: InboundParser;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* SendGrid Inbound Parse: `multipart/form-data` with the message's fields
|
|
842
|
+
* (`headers`, `from`, `subject`, `text`, ...) plus one file part per
|
|
843
|
+
* attachment, named `attachment1`, `attachment2`, and so on.
|
|
844
|
+
*
|
|
845
|
+
* Read through `multer.any()`, so the field names are not trusted — every file
|
|
846
|
+
* part is a candidate and the extension gate below decides.
|
|
847
|
+
*/
|
|
848
|
+
declare const sendgridInboundParser: InboundParser;
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Public router — endpoints that must be reachable by email clients and
|
|
852
|
+
* provider webhooks. Mount under your tracking base path (default `/m`).
|
|
853
|
+
*
|
|
854
|
+
* app.use('/m', createPublicRouter(mailer))
|
|
855
|
+
*
|
|
856
|
+
* Routes:
|
|
857
|
+
* GET /open/:sendId.:sig.png — open pixel (records open, returns 1×1 PNG)
|
|
858
|
+
* GET /click/:sendId/:linkId/:sig — click redirect (records click, 302 → target)
|
|
859
|
+
* GET /unsub/:token — confirmation page (one-click POST link)
|
|
860
|
+
* POST /unsub/:token — RFC 8058 one-click unsubscribe
|
|
861
|
+
* POST /webhooks/:provider — inbound provider event webhook
|
|
862
|
+
* POST /inbound/dmarc — inbound DMARC report (opt-in; see below)
|
|
863
|
+
*
|
|
864
|
+
* Every route here is unauthenticated by necessity — mail clients and provider
|
|
865
|
+
* webhook servers cannot present credentials. The one exception is
|
|
866
|
+
* `/inbound/dmarc`, which is **not mounted at all** unless a shared secret is
|
|
867
|
+
* configured; see `api/dmarc-inbound.ts`.
|
|
868
|
+
*
|
|
869
|
+
* `:sig` is a 12-character truncated HMAC issued by `applyTracking`. It is
|
|
870
|
+
* syntactically optional on both tracking routes so that mail delivered before
|
|
871
|
+
* signing existed keeps working — see `checkTrackingSignature`.
|
|
872
|
+
*/
|
|
873
|
+
|
|
874
|
+
interface PublicRouterOptions {
|
|
875
|
+
/**
|
|
876
|
+
* Overrides `MailerConfig.pendingUnsubsPath` for this router only.
|
|
877
|
+
*
|
|
878
|
+
* @deprecated Set `pendingUnsubsPath` in `MailerConfig` instead. The tick
|
|
879
|
+
* drain (`drainPendingUnsubscribes`) reads the *config* value, so a path set
|
|
880
|
+
* only here is written but never replayed — which is precisely the bug
|
|
881
|
+
* INVARIANT 8 was carrying. Setting it here without also setting it in
|
|
882
|
+
* config logs a warning at construction time.
|
|
883
|
+
*/
|
|
884
|
+
pendingUnsubsPath?: string;
|
|
885
|
+
/**
|
|
886
|
+
* Structured logger for public-route failures, pino-style
|
|
887
|
+
* (`logger.error(fields, message)`).
|
|
888
|
+
*
|
|
889
|
+
* Defaults to a `console`-backed logger that reproduces the output this
|
|
890
|
+
* package emitted before the option existed. Pass `{}` to silence.
|
|
891
|
+
*/
|
|
892
|
+
logger?: RouteLogger;
|
|
893
|
+
/**
|
|
894
|
+
* Inbound DMARC aggregate-report webhook (SendGrid Inbound Parse and
|
|
895
|
+
* friends). **Off unless `secret` is set** — see `api/dmarc-inbound.ts` for
|
|
896
|
+
* why an endpoint that accepts unsigned file uploads must never appear on an
|
|
897
|
+
* upgrade by itself.
|
|
898
|
+
*/
|
|
899
|
+
dmarcInbound?: DmarcInboundOptions;
|
|
900
|
+
}
|
|
901
|
+
declare function createPublicRouter(mailer: Mailer, opts?: PublicRouterOptions): Router;
|
|
902
|
+
|
|
314
903
|
/**
|
|
315
904
|
* HMAC-signed tokens for unsubscribe + preference-center URLs.
|
|
316
905
|
*
|
|
@@ -328,11 +917,71 @@ interface UnsubscribeTokenPayload {
|
|
|
328
917
|
}
|
|
329
918
|
declare function signUnsubscribeToken(payload: UnsubscribeTokenPayload, secret: string): string;
|
|
330
919
|
declare function verifyUnsubscribeToken(token: string, secret: string, now?: Date): UnsubscribeTokenPayload | null;
|
|
920
|
+
/**
|
|
921
|
+
* Scopes a tracking signature can be issued for. An allowlist, mirroring the
|
|
922
|
+
* `k: 'doi'` discriminator on the DOI token: the scope is part of the signed
|
|
923
|
+
* message, so an `/m/open` signature cannot be replayed as an `/m/click` one
|
|
924
|
+
* even for the same send.
|
|
925
|
+
*/
|
|
926
|
+
type TrackingScope = 'open' | 'click';
|
|
927
|
+
/**
|
|
928
|
+
* Signature length in base64url characters. 12 chars = 72 bits of the
|
|
929
|
+
* HMAC-SHA256 digest.
|
|
930
|
+
*
|
|
931
|
+
* Truncation is deliberate. These signatures are embedded in every link of
|
|
932
|
+
* every email, and the pixel URL in particular is parsed by mail clients with
|
|
933
|
+
* their own length quirks, so bytes are not free. 72 bits is far beyond what an
|
|
934
|
+
* online forgery attack can reach: an attacker gets no oracle beyond "the open
|
|
935
|
+
* was counted", each guess is a live HTTP request, and there is no offline
|
|
936
|
+
* verification step. Nothing here is a bearer credential for anything except
|
|
937
|
+
* "this recipient was sent this mail".
|
|
938
|
+
*/
|
|
939
|
+
declare const TRACKING_SIG_LENGTH = 12;
|
|
940
|
+
interface TrackingTokenParams {
|
|
941
|
+
sendId: string;
|
|
942
|
+
/** Present for `click` scope, absent for `open`. */
|
|
943
|
+
linkId?: string;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Sign a tracking URL. Returns `TRACKING_SIG_LENGTH` base64url characters.
|
|
947
|
+
*
|
|
948
|
+
* Keyed with `unsubscribeSecret` — the same secret the unsubscribe and DOI
|
|
949
|
+
* tokens use, so there is exactly one secret to provision
|
|
950
|
+
* (`MAILER_UNSUBSCRIBE_SECRET`) and one to rotate. The scope prefix keeps the
|
|
951
|
+
* key domains separate.
|
|
952
|
+
*/
|
|
953
|
+
declare function signTrackingToken(scope: TrackingScope, params: TrackingTokenParams, secret: string): string;
|
|
954
|
+
/**
|
|
955
|
+
* Verify a tracking-URL signature in constant time.
|
|
956
|
+
*
|
|
957
|
+
* The length pre-check before `timingSafeEqual` is required, not defensive:
|
|
958
|
+
* `crypto.timingSafeEqual` throws on mismatched lengths, and a thrown
|
|
959
|
+
* verification is a rejection that leaks length through a different channel.
|
|
960
|
+
* Same shape as `verifyUnsubscribeToken`.
|
|
961
|
+
*/
|
|
962
|
+
declare function verifyTrackingToken(token: unknown, scope: TrackingScope, params: TrackingTokenParams, secret: string): boolean;
|
|
331
963
|
/**
|
|
332
964
|
* sha256 hex digest, used for storing email hashes (GDPR forget) and dedup helpers.
|
|
333
965
|
*/
|
|
334
966
|
declare function sha256Hex(input: string): string;
|
|
335
967
|
|
|
968
|
+
/**
|
|
969
|
+
* Predicate evaluator. Resolves all Predicate variants from `shared/types.ts`
|
|
970
|
+
* against a Contact (host-side fields/tags) + mailer state (events, sends,
|
|
971
|
+
* subscription).
|
|
972
|
+
*
|
|
973
|
+
* Network calls hit Mongo. Cache the Contact at the call site if you evaluate
|
|
974
|
+
* many predicates in a row.
|
|
975
|
+
*/
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* User agents treated as automated unless the host overrides
|
|
979
|
+
* `botFilter.userAgentPattern`. Deliberately short: these are the scanners that
|
|
980
|
+
* announce themselves. Anything that impersonates a browser is not catchable
|
|
981
|
+
* from the UA string and is out of scope here — see INVARIANT 7.
|
|
982
|
+
*/
|
|
983
|
+
declare const DEFAULT_BOT_UA_RE: RegExp;
|
|
984
|
+
|
|
336
985
|
/**
|
|
337
986
|
* Delivery-window math. Pure — no I/O, no Date.now(); callers pass `now`.
|
|
338
987
|
*
|
|
@@ -404,4 +1053,4 @@ declare const DEDUPE_POLICIES: readonly DedupePolicyOption[];
|
|
|
404
1053
|
*/
|
|
405
1054
|
declare const VERSION: string;
|
|
406
1055
|
|
|
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 };
|
|
1056
|
+
export { AdapterFilter, type AdminRouterOptions, type AgentRouterOptions, type AgentToken, type ArmFlowOptions, type ArmFlowResult, Collections, Contact, ContactAdapter, DEDUPE_POLICIES, DEFAULT_BOT_UA_RE, type DedupePolicyOption, DeliveryWindow, type DmarcInboundOptions, type DrainPendingUnsubsOptions, type DrainPendingUnsubsResult, FLOW_STEP_KINDS, FlowDoc, FlowOperationError, FlowStep, type FlowStepKindOption, type GateFlowResult, type InboundAttachment, type InboundParser, MIN_AGENT_TOKEN_LENGTH, 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, type SimulateOptions, type SimulatedStep, type SimulationResult, SuppressionScope, TRACKING_SIG_LENGTH, TemplateDoc, type TrackingScope, type TrackingTokenParams, type UngateFlowResult, VERSION, type VerifyOptions, type VerifyReport, applyTracking, applyWebhookEvent, armFlow, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminApiRouter, createAdminRouter, createAgentRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, disarmFlow, dispatchSend, drainPendingUnsubscribes, gateFlow, isCanaryGate, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, referencedPaths, renderForContact, renderTemplate, runTick, sendgridInboundParser, sha256Hex, signTrackingToken, signUnsubscribeToken, simulateFlow, stampWatermarkIfNull, sweepStrandedFlowRuns, ungateFlow, verifyTemplate, verifyTrackingToken, verifyUnsubscribeToken };
|