@growth-labs/mailer 0.2.2 → 0.4.0

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 (74) hide show
  1. package/dist/_internal/schema-probe.d.ts +30 -0
  2. package/dist/_internal/schema-probe.d.ts.map +1 -0
  3. package/dist/_internal/schema-probe.js +68 -0
  4. package/dist/_internal/schema-probe.js.map +1 -0
  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 +1 -6
  14. package/dist/queue/consumer.d.ts.map +1 -1
  15. package/dist/queue/consumer.js +57 -13
  16. package/dist/queue/consumer.js.map +1 -1
  17. package/dist/routes/confirm.d.ts.map +1 -1
  18. package/dist/routes/confirm.js +5 -0
  19. package/dist/routes/confirm.js.map +1 -1
  20. package/dist/routes/subscribe.d.ts.map +1 -1
  21. package/dist/routes/subscribe.js +6 -0
  22. package/dist/routes/subscribe.js.map +1 -1
  23. package/dist/routes/track-click.d.ts.map +1 -1
  24. package/dist/routes/track-click.js +17 -10
  25. package/dist/routes/track-click.js.map +1 -1
  26. package/dist/routes/track-open.d.ts.map +1 -1
  27. package/dist/routes/track-open.js +16 -10
  28. package/dist/routes/track-open.js.map +1 -1
  29. package/dist/routes/unsubscribe.d.ts.map +1 -1
  30. package/dist/routes/unsubscribe.js +10 -0
  31. package/dist/routes/unsubscribe.js.map +1 -1
  32. package/dist/schema/index.d.ts +3 -0
  33. package/dist/schema/index.d.ts.map +1 -0
  34. package/dist/schema/index.js +3 -0
  35. package/dist/schema/index.js.map +1 -0
  36. package/dist/schema/sends.d.ts +312 -0
  37. package/dist/schema/sends.d.ts.map +1 -0
  38. package/dist/schema/sends.js +26 -0
  39. package/dist/schema/sends.js.map +1 -0
  40. package/dist/schema/subscribers.d.ts +253 -0
  41. package/dist/schema/subscribers.d.ts.map +1 -0
  42. package/dist/schema/subscribers.js +21 -0
  43. package/dist/schema/subscribers.js.map +1 -0
  44. package/dist/types.d.ts +30 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/utils/bounce.d.ts.map +1 -1
  47. package/dist/utils/bounce.js +2 -1
  48. package/dist/utils/bounce.js.map +1 -1
  49. package/dist/utils/send.d.ts +7 -4
  50. package/dist/utils/send.d.ts.map +1 -1
  51. package/dist/utils/send.js +24 -9
  52. package/dist/utils/send.js.map +1 -1
  53. package/dist/utils/subscribers.js +1 -1
  54. package/dist/utils/subscribers.js.map +1 -1
  55. package/migrations/0001_create_gl_mailer_tables.sql +48 -0
  56. package/package.json +13 -4
  57. package/src/_internal/schema-probe.ts +89 -0
  58. package/src/index.ts +2 -1
  59. package/src/options.ts +6 -0
  60. package/src/queue/consumer.ts +85 -19
  61. package/src/routes/confirm.ts +6 -0
  62. package/src/routes/subscribe.ts +7 -0
  63. package/src/routes/track-click.ts +22 -15
  64. package/src/routes/track-open.ts +21 -15
  65. package/src/routes/unsubscribe.ts +9 -0
  66. package/src/schema/index.ts +2 -0
  67. package/src/schema/sends.ts +30 -0
  68. package/src/schema/subscribers.ts +25 -0
  69. package/src/types.ts +37 -0
  70. package/src/utils/bounce.ts +2 -1
  71. package/src/utils/send.ts +43 -12
  72. package/src/utils/subscribers.ts +1 -1
  73. package/src/virtual.d.ts +4 -0
  74. package/src/schema.ts +0 -56
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/mailer",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -35,7 +35,7 @@
35
35
  "import": "./dist/routes/*.js",
36
36
  "default": "./dist/routes/*.js"
37
37
  },
38
- "./queue": {
38
+ "./consumer": {
39
39
  "types": "./dist/queue/consumer.d.ts",
40
40
  "import": "./dist/queue/consumer.js"
41
41
  },
@@ -44,12 +44,21 @@
44
44
  "import": "./src/components/*.astro"
45
45
  },
46
46
  "./schema": {
47
- "types": "./dist/schema.d.ts",
48
- "import": "./dist/schema.js"
47
+ "types": "./dist/schema/index.d.ts",
48
+ "import": "./dist/schema/index.js"
49
+ },
50
+ "./schema/subscribers": {
51
+ "types": "./dist/schema/subscribers.d.ts",
52
+ "import": "./dist/schema/subscribers.js"
53
+ },
54
+ "./schema/sends": {
55
+ "types": "./dist/schema/sends.d.ts",
56
+ "import": "./dist/schema/sends.js"
49
57
  }
50
58
  },
51
59
  "files": [
52
60
  "dist",
61
+ "migrations",
53
62
  "src",
54
63
  "README.md"
55
64
  ],
@@ -0,0 +1,89 @@
1
+ // One-shot D1 schema probe with module-scoped cache. Mailer routes that touch
2
+ // gl_subscribers / gl_email_sends call this before doing D1 work. On miss the
3
+ // route returns 503 with the GL_MAILER_SCHEMA_MISSING code instead of letting
4
+ // drizzle throw and falling into Astro's SSR error template.
5
+ //
6
+ // Does NOT throw. A throw here would recreate the silent-500 cascade that
7
+ // motivates this release. The contract: schema missing → mailer disabled
8
+ // for this Worker's lifetime, affected routes return 503 with a diagnostic
9
+ // body, observability gets one loud error per instance startup.
10
+
11
+ export const GL_MAILER_SCHEMA_MISSING = 'GL_MAILER_SCHEMA_MISSING'
12
+
13
+ export type ProbeResult = { ok: boolean }
14
+
15
+ interface D1PreparedStatement {
16
+ first<T = unknown>(): Promise<T | null>
17
+ }
18
+
19
+ interface D1DatabaseLike {
20
+ prepare(query: string): D1PreparedStatement
21
+ }
22
+
23
+ let _cached: ProbeResult | null = null
24
+
25
+ /** @internal Reset the module-scoped cache — tests only. */
26
+ export function _resetSchemaProbeCache(): void {
27
+ _cached = null
28
+ }
29
+
30
+ /**
31
+ * Probe the configured D1 binding for `gl_subscribers` AND `gl_email_sends`.
32
+ * Returns `{ ok: true }` on success, `{ ok: false }` on failure (either table
33
+ * missing). Caches the result on the first call per Worker instance.
34
+ *
35
+ * Does not throw. A missing binding is treated as `ok: false` and logged —
36
+ * mailer cannot function without D1, so unlike analytics this is loud rather
37
+ * than silent.
38
+ */
39
+ export async function probeMailerSchema(
40
+ db: D1DatabaseLike | undefined,
41
+ d1Binding: string,
42
+ ): Promise<ProbeResult> {
43
+ if (_cached) return _cached
44
+ if (!db) {
45
+ logSchemaMissing(d1Binding, 'D1 binding is not bound')
46
+ _cached = { ok: false }
47
+ return _cached
48
+ }
49
+ try {
50
+ await db.prepare('SELECT 1 FROM gl_subscribers LIMIT 1').first()
51
+ await db.prepare('SELECT 1 FROM gl_email_sends LIMIT 1').first()
52
+ _cached = { ok: true }
53
+ return _cached
54
+ } catch (err) {
55
+ const message = err instanceof Error ? err.message : String(err)
56
+ logSchemaMissing(d1Binding, message)
57
+ _cached = { ok: false }
58
+ return _cached
59
+ }
60
+ }
61
+
62
+ function logSchemaMissing(d1Binding: string, underlying: string): void {
63
+ console.error(
64
+ `[${GL_MAILER_SCHEMA_MISSING}] @growth-labs/mailer: D1 binding "${d1Binding}" is ` +
65
+ 'missing one or both of the gl_subscribers / gl_email_sends tables.\n' +
66
+ 'Remediation:\n' +
67
+ ' 1. Add to wrangler.toml under your [[d1_databases]] block:\n' +
68
+ ' migrations_dir = "node_modules/@growth-labs/mailer/migrations"\n' +
69
+ ` 2. Run: pnpm exec wrangler d1 migrations apply ${d1Binding} --remote\n` +
70
+ 'See packages-docs/mailer-d1-migrations.md for the full guide.\n' +
71
+ 'Mailer routes return 503 until the schema is present. ' +
72
+ `Underlying error: ${underlying}`,
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Helper: build the standard 503 response mailer routes return on schema
78
+ * miss. The body shape is stable so observability dashboards can match on
79
+ * `code === 'GL_MAILER_SCHEMA_MISSING'`.
80
+ */
81
+ export function schemaMissingResponse(): Response {
82
+ return Response.json(
83
+ {
84
+ error: 'Mailer schema is not initialized',
85
+ code: GL_MAILER_SCHEMA_MISSING,
86
+ },
87
+ { status: 503 },
88
+ )
89
+ }
package/src/index.ts CHANGED
@@ -66,7 +66,6 @@ export default function mailer(userOptions: MailerOptions): AstroIntegration {
66
66
  export type { MailerOptions, ResolvedMailerOptions } from './options.js'
67
67
  // Re-export options and types
68
68
  export { mailerOptionsSchema } from './options.js'
69
- export { handleEmailQueue } from './queue/consumer.js'
70
69
  export type {
71
70
  DigestItem,
72
71
  EmailProvider,
@@ -75,6 +74,8 @@ export type {
75
74
  EmailSendStatus,
76
75
  OutboundEmail,
77
76
  SendResult,
77
+ SiteConfigLookup,
78
+ SiteMailerConfig,
78
79
  Subscriber,
79
80
  SubscriberFilter,
80
81
  SubscriberStatus,
package/src/options.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { z } from 'zod'
2
+ import type { SiteConfigLookup } from './types.js'
2
3
 
3
4
  export const mailerOptionsSchema = z.object({
4
5
  // ─── Required ───
6
+ siteId: z.string().min(1),
5
7
  senderName: z.string(),
6
8
  fromAddress: z.string().email(),
7
9
 
@@ -11,6 +13,10 @@ export const mailerOptionsSchema = z.object({
11
13
  // ─── Cloudflare bindings ───
12
14
  d1Binding: z.string().default('SITE_DB'),
13
15
  queueBinding: z.string().default('EMAIL_QUEUE'),
16
+ senderBinding: z.string().default('EMAIL_SENDER'),
17
+
18
+ // ─── Realm-level multi-tenant lookup (optional) ───
19
+ siteConfigLookup: z.custom<SiteConfigLookup>().optional(),
14
20
 
15
21
  // ─── Turnstile (subscribe form) ───
16
22
  turnstileSiteKey: z.string(),
@@ -1,35 +1,95 @@
1
1
  import { drizzle } from 'drizzle-orm/d1'
2
+ import { probeMailerSchema } from '../_internal/schema-probe.js'
2
3
  import type { ResolvedMailerOptions } from '../options.js'
3
- import type { EmailProvider, EmailQueueMessage } from '../types.js'
4
+ import type { EmailQueueMessage, SiteMailerConfig } from '../types.js'
4
5
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
5
6
  import { updateSendStatus } from '../utils/bounce.js'
6
7
  import type { CloudflareEmailSender } from '../utils/providers.js'
7
8
  import { CloudflareEmailProvider, sleep } from '../utils/providers.js'
8
9
 
10
+ /**
11
+ * Effective options for a single message — options merged with any
12
+ * SiteMailerConfig override returned by `siteConfigLookup`.
13
+ */
14
+ interface EffectiveOptions {
15
+ siteUrl: string
16
+ unsubscribePath: string
17
+ preferencesPath: string
18
+ }
19
+
20
+ function applyOverride(
21
+ options: ResolvedMailerOptions,
22
+ override: SiteMailerConfig | null,
23
+ ): EffectiveOptions {
24
+ return {
25
+ siteUrl: override?.siteUrl ?? options.siteUrl,
26
+ unsubscribePath: override?.unsubscribePath ?? options.unsubscribePath,
27
+ preferencesPath: override?.preferencesPath ?? options.preferencesPath,
28
+ }
29
+ }
30
+
9
31
  export async function handleEmailQueue(
10
32
  batch: MessageBatch<EmailQueueMessage>,
11
- env: {
12
- DB: D1Database
13
- EMAIL_SENDER?: CloudflareEmailSender
14
- [key: string]: unknown
15
- },
33
+ env: Record<string, unknown>,
16
34
  options: ResolvedMailerOptions,
17
35
  ): Promise<void> {
18
- const db = drizzle(env.DB)
19
- const provider: EmailProvider = env.EMAIL_SENDER
20
- ? new CloudflareEmailProvider(env.EMAIL_SENDER)
21
- : new CloudflareEmailProvider({
22
- send: () => Promise.reject(new Error('No email sender configured')),
23
- })
36
+ const d1 = env[options.d1Binding] as D1Database | undefined
37
+ if (!d1) {
38
+ throw new Error(
39
+ `[mailer] env.${options.d1Binding} is undefined; ` +
40
+ 'pass d1Binding option to the mailer integration or bind that name in wrangler.toml.',
41
+ )
42
+ }
43
+
44
+ const sender = env[options.senderBinding] as CloudflareEmailSender | undefined
45
+ if (!sender) {
46
+ throw new Error(
47
+ `[mailer] env.${options.senderBinding} is undefined; ` +
48
+ 'pass senderBinding option to the mailer integration or bind a [[send_email]] entry of that name.',
49
+ )
50
+ }
51
+
52
+ // Schema probe — runs once per Worker instance. On miss the probe logs
53
+ // GL_MAILER_SCHEMA_MISSING and we ack every message in the batch without
54
+ // retry. Re-queueing without the schema would cycle indefinitely and burn
55
+ // Cloudflare Queue retry budget.
56
+ const schemaProbe = await probeMailerSchema(d1, options.d1Binding)
57
+ if (!schemaProbe.ok) {
58
+ console.error(
59
+ '[mailer] gl_email_sends not found in env.' +
60
+ options.d1Binding +
61
+ ' — apply mailer migrations before processing queue. ' +
62
+ 'Acking ' +
63
+ batch.messages.length +
64
+ ' message(s) to avoid retry storm.',
65
+ )
66
+ for (const message of batch.messages) {
67
+ message.ack()
68
+ }
69
+ return
70
+ }
71
+
72
+ const db = drizzle(d1)
73
+ const provider = new CloudflareEmailProvider(sender)
24
74
 
25
75
  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)
81
+
26
82
  const { recipients, htmlTemplate, subject, from, replyTo, headers, type } = message.body
83
+ const effectiveFrom = override?.fromAddress
84
+ ? formatFromHeader(override.senderName ?? options.senderName, override.fromAddress)
85
+ : from
86
+ const effectiveReplyTo = override?.replyTo ?? replyTo
27
87
 
28
88
  for (const recipient of recipients) {
29
89
  let html = htmlTemplate
30
90
  if (type !== 'transactional') {
31
- const unsubscribeUrl = `${options.siteUrl}${options.unsubscribePath}?token=${recipient.unsubscribeToken}`
32
- const preferencesUrl = `${options.siteUrl}${options.preferencesPath}?token=${recipient.preferencesToken}`
91
+ const unsubscribeUrl = `${effective.siteUrl}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}`
92
+ const preferencesUrl = `${effective.siteUrl}${effective.preferencesPath}?token=${recipient.preferencesToken}`
33
93
  html = html
34
94
  .replaceAll('{{TRACKING_ID}}', recipient.trackingId)
35
95
  .replaceAll('{{UNSUBSCRIBE_URL}}', unsubscribeUrl)
@@ -40,15 +100,15 @@ export async function handleEmailQueue(
40
100
  type !== 'transactional'
41
101
  ? {
42
102
  ...headers,
43
- 'List-Unsubscribe': `<${options.siteUrl}${options.unsubscribePath}?token=${recipient.unsubscribeToken}>`,
103
+ 'List-Unsubscribe': `<${effective.siteUrl}${effective.unsubscribePath}?token=${recipient.unsubscribeToken}>`,
44
104
  'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
45
105
  }
46
106
  : headers
47
107
 
48
108
  let result = await provider.send({
49
109
  to: recipient.email,
50
- from,
51
- replyTo,
110
+ from: effectiveFrom,
111
+ replyTo: effectiveReplyTo,
52
112
  subject,
53
113
  html,
54
114
  headers: recipientHeaders,
@@ -59,8 +119,8 @@ export async function handleEmailQueue(
59
119
  await sleep(2 ** attempt * 1000)
60
120
  result = await provider.send({
61
121
  to: recipient.email,
62
- from,
63
- replyTo,
122
+ from: effectiveFrom,
123
+ replyTo: effectiveReplyTo,
64
124
  subject,
65
125
  html,
66
126
  headers: recipientHeaders,
@@ -81,6 +141,7 @@ export async function handleEmailQueue(
81
141
  email: recipient.email,
82
142
  campaignId: message.body.campaignId,
83
143
  type,
144
+ siteId: message.body.siteId,
84
145
  },
85
146
  })
86
147
  } else {
@@ -96,6 +157,7 @@ export async function handleEmailQueue(
96
157
  email: recipient.email,
97
158
  campaignId: message.body.campaignId,
98
159
  type,
160
+ siteId: message.body.siteId,
99
161
  error: result.error,
100
162
  },
101
163
  })
@@ -106,3 +168,7 @@ export async function handleEmailQueue(
106
168
  message.ack()
107
169
  }
108
170
  }
171
+
172
+ function formatFromHeader(senderName: string, fromAddress: string): string {
173
+ return `${senderName} <${fromAddress}>`
174
+ }
@@ -2,6 +2,7 @@ import { env as cloudflareEnv } from 'cloudflare:workers'
2
2
  import { config } from 'virtual:growth-labs/mailer/config'
3
3
  import type { APIRoute } from 'astro'
4
4
  import { drizzle } from 'drizzle-orm/d1'
5
+ import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
5
6
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
6
7
  import type { MailerEnv } from '../utils/send.js'
7
8
  import { sendTransactional } from '../utils/send.js'
@@ -25,6 +26,11 @@ export const GET: APIRoute = async (context) => {
25
26
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
26
27
  const d1 = bindingsEnv[config.d1Binding] as D1Database
27
28
  const queue = bindingsEnv[config.queueBinding] as Queue
29
+
30
+ // Schema probe — return 503 with GL_MAILER_SCHEMA_MISSING on miss.
31
+ const schema = await probeMailerSchema(d1, config.d1Binding)
32
+ if (!schema.ok) return schemaMissingResponse()
33
+
28
34
  const db = drizzle(d1)
29
35
  const env: MailerEnv = { DB: d1, QUEUE: queue }
30
36
 
@@ -2,6 +2,7 @@ import { env as cloudflareEnv } from 'cloudflare:workers'
2
2
  import { config } from 'virtual:growth-labs/mailer/config'
3
3
  import type { APIRoute } from 'astro'
4
4
  import { drizzle } from 'drizzle-orm/d1'
5
+ import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
5
6
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
6
7
  import type { MailerEnv } from '../utils/send.js'
7
8
  import { sendTransactional } from '../utils/send.js'
@@ -53,6 +54,12 @@ export const POST: APIRoute = async (context) => {
53
54
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
54
55
  const d1 = bindingsEnv[config.d1Binding] as D1Database
55
56
  const queue = bindingsEnv[config.queueBinding] as Queue
57
+
58
+ // 5a. Schema probe — runs once per Worker instance. On miss, return 503
59
+ // with GL_MAILER_SCHEMA_MISSING so the consumer's site doesn't 500-cascade.
60
+ const schema = await probeMailerSchema(d1, config.d1Binding)
61
+ if (!schema.ok) return schemaMissingResponse()
62
+
56
63
  const db = drizzle(d1)
57
64
  const env: MailerEnv = { DB: d1, QUEUE: queue }
58
65
 
@@ -3,7 +3,8 @@ import { config } from 'virtual:growth-labs/mailer/config'
3
3
  import type { APIRoute } from 'astro'
4
4
  import { and, eq, inArray } from 'drizzle-orm'
5
5
  import { drizzle } from 'drizzle-orm/d1'
6
- import { emailSends } from '../schema.js'
6
+ import { probeMailerSchema } from '../_internal/schema-probe.js'
7
+ import { emailSends } from '../schema/sends.js'
7
8
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
8
9
 
9
10
  export const GET: APIRoute = async (context) => {
@@ -25,24 +26,30 @@ export const GET: APIRoute = async (context) => {
25
26
  return new Response('Invalid URL', { status: 400 })
26
27
  }
27
28
 
28
- // Update status to 'clicked' only when it hasn't already reached 'clicked'
29
+ // Update status to 'clicked' only when it hasn't already reached 'clicked'.
30
+ // On schema miss the probe logs GL_MAILER_SCHEMA_MISSING and we skip the
31
+ // D1 update — but always still 302 to the destination so we don't break
32
+ // the user's actual click intent.
29
33
  try {
30
34
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
31
35
  if (bindingsEnv) {
32
36
  const d1 = bindingsEnv[config.d1Binding] as D1Database
33
- const db = drizzle(d1)
34
- await db
35
- .update(emailSends)
36
- .set({
37
- status: 'clicked',
38
- clickedAt: new Date().toISOString(),
39
- })
40
- .where(
41
- and(
42
- eq(emailSends.trackingId, trackingId),
43
- inArray(emailSends.status, ['sent', 'delivered', 'opened']),
44
- ),
45
- )
37
+ const schema = await probeMailerSchema(d1, config.d1Binding)
38
+ if (schema.ok) {
39
+ const db = drizzle(d1)
40
+ await db
41
+ .update(emailSends)
42
+ .set({
43
+ status: 'clicked',
44
+ clickedAt: new Date().toISOString(),
45
+ })
46
+ .where(
47
+ and(
48
+ eq(emailSends.trackingId, trackingId),
49
+ inArray(emailSends.status, ['sent', 'delivered', 'opened']),
50
+ ),
51
+ )
52
+ }
46
53
  }
47
54
  } catch {
48
55
  // Never fail the redirect on DB errors
@@ -3,7 +3,8 @@ import { config } from 'virtual:growth-labs/mailer/config'
3
3
  import type { APIRoute } from 'astro'
4
4
  import { and, eq, inArray } from 'drizzle-orm'
5
5
  import { drizzle } from 'drizzle-orm/d1'
6
- import { emailSends } from '../schema.js'
6
+ import { probeMailerSchema } from '../_internal/schema-probe.js'
7
+ import { emailSends } from '../schema/sends.js'
7
8
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
8
9
  import { TRANSPARENT_GIF } from '../utils/tracking.js'
9
10
 
@@ -15,24 +16,29 @@ export const GET: APIRoute = async (context) => {
15
16
  }
16
17
 
17
18
  // Update status to 'opened' only when currently 'sent' or 'delivered'
18
- // to avoid downgrading from 'clicked'.
19
+ // to avoid downgrading from 'clicked'. On schema miss, the probe logs
20
+ // GL_MAILER_SCHEMA_MISSING and we skip the D1 work — but always still
21
+ // return the transparent GIF so we don't break email rendering.
19
22
  try {
20
23
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
21
24
  if (bindingsEnv) {
22
25
  const d1 = bindingsEnv[config.d1Binding] as D1Database
23
- const db = drizzle(d1)
24
- await db
25
- .update(emailSends)
26
- .set({
27
- status: 'opened',
28
- openedAt: new Date().toISOString(),
29
- })
30
- .where(
31
- and(
32
- eq(emailSends.trackingId, trackingId),
33
- inArray(emailSends.status, ['sent', 'delivered']),
34
- ),
35
- )
26
+ const schema = await probeMailerSchema(d1, config.d1Binding)
27
+ if (schema.ok) {
28
+ const db = drizzle(d1)
29
+ await db
30
+ .update(emailSends)
31
+ .set({
32
+ status: 'opened',
33
+ openedAt: new Date().toISOString(),
34
+ })
35
+ .where(
36
+ and(
37
+ eq(emailSends.trackingId, trackingId),
38
+ inArray(emailSends.status, ['sent', 'delivered']),
39
+ ),
40
+ )
41
+ }
36
42
  }
37
43
  } catch {
38
44
  // Never fail the pixel response on DB errors
@@ -2,6 +2,7 @@ import { env as cloudflareEnv } from 'cloudflare:workers'
2
2
  import { config } from 'virtual:growth-labs/mailer/config'
3
3
  import type { APIRoute } from 'astro'
4
4
  import { drizzle } from 'drizzle-orm/d1'
5
+ import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
5
6
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
6
7
  import type { MailerEnv } from '../utils/send.js'
7
8
  import { sendTransactional } from '../utils/send.js'
@@ -24,6 +25,12 @@ async function processUnsubscribe(
24
25
  const bindingsEnv = cloudflareEnv as Record<string, unknown>
25
26
  const d1 = bindingsEnv[config.d1Binding] as D1Database
26
27
  const queue = bindingsEnv[config.queueBinding] as Queue
28
+
29
+ // Schema probe — short-circuit with a Response on miss; both GET and POST
30
+ // handlers below forward it unchanged.
31
+ const schema = await probeMailerSchema(d1, config.d1Binding)
32
+ if (!schema.ok) return { schemaMissing: true as const }
33
+
27
34
  const db = drizzle(d1)
28
35
  const env: MailerEnv = { DB: d1, QUEUE: queue }
29
36
 
@@ -73,6 +80,7 @@ export const GET: APIRoute = async (context) => {
73
80
  }
74
81
 
75
82
  const result = await processUnsubscribe(token, context, request)
83
+ if ('schemaMissing' in result) return schemaMissingResponse()
76
84
  if ('error' in result) {
77
85
  return Response.json({ error: result.error }, { status: result.status })
78
86
  }
@@ -105,6 +113,7 @@ export const POST: APIRoute = async (context) => {
105
113
  }
106
114
 
107
115
  const result = await processUnsubscribe(token, context, request)
116
+ if ('schemaMissing' in result) return schemaMissingResponse()
108
117
  if ('error' in result) {
109
118
  return new Response(result.error, { status: result.status })
110
119
  }
@@ -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,42 @@ export interface EmailQueueMessage {
48
49
  campaignId?: string
49
50
  }
50
51
 
52
+ // ─── Realm-level multi-tenant config lookup ───
53
+
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.
61
+ */
62
+ export interface SiteMailerConfig {
63
+ siteUrl?: string
64
+ unsubscribePath?: string
65
+ preferencesPath?: string
66
+ senderName?: string
67
+ fromAddress?: string
68
+ replyTo?: string
69
+ signingSecret?: string
70
+ brand?: {
71
+ logoUrl?: string
72
+ primaryColor?: string
73
+ accentColor?: string
74
+ footerText?: string
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 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.
82
+ */
83
+ export type SiteConfigLookup = (
84
+ siteId: string,
85
+ env: Record<string, unknown>,
86
+ ) => Promise<SiteMailerConfig | null>
87
+
51
88
  export interface QueueRecipient {
52
89
  email: string
53
90
  subscriberId: 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