@growth-labs/mailer 0.4.10 → 0.5.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/README.md +19 -2
- package/dist/options.d.ts +12 -6
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -2
- package/dist/options.js.map +1 -1
- package/dist/routes/confirm.d.ts.map +1 -1
- package/dist/routes/confirm.js +6 -2
- package/dist/routes/confirm.js.map +1 -1
- package/dist/routes/subscribe.d.ts.map +1 -1
- package/dist/routes/subscribe.js +11 -3
- package/dist/routes/subscribe.js.map +1 -1
- package/dist/routes/unsubscribe.d.ts.map +1 -1
- package/dist/routes/unsubscribe.js +14 -3
- package/dist/routes/unsubscribe.js.map +1 -1
- package/dist/utils/broadcast.d.ts.map +1 -1
- package/dist/utils/broadcast.js +10 -2
- package/dist/utils/broadcast.js.map +1 -1
- package/dist/utils/providers.d.ts +4 -5
- package/dist/utils/providers.d.ts.map +1 -1
- package/dist/utils/providers.js +10 -3
- package/dist/utils/providers.js.map +1 -1
- package/dist/utils/runtime-secrets.d.ts +25 -0
- package/dist/utils/runtime-secrets.d.ts.map +1 -0
- package/dist/utils/runtime-secrets.js +96 -0
- package/dist/utils/runtime-secrets.js.map +1 -0
- package/dist/utils/send.d.ts.map +1 -1
- package/dist/utils/send.js +18 -6
- package/dist/utils/send.js.map +1 -1
- package/dist/utils/tokens.d.ts +3 -1
- package/dist/utils/tokens.d.ts.map +1 -1
- package/dist/utils/tokens.js +14 -5
- package/dist/utils/tokens.js.map +1 -1
- package/dist/utils/urls.d.ts +1 -1
- package/dist/utils/urls.d.ts.map +1 -1
- package/dist/utils/urls.js +9 -3
- package/dist/utils/urls.js.map +1 -1
- package/dist/vite-plugin.d.ts +1 -0
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +13 -1
- package/dist/vite-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/options.ts +4 -2
- package/src/routes/confirm.ts +9 -2
- package/src/routes/preferences.astro +12 -3
- package/src/routes/subscribe.ts +15 -3
- package/src/routes/unsubscribe.ts +16 -3
- package/src/utils/broadcast.ts +9 -2
- package/src/utils/providers.ts +15 -5
- package/src/utils/runtime-secrets.ts +144 -0
- package/src/utils/send.ts +21 -5
- package/src/utils/tokens.ts +16 -5
- package/src/utils/urls.ts +9 -6
- package/src/virtual.d.ts +4 -2
- package/src/vite-plugin.ts +18 -1
|
@@ -4,6 +4,11 @@ import type { APIRoute } from 'astro'
|
|
|
4
4
|
import { drizzle } from 'drizzle-orm/d1'
|
|
5
5
|
import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
|
|
6
6
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
7
|
+
import {
|
|
8
|
+
resolveSigningSecret,
|
|
9
|
+
runtimeSecretErrorResponse,
|
|
10
|
+
siteHost,
|
|
11
|
+
} from '../utils/runtime-secrets.js'
|
|
7
12
|
import { sendTransactional } from '../utils/send.js'
|
|
8
13
|
import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
|
|
9
14
|
import { generateToken, verifyToken } from '../utils/tokens.js'
|
|
@@ -14,14 +19,18 @@ async function processUnsubscribe(
|
|
|
14
19
|
context?: Parameters<APIRoute>[0],
|
|
15
20
|
request?: Request,
|
|
16
21
|
) {
|
|
22
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
23
|
+
const requestUrl = request?.url ?? context?.url.toString() ?? config.siteUrl
|
|
24
|
+
const signingSecret = await resolveSigningSecret(bindingsEnv, config, requestUrl)
|
|
25
|
+
if (!signingSecret.ok) return { secretFailure: signingSecret }
|
|
26
|
+
|
|
17
27
|
// Verify token
|
|
18
|
-
const payload = await verifyToken(
|
|
28
|
+
const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl))
|
|
19
29
|
if (!payload || payload.action !== 'unsubscribe') {
|
|
20
30
|
return { error: 'Invalid or expired token', status: 400 }
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
// Resolve bindings
|
|
24
|
-
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
25
34
|
const d1 = bindingsEnv[config.d1Binding] as D1Database
|
|
26
35
|
|
|
27
36
|
// Schema probe — short-circuit with a Response on miss; both GET and POST
|
|
@@ -66,6 +75,7 @@ async function processUnsubscribe(
|
|
|
66
75
|
return {
|
|
67
76
|
success: true,
|
|
68
77
|
subscriberId: payload.subscriberId,
|
|
78
|
+
signingSecret: signingSecret.secret,
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -78,15 +88,17 @@ export const GET: APIRoute = async (context) => {
|
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
const result = await processUnsubscribe(token, context, request)
|
|
91
|
+
if (result.secretFailure) return runtimeSecretErrorResponse(result.secretFailure)
|
|
81
92
|
if ('schemaMissing' in result) return schemaMissingResponse()
|
|
82
93
|
if ('error' in result) {
|
|
83
94
|
return Response.json({ error: result.error }, { status: result.status })
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
// Generate preferences token for the redirect
|
|
87
|
-
const preferencesToken = await generateToken(
|
|
98
|
+
const preferencesToken = await generateToken(result.signingSecret, {
|
|
88
99
|
subscriberId: result.subscriberId,
|
|
89
100
|
action: 'preferences',
|
|
101
|
+
host: siteHost(config.siteUrl),
|
|
90
102
|
})
|
|
91
103
|
const preferencesUrl = buildSiteUrl(config.siteUrl, config.preferencesPath, {
|
|
92
104
|
token: preferencesToken,
|
|
@@ -111,6 +123,7 @@ export const POST: APIRoute = async (context) => {
|
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
const result = await processUnsubscribe(token, context, request)
|
|
126
|
+
if (result.secretFailure) return runtimeSecretErrorResponse(result.secretFailure)
|
|
114
127
|
if ('schemaMissing' in result) return schemaMissingResponse()
|
|
115
128
|
if ('error' in result) {
|
|
116
129
|
return new Response(result.error, { status: result.status })
|
package/src/utils/broadcast.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { emailSends } from '../schema/sends.js'
|
|
|
6
6
|
import { subscribers } from '../schema/subscribers.js'
|
|
7
7
|
import type { EmailQueueMessage, QueueRecipient, SubscriberStatus } from '../types.js'
|
|
8
8
|
import { sleep } from './providers.js'
|
|
9
|
+
import { resolveSigningSecret, siteHost } from './runtime-secrets.js'
|
|
9
10
|
import type { MailerEnv } from './send.js'
|
|
10
11
|
import { generateToken } from './tokens.js'
|
|
11
12
|
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js'
|
|
@@ -330,6 +331,10 @@ export async function sendBroadcast(
|
|
|
330
331
|
dryRun: false,
|
|
331
332
|
}
|
|
332
333
|
}
|
|
334
|
+
const signingSecretResult = await resolveSigningSecret(env, options, options.siteUrl)
|
|
335
|
+
if (!signingSecretResult.ok) throw new Error(signingSecretResult.code)
|
|
336
|
+
const signingSecret = signingSecretResult.secret
|
|
337
|
+
const host = siteHost(options.siteUrl)
|
|
333
338
|
|
|
334
339
|
const html = prepareBroadcastHtml(params.html, options)
|
|
335
340
|
const now = new Date().toISOString()
|
|
@@ -339,13 +344,15 @@ export async function sendBroadcast(
|
|
|
339
344
|
for (const recipient of fresh) {
|
|
340
345
|
const trackingId = ulid()
|
|
341
346
|
const [unsubscribeToken, preferencesToken] = await Promise.all([
|
|
342
|
-
generateToken(
|
|
347
|
+
generateToken(signingSecret, {
|
|
343
348
|
subscriberId: recipient.subscriberId,
|
|
344
349
|
action: 'unsubscribe',
|
|
350
|
+
host,
|
|
345
351
|
}),
|
|
346
|
-
generateToken(
|
|
352
|
+
generateToken(signingSecret, {
|
|
347
353
|
subscriberId: recipient.subscriberId,
|
|
348
354
|
action: 'preferences',
|
|
355
|
+
host,
|
|
349
356
|
}),
|
|
350
357
|
])
|
|
351
358
|
|
package/src/utils/providers.ts
CHANGED
|
@@ -7,8 +7,10 @@ export interface CloudflareEmailSender {
|
|
|
7
7
|
to: string
|
|
8
8
|
from: string
|
|
9
9
|
subject: string
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
html: string
|
|
11
|
+
replyTo?: string
|
|
12
|
+
headers?: Record<string, string>
|
|
13
|
+
}): Promise<unknown>
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
// ─── CloudflareEmailProvider ───
|
|
@@ -23,12 +25,20 @@ export class CloudflareEmailProvider implements EmailProvider {
|
|
|
23
25
|
|
|
24
26
|
async send(email: OutboundEmail): Promise<SendResult> {
|
|
25
27
|
try {
|
|
26
|
-
|
|
28
|
+
const message: Parameters<CloudflareEmailSender['send']>[0] = {
|
|
27
29
|
to: email.to,
|
|
28
30
|
from: email.from,
|
|
29
31
|
subject: email.subject,
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
+
html: email.html,
|
|
33
|
+
}
|
|
34
|
+
if (email.replyTo) {
|
|
35
|
+
message.replyTo = email.replyTo
|
|
36
|
+
}
|
|
37
|
+
if (email.headers && Object.keys(email.headers).length > 0) {
|
|
38
|
+
message.headers = email.headers
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await this.sender.send(message)
|
|
32
42
|
|
|
33
43
|
return { success: true, retryable: false }
|
|
34
44
|
} catch (err) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ResolvedMailerOptions } from '../options.js'
|
|
2
|
+
|
|
3
|
+
export const GL_MAILER_TURNSTILE_UNCONFIGURED = 'GL_MAILER_TURNSTILE_UNCONFIGURED'
|
|
4
|
+
export const GL_MAILER_SIGNING_UNCONFIGURED = 'GL_MAILER_SIGNING_UNCONFIGURED'
|
|
5
|
+
|
|
6
|
+
const TURNSTILE_TEST_SECRETS = new Set([
|
|
7
|
+
'1x0000000000000000000000000000000AA',
|
|
8
|
+
'2x0000000000000000000000000000000AA',
|
|
9
|
+
'3x0000000000000000000000000000000AA',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
type SecretKind = 'turnstile' | 'signing'
|
|
13
|
+
type SecretStoreBinding = { get(): Promise<string> }
|
|
14
|
+
|
|
15
|
+
export type MailerRuntimeSecretFailure = {
|
|
16
|
+
ok: false
|
|
17
|
+
code: typeof GL_MAILER_TURNSTILE_UNCONFIGURED | typeof GL_MAILER_SIGNING_UNCONFIGURED
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type MailerRuntimeSecretResult = { ok: true; secret: string } | MailerRuntimeSecretFailure
|
|
21
|
+
|
|
22
|
+
const secretStoreCache = new WeakMap<object, Promise<string | undefined>>()
|
|
23
|
+
|
|
24
|
+
function isSecretStoreBinding(value: unknown): value is SecretStoreBinding {
|
|
25
|
+
return (
|
|
26
|
+
typeof value === 'object' &&
|
|
27
|
+
value !== null &&
|
|
28
|
+
'get' in value &&
|
|
29
|
+
typeof (value as SecretStoreBinding).get === 'function'
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readBinding(value: unknown): Promise<string | undefined> {
|
|
34
|
+
if (typeof value === 'string') return value.trim() || undefined
|
|
35
|
+
if (!isSecretStoreBinding(value)) return undefined
|
|
36
|
+
|
|
37
|
+
let pending = secretStoreCache.get(value)
|
|
38
|
+
if (!pending) {
|
|
39
|
+
pending = Promise.resolve()
|
|
40
|
+
.then(() => value.get())
|
|
41
|
+
.then((secret) => {
|
|
42
|
+
const resolved = typeof secret === 'string' ? secret.trim() || undefined : undefined
|
|
43
|
+
if (!resolved) secretStoreCache.delete(value)
|
|
44
|
+
return resolved
|
|
45
|
+
})
|
|
46
|
+
.catch(() => {
|
|
47
|
+
secretStoreCache.delete(value)
|
|
48
|
+
return undefined
|
|
49
|
+
})
|
|
50
|
+
secretStoreCache.set(value, pending)
|
|
51
|
+
}
|
|
52
|
+
return pending
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hostFromUrl(url: string): { host: string; isLocal: boolean } {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = new URL(url)
|
|
58
|
+
return {
|
|
59
|
+
host: parsed.host.toLowerCase(),
|
|
60
|
+
isLocal: parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1',
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
return { host: 'unknown', isLocal: false }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function failureCode(kind: SecretKind): MailerRuntimeSecretFailure['code'] {
|
|
68
|
+
return kind === 'turnstile' ? GL_MAILER_TURNSTILE_UNCONFIGURED : GL_MAILER_SIGNING_UNCONFIGURED
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function resolveMailerRuntimeSecret({
|
|
72
|
+
bindingsEnv,
|
|
73
|
+
bindingName,
|
|
74
|
+
legacyValue,
|
|
75
|
+
requestUrl,
|
|
76
|
+
kind,
|
|
77
|
+
}: {
|
|
78
|
+
bindingsEnv: Record<string, unknown>
|
|
79
|
+
bindingName: string
|
|
80
|
+
legacyValue?: string
|
|
81
|
+
requestUrl: string
|
|
82
|
+
kind: SecretKind
|
|
83
|
+
}): Promise<MailerRuntimeSecretResult> {
|
|
84
|
+
const requestHost = hostFromUrl(requestUrl)
|
|
85
|
+
let secret = await readBinding(bindingsEnv[bindingName])
|
|
86
|
+
|
|
87
|
+
// Build-time literals are a localhost-only development escape hatch. A
|
|
88
|
+
// production request must always resolve its secret from the Worker env.
|
|
89
|
+
if (!secret && requestHost.isLocal) secret = legacyValue?.trim() || undefined
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
kind === 'turnstile' &&
|
|
93
|
+
secret &&
|
|
94
|
+
TURNSTILE_TEST_SECRETS.has(secret) &&
|
|
95
|
+
!requestHost.isLocal
|
|
96
|
+
) {
|
|
97
|
+
secret = undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (secret) return { ok: true, secret }
|
|
101
|
+
|
|
102
|
+
const code = failureCode(kind)
|
|
103
|
+
console.error(`[${code}] @growth-labs/mailer: host=${requestHost.host} binding=${bindingName}`)
|
|
104
|
+
return { ok: false, code }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function resolveTurnstileSecret(
|
|
108
|
+
bindingsEnv: Record<string, unknown>,
|
|
109
|
+
options: ResolvedMailerOptions,
|
|
110
|
+
requestUrl: string,
|
|
111
|
+
): Promise<MailerRuntimeSecretResult> {
|
|
112
|
+
return resolveMailerRuntimeSecret({
|
|
113
|
+
bindingsEnv,
|
|
114
|
+
bindingName: options.turnstileSecretBinding,
|
|
115
|
+
legacyValue: options.turnstileSecretKey,
|
|
116
|
+
requestUrl,
|
|
117
|
+
kind: 'turnstile',
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function resolveSigningSecret(
|
|
122
|
+
bindingsEnv: Record<string, unknown>,
|
|
123
|
+
options: ResolvedMailerOptions,
|
|
124
|
+
requestUrl: string,
|
|
125
|
+
): Promise<MailerRuntimeSecretResult> {
|
|
126
|
+
return resolveMailerRuntimeSecret({
|
|
127
|
+
bindingsEnv,
|
|
128
|
+
bindingName: options.signingSecretBinding,
|
|
129
|
+
legacyValue: options.signingSecret,
|
|
130
|
+
requestUrl,
|
|
131
|
+
kind: 'signing',
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function runtimeSecretErrorResponse(failure: MailerRuntimeSecretFailure): Response {
|
|
136
|
+
return Response.json(
|
|
137
|
+
{ error: failure.code },
|
|
138
|
+
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function siteHost(siteUrl: string): string {
|
|
143
|
+
return new URL(siteUrl).host.toLowerCase()
|
|
144
|
+
}
|
package/src/utils/send.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
TemplateData,
|
|
12
12
|
TemplateName,
|
|
13
13
|
} from '../types.js'
|
|
14
|
+
import { resolveSigningSecret, siteHost } from './runtime-secrets.js'
|
|
14
15
|
import { getSubscriberBatch } from './subscribers.js'
|
|
15
16
|
import { renderDigestItems, renderEmail } from './templates.js'
|
|
16
17
|
import { generateToken } from './tokens.js'
|
|
@@ -37,6 +38,15 @@ interface ResolvedProducerBindings {
|
|
|
37
38
|
queue: Queue
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
async function requireSigningSecret(
|
|
42
|
+
env: MailerEnv,
|
|
43
|
+
options: ResolvedMailerOptions,
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const result = await resolveSigningSecret(env, options, options.siteUrl)
|
|
46
|
+
if (!result.ok) throw new Error(result.code)
|
|
47
|
+
return result.secret
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
function resolveProducerBindings(
|
|
41
51
|
env: MailerEnv,
|
|
42
52
|
options: ResolvedMailerOptions,
|
|
@@ -80,6 +90,7 @@ export async function sendTransactional(
|
|
|
80
90
|
data?: Record<string, unknown>
|
|
81
91
|
},
|
|
82
92
|
): Promise<{ trackingId: string }> {
|
|
93
|
+
const signingSecret = params.subscriberId ? await requireSigningSecret(env, options) : undefined
|
|
83
94
|
const { sendsDb, queue } = resolveProducerBindings(env, options)
|
|
84
95
|
const trackingId = ulid()
|
|
85
96
|
const subscriberId = params.subscriberId ?? ulid()
|
|
@@ -87,7 +98,7 @@ export async function sendTransactional(
|
|
|
87
98
|
// Render HTML
|
|
88
99
|
let html: string
|
|
89
100
|
if (params.template) {
|
|
90
|
-
const manageUrls = await buildSubscriberManageUrls(options, params.subscriberId)
|
|
101
|
+
const manageUrls = await buildSubscriberManageUrls(options, params.subscriberId, signingSecret)
|
|
91
102
|
html = renderEmail(params.template, {
|
|
92
103
|
...params.data,
|
|
93
104
|
senderName: options.senderName,
|
|
@@ -146,6 +157,7 @@ async function fanOutToSubscribers(
|
|
|
146
157
|
db: DrizzleDB,
|
|
147
158
|
sendsDb: DrizzleDB,
|
|
148
159
|
options: ResolvedMailerOptions,
|
|
160
|
+
signingSecret: string,
|
|
149
161
|
params: {
|
|
150
162
|
subject: string
|
|
151
163
|
html: string
|
|
@@ -166,13 +178,15 @@ async function fanOutToSubscribers(
|
|
|
166
178
|
const recipients: QueueRecipient[] = []
|
|
167
179
|
for (const sub of batch) {
|
|
168
180
|
const trackingId = ulid()
|
|
169
|
-
const unsubscribeToken = await generateToken(
|
|
181
|
+
const unsubscribeToken = await generateToken(signingSecret, {
|
|
170
182
|
subscriberId: sub.id,
|
|
171
183
|
action: 'unsubscribe',
|
|
184
|
+
host: siteHost(options.siteUrl),
|
|
172
185
|
})
|
|
173
|
-
const preferencesToken = await generateToken(
|
|
186
|
+
const preferencesToken = await generateToken(signingSecret, {
|
|
174
187
|
subscriberId: sub.id,
|
|
175
188
|
action: 'preferences',
|
|
189
|
+
host: siteHost(options.siteUrl),
|
|
176
190
|
})
|
|
177
191
|
|
|
178
192
|
await sendsDb.insert(emailSends).values({
|
|
@@ -231,6 +245,7 @@ export async function sendCampaign(
|
|
|
231
245
|
},
|
|
232
246
|
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
233
247
|
const campaignId = params.campaignId ?? ulid()
|
|
248
|
+
const signingSecret = await requireSigningSecret(env, options)
|
|
234
249
|
const { db, sendsDb, queue } = resolveProducerBindings(env, options)
|
|
235
250
|
|
|
236
251
|
// Render HTML
|
|
@@ -256,7 +271,7 @@ export async function sendCampaign(
|
|
|
256
271
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
257
272
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
258
273
|
|
|
259
|
-
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, {
|
|
274
|
+
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, signingSecret, {
|
|
260
275
|
subject: params.subject,
|
|
261
276
|
html,
|
|
262
277
|
campaignId,
|
|
@@ -281,6 +296,7 @@ export async function sendDigest(
|
|
|
281
296
|
},
|
|
282
297
|
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
283
298
|
const campaignId = params.campaignId ?? ulid()
|
|
299
|
+
const signingSecret = await requireSigningSecret(env, options)
|
|
284
300
|
const { db, sendsDb, queue } = resolveProducerBindings(env, options)
|
|
285
301
|
|
|
286
302
|
// Render digest items then full template
|
|
@@ -301,7 +317,7 @@ export async function sendDigest(
|
|
|
301
317
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
302
318
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
303
319
|
|
|
304
|
-
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, {
|
|
320
|
+
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, signingSecret, {
|
|
305
321
|
subject: params.subject,
|
|
306
322
|
html,
|
|
307
323
|
campaignId,
|
package/src/utils/tokens.ts
CHANGED
|
@@ -43,12 +43,17 @@ async function hmacVerify(secret: string, data: string, signature: string): Prom
|
|
|
43
43
|
interface TokenPayload {
|
|
44
44
|
subscriberId: string
|
|
45
45
|
action: 'confirm' | 'unsubscribe' | 'preferences'
|
|
46
|
+
host: string
|
|
46
47
|
exp?: number
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
|
50
51
|
|
|
51
52
|
export async function generateToken(secret: string, payload: TokenPayload): Promise<string> {
|
|
53
|
+
if (typeof payload.host !== 'string' || !payload.host.trim()) {
|
|
54
|
+
throw new Error('GL_MAILER_TOKEN_HOST_REQUIRED')
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
const finalPayload = { ...payload }
|
|
53
58
|
|
|
54
59
|
if (finalPayload.action === 'confirm' && finalPayload.exp === undefined) {
|
|
@@ -63,7 +68,10 @@ export async function generateToken(secret: string, payload: TokenPayload): Prom
|
|
|
63
68
|
export async function verifyToken(
|
|
64
69
|
secret: string,
|
|
65
70
|
token: string,
|
|
66
|
-
|
|
71
|
+
expectedHost: string,
|
|
72
|
+
): Promise<{ subscriberId: string; action: string; host: string; exp?: number } | null> {
|
|
73
|
+
if (typeof expectedHost !== 'string' || !expectedHost.trim()) return null
|
|
74
|
+
|
|
67
75
|
const dotIndex = token.indexOf('.')
|
|
68
76
|
if (dotIndex === -1) return null
|
|
69
77
|
|
|
@@ -72,19 +80,22 @@ export async function verifyToken(
|
|
|
72
80
|
|
|
73
81
|
if (!payloadB64 || !signature) return null
|
|
74
82
|
|
|
75
|
-
const valid = await hmacVerify(secret, payloadB64, signature)
|
|
76
|
-
if (!valid) return null
|
|
77
|
-
|
|
78
83
|
try {
|
|
84
|
+
const valid = await hmacVerify(secret, payloadB64, signature)
|
|
85
|
+
if (!valid) return null
|
|
86
|
+
|
|
79
87
|
const decoded = JSON.parse(new TextDecoder().decode(fromBase64Url(payloadB64))) as {
|
|
80
88
|
subscriberId: string
|
|
81
89
|
action: string
|
|
90
|
+
host?: string
|
|
82
91
|
exp?: number
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
if (decoded.exp !== undefined && decoded.exp < Date.now()) return null
|
|
95
|
+
if (typeof decoded.host !== 'string' || !decoded.host.trim()) return null
|
|
96
|
+
if (decoded.host !== expectedHost) return null
|
|
86
97
|
|
|
87
|
-
return decoded
|
|
98
|
+
return { ...decoded, host: decoded.host }
|
|
88
99
|
} catch {
|
|
89
100
|
return null
|
|
90
101
|
}
|
package/src/utils/urls.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedMailerOptions } from '../options.js'
|
|
2
|
+
import { siteHost } from './runtime-secrets.js'
|
|
2
3
|
import { generateToken } from './tokens.js'
|
|
3
4
|
|
|
4
5
|
export function buildSiteUrl(
|
|
@@ -17,24 +18,26 @@ export function buildSiteUrl(
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export async function buildSubscriberManageUrls(
|
|
20
|
-
options: Pick<
|
|
21
|
-
ResolvedMailerOptions,
|
|
22
|
-
'preferencesPath' | 'siteUrl' | 'signingSecret' | 'unsubscribePath'
|
|
23
|
-
>,
|
|
21
|
+
options: Pick<ResolvedMailerOptions, 'preferencesPath' | 'siteUrl' | 'unsubscribePath'>,
|
|
24
22
|
subscriberId?: string,
|
|
23
|
+
signingSecret?: string,
|
|
25
24
|
): Promise<{ preferencesUrl: string; unsubscribeUrl: string }> {
|
|
26
25
|
if (!subscriberId) {
|
|
27
26
|
return { preferencesUrl: '', unsubscribeUrl: '' }
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
if (!signingSecret) throw new Error('GL_MAILER_SIGNING_UNCONFIGURED')
|
|
30
|
+
const host = siteHost(options.siteUrl)
|
|
30
31
|
const [unsubscribeToken, preferencesToken] = await Promise.all([
|
|
31
|
-
generateToken(
|
|
32
|
+
generateToken(signingSecret, {
|
|
32
33
|
subscriberId,
|
|
33
34
|
action: 'unsubscribe',
|
|
35
|
+
host,
|
|
34
36
|
}),
|
|
35
|
-
generateToken(
|
|
37
|
+
generateToken(signingSecret, {
|
|
36
38
|
subscriberId,
|
|
37
39
|
action: 'preferences',
|
|
40
|
+
host,
|
|
38
41
|
}),
|
|
39
42
|
])
|
|
40
43
|
|
package/src/virtual.d.ts
CHANGED
|
@@ -11,10 +11,12 @@ declare module 'virtual:growth-labs/mailer/config' {
|
|
|
11
11
|
senderBinding: string
|
|
12
12
|
siteConfigLookup?: SiteConfigLookup
|
|
13
13
|
turnstileSiteKey: string
|
|
14
|
-
|
|
14
|
+
turnstileSecretBinding: string
|
|
15
|
+
turnstileSecretKey?: string
|
|
15
16
|
doubleOptIn: boolean
|
|
16
17
|
topics?: string[]
|
|
17
|
-
|
|
18
|
+
signingSecretBinding: string
|
|
19
|
+
signingSecret?: string
|
|
18
20
|
subscribePath: string
|
|
19
21
|
confirmPath: string
|
|
20
22
|
unsubscribePath: string
|
package/src/vite-plugin.ts
CHANGED
|
@@ -4,9 +4,26 @@ import type { ResolvedMailerOptions } from './options.js'
|
|
|
4
4
|
const VIRTUAL_MODULE_ID = 'virtual:growth-labs/mailer/config'
|
|
5
5
|
const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`
|
|
6
6
|
|
|
7
|
+
export function renderMailerConfigModule(
|
|
8
|
+
config: ResolvedMailerOptions,
|
|
9
|
+
command: 'build' | 'serve',
|
|
10
|
+
): string {
|
|
11
|
+
const serializableConfig = { ...config }
|
|
12
|
+
if (command !== 'serve') {
|
|
13
|
+
delete serializableConfig.turnstileSecretKey
|
|
14
|
+
delete serializableConfig.signingSecret
|
|
15
|
+
}
|
|
16
|
+
return `export const config = ${JSON.stringify(serializableConfig)};`
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
export function growthLabsMailerPlugin(config: ResolvedMailerOptions): Plugin {
|
|
20
|
+
let command: 'build' | 'serve' = 'build'
|
|
21
|
+
|
|
8
22
|
return {
|
|
9
23
|
name: 'growth-labs-mailer-config',
|
|
24
|
+
configResolved(resolvedConfig) {
|
|
25
|
+
command = resolvedConfig.command
|
|
26
|
+
},
|
|
10
27
|
resolveId(id) {
|
|
11
28
|
if (id === VIRTUAL_MODULE_ID) {
|
|
12
29
|
return RESOLVED_VIRTUAL_MODULE_ID
|
|
@@ -14,7 +31,7 @@ export function growthLabsMailerPlugin(config: ResolvedMailerOptions): Plugin {
|
|
|
14
31
|
},
|
|
15
32
|
load(id) {
|
|
16
33
|
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
17
|
-
return
|
|
34
|
+
return renderMailerConfigModule(config, command)
|
|
18
35
|
}
|
|
19
36
|
},
|
|
20
37
|
}
|