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
|
@@ -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.
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
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 {
|
|
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.
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
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 {
|
|
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 };
|