@growth-labs/mailer 0.4.11 → 0.5.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.
- package/README.md +18 -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/queue/consumer.d.ts +12 -2
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +66 -0
- package/dist/queue/consumer.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/schema/sends.d.ts +38 -0
- package/dist/schema/sends.d.ts.map +1 -1
- package/dist/schema/sends.js +2 -0
- package/dist/schema/sends.js.map +1 -1
- package/dist/types.d.ts +22 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/broadcast.d.ts +12 -0
- package/dist/utils/broadcast.d.ts.map +1 -1
- package/dist/utils/broadcast.js +17 -3
- package/dist/utils/broadcast.js.map +1 -1
- package/dist/utils/delivery-claim.d.ts +37 -0
- package/dist/utils/delivery-claim.d.ts.map +1 -0
- package/dist/utils/delivery-claim.js +46 -0
- package/dist/utils/delivery-claim.js.map +1 -0
- 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 +24 -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/migrations/0003_add_delivery_claim_to_email_sends.sql +8 -0
- package/package.json +4 -4
- package/src/options.ts +4 -2
- package/src/queue/consumer.ts +116 -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/schema/sends.ts +2 -0
- package/src/types.ts +23 -0
- package/src/utils/broadcast.ts +16 -3
- package/src/utils/delivery-claim.ts +66 -0
- package/src/utils/runtime-secrets.ts +144 -0
- package/src/utils/send.ts +27 -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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm'
|
|
2
|
+
import type { drizzle } from 'drizzle-orm/d1'
|
|
3
|
+
import { emailSends } from '../schema/sends.js'
|
|
4
|
+
|
|
5
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
6
|
+
|
|
7
|
+
export type ClaimOutcome =
|
|
8
|
+
/** This invocation owns the send — no prior claim exists. */
|
|
9
|
+
| 'claimed'
|
|
10
|
+
/** A prior claim never reached ack (crash/timeout). Reconcile, don't resend. */
|
|
11
|
+
| 'reconcile-in-flight'
|
|
12
|
+
/** The row is already in a terminal state — a duplicate enqueue, no-op. */
|
|
13
|
+
| 'already-terminal'
|
|
14
|
+
/** The row exists but under a different delivery_token — a superseded or replayed message. */
|
|
15
|
+
| 'invalid-authorization'
|
|
16
|
+
/** No row for this trackingId at all — lost or not-yet-visible write. */
|
|
17
|
+
| 'missing'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Atomically claim a queued send for this delivery attempt (WM-01) — the
|
|
21
|
+
* stable per-(campaign, recipient) delivery claim the mailer consumer needs.
|
|
22
|
+
* `trackingId` is unique per recipient/attempt; the update is a
|
|
23
|
+
* compare-and-swap gated on the row still being `queued`, so of two
|
|
24
|
+
* concurrent or sequential claim attempts for the same row only one can
|
|
25
|
+
* succeed — D1 serializes writes, so the loser always observes the winner's
|
|
26
|
+
* new status.
|
|
27
|
+
*
|
|
28
|
+
* `deliveryToken` is minted once at enqueue time and carried in the queue
|
|
29
|
+
* message. Matching it against the row's stored token means a message from
|
|
30
|
+
* a superseded generation of the same trackingId (a fresh resend cycle
|
|
31
|
+
* re-keyed the row, or the message is a stale replay from before a D1
|
|
32
|
+
* restore) can never claim or act on the current row — it reads back as
|
|
33
|
+
* `invalid-authorization`, never `claimed` or `reconcile-in-flight`.
|
|
34
|
+
*
|
|
35
|
+
* An empty `deliveryToken` skips the token match: queue messages already
|
|
36
|
+
* in flight when this column shipped carry no token, and requiring one
|
|
37
|
+
* would strand that one-time backlog instead of letting it drain under the
|
|
38
|
+
* trackingId + status guard alone.
|
|
39
|
+
*/
|
|
40
|
+
export async function claimDelivery(
|
|
41
|
+
db: DrizzleDB,
|
|
42
|
+
trackingId: string,
|
|
43
|
+
deliveryToken: string,
|
|
44
|
+
now: string,
|
|
45
|
+
): Promise<ClaimOutcome> {
|
|
46
|
+
const claimConditions = [eq(emailSends.trackingId, trackingId), eq(emailSends.status, 'queued')]
|
|
47
|
+
if (deliveryToken) claimConditions.push(eq(emailSends.deliveryToken, deliveryToken))
|
|
48
|
+
|
|
49
|
+
const claimed = await db
|
|
50
|
+
.update(emailSends)
|
|
51
|
+
.set({ status: 'sending', claimedAt: now })
|
|
52
|
+
.where(and(...claimConditions))
|
|
53
|
+
.returning({ id: emailSends.id })
|
|
54
|
+
|
|
55
|
+
if (claimed.length > 0) return 'claimed'
|
|
56
|
+
|
|
57
|
+
const rows = await db.select().from(emailSends).where(eq(emailSends.trackingId, trackingId))
|
|
58
|
+
if (rows.length === 0) return 'missing'
|
|
59
|
+
|
|
60
|
+
const row = rows[0] as { status: string; deliveryToken: string | null }
|
|
61
|
+
if (deliveryToken && row.deliveryToken && row.deliveryToken !== deliveryToken) {
|
|
62
|
+
return 'invalid-authorization'
|
|
63
|
+
}
|
|
64
|
+
if (row.status === 'sending') return 'reconcile-in-flight'
|
|
65
|
+
return 'already-terminal'
|
|
66
|
+
}
|
|
@@ -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,14 +90,16 @@ 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()
|
|
96
|
+
const deliveryToken = ulid()
|
|
85
97
|
const subscriberId = params.subscriberId ?? ulid()
|
|
86
98
|
|
|
87
99
|
// Render HTML
|
|
88
100
|
let html: string
|
|
89
101
|
if (params.template) {
|
|
90
|
-
const manageUrls = await buildSubscriberManageUrls(options, params.subscriberId)
|
|
102
|
+
const manageUrls = await buildSubscriberManageUrls(options, params.subscriberId, signingSecret)
|
|
91
103
|
html = renderEmail(params.template, {
|
|
92
104
|
...params.data,
|
|
93
105
|
senderName: options.senderName,
|
|
@@ -112,6 +124,7 @@ export async function sendTransactional(
|
|
|
112
124
|
type: 'transactional',
|
|
113
125
|
status: 'queued',
|
|
114
126
|
trackingId,
|
|
127
|
+
deliveryToken,
|
|
115
128
|
createdAt: new Date().toISOString(),
|
|
116
129
|
})
|
|
117
130
|
|
|
@@ -126,6 +139,7 @@ export async function sendTransactional(
|
|
|
126
139
|
trackingId,
|
|
127
140
|
unsubscribeToken: '',
|
|
128
141
|
preferencesToken: '',
|
|
142
|
+
deliveryToken,
|
|
129
143
|
},
|
|
130
144
|
],
|
|
131
145
|
subject: params.subject,
|
|
@@ -146,6 +160,7 @@ async function fanOutToSubscribers(
|
|
|
146
160
|
db: DrizzleDB,
|
|
147
161
|
sendsDb: DrizzleDB,
|
|
148
162
|
options: ResolvedMailerOptions,
|
|
163
|
+
signingSecret: string,
|
|
149
164
|
params: {
|
|
150
165
|
subject: string
|
|
151
166
|
html: string
|
|
@@ -166,13 +181,16 @@ async function fanOutToSubscribers(
|
|
|
166
181
|
const recipients: QueueRecipient[] = []
|
|
167
182
|
for (const sub of batch) {
|
|
168
183
|
const trackingId = ulid()
|
|
169
|
-
const
|
|
184
|
+
const deliveryToken = ulid()
|
|
185
|
+
const unsubscribeToken = await generateToken(signingSecret, {
|
|
170
186
|
subscriberId: sub.id,
|
|
171
187
|
action: 'unsubscribe',
|
|
188
|
+
host: siteHost(options.siteUrl),
|
|
172
189
|
})
|
|
173
|
-
const preferencesToken = await generateToken(
|
|
190
|
+
const preferencesToken = await generateToken(signingSecret, {
|
|
174
191
|
subscriberId: sub.id,
|
|
175
192
|
action: 'preferences',
|
|
193
|
+
host: siteHost(options.siteUrl),
|
|
176
194
|
})
|
|
177
195
|
|
|
178
196
|
await sendsDb.insert(emailSends).values({
|
|
@@ -185,6 +203,7 @@ async function fanOutToSubscribers(
|
|
|
185
203
|
type: params.type,
|
|
186
204
|
status: 'queued',
|
|
187
205
|
trackingId,
|
|
206
|
+
deliveryToken,
|
|
188
207
|
createdAt: new Date().toISOString(),
|
|
189
208
|
})
|
|
190
209
|
|
|
@@ -194,6 +213,7 @@ async function fanOutToSubscribers(
|
|
|
194
213
|
trackingId,
|
|
195
214
|
unsubscribeToken,
|
|
196
215
|
preferencesToken,
|
|
216
|
+
deliveryToken,
|
|
197
217
|
})
|
|
198
218
|
}
|
|
199
219
|
|
|
@@ -231,6 +251,7 @@ export async function sendCampaign(
|
|
|
231
251
|
},
|
|
232
252
|
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
233
253
|
const campaignId = params.campaignId ?? ulid()
|
|
254
|
+
const signingSecret = await requireSigningSecret(env, options)
|
|
234
255
|
const { db, sendsDb, queue } = resolveProducerBindings(env, options)
|
|
235
256
|
|
|
236
257
|
// Render HTML
|
|
@@ -256,7 +277,7 @@ export async function sendCampaign(
|
|
|
256
277
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
257
278
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
258
279
|
|
|
259
|
-
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, {
|
|
280
|
+
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, signingSecret, {
|
|
260
281
|
subject: params.subject,
|
|
261
282
|
html,
|
|
262
283
|
campaignId,
|
|
@@ -281,6 +302,7 @@ export async function sendDigest(
|
|
|
281
302
|
},
|
|
282
303
|
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
283
304
|
const campaignId = params.campaignId ?? ulid()
|
|
305
|
+
const signingSecret = await requireSigningSecret(env, options)
|
|
284
306
|
const { db, sendsDb, queue } = resolveProducerBindings(env, options)
|
|
285
307
|
|
|
286
308
|
// Render digest items then full template
|
|
@@ -301,7 +323,7 @@ export async function sendDigest(
|
|
|
301
323
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
302
324
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
303
325
|
|
|
304
|
-
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, {
|
|
326
|
+
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, signingSecret, {
|
|
305
327
|
subject: params.subject,
|
|
306
328
|
html,
|
|
307
329
|
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
|
}
|