@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.
- package/dist/_internal/schema-probe.d.ts +30 -0
- package/dist/_internal/schema-probe.d.ts.map +1 -0
- package/dist/_internal/schema-probe.js +68 -0
- package/dist/_internal/schema-probe.js.map +1 -0
- 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 +1 -6
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +57 -13
- package/dist/queue/consumer.js.map +1 -1
- package/dist/routes/confirm.d.ts.map +1 -1
- package/dist/routes/confirm.js +5 -0
- package/dist/routes/confirm.js.map +1 -1
- package/dist/routes/subscribe.d.ts.map +1 -1
- package/dist/routes/subscribe.js +6 -0
- package/dist/routes/subscribe.js.map +1 -1
- package/dist/routes/track-click.d.ts.map +1 -1
- package/dist/routes/track-click.js +17 -10
- package/dist/routes/track-click.js.map +1 -1
- package/dist/routes/track-open.d.ts.map +1 -1
- package/dist/routes/track-open.js +16 -10
- package/dist/routes/track-open.js.map +1 -1
- package/dist/routes/unsubscribe.d.ts.map +1 -1
- package/dist/routes/unsubscribe.js +10 -0
- package/dist/routes/unsubscribe.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 +30 -0
- package/dist/types.d.ts.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/migrations/0001_create_gl_mailer_tables.sql +48 -0
- package/package.json +13 -4
- package/src/_internal/schema-probe.ts +89 -0
- package/src/index.ts +2 -1
- package/src/options.ts +6 -0
- package/src/queue/consumer.ts +85 -19
- package/src/routes/confirm.ts +6 -0
- package/src/routes/subscribe.ts +7 -0
- package/src/routes/track-click.ts +22 -15
- package/src/routes/track-open.ts +21 -15
- package/src/routes/unsubscribe.ts +9 -0
- 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 +37 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/mailer",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"./
|
|
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(),
|
package/src/queue/consumer.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 = `${
|
|
32
|
-
const preferencesUrl = `${
|
|
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': `<${
|
|
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
|
+
}
|
package/src/routes/confirm.ts
CHANGED
|
@@ -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
|
|
package/src/routes/subscribe.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
package/src/routes/track-open.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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,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
|
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
|
|