@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.
Files changed (69) hide show
  1. package/README.md +18 -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/queue/consumer.d.ts +12 -2
  7. package/dist/queue/consumer.d.ts.map +1 -1
  8. package/dist/queue/consumer.js +66 -0
  9. package/dist/queue/consumer.js.map +1 -1
  10. package/dist/routes/confirm.d.ts.map +1 -1
  11. package/dist/routes/confirm.js +6 -2
  12. package/dist/routes/confirm.js.map +1 -1
  13. package/dist/routes/subscribe.d.ts.map +1 -1
  14. package/dist/routes/subscribe.js +11 -3
  15. package/dist/routes/subscribe.js.map +1 -1
  16. package/dist/routes/unsubscribe.d.ts.map +1 -1
  17. package/dist/routes/unsubscribe.js +14 -3
  18. package/dist/routes/unsubscribe.js.map +1 -1
  19. package/dist/schema/sends.d.ts +38 -0
  20. package/dist/schema/sends.d.ts.map +1 -1
  21. package/dist/schema/sends.js +2 -0
  22. package/dist/schema/sends.js.map +1 -1
  23. package/dist/types.d.ts +22 -1
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/utils/broadcast.d.ts +12 -0
  26. package/dist/utils/broadcast.d.ts.map +1 -1
  27. package/dist/utils/broadcast.js +17 -3
  28. package/dist/utils/broadcast.js.map +1 -1
  29. package/dist/utils/delivery-claim.d.ts +37 -0
  30. package/dist/utils/delivery-claim.d.ts.map +1 -0
  31. package/dist/utils/delivery-claim.js +46 -0
  32. package/dist/utils/delivery-claim.js.map +1 -0
  33. package/dist/utils/runtime-secrets.d.ts +25 -0
  34. package/dist/utils/runtime-secrets.d.ts.map +1 -0
  35. package/dist/utils/runtime-secrets.js +96 -0
  36. package/dist/utils/runtime-secrets.js.map +1 -0
  37. package/dist/utils/send.d.ts.map +1 -1
  38. package/dist/utils/send.js +24 -6
  39. package/dist/utils/send.js.map +1 -1
  40. package/dist/utils/tokens.d.ts +3 -1
  41. package/dist/utils/tokens.d.ts.map +1 -1
  42. package/dist/utils/tokens.js +14 -5
  43. package/dist/utils/tokens.js.map +1 -1
  44. package/dist/utils/urls.d.ts +1 -1
  45. package/dist/utils/urls.d.ts.map +1 -1
  46. package/dist/utils/urls.js +9 -3
  47. package/dist/utils/urls.js.map +1 -1
  48. package/dist/vite-plugin.d.ts +1 -0
  49. package/dist/vite-plugin.d.ts.map +1 -1
  50. package/dist/vite-plugin.js +13 -1
  51. package/dist/vite-plugin.js.map +1 -1
  52. package/migrations/0003_add_delivery_claim_to_email_sends.sql +8 -0
  53. package/package.json +4 -4
  54. package/src/options.ts +4 -2
  55. package/src/queue/consumer.ts +116 -2
  56. package/src/routes/confirm.ts +9 -2
  57. package/src/routes/preferences.astro +12 -3
  58. package/src/routes/subscribe.ts +15 -3
  59. package/src/routes/unsubscribe.ts +16 -3
  60. package/src/schema/sends.ts +2 -0
  61. package/src/types.ts +23 -0
  62. package/src/utils/broadcast.ts +16 -3
  63. package/src/utils/delivery-claim.ts +66 -0
  64. package/src/utils/runtime-secrets.ts +144 -0
  65. package/src/utils/send.ts +27 -5
  66. package/src/utils/tokens.ts +16 -5
  67. package/src/utils/urls.ts +9 -6
  68. package/src/virtual.d.ts +4 -2
  69. 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 unsubscribeToken = await generateToken(options.signingSecret, {
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(options.signingSecret, {
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,
@@ -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
  }