@growth-labs/mailer 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/broadcast.d.ts +82 -0
- package/dist/utils/broadcast.d.ts.map +1 -0
- package/dist/utils/broadcast.js +254 -0
- package/dist/utils/broadcast.js.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/SubscribeForm.astro +78 -65
- package/src/index.ts +13 -0
- package/src/types.ts +2 -2
- package/src/utils/broadcast.ts +392 -0
- package/src/utils/index.ts +6 -0
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export default function mailer(userOptions: MailerOptions): AstroIntegration;
|
|
|
4
4
|
export type { MailerOptions, ResolvedMailerOptions } from './options.js';
|
|
5
5
|
export { mailerOptionsSchema } from './options.js';
|
|
6
6
|
export type { DigestItem, EmailProvider, EmailQueueMessage, EmailSend, EmailSendStatus, OutboundEmail, SendResult, SiteConfigLookup, SiteMailerConfig, Subscriber, SubscriberFilter, SubscriberStatus, TemplateData, TemplateName, } from './types.js';
|
|
7
|
+
export type { BroadcastRecipient, SendBroadcastParams, SendBroadcastResult, } from './utils/broadcast.js';
|
|
8
|
+
export { sendBroadcast } from './utils/broadcast.js';
|
|
7
9
|
export type { CampaignSchedule, DigestSchedule } from './utils/scheduling.js';
|
|
8
10
|
export { executeCampaignSchedule, executeDigestSchedule, prepareCampaign, prepareDigest, sendBatchCampaigns, } from './utils/scheduling.js';
|
|
9
11
|
export type { MailerEnv } from './utils/send.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAA;AAC7C,OAAO,KAAK,EAAE,aAAa,EAAyB,MAAM,cAAc,CAAA;AAmBxE,MAAM,CAAC,OAAO,UAAU,MAAM,CAAC,WAAW,EAAE,aAAa,GAAG,gBAAgB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAA;AAC7C,OAAO,KAAK,EAAE,aAAa,EAAyB,MAAM,cAAc,CAAA;AAmBxE,MAAM,CAAC,OAAO,UAAU,MAAM,CAAC,WAAW,EAAE,aAAa,GAAG,gBAAgB,CAiE3E;AAED,YAAY,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAExE,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAClD,YAAY,EACX,UAAU,EACV,aAAa,EACb,iBAAiB,EACjB,SAAS,EACT,eAAe,EACf,aAAa,EACb,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,GACZ,MAAM,YAAY,CAAA;AACnB,YAAY,EACX,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AACpD,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAC7E,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAC7E,OAAO,EACN,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,iBAAiB,GACjB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -32,30 +32,37 @@ export default function mailer(userOptions) {
|
|
|
32
32
|
injectRoute({
|
|
33
33
|
pattern: options.subscribePath,
|
|
34
34
|
entrypoint: resolveEntrypoint('./routes/subscribe'),
|
|
35
|
+
prerender: false,
|
|
35
36
|
});
|
|
36
37
|
injectRoute({
|
|
37
38
|
pattern: options.confirmPath,
|
|
38
39
|
entrypoint: resolveEntrypoint('./routes/confirm'),
|
|
40
|
+
prerender: false,
|
|
39
41
|
});
|
|
40
42
|
injectRoute({
|
|
41
43
|
pattern: options.unsubscribePath,
|
|
42
44
|
entrypoint: resolveEntrypoint('./routes/unsubscribe'),
|
|
45
|
+
prerender: false,
|
|
43
46
|
});
|
|
44
47
|
injectRoute({
|
|
45
48
|
pattern: options.preferencesPath,
|
|
46
49
|
entrypoint: resolveEntrypoint('./routes/preferences.astro'),
|
|
50
|
+
prerender: false,
|
|
47
51
|
});
|
|
48
52
|
injectRoute({
|
|
49
53
|
pattern: options.webhookPath,
|
|
50
54
|
entrypoint: resolveEntrypoint('./routes/webhook'),
|
|
55
|
+
prerender: false,
|
|
51
56
|
});
|
|
52
57
|
injectRoute({
|
|
53
58
|
pattern: `${options.trackOpenPath}/[trackingId]`,
|
|
54
59
|
entrypoint: resolveEntrypoint('./routes/track-open'),
|
|
60
|
+
prerender: false,
|
|
55
61
|
});
|
|
56
62
|
injectRoute({
|
|
57
63
|
pattern: `${options.trackClickPath}/[trackingId]`,
|
|
58
64
|
entrypoint: resolveEntrypoint('./routes/track-click'),
|
|
65
|
+
prerender: false,
|
|
59
66
|
});
|
|
60
67
|
// Add tracking middleware
|
|
61
68
|
addMiddleware({
|
|
@@ -68,6 +75,7 @@ export default function mailer(userOptions) {
|
|
|
68
75
|
}
|
|
69
76
|
// Re-export options and types
|
|
70
77
|
export { mailerOptionsSchema } from './options.js';
|
|
78
|
+
export { sendBroadcast } from './utils/broadcast.js';
|
|
71
79
|
export { executeCampaignSchedule, executeDigestSchedule, prepareCampaign, prepareDigest, sendBatchCampaigns, } from './utils/scheduling.js';
|
|
72
80
|
// Re-export utilities for standalone use
|
|
73
81
|
export { sendCampaign, sendDigest, sendTransactional } from './utils/send.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAGxC,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAClD,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAA;AAEzD,SAAS,kBAAkB;IAC1B,OAAO,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AAC9E,CAAC;AAED,MAAM,oBAAoB,GAAG,kBAAkB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAA;AAEjE,SAAS,iBAAiB,CAAC,IAAY;IACtC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC7B,MAAM,UAAU,GAAG,kBAAkB,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAA;QACtF,OAAO,aAAa,CAAC,IAAI,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IAC3D,CAAC;IAED,OAAO,aAAa,CAAC,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,oBAAoB,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;AACjF,CAAC;AAED,MAAM,CAAC,OAAO,UAAU,MAAM,CAAC,WAA0B;IACxD,MAAM,OAAO,GAAG,mBAAmB,CAAC,KAAK,CAAC,WAAW,CAA0B,CAAA;IAE/E,OAAO;QACN,IAAI,EAAE,qBAAqB;QAC3B,KAAK,EAAE;YACN,oBAAoB,EAAE,CAAC,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE;gBAC3F,qCAAqC;gBACrC,MAAM,YAAY,GAAG,WAAW,CAAC,YAAY,CAAC,IAAI,CACjD,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,wBAAwB,CAC1C,CAAA;gBACD,IAAI,YAAY,EAAE,CAAC;oBAClB,CAAC;oBAAC,OAAmC,CAAC,gBAAgB,GAAG,IAAI,CAAA;gBAC9D,CAAC;gBAED,yCAAyC;gBACzC,YAAY,CAAC;oBACZ,IAAI,EAAE,EAAE,OAAO,EAAE,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC,EAAE;iBACpD,CAAC,CAAA;gBAEF,gBAAgB;gBAChB,WAAW,CAAC;oBACX,OAAO,EAAE,OAAO,CAAC,aAAa;oBAC9B,UAAU,EAAE,iBAAiB,CAAC,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface SubscriberFilter {
|
|
|
29
29
|
}
|
|
30
30
|
export interface EmailQueueMessage {
|
|
31
31
|
siteId: string;
|
|
32
|
-
type: 'transactional' | 'campaign' | 'digest';
|
|
32
|
+
type: 'transactional' | 'campaign' | 'digest' | 'broadcast';
|
|
33
33
|
recipients: QueueRecipient[];
|
|
34
34
|
subject: string;
|
|
35
35
|
htmlTemplate: string;
|
|
@@ -77,7 +77,7 @@ export interface EmailSend {
|
|
|
77
77
|
campaignId?: string;
|
|
78
78
|
email: string;
|
|
79
79
|
subject: string;
|
|
80
|
-
type: 'transactional' | 'campaign' | 'digest';
|
|
80
|
+
type: 'transactional' | 'campaign' | 'digest' | 'broadcast';
|
|
81
81
|
status: EmailSendStatus;
|
|
82
82
|
sentAt?: string;
|
|
83
83
|
deliveredAt?: string;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,gBAAgB,CAAA;IACxB,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,qBAAqB,CAAA;IACnC,eAAe,EAAE,MAAM,CAAA;IACvB,YAAY,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,QAAQ,GAAG,cAAc,GAAG,SAAS,GAAG,YAAY,CAAA;AAE/F,MAAM,WAAW,qBAAqB;IACrC,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;CACpB;AAID,MAAM,WAAW,gBAAgB;IAChC,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC3B,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB;AAID,MAAM,WAAW,iBAAiB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,eAAe,GAAG,UAAU,GAAG,QAAQ,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ResolvedMailerOptions } from '../options.js';
|
|
2
|
+
import type { MailerEnv } from './send.js';
|
|
3
|
+
/**
|
|
4
|
+
* One recipient of a broadcast. `subscriberId` is optional: when omitted the
|
|
5
|
+
* subscriber is resolved by `email` against `gl_subscribers` (needed both for
|
|
6
|
+
* suppression enforcement and for per-recipient unsubscribe/preferences links).
|
|
7
|
+
* An email with no matching subscriber row is treated as suppressed (we never
|
|
8
|
+
* blast an address we cannot honour an unsubscribe for).
|
|
9
|
+
*/
|
|
10
|
+
export interface BroadcastRecipient {
|
|
11
|
+
email: string;
|
|
12
|
+
subscriberId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SendBroadcastParams {
|
|
15
|
+
/**
|
|
16
|
+
* Explicit recipient list. Mutually-resolvable with `segmentName` +
|
|
17
|
+
* `resolveRecipients`: segmentation itself is NOT built here (WS5 bead 06 is
|
|
18
|
+
* identity-gated) — `sendBroadcast` takes the list, or a resolver callback
|
|
19
|
+
* that turns a saved-segment name into the list, from elsewhere.
|
|
20
|
+
*/
|
|
21
|
+
recipients?: Array<BroadcastRecipient | string>;
|
|
22
|
+
/**
|
|
23
|
+
* Name of a saved segment to blast. Resolved to a recipient list by
|
|
24
|
+
* `resolveRecipients` (provided by the caller / a later segmentation bead).
|
|
25
|
+
*/
|
|
26
|
+
segmentName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a saved-segment name to its recipient list. Supplied by the
|
|
29
|
+
* caller; broadcast does not implement segmentation.
|
|
30
|
+
*/
|
|
31
|
+
resolveRecipients?: (segmentName: string) => Promise<Array<BroadcastRecipient | string>>;
|
|
32
|
+
subject: string;
|
|
33
|
+
html: string;
|
|
34
|
+
/**
|
|
35
|
+
* Idempotency key. A re-run with the same `campaignId` will NOT re-send to
|
|
36
|
+
* any recipient that already has a `gl_email_sends` row for this campaign.
|
|
37
|
+
* Auto-generated when omitted (a fresh, non-idempotent blast).
|
|
38
|
+
*/
|
|
39
|
+
campaignId?: string;
|
|
40
|
+
/** When true, resolve + suppress + dedupe and return counts WITHOUT sending. */
|
|
41
|
+
dryRun?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Max recipients packed into a single queue message. Bounds the message
|
|
44
|
+
* payload well under Cloudflare's 128 KiB queue-message limit. Default 100.
|
|
45
|
+
*/
|
|
46
|
+
maxRecipientsPerMessage?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Max queue messages to enqueue per second, to stay under Cloudflare send /
|
|
49
|
+
* queue producer rate limits. `0` (default) disables pacing.
|
|
50
|
+
*/
|
|
51
|
+
messagesPerSecond?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface SendBroadcastResult {
|
|
54
|
+
campaignId: string;
|
|
55
|
+
/** Recipients actually queued (dry-run: would-be-queued). */
|
|
56
|
+
recipientCount: number;
|
|
57
|
+
/** Recipients skipped because their subscriber row is on the suppression list. */
|
|
58
|
+
suppressedCount: number;
|
|
59
|
+
/** Recipients skipped because a `gl_email_sends` row already existed (idempotency). */
|
|
60
|
+
alreadySentCount: number;
|
|
61
|
+
/** Number of queue messages enqueued (0 on dry-run). */
|
|
62
|
+
batchCount: number;
|
|
63
|
+
dryRun: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Send a one-off broadcast blast (Customer.io one-off-blast parity).
|
|
67
|
+
*
|
|
68
|
+
* - Takes an explicit recipient list, or a saved-segment name resolved via
|
|
69
|
+
* `resolveRecipients` (segmentation is NOT built here — WS5 bead 06).
|
|
70
|
+
* - Enforces the suppression list: only `active` subscribers are sent;
|
|
71
|
+
* unsubscribed / bounced / complained / pending / unknown addresses skip.
|
|
72
|
+
* - Idempotent on `campaignId`: a re-run never double-sends to a recipient that
|
|
73
|
+
* already has a `gl_email_sends` row for the campaign.
|
|
74
|
+
* - Writes a per-recipient `gl_email_sends` row (`type='broadcast'`, status
|
|
75
|
+
* `queued`) before enqueueing.
|
|
76
|
+
* - Fans out in batches sized for Cloudflare queue/send-rate limits, with
|
|
77
|
+
* optional `messagesPerSecond` pacing.
|
|
78
|
+
* - `dryRun` resolves + suppresses + dedupes and returns the recipient count
|
|
79
|
+
* WITHOUT writing rows or enqueueing anything.
|
|
80
|
+
*/
|
|
81
|
+
export declare function sendBroadcast(env: MailerEnv, options: ResolvedMailerOptions, params: SendBroadcastParams): Promise<SendBroadcastResult>;
|
|
82
|
+
//# sourceMappingURL=broadcast.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"broadcast.d.ts","sourceRoot":"","sources":["../../src/utils/broadcast.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAK1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAsB1C;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,mBAAmB;IACnC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,KAAK,CAAC,kBAAkB,GAAG,MAAM,CAAC,CAAA;IAC/C;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,KAAK,CAAC,kBAAkB,GAAG,MAAM,CAAC,CAAC,CAAA;IAExF,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IAEZ;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IAEnB,gFAAgF;IAChF,MAAM,CAAC,EAAE,OAAO,CAAA;IAEhB;;;OAGG;IACH,uBAAuB,CAAC,EAAE,MAAM,CAAA;IAEhC;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,mBAAmB;IACnC,UAAU,EAAE,MAAM,CAAA;IAClB,6DAA6D;IAC7D,cAAc,EAAE,MAAM,CAAA;IACtB,kFAAkF;IAClF,eAAe,EAAE,MAAM,CAAA;IACvB,uFAAuF;IACvF,gBAAgB,EAAE,MAAM,CAAA;IACxB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,OAAO,CAAA;CACf;AAmJD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,aAAa,CAClC,GAAG,EAAE,SAAS,EACd,OAAO,EAAE,qBAAqB,EAC9B,MAAM,EAAE,mBAAmB,GACzB,OAAO,CAAC,mBAAmB,CAAC,CA8H9B"}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { and, eq, inArray } from 'drizzle-orm';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { ulid } from 'ulidx';
|
|
4
|
+
import { emailSends } from '../schema/sends.js';
|
|
5
|
+
import { subscribers } from '../schema/subscribers.js';
|
|
6
|
+
import { sleep } from './providers.js';
|
|
7
|
+
import { generateToken } from './tokens.js';
|
|
8
|
+
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js';
|
|
9
|
+
/**
|
|
10
|
+
* Statuses that mean "do not send" — the suppression list. A broadcast skips
|
|
11
|
+
* every recipient whose `gl_subscribers` row carries one of these. `active` is
|
|
12
|
+
* the ONLY sendable status; everything else (including `pending`, i.e. an
|
|
13
|
+
* unconfirmed double-opt-in subscriber) is suppressed so a one-off blast can
|
|
14
|
+
* never re-permission or re-mail a suppressed address.
|
|
15
|
+
*/
|
|
16
|
+
const SUPPRESSED_STATUSES = new Set([
|
|
17
|
+
'unsubscribed',
|
|
18
|
+
'bounced',
|
|
19
|
+
'complained',
|
|
20
|
+
'pending',
|
|
21
|
+
]);
|
|
22
|
+
function resolveProducerBindings(env, options) {
|
|
23
|
+
const d1 = env[options.d1Binding];
|
|
24
|
+
if (!d1) {
|
|
25
|
+
throw new Error(`[mailer] env.${options.d1Binding} is undefined; ` +
|
|
26
|
+
'pass d1Binding option to the mailer integration or bind that name in wrangler.toml.');
|
|
27
|
+
}
|
|
28
|
+
const queue = env[options.queueBinding];
|
|
29
|
+
if (!queue) {
|
|
30
|
+
throw new Error(`[mailer] env.${options.queueBinding} is undefined; ` +
|
|
31
|
+
'pass queueBinding option to the mailer integration or bind that name in wrangler.toml.');
|
|
32
|
+
}
|
|
33
|
+
return { db: drizzle(d1), queue };
|
|
34
|
+
}
|
|
35
|
+
function normalizeRecipient(r) {
|
|
36
|
+
return typeof r === 'string' ? { email: r } : r;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve each requested recipient against `gl_subscribers`:
|
|
40
|
+
* - dedupe by email (a list may repeat an address)
|
|
41
|
+
* - attach the canonical subscriberId + status
|
|
42
|
+
* - drop emails with no subscriber row (cannot honour unsubscribe → suppress)
|
|
43
|
+
* Returns the resolved sendable-candidate set plus the suppressed count.
|
|
44
|
+
*/
|
|
45
|
+
async function resolveAgainstSubscribers(db, requested) {
|
|
46
|
+
// Dedupe requested emails (case-insensitive, trimmed) preserving first-seen order.
|
|
47
|
+
const byEmail = new Map();
|
|
48
|
+
for (const r of requested) {
|
|
49
|
+
const key = r.email.trim().toLowerCase();
|
|
50
|
+
if (key.length === 0)
|
|
51
|
+
continue;
|
|
52
|
+
if (!byEmail.has(key))
|
|
53
|
+
byEmail.set(key, { ...r, email: r.email.trim() });
|
|
54
|
+
}
|
|
55
|
+
const emails = [...byEmail.values()].map((r) => r.email);
|
|
56
|
+
if (emails.length === 0)
|
|
57
|
+
return { candidates: [], suppressedCount: 0 };
|
|
58
|
+
// Look up subscriber rows for all requested emails.
|
|
59
|
+
const rows = await db
|
|
60
|
+
.select({
|
|
61
|
+
id: subscribers.id,
|
|
62
|
+
email: subscribers.email,
|
|
63
|
+
status: subscribers.status,
|
|
64
|
+
})
|
|
65
|
+
.from(subscribers)
|
|
66
|
+
.where(inArray(subscribers.email, emails));
|
|
67
|
+
const rowByEmail = new Map();
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
rowByEmail.set(row.email.toLowerCase(), {
|
|
70
|
+
id: row.id,
|
|
71
|
+
status: row.status,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const candidates = [];
|
|
75
|
+
let suppressedCount = 0;
|
|
76
|
+
for (const r of byEmail.values()) {
|
|
77
|
+
const row = rowByEmail.get(r.email.toLowerCase());
|
|
78
|
+
// No subscriber row, or a suppressed status → skip. Never send to an
|
|
79
|
+
// address we cannot honour an unsubscribe for, and never to a
|
|
80
|
+
// bounced/complained/unsubscribed/pending one.
|
|
81
|
+
if (!row || SUPPRESSED_STATUSES.has(row.status)) {
|
|
82
|
+
suppressedCount++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
candidates.push({ email: r.email, subscriberId: r.subscriberId ?? row.id, status: row.status });
|
|
86
|
+
}
|
|
87
|
+
return { candidates, suppressedCount };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Of the suppression-cleared candidates, drop any that already have a
|
|
91
|
+
* `gl_email_sends` row for this campaignId — the idempotency guard that makes a
|
|
92
|
+
* re-run a no-op for already-processed recipients.
|
|
93
|
+
*/
|
|
94
|
+
async function filterAlreadySent(db, campaignId, candidates) {
|
|
95
|
+
if (candidates.length === 0)
|
|
96
|
+
return { fresh: [], alreadySentCount: 0 };
|
|
97
|
+
const candidateEmails = candidates.map((c) => c.email);
|
|
98
|
+
const existing = await db
|
|
99
|
+
.select({ email: emailSends.email })
|
|
100
|
+
.from(emailSends)
|
|
101
|
+
.where(and(eq(emailSends.campaignId, campaignId), inArray(emailSends.email, candidateEmails)));
|
|
102
|
+
const sentEmails = new Set(existing.map((row) => row.email.toLowerCase()));
|
|
103
|
+
const fresh = [];
|
|
104
|
+
let alreadySentCount = 0;
|
|
105
|
+
for (const c of candidates) {
|
|
106
|
+
if (sentEmails.has(c.email.toLowerCase())) {
|
|
107
|
+
alreadySentCount++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
fresh.push(c);
|
|
111
|
+
}
|
|
112
|
+
return { fresh, alreadySentCount };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build the broadcast HTML with the tracking pixel + click-rewrite applied
|
|
116
|
+
* once (the per-recipient {{TRACKING_ID}} is substituted in the consumer).
|
|
117
|
+
*/
|
|
118
|
+
function prepareBroadcastHtml(html, options) {
|
|
119
|
+
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`;
|
|
120
|
+
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`;
|
|
121
|
+
return rewriteLinksForTracking(injectTrackingPixel(html, trackOpenUrl), trackClickUrl);
|
|
122
|
+
}
|
|
123
|
+
function chunk(items, size) {
|
|
124
|
+
const out = [];
|
|
125
|
+
for (let i = 0; i < items.length; i += size) {
|
|
126
|
+
out.push(items.slice(i, i + size));
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Send a one-off broadcast blast (Customer.io one-off-blast parity).
|
|
132
|
+
*
|
|
133
|
+
* - Takes an explicit recipient list, or a saved-segment name resolved via
|
|
134
|
+
* `resolveRecipients` (segmentation is NOT built here — WS5 bead 06).
|
|
135
|
+
* - Enforces the suppression list: only `active` subscribers are sent;
|
|
136
|
+
* unsubscribed / bounced / complained / pending / unknown addresses skip.
|
|
137
|
+
* - Idempotent on `campaignId`: a re-run never double-sends to a recipient that
|
|
138
|
+
* already has a `gl_email_sends` row for the campaign.
|
|
139
|
+
* - Writes a per-recipient `gl_email_sends` row (`type='broadcast'`, status
|
|
140
|
+
* `queued`) before enqueueing.
|
|
141
|
+
* - Fans out in batches sized for Cloudflare queue/send-rate limits, with
|
|
142
|
+
* optional `messagesPerSecond` pacing.
|
|
143
|
+
* - `dryRun` resolves + suppresses + dedupes and returns the recipient count
|
|
144
|
+
* WITHOUT writing rows or enqueueing anything.
|
|
145
|
+
*/
|
|
146
|
+
export async function sendBroadcast(env, options, params) {
|
|
147
|
+
const campaignId = params.campaignId ?? ulid();
|
|
148
|
+
const dryRun = params.dryRun ?? false;
|
|
149
|
+
const maxRecipientsPerMessage = Math.max(1, Math.min(params.maxRecipientsPerMessage ?? options.batchSize, 500));
|
|
150
|
+
const messagesPerSecond = params.messagesPerSecond ?? 0;
|
|
151
|
+
const { db, queue } = resolveProducerBindings(env, options);
|
|
152
|
+
// 1. Resolve the recipient list — explicit list and/or a resolved segment.
|
|
153
|
+
const requestedRaw = [...(params.recipients ?? [])];
|
|
154
|
+
if (params.segmentName) {
|
|
155
|
+
if (!params.resolveRecipients) {
|
|
156
|
+
throw new Error('[mailer] sendBroadcast: segmentName given without resolveRecipients; ' +
|
|
157
|
+
'segmentation is resolved by the caller (WS5 bead 06), not by the mailer.');
|
|
158
|
+
}
|
|
159
|
+
const resolved = await params.resolveRecipients(params.segmentName);
|
|
160
|
+
requestedRaw.push(...resolved);
|
|
161
|
+
}
|
|
162
|
+
if (requestedRaw.length === 0) {
|
|
163
|
+
throw new Error('[mailer] sendBroadcast: no recipients or segmentName provided');
|
|
164
|
+
}
|
|
165
|
+
const requested = requestedRaw.map(normalizeRecipient);
|
|
166
|
+
// 2. Suppression enforcement + dedupe.
|
|
167
|
+
const { candidates, suppressedCount } = await resolveAgainstSubscribers(db, requested);
|
|
168
|
+
// 3. Idempotency — drop recipients already recorded for this campaign.
|
|
169
|
+
const { fresh, alreadySentCount } = await filterAlreadySent(db, campaignId, candidates);
|
|
170
|
+
// Dry-run: report the counts and stop before any write or enqueue.
|
|
171
|
+
if (dryRun) {
|
|
172
|
+
return {
|
|
173
|
+
campaignId,
|
|
174
|
+
recipientCount: fresh.length,
|
|
175
|
+
suppressedCount,
|
|
176
|
+
alreadySentCount,
|
|
177
|
+
batchCount: 0,
|
|
178
|
+
dryRun: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (fresh.length === 0) {
|
|
182
|
+
return {
|
|
183
|
+
campaignId,
|
|
184
|
+
recipientCount: 0,
|
|
185
|
+
suppressedCount,
|
|
186
|
+
alreadySentCount,
|
|
187
|
+
batchCount: 0,
|
|
188
|
+
dryRun: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const html = prepareBroadcastHtml(params.html, options);
|
|
192
|
+
const now = new Date().toISOString();
|
|
193
|
+
// 4. Per-recipient row write + queue-recipient build.
|
|
194
|
+
const queueRecipients = [];
|
|
195
|
+
for (const recipient of fresh) {
|
|
196
|
+
const trackingId = ulid();
|
|
197
|
+
const [unsubscribeToken, preferencesToken] = await Promise.all([
|
|
198
|
+
generateToken(options.signingSecret, {
|
|
199
|
+
subscriberId: recipient.subscriberId,
|
|
200
|
+
action: 'unsubscribe',
|
|
201
|
+
}),
|
|
202
|
+
generateToken(options.signingSecret, {
|
|
203
|
+
subscriberId: recipient.subscriberId,
|
|
204
|
+
action: 'preferences',
|
|
205
|
+
}),
|
|
206
|
+
]);
|
|
207
|
+
await db.insert(emailSends).values({
|
|
208
|
+
id: ulid(),
|
|
209
|
+
subscriberId: recipient.subscriberId,
|
|
210
|
+
campaignId,
|
|
211
|
+
email: recipient.email,
|
|
212
|
+
subject: params.subject,
|
|
213
|
+
type: 'broadcast',
|
|
214
|
+
status: 'queued',
|
|
215
|
+
trackingId,
|
|
216
|
+
createdAt: now,
|
|
217
|
+
});
|
|
218
|
+
queueRecipients.push({
|
|
219
|
+
email: recipient.email,
|
|
220
|
+
subscriberId: recipient.subscriberId,
|
|
221
|
+
trackingId,
|
|
222
|
+
unsubscribeToken,
|
|
223
|
+
preferencesToken,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// 5. Batched fan-out, paced to the configured send rate.
|
|
227
|
+
const batches = chunk(queueRecipients, maxRecipientsPerMessage);
|
|
228
|
+
const intervalMs = messagesPerSecond > 0 ? Math.ceil(1000 / messagesPerSecond) : 0;
|
|
229
|
+
for (let i = 0; i < batches.length; i++) {
|
|
230
|
+
const message = {
|
|
231
|
+
siteId: options.siteId,
|
|
232
|
+
type: 'broadcast',
|
|
233
|
+
recipients: batches[i],
|
|
234
|
+
subject: params.subject,
|
|
235
|
+
htmlTemplate: html,
|
|
236
|
+
from: `${options.senderName} <${options.fromAddress}>`,
|
|
237
|
+
replyTo: options.replyTo,
|
|
238
|
+
campaignId,
|
|
239
|
+
};
|
|
240
|
+
await queue.send(message);
|
|
241
|
+
if (intervalMs > 0 && i < batches.length - 1) {
|
|
242
|
+
await sleep(intervalMs);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
campaignId,
|
|
247
|
+
recipientCount: fresh.length,
|
|
248
|
+
suppressedCount,
|
|
249
|
+
alreadySentCount,
|
|
250
|
+
batchCount: batches.length,
|
|
251
|
+
dryRun: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=broadcast.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"broadcast.js","sourceRoot":"","sources":["../../src/utils/broadcast.ts"],"names":[],"mappings":"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"}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { buildMailerAnalyticsDataPoint, emitMailerAnalyticsEvent, type MailerAnalyticsEvent, } from './analytics.js';
|
|
2
2
|
export { handleBounce, handleComplaint, handleDelivery, updateSendStatus, } from './bounce.js';
|
|
3
|
+
export type { BroadcastRecipient, SendBroadcastParams, SendBroadcastResult, } from './broadcast.js';
|
|
4
|
+
export { sendBroadcast } from './broadcast.js';
|
|
3
5
|
export type { CloudflareEmailSender } from './providers.js';
|
|
4
6
|
export { CloudflareEmailProvider, getProvider, sleep } from './providers.js';
|
|
5
7
|
export type { CampaignSchedule, DigestSchedule } from './scheduling.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,6BAA6B,EAC7B,wBAAwB,EACxB,KAAK,oBAAoB,GACzB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAE,uBAAuB,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AAC5E,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACvE,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACvE,OAAO,EACN,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACN,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EACN,mBAAmB,EACnB,uBAAuB,EACvB,eAAe,GACf,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,6BAA6B,EAC7B,wBAAwB,EACxB,KAAK,oBAAoB,GACzB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;AACpB,YAAY,EACX,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC9C,YAAY,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAE,uBAAuB,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AAC5E,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACvE,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACvE,OAAO,EACN,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACN,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EACN,mBAAmB,EACnB,uBAAuB,EACvB,eAAe,GACf,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA"}
|
package/dist/utils/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { buildMailerAnalyticsDataPoint, emitMailerAnalyticsEvent, } from './analytics.js';
|
|
2
2
|
export { handleBounce, handleComplaint, handleDelivery, updateSendStatus, } from './bounce.js';
|
|
3
|
+
export { sendBroadcast } from './broadcast.js';
|
|
3
4
|
export { CloudflareEmailProvider, getProvider, sleep } from './providers.js';
|
|
4
5
|
export { executeCampaignSchedule, executeDigestSchedule, prepareCampaign, prepareDigest, sendBatchCampaigns, } from './scheduling.js';
|
|
5
6
|
export { sendCampaign, sendDigest, sendTransactional } from './send.js';
|
package/dist/utils/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,6BAA6B,EAC7B,wBAAwB,GAExB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,6BAA6B,EAC7B,wBAAwB,GAExB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;AAMpB,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAE9C,OAAO,EAAE,uBAAuB,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AAE5E,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACvE,OAAO,EACN,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACN,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EACN,mBAAmB,EACnB,uBAAuB,EACvB,eAAe,GACf,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA"}
|
package/package.json
CHANGED
|
@@ -30,6 +30,7 @@ const formId = `gl-subscribe-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
30
30
|
method="POST"
|
|
31
31
|
action={config.subscribePath}
|
|
32
32
|
data-gl-subscribe-form
|
|
33
|
+
data-subscribe-path={config.subscribePath}
|
|
33
34
|
>
|
|
34
35
|
<div class="gl-subscribe-fields">
|
|
35
36
|
{showNameField && (
|
|
@@ -87,75 +88,87 @@ const formId = `gl-subscribe-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
87
88
|
</form>
|
|
88
89
|
</div>
|
|
89
90
|
|
|
90
|
-
<script
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Load Turnstile
|
|
91
|
+
<script>
|
|
92
|
+
// Hoisted (bundled) module shared by every SubscribeForm on the page.
|
|
93
|
+
// Previously this used `define:vars`, which Astro cannot bundle, so the
|
|
94
|
+
// whole handler was re-emitted inline per form instance. Now it wires all
|
|
95
|
+
// forms from one cached script, reading per-form config from data-* attrs.
|
|
96
|
+
function initGlSubscribeForms() {
|
|
97
|
+
const forms = document.querySelectorAll<HTMLFormElement>(
|
|
98
|
+
"[data-gl-subscribe-form]",
|
|
99
|
+
);
|
|
100
|
+
if (forms.length === 0) return;
|
|
101
|
+
|
|
102
|
+
// Load Turnstile once per page (de-duped) when a subscribe form exists.
|
|
102
103
|
if (!document.querySelector('script[src*="turnstile"]')) {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
document.head.appendChild(
|
|
104
|
+
const turnstile = document.createElement("script");
|
|
105
|
+
turnstile.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
|
106
|
+
turnstile.async = true;
|
|
107
|
+
document.head.appendChild(turnstile);
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
110
|
+
for (const form of forms) {
|
|
111
|
+
if (form.dataset.glWired) continue;
|
|
112
|
+
form.dataset.glWired = "1";
|
|
113
|
+
|
|
114
|
+
const subscribePath = form.getAttribute("data-subscribe-path") || "";
|
|
115
|
+
const submitBtn = form.querySelector("[data-gl-submit]");
|
|
116
|
+
const messageEl = form.querySelector("[data-gl-message]");
|
|
117
|
+
const successEl = form.querySelector("[data-gl-success]");
|
|
118
|
+
const errorEl = form.querySelector("[data-gl-error]");
|
|
119
|
+
|
|
120
|
+
form.addEventListener("submit", async (e) => {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
if (!submitBtn) return;
|
|
123
|
+
|
|
124
|
+
submitBtn.setAttribute("disabled", "true");
|
|
125
|
+
messageEl?.setAttribute("hidden", "");
|
|
126
|
+
successEl?.setAttribute("hidden", "");
|
|
127
|
+
errorEl?.setAttribute("hidden", "");
|
|
128
|
+
|
|
129
|
+
const formData = new FormData(form);
|
|
130
|
+
const preferences = formData.getAll("preferences");
|
|
131
|
+
const turnstileInput = form.querySelector<HTMLInputElement>(
|
|
132
|
+
'[name="cf-turnstile-response"]',
|
|
133
|
+
);
|
|
134
|
+
const turnstileToken = turnstileInput?.value ?? "";
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(subscribePath, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
email: formData.get("email"),
|
|
142
|
+
name: formData.get("name") || undefined,
|
|
143
|
+
source: formData.get("source"),
|
|
144
|
+
preferences: preferences.length ? preferences : undefined,
|
|
145
|
+
turnstileToken,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
|
|
151
|
+
messageEl?.removeAttribute("hidden");
|
|
152
|
+
if (data.success) {
|
|
153
|
+
successEl?.removeAttribute("hidden");
|
|
154
|
+
form.reset();
|
|
155
|
+
} else if (errorEl) {
|
|
156
|
+
errorEl.textContent = data.error || "Something went wrong";
|
|
157
|
+
errorEl.removeAttribute("hidden");
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
messageEl?.removeAttribute("hidden");
|
|
145
161
|
if (errorEl) {
|
|
146
|
-
errorEl.textContent =
|
|
147
|
-
errorEl.removeAttribute(
|
|
162
|
+
errorEl.textContent = "Network error. Please try again.";
|
|
163
|
+
errorEl.removeAttribute("hidden");
|
|
148
164
|
}
|
|
165
|
+
} finally {
|
|
166
|
+
submitBtn.removeAttribute("disabled");
|
|
149
167
|
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} finally {
|
|
157
|
-
submitBtn.removeAttribute('disabled')
|
|
158
|
-
}
|
|
159
|
-
})
|
|
160
|
-
})()
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
initGlSubscribeForms();
|
|
173
|
+
document.addEventListener("astro:page-load", initGlSubscribeForms);
|
|
161
174
|
</script>
|
package/src/index.ts
CHANGED
|
@@ -43,30 +43,37 @@ export default function mailer(userOptions: MailerOptions): AstroIntegration {
|
|
|
43
43
|
injectRoute({
|
|
44
44
|
pattern: options.subscribePath,
|
|
45
45
|
entrypoint: resolveEntrypoint('./routes/subscribe'),
|
|
46
|
+
prerender: false,
|
|
46
47
|
})
|
|
47
48
|
injectRoute({
|
|
48
49
|
pattern: options.confirmPath,
|
|
49
50
|
entrypoint: resolveEntrypoint('./routes/confirm'),
|
|
51
|
+
prerender: false,
|
|
50
52
|
})
|
|
51
53
|
injectRoute({
|
|
52
54
|
pattern: options.unsubscribePath,
|
|
53
55
|
entrypoint: resolveEntrypoint('./routes/unsubscribe'),
|
|
56
|
+
prerender: false,
|
|
54
57
|
})
|
|
55
58
|
injectRoute({
|
|
56
59
|
pattern: options.preferencesPath,
|
|
57
60
|
entrypoint: resolveEntrypoint('./routes/preferences.astro'),
|
|
61
|
+
prerender: false,
|
|
58
62
|
})
|
|
59
63
|
injectRoute({
|
|
60
64
|
pattern: options.webhookPath,
|
|
61
65
|
entrypoint: resolveEntrypoint('./routes/webhook'),
|
|
66
|
+
prerender: false,
|
|
62
67
|
})
|
|
63
68
|
injectRoute({
|
|
64
69
|
pattern: `${options.trackOpenPath}/[trackingId]`,
|
|
65
70
|
entrypoint: resolveEntrypoint('./routes/track-open'),
|
|
71
|
+
prerender: false,
|
|
66
72
|
})
|
|
67
73
|
injectRoute({
|
|
68
74
|
pattern: `${options.trackClickPath}/[trackingId]`,
|
|
69
75
|
entrypoint: resolveEntrypoint('./routes/track-click'),
|
|
76
|
+
prerender: false,
|
|
70
77
|
})
|
|
71
78
|
|
|
72
79
|
// Add tracking middleware
|
|
@@ -98,6 +105,12 @@ export type {
|
|
|
98
105
|
TemplateData,
|
|
99
106
|
TemplateName,
|
|
100
107
|
} from './types.js'
|
|
108
|
+
export type {
|
|
109
|
+
BroadcastRecipient,
|
|
110
|
+
SendBroadcastParams,
|
|
111
|
+
SendBroadcastResult,
|
|
112
|
+
} from './utils/broadcast.js'
|
|
113
|
+
export { sendBroadcast } from './utils/broadcast.js'
|
|
101
114
|
export type { CampaignSchedule, DigestSchedule } from './utils/scheduling.js'
|
|
102
115
|
export {
|
|
103
116
|
executeCampaignSchedule,
|
package/src/types.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface SubscriberFilter {
|
|
|
39
39
|
|
|
40
40
|
export interface EmailQueueMessage {
|
|
41
41
|
siteId: string
|
|
42
|
-
type: 'transactional' | 'campaign' | 'digest'
|
|
42
|
+
type: 'transactional' | 'campaign' | 'digest' | 'broadcast'
|
|
43
43
|
recipients: QueueRecipient[]
|
|
44
44
|
subject: string
|
|
45
45
|
htmlTemplate: string
|
|
@@ -98,7 +98,7 @@ export interface EmailSend {
|
|
|
98
98
|
campaignId?: string
|
|
99
99
|
email: string
|
|
100
100
|
subject: string
|
|
101
|
-
type: 'transactional' | 'campaign' | 'digest'
|
|
101
|
+
type: 'transactional' | 'campaign' | 'digest' | 'broadcast'
|
|
102
102
|
status: EmailSendStatus
|
|
103
103
|
sentAt?: string
|
|
104
104
|
deliveredAt?: string
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { and, eq, inArray } from 'drizzle-orm'
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1'
|
|
3
|
+
import { ulid } from 'ulidx'
|
|
4
|
+
import type { ResolvedMailerOptions } from '../options.js'
|
|
5
|
+
import { emailSends } from '../schema/sends.js'
|
|
6
|
+
import { subscribers } from '../schema/subscribers.js'
|
|
7
|
+
import type { EmailQueueMessage, QueueRecipient, SubscriberStatus } from '../types.js'
|
|
8
|
+
import { sleep } from './providers.js'
|
|
9
|
+
import type { MailerEnv } from './send.js'
|
|
10
|
+
import { generateToken } from './tokens.js'
|
|
11
|
+
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js'
|
|
12
|
+
|
|
13
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Statuses that mean "do not send" — the suppression list. A broadcast skips
|
|
17
|
+
* every recipient whose `gl_subscribers` row carries one of these. `active` is
|
|
18
|
+
* the ONLY sendable status; everything else (including `pending`, i.e. an
|
|
19
|
+
* unconfirmed double-opt-in subscriber) is suppressed so a one-off blast can
|
|
20
|
+
* never re-permission or re-mail a suppressed address.
|
|
21
|
+
*/
|
|
22
|
+
const SUPPRESSED_STATUSES: ReadonlySet<SubscriberStatus> = new Set<SubscriberStatus>([
|
|
23
|
+
'unsubscribed',
|
|
24
|
+
'bounced',
|
|
25
|
+
'complained',
|
|
26
|
+
'pending',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
// ─── Inputs ───
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One recipient of a broadcast. `subscriberId` is optional: when omitted the
|
|
33
|
+
* subscriber is resolved by `email` against `gl_subscribers` (needed both for
|
|
34
|
+
* suppression enforcement and for per-recipient unsubscribe/preferences links).
|
|
35
|
+
* An email with no matching subscriber row is treated as suppressed (we never
|
|
36
|
+
* blast an address we cannot honour an unsubscribe for).
|
|
37
|
+
*/
|
|
38
|
+
export interface BroadcastRecipient {
|
|
39
|
+
email: string
|
|
40
|
+
subscriberId?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SendBroadcastParams {
|
|
44
|
+
/**
|
|
45
|
+
* Explicit recipient list. Mutually-resolvable with `segmentName` +
|
|
46
|
+
* `resolveRecipients`: segmentation itself is NOT built here (WS5 bead 06 is
|
|
47
|
+
* identity-gated) — `sendBroadcast` takes the list, or a resolver callback
|
|
48
|
+
* that turns a saved-segment name into the list, from elsewhere.
|
|
49
|
+
*/
|
|
50
|
+
recipients?: Array<BroadcastRecipient | string>
|
|
51
|
+
/**
|
|
52
|
+
* Name of a saved segment to blast. Resolved to a recipient list by
|
|
53
|
+
* `resolveRecipients` (provided by the caller / a later segmentation bead).
|
|
54
|
+
*/
|
|
55
|
+
segmentName?: string
|
|
56
|
+
/**
|
|
57
|
+
* Resolves a saved-segment name to its recipient list. Supplied by the
|
|
58
|
+
* caller; broadcast does not implement segmentation.
|
|
59
|
+
*/
|
|
60
|
+
resolveRecipients?: (segmentName: string) => Promise<Array<BroadcastRecipient | string>>
|
|
61
|
+
|
|
62
|
+
subject: string
|
|
63
|
+
html: string
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Idempotency key. A re-run with the same `campaignId` will NOT re-send to
|
|
67
|
+
* any recipient that already has a `gl_email_sends` row for this campaign.
|
|
68
|
+
* Auto-generated when omitted (a fresh, non-idempotent blast).
|
|
69
|
+
*/
|
|
70
|
+
campaignId?: string
|
|
71
|
+
|
|
72
|
+
/** When true, resolve + suppress + dedupe and return counts WITHOUT sending. */
|
|
73
|
+
dryRun?: boolean
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Max recipients packed into a single queue message. Bounds the message
|
|
77
|
+
* payload well under Cloudflare's 128 KiB queue-message limit. Default 100.
|
|
78
|
+
*/
|
|
79
|
+
maxRecipientsPerMessage?: number
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Max queue messages to enqueue per second, to stay under Cloudflare send /
|
|
83
|
+
* queue producer rate limits. `0` (default) disables pacing.
|
|
84
|
+
*/
|
|
85
|
+
messagesPerSecond?: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SendBroadcastResult {
|
|
89
|
+
campaignId: string
|
|
90
|
+
/** Recipients actually queued (dry-run: would-be-queued). */
|
|
91
|
+
recipientCount: number
|
|
92
|
+
/** Recipients skipped because their subscriber row is on the suppression list. */
|
|
93
|
+
suppressedCount: number
|
|
94
|
+
/** Recipients skipped because a `gl_email_sends` row already existed (idempotency). */
|
|
95
|
+
alreadySentCount: number
|
|
96
|
+
/** Number of queue messages enqueued (0 on dry-run). */
|
|
97
|
+
batchCount: number
|
|
98
|
+
dryRun: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ResolvedProducerBindings {
|
|
102
|
+
db: DrizzleDB
|
|
103
|
+
queue: Queue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveProducerBindings(
|
|
107
|
+
env: MailerEnv,
|
|
108
|
+
options: ResolvedMailerOptions,
|
|
109
|
+
): ResolvedProducerBindings {
|
|
110
|
+
const d1 = env[options.d1Binding] as D1Database | undefined
|
|
111
|
+
if (!d1) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[mailer] env.${options.d1Binding} is undefined; ` +
|
|
114
|
+
'pass d1Binding option to the mailer integration or bind that name in wrangler.toml.',
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
const queue = env[options.queueBinding] as Queue | undefined
|
|
118
|
+
if (!queue) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`[mailer] env.${options.queueBinding} is undefined; ` +
|
|
121
|
+
'pass queueBinding option to the mailer integration or bind that name in wrangler.toml.',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
return { db: drizzle(d1), queue }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeRecipient(r: BroadcastRecipient | string): BroadcastRecipient {
|
|
128
|
+
return typeof r === 'string' ? { email: r } : r
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A recipient resolved against `gl_subscribers`: sendable, with its id + status. */
|
|
132
|
+
interface ResolvedRecipient {
|
|
133
|
+
email: string
|
|
134
|
+
subscriberId: string
|
|
135
|
+
status: SubscriberStatus
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve each requested recipient against `gl_subscribers`:
|
|
140
|
+
* - dedupe by email (a list may repeat an address)
|
|
141
|
+
* - attach the canonical subscriberId + status
|
|
142
|
+
* - drop emails with no subscriber row (cannot honour unsubscribe → suppress)
|
|
143
|
+
* Returns the resolved sendable-candidate set plus the suppressed count.
|
|
144
|
+
*/
|
|
145
|
+
async function resolveAgainstSubscribers(
|
|
146
|
+
db: DrizzleDB,
|
|
147
|
+
requested: BroadcastRecipient[],
|
|
148
|
+
): Promise<{ candidates: ResolvedRecipient[]; suppressedCount: number }> {
|
|
149
|
+
// Dedupe requested emails (case-insensitive, trimmed) preserving first-seen order.
|
|
150
|
+
const byEmail = new Map<string, BroadcastRecipient>()
|
|
151
|
+
for (const r of requested) {
|
|
152
|
+
const key = r.email.trim().toLowerCase()
|
|
153
|
+
if (key.length === 0) continue
|
|
154
|
+
if (!byEmail.has(key)) byEmail.set(key, { ...r, email: r.email.trim() })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const emails = [...byEmail.values()].map((r) => r.email)
|
|
158
|
+
if (emails.length === 0) return { candidates: [], suppressedCount: 0 }
|
|
159
|
+
|
|
160
|
+
// Look up subscriber rows for all requested emails.
|
|
161
|
+
const rows = await db
|
|
162
|
+
.select({
|
|
163
|
+
id: subscribers.id,
|
|
164
|
+
email: subscribers.email,
|
|
165
|
+
status: subscribers.status,
|
|
166
|
+
})
|
|
167
|
+
.from(subscribers)
|
|
168
|
+
.where(inArray(subscribers.email, emails))
|
|
169
|
+
|
|
170
|
+
const rowByEmail = new Map<string, { id: string; status: SubscriberStatus }>()
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
rowByEmail.set((row.email as string).toLowerCase(), {
|
|
173
|
+
id: row.id as string,
|
|
174
|
+
status: row.status as SubscriberStatus,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const candidates: ResolvedRecipient[] = []
|
|
179
|
+
let suppressedCount = 0
|
|
180
|
+
for (const r of byEmail.values()) {
|
|
181
|
+
const row = rowByEmail.get(r.email.toLowerCase())
|
|
182
|
+
// No subscriber row, or a suppressed status → skip. Never send to an
|
|
183
|
+
// address we cannot honour an unsubscribe for, and never to a
|
|
184
|
+
// bounced/complained/unsubscribed/pending one.
|
|
185
|
+
if (!row || SUPPRESSED_STATUSES.has(row.status)) {
|
|
186
|
+
suppressedCount++
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
candidates.push({ email: r.email, subscriberId: r.subscriberId ?? row.id, status: row.status })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { candidates, suppressedCount }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Of the suppression-cleared candidates, drop any that already have a
|
|
197
|
+
* `gl_email_sends` row for this campaignId — the idempotency guard that makes a
|
|
198
|
+
* re-run a no-op for already-processed recipients.
|
|
199
|
+
*/
|
|
200
|
+
async function filterAlreadySent(
|
|
201
|
+
db: DrizzleDB,
|
|
202
|
+
campaignId: string,
|
|
203
|
+
candidates: ResolvedRecipient[],
|
|
204
|
+
): Promise<{ fresh: ResolvedRecipient[]; alreadySentCount: number }> {
|
|
205
|
+
if (candidates.length === 0) return { fresh: [], alreadySentCount: 0 }
|
|
206
|
+
|
|
207
|
+
const candidateEmails = candidates.map((c) => c.email)
|
|
208
|
+
const existing = await db
|
|
209
|
+
.select({ email: emailSends.email })
|
|
210
|
+
.from(emailSends)
|
|
211
|
+
.where(and(eq(emailSends.campaignId, campaignId), inArray(emailSends.email, candidateEmails)))
|
|
212
|
+
|
|
213
|
+
const sentEmails = new Set(existing.map((row) => (row.email as string).toLowerCase()))
|
|
214
|
+
|
|
215
|
+
const fresh: ResolvedRecipient[] = []
|
|
216
|
+
let alreadySentCount = 0
|
|
217
|
+
for (const c of candidates) {
|
|
218
|
+
if (sentEmails.has(c.email.toLowerCase())) {
|
|
219
|
+
alreadySentCount++
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
fresh.push(c)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { fresh, alreadySentCount }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build the broadcast HTML with the tracking pixel + click-rewrite applied
|
|
230
|
+
* once (the per-recipient {{TRACKING_ID}} is substituted in the consumer).
|
|
231
|
+
*/
|
|
232
|
+
function prepareBroadcastHtml(html: string, options: ResolvedMailerOptions): string {
|
|
233
|
+
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`
|
|
234
|
+
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`
|
|
235
|
+
return rewriteLinksForTracking(injectTrackingPixel(html, trackOpenUrl), trackClickUrl)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
239
|
+
const out: T[][] = []
|
|
240
|
+
for (let i = 0; i < items.length; i += size) {
|
|
241
|
+
out.push(items.slice(i, i + size))
|
|
242
|
+
}
|
|
243
|
+
return out
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Send a one-off broadcast blast (Customer.io one-off-blast parity).
|
|
248
|
+
*
|
|
249
|
+
* - Takes an explicit recipient list, or a saved-segment name resolved via
|
|
250
|
+
* `resolveRecipients` (segmentation is NOT built here — WS5 bead 06).
|
|
251
|
+
* - Enforces the suppression list: only `active` subscribers are sent;
|
|
252
|
+
* unsubscribed / bounced / complained / pending / unknown addresses skip.
|
|
253
|
+
* - Idempotent on `campaignId`: a re-run never double-sends to a recipient that
|
|
254
|
+
* already has a `gl_email_sends` row for the campaign.
|
|
255
|
+
* - Writes a per-recipient `gl_email_sends` row (`type='broadcast'`, status
|
|
256
|
+
* `queued`) before enqueueing.
|
|
257
|
+
* - Fans out in batches sized for Cloudflare queue/send-rate limits, with
|
|
258
|
+
* optional `messagesPerSecond` pacing.
|
|
259
|
+
* - `dryRun` resolves + suppresses + dedupes and returns the recipient count
|
|
260
|
+
* WITHOUT writing rows or enqueueing anything.
|
|
261
|
+
*/
|
|
262
|
+
export async function sendBroadcast(
|
|
263
|
+
env: MailerEnv,
|
|
264
|
+
options: ResolvedMailerOptions,
|
|
265
|
+
params: SendBroadcastParams,
|
|
266
|
+
): Promise<SendBroadcastResult> {
|
|
267
|
+
const campaignId = params.campaignId ?? ulid()
|
|
268
|
+
const dryRun = params.dryRun ?? false
|
|
269
|
+
const maxRecipientsPerMessage = Math.max(
|
|
270
|
+
1,
|
|
271
|
+
Math.min(params.maxRecipientsPerMessage ?? options.batchSize, 500),
|
|
272
|
+
)
|
|
273
|
+
const messagesPerSecond = params.messagesPerSecond ?? 0
|
|
274
|
+
|
|
275
|
+
const { db, queue } = resolveProducerBindings(env, options)
|
|
276
|
+
|
|
277
|
+
// 1. Resolve the recipient list — explicit list and/or a resolved segment.
|
|
278
|
+
const requestedRaw: Array<BroadcastRecipient | string> = [...(params.recipients ?? [])]
|
|
279
|
+
if (params.segmentName) {
|
|
280
|
+
if (!params.resolveRecipients) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
'[mailer] sendBroadcast: segmentName given without resolveRecipients; ' +
|
|
283
|
+
'segmentation is resolved by the caller (WS5 bead 06), not by the mailer.',
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
const resolved = await params.resolveRecipients(params.segmentName)
|
|
287
|
+
requestedRaw.push(...resolved)
|
|
288
|
+
}
|
|
289
|
+
if (requestedRaw.length === 0) {
|
|
290
|
+
throw new Error('[mailer] sendBroadcast: no recipients or segmentName provided')
|
|
291
|
+
}
|
|
292
|
+
const requested = requestedRaw.map(normalizeRecipient)
|
|
293
|
+
|
|
294
|
+
// 2. Suppression enforcement + dedupe.
|
|
295
|
+
const { candidates, suppressedCount } = await resolveAgainstSubscribers(db, requested)
|
|
296
|
+
|
|
297
|
+
// 3. Idempotency — drop recipients already recorded for this campaign.
|
|
298
|
+
const { fresh, alreadySentCount } = await filterAlreadySent(db, campaignId, candidates)
|
|
299
|
+
|
|
300
|
+
// Dry-run: report the counts and stop before any write or enqueue.
|
|
301
|
+
if (dryRun) {
|
|
302
|
+
return {
|
|
303
|
+
campaignId,
|
|
304
|
+
recipientCount: fresh.length,
|
|
305
|
+
suppressedCount,
|
|
306
|
+
alreadySentCount,
|
|
307
|
+
batchCount: 0,
|
|
308
|
+
dryRun: true,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (fresh.length === 0) {
|
|
313
|
+
return {
|
|
314
|
+
campaignId,
|
|
315
|
+
recipientCount: 0,
|
|
316
|
+
suppressedCount,
|
|
317
|
+
alreadySentCount,
|
|
318
|
+
batchCount: 0,
|
|
319
|
+
dryRun: false,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const html = prepareBroadcastHtml(params.html, options)
|
|
324
|
+
const now = new Date().toISOString()
|
|
325
|
+
|
|
326
|
+
// 4. Per-recipient row write + queue-recipient build.
|
|
327
|
+
const queueRecipients: QueueRecipient[] = []
|
|
328
|
+
for (const recipient of fresh) {
|
|
329
|
+
const trackingId = ulid()
|
|
330
|
+
const [unsubscribeToken, preferencesToken] = await Promise.all([
|
|
331
|
+
generateToken(options.signingSecret, {
|
|
332
|
+
subscriberId: recipient.subscriberId,
|
|
333
|
+
action: 'unsubscribe',
|
|
334
|
+
}),
|
|
335
|
+
generateToken(options.signingSecret, {
|
|
336
|
+
subscriberId: recipient.subscriberId,
|
|
337
|
+
action: 'preferences',
|
|
338
|
+
}),
|
|
339
|
+
])
|
|
340
|
+
|
|
341
|
+
await db.insert(emailSends).values({
|
|
342
|
+
id: ulid(),
|
|
343
|
+
subscriberId: recipient.subscriberId,
|
|
344
|
+
campaignId,
|
|
345
|
+
email: recipient.email,
|
|
346
|
+
subject: params.subject,
|
|
347
|
+
type: 'broadcast',
|
|
348
|
+
status: 'queued',
|
|
349
|
+
trackingId,
|
|
350
|
+
createdAt: now,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
queueRecipients.push({
|
|
354
|
+
email: recipient.email,
|
|
355
|
+
subscriberId: recipient.subscriberId,
|
|
356
|
+
trackingId,
|
|
357
|
+
unsubscribeToken,
|
|
358
|
+
preferencesToken,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 5. Batched fan-out, paced to the configured send rate.
|
|
363
|
+
const batches = chunk(queueRecipients, maxRecipientsPerMessage)
|
|
364
|
+
const intervalMs = messagesPerSecond > 0 ? Math.ceil(1000 / messagesPerSecond) : 0
|
|
365
|
+
|
|
366
|
+
for (let i = 0; i < batches.length; i++) {
|
|
367
|
+
const message: EmailQueueMessage = {
|
|
368
|
+
siteId: options.siteId,
|
|
369
|
+
type: 'broadcast',
|
|
370
|
+
recipients: batches[i],
|
|
371
|
+
subject: params.subject,
|
|
372
|
+
htmlTemplate: html,
|
|
373
|
+
from: `${options.senderName} <${options.fromAddress}>`,
|
|
374
|
+
replyTo: options.replyTo,
|
|
375
|
+
campaignId,
|
|
376
|
+
}
|
|
377
|
+
await queue.send(message)
|
|
378
|
+
|
|
379
|
+
if (intervalMs > 0 && i < batches.length - 1) {
|
|
380
|
+
await sleep(intervalMs)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
campaignId,
|
|
386
|
+
recipientCount: fresh.length,
|
|
387
|
+
suppressedCount,
|
|
388
|
+
alreadySentCount,
|
|
389
|
+
batchCount: batches.length,
|
|
390
|
+
dryRun: false,
|
|
391
|
+
}
|
|
392
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -9,6 +9,12 @@ export {
|
|
|
9
9
|
handleDelivery,
|
|
10
10
|
updateSendStatus,
|
|
11
11
|
} from './bounce.js'
|
|
12
|
+
export type {
|
|
13
|
+
BroadcastRecipient,
|
|
14
|
+
SendBroadcastParams,
|
|
15
|
+
SendBroadcastResult,
|
|
16
|
+
} from './broadcast.js'
|
|
17
|
+
export { sendBroadcast } from './broadcast.js'
|
|
12
18
|
export type { CloudflareEmailSender } from './providers.js'
|
|
13
19
|
export { CloudflareEmailProvider, getProvider, sleep } from './providers.js'
|
|
14
20
|
export type { CampaignSchedule, DigestSchedule } from './scheduling.js'
|