@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.
Files changed (63) hide show
  1. package/dist/_internal/schema-probe.d.ts +6 -0
  2. package/dist/_internal/schema-probe.d.ts.map +1 -1
  3. package/dist/_internal/schema-probe.js +36 -0
  4. package/dist/_internal/schema-probe.js.map +1 -1
  5. package/dist/index.d.ts +1 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +0 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/options.d.ts +24 -14
  10. package/dist/options.d.ts.map +1 -1
  11. package/dist/options.js +4 -0
  12. package/dist/options.js.map +1 -1
  13. package/dist/queue/consumer.d.ts +9 -8
  14. package/dist/queue/consumer.d.ts.map +1 -1
  15. package/dist/queue/consumer.js +68 -17
  16. package/dist/queue/consumer.js.map +1 -1
  17. package/dist/routes/track-click.js +1 -1
  18. package/dist/routes/track-click.js.map +1 -1
  19. package/dist/routes/track-open.js +1 -1
  20. package/dist/routes/track-open.js.map +1 -1
  21. package/dist/schema/index.d.ts +3 -0
  22. package/dist/schema/index.d.ts.map +1 -0
  23. package/dist/schema/index.js +3 -0
  24. package/dist/schema/index.js.map +1 -0
  25. package/dist/schema/sends.d.ts +312 -0
  26. package/dist/schema/sends.d.ts.map +1 -0
  27. package/dist/schema/sends.js +26 -0
  28. package/dist/schema/sends.js.map +1 -0
  29. package/dist/schema/subscribers.d.ts +253 -0
  30. package/dist/schema/subscribers.d.ts.map +1 -0
  31. package/dist/schema/subscribers.js +21 -0
  32. package/dist/schema/subscribers.js.map +1 -0
  33. package/dist/types.d.ts +27 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/utils/analytics.d.ts +7 -3
  36. package/dist/utils/analytics.d.ts.map +1 -1
  37. package/dist/utils/analytics.js.map +1 -1
  38. package/dist/utils/bounce.d.ts.map +1 -1
  39. package/dist/utils/bounce.js +2 -1
  40. package/dist/utils/bounce.js.map +1 -1
  41. package/dist/utils/send.d.ts +7 -4
  42. package/dist/utils/send.d.ts.map +1 -1
  43. package/dist/utils/send.js +24 -9
  44. package/dist/utils/send.js.map +1 -1
  45. package/dist/utils/subscribers.js +1 -1
  46. package/dist/utils/subscribers.js.map +1 -1
  47. package/package.json +12 -4
  48. package/src/_internal/schema-probe.ts +41 -0
  49. package/src/index.ts +2 -1
  50. package/src/options.ts +6 -0
  51. package/src/queue/consumer.ts +142 -42
  52. package/src/routes/track-click.ts +1 -1
  53. package/src/routes/track-open.ts +1 -1
  54. package/src/schema/index.ts +2 -0
  55. package/src/schema/sends.ts +30 -0
  56. package/src/schema/subscribers.ts +25 -0
  57. package/src/types.ts +34 -0
  58. package/src/utils/analytics.ts +8 -4
  59. package/src/utils/bounce.ts +2 -1
  60. package/src/utils/send.ts +43 -12
  61. package/src/utils/subscribers.ts +1 -1
  62. package/src/virtual.d.ts +4 -0
  63. package/src/schema.ts +0 -56
@@ -1,48 +1,116 @@
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 { 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
- DB: D1Database
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 probeMailerSchema(env.DB, options.d1Binding)
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(env.DB)
32
- const provider: EmailProvider = env.EMAIL_SENDER
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 unsubscribeUrl = `${options.siteUrl}${options.unsubscribePath}?token=${recipient.unsubscribeToken}`
45
- const preferencesUrl = `${options.siteUrl}${options.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}`
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': `<${options.siteUrl}${options.unsubscribePath}?token=${recipient.unsubscribeToken}>`,
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(options, env, 'newsletter_sent', {
90
- contentSlug: recipient.trackingId,
91
- label: {
92
- trackingId: recipient.trackingId,
93
- subscriberId: recipient.subscriberId,
94
- email: recipient.email,
95
- campaignId: message.body.campaignId,
96
- type,
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(options, env, 'newsletter_send_failed', {
105
- contentSlug: recipient.trackingId,
106
- label: {
107
- trackingId: recipient.trackingId,
108
- subscriberId: recipient.subscriberId,
109
- email: recipient.email,
110
- campaignId: message.body.campaignId,
111
- type,
112
- 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
+ },
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) => {
@@ -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,2 @@
1
+ export { emailSends } from './sends.js'
2
+ export { subscribers } from './subscribers.js'
@@ -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
@@ -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[] } {
@@ -1,6 +1,7 @@
1
1
  import { eq, sql } from 'drizzle-orm'
2
2
  import type { drizzle } from 'drizzle-orm/d1'
3
- import { emailSends, subscribers } from '../schema.js'
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
- export interface MailerEnv {
25
- DB: D1Database
26
- QUEUE: Queue
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 = drizzle(env.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 env.QUEUE.send(message)
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
- env: MailerEnv,
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 env.QUEUE.send(message)
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 = drizzle(env.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(env, db, options, {
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 = drizzle(env.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(env, db, options, {
290
+ const recipientCount = await fanOutToSubscribers(queue, db, options, {
260
291
  subject: params.subject,
261
292
  html,
262
293
  campaignId,
@@ -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
- )