@growth-labs/mailer 0.4.7 → 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 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';
@@ -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,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,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"}
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
@@ -75,6 +75,7 @@ export default function mailer(userOptions) {
75
75
  }
76
76
  // Re-export options and types
77
77
  export { mailerOptionsSchema } from './options.js';
78
+ export { sendBroadcast } from './utils/broadcast.js';
78
79
  export { executeCampaignSchedule, executeDigestSchedule, prepareCampaign, prepareDigest, sendBatchCampaigns, } from './utils/scheduling.js';
79
80
  // Re-export utilities for standalone use
80
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":"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"}
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;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
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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"}
@@ -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"}
@@ -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';
@@ -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;AAEpB,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/mailer",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -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 define:vars={{ subscribePath: config.subscribePath, formId }}>
91
- ;(function () {
92
- const container = document.getElementById(formId)
93
- if (!container) return
94
-
95
- const form = container.querySelector('[data-gl-subscribe-form]')
96
- const submitBtn = container.querySelector('[data-gl-submit]')
97
- const messageEl = container.querySelector('[data-gl-message]')
98
- const successEl = container.querySelector('[data-gl-success]')
99
- const errorEl = container.querySelector('[data-gl-error]')
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 script = document.createElement('script')
104
- script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
105
- script.async = true
106
- document.head.appendChild(script)
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
- form?.addEventListener('submit', async function (e) {
110
- e.preventDefault()
111
- if (!form || !submitBtn) return
112
-
113
- submitBtn.setAttribute('disabled', 'true')
114
- messageEl?.setAttribute('hidden', '')
115
- successEl?.setAttribute('hidden', '')
116
- errorEl?.setAttribute('hidden', '')
117
-
118
- const formData = new FormData(form)
119
- const preferences = formData.getAll('preferences')
120
- const turnstileInput = form.querySelector(
121
- '[name="cf-turnstile-response"]',
122
- )
123
- const turnstileToken = turnstileInput?.value ?? ''
124
-
125
- try {
126
- const res = await fetch(subscribePath, {
127
- method: 'POST',
128
- headers: { 'Content-Type': 'application/json' },
129
- body: JSON.stringify({
130
- email: formData.get('email'),
131
- name: formData.get('name') || undefined,
132
- source: formData.get('source'),
133
- preferences: preferences.length ? preferences : undefined,
134
- turnstileToken,
135
- }),
136
- })
137
-
138
- const data = await res.json()
139
-
140
- messageEl?.removeAttribute('hidden')
141
- if (data.success) {
142
- successEl?.removeAttribute('hidden')
143
- form.reset()
144
- } else {
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 = data.error || 'Something went wrong'
147
- errorEl.removeAttribute('hidden')
162
+ errorEl.textContent = "Network error. Please try again.";
163
+ errorEl.removeAttribute("hidden");
148
164
  }
165
+ } finally {
166
+ submitBtn.removeAttribute("disabled");
149
167
  }
150
- } catch {
151
- messageEl?.removeAttribute('hidden')
152
- if (errorEl) {
153
- errorEl.textContent = 'Network error. Please try again.'
154
- errorEl.removeAttribute('hidden')
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
@@ -105,6 +105,12 @@ export type {
105
105
  TemplateData,
106
106
  TemplateName,
107
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'
108
114
  export type { CampaignSchedule, DigestSchedule } from './utils/scheduling.js'
109
115
  export {
110
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
+ }
@@ -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'