@growth-labs/mailer 0.2.0 → 0.2.2

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 (62) hide show
  1. package/README.md +32 -4
  2. package/dist/options.d.ts +61 -0
  3. package/dist/options.d.ts.map +1 -1
  4. package/dist/options.js +23 -0
  5. package/dist/options.js.map +1 -1
  6. package/dist/queue/consumer.d.ts +1 -0
  7. package/dist/queue/consumer.d.ts.map +1 -1
  8. package/dist/queue/consumer.js +22 -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 +15 -4
  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 +20 -4
  15. package/dist/routes/subscribe.js.map +1 -1
  16. package/dist/routes/track-click.d.ts.map +1 -1
  17. package/dist/routes/track-click.js +13 -4
  18. package/dist/routes/track-click.js.map +1 -1
  19. package/dist/routes/track-open.d.ts.map +1 -1
  20. package/dist/routes/track-open.js +13 -4
  21. package/dist/routes/track-open.js.map +1 -1
  22. package/dist/routes/unsubscribe.d.ts.map +1 -1
  23. package/dist/routes/unsubscribe.js +21 -8
  24. package/dist/routes/unsubscribe.js.map +1 -1
  25. package/dist/routes/webhook.d.ts.map +1 -1
  26. package/dist/routes/webhook.js +52 -6
  27. package/dist/routes/webhook.js.map +1 -1
  28. package/dist/utils/analytics.d.ts +24 -0
  29. package/dist/utils/analytics.d.ts.map +1 -0
  30. package/dist/utils/analytics.js +75 -0
  31. package/dist/utils/analytics.js.map +1 -0
  32. package/dist/utils/bindings.d.ts +4 -10
  33. package/dist/utils/bindings.d.ts.map +1 -1
  34. package/dist/utils/bindings.js +7 -7
  35. package/dist/utils/bindings.js.map +1 -1
  36. package/dist/utils/index.d.ts +3 -1
  37. package/dist/utils/index.d.ts.map +1 -1
  38. package/dist/utils/index.js +3 -1
  39. package/dist/utils/index.js.map +1 -1
  40. package/dist/utils/providers.js +1 -1
  41. package/dist/utils/providers.js.map +1 -1
  42. package/dist/utils/webhook-signature.d.ts +6 -0
  43. package/dist/utils/webhook-signature.d.ts.map +1 -0
  44. package/dist/utils/webhook-signature.js +59 -0
  45. package/dist/utils/webhook-signature.js.map +1 -0
  46. package/package.json +7 -1
  47. package/src/cloudflare-workers.d.ts +3 -0
  48. package/src/options.ts +23 -0
  49. package/src/queue/consumer.ts +27 -1
  50. package/src/routes/confirm.ts +16 -5
  51. package/src/routes/preferences.astro +5 -9
  52. package/src/routes/subscribe.ts +21 -5
  53. package/src/routes/track-click.ts +14 -8
  54. package/src/routes/track-open.ts +14 -8
  55. package/src/routes/unsubscribe.ts +26 -9
  56. package/src/routes/webhook.ts +55 -11
  57. package/src/utils/analytics.ts +120 -0
  58. package/src/utils/bindings.ts +9 -11
  59. package/src/utils/index.ts +7 -5
  60. package/src/utils/providers.ts +1 -1
  61. package/src/utils/webhook-signature.ts +91 -0
  62. package/src/virtual.d.ts +15 -0
@@ -1,13 +1,15 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { drizzle } from 'drizzle-orm/d1'
4
- import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
5
6
  import type { MailerEnv } from '../utils/send.js'
6
7
  import { sendTransactional } from '../utils/send.js'
7
8
  import { createSubscriber } from '../utils/subscribers.js'
8
9
  import { generateToken } from '../utils/tokens.js'
9
10
 
10
- export const POST: APIRoute = async ({ request, locals }) => {
11
+ export const POST: APIRoute = async (context) => {
12
+ const { request } = context
11
13
  // 1. Parse request body
12
14
  const body = (await request.json()) as {
13
15
  email?: string
@@ -48,9 +50,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
48
50
  }
49
51
 
50
52
  // 5. Resolve bindings
51
- const runtimeEnv = (locals as RuntimeLocals).runtime?.env as Record<string, unknown>
52
- const d1 = runtimeEnv[config.d1Binding] as D1Database
53
- const queue = runtimeEnv[config.queueBinding] as Queue
53
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
54
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
55
+ const queue = bindingsEnv[config.queueBinding] as Queue
54
56
  const db = drizzle(d1)
55
57
  const env: MailerEnv = { DB: d1, QUEUE: queue }
56
58
 
@@ -92,6 +94,20 @@ export const POST: APIRoute = async ({ request, locals }) => {
92
94
  })
93
95
  }
94
96
 
97
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_subscribed', {
98
+ request,
99
+ context,
100
+ contentSlug: subscriber.id,
101
+ label: {
102
+ subscriberId: subscriber.id,
103
+ email: subscriber.email,
104
+ source: body.source ?? 'form',
105
+ preferences: body.preferences ?? [],
106
+ requiresConfirmation: config.doubleOptIn,
107
+ isNew,
108
+ },
109
+ })
110
+
95
111
  // Return success (always 200 to avoid email enumeration)
96
112
  return Response.json({
97
113
  success: true,
@@ -1,10 +1,13 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { and, eq, inArray } from 'drizzle-orm'
4
5
  import { drizzle } from 'drizzle-orm/d1'
5
6
  import { emailSends } from '../schema.js'
7
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
6
8
 
7
- export const GET: APIRoute = async ({ params, url, locals }) => {
9
+ export const GET: APIRoute = async (context) => {
10
+ const { params, request, url } = context
8
11
  const trackingId = params.trackingId
9
12
  const destination = url.searchParams.get('url')
10
13
 
@@ -24,13 +27,9 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
24
27
 
25
28
  // Update status to 'clicked' only when it hasn't already reached 'clicked'
26
29
  try {
27
- const runtimeEnv = (
28
- locals as Record<string, unknown> & {
29
- runtime?: { env: Record<string, unknown> }
30
- }
31
- ).runtime?.env
32
- if (runtimeEnv) {
33
- const d1 = runtimeEnv[config.d1Binding] as D1Database
30
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
31
+ if (bindingsEnv) {
32
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
34
33
  const db = drizzle(d1)
35
34
  await db
36
35
  .update(emailSends)
@@ -49,6 +48,13 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
49
48
  // Never fail the redirect on DB errors
50
49
  }
51
50
 
51
+ emitMailerAnalyticsEvent(config, cloudflareEnv as Record<string, unknown>, 'newsletter_clicked', {
52
+ request,
53
+ context,
54
+ contentSlug: trackingId,
55
+ label: { trackingId, destination },
56
+ })
57
+
52
58
  // 302 redirect to original destination
53
59
  return new Response(null, {
54
60
  status: 302,
@@ -1,11 +1,14 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { and, eq, inArray } from 'drizzle-orm'
4
5
  import { drizzle } from 'drizzle-orm/d1'
5
6
  import { emailSends } from '../schema.js'
7
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
6
8
  import { TRANSPARENT_GIF } from '../utils/tracking.js'
7
9
 
8
- export const GET: APIRoute = async ({ params, locals }) => {
10
+ export const GET: APIRoute = async (context) => {
11
+ const { params, request } = context
9
12
  const trackingId = params.trackingId
10
13
  if (!trackingId) {
11
14
  return new Response(null, { status: 400 })
@@ -14,13 +17,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
14
17
  // Update status to 'opened' only when currently 'sent' or 'delivered'
15
18
  // to avoid downgrading from 'clicked'.
16
19
  try {
17
- const runtimeEnv = (
18
- locals as Record<string, unknown> & {
19
- runtime?: { env: Record<string, unknown> }
20
- }
21
- ).runtime?.env
22
- if (runtimeEnv) {
23
- const d1 = runtimeEnv[config.d1Binding] as D1Database
20
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
21
+ if (bindingsEnv) {
22
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
24
23
  const db = drizzle(d1)
25
24
  await db
26
25
  .update(emailSends)
@@ -39,6 +38,13 @@ export const GET: APIRoute = async ({ params, locals }) => {
39
38
  // Never fail the pixel response on DB errors
40
39
  }
41
40
 
41
+ emitMailerAnalyticsEvent(config, cloudflareEnv as Record<string, unknown>, 'newsletter_opened', {
42
+ request,
43
+ context,
44
+ contentSlug: trackingId,
45
+ label: { trackingId },
46
+ })
47
+
42
48
  // 1x1 transparent GIF
43
49
  return new Response(TRANSPARENT_GIF, {
44
50
  status: 200,
@@ -1,14 +1,19 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { drizzle } from 'drizzle-orm/d1'
4
- import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
5
6
  import type { MailerEnv } from '../utils/send.js'
6
7
  import { sendTransactional } from '../utils/send.js'
7
8
  import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
8
9
  import { generateToken, verifyToken } from '../utils/tokens.js'
9
10
  import { buildSiteUrl } from '../utils/urls.js'
10
11
 
11
- async function processUnsubscribe(locals: RuntimeLocals, token: string) {
12
+ async function processUnsubscribe(
13
+ token: string,
14
+ context?: Parameters<APIRoute>[0],
15
+ request?: Request,
16
+ ) {
12
17
  // Verify token
13
18
  const payload = await verifyToken(config.signingSecret, token)
14
19
  if (!payload || payload.action !== 'unsubscribe') {
@@ -16,9 +21,9 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
16
21
  }
17
22
 
18
23
  // Resolve bindings
19
- const runtimeEnv = locals.runtime?.env as Record<string, unknown>
20
- const d1 = runtimeEnv[config.d1Binding] as D1Database
21
- const queue = runtimeEnv[config.queueBinding] as Queue
24
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
25
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
26
+ const queue = bindingsEnv[config.queueBinding] as Queue
22
27
  const db = drizzle(d1)
23
28
  const env: MailerEnv = { DB: d1, QUEUE: queue }
24
29
 
@@ -43,6 +48,16 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
43
48
  },
44
49
  })
45
50
 
51
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
52
+ request,
53
+ context,
54
+ contentSlug: subscriber.id,
55
+ label: {
56
+ subscriberId: subscriber.id,
57
+ email: subscriber.email,
58
+ },
59
+ })
60
+
46
61
  return {
47
62
  success: true,
48
63
  subscriberId: payload.subscriberId,
@@ -50,13 +65,14 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
50
65
  }
51
66
 
52
67
  // GET: Link from email footer
53
- export const GET: APIRoute = async ({ url, locals }) => {
68
+ export const GET: APIRoute = async (context) => {
69
+ const { url, request } = context
54
70
  const token = url.searchParams.get('token')
55
71
  if (!token) {
56
72
  return Response.json({ error: 'Missing token' }, { status: 400 })
57
73
  }
58
74
 
59
- const result = await processUnsubscribe(locals as RuntimeLocals, token)
75
+ const result = await processUnsubscribe(token, context, request)
60
76
  if ('error' in result) {
61
77
  return Response.json({ error: result.error }, { status: result.status })
62
78
  }
@@ -78,7 +94,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
78
94
  }
79
95
 
80
96
  // POST: RFC 8058 List-Unsubscribe-Post
81
- export const POST: APIRoute = async ({ request, locals }) => {
97
+ export const POST: APIRoute = async (context) => {
98
+ const { request } = context
82
99
  // RFC 8058: body is "List-Unsubscribe=One-Click"
83
100
  // Token comes from List-Unsubscribe header URL
84
101
  const url = new URL(request.url)
@@ -87,7 +104,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
87
104
  return new Response('Missing token', { status: 400 })
88
105
  }
89
106
 
90
- const result = await processUnsubscribe(locals as RuntimeLocals, token)
107
+ const result = await processUnsubscribe(token, context, request)
91
108
  if ('error' in result) {
92
109
  return new Response(result.error, { status: result.status })
93
110
  }
@@ -1,7 +1,10 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { drizzle } from 'drizzle-orm/d1'
5
+ import { emitMailerAnalyticsEvent, type MailerAnalyticsEvent } from '../utils/analytics.js'
4
6
  import { handleBounce, handleComplaint, handleDelivery } from '../utils/bounce.js'
7
+ import { verifyWebhookSignature } from '../utils/webhook-signature.js'
5
8
 
6
9
  interface WebhookPayload {
7
10
  type: 'bounce' | 'complaint' | 'delivery'
@@ -11,25 +14,44 @@ interface WebhookPayload {
11
14
  timestamp: string
12
15
  }
13
16
 
14
- export const POST: APIRoute = async ({ request, locals }) => {
15
- const body = (await request.json()) as WebhookPayload
17
+ export const POST: APIRoute = async (context) => {
18
+ const { request } = context
19
+ const rawBody = await request.text()
20
+ const signatureValid = await verifyWebhookSignature(config.webhookSignature, request, rawBody)
21
+ if (!signatureValid) {
22
+ return Response.json({ error: 'Invalid webhook signature' }, { status: 401 })
23
+ }
16
24
 
17
- if (!body.type || !body.email) {
25
+ let body: WebhookPayload
26
+ try {
27
+ body = JSON.parse(rawBody) as WebhookPayload
28
+ } catch {
18
29
  return Response.json({ error: 'Invalid payload' }, { status: 400 })
19
30
  }
20
31
 
21
- const runtimeEnv = (
22
- locals as Record<string, unknown> & {
23
- runtime?: { env: Record<string, unknown> }
24
- }
25
- ).runtime?.env
26
- if (!runtimeEnv) {
27
- return Response.json({ error: 'Runtime not available' }, { status: 500 })
32
+ if (!body.type || !body.email) {
33
+ return Response.json({ error: 'Invalid payload' }, { status: 400 })
28
34
  }
29
35
 
30
- const d1 = runtimeEnv[config.d1Binding] as D1Database
36
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
37
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
38
+ if (!d1) {
39
+ return Response.json({ error: 'D1 binding not available' }, { status: 500 })
40
+ }
31
41
  const db = drizzle(d1)
32
42
 
43
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_webhook_received', {
44
+ request,
45
+ context,
46
+ contentSlug: body.trackingId ?? body.email,
47
+ label: {
48
+ type: body.type,
49
+ email: body.email,
50
+ trackingId: body.trackingId,
51
+ bounceType: body.bounceType,
52
+ },
53
+ })
54
+
33
55
  switch (body.type) {
34
56
  case 'delivery':
35
57
  if (body.trackingId) {
@@ -44,5 +66,27 @@ export const POST: APIRoute = async ({ request, locals }) => {
44
66
  break
45
67
  }
46
68
 
69
+ emitMailerAnalyticsEvent(config, bindingsEnv, eventForWebhookType(body.type), {
70
+ request,
71
+ context,
72
+ contentSlug: body.trackingId ?? body.email,
73
+ label: {
74
+ email: body.email,
75
+ trackingId: body.trackingId,
76
+ bounceType: body.bounceType,
77
+ },
78
+ })
79
+
47
80
  return Response.json({ ok: true })
48
81
  }
82
+
83
+ function eventForWebhookType(type: WebhookPayload['type']): MailerAnalyticsEvent {
84
+ switch (type) {
85
+ case 'delivery':
86
+ return 'newsletter_delivered'
87
+ case 'bounce':
88
+ return 'newsletter_bounced'
89
+ case 'complaint':
90
+ return 'newsletter_complained'
91
+ }
92
+ }
@@ -0,0 +1,120 @@
1
+ import type { ResolvedMailerOptions } from '../options.js'
2
+
3
+ export type MailerAnalyticsEvent =
4
+ | 'newsletter_subscribed'
5
+ | 'newsletter_confirmed'
6
+ | 'newsletter_unsubscribed'
7
+ | 'newsletter_opened'
8
+ | 'newsletter_clicked'
9
+ | 'newsletter_webhook_received'
10
+ | 'newsletter_delivered'
11
+ | 'newsletter_bounced'
12
+ | 'newsletter_complained'
13
+ | 'newsletter_sent'
14
+ | 'newsletter_send_failed'
15
+
16
+ interface AnalyticsBinding {
17
+ writeDataPoint(dataPoint: unknown): Promise<unknown>
18
+ }
19
+
20
+ interface AnalyticsContext {
21
+ locals?: {
22
+ cfContext?: {
23
+ waitUntil(promise: Promise<unknown>): void
24
+ }
25
+ }
26
+ }
27
+
28
+ interface EmitMailerAnalyticsOptions {
29
+ request?: Request
30
+ context?: AnalyticsContext
31
+ label?: Record<string, unknown>
32
+ contentSlug?: string
33
+ eventValue?: number
34
+ }
35
+
36
+ export function emitMailerAnalyticsEvent(
37
+ options: ResolvedMailerOptions,
38
+ bindingsEnv: Record<string, unknown>,
39
+ eventName: MailerAnalyticsEvent,
40
+ emitOptions: EmitMailerAnalyticsOptions = {},
41
+ ): boolean {
42
+ if (!options.analyticsEnabled) return false
43
+
44
+ const analyticsBinding = bindingsEnv[options.analyticsBinding] as AnalyticsBinding | undefined
45
+ if (!analyticsBinding?.writeDataPoint) return false
46
+
47
+ const dataPoint = buildMailerAnalyticsDataPoint(options, eventName, emitOptions)
48
+ const write = analyticsBinding.writeDataPoint(dataPoint)
49
+ const waitUntil = emitOptions.context?.locals?.cfContext?.waitUntil
50
+ if (waitUntil) {
51
+ waitUntil(write.catch(() => undefined))
52
+ } else {
53
+ void write.catch(() => undefined)
54
+ }
55
+ return true
56
+ }
57
+
58
+ export function buildMailerAnalyticsDataPoint(
59
+ options: ResolvedMailerOptions,
60
+ eventName: MailerAnalyticsEvent,
61
+ emitOptions: EmitMailerAnalyticsOptions = {},
62
+ ): { blobs: string[]; doubles: number[]; indexes: string[] } {
63
+ const url = emitOptions.request ? new URL(emitOptions.request.url) : new URL(options.siteUrl)
64
+ const siteId = siteIdFromUrl(options.siteUrl)
65
+ const label = emitOptions.label ? JSON.stringify(emitOptions.label) : ''
66
+
67
+ return {
68
+ blobs: [
69
+ eventName,
70
+ siteId,
71
+ '',
72
+ '',
73
+ url.toString(),
74
+ url.pathname,
75
+ emitOptions.request?.headers.get('referer') ?? '',
76
+ '',
77
+ '',
78
+ '',
79
+ '',
80
+ emitOptions.request?.headers.get('cf-ipcountry') ?? '',
81
+ '',
82
+ '',
83
+ '',
84
+ emitOptions.contentSlug ?? '',
85
+ 'newsletter',
86
+ categoryForMailerEvent(eventName),
87
+ label,
88
+ 'false',
89
+ ],
90
+ doubles: [Date.now(), emitOptions.eventValue ?? 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
91
+ indexes: [eventName],
92
+ }
93
+ }
94
+
95
+ function siteIdFromUrl(siteUrl: string): string {
96
+ try {
97
+ return new URL(siteUrl).hostname.replace(/^www\./, '')
98
+ } catch {
99
+ return siteUrl
100
+ }
101
+ }
102
+
103
+ function categoryForMailerEvent(eventName: MailerAnalyticsEvent): string {
104
+ switch (eventName) {
105
+ case 'newsletter_subscribed':
106
+ case 'newsletter_confirmed':
107
+ return 'conversion'
108
+ case 'newsletter_opened':
109
+ case 'newsletter_clicked':
110
+ return 'interaction'
111
+ case 'newsletter_webhook_received':
112
+ case 'newsletter_delivered':
113
+ case 'newsletter_bounced':
114
+ case 'newsletter_complained':
115
+ case 'newsletter_sent':
116
+ case 'newsletter_send_failed':
117
+ case 'newsletter_unsubscribed':
118
+ return 'newsletter'
119
+ }
120
+ }
@@ -1,25 +1,23 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import { drizzle } from 'drizzle-orm/d1'
3
4
 
4
5
  type DrizzleDB = ReturnType<typeof drizzle>
5
6
 
6
- export interface RuntimeLocals {
7
- runtime?: { env: Record<string, unknown> }
8
- }
9
-
10
7
  /**
11
- * Resolve Cloudflare bindings from the runtime environment object.
8
+ * Resolve Cloudflare bindings from the Astro 6 Cloudflare Workers env object.
12
9
  *
13
- * Route handlers call this with `context.locals.runtime.env` (the standard
14
- * Astro 6 + @astrojs/cloudflare adapter pattern). Binding names come from
15
- * the virtual config so they stay in sync with the consumer's `astro.config`.
10
+ * Binding names come from the virtual config so they stay in sync with the
11
+ * consumer's `astro.config`.
16
12
  */
17
- export function resolveBindings(runtimeEnv: Record<string, unknown>): {
13
+ export function resolveBindings(
14
+ bindingsEnv: Record<string, unknown> = cloudflareEnv as Record<string, unknown>,
15
+ ): {
18
16
  db: DrizzleDB
19
17
  queue: Queue
20
18
  } {
21
- const d1 = runtimeEnv[config.d1Binding] as D1Database
22
- const queue = runtimeEnv[config.queueBinding] as Queue
19
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
20
+ const queue = bindingsEnv[config.queueBinding] as Queue
23
21
 
24
22
  if (!d1) throw new Error(`[mailer] D1 binding "${config.d1Binding}" not found`)
25
23
  if (!queue) throw new Error(`[mailer] Queue binding "${config.queueBinding}" not found`)
@@ -1,3 +1,8 @@
1
+ export {
2
+ buildMailerAnalyticsDataPoint,
3
+ emitMailerAnalyticsEvent,
4
+ type MailerAnalyticsEvent,
5
+ } from './analytics.js'
1
6
  export {
2
7
  handleBounce,
3
8
  handleComplaint,
@@ -5,11 +10,7 @@ export {
5
10
  updateSendStatus,
6
11
  } from './bounce.js'
7
12
  export type { CloudflareEmailSender } from './providers.js'
8
- export {
9
- CloudflareEmailProvider,
10
- getProvider,
11
- sleep,
12
- } from './providers.js'
13
+ export { CloudflareEmailProvider, getProvider, sleep } from './providers.js'
13
14
  export type { CampaignSchedule, DigestSchedule } from './scheduling.js'
14
15
  export {
15
16
  executeCampaignSchedule,
@@ -43,3 +44,4 @@ export {
43
44
  rewriteLinksForTracking,
44
45
  TRANSPARENT_GIF,
45
46
  } from './tracking.js'
47
+ export { signWebhookPayload, verifyWebhookSignature } from './webhook-signature.js'
@@ -44,7 +44,7 @@ export class CloudflareEmailProvider implements EmailProvider {
44
44
  }
45
45
  }
46
46
 
47
- // ─── Factory functions ───
47
+ // ─── Factory function ───
48
48
 
49
49
  export function getProvider(emailSender: CloudflareEmailSender): EmailProvider {
50
50
  return new CloudflareEmailProvider(emailSender)
@@ -0,0 +1,91 @@
1
+ import type { ResolvedMailerOptions } from '../options.js'
2
+
3
+ type WebhookSignatureOptions = ResolvedMailerOptions['webhookSignature']
4
+
5
+ export async function verifyWebhookSignature(
6
+ options: WebhookSignatureOptions,
7
+ request: Request,
8
+ body: string,
9
+ nowMs: number = Date.now(),
10
+ ): Promise<boolean> {
11
+ if (!options.enabled) return true
12
+
13
+ const timestamp = request.headers.get(options.timestampHeader)
14
+ const signatureHeader = request.headers.get(options.header)
15
+ if (!timestamp || !signatureHeader) return false
16
+
17
+ if (!timestampWithinTolerance(timestamp, options.toleranceSeconds, nowMs)) return false
18
+
19
+ const signature = signatureFromHeader(signatureHeader)
20
+ if (!signature) return false
21
+
22
+ const key = await importWebhookKey(options.secret)
23
+ const signedBody = `${timestamp}.${body}`
24
+ return crypto.subtle.verify(
25
+ 'HMAC',
26
+ key,
27
+ fromBase64Url(signature),
28
+ new TextEncoder().encode(signedBody),
29
+ )
30
+ }
31
+
32
+ export async function signWebhookPayload(
33
+ secret: string,
34
+ timestamp: string,
35
+ body: string,
36
+ ): Promise<string> {
37
+ const key = await importWebhookKey(secret)
38
+ const signature = await crypto.subtle.sign(
39
+ 'HMAC',
40
+ key,
41
+ new TextEncoder().encode(`${timestamp}.${body}`),
42
+ )
43
+ return toBase64Url(signature)
44
+ }
45
+
46
+ function timestampWithinTolerance(
47
+ timestamp: string,
48
+ toleranceSeconds: number,
49
+ nowMs: number,
50
+ ): boolean {
51
+ if (toleranceSeconds === 0) return true
52
+ const parsed = Number(timestamp)
53
+ if (!Number.isFinite(parsed)) return false
54
+ const timestampMs = parsed > 1_000_000_000_000 ? parsed : parsed * 1000
55
+ return Math.abs(nowMs - timestampMs) <= toleranceSeconds * 1000
56
+ }
57
+
58
+ function signatureFromHeader(header: string): string | null {
59
+ const parts = header.split(',').map((part) => part.trim())
60
+ for (const part of parts) {
61
+ if (part.startsWith('v1=')) return part.slice(3)
62
+ }
63
+ return parts[0] || null
64
+ }
65
+
66
+ async function importWebhookKey(secret: string): Promise<CryptoKey> {
67
+ return crypto.subtle.importKey(
68
+ 'raw',
69
+ new TextEncoder().encode(secret),
70
+ { name: 'HMAC', hash: 'SHA-256' },
71
+ false,
72
+ ['sign', 'verify'],
73
+ )
74
+ }
75
+
76
+ function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {
77
+ const padded = value
78
+ .replace(/-/g, '+')
79
+ .replace(/_/g, '/')
80
+ .padEnd(Math.ceil(value.length / 4) * 4, '=')
81
+ const binary = atob(padded)
82
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length))
83
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
84
+ return bytes
85
+ }
86
+
87
+ function toBase64Url(bytes: ArrayBuffer): string {
88
+ let binary = ''
89
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
90
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
91
+ }
package/src/virtual.d.ts CHANGED
@@ -15,6 +15,20 @@ declare module 'virtual:growth-labs/mailer/config' {
15
15
  unsubscribePath: string
16
16
  preferencesPath: string
17
17
  webhookPath: string
18
+ webhookSignature:
19
+ | {
20
+ enabled: false
21
+ header: string
22
+ timestampHeader: string
23
+ toleranceSeconds: number
24
+ }
25
+ | {
26
+ enabled: true
27
+ secret: string
28
+ header: string
29
+ timestampHeader: string
30
+ toleranceSeconds: number
31
+ }
18
32
  trackOpenPath: string
19
33
  trackClickPath: string
20
34
  siteUrl: string
@@ -26,5 +40,6 @@ declare module 'virtual:growth-labs/mailer/config' {
26
40
  }
27
41
  batchSize: number
28
42
  analyticsEnabled: boolean
43
+ analyticsBinding: string
29
44
  }
30
45
  }