@growth-labs/mailer 0.3.0 → 0.4.1
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/_internal/schema-probe.d.ts +6 -0
- package/dist/_internal/schema-probe.d.ts.map +1 -1
- package/dist/_internal/schema-probe.js +36 -0
- package/dist/_internal/schema-probe.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +24 -14
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -0
- package/dist/options.js.map +1 -1
- package/dist/queue/consumer.d.ts +9 -8
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +68 -17
- package/dist/queue/consumer.js.map +1 -1
- package/dist/routes/track-click.js +1 -1
- package/dist/routes/track-click.js.map +1 -1
- package/dist/routes/track-open.js +1 -1
- package/dist/routes/track-open.js.map +1 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +3 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/sends.d.ts +312 -0
- package/dist/schema/sends.d.ts.map +1 -0
- package/dist/schema/sends.js +26 -0
- package/dist/schema/sends.js.map +1 -0
- package/dist/schema/subscribers.d.ts +253 -0
- package/dist/schema/subscribers.d.ts.map +1 -0
- package/dist/schema/subscribers.js +21 -0
- package/dist/schema/subscribers.js.map +1 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/analytics.d.ts +7 -3
- package/dist/utils/analytics.d.ts.map +1 -1
- package/dist/utils/analytics.js.map +1 -1
- package/dist/utils/bounce.d.ts.map +1 -1
- package/dist/utils/bounce.js +2 -1
- package/dist/utils/bounce.js.map +1 -1
- package/dist/utils/send.d.ts +7 -4
- package/dist/utils/send.d.ts.map +1 -1
- package/dist/utils/send.js +24 -9
- package/dist/utils/send.js.map +1 -1
- package/dist/utils/subscribers.js +1 -1
- package/dist/utils/subscribers.js.map +1 -1
- package/package.json +12 -4
- package/src/_internal/schema-probe.ts +41 -0
- package/src/index.ts +2 -1
- package/src/options.ts +6 -0
- package/src/queue/consumer.ts +142 -42
- package/src/routes/track-click.ts +1 -1
- package/src/routes/track-open.ts +1 -1
- package/src/schema/index.ts +2 -0
- package/src/schema/sends.ts +30 -0
- package/src/schema/subscribers.ts +25 -0
- package/src/types.ts +34 -0
- package/src/utils/analytics.ts +8 -4
- package/src/utils/bounce.ts +2 -1
- package/src/utils/send.ts +43 -12
- package/src/utils/subscribers.ts +1 -1
- package/src/virtual.d.ts +4 -0
- package/src/schema.ts +0 -56
package/src/queue/consumer.ts
CHANGED
|
@@ -1,48 +1,116 @@
|
|
|
1
1
|
import { drizzle } from 'drizzle-orm/d1'
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
import type { EmailProvider, EmailQueueMessage } from '../types.js'
|
|
2
|
+
import { probeMailerSendsSchema } from '../_internal/schema-probe.js'
|
|
3
|
+
import type { EmailQueueMessage, SiteConfigLookup, SiteMailerConfig } from '../types.js'
|
|
5
4
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
6
5
|
import { updateSendStatus } from '../utils/bounce.js'
|
|
7
6
|
import type { CloudflareEmailSender } from '../utils/providers.js'
|
|
8
7
|
import { CloudflareEmailProvider, sleep } from '../utils/providers.js'
|
|
9
8
|
|
|
9
|
+
export type { SiteConfigLookup, SiteMailerConfig } from '../types.js'
|
|
10
|
+
|
|
11
|
+
export interface MailerConsumerOptions {
|
|
12
|
+
d1Binding: string
|
|
13
|
+
senderBinding: string
|
|
14
|
+
analyticsBinding?: string
|
|
15
|
+
siteConfigLookup: SiteConfigLookup
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Effective options for a single message, resolved from `siteConfigLookup`. */
|
|
19
|
+
interface EffectiveOptions {
|
|
20
|
+
siteUrl?: string
|
|
21
|
+
unsubscribePath: string
|
|
22
|
+
preferencesPath: string
|
|
23
|
+
senderName?: string
|
|
24
|
+
fromAddress?: string
|
|
25
|
+
replyTo?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function applySiteConfig(config: SiteMailerConfig): EffectiveOptions {
|
|
29
|
+
return {
|
|
30
|
+
siteUrl: config.siteUrl,
|
|
31
|
+
unsubscribePath: config.unsubscribePath ?? '/api/newsletter/unsubscribe',
|
|
32
|
+
preferencesPath: config.preferencesPath ?? '/email/preferences',
|
|
33
|
+
senderName: config.senderName,
|
|
34
|
+
fromAddress: config.fromAddress,
|
|
35
|
+
replyTo: config.replyTo,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
10
39
|
export async function handleEmailQueue(
|
|
11
40
|
batch: MessageBatch<EmailQueueMessage>,
|
|
12
|
-
env:
|
|
13
|
-
|
|
14
|
-
EMAIL_SENDER?: CloudflareEmailSender
|
|
15
|
-
[key: string]: unknown
|
|
16
|
-
},
|
|
17
|
-
options: ResolvedMailerOptions,
|
|
41
|
+
env: Record<string, unknown>,
|
|
42
|
+
options: MailerConsumerOptions,
|
|
18
43
|
): Promise<void> {
|
|
44
|
+
const d1 = env[options.d1Binding] as D1Database | undefined
|
|
45
|
+
if (!d1) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[mailer] env.${options.d1Binding} is undefined; ` +
|
|
48
|
+
'pass d1Binding option to the mailer integration or bind that name in wrangler.toml.',
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sender = env[options.senderBinding] as CloudflareEmailSender | undefined
|
|
53
|
+
if (!sender) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[mailer] env.${options.senderBinding} is undefined; ` +
|
|
56
|
+
'pass senderBinding option to the mailer integration or bind a [[send_email]] entry of that name.',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
19
60
|
// Schema probe — runs once per Worker instance. On miss the probe logs
|
|
20
61
|
// GL_MAILER_SCHEMA_MISSING and we ack every message in the batch without
|
|
21
62
|
// retry. Re-queueing without the schema would cycle indefinitely and burn
|
|
22
63
|
// Cloudflare Queue retry budget.
|
|
23
|
-
const schemaProbe = await
|
|
64
|
+
const schemaProbe = await probeMailerSendsSchema(d1, options.d1Binding)
|
|
24
65
|
if (!schemaProbe.ok) {
|
|
66
|
+
console.error(
|
|
67
|
+
'[mailer] gl_email_sends not found in env.' +
|
|
68
|
+
options.d1Binding +
|
|
69
|
+
' — apply mailer migrations before processing queue. ' +
|
|
70
|
+
'Acking ' +
|
|
71
|
+
batch.messages.length +
|
|
72
|
+
' message(s) to avoid retry storm.',
|
|
73
|
+
)
|
|
25
74
|
for (const message of batch.messages) {
|
|
26
75
|
message.ack()
|
|
27
76
|
}
|
|
28
77
|
return
|
|
29
78
|
}
|
|
30
79
|
|
|
31
|
-
const db = drizzle(
|
|
32
|
-
const provider
|
|
33
|
-
? new CloudflareEmailProvider(env.EMAIL_SENDER)
|
|
34
|
-
: new CloudflareEmailProvider({
|
|
35
|
-
send: () => Promise.reject(new Error('No email sender configured')),
|
|
36
|
-
})
|
|
80
|
+
const db = drizzle(d1)
|
|
81
|
+
const provider = new CloudflareEmailProvider(sender)
|
|
37
82
|
|
|
38
83
|
for (const message of batch.messages) {
|
|
84
|
+
const siteConfig = await options.siteConfigLookup(message.body.siteId, env)
|
|
85
|
+
if (!siteConfig) {
|
|
86
|
+
console.error(
|
|
87
|
+
`[mailer] No SiteMailerConfig for siteId=${message.body.siteId}; acking without send.`,
|
|
88
|
+
)
|
|
89
|
+
message.ack()
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
const effective = applySiteConfig(siteConfig)
|
|
93
|
+
|
|
39
94
|
const { recipients, htmlTemplate, subject, from, replyTo, headers, type } = message.body
|
|
95
|
+
if (type !== 'transactional' && !effective.siteUrl) {
|
|
96
|
+
console.error(
|
|
97
|
+
`[mailer] SiteMailerConfig for siteId=${message.body.siteId} is missing siteUrl; acking without send.`,
|
|
98
|
+
)
|
|
99
|
+
message.ack()
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const effectiveFrom = effective.fromAddress
|
|
104
|
+
? formatFromHeader(effective.senderName ?? effective.fromAddress, effective.fromAddress)
|
|
105
|
+
: from
|
|
106
|
+
const effectiveReplyTo = effective.replyTo ?? replyTo
|
|
40
107
|
|
|
41
108
|
for (const recipient of recipients) {
|
|
42
109
|
let html = htmlTemplate
|
|
43
110
|
if (type !== 'transactional') {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
111
|
+
const siteUrl = effective.siteUrl ?? ''
|
|
112
|
+
const unsubscribeUrl = `${siteUrl}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}`
|
|
113
|
+
const preferencesUrl = `${siteUrl}${effective.preferencesPath}?token=${recipient.preferencesToken}`
|
|
46
114
|
html = html
|
|
47
115
|
.replaceAll('{{TRACKING_ID}}', recipient.trackingId)
|
|
48
116
|
.replaceAll('{{UNSUBSCRIBE_URL}}', unsubscribeUrl)
|
|
@@ -53,15 +121,15 @@ export async function handleEmailQueue(
|
|
|
53
121
|
type !== 'transactional'
|
|
54
122
|
? {
|
|
55
123
|
...headers,
|
|
56
|
-
'List-Unsubscribe': `<${
|
|
124
|
+
'List-Unsubscribe': `<${effective.siteUrl ?? ''}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}>`,
|
|
57
125
|
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
|
58
126
|
}
|
|
59
127
|
: headers
|
|
60
128
|
|
|
61
129
|
let result = await provider.send({
|
|
62
130
|
to: recipient.email,
|
|
63
|
-
from,
|
|
64
|
-
replyTo,
|
|
131
|
+
from: effectiveFrom,
|
|
132
|
+
replyTo: effectiveReplyTo,
|
|
65
133
|
subject,
|
|
66
134
|
html,
|
|
67
135
|
headers: recipientHeaders,
|
|
@@ -72,8 +140,8 @@ export async function handleEmailQueue(
|
|
|
72
140
|
await sleep(2 ** attempt * 1000)
|
|
73
141
|
result = await provider.send({
|
|
74
142
|
to: recipient.email,
|
|
75
|
-
from,
|
|
76
|
-
replyTo,
|
|
143
|
+
from: effectiveFrom,
|
|
144
|
+
replyTo: effectiveReplyTo,
|
|
77
145
|
subject,
|
|
78
146
|
html,
|
|
79
147
|
headers: recipientHeaders,
|
|
@@ -86,32 +154,44 @@ export async function handleEmailQueue(
|
|
|
86
154
|
await updateSendStatus(db, recipient.trackingId, 'sent', {
|
|
87
155
|
sentAt: new Date().toISOString(),
|
|
88
156
|
})
|
|
89
|
-
emitMailerAnalyticsEvent(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
157
|
+
emitMailerAnalyticsEvent(
|
|
158
|
+
analyticsOptions(options, effective, message.body.siteId),
|
|
159
|
+
env,
|
|
160
|
+
'newsletter_sent',
|
|
161
|
+
{
|
|
162
|
+
contentSlug: recipient.trackingId,
|
|
163
|
+
label: {
|
|
164
|
+
trackingId: recipient.trackingId,
|
|
165
|
+
subscriberId: recipient.subscriberId,
|
|
166
|
+
email: recipient.email,
|
|
167
|
+
campaignId: message.body.campaignId,
|
|
168
|
+
type,
|
|
169
|
+
siteId: message.body.siteId,
|
|
170
|
+
},
|
|
97
171
|
},
|
|
98
|
-
|
|
172
|
+
)
|
|
99
173
|
} else {
|
|
100
174
|
await updateSendStatus(db, recipient.trackingId, 'bounced', {
|
|
101
175
|
bouncedAt: new Date().toISOString(),
|
|
102
176
|
bounceType: 'hard',
|
|
103
177
|
})
|
|
104
|
-
emitMailerAnalyticsEvent(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
178
|
+
emitMailerAnalyticsEvent(
|
|
179
|
+
analyticsOptions(options, effective, message.body.siteId),
|
|
180
|
+
env,
|
|
181
|
+
'newsletter_send_failed',
|
|
182
|
+
{
|
|
183
|
+
contentSlug: recipient.trackingId,
|
|
184
|
+
label: {
|
|
185
|
+
trackingId: recipient.trackingId,
|
|
186
|
+
subscriberId: recipient.subscriberId,
|
|
187
|
+
email: recipient.email,
|
|
188
|
+
campaignId: message.body.campaignId,
|
|
189
|
+
type,
|
|
190
|
+
siteId: message.body.siteId,
|
|
191
|
+
error: result.error,
|
|
192
|
+
},
|
|
113
193
|
},
|
|
114
|
-
|
|
194
|
+
)
|
|
115
195
|
console.error(`[mailer] Send failed for ${recipient.email}: ${result.error}`)
|
|
116
196
|
}
|
|
117
197
|
}
|
|
@@ -119,3 +199,23 @@ export async function handleEmailQueue(
|
|
|
119
199
|
message.ack()
|
|
120
200
|
}
|
|
121
201
|
}
|
|
202
|
+
|
|
203
|
+
function formatFromHeader(senderName: string, fromAddress: string): string {
|
|
204
|
+
return `${senderName} <${fromAddress}>`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function analyticsOptions(
|
|
208
|
+
options: MailerConsumerOptions,
|
|
209
|
+
effective: EffectiveOptions,
|
|
210
|
+
siteId: string,
|
|
211
|
+
): {
|
|
212
|
+
siteUrl: string
|
|
213
|
+
analyticsEnabled: boolean
|
|
214
|
+
analyticsBinding: string
|
|
215
|
+
} {
|
|
216
|
+
return {
|
|
217
|
+
siteUrl: effective.siteUrl ?? `https://${siteId}`,
|
|
218
|
+
analyticsEnabled: Boolean(options.analyticsBinding),
|
|
219
|
+
analyticsBinding: options.analyticsBinding ?? 'ANALYTICS',
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { APIRoute } from 'astro'
|
|
|
4
4
|
import { and, eq, inArray } from 'drizzle-orm'
|
|
5
5
|
import { drizzle } from 'drizzle-orm/d1'
|
|
6
6
|
import { probeMailerSchema } from '../_internal/schema-probe.js'
|
|
7
|
-
import { emailSends } from '../schema.js'
|
|
7
|
+
import { emailSends } from '../schema/sends.js'
|
|
8
8
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
9
9
|
|
|
10
10
|
export const GET: APIRoute = async (context) => {
|
package/src/routes/track-open.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { APIRoute } from 'astro'
|
|
|
4
4
|
import { and, eq, inArray } from 'drizzle-orm'
|
|
5
5
|
import { drizzle } from 'drizzle-orm/d1'
|
|
6
6
|
import { probeMailerSchema } from '../_internal/schema-probe.js'
|
|
7
|
-
import { emailSends } from '../schema.js'
|
|
7
|
+
import { emailSends } from '../schema/sends.js'
|
|
8
8
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
9
9
|
import { TRANSPARENT_GIF } from '../utils/tracking.js'
|
|
10
10
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
export const emailSends = sqliteTable(
|
|
4
|
+
'gl_email_sends',
|
|
5
|
+
{
|
|
6
|
+
id: text('id').primaryKey(),
|
|
7
|
+
subscriberId: text('subscriber_id').notNull(),
|
|
8
|
+
campaignId: text('campaign_id'),
|
|
9
|
+
email: text('email').notNull(),
|
|
10
|
+
subject: text('subject').notNull(),
|
|
11
|
+
type: text('type').notNull(),
|
|
12
|
+
status: text('status').notNull().default('queued'),
|
|
13
|
+
sentAt: text('sent_at'),
|
|
14
|
+
deliveredAt: text('delivered_at'),
|
|
15
|
+
openedAt: text('opened_at'),
|
|
16
|
+
clickedAt: text('clicked_at'),
|
|
17
|
+
bouncedAt: text('bounced_at'),
|
|
18
|
+
bounceType: text('bounce_type'),
|
|
19
|
+
complainedAt: text('complained_at'),
|
|
20
|
+
trackingId: text('tracking_id').notNull().unique(),
|
|
21
|
+
createdAt: text('created_at').notNull(),
|
|
22
|
+
},
|
|
23
|
+
(table) => [
|
|
24
|
+
index('idx_sends_subscriber').on(table.subscriberId),
|
|
25
|
+
index('idx_sends_campaign').on(table.campaignId),
|
|
26
|
+
index('idx_sends_tracking').on(table.trackingId),
|
|
27
|
+
index('idx_sends_status').on(table.status),
|
|
28
|
+
index('idx_sends_type_created').on(table.type, table.createdAt),
|
|
29
|
+
],
|
|
30
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
export const subscribers = sqliteTable(
|
|
4
|
+
'gl_subscribers',
|
|
5
|
+
{
|
|
6
|
+
id: text('id').primaryKey(),
|
|
7
|
+
email: text('email').notNull().unique(),
|
|
8
|
+
name: text('name'),
|
|
9
|
+
status: text('status').notNull().default('pending'),
|
|
10
|
+
preferences: text('preferences').notNull().default('[]'),
|
|
11
|
+
source: text('source').notNull(),
|
|
12
|
+
attribution: text('attribution'),
|
|
13
|
+
softBounceCount: integer('soft_bounce_count').notNull().default(0),
|
|
14
|
+
subscribedAt: text('subscribed_at').notNull(),
|
|
15
|
+
confirmedAt: text('confirmed_at'),
|
|
16
|
+
unsubscribedAt: text('unsubscribed_at'),
|
|
17
|
+
createdAt: text('created_at').notNull(),
|
|
18
|
+
updatedAt: text('updated_at').notNull(),
|
|
19
|
+
},
|
|
20
|
+
(table) => [
|
|
21
|
+
index('idx_subscribers_status').on(table.status),
|
|
22
|
+
index('idx_subscribers_email').on(table.email),
|
|
23
|
+
index('idx_subscribers_subscribed_at').on(table.subscribedAt),
|
|
24
|
+
],
|
|
25
|
+
)
|
package/src/types.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface SubscriberFilter {
|
|
|
38
38
|
// ─── Email Queue message ───
|
|
39
39
|
|
|
40
40
|
export interface EmailQueueMessage {
|
|
41
|
+
siteId: string
|
|
41
42
|
type: 'transactional' | 'campaign' | 'digest'
|
|
42
43
|
recipients: QueueRecipient[]
|
|
43
44
|
subject: string
|
|
@@ -48,6 +49,39 @@ export interface EmailQueueMessage {
|
|
|
48
49
|
campaignId?: string
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// ─── Realm-level multi-tenant config lookup ───
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Per-site config resolved at consumer time. Realm consumers keep bindings in
|
|
56
|
+
* their own Worker config and load sender / URL metadata from this object for
|
|
57
|
+
* every queued message.
|
|
58
|
+
*/
|
|
59
|
+
export interface SiteMailerConfig {
|
|
60
|
+
siteUrl?: string
|
|
61
|
+
unsubscribePath?: string
|
|
62
|
+
preferencesPath?: string
|
|
63
|
+
senderName?: string
|
|
64
|
+
fromAddress?: string
|
|
65
|
+
replyTo?: string
|
|
66
|
+
signingSecret?: string
|
|
67
|
+
brand?: {
|
|
68
|
+
logoUrl?: string
|
|
69
|
+
primaryColor?: string
|
|
70
|
+
accentColor?: string
|
|
71
|
+
footerText?: string
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Per-message lookup, called by the realm consumer with the message's
|
|
77
|
+
* `siteId`. Returns the per-site config to apply, or `null` when the consumer
|
|
78
|
+
* should ack the message without sending because the registry entry is absent.
|
|
79
|
+
*/
|
|
80
|
+
export type SiteConfigLookup = (
|
|
81
|
+
siteId: string,
|
|
82
|
+
env: Record<string, unknown>,
|
|
83
|
+
) => Promise<SiteMailerConfig | null>
|
|
84
|
+
|
|
51
85
|
export interface QueueRecipient {
|
|
52
86
|
email: string
|
|
53
87
|
subscriberId: string
|
package/src/utils/analytics.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { ResolvedMailerOptions } from '../options.js'
|
|
2
|
-
|
|
3
1
|
export type MailerAnalyticsEvent =
|
|
4
2
|
| 'newsletter_subscribed'
|
|
5
3
|
| 'newsletter_confirmed'
|
|
@@ -25,6 +23,12 @@ interface AnalyticsContext {
|
|
|
25
23
|
}
|
|
26
24
|
}
|
|
27
25
|
|
|
26
|
+
export interface MailerAnalyticsRuntimeOptions {
|
|
27
|
+
siteUrl: string
|
|
28
|
+
analyticsEnabled: boolean
|
|
29
|
+
analyticsBinding: string
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
interface EmitMailerAnalyticsOptions {
|
|
29
33
|
request?: Request
|
|
30
34
|
context?: AnalyticsContext
|
|
@@ -34,7 +38,7 @@ interface EmitMailerAnalyticsOptions {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export function emitMailerAnalyticsEvent(
|
|
37
|
-
options:
|
|
41
|
+
options: MailerAnalyticsRuntimeOptions,
|
|
38
42
|
bindingsEnv: Record<string, unknown>,
|
|
39
43
|
eventName: MailerAnalyticsEvent,
|
|
40
44
|
emitOptions: EmitMailerAnalyticsOptions = {},
|
|
@@ -56,7 +60,7 @@ export function emitMailerAnalyticsEvent(
|
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
export function buildMailerAnalyticsDataPoint(
|
|
59
|
-
options:
|
|
63
|
+
options: MailerAnalyticsRuntimeOptions,
|
|
60
64
|
eventName: MailerAnalyticsEvent,
|
|
61
65
|
emitOptions: EmitMailerAnalyticsOptions = {},
|
|
62
66
|
): { blobs: string[]; doubles: number[]; indexes: string[] } {
|
package/src/utils/bounce.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { eq, sql } from 'drizzle-orm'
|
|
2
2
|
import type { drizzle } from 'drizzle-orm/d1'
|
|
3
|
-
import { emailSends
|
|
3
|
+
import { emailSends } from '../schema/sends.js'
|
|
4
|
+
import { subscribers } from '../schema/subscribers.js'
|
|
4
5
|
|
|
5
6
|
type DrizzleDB = ReturnType<typeof drizzle>
|
|
6
7
|
|
package/src/utils/send.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm'
|
|
|
2
2
|
import { drizzle } from 'drizzle-orm/d1'
|
|
3
3
|
import { ulid } from 'ulidx'
|
|
4
4
|
import type { ResolvedMailerOptions } from '../options.js'
|
|
5
|
-
import { emailSends } from '../schema.js'
|
|
5
|
+
import { emailSends } from '../schema/sends.js'
|
|
6
6
|
import type {
|
|
7
7
|
DigestItem,
|
|
8
8
|
EmailQueueMessage,
|
|
@@ -21,9 +21,38 @@ type DrizzleDB = ReturnType<typeof drizzle>
|
|
|
21
21
|
|
|
22
22
|
// ─── MailerEnv ───
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Producer-side env. The actual binding *names* come from
|
|
26
|
+
* `options.d1Binding` / `options.queueBinding` (default `SITE_DB` /
|
|
27
|
+
* `EMAIL_QUEUE`); MailerEnv is a generic record so consumers can pass
|
|
28
|
+
* their full Worker env without renaming bindings.
|
|
29
|
+
*/
|
|
30
|
+
export type MailerEnv = Record<string, unknown>
|
|
31
|
+
|
|
32
|
+
interface ResolvedProducerBindings {
|
|
33
|
+
db: DrizzleDB
|
|
34
|
+
queue: Queue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveProducerBindings(
|
|
38
|
+
env: MailerEnv,
|
|
39
|
+
options: ResolvedMailerOptions,
|
|
40
|
+
): ResolvedProducerBindings {
|
|
41
|
+
const d1 = env[options.d1Binding] as D1Database | undefined
|
|
42
|
+
if (!d1) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[mailer] env.${options.d1Binding} is undefined; ` +
|
|
45
|
+
'pass d1Binding option to the mailer integration or bind that name in wrangler.toml.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const queue = env[options.queueBinding] as Queue | undefined
|
|
49
|
+
if (!queue) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[mailer] env.${options.queueBinding} is undefined; ` +
|
|
52
|
+
'pass queueBinding option to the mailer integration or bind that name in wrangler.toml.',
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
return { db: drizzle(d1), queue }
|
|
27
56
|
}
|
|
28
57
|
|
|
29
58
|
// ─── sendTransactional ───
|
|
@@ -40,7 +69,7 @@ export async function sendTransactional(
|
|
|
40
69
|
data?: Record<string, unknown>
|
|
41
70
|
},
|
|
42
71
|
): Promise<{ trackingId: string }> {
|
|
43
|
-
const db =
|
|
72
|
+
const { db, queue } = resolveProducerBindings(env, options)
|
|
44
73
|
const trackingId = ulid()
|
|
45
74
|
const subscriberId = params.subscriberId ?? ulid()
|
|
46
75
|
|
|
@@ -76,6 +105,7 @@ export async function sendTransactional(
|
|
|
76
105
|
|
|
77
106
|
// Build and enqueue the message
|
|
78
107
|
const message: EmailQueueMessage = {
|
|
108
|
+
siteId: options.siteId,
|
|
79
109
|
type: 'transactional',
|
|
80
110
|
recipients: [
|
|
81
111
|
{
|
|
@@ -92,7 +122,7 @@ export async function sendTransactional(
|
|
|
92
122
|
replyTo: options.replyTo,
|
|
93
123
|
}
|
|
94
124
|
|
|
95
|
-
await
|
|
125
|
+
await queue.send(message)
|
|
96
126
|
|
|
97
127
|
return { trackingId }
|
|
98
128
|
}
|
|
@@ -100,7 +130,7 @@ export async function sendTransactional(
|
|
|
100
130
|
// ─── Fan-out helper (shared by campaign + digest) ───
|
|
101
131
|
|
|
102
132
|
async function fanOutToSubscribers(
|
|
103
|
-
|
|
133
|
+
queue: Queue,
|
|
104
134
|
db: DrizzleDB,
|
|
105
135
|
options: ResolvedMailerOptions,
|
|
106
136
|
params: {
|
|
@@ -154,6 +184,7 @@ async function fanOutToSubscribers(
|
|
|
154
184
|
}
|
|
155
185
|
|
|
156
186
|
const message: EmailQueueMessage = {
|
|
187
|
+
siteId: options.siteId,
|
|
157
188
|
type: params.type,
|
|
158
189
|
recipients,
|
|
159
190
|
subject: params.subject,
|
|
@@ -162,7 +193,7 @@ async function fanOutToSubscribers(
|
|
|
162
193
|
replyTo: options.replyTo,
|
|
163
194
|
campaignId: params.campaignId,
|
|
164
195
|
}
|
|
165
|
-
await
|
|
196
|
+
await queue.send(message)
|
|
166
197
|
|
|
167
198
|
recipientCount += batch.length
|
|
168
199
|
cursor = batch[batch.length - 1].id
|
|
@@ -186,7 +217,7 @@ export async function sendCampaign(
|
|
|
186
217
|
},
|
|
187
218
|
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
188
219
|
const campaignId = params.campaignId ?? ulid()
|
|
189
|
-
const db =
|
|
220
|
+
const { db, queue } = resolveProducerBindings(env, options)
|
|
190
221
|
|
|
191
222
|
// Render HTML
|
|
192
223
|
let html: string
|
|
@@ -211,7 +242,7 @@ export async function sendCampaign(
|
|
|
211
242
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
212
243
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
213
244
|
|
|
214
|
-
const recipientCount = await fanOutToSubscribers(
|
|
245
|
+
const recipientCount = await fanOutToSubscribers(queue, db, options, {
|
|
215
246
|
subject: params.subject,
|
|
216
247
|
html,
|
|
217
248
|
campaignId,
|
|
@@ -236,7 +267,7 @@ export async function sendDigest(
|
|
|
236
267
|
},
|
|
237
268
|
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
238
269
|
const campaignId = params.campaignId ?? ulid()
|
|
239
|
-
const db =
|
|
270
|
+
const { db, queue } = resolveProducerBindings(env, options)
|
|
240
271
|
|
|
241
272
|
// Render digest items then full template
|
|
242
273
|
const itemsHtml = renderDigestItems(params.items)
|
|
@@ -256,7 +287,7 @@ export async function sendDigest(
|
|
|
256
287
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
257
288
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
258
289
|
|
|
259
|
-
const recipientCount = await fanOutToSubscribers(
|
|
290
|
+
const recipientCount = await fanOutToSubscribers(queue, db, options, {
|
|
260
291
|
subject: params.subject,
|
|
261
292
|
html,
|
|
262
293
|
campaignId,
|
package/src/utils/subscribers.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { and, eq, gt, inArray, like, lt, or, sql } from 'drizzle-orm'
|
|
2
2
|
import type { drizzle } from 'drizzle-orm/d1'
|
|
3
3
|
import { ulid } from 'ulidx'
|
|
4
|
-
import { subscribers } from '../schema.js'
|
|
4
|
+
import { subscribers } from '../schema/subscribers.js'
|
|
5
5
|
import type { Subscriber, SubscriberAttribution, SubscriberFilter } from '../types.js'
|
|
6
6
|
|
|
7
7
|
type DrizzleDB = ReturnType<typeof drizzle>
|
package/src/virtual.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
declare module 'virtual:growth-labs/mailer/config' {
|
|
2
|
+
import type { SiteConfigLookup } from './types.js'
|
|
2
3
|
export const config: {
|
|
4
|
+
siteId: string
|
|
3
5
|
senderName: string
|
|
4
6
|
fromAddress: string
|
|
5
7
|
replyTo?: string
|
|
6
8
|
d1Binding: string
|
|
7
9
|
queueBinding: string
|
|
10
|
+
senderBinding: string
|
|
11
|
+
siteConfigLookup?: SiteConfigLookup
|
|
8
12
|
turnstileSiteKey: string
|
|
9
13
|
turnstileSecretKey: string
|
|
10
14
|
doubleOptIn: boolean
|
package/src/schema.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
|
|
3
|
-
export const subscribers = sqliteTable(
|
|
4
|
-
'gl_subscribers',
|
|
5
|
-
{
|
|
6
|
-
id: text('id').primaryKey(),
|
|
7
|
-
email: text('email').notNull().unique(),
|
|
8
|
-
name: text('name'),
|
|
9
|
-
status: text('status').notNull().default('pending'),
|
|
10
|
-
preferences: text('preferences').notNull().default('[]'),
|
|
11
|
-
source: text('source').notNull(),
|
|
12
|
-
attribution: text('attribution'),
|
|
13
|
-
softBounceCount: integer('soft_bounce_count').notNull().default(0),
|
|
14
|
-
subscribedAt: text('subscribed_at').notNull(),
|
|
15
|
-
confirmedAt: text('confirmed_at'),
|
|
16
|
-
unsubscribedAt: text('unsubscribed_at'),
|
|
17
|
-
createdAt: text('created_at').notNull(),
|
|
18
|
-
updatedAt: text('updated_at').notNull(),
|
|
19
|
-
},
|
|
20
|
-
(table) => [
|
|
21
|
-
index('idx_subscribers_status').on(table.status),
|
|
22
|
-
index('idx_subscribers_email').on(table.email),
|
|
23
|
-
index('idx_subscribers_subscribed_at').on(table.subscribedAt),
|
|
24
|
-
],
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
export const emailSends = sqliteTable(
|
|
28
|
-
'gl_email_sends',
|
|
29
|
-
{
|
|
30
|
-
id: text('id').primaryKey(),
|
|
31
|
-
subscriberId: text('subscriber_id')
|
|
32
|
-
.notNull()
|
|
33
|
-
.references(() => subscribers.id),
|
|
34
|
-
campaignId: text('campaign_id'),
|
|
35
|
-
email: text('email').notNull(),
|
|
36
|
-
subject: text('subject').notNull(),
|
|
37
|
-
type: text('type').notNull(),
|
|
38
|
-
status: text('status').notNull().default('queued'),
|
|
39
|
-
sentAt: text('sent_at'),
|
|
40
|
-
deliveredAt: text('delivered_at'),
|
|
41
|
-
openedAt: text('opened_at'),
|
|
42
|
-
clickedAt: text('clicked_at'),
|
|
43
|
-
bouncedAt: text('bounced_at'),
|
|
44
|
-
bounceType: text('bounce_type'),
|
|
45
|
-
complainedAt: text('complained_at'),
|
|
46
|
-
trackingId: text('tracking_id').notNull().unique(),
|
|
47
|
-
createdAt: text('created_at').notNull(),
|
|
48
|
-
},
|
|
49
|
-
(table) => [
|
|
50
|
-
index('idx_sends_subscriber').on(table.subscriberId),
|
|
51
|
-
index('idx_sends_campaign').on(table.campaignId),
|
|
52
|
-
index('idx_sends_tracking').on(table.trackingId),
|
|
53
|
-
index('idx_sends_status').on(table.status),
|
|
54
|
-
index('idx_sends_type_created').on(table.type, table.createdAt),
|
|
55
|
-
],
|
|
56
|
-
)
|