@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.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +3 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +5 -0
- package/dist/options.js.map +1 -1
- package/dist/routes/track-click.d.ts.map +1 -1
- package/dist/routes/track-click.js +3 -2
- package/dist/routes/track-click.js.map +1 -1
- package/dist/routes/track-open.d.ts.map +1 -1
- package/dist/routes/track-open.js +3 -2
- package/dist/routes/track-open.js.map +1 -1
- package/dist/routes/unsubscribe.d.ts.map +1 -1
- package/dist/routes/unsubscribe.js +3 -4
- package/dist/routes/unsubscribe.js.map +1 -1
- package/dist/routes/webhook.d.ts.map +1 -1
- package/dist/routes/webhook.js +8 -1
- package/dist/routes/webhook.js.map +1 -1
- package/dist/schema/sends.d.ts +19 -0
- package/dist/schema/sends.d.ts.map +1 -1
- package/dist/schema/sends.js +2 -0
- package/dist/schema/sends.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 +261 -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/dist/utils/send.d.ts.map +1 -1
- package/dist/utils/send.js +18 -10
- package/dist/utils/send.js.map +1 -1
- package/migrations/0002_add_site_id_to_email_sends.sql +4 -0
- package/package.json +2 -2
- package/src/components/SubscribeForm.astro +78 -65
- package/src/index.ts +6 -0
- package/src/options.ts +5 -0
- package/src/routes/track-click.ts +3 -2
- package/src/routes/track-open.ts +3 -2
- package/src/routes/unsubscribe.ts +3 -5
- package/src/routes/webhook.ts +9 -1
- package/src/schema/sends.ts +2 -0
- package/src/types.ts +2 -2
- package/src/utils/broadcast.ts +404 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/send.ts +23 -9
- 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
|
|
37
|
-
const
|
|
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
|
package/src/routes/track-open.ts
CHANGED
|
@@ -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
|
|
26
|
-
const
|
|
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
|
-
|
|
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}`,
|
package/src/routes/webhook.ts
CHANGED
|
@@ -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(
|
|
66
|
+
await handleDelivery(sendsDb, body.trackingId)
|
|
59
67
|
}
|
|
60
68
|
break
|
|
61
69
|
case 'bounce':
|
package/src/schema/sends.ts
CHANGED
|
@@ -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
|
+
}
|
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'
|
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
|
|
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
|
|
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,
|