@growth-labs/mailer 0.4.7 → 0.4.9

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.
Files changed (52) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/options.d.ts +3 -0
  6. package/dist/options.d.ts.map +1 -1
  7. package/dist/options.js +5 -0
  8. package/dist/options.js.map +1 -1
  9. package/dist/routes/track-click.d.ts.map +1 -1
  10. package/dist/routes/track-click.js +3 -2
  11. package/dist/routes/track-click.js.map +1 -1
  12. package/dist/routes/track-open.d.ts.map +1 -1
  13. package/dist/routes/track-open.js +3 -2
  14. package/dist/routes/track-open.js.map +1 -1
  15. package/dist/routes/unsubscribe.d.ts.map +1 -1
  16. package/dist/routes/unsubscribe.js +3 -4
  17. package/dist/routes/unsubscribe.js.map +1 -1
  18. package/dist/routes/webhook.d.ts.map +1 -1
  19. package/dist/routes/webhook.js +8 -1
  20. package/dist/routes/webhook.js.map +1 -1
  21. package/dist/schema/sends.d.ts +19 -0
  22. package/dist/schema/sends.d.ts.map +1 -1
  23. package/dist/schema/sends.js +2 -0
  24. package/dist/schema/sends.js.map +1 -1
  25. package/dist/types.d.ts +2 -2
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/utils/broadcast.d.ts +82 -0
  28. package/dist/utils/broadcast.d.ts.map +1 -0
  29. package/dist/utils/broadcast.js +261 -0
  30. package/dist/utils/broadcast.js.map +1 -0
  31. package/dist/utils/index.d.ts +2 -0
  32. package/dist/utils/index.d.ts.map +1 -1
  33. package/dist/utils/index.js +1 -0
  34. package/dist/utils/index.js.map +1 -1
  35. package/dist/utils/send.d.ts.map +1 -1
  36. package/dist/utils/send.js +18 -10
  37. package/dist/utils/send.js.map +1 -1
  38. package/migrations/0002_add_site_id_to_email_sends.sql +4 -0
  39. package/package.json +2 -2
  40. package/src/components/SubscribeForm.astro +78 -65
  41. package/src/index.ts +6 -0
  42. package/src/options.ts +5 -0
  43. package/src/routes/track-click.ts +3 -2
  44. package/src/routes/track-open.ts +3 -2
  45. package/src/routes/unsubscribe.ts +3 -5
  46. package/src/routes/webhook.ts +9 -1
  47. package/src/schema/sends.ts +2 -0
  48. package/src/types.ts +2 -2
  49. package/src/utils/broadcast.ts +404 -0
  50. package/src/utils/index.ts +6 -0
  51. package/src/utils/send.ts +23 -9
  52. package/src/virtual.d.ts +1 -0
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/options.ts CHANGED
@@ -12,6 +12,11 @@ export const mailerOptionsSchema = z.object({
12
12
 
13
13
  // ─── Cloudflare bindings ───
14
14
  d1Binding: z.string().default('SITE_DB'),
15
+ // D1 binding holding gl_email_sends (the send-record table). Defaults to
16
+ // d1Binding. Set to the realm mailer DB (e.g. 'MAILER_DB') on sites whose
17
+ // queue is drained by the central mail-platform Worker, so the consumer's
18
+ // send-status writeback and the producer's row writes share one DB.
19
+ sendsD1Binding: z.string().optional(),
15
20
  queueBinding: z.string().default('EMAIL_QUEUE'),
16
21
  senderBinding: z.string().default('EMAIL_SENDER'),
17
22
 
@@ -33,8 +33,9 @@ export const GET: APIRoute = async (context) => {
33
33
  try {
34
34
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
35
35
  if (bindingsEnv) {
36
- const d1 = bindingsEnv[config.d1Binding] as D1Database
37
- const schema = await probeMailerSchema(d1, config.d1Binding)
36
+ const sendsBindingName = config.sendsD1Binding ?? config.d1Binding
37
+ const d1 = bindingsEnv[sendsBindingName] as D1Database
38
+ const schema = await probeMailerSchema(d1, sendsBindingName)
38
39
  if (schema.ok) {
39
40
  const db = drizzle(d1)
40
41
  await db
@@ -22,8 +22,9 @@ export const GET: APIRoute = async (context) => {
22
22
  try {
23
23
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
24
24
  if (bindingsEnv) {
25
- const d1 = bindingsEnv[config.d1Binding] as D1Database
26
- const schema = await probeMailerSchema(d1, config.d1Binding)
25
+ const sendsBindingName = config.sendsD1Binding ?? config.d1Binding
26
+ const d1 = bindingsEnv[sendsBindingName] as D1Database
27
+ const schema = await probeMailerSchema(d1, sendsBindingName)
27
28
  if (schema.ok) {
28
29
  const db = drizzle(d1)
29
30
  await db
@@ -4,7 +4,6 @@ import type { APIRoute } from 'astro'
4
4
  import { drizzle } from 'drizzle-orm/d1'
5
5
  import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
6
6
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
7
- import type { MailerEnv } from '../utils/send.js'
8
7
  import { sendTransactional } from '../utils/send.js'
9
8
  import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
10
9
  import { generateToken, verifyToken } from '../utils/tokens.js'
@@ -24,7 +23,6 @@ async function processUnsubscribe(
24
23
  // Resolve bindings
25
24
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
26
25
  const d1 = bindingsEnv[config.d1Binding] as D1Database
27
- const queue = bindingsEnv[config.queueBinding] as Queue
28
26
 
29
27
  // Schema probe — short-circuit with a Response on miss; both GET and POST
30
28
  // handlers below forward it unchanged.
@@ -32,7 +30,6 @@ async function processUnsubscribe(
32
30
  if (!schema.ok) return { schemaMissing: true as const }
33
31
 
34
32
  const db = drizzle(d1)
35
- const env: MailerEnv = { DB: d1, QUEUE: queue }
36
33
 
37
34
  // Get subscriber
38
35
  const subscriber = await getSubscriberById(db, payload.subscriberId)
@@ -43,8 +40,9 @@ async function processUnsubscribe(
43
40
  // Unsubscribe
44
41
  await unsubscribeSubscriber(db, payload.subscriberId)
45
42
 
46
- // Send confirmation email
47
- await sendTransactional(env, config, {
43
+ // Send confirmation email — pass the full Worker env so config.d1Binding,
44
+ // config.queueBinding, and config.sendsD1Binding all resolve correctly.
45
+ await sendTransactional(bindingsEnv, config, {
48
46
  to: subscriber.email,
49
47
  subscriberId: subscriber.id,
50
48
  subject: `You've been unsubscribed from ${config.senderName}`,
@@ -40,6 +40,14 @@ export const POST: APIRoute = async (context) => {
40
40
  }
41
41
  const db = drizzle(d1)
42
42
 
43
+ // Resolve the sends DB (may differ from the subscribers DB when sendsD1Binding is set).
44
+ const sendsBindingName = config.sendsD1Binding ?? config.d1Binding
45
+ const sendsD1 = bindingsEnv[sendsBindingName] as D1Database | undefined
46
+ if (!sendsD1) {
47
+ return Response.json({ error: 'Sends D1 binding not available' }, { status: 500 })
48
+ }
49
+ const sendsDb = drizzle(sendsD1)
50
+
43
51
  emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_webhook_received', {
44
52
  request,
45
53
  context,
@@ -55,7 +63,7 @@ export const POST: APIRoute = async (context) => {
55
63
  switch (body.type) {
56
64
  case 'delivery':
57
65
  if (body.trackingId) {
58
- await handleDelivery(db, body.trackingId)
66
+ await handleDelivery(sendsDb, body.trackingId)
59
67
  }
60
68
  break
61
69
  case 'bounce':
@@ -5,6 +5,7 @@ export const emailSends = sqliteTable(
5
5
  {
6
6
  id: text('id').primaryKey(),
7
7
  subscriberId: text('subscriber_id').notNull(),
8
+ siteId: text('site_id'),
8
9
  campaignId: text('campaign_id'),
9
10
  email: text('email').notNull(),
10
11
  subject: text('subject').notNull(),
@@ -26,5 +27,6 @@ export const emailSends = sqliteTable(
26
27
  index('idx_sends_tracking').on(table.trackingId),
27
28
  index('idx_sends_status').on(table.status),
28
29
  index('idx_sends_type_created').on(table.type, table.createdAt),
30
+ index('idx_sends_site_campaign').on(table.siteId, table.campaignId),
29
31
  ],
30
32
  )
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,404 @@
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
+ /** Subscribers DB (gl_subscribers lives here). */
103
+ db: DrizzleDB
104
+ /** Sends DB (gl_email_sends lives here). Equals db when sendsD1Binding is unset. */
105
+ sendsDb: DrizzleDB
106
+ queue: Queue
107
+ }
108
+
109
+ function resolveProducerBindings(
110
+ env: MailerEnv,
111
+ options: ResolvedMailerOptions,
112
+ ): ResolvedProducerBindings {
113
+ const d1 = env[options.d1Binding] as D1Database | undefined
114
+ if (!d1) {
115
+ throw new Error(
116
+ `[mailer] env.${options.d1Binding} is undefined; ` +
117
+ 'pass d1Binding option to the mailer integration or bind that name in wrangler.toml.',
118
+ )
119
+ }
120
+ const sendsBindingName = options.sendsD1Binding ?? options.d1Binding
121
+ const sendsD1 = env[sendsBindingName] as D1Database | undefined
122
+ if (!sendsD1) {
123
+ throw new Error(
124
+ `[mailer] env.${sendsBindingName} is undefined; ` +
125
+ 'pass sendsD1Binding option to the mailer integration or bind that name in wrangler.toml.',
126
+ )
127
+ }
128
+ const queue = env[options.queueBinding] as Queue | undefined
129
+ if (!queue) {
130
+ throw new Error(
131
+ `[mailer] env.${options.queueBinding} is undefined; ` +
132
+ 'pass queueBinding option to the mailer integration or bind that name in wrangler.toml.',
133
+ )
134
+ }
135
+ return { db: drizzle(d1), sendsDb: drizzle(sendsD1), queue }
136
+ }
137
+
138
+ function normalizeRecipient(r: BroadcastRecipient | string): BroadcastRecipient {
139
+ return typeof r === 'string' ? { email: r } : r
140
+ }
141
+
142
+ /** A recipient resolved against `gl_subscribers`: sendable, with its id + status. */
143
+ interface ResolvedRecipient {
144
+ email: string
145
+ subscriberId: string
146
+ status: SubscriberStatus
147
+ }
148
+
149
+ /**
150
+ * Resolve each requested recipient against `gl_subscribers`:
151
+ * - dedupe by email (a list may repeat an address)
152
+ * - attach the canonical subscriberId + status
153
+ * - drop emails with no subscriber row (cannot honour unsubscribe → suppress)
154
+ * Returns the resolved sendable-candidate set plus the suppressed count.
155
+ */
156
+ async function resolveAgainstSubscribers(
157
+ db: DrizzleDB,
158
+ requested: BroadcastRecipient[],
159
+ ): Promise<{ candidates: ResolvedRecipient[]; suppressedCount: number }> {
160
+ // Dedupe requested emails (case-insensitive, trimmed) preserving first-seen order.
161
+ const byEmail = new Map<string, BroadcastRecipient>()
162
+ for (const r of requested) {
163
+ const key = r.email.trim().toLowerCase()
164
+ if (key.length === 0) continue
165
+ if (!byEmail.has(key)) byEmail.set(key, { ...r, email: r.email.trim() })
166
+ }
167
+
168
+ const emails = [...byEmail.values()].map((r) => r.email)
169
+ if (emails.length === 0) return { candidates: [], suppressedCount: 0 }
170
+
171
+ // Look up subscriber rows for all requested emails.
172
+ const rows = await db
173
+ .select({
174
+ id: subscribers.id,
175
+ email: subscribers.email,
176
+ status: subscribers.status,
177
+ })
178
+ .from(subscribers)
179
+ .where(inArray(subscribers.email, emails))
180
+
181
+ const rowByEmail = new Map<string, { id: string; status: SubscriberStatus }>()
182
+ for (const row of rows) {
183
+ rowByEmail.set((row.email as string).toLowerCase(), {
184
+ id: row.id as string,
185
+ status: row.status as SubscriberStatus,
186
+ })
187
+ }
188
+
189
+ const candidates: ResolvedRecipient[] = []
190
+ let suppressedCount = 0
191
+ for (const r of byEmail.values()) {
192
+ const row = rowByEmail.get(r.email.toLowerCase())
193
+ // No subscriber row, or a suppressed status → skip. Never send to an
194
+ // address we cannot honour an unsubscribe for, and never to a
195
+ // bounced/complained/unsubscribed/pending one.
196
+ if (!row || SUPPRESSED_STATUSES.has(row.status)) {
197
+ suppressedCount++
198
+ continue
199
+ }
200
+ candidates.push({ email: r.email, subscriberId: r.subscriberId ?? row.id, status: row.status })
201
+ }
202
+
203
+ return { candidates, suppressedCount }
204
+ }
205
+
206
+ /**
207
+ * Of the suppression-cleared candidates, drop any that already have a
208
+ * `gl_email_sends` row for this campaignId — the idempotency guard that makes a
209
+ * re-run a no-op for already-processed recipients.
210
+ */
211
+ async function filterAlreadySent(
212
+ db: DrizzleDB,
213
+ campaignId: string,
214
+ candidates: ResolvedRecipient[],
215
+ ): Promise<{ fresh: ResolvedRecipient[]; alreadySentCount: number }> {
216
+ if (candidates.length === 0) return { fresh: [], alreadySentCount: 0 }
217
+
218
+ const candidateEmails = candidates.map((c) => c.email)
219
+ const existing = await db
220
+ .select({ email: emailSends.email })
221
+ .from(emailSends)
222
+ .where(and(eq(emailSends.campaignId, campaignId), inArray(emailSends.email, candidateEmails)))
223
+
224
+ const sentEmails = new Set(existing.map((row) => (row.email as string).toLowerCase()))
225
+
226
+ const fresh: ResolvedRecipient[] = []
227
+ let alreadySentCount = 0
228
+ for (const c of candidates) {
229
+ if (sentEmails.has(c.email.toLowerCase())) {
230
+ alreadySentCount++
231
+ continue
232
+ }
233
+ fresh.push(c)
234
+ }
235
+
236
+ return { fresh, alreadySentCount }
237
+ }
238
+
239
+ /**
240
+ * Build the broadcast HTML with the tracking pixel + click-rewrite applied
241
+ * once (the per-recipient {{TRACKING_ID}} is substituted in the consumer).
242
+ */
243
+ function prepareBroadcastHtml(html: string, options: ResolvedMailerOptions): string {
244
+ const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`
245
+ const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`
246
+ return rewriteLinksForTracking(injectTrackingPixel(html, trackOpenUrl), trackClickUrl)
247
+ }
248
+
249
+ function chunk<T>(items: T[], size: number): T[][] {
250
+ const out: T[][] = []
251
+ for (let i = 0; i < items.length; i += size) {
252
+ out.push(items.slice(i, i + size))
253
+ }
254
+ return out
255
+ }
256
+
257
+ /**
258
+ * Send a one-off broadcast blast (Customer.io one-off-blast parity).
259
+ *
260
+ * - Takes an explicit recipient list, or a saved-segment name resolved via
261
+ * `resolveRecipients` (segmentation is NOT built here — WS5 bead 06).
262
+ * - Enforces the suppression list: only `active` subscribers are sent;
263
+ * unsubscribed / bounced / complained / pending / unknown addresses skip.
264
+ * - Idempotent on `campaignId`: a re-run never double-sends to a recipient that
265
+ * already has a `gl_email_sends` row for the campaign.
266
+ * - Writes a per-recipient `gl_email_sends` row (`type='broadcast'`, status
267
+ * `queued`) before enqueueing.
268
+ * - Fans out in batches sized for Cloudflare queue/send-rate limits, with
269
+ * optional `messagesPerSecond` pacing.
270
+ * - `dryRun` resolves + suppresses + dedupes and returns the recipient count
271
+ * WITHOUT writing rows or enqueueing anything.
272
+ */
273
+ export async function sendBroadcast(
274
+ env: MailerEnv,
275
+ options: ResolvedMailerOptions,
276
+ params: SendBroadcastParams,
277
+ ): Promise<SendBroadcastResult> {
278
+ const campaignId = params.campaignId ?? ulid()
279
+ const dryRun = params.dryRun ?? false
280
+ const maxRecipientsPerMessage = Math.max(
281
+ 1,
282
+ Math.min(params.maxRecipientsPerMessage ?? options.batchSize, 500),
283
+ )
284
+ const messagesPerSecond = params.messagesPerSecond ?? 0
285
+
286
+ const { db, sendsDb, queue } = resolveProducerBindings(env, options)
287
+
288
+ // 1. Resolve the recipient list — explicit list and/or a resolved segment.
289
+ const requestedRaw: Array<BroadcastRecipient | string> = [...(params.recipients ?? [])]
290
+ if (params.segmentName) {
291
+ if (!params.resolveRecipients) {
292
+ throw new Error(
293
+ '[mailer] sendBroadcast: segmentName given without resolveRecipients; ' +
294
+ 'segmentation is resolved by the caller (WS5 bead 06), not by the mailer.',
295
+ )
296
+ }
297
+ const resolved = await params.resolveRecipients(params.segmentName)
298
+ requestedRaw.push(...resolved)
299
+ }
300
+ if (requestedRaw.length === 0) {
301
+ throw new Error('[mailer] sendBroadcast: no recipients or segmentName provided')
302
+ }
303
+ const requested = requestedRaw.map(normalizeRecipient)
304
+
305
+ // 2. Suppression enforcement + dedupe — reads gl_subscribers (subscribers db).
306
+ const { candidates, suppressedCount } = await resolveAgainstSubscribers(db, requested)
307
+
308
+ // 3. Idempotency — drop recipients already recorded for this campaign (sends db).
309
+ const { fresh, alreadySentCount } = await filterAlreadySent(sendsDb, campaignId, candidates)
310
+
311
+ // Dry-run: report the counts and stop before any write or enqueue.
312
+ if (dryRun) {
313
+ return {
314
+ campaignId,
315
+ recipientCount: fresh.length,
316
+ suppressedCount,
317
+ alreadySentCount,
318
+ batchCount: 0,
319
+ dryRun: true,
320
+ }
321
+ }
322
+
323
+ if (fresh.length === 0) {
324
+ return {
325
+ campaignId,
326
+ recipientCount: 0,
327
+ suppressedCount,
328
+ alreadySentCount,
329
+ batchCount: 0,
330
+ dryRun: false,
331
+ }
332
+ }
333
+
334
+ const html = prepareBroadcastHtml(params.html, options)
335
+ const now = new Date().toISOString()
336
+
337
+ // 4. Per-recipient row write + queue-recipient build.
338
+ const queueRecipients: QueueRecipient[] = []
339
+ for (const recipient of fresh) {
340
+ const trackingId = ulid()
341
+ const [unsubscribeToken, preferencesToken] = await Promise.all([
342
+ generateToken(options.signingSecret, {
343
+ subscriberId: recipient.subscriberId,
344
+ action: 'unsubscribe',
345
+ }),
346
+ generateToken(options.signingSecret, {
347
+ subscriberId: recipient.subscriberId,
348
+ action: 'preferences',
349
+ }),
350
+ ])
351
+
352
+ await sendsDb.insert(emailSends).values({
353
+ id: ulid(),
354
+ subscriberId: recipient.subscriberId,
355
+ siteId: options.siteId,
356
+ campaignId,
357
+ email: recipient.email,
358
+ subject: params.subject,
359
+ type: 'broadcast',
360
+ status: 'queued',
361
+ trackingId,
362
+ createdAt: now,
363
+ })
364
+
365
+ queueRecipients.push({
366
+ email: recipient.email,
367
+ subscriberId: recipient.subscriberId,
368
+ trackingId,
369
+ unsubscribeToken,
370
+ preferencesToken,
371
+ })
372
+ }
373
+
374
+ // 5. Batched fan-out, paced to the configured send rate.
375
+ const batches = chunk(queueRecipients, maxRecipientsPerMessage)
376
+ const intervalMs = messagesPerSecond > 0 ? Math.ceil(1000 / messagesPerSecond) : 0
377
+
378
+ for (let i = 0; i < batches.length; i++) {
379
+ const message: EmailQueueMessage = {
380
+ siteId: options.siteId,
381
+ type: 'broadcast',
382
+ recipients: batches[i],
383
+ subject: params.subject,
384
+ htmlTemplate: html,
385
+ from: `${options.senderName} <${options.fromAddress}>`,
386
+ replyTo: options.replyTo,
387
+ campaignId,
388
+ }
389
+ await queue.send(message)
390
+
391
+ if (intervalMs > 0 && i < batches.length - 1) {
392
+ await sleep(intervalMs)
393
+ }
394
+ }
395
+
396
+ return {
397
+ campaignId,
398
+ recipientCount: fresh.length,
399
+ suppressedCount,
400
+ alreadySentCount,
401
+ batchCount: batches.length,
402
+ dryRun: false,
403
+ }
404
+ }
@@ -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'
package/src/utils/send.ts CHANGED
@@ -30,7 +30,10 @@ type DrizzleDB = ReturnType<typeof drizzle>
30
30
  export type MailerEnv = Record<string, unknown>
31
31
 
32
32
  interface ResolvedProducerBindings {
33
+ /** Subscribers DB (gl_subscribers lives here). */
33
34
  db: DrizzleDB
35
+ /** Sends DB (gl_email_sends lives here). Equals db when sendsD1Binding is unset. */
36
+ sendsDb: DrizzleDB
34
37
  queue: Queue
35
38
  }
36
39
 
@@ -45,6 +48,14 @@ function resolveProducerBindings(
45
48
  'pass d1Binding option to the mailer integration or bind that name in wrangler.toml.',
46
49
  )
47
50
  }
51
+ const sendsBindingName = options.sendsD1Binding ?? options.d1Binding
52
+ const sendsD1 = env[sendsBindingName] as D1Database | undefined
53
+ if (!sendsD1) {
54
+ throw new Error(
55
+ `[mailer] env.${sendsBindingName} is undefined; ` +
56
+ 'pass sendsD1Binding option to the mailer integration or bind that name in wrangler.toml.',
57
+ )
58
+ }
48
59
  const queue = env[options.queueBinding] as Queue | undefined
49
60
  if (!queue) {
50
61
  throw new Error(
@@ -52,7 +63,7 @@ function resolveProducerBindings(
52
63
  'pass queueBinding option to the mailer integration or bind that name in wrangler.toml.',
53
64
  )
54
65
  }
55
- return { db: drizzle(d1), queue }
66
+ return { db: drizzle(d1), sendsDb: drizzle(sendsD1), queue }
56
67
  }
57
68
 
58
69
  // ─── sendTransactional ───
@@ -69,7 +80,7 @@ export async function sendTransactional(
69
80
  data?: Record<string, unknown>
70
81
  },
71
82
  ): Promise<{ trackingId: string }> {
72
- const { db, queue } = resolveProducerBindings(env, options)
83
+ const { db, sendsDb, queue } = resolveProducerBindings(env, options)
73
84
  const trackingId = ulid()
74
85
  const subscriberId = params.subscriberId ?? ulid()
75
86
 
@@ -91,10 +102,11 @@ export async function sendTransactional(
91
102
  throw new Error('Either template or html must be provided')
92
103
  }
93
104
 
94
- // Record the send
95
- await db.insert(emailSends).values({
105
+ // Record the send (in the sends DB — may differ from the subscribers DB)
106
+ await sendsDb.insert(emailSends).values({
96
107
  id: ulid(),
97
108
  subscriberId,
109
+ siteId: options.siteId,
98
110
  email: params.to,
99
111
  subject: params.subject,
100
112
  type: 'transactional',
@@ -132,6 +144,7 @@ export async function sendTransactional(
132
144
  async function fanOutToSubscribers(
133
145
  queue: Queue,
134
146
  db: DrizzleDB,
147
+ sendsDb: DrizzleDB,
135
148
  options: ResolvedMailerOptions,
136
149
  params: {
137
150
  subject: string
@@ -162,9 +175,10 @@ async function fanOutToSubscribers(
162
175
  action: 'preferences',
163
176
  })
164
177
 
165
- await db.insert(emailSends).values({
178
+ await sendsDb.insert(emailSends).values({
166
179
  id: ulid(),
167
180
  subscriberId: sub.id,
181
+ siteId: options.siteId,
168
182
  campaignId: params.campaignId,
169
183
  email: sub.email,
170
184
  subject: params.subject,
@@ -217,7 +231,7 @@ export async function sendCampaign(
217
231
  },
218
232
  ): Promise<{ campaignId: string; recipientCount: number }> {
219
233
  const campaignId = params.campaignId ?? ulid()
220
- const { db, queue } = resolveProducerBindings(env, options)
234
+ const { db, sendsDb, queue } = resolveProducerBindings(env, options)
221
235
 
222
236
  // Render HTML
223
237
  let html: string
@@ -242,7 +256,7 @@ export async function sendCampaign(
242
256
  html = injectTrackingPixel(html, trackOpenUrl)
243
257
  html = rewriteLinksForTracking(html, trackClickUrl)
244
258
 
245
- const recipientCount = await fanOutToSubscribers(queue, db, options, {
259
+ const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, {
246
260
  subject: params.subject,
247
261
  html,
248
262
  campaignId,
@@ -267,7 +281,7 @@ export async function sendDigest(
267
281
  },
268
282
  ): Promise<{ campaignId: string; recipientCount: number }> {
269
283
  const campaignId = params.campaignId ?? ulid()
270
- const { db, queue } = resolveProducerBindings(env, options)
284
+ const { db, sendsDb, queue } = resolveProducerBindings(env, options)
271
285
 
272
286
  // Render digest items then full template
273
287
  const itemsHtml = renderDigestItems(params.items)
@@ -287,7 +301,7 @@ export async function sendDigest(
287
301
  html = injectTrackingPixel(html, trackOpenUrl)
288
302
  html = rewriteLinksForTracking(html, trackClickUrl)
289
303
 
290
- const recipientCount = await fanOutToSubscribers(queue, db, options, {
304
+ const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, {
291
305
  subject: params.subject,
292
306
  html,
293
307
  campaignId,
package/src/virtual.d.ts CHANGED
@@ -6,6 +6,7 @@ declare module 'virtual:growth-labs/mailer/config' {
6
6
  fromAddress: string
7
7
  replyTo?: string
8
8
  d1Binding: string
9
+ sendsD1Binding?: string
9
10
  queueBinding: string
10
11
  senderBinding: string
11
12
  siteConfigLookup?: SiteConfigLookup