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.
@@ -661,6 +661,30 @@ interface CircuitBreakerThresholds {
661
661
  windowMinutes: number;
662
662
  minSendsBeforeEval: number;
663
663
  }
664
+ interface BotFilterConfig {
665
+ /**
666
+ * User agents matching this pattern are treated as automated. Applied to
667
+ * recorded open and click user agents alike.
668
+ *
669
+ * Default: `/Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i`
670
+ * (`DEFAULT_BOT_UA_RE`). Replace it to cover your own corporate scanners;
671
+ * extend rather than replace if you want to keep the built-ins.
672
+ */
673
+ userAgentPattern?: RegExp;
674
+ /**
675
+ * Opens recorded within this many milliseconds of the send being queued are
676
+ * treated as automated regardless of user agent — corporate scanners and
677
+ * gateway prefetchers fetch the pixel within seconds of delivery, and a
678
+ * human cannot.
679
+ *
680
+ * Default `0` — disabled. It is off by default because it is a heuristic
681
+ * with a real false-positive tail (a recipient already reading their inbox
682
+ * when the mail lands) and because enabling it silently changes the counts
683
+ * every existing flow branches on. `10000` (10s) is the value worth trying
684
+ * first if your open counts look implausible.
685
+ */
686
+ minOpenDelayMs?: number;
687
+ }
664
688
  interface MailerConfig {
665
689
  db: Db;
666
690
  collectionPrefix?: string;
@@ -706,6 +730,40 @@ interface MailerConfig {
706
730
  requireDoubleOptIn?: boolean;
707
731
  unsubscribeTokenLifetimeDays?: number;
708
732
  transactionalRespectUnsubscribe?: boolean;
733
+ /**
734
+ * Absolute path to the pending-unsubscribe journal (INVARIANT 8).
735
+ *
736
+ * When `POST /m/unsub/:token` cannot reach Mongo, the opt-out is appended
737
+ * here as JSONL and replayed by the tick drain
738
+ * (`drainPendingUnsubscribes`). The file is created 0600 inside a 0700
739
+ * directory and opened `O_NOFOLLOW`.
740
+ *
741
+ * **There is no default, and that is deliberate.** The journal holds
742
+ * recipient email addresses in plaintext outside the database, and a library
743
+ * mounted into an unknown host cannot pick a directory that is at once
744
+ * writable, durable and private — which is how the previous default ended up
745
+ * being `/tmp` (world-writable, wiped on reboot, and never read back).
746
+ *
747
+ * With this unset the endpoint answers **503** when Mongo is unreachable,
748
+ * rather than promising an unsubscribe it has nowhere to record. Set it to a
749
+ * durable, private, node-local path to get the journal instead — e.g.
750
+ * `/var/lib/mailery/pending-unsubs.jsonl` (systemd: `StateDirectory=mailery`).
751
+ *
752
+ * Must not be on a shared network filesystem: claiming a batch relies on
753
+ * `rename` being atomic within the directory.
754
+ */
755
+ pendingUnsubsPath?: string;
756
+ /**
757
+ * How long `POST /m/unsub/:token` waits for the Mongo write before falling
758
+ * back to the journal. Default 5000ms.
759
+ *
760
+ * The tension is INVARIANT 8's own: the response must be fast (clients are
761
+ * impatient, providers retry) *and* must not claim an unsubscribe that was
762
+ * never recorded. A healthy write is sub-millisecond, so this budget is only
763
+ * ever spent during an outage, and spending it is what makes the difference
764
+ * between a truthful 200 and a lie.
765
+ */
766
+ unsubscribeWriteTimeoutMs?: number;
709
767
  /** Slug of the transactional template sent for DOI confirmation. Defaults to 'doi-confirmation'. */
710
768
  doiTemplateSlug?: string;
711
769
  /** How long the DOI token stays valid. Default 7 days. */
@@ -740,10 +798,18 @@ interface MailerConfig {
740
798
  mailTester?: MailTesterConfig;
741
799
  /**
742
800
  * DMARC RUA aggregate reports tell you who is sending mail that claims to
743
- * be from your domain — both legitimate sources and spoofers. Operator
744
- * uploads the report attachments (or POSTs them via SendGrid Inbound Parse
745
- * to /admin/mailer/api/dmarc/upload). Mailery decompresses, parses, and
746
- * surfaces alignment failures + unknown senders.
801
+ * be from your domain — both legitimate sources and spoofers. Mailery
802
+ * decompresses, parses, and surfaces alignment failures + unknown senders.
803
+ *
804
+ * Two ways in:
805
+ * - the operator uploads attachments in the admin UI
806
+ * (`POST /admin/mailer/api/dmarc/upload`, behind your admin guard)
807
+ * - the inbound webhook, for SendGrid Inbound Parse and friends:
808
+ * `createPublicRouter(mailer, { dmarcInbound: { secret } })`. Off unless
809
+ * that secret is set. Do **not** point Inbound Parse at the admin
810
+ * upload route — it sits behind the host's `requireAdmin` guard, which
811
+ * Inbound Parse cannot satisfy. (Versions before 0.15 recommended
812
+ * exactly that, and it never worked.)
747
813
  */
748
814
  dmarc?: DmarcConfig;
749
815
  broadcastConfirmationThreshold?: number;
@@ -761,6 +827,35 @@ interface MailerConfig {
761
827
  trackClicks?: boolean;
762
828
  storeTrackingIp?: boolean;
763
829
  storeRenderedBody?: boolean;
830
+ /**
831
+ * Reject `/m/open` and `/m/click` hits that carry no signature.
832
+ *
833
+ * Default `false` — grace mode. Newly rendered mail is always signed; this
834
+ * flag only decides what happens to the *unsigned* URLs sitting in mail that
835
+ * was already delivered. In grace mode they are still counted and each one is
836
+ * logged (`mailery: unsigned tracking URL accepted`) so the operator can watch
837
+ * the legacy rate fall to zero and then flip this to `true`.
838
+ *
839
+ * A signature that is *present but wrong* is always rejected, in both modes.
840
+ */
841
+ requireSignedTrackingUrls?: boolean;
842
+ /**
843
+ * How long a tracking URL stays valid, in days, measured from the send's
844
+ * `queuedAt`. Default `0` — never expires.
845
+ *
846
+ * Never-expire is the right default: an email sits in an inbox for years and
847
+ * a legitimate open six months later is real data, not an attack. The
848
+ * signature already proves the caller was handed the URL. Operators with a
849
+ * data-retention policy can set a window; because the deadline is derived
850
+ * from the send row rather than baked into the token, changing this value
851
+ * takes effect immediately for already-delivered mail and costs no URL bytes.
852
+ */
853
+ trackingUrlLifetimeDays?: number;
854
+ /**
855
+ * Tuning for `hasOpenedExcludingBots` / `hasClickedExcludingBots` /
856
+ * `openedAtLeastN` / `clickedAtLeastN`. See `BotFilterConfig`.
857
+ */
858
+ botFilter?: BotFilterConfig;
764
859
  getAdminActor?: (req: any) => string;
765
860
  onCircuitBreakerTrip?: (info: {
766
861
  reason: string;
@@ -772,7 +867,7 @@ interface MailerConfig {
772
867
  }) => Promise<void> | void;
773
868
  handlebarsHelpers?: Record<string, Handlebars.HelperDelegate>;
774
869
  }
775
- type ResolvedConfig = Required<Pick<MailerConfig, 'collectionPrefix' | 'requireDoubleOptIn' | 'unsubscribeTokenLifetimeDays' | 'transactionalRespectUnsubscribe' | 'doiTemplateSlug' | 'doiTokenLifetimeDays' | 'broadcastConfirmationThreshold' | 'broadcastEnqueueBatchSize' | 'broadcastEnqueueMaxWaiting' | 'workerless' | 'tickIntervalSeconds' | 'sendConcurrency' | 'sendRatePerSecond' | 'softBouncePromotionThreshold' | 'softBouncePromotionWindowDays' | 'webhookRetryAttempts' | 'sendRetryAttempts' | 'trackOpens' | 'trackClicks' | 'storeTrackingIp' | 'storeRenderedBody'>> & {
870
+ type ResolvedConfig = Required<Pick<MailerConfig, 'collectionPrefix' | 'requireDoubleOptIn' | 'unsubscribeTokenLifetimeDays' | 'transactionalRespectUnsubscribe' | 'unsubscribeWriteTimeoutMs' | 'doiTemplateSlug' | 'doiTokenLifetimeDays' | 'broadcastConfirmationThreshold' | 'broadcastEnqueueBatchSize' | 'broadcastEnqueueMaxWaiting' | 'workerless' | 'tickIntervalSeconds' | 'sendConcurrency' | 'sendRatePerSecond' | 'softBouncePromotionThreshold' | 'softBouncePromotionWindowDays' | 'webhookRetryAttempts' | 'sendRetryAttempts' | 'trackOpens' | 'trackClicks' | 'storeTrackingIp' | 'storeRenderedBody' | 'requireSignedTrackingUrls' | 'trackingUrlLifetimeDays'>> & {
776
871
  circuitBreaker: CircuitBreakerThresholds;
777
872
  } & MailerConfig;
778
873
 
@@ -1017,6 +1112,19 @@ interface SendDoc {
1017
1112
  vars: Record<string, unknown>;
1018
1113
  openedAt: Date | null;
1019
1114
  openCount: number;
1115
+ /**
1116
+ * Per-open history, capped at the most recent `MAX_TRACKED_OPENS`. Exists so
1117
+ * the bot filter (`hasOpenedExcludingBots`) has something to filter on —
1118
+ * `openCount` alone cannot distinguish a scanner from a reader.
1119
+ *
1120
+ * `userAgent` is null when the requesting client sent no `User-Agent`, which
1121
+ * is common for image fetches; see `predicate.ts` for how unknown is scored.
1122
+ * Absent entirely on sends recorded before the field existed.
1123
+ */
1124
+ opens?: Array<{
1125
+ openedAt: Date;
1126
+ userAgent: string | null;
1127
+ }>;
1020
1128
  firstClickAt: Date | null;
1021
1129
  clickCount: number;
1022
1130
  /** History of actual clicks (a linkId may appear multiple times). */
@@ -1024,6 +1132,7 @@ interface SendDoc {
1024
1132
  url: string;
1025
1133
  linkId: string;
1026
1134
  clickedAt: Date;
1135
+ userAgent?: string | null;
1027
1136
  }>;
1028
1137
  unsubscribedAt: Date | null;
1029
1138
  complainedAt: Date | null;
@@ -1442,6 +1551,10 @@ declare class Mailer {
1442
1551
  * MAILER_FROM_NAME / MAILER_FROM_EMAIL
1443
1552
  * MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
1444
1553
  * MAILER_SENDGRID_API_KEY / MAILER_SENDGRID_WEBHOOK_KEY
1554
+ * MAILER_SENDGRID_WEBHOOK_TOLERANCE — signed-webhook replay window, seconds
1555
+ * (default 300; '0' disables the check).
1556
+ * Unparseable values fall back to the
1557
+ * default rather than disabling it.
1445
1558
  * MAILER_HOST_USERS_COLLECTION (default 'users')
1446
1559
  * MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
1447
1560
  * MAILER_HOST_USERS_ID_FIELD (default '_id')
@@ -1457,6 +1570,11 @@ declare class Mailer {
1457
1570
  fire(eventName: string, externalId: string, properties?: Record<string, unknown>, dedupeKey?: string): Promise<void>;
1458
1571
  fireFromSession(session: ClientSession, eventName: string, externalId: string, properties?: Record<string, unknown>, dedupeKey?: string): Promise<void>;
1459
1572
  upsertSubscription(input: UpsertSubscriptionInput): Promise<void>;
1573
+ /**
1574
+ * The writes live in `server/unsubscribe.ts` so the pending-unsubscribe
1575
+ * drain (INVARIANT 8) replays a journaled opt-out through exactly this path
1576
+ * without holding a `Mailer` instance.
1577
+ */
1460
1578
  unsubscribe(email: string, opts: Omit<UnsubscribeInput, 'email'>): Promise<void>;
1461
1579
  suppress(email: string, opts: Omit<SuppressInput, 'email'>): Promise<void>;
1462
1580
  tag(externalId: string, tag: string): Promise<void>;
@@ -1533,9 +1651,21 @@ declare class NullProvider implements MailProvider {
1533
1651
  readonly sendRatePerSecond = 1000;
1534
1652
  readonly sent: SendArgs[];
1535
1653
  send(args: SendArgs): Promise<SendResult>;
1654
+ /**
1655
+ * Fails closed. This provider has no signing key, so it can never establish
1656
+ * that an inbound payload is authentic — and a method whose entire job is to
1657
+ * reject unauthenticated input must not answer "yes" by default. Returning
1658
+ * `true` here would mean any unsigned body posted to the webhook route is
1659
+ * accepted as genuine whenever the null provider is registered, which is
1660
+ * exactly the dev/staging configuration where that is easiest to miss.
1661
+ *
1662
+ * Nothing is lost: `parseWebhookEvents()` returns `[]`, so there was never
1663
+ * any inbound behaviour to preserve. To exercise the inbound half locally,
1664
+ * use a provider that actually signs (see `test/README.md`).
1665
+ */
1536
1666
  verifyWebhook(): Promise<boolean>;
1537
1667
  parseWebhookEvents(): NormalizedEvent[];
1538
1668
  reset(): void;
1539
1669
  }
1540
1670
 
1541
- export { getCollections as $, type AdapterFilter as A, type BroadcastDoc as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainValidation as G, type HealthDoc as H, type SubscriptionDoc as I, type SubscriptionStatus as J, type SuppressionDoc as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionReason as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type TemplateKind as U, type TemplateVersionDoc as V, type VarsAdapter as W, type VarsResolveInfo as X, type WebhookEventDoc as Y, defineVars as Z, ensureIndexes as _, type Contact as a, validateSenderDomain as a0, varsJsonSchema as a1, type FlowTrigger as a2, type QueueDriverConfig as a3, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type SuppressionScope as e, type SegmentFilter as f, type AuditLogDoc as g, type BroadcastStatus as h, type CircuitBreakerThresholds as i, type Collections as j, type ContactTagDoc as k, type FlowDoc as l, type FlowGoal as m, type FlowRunDoc as n, type FlowRunStatus as o, type FlowVersionDoc as p, type HealthStatus as q, type MailerConfig as r, NullProvider as s, RESERVED_VAR_KEYS as t, type RedisOptions as u, type SegmentDefinition as v, type SendDoc as w, type SendStatus as x, type SenderDomainConfig as y, type SenderDomainRegistry as z };
1671
+ export { ensureIndexes as $, type AdapterFilter as A, type BotFilterConfig as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainRegistry as G, type HealthDoc as H, type SenderDomainValidation as I, type SubscriptionDoc as J, type SubscriptionStatus as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionDoc as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type SuppressionReason as U, type TemplateKind as V, type TemplateVersionDoc as W, type VarsAdapter as X, type VarsResolveInfo as Y, type WebhookEventDoc as Z, defineVars as _, type Contact as a, getCollections as a0, validateSenderDomain as a1, varsJsonSchema as a2, type FlowTrigger as a3, type QueueDriverConfig as a4, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type Collections as e, type FlowDoc as f, type SuppressionScope as g, type SegmentFilter as h, type AuditLogDoc as i, type BroadcastDoc as j, type BroadcastStatus as k, type CircuitBreakerThresholds as l, type ContactTagDoc as m, type FlowGoal as n, type FlowRunDoc as o, type FlowRunStatus as p, type FlowVersionDoc as q, type HealthStatus as r, type MailerConfig as s, NullProvider as t, RESERVED_VAR_KEYS as u, type RedisOptions as v, type SegmentDefinition as w, type SendDoc as x, type SendStatus as y, type SenderDomainConfig as z };
@@ -661,6 +661,30 @@ interface CircuitBreakerThresholds {
661
661
  windowMinutes: number;
662
662
  minSendsBeforeEval: number;
663
663
  }
664
+ interface BotFilterConfig {
665
+ /**
666
+ * User agents matching this pattern are treated as automated. Applied to
667
+ * recorded open and click user agents alike.
668
+ *
669
+ * Default: `/Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i`
670
+ * (`DEFAULT_BOT_UA_RE`). Replace it to cover your own corporate scanners;
671
+ * extend rather than replace if you want to keep the built-ins.
672
+ */
673
+ userAgentPattern?: RegExp;
674
+ /**
675
+ * Opens recorded within this many milliseconds of the send being queued are
676
+ * treated as automated regardless of user agent — corporate scanners and
677
+ * gateway prefetchers fetch the pixel within seconds of delivery, and a
678
+ * human cannot.
679
+ *
680
+ * Default `0` — disabled. It is off by default because it is a heuristic
681
+ * with a real false-positive tail (a recipient already reading their inbox
682
+ * when the mail lands) and because enabling it silently changes the counts
683
+ * every existing flow branches on. `10000` (10s) is the value worth trying
684
+ * first if your open counts look implausible.
685
+ */
686
+ minOpenDelayMs?: number;
687
+ }
664
688
  interface MailerConfig {
665
689
  db: Db;
666
690
  collectionPrefix?: string;
@@ -706,6 +730,40 @@ interface MailerConfig {
706
730
  requireDoubleOptIn?: boolean;
707
731
  unsubscribeTokenLifetimeDays?: number;
708
732
  transactionalRespectUnsubscribe?: boolean;
733
+ /**
734
+ * Absolute path to the pending-unsubscribe journal (INVARIANT 8).
735
+ *
736
+ * When `POST /m/unsub/:token` cannot reach Mongo, the opt-out is appended
737
+ * here as JSONL and replayed by the tick drain
738
+ * (`drainPendingUnsubscribes`). The file is created 0600 inside a 0700
739
+ * directory and opened `O_NOFOLLOW`.
740
+ *
741
+ * **There is no default, and that is deliberate.** The journal holds
742
+ * recipient email addresses in plaintext outside the database, and a library
743
+ * mounted into an unknown host cannot pick a directory that is at once
744
+ * writable, durable and private — which is how the previous default ended up
745
+ * being `/tmp` (world-writable, wiped on reboot, and never read back).
746
+ *
747
+ * With this unset the endpoint answers **503** when Mongo is unreachable,
748
+ * rather than promising an unsubscribe it has nowhere to record. Set it to a
749
+ * durable, private, node-local path to get the journal instead — e.g.
750
+ * `/var/lib/mailery/pending-unsubs.jsonl` (systemd: `StateDirectory=mailery`).
751
+ *
752
+ * Must not be on a shared network filesystem: claiming a batch relies on
753
+ * `rename` being atomic within the directory.
754
+ */
755
+ pendingUnsubsPath?: string;
756
+ /**
757
+ * How long `POST /m/unsub/:token` waits for the Mongo write before falling
758
+ * back to the journal. Default 5000ms.
759
+ *
760
+ * The tension is INVARIANT 8's own: the response must be fast (clients are
761
+ * impatient, providers retry) *and* must not claim an unsubscribe that was
762
+ * never recorded. A healthy write is sub-millisecond, so this budget is only
763
+ * ever spent during an outage, and spending it is what makes the difference
764
+ * between a truthful 200 and a lie.
765
+ */
766
+ unsubscribeWriteTimeoutMs?: number;
709
767
  /** Slug of the transactional template sent for DOI confirmation. Defaults to 'doi-confirmation'. */
710
768
  doiTemplateSlug?: string;
711
769
  /** How long the DOI token stays valid. Default 7 days. */
@@ -740,10 +798,18 @@ interface MailerConfig {
740
798
  mailTester?: MailTesterConfig;
741
799
  /**
742
800
  * DMARC RUA aggregate reports tell you who is sending mail that claims to
743
- * be from your domain — both legitimate sources and spoofers. Operator
744
- * uploads the report attachments (or POSTs them via SendGrid Inbound Parse
745
- * to /admin/mailer/api/dmarc/upload). Mailery decompresses, parses, and
746
- * surfaces alignment failures + unknown senders.
801
+ * be from your domain — both legitimate sources and spoofers. Mailery
802
+ * decompresses, parses, and surfaces alignment failures + unknown senders.
803
+ *
804
+ * Two ways in:
805
+ * - the operator uploads attachments in the admin UI
806
+ * (`POST /admin/mailer/api/dmarc/upload`, behind your admin guard)
807
+ * - the inbound webhook, for SendGrid Inbound Parse and friends:
808
+ * `createPublicRouter(mailer, { dmarcInbound: { secret } })`. Off unless
809
+ * that secret is set. Do **not** point Inbound Parse at the admin
810
+ * upload route — it sits behind the host's `requireAdmin` guard, which
811
+ * Inbound Parse cannot satisfy. (Versions before 0.15 recommended
812
+ * exactly that, and it never worked.)
747
813
  */
748
814
  dmarc?: DmarcConfig;
749
815
  broadcastConfirmationThreshold?: number;
@@ -761,6 +827,35 @@ interface MailerConfig {
761
827
  trackClicks?: boolean;
762
828
  storeTrackingIp?: boolean;
763
829
  storeRenderedBody?: boolean;
830
+ /**
831
+ * Reject `/m/open` and `/m/click` hits that carry no signature.
832
+ *
833
+ * Default `false` — grace mode. Newly rendered mail is always signed; this
834
+ * flag only decides what happens to the *unsigned* URLs sitting in mail that
835
+ * was already delivered. In grace mode they are still counted and each one is
836
+ * logged (`mailery: unsigned tracking URL accepted`) so the operator can watch
837
+ * the legacy rate fall to zero and then flip this to `true`.
838
+ *
839
+ * A signature that is *present but wrong* is always rejected, in both modes.
840
+ */
841
+ requireSignedTrackingUrls?: boolean;
842
+ /**
843
+ * How long a tracking URL stays valid, in days, measured from the send's
844
+ * `queuedAt`. Default `0` — never expires.
845
+ *
846
+ * Never-expire is the right default: an email sits in an inbox for years and
847
+ * a legitimate open six months later is real data, not an attack. The
848
+ * signature already proves the caller was handed the URL. Operators with a
849
+ * data-retention policy can set a window; because the deadline is derived
850
+ * from the send row rather than baked into the token, changing this value
851
+ * takes effect immediately for already-delivered mail and costs no URL bytes.
852
+ */
853
+ trackingUrlLifetimeDays?: number;
854
+ /**
855
+ * Tuning for `hasOpenedExcludingBots` / `hasClickedExcludingBots` /
856
+ * `openedAtLeastN` / `clickedAtLeastN`. See `BotFilterConfig`.
857
+ */
858
+ botFilter?: BotFilterConfig;
764
859
  getAdminActor?: (req: any) => string;
765
860
  onCircuitBreakerTrip?: (info: {
766
861
  reason: string;
@@ -772,7 +867,7 @@ interface MailerConfig {
772
867
  }) => Promise<void> | void;
773
868
  handlebarsHelpers?: Record<string, Handlebars.HelperDelegate>;
774
869
  }
775
- type ResolvedConfig = Required<Pick<MailerConfig, 'collectionPrefix' | 'requireDoubleOptIn' | 'unsubscribeTokenLifetimeDays' | 'transactionalRespectUnsubscribe' | 'doiTemplateSlug' | 'doiTokenLifetimeDays' | 'broadcastConfirmationThreshold' | 'broadcastEnqueueBatchSize' | 'broadcastEnqueueMaxWaiting' | 'workerless' | 'tickIntervalSeconds' | 'sendConcurrency' | 'sendRatePerSecond' | 'softBouncePromotionThreshold' | 'softBouncePromotionWindowDays' | 'webhookRetryAttempts' | 'sendRetryAttempts' | 'trackOpens' | 'trackClicks' | 'storeTrackingIp' | 'storeRenderedBody'>> & {
870
+ type ResolvedConfig = Required<Pick<MailerConfig, 'collectionPrefix' | 'requireDoubleOptIn' | 'unsubscribeTokenLifetimeDays' | 'transactionalRespectUnsubscribe' | 'unsubscribeWriteTimeoutMs' | 'doiTemplateSlug' | 'doiTokenLifetimeDays' | 'broadcastConfirmationThreshold' | 'broadcastEnqueueBatchSize' | 'broadcastEnqueueMaxWaiting' | 'workerless' | 'tickIntervalSeconds' | 'sendConcurrency' | 'sendRatePerSecond' | 'softBouncePromotionThreshold' | 'softBouncePromotionWindowDays' | 'webhookRetryAttempts' | 'sendRetryAttempts' | 'trackOpens' | 'trackClicks' | 'storeTrackingIp' | 'storeRenderedBody' | 'requireSignedTrackingUrls' | 'trackingUrlLifetimeDays'>> & {
776
871
  circuitBreaker: CircuitBreakerThresholds;
777
872
  } & MailerConfig;
778
873
 
@@ -1017,6 +1112,19 @@ interface SendDoc {
1017
1112
  vars: Record<string, unknown>;
1018
1113
  openedAt: Date | null;
1019
1114
  openCount: number;
1115
+ /**
1116
+ * Per-open history, capped at the most recent `MAX_TRACKED_OPENS`. Exists so
1117
+ * the bot filter (`hasOpenedExcludingBots`) has something to filter on —
1118
+ * `openCount` alone cannot distinguish a scanner from a reader.
1119
+ *
1120
+ * `userAgent` is null when the requesting client sent no `User-Agent`, which
1121
+ * is common for image fetches; see `predicate.ts` for how unknown is scored.
1122
+ * Absent entirely on sends recorded before the field existed.
1123
+ */
1124
+ opens?: Array<{
1125
+ openedAt: Date;
1126
+ userAgent: string | null;
1127
+ }>;
1020
1128
  firstClickAt: Date | null;
1021
1129
  clickCount: number;
1022
1130
  /** History of actual clicks (a linkId may appear multiple times). */
@@ -1024,6 +1132,7 @@ interface SendDoc {
1024
1132
  url: string;
1025
1133
  linkId: string;
1026
1134
  clickedAt: Date;
1135
+ userAgent?: string | null;
1027
1136
  }>;
1028
1137
  unsubscribedAt: Date | null;
1029
1138
  complainedAt: Date | null;
@@ -1442,6 +1551,10 @@ declare class Mailer {
1442
1551
  * MAILER_FROM_NAME / MAILER_FROM_EMAIL
1443
1552
  * MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
1444
1553
  * MAILER_SENDGRID_API_KEY / MAILER_SENDGRID_WEBHOOK_KEY
1554
+ * MAILER_SENDGRID_WEBHOOK_TOLERANCE — signed-webhook replay window, seconds
1555
+ * (default 300; '0' disables the check).
1556
+ * Unparseable values fall back to the
1557
+ * default rather than disabling it.
1445
1558
  * MAILER_HOST_USERS_COLLECTION (default 'users')
1446
1559
  * MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
1447
1560
  * MAILER_HOST_USERS_ID_FIELD (default '_id')
@@ -1457,6 +1570,11 @@ declare class Mailer {
1457
1570
  fire(eventName: string, externalId: string, properties?: Record<string, unknown>, dedupeKey?: string): Promise<void>;
1458
1571
  fireFromSession(session: ClientSession, eventName: string, externalId: string, properties?: Record<string, unknown>, dedupeKey?: string): Promise<void>;
1459
1572
  upsertSubscription(input: UpsertSubscriptionInput): Promise<void>;
1573
+ /**
1574
+ * The writes live in `server/unsubscribe.ts` so the pending-unsubscribe
1575
+ * drain (INVARIANT 8) replays a journaled opt-out through exactly this path
1576
+ * without holding a `Mailer` instance.
1577
+ */
1460
1578
  unsubscribe(email: string, opts: Omit<UnsubscribeInput, 'email'>): Promise<void>;
1461
1579
  suppress(email: string, opts: Omit<SuppressInput, 'email'>): Promise<void>;
1462
1580
  tag(externalId: string, tag: string): Promise<void>;
@@ -1533,9 +1651,21 @@ declare class NullProvider implements MailProvider {
1533
1651
  readonly sendRatePerSecond = 1000;
1534
1652
  readonly sent: SendArgs[];
1535
1653
  send(args: SendArgs): Promise<SendResult>;
1654
+ /**
1655
+ * Fails closed. This provider has no signing key, so it can never establish
1656
+ * that an inbound payload is authentic — and a method whose entire job is to
1657
+ * reject unauthenticated input must not answer "yes" by default. Returning
1658
+ * `true` here would mean any unsigned body posted to the webhook route is
1659
+ * accepted as genuine whenever the null provider is registered, which is
1660
+ * exactly the dev/staging configuration where that is easiest to miss.
1661
+ *
1662
+ * Nothing is lost: `parseWebhookEvents()` returns `[]`, so there was never
1663
+ * any inbound behaviour to preserve. To exercise the inbound half locally,
1664
+ * use a provider that actually signs (see `test/README.md`).
1665
+ */
1536
1666
  verifyWebhook(): Promise<boolean>;
1537
1667
  parseWebhookEvents(): NormalizedEvent[];
1538
1668
  reset(): void;
1539
1669
  }
1540
1670
 
1541
- export { getCollections as $, type AdapterFilter as A, type BroadcastDoc as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainValidation as G, type HealthDoc as H, type SubscriptionDoc as I, type SubscriptionStatus as J, type SuppressionDoc as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionReason as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type TemplateKind as U, type TemplateVersionDoc as V, type VarsAdapter as W, type VarsResolveInfo as X, type WebhookEventDoc as Y, defineVars as Z, ensureIndexes as _, type Contact as a, validateSenderDomain as a0, varsJsonSchema as a1, type FlowTrigger as a2, type QueueDriverConfig as a3, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type SuppressionScope as e, type SegmentFilter as f, type AuditLogDoc as g, type BroadcastStatus as h, type CircuitBreakerThresholds as i, type Collections as j, type ContactTagDoc as k, type FlowDoc as l, type FlowGoal as m, type FlowRunDoc as n, type FlowRunStatus as o, type FlowVersionDoc as p, type HealthStatus as q, type MailerConfig as r, NullProvider as s, RESERVED_VAR_KEYS as t, type RedisOptions as u, type SegmentDefinition as v, type SendDoc as w, type SendStatus as x, type SenderDomainConfig as y, type SenderDomainRegistry as z };
1671
+ export { ensureIndexes as $, type AdapterFilter as A, type BotFilterConfig as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainRegistry as G, type HealthDoc as H, type SenderDomainValidation as I, type SubscriptionDoc as J, type SubscriptionStatus as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionDoc as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type SuppressionReason as U, type TemplateKind as V, type TemplateVersionDoc as W, type VarsAdapter as X, type VarsResolveInfo as Y, type WebhookEventDoc as Z, defineVars as _, type Contact as a, getCollections as a0, validateSenderDomain as a1, varsJsonSchema as a2, type FlowTrigger as a3, type QueueDriverConfig as a4, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type Collections as e, type FlowDoc as f, type SuppressionScope as g, type SegmentFilter as h, type AuditLogDoc as i, type BroadcastDoc as j, type BroadcastStatus as k, type CircuitBreakerThresholds as l, type ContactTagDoc as m, type FlowGoal as n, type FlowRunDoc as o, type FlowRunStatus as p, type FlowVersionDoc as q, type HealthStatus as r, type MailerConfig as s, NullProvider as t, RESERVED_VAR_KEYS as u, type RedisOptions as v, type SegmentDefinition as w, type SendDoc as x, type SendStatus as y, type SenderDomainConfig as z };