@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.
Files changed (54) hide show
  1. package/README.md +19 -2
  2. package/dist/options.d.ts +12 -6
  3. package/dist/options.d.ts.map +1 -1
  4. package/dist/options.js +4 -2
  5. package/dist/options.js.map +1 -1
  6. package/dist/routes/confirm.d.ts.map +1 -1
  7. package/dist/routes/confirm.js +6 -2
  8. package/dist/routes/confirm.js.map +1 -1
  9. package/dist/routes/subscribe.d.ts.map +1 -1
  10. package/dist/routes/subscribe.js +11 -3
  11. package/dist/routes/subscribe.js.map +1 -1
  12. package/dist/routes/unsubscribe.d.ts.map +1 -1
  13. package/dist/routes/unsubscribe.js +14 -3
  14. package/dist/routes/unsubscribe.js.map +1 -1
  15. package/dist/utils/broadcast.d.ts.map +1 -1
  16. package/dist/utils/broadcast.js +10 -2
  17. package/dist/utils/broadcast.js.map +1 -1
  18. package/dist/utils/providers.d.ts +4 -5
  19. package/dist/utils/providers.d.ts.map +1 -1
  20. package/dist/utils/providers.js +10 -3
  21. package/dist/utils/providers.js.map +1 -1
  22. package/dist/utils/runtime-secrets.d.ts +25 -0
  23. package/dist/utils/runtime-secrets.d.ts.map +1 -0
  24. package/dist/utils/runtime-secrets.js +96 -0
  25. package/dist/utils/runtime-secrets.js.map +1 -0
  26. package/dist/utils/send.d.ts.map +1 -1
  27. package/dist/utils/send.js +18 -6
  28. package/dist/utils/send.js.map +1 -1
  29. package/dist/utils/tokens.d.ts +3 -1
  30. package/dist/utils/tokens.d.ts.map +1 -1
  31. package/dist/utils/tokens.js +14 -5
  32. package/dist/utils/tokens.js.map +1 -1
  33. package/dist/utils/urls.d.ts +1 -1
  34. package/dist/utils/urls.d.ts.map +1 -1
  35. package/dist/utils/urls.js +9 -3
  36. package/dist/utils/urls.js.map +1 -1
  37. package/dist/vite-plugin.d.ts +1 -0
  38. package/dist/vite-plugin.d.ts.map +1 -1
  39. package/dist/vite-plugin.js +13 -1
  40. package/dist/vite-plugin.js.map +1 -1
  41. package/package.json +4 -4
  42. package/src/options.ts +4 -2
  43. package/src/routes/confirm.ts +9 -2
  44. package/src/routes/preferences.astro +12 -3
  45. package/src/routes/subscribe.ts +15 -3
  46. package/src/routes/unsubscribe.ts +16 -3
  47. package/src/utils/broadcast.ts +9 -2
  48. package/src/utils/providers.ts +15 -5
  49. package/src/utils/runtime-secrets.ts +144 -0
  50. package/src/utils/send.ts +21 -5
  51. package/src/utils/tokens.ts +16 -5
  52. package/src/utils/urls.ts +9 -6
  53. package/src/virtual.d.ts +4 -2
  54. 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(config.signingSecret, token)
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(config.signingSecret, {
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 })
@@ -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(options.signingSecret, {
347
+ generateToken(signingSecret, {
343
348
  subscriberId: recipient.subscriberId,
344
349
  action: 'unsubscribe',
350
+ host,
345
351
  }),
346
- generateToken(options.signingSecret, {
352
+ generateToken(signingSecret, {
347
353
  subscriberId: recipient.subscriberId,
348
354
  action: 'preferences',
355
+ host,
349
356
  }),
350
357
  ])
351
358
 
@@ -7,8 +7,10 @@ export interface CloudflareEmailSender {
7
7
  to: string
8
8
  from: string
9
9
  subject: string
10
- content: { type: string; value: string }[]
11
- }): Promise<void>
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
- await this.sender.send({
28
+ const message: Parameters<CloudflareEmailSender['send']>[0] = {
27
29
  to: email.to,
28
30
  from: email.from,
29
31
  subject: email.subject,
30
- content: [{ type: 'text/html', value: email.html }],
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(options.signingSecret, {
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(options.signingSecret, {
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,
@@ -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
- ): Promise<{ subscriberId: string; action: string; exp?: number } | null> {
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(options.signingSecret, {
32
+ generateToken(signingSecret, {
32
33
  subscriberId,
33
34
  action: 'unsubscribe',
35
+ host,
34
36
  }),
35
- generateToken(options.signingSecret, {
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
- turnstileSecretKey: string
14
+ turnstileSecretBinding: string
15
+ turnstileSecretKey?: string
15
16
  doubleOptIn: boolean
16
17
  topics?: string[]
17
- signingSecret: string
18
+ signingSecretBinding: string
19
+ signingSecret?: string
18
20
  subscribePath: string
19
21
  confirmPath: string
20
22
  unsubscribePath: string
@@ -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 `export const config = ${JSON.stringify(config)};`
34
+ return renderMailerConfigModule(config, command)
18
35
  }
19
36
  },
20
37
  }