@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.
@@ -1,37 +1,45 @@
1
1
  import { drizzle } from 'drizzle-orm/d1'
2
- import { probeMailerSchema } from '../_internal/schema-probe.js'
3
- import type { ResolvedMailerOptions } from '../options.js'
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
- * Effective options for a single message — options merged with any
12
- * SiteMailerConfig override returned by `siteConfigLookup`.
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: string
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 applyOverride(
21
- options: ResolvedMailerOptions,
22
- override: SiteMailerConfig | null,
23
- ): EffectiveOptions {
28
+ function applySiteConfig(config: SiteMailerConfig): EffectiveOptions {
24
29
  return {
25
- siteUrl: override?.siteUrl ?? options.siteUrl,
26
- unsubscribePath: override?.unsubscribePath ?? options.unsubscribePath,
27
- preferencesPath: override?.preferencesPath ?? options.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: ResolvedMailerOptions,
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 probeMailerSchema(d1, options.d1Binding)
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 override =
77
- options.siteConfigLookup && message.body.siteId
78
- ? await options.siteConfigLookup(message.body.siteId, env)
79
- : null
80
- const effective = applyOverride(options, override)
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
- const effectiveFrom = override?.fromAddress
84
- ? formatFromHeader(override.senderName ?? options.senderName, override.fromAddress)
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 = override?.replyTo ?? replyTo
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 unsubscribeUrl = `${effective.siteUrl}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}`
92
- const preferencesUrl = `${effective.siteUrl}${effective.preferencesPath}?token=${recipient.preferencesToken}`
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(options, env, 'newsletter_sent', {
137
- contentSlug: recipient.trackingId,
138
- label: {
139
- trackingId: recipient.trackingId,
140
- subscriberId: recipient.subscriberId,
141
- email: recipient.email,
142
- campaignId: message.body.campaignId,
143
- type,
144
- siteId: message.body.siteId,
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(options, env, 'newsletter_send_failed', {
153
- contentSlug: recipient.trackingId,
154
- label: {
155
- trackingId: recipient.trackingId,
156
- subscriberId: recipient.subscriberId,
157
- email: recipient.email,
158
- campaignId: message.body.campaignId,
159
- type,
160
- siteId: message.body.siteId,
161
- error: result.error,
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 override applied at consumer time. Returned from
56
- * SiteConfigLookup. All fields are optional only the ones present
57
- * will override the consumer's resolved options for that message.
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` to skip the
81
- * override and use the consumer's options as-is.
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,
@@ -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: ResolvedMailerOptions,
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: ResolvedMailerOptions,
63
+ options: MailerAnalyticsRuntimeOptions,
60
64
  eventName: MailerAnalyticsEvent,
61
65
  emitOptions: EmitMailerAnalyticsOptions = {},
62
66
  ): { blobs: string[]; doubles: number[]; indexes: string[] } {