mailery 0.17.1 → 0.19.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.
@@ -32,17 +32,41 @@ interface AdapterFilter {
32
32
  createdAfter?: Date;
33
33
  createdBefore?: Date;
34
34
  }
35
+ /**
36
+ * Order for `ContactAdapter.query`. `field` names a field on the host's
37
+ * contact record (for MongoContactAdapter, a document path such as
38
+ * `updatedAt`), not a key of the `Contact` projection. Ties break on the
39
+ * contact id, ascending, so the order is total and pagination is stable.
40
+ */
41
+ interface AdapterSort {
42
+ field: string;
43
+ direction: 'asc' | 'desc';
44
+ }
35
45
  interface ContactAdapter {
36
46
  getById(externalId: string): Promise<Contact | null>;
37
47
  getByEmail(email: string): Promise<Contact | null>;
38
48
  getBatch(externalIds: string[]): Promise<Map<string, Contact>>;
49
+ /**
50
+ * One page of contacts matching `filter`. Without `opts.sort` the order is
51
+ * the adapter's own and must be stable across calls (MongoContactAdapter:
52
+ * by id). `opts.sort` is honoured only by adapters that declare
53
+ * `supportsSort: true`; mailery never passes it to one that does not.
54
+ */
39
55
  query(filter: AdapterFilter, opts: {
40
56
  limit: number;
41
57
  cursor?: string;
58
+ sort?: AdapterSort;
42
59
  }): Promise<{
43
60
  contacts: Contact[];
44
61
  nextCursor?: string;
45
62
  }>;
63
+ /**
64
+ * True when `query` honours `opts.sort` (with an opaque cursor that
65
+ * encodes the sort position). Optional: adapters written before 0.18 omit
66
+ * it, and a broadcast with an `order` is then refused at create time
67
+ * rather than silently sent in id order.
68
+ */
69
+ readonly supportsSort?: boolean;
46
70
  count(filter: AdapterFilter): Promise<number>;
47
71
  addTags?(externalId: string, tags: string[]): Promise<void>;
48
72
  removeTags?(externalId: string, tags: string[]): Promise<void>;
@@ -448,7 +472,13 @@ type QueueDriverConfig = {
448
472
  * server-shaped interfaces.
449
473
  */
450
474
  type SubscriptionStatus = 'subscribed' | 'pending_doi' | 'unsubscribed' | 'bounced' | 'complained';
451
- type SendStatus = 'queued' | 'sending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'cancelled';
475
+ type SendStatus = 'queued' | 'sending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'cancelled'
476
+ /**
477
+ * A broadcast send parked because its broadcast is paused (stop rule,
478
+ * circuit breaker, operator). Never dispatched while held; resuming the
479
+ * broadcast re-queues it.
480
+ */
481
+ | 'held';
452
482
  type TemplateKind = 'transactional' | 'marketing';
453
483
  /**
454
484
  * How a template's body goes on the wire.
@@ -466,7 +496,12 @@ type TemplateBodyFormat = 'multipart' | 'text_only';
466
496
  type SuppressionScope = 'all' | 'marketing' | 'transactional';
467
497
  type SuppressionReason = 'unsubscribed' | 'hard_bounce' | 'complaint' | 'manual' | 'list_cleaning' | 'gdpr_forget';
468
498
  type FlowRunStatus = 'active' | 'completed' | 'exited' | 'failed';
469
- type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled' | 'failed';
499
+ /**
500
+ * `paused` — dispatch stopped with the broadcast re-openable: a wave reached
501
+ * its `recipientCap`, a stop rule or the circuit breaker fired, or an
502
+ * operator paused it. `pauseReason` says which; `resume` re-opens it.
503
+ */
504
+ type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'paused' | 'sent' | 'cancelled' | 'failed';
470
505
  type HealthStatus = 'healthy' | 'degraded' | 'tripped';
471
506
  type FlowGoal = 'activation' | 'conversion' | 'retention' | 'reactivation' | 'transactional' | 'broadcast';
472
507
 
@@ -588,6 +623,15 @@ interface DnsblConfig {
588
623
  dedicatedIps?: string[];
589
624
  /** Hours between automatic runs. Default 24. Set to 0 to disable scheduled runs. */
590
625
  intervalHours?: number;
626
+ /**
627
+ * Spamhaus Data Query Service key (free at spamhaus.com for low volume).
628
+ * Spamhaus refuses queries that arrive through public or shared resolvers —
629
+ * Google, Cloudflare, and cloud VPC resolvers such as AWS's — answering
630
+ * 127.255.255.254 instead of a verdict. With a key, every `*.spamhaus.org`
631
+ * list is queried as `<key>.<zone>.dq.spamhaus.net`, which answers from
632
+ * anywhere. Rows and the UI keep the public list name; the key is never stored.
633
+ */
634
+ spamhausDqsKey?: string;
591
635
  }
592
636
  interface MailTesterConfig {
593
637
  /** API key from your Mail-Tester paid plan. */
@@ -678,6 +722,25 @@ interface CircuitBreakerThresholds {
678
722
  windowMinutes: number;
679
723
  minSendsBeforeEval: number;
680
724
  }
725
+ /**
726
+ * Automatic stop rules for one broadcast. A breach pauses the broadcast
727
+ * (`pauseReason.code: 'stop_rule'`): no more enqueues, and its queued sends
728
+ * are held until an explicit resume. Rates are measured over the
729
+ * broadcast's sends with a known outcome (delivered or bounced) and only
730
+ * once `minSample` of them are in. A breach is `rate > threshold`.
731
+ */
732
+ interface BroadcastStopRules {
733
+ /** Default true. */
734
+ enabled: boolean;
735
+ /** Hard bounces, % of outcomes. Default 2. */
736
+ hardBounceRatePct: number;
737
+ /** Complaints (spam reports), % of outcomes. Default 0.1. */
738
+ complaintRatePct: number;
739
+ /** Unsubscribes attributed to the broadcast, % of outcomes. Default 1. */
740
+ unsubscribeRatePct: number;
741
+ /** Outcomes required before any rule is evaluated. Default 100. */
742
+ minSample: number;
743
+ }
681
744
  interface BotFilterConfig {
682
745
  /**
683
746
  * User agents matching this pattern are treated as automated. Applied to
@@ -840,6 +903,12 @@ interface MailerConfig {
840
903
  broadcastConfirmationThreshold?: number;
841
904
  broadcastEnqueueBatchSize?: number;
842
905
  broadcastEnqueueMaxWaiting?: number;
906
+ /**
907
+ * Defaults for every broadcast's automatic stop rules; a broadcast can
908
+ * override any of them (`stopRules` on create/patch/resume). See
909
+ * `BroadcastStopRules`.
910
+ */
911
+ broadcastStopRules?: Partial<BroadcastStopRules>;
843
912
  workerless?: boolean;
844
913
  tickIntervalSeconds?: number;
845
914
  sendConcurrency?: number;
@@ -884,16 +953,33 @@ interface MailerConfig {
884
953
  getAdminActor?: (req: any) => string;
885
954
  onCircuitBreakerTrip?: (info: {
886
955
  reason: string;
887
- rates: Record<string, number>;
956
+ rates: Record<string, number | null>;
888
957
  }) => Promise<void> | void;
889
958
  onSendFailure?: (info: {
890
959
  send: any;
891
960
  error: Error;
892
961
  }) => Promise<void> | void;
962
+ /**
963
+ * Called when a broadcast is paused by a stop rule, the circuit breaker, a
964
+ * reached cap, or an operator. Alerting belongs here: a stop-rule pause
965
+ * waits for a human.
966
+ */
967
+ onBroadcastPaused?: (info: {
968
+ broadcastId: string;
969
+ slug: string;
970
+ reason: {
971
+ code: string;
972
+ message: string;
973
+ at: Date;
974
+ details?: Record<string, unknown>;
975
+ };
976
+ heldSends: number;
977
+ }) => Promise<void> | void;
893
978
  handlebarsHelpers?: Record<string, Handlebars.HelperDelegate>;
894
979
  }
895
980
  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'>> & {
896
981
  circuitBreaker: CircuitBreakerThresholds;
982
+ broadcastStopRules: BroadcastStopRules;
897
983
  } & MailerConfig;
898
984
 
899
985
  /**
@@ -1168,6 +1254,12 @@ interface SendDoc {
1168
1254
  updatedAt: Date;
1169
1255
  sentAt: Date | null;
1170
1256
  deliveredAt: Date | null;
1257
+ /**
1258
+ * Broadcast sends: the moment the send was meant to go (scheduledAt, or
1259
+ * the recipient-local slot). A held send is re-queued with the delay that
1260
+ * remains until then.
1261
+ */
1262
+ notBefore?: Date | null;
1171
1263
  }
1172
1264
  interface SuppressionDoc {
1173
1265
  _id?: ObjectId;
@@ -1197,6 +1289,43 @@ interface BroadcastDoc {
1197
1289
  recipientCount: number | null;
1198
1290
  /** When true, dispatch fires each recipient at their local-timezone equivalent of `scheduledAt`. */
1199
1291
  respectRecipientTimezone?: boolean;
1292
+ /**
1293
+ * Native waves: the most send rows this broadcast may have. Dispatch stops
1294
+ * enqueueing when the broadcast's send rows (every status, earlier waves
1295
+ * included) reach it, and parks the broadcast in `paused` with
1296
+ * `pauseReason.code === 'cap_reached'` if eligible recipients remain.
1297
+ * Raising it and resuming sends the next slice. Null or absent: no cap.
1298
+ */
1299
+ recipientCap?: number | null;
1300
+ /** Order recipients are taken in (host-side sort). Null or absent: the adapter's own order. */
1301
+ order?: BroadcastOrder | null;
1302
+ /** Set while `status === 'paused'`. */
1303
+ pausedAt?: Date | null;
1304
+ pauseReason?: BroadcastPauseReason | null;
1305
+ /** Lease held by the one dispatcher allowed to enqueue for this broadcast. */
1306
+ dispatchLeaseId?: string | null;
1307
+ /** Bumped on every (re)start of dispatch; part of the dispatch job id. */
1308
+ dispatchGeneration?: number;
1309
+ /** Why dispatch set status 'failed'. */
1310
+ failureReason?: string | null;
1311
+ /** Per-broadcast overrides of `MailerConfig.broadcastStopRules`. */
1312
+ stopRules?: Partial<{
1313
+ enabled: boolean;
1314
+ hardBounceRatePct: number;
1315
+ complaintRatePct: number;
1316
+ unsubscribeRatePct: number;
1317
+ minSample: number;
1318
+ }> | null;
1319
+ /**
1320
+ * First stop-rule breach seen after the broadcast had nothing left to
1321
+ * hold (status 'sent', no queued sends) — recorded, since there was
1322
+ * nothing to pause.
1323
+ */
1324
+ stopRuleBreach?: {
1325
+ at: Date;
1326
+ sample: number;
1327
+ breaches: StopRuleBreach[];
1328
+ } | null;
1200
1329
  stats: {
1201
1330
  sent: number;
1202
1331
  delivered: number;
@@ -1210,6 +1339,29 @@ interface BroadcastDoc {
1210
1339
  createdBy: string;
1211
1340
  updatedAt: Date;
1212
1341
  }
1342
+ interface BroadcastOrder {
1343
+ /** A host field, e.g. `updatedAt` for "most recently active first" with `direction: 'desc'`. */
1344
+ field: string;
1345
+ direction: 'asc' | 'desc';
1346
+ }
1347
+ interface StopRuleBreach {
1348
+ rule: 'hardBounceRatePct' | 'complaintRatePct' | 'unsubscribeRatePct';
1349
+ count: number;
1350
+ ratePct: number;
1351
+ thresholdPct: number;
1352
+ }
1353
+ interface BroadcastPauseReason {
1354
+ /**
1355
+ * cap_reached — the wave's recipientCap is spent; raise it and resume.
1356
+ * stop_rule — a per-broadcast bounce/complaint/unsubscribe threshold was crossed.
1357
+ * circuit_breaker — the sender domain's marketing circuit breaker tripped.
1358
+ * manual — paused by an operator.
1359
+ */
1360
+ code: 'cap_reached' | 'stop_rule' | 'circuit_breaker' | 'manual';
1361
+ message: string;
1362
+ at: Date;
1363
+ details?: Record<string, unknown>;
1364
+ }
1213
1365
  interface OutboxDoc {
1214
1366
  _id?: ObjectId;
1215
1367
  payload: {
@@ -1281,11 +1433,15 @@ interface HealthDoc {
1281
1433
  complained: number;
1282
1434
  failedToSend: number;
1283
1435
  };
1436
+ /**
1437
+ * Bounce/complaint rates are per send in the window; failureRate is failed
1438
+ * attempts over all attempts. Null when the window has no denominator yet.
1439
+ */
1284
1440
  rates: {
1285
- bounceRate: number;
1286
- hardBounceRate: number;
1287
- complaintRate: number;
1288
- failureRate: number;
1441
+ bounceRate: number | null;
1442
+ hardBounceRate: number | null;
1443
+ complaintRate: number | null;
1444
+ failureRate: number | null;
1289
1445
  };
1290
1446
  status: HealthStatus;
1291
1447
  trippedAt: Date | null;
@@ -1713,4 +1869,4 @@ declare class NullProvider implements MailProvider {
1713
1869
  reset(): void;
1714
1870
  }
1715
1871
 
1716
- 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 };
1872
+ export { type VarsResolveInfo 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 SendDoc as G, type HealthDoc as H, type SendStatus as I, type SenderDomainConfig as J, type SenderDomainRegistry as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SenderDomainValidation as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type SubscriptionDoc as U, type SubscriptionStatus as V, type SuppressionDoc as W, type SuppressionReason as X, type TemplateKind as Y, type TemplateVersionDoc as Z, type VarsAdapter as _, type Contact as a, type WebhookEventDoc as a0, defineVars as a1, ensureIndexes as a2, getCollections as a3, validateSenderDomain as a4, varsJsonSchema as a5, type FlowTrigger as a6, type QueueDriverConfig as a7, type AdapterSort as b, type SendResult as c, type MailTesterFeedback as d, Mailer as e, type Collections as f, type FlowDoc as g, type SuppressionScope as h, type SegmentFilter as i, type AuditLogDoc as j, type BroadcastDoc as k, type BroadcastOrder as l, type BroadcastPauseReason as m, type BroadcastStatus as n, type CircuitBreakerThresholds as o, type ContactTagDoc as p, type FlowGoal as q, type FlowRunDoc as r, type FlowRunStatus as s, type FlowVersionDoc as t, type HealthStatus as u, type MailerConfig as v, NullProvider as w, RESERVED_VAR_KEYS as x, type RedisOptions as y, type SegmentDefinition as z };
@@ -32,17 +32,41 @@ interface AdapterFilter {
32
32
  createdAfter?: Date;
33
33
  createdBefore?: Date;
34
34
  }
35
+ /**
36
+ * Order for `ContactAdapter.query`. `field` names a field on the host's
37
+ * contact record (for MongoContactAdapter, a document path such as
38
+ * `updatedAt`), not a key of the `Contact` projection. Ties break on the
39
+ * contact id, ascending, so the order is total and pagination is stable.
40
+ */
41
+ interface AdapterSort {
42
+ field: string;
43
+ direction: 'asc' | 'desc';
44
+ }
35
45
  interface ContactAdapter {
36
46
  getById(externalId: string): Promise<Contact | null>;
37
47
  getByEmail(email: string): Promise<Contact | null>;
38
48
  getBatch(externalIds: string[]): Promise<Map<string, Contact>>;
49
+ /**
50
+ * One page of contacts matching `filter`. Without `opts.sort` the order is
51
+ * the adapter's own and must be stable across calls (MongoContactAdapter:
52
+ * by id). `opts.sort` is honoured only by adapters that declare
53
+ * `supportsSort: true`; mailery never passes it to one that does not.
54
+ */
39
55
  query(filter: AdapterFilter, opts: {
40
56
  limit: number;
41
57
  cursor?: string;
58
+ sort?: AdapterSort;
42
59
  }): Promise<{
43
60
  contacts: Contact[];
44
61
  nextCursor?: string;
45
62
  }>;
63
+ /**
64
+ * True when `query` honours `opts.sort` (with an opaque cursor that
65
+ * encodes the sort position). Optional: adapters written before 0.18 omit
66
+ * it, and a broadcast with an `order` is then refused at create time
67
+ * rather than silently sent in id order.
68
+ */
69
+ readonly supportsSort?: boolean;
46
70
  count(filter: AdapterFilter): Promise<number>;
47
71
  addTags?(externalId: string, tags: string[]): Promise<void>;
48
72
  removeTags?(externalId: string, tags: string[]): Promise<void>;
@@ -448,7 +472,13 @@ type QueueDriverConfig = {
448
472
  * server-shaped interfaces.
449
473
  */
450
474
  type SubscriptionStatus = 'subscribed' | 'pending_doi' | 'unsubscribed' | 'bounced' | 'complained';
451
- type SendStatus = 'queued' | 'sending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'cancelled';
475
+ type SendStatus = 'queued' | 'sending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'cancelled'
476
+ /**
477
+ * A broadcast send parked because its broadcast is paused (stop rule,
478
+ * circuit breaker, operator). Never dispatched while held; resuming the
479
+ * broadcast re-queues it.
480
+ */
481
+ | 'held';
452
482
  type TemplateKind = 'transactional' | 'marketing';
453
483
  /**
454
484
  * How a template's body goes on the wire.
@@ -466,7 +496,12 @@ type TemplateBodyFormat = 'multipart' | 'text_only';
466
496
  type SuppressionScope = 'all' | 'marketing' | 'transactional';
467
497
  type SuppressionReason = 'unsubscribed' | 'hard_bounce' | 'complaint' | 'manual' | 'list_cleaning' | 'gdpr_forget';
468
498
  type FlowRunStatus = 'active' | 'completed' | 'exited' | 'failed';
469
- type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled' | 'failed';
499
+ /**
500
+ * `paused` — dispatch stopped with the broadcast re-openable: a wave reached
501
+ * its `recipientCap`, a stop rule or the circuit breaker fired, or an
502
+ * operator paused it. `pauseReason` says which; `resume` re-opens it.
503
+ */
504
+ type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'paused' | 'sent' | 'cancelled' | 'failed';
470
505
  type HealthStatus = 'healthy' | 'degraded' | 'tripped';
471
506
  type FlowGoal = 'activation' | 'conversion' | 'retention' | 'reactivation' | 'transactional' | 'broadcast';
472
507
 
@@ -588,6 +623,15 @@ interface DnsblConfig {
588
623
  dedicatedIps?: string[];
589
624
  /** Hours between automatic runs. Default 24. Set to 0 to disable scheduled runs. */
590
625
  intervalHours?: number;
626
+ /**
627
+ * Spamhaus Data Query Service key (free at spamhaus.com for low volume).
628
+ * Spamhaus refuses queries that arrive through public or shared resolvers —
629
+ * Google, Cloudflare, and cloud VPC resolvers such as AWS's — answering
630
+ * 127.255.255.254 instead of a verdict. With a key, every `*.spamhaus.org`
631
+ * list is queried as `<key>.<zone>.dq.spamhaus.net`, which answers from
632
+ * anywhere. Rows and the UI keep the public list name; the key is never stored.
633
+ */
634
+ spamhausDqsKey?: string;
591
635
  }
592
636
  interface MailTesterConfig {
593
637
  /** API key from your Mail-Tester paid plan. */
@@ -678,6 +722,25 @@ interface CircuitBreakerThresholds {
678
722
  windowMinutes: number;
679
723
  minSendsBeforeEval: number;
680
724
  }
725
+ /**
726
+ * Automatic stop rules for one broadcast. A breach pauses the broadcast
727
+ * (`pauseReason.code: 'stop_rule'`): no more enqueues, and its queued sends
728
+ * are held until an explicit resume. Rates are measured over the
729
+ * broadcast's sends with a known outcome (delivered or bounced) and only
730
+ * once `minSample` of them are in. A breach is `rate > threshold`.
731
+ */
732
+ interface BroadcastStopRules {
733
+ /** Default true. */
734
+ enabled: boolean;
735
+ /** Hard bounces, % of outcomes. Default 2. */
736
+ hardBounceRatePct: number;
737
+ /** Complaints (spam reports), % of outcomes. Default 0.1. */
738
+ complaintRatePct: number;
739
+ /** Unsubscribes attributed to the broadcast, % of outcomes. Default 1. */
740
+ unsubscribeRatePct: number;
741
+ /** Outcomes required before any rule is evaluated. Default 100. */
742
+ minSample: number;
743
+ }
681
744
  interface BotFilterConfig {
682
745
  /**
683
746
  * User agents matching this pattern are treated as automated. Applied to
@@ -840,6 +903,12 @@ interface MailerConfig {
840
903
  broadcastConfirmationThreshold?: number;
841
904
  broadcastEnqueueBatchSize?: number;
842
905
  broadcastEnqueueMaxWaiting?: number;
906
+ /**
907
+ * Defaults for every broadcast's automatic stop rules; a broadcast can
908
+ * override any of them (`stopRules` on create/patch/resume). See
909
+ * `BroadcastStopRules`.
910
+ */
911
+ broadcastStopRules?: Partial<BroadcastStopRules>;
843
912
  workerless?: boolean;
844
913
  tickIntervalSeconds?: number;
845
914
  sendConcurrency?: number;
@@ -884,16 +953,33 @@ interface MailerConfig {
884
953
  getAdminActor?: (req: any) => string;
885
954
  onCircuitBreakerTrip?: (info: {
886
955
  reason: string;
887
- rates: Record<string, number>;
956
+ rates: Record<string, number | null>;
888
957
  }) => Promise<void> | void;
889
958
  onSendFailure?: (info: {
890
959
  send: any;
891
960
  error: Error;
892
961
  }) => Promise<void> | void;
962
+ /**
963
+ * Called when a broadcast is paused by a stop rule, the circuit breaker, a
964
+ * reached cap, or an operator. Alerting belongs here: a stop-rule pause
965
+ * waits for a human.
966
+ */
967
+ onBroadcastPaused?: (info: {
968
+ broadcastId: string;
969
+ slug: string;
970
+ reason: {
971
+ code: string;
972
+ message: string;
973
+ at: Date;
974
+ details?: Record<string, unknown>;
975
+ };
976
+ heldSends: number;
977
+ }) => Promise<void> | void;
893
978
  handlebarsHelpers?: Record<string, Handlebars.HelperDelegate>;
894
979
  }
895
980
  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'>> & {
896
981
  circuitBreaker: CircuitBreakerThresholds;
982
+ broadcastStopRules: BroadcastStopRules;
897
983
  } & MailerConfig;
898
984
 
899
985
  /**
@@ -1168,6 +1254,12 @@ interface SendDoc {
1168
1254
  updatedAt: Date;
1169
1255
  sentAt: Date | null;
1170
1256
  deliveredAt: Date | null;
1257
+ /**
1258
+ * Broadcast sends: the moment the send was meant to go (scheduledAt, or
1259
+ * the recipient-local slot). A held send is re-queued with the delay that
1260
+ * remains until then.
1261
+ */
1262
+ notBefore?: Date | null;
1171
1263
  }
1172
1264
  interface SuppressionDoc {
1173
1265
  _id?: ObjectId;
@@ -1197,6 +1289,43 @@ interface BroadcastDoc {
1197
1289
  recipientCount: number | null;
1198
1290
  /** When true, dispatch fires each recipient at their local-timezone equivalent of `scheduledAt`. */
1199
1291
  respectRecipientTimezone?: boolean;
1292
+ /**
1293
+ * Native waves: the most send rows this broadcast may have. Dispatch stops
1294
+ * enqueueing when the broadcast's send rows (every status, earlier waves
1295
+ * included) reach it, and parks the broadcast in `paused` with
1296
+ * `pauseReason.code === 'cap_reached'` if eligible recipients remain.
1297
+ * Raising it and resuming sends the next slice. Null or absent: no cap.
1298
+ */
1299
+ recipientCap?: number | null;
1300
+ /** Order recipients are taken in (host-side sort). Null or absent: the adapter's own order. */
1301
+ order?: BroadcastOrder | null;
1302
+ /** Set while `status === 'paused'`. */
1303
+ pausedAt?: Date | null;
1304
+ pauseReason?: BroadcastPauseReason | null;
1305
+ /** Lease held by the one dispatcher allowed to enqueue for this broadcast. */
1306
+ dispatchLeaseId?: string | null;
1307
+ /** Bumped on every (re)start of dispatch; part of the dispatch job id. */
1308
+ dispatchGeneration?: number;
1309
+ /** Why dispatch set status 'failed'. */
1310
+ failureReason?: string | null;
1311
+ /** Per-broadcast overrides of `MailerConfig.broadcastStopRules`. */
1312
+ stopRules?: Partial<{
1313
+ enabled: boolean;
1314
+ hardBounceRatePct: number;
1315
+ complaintRatePct: number;
1316
+ unsubscribeRatePct: number;
1317
+ minSample: number;
1318
+ }> | null;
1319
+ /**
1320
+ * First stop-rule breach seen after the broadcast had nothing left to
1321
+ * hold (status 'sent', no queued sends) — recorded, since there was
1322
+ * nothing to pause.
1323
+ */
1324
+ stopRuleBreach?: {
1325
+ at: Date;
1326
+ sample: number;
1327
+ breaches: StopRuleBreach[];
1328
+ } | null;
1200
1329
  stats: {
1201
1330
  sent: number;
1202
1331
  delivered: number;
@@ -1210,6 +1339,29 @@ interface BroadcastDoc {
1210
1339
  createdBy: string;
1211
1340
  updatedAt: Date;
1212
1341
  }
1342
+ interface BroadcastOrder {
1343
+ /** A host field, e.g. `updatedAt` for "most recently active first" with `direction: 'desc'`. */
1344
+ field: string;
1345
+ direction: 'asc' | 'desc';
1346
+ }
1347
+ interface StopRuleBreach {
1348
+ rule: 'hardBounceRatePct' | 'complaintRatePct' | 'unsubscribeRatePct';
1349
+ count: number;
1350
+ ratePct: number;
1351
+ thresholdPct: number;
1352
+ }
1353
+ interface BroadcastPauseReason {
1354
+ /**
1355
+ * cap_reached — the wave's recipientCap is spent; raise it and resume.
1356
+ * stop_rule — a per-broadcast bounce/complaint/unsubscribe threshold was crossed.
1357
+ * circuit_breaker — the sender domain's marketing circuit breaker tripped.
1358
+ * manual — paused by an operator.
1359
+ */
1360
+ code: 'cap_reached' | 'stop_rule' | 'circuit_breaker' | 'manual';
1361
+ message: string;
1362
+ at: Date;
1363
+ details?: Record<string, unknown>;
1364
+ }
1213
1365
  interface OutboxDoc {
1214
1366
  _id?: ObjectId;
1215
1367
  payload: {
@@ -1281,11 +1433,15 @@ interface HealthDoc {
1281
1433
  complained: number;
1282
1434
  failedToSend: number;
1283
1435
  };
1436
+ /**
1437
+ * Bounce/complaint rates are per send in the window; failureRate is failed
1438
+ * attempts over all attempts. Null when the window has no denominator yet.
1439
+ */
1284
1440
  rates: {
1285
- bounceRate: number;
1286
- hardBounceRate: number;
1287
- complaintRate: number;
1288
- failureRate: number;
1441
+ bounceRate: number | null;
1442
+ hardBounceRate: number | null;
1443
+ complaintRate: number | null;
1444
+ failureRate: number | null;
1289
1445
  };
1290
1446
  status: HealthStatus;
1291
1447
  trippedAt: Date | null;
@@ -1713,4 +1869,4 @@ declare class NullProvider implements MailProvider {
1713
1869
  reset(): void;
1714
1870
  }
1715
1871
 
1716
- 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 };
1872
+ export { type VarsResolveInfo 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 SendDoc as G, type HealthDoc as H, type SendStatus as I, type SenderDomainConfig as J, type SenderDomainRegistry as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SenderDomainValidation as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type SubscriptionDoc as U, type SubscriptionStatus as V, type SuppressionDoc as W, type SuppressionReason as X, type TemplateKind as Y, type TemplateVersionDoc as Z, type VarsAdapter as _, type Contact as a, type WebhookEventDoc as a0, defineVars as a1, ensureIndexes as a2, getCollections as a3, validateSenderDomain as a4, varsJsonSchema as a5, type FlowTrigger as a6, type QueueDriverConfig as a7, type AdapterSort as b, type SendResult as c, type MailTesterFeedback as d, Mailer as e, type Collections as f, type FlowDoc as g, type SuppressionScope as h, type SegmentFilter as i, type AuditLogDoc as j, type BroadcastDoc as k, type BroadcastOrder as l, type BroadcastPauseReason as m, type BroadcastStatus as n, type CircuitBreakerThresholds as o, type ContactTagDoc as p, type FlowGoal as q, type FlowRunDoc as r, type FlowRunStatus as s, type FlowVersionDoc as t, type HealthStatus as u, type MailerConfig as v, NullProvider as w, RESERVED_VAR_KEYS as x, type RedisOptions as y, type SegmentDefinition as z };