@growth-labs/mailer 0.4.0 → 0.4.2
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/README.md +6 -0
- 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/queue/consumer.d.ts +9 -3
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +37 -18
- package/dist/queue/consumer.js.map +1 -1
- package/dist/types.d.ts +5 -8
- 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/package.json +1 -1
- package/src/_internal/schema-probe.ts +41 -0
- package/src/queue/consumer.ts +96 -49
- package/src/types.ts +5 -8
- package/src/utils/analytics.ts +8 -4
- package/dist/schema.d.ts +0 -564
- package/dist/schema.d.ts.map +0 -1
- package/dist/schema.js +0 -47
- package/dist/schema.js.map +0 -1
package/src/queue/consumer.ts
CHANGED
|
@@ -1,37 +1,45 @@
|
|
|
1
1
|
import { drizzle } from 'drizzle-orm/d1'
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
import type { EmailQueueMessage, SiteMailerConfig } 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
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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`. */
|
|
14
19
|
interface EffectiveOptions {
|
|
15
|
-
siteUrl
|
|
20
|
+
siteUrl?: string
|
|
16
21
|
unsubscribePath: string
|
|
17
22
|
preferencesPath: string
|
|
23
|
+
senderName?: string
|
|
24
|
+
fromAddress?: string
|
|
25
|
+
replyTo?: string
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
function
|
|
21
|
-
options: ResolvedMailerOptions,
|
|
22
|
-
override: SiteMailerConfig | null,
|
|
23
|
-
): EffectiveOptions {
|
|
28
|
+
function applySiteConfig(config: SiteMailerConfig): EffectiveOptions {
|
|
24
29
|
return {
|
|
25
|
-
siteUrl:
|
|
26
|
-
unsubscribePath:
|
|
27
|
-
preferencesPath:
|
|
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,
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export async function handleEmailQueue(
|
|
32
40
|
batch: MessageBatch<EmailQueueMessage>,
|
|
33
41
|
env: Record<string, unknown>,
|
|
34
|
-
options:
|
|
42
|
+
options: MailerConsumerOptions,
|
|
35
43
|
): Promise<void> {
|
|
36
44
|
const d1 = env[options.d1Binding] as D1Database | undefined
|
|
37
45
|
if (!d1) {
|
|
@@ -53,7 +61,7 @@ export async function handleEmailQueue(
|
|
|
53
61
|
// GL_MAILER_SCHEMA_MISSING and we ack every message in the batch without
|
|
54
62
|
// retry. Re-queueing without the schema would cycle indefinitely and burn
|
|
55
63
|
// Cloudflare Queue retry budget.
|
|
56
|
-
const schemaProbe = await
|
|
64
|
+
const schemaProbe = await probeMailerSendsSchema(d1, options.d1Binding)
|
|
57
65
|
if (!schemaProbe.ok) {
|
|
58
66
|
console.error(
|
|
59
67
|
'[mailer] gl_email_sends not found in env.' +
|
|
@@ -73,23 +81,36 @@ export async function handleEmailQueue(
|
|
|
73
81
|
const provider = new CloudflareEmailProvider(sender)
|
|
74
82
|
|
|
75
83
|
for (const message of batch.messages) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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)
|
|
81
93
|
|
|
82
94
|
const { recipients, htmlTemplate, subject, from, replyTo, headers, type } = message.body
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
85
105
|
: from
|
|
86
|
-
const effectiveReplyTo =
|
|
106
|
+
const effectiveReplyTo = effective.replyTo ?? replyTo
|
|
87
107
|
|
|
88
108
|
for (const recipient of recipients) {
|
|
89
109
|
let html = htmlTemplate
|
|
90
110
|
if (type !== 'transactional') {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
111
|
+
const siteUrl = effective.siteUrl ?? ''
|
|
112
|
+
const unsubscribeUrl = `${siteUrl}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}`
|
|
113
|
+
const preferencesUrl = `${siteUrl}${effective.preferencesPath}?token=${recipient.preferencesToken}`
|
|
93
114
|
html = html
|
|
94
115
|
.replaceAll('{{TRACKING_ID}}', recipient.trackingId)
|
|
95
116
|
.replaceAll('{{UNSUBSCRIBE_URL}}', unsubscribeUrl)
|
|
@@ -100,7 +121,7 @@ export async function handleEmailQueue(
|
|
|
100
121
|
type !== 'transactional'
|
|
101
122
|
? {
|
|
102
123
|
...headers,
|
|
103
|
-
'List-Unsubscribe': `<${effective.siteUrl}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}>`,
|
|
124
|
+
'List-Unsubscribe': `<${effective.siteUrl ?? ''}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}>`,
|
|
104
125
|
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
|
105
126
|
}
|
|
106
127
|
: headers
|
|
@@ -133,34 +154,44 @@ export async function handleEmailQueue(
|
|
|
133
154
|
await updateSendStatus(db, recipient.trackingId, 'sent', {
|
|
134
155
|
sentAt: new Date().toISOString(),
|
|
135
156
|
})
|
|
136
|
-
emitMailerAnalyticsEvent(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
},
|
|
145
171
|
},
|
|
146
|
-
|
|
172
|
+
)
|
|
147
173
|
} else {
|
|
148
174
|
await updateSendStatus(db, recipient.trackingId, 'bounced', {
|
|
149
175
|
bouncedAt: new Date().toISOString(),
|
|
150
176
|
bounceType: 'hard',
|
|
151
177
|
})
|
|
152
|
-
emitMailerAnalyticsEvent(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
},
|
|
162
193
|
},
|
|
163
|
-
|
|
194
|
+
)
|
|
164
195
|
console.error(`[mailer] Send failed for ${recipient.email}: ${result.error}`)
|
|
165
196
|
}
|
|
166
197
|
}
|
|
@@ -172,3 +203,19 @@ export async function handleEmailQueue(
|
|
|
172
203
|
function formatFromHeader(senderName: string, fromAddress: string): string {
|
|
173
204
|
return `${senderName} <${fromAddress}>`
|
|
174
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -52,12 +52,9 @@ export interface EmailQueueMessage {
|
|
|
52
52
|
// ─── Realm-level multi-tenant config lookup ───
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Per-site config
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* Bindings (d1Binding, queueBinding, senderBinding) and route paths
|
|
60
|
-
* are NOT overridable per-site; they're realm-wide.
|
|
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.
|
|
61
58
|
*/
|
|
62
59
|
export interface SiteMailerConfig {
|
|
63
60
|
siteUrl?: string
|
|
@@ -77,8 +74,8 @@ export interface SiteMailerConfig {
|
|
|
77
74
|
|
|
78
75
|
/**
|
|
79
76
|
* Per-message lookup, called by the realm consumer with the message's
|
|
80
|
-
* `siteId`. Returns the per-site config to apply, or `null`
|
|
81
|
-
*
|
|
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.
|
|
82
79
|
*/
|
|
83
80
|
export type SiteConfigLookup = (
|
|
84
81
|
siteId: 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[] } {
|