@growth-labs/mailer 0.2.0 → 0.2.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 (57) hide show
  1. package/README.md +28 -4
  2. package/dist/options.d.ts +3 -0
  3. package/dist/options.d.ts.map +1 -1
  4. package/dist/options.js +1 -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 +28 -5
  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 +74 -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 +2 -1
  37. package/dist/utils/index.d.ts.map +1 -1
  38. package/dist/utils/index.js +2 -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/package.json +7 -1
  43. package/src/cloudflare-workers.d.ts +3 -0
  44. package/src/options.ts +1 -0
  45. package/src/queue/consumer.ts +27 -1
  46. package/src/routes/confirm.ts +16 -5
  47. package/src/routes/preferences.astro +5 -9
  48. package/src/routes/subscribe.ts +21 -5
  49. package/src/routes/track-click.ts +14 -8
  50. package/src/routes/track-open.ts +14 -8
  51. package/src/routes/unsubscribe.ts +26 -9
  52. package/src/routes/webhook.ts +30 -10
  53. package/src/utils/analytics.ts +118 -0
  54. package/src/utils/bindings.ts +9 -11
  55. package/src/utils/index.ts +6 -5
  56. package/src/utils/providers.ts +1 -1
  57. package/src/virtual.d.ts +1 -0
@@ -1,6 +1,8 @@
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'
5
7
 
6
8
  interface WebhookPayload {
@@ -11,23 +13,19 @@ interface WebhookPayload {
11
13
  timestamp: string
12
14
  }
13
15
 
14
- export const POST: APIRoute = async ({ request, locals }) => {
16
+ export const POST: APIRoute = async (context) => {
17
+ const { request } = context
15
18
  const body = (await request.json()) as WebhookPayload
16
19
 
17
20
  if (!body.type || !body.email) {
18
21
  return Response.json({ error: 'Invalid payload' }, { status: 400 })
19
22
  }
20
23
 
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 })
24
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
25
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
26
+ if (!d1) {
27
+ return Response.json({ error: 'D1 binding not available' }, { status: 500 })
28
28
  }
29
-
30
- const d1 = runtimeEnv[config.d1Binding] as D1Database
31
29
  const db = drizzle(d1)
32
30
 
33
31
  switch (body.type) {
@@ -44,5 +42,27 @@ export const POST: APIRoute = async ({ request, locals }) => {
44
42
  break
45
43
  }
46
44
 
45
+ emitMailerAnalyticsEvent(config, bindingsEnv, eventForWebhookType(body.type), {
46
+ request,
47
+ context,
48
+ contentSlug: body.trackingId ?? body.email,
49
+ label: {
50
+ email: body.email,
51
+ trackingId: body.trackingId,
52
+ bounceType: body.bounceType,
53
+ },
54
+ })
55
+
47
56
  return Response.json({ ok: true })
48
57
  }
58
+
59
+ function eventForWebhookType(type: WebhookPayload['type']): MailerAnalyticsEvent {
60
+ switch (type) {
61
+ case 'delivery':
62
+ return 'newsletter_delivered'
63
+ case 'bounce':
64
+ return 'newsletter_bounced'
65
+ case 'complaint':
66
+ return 'newsletter_complained'
67
+ }
68
+ }
@@ -0,0 +1,118 @@
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_delivered'
10
+ | 'newsletter_bounced'
11
+ | 'newsletter_complained'
12
+ | 'newsletter_sent'
13
+ | 'newsletter_send_failed'
14
+
15
+ interface AnalyticsBinding {
16
+ writeDataPoint(dataPoint: unknown): Promise<unknown>
17
+ }
18
+
19
+ interface AnalyticsContext {
20
+ locals?: {
21
+ cfContext?: {
22
+ waitUntil(promise: Promise<unknown>): void
23
+ }
24
+ }
25
+ }
26
+
27
+ interface EmitMailerAnalyticsOptions {
28
+ request?: Request
29
+ context?: AnalyticsContext
30
+ label?: Record<string, unknown>
31
+ contentSlug?: string
32
+ eventValue?: number
33
+ }
34
+
35
+ export function emitMailerAnalyticsEvent(
36
+ options: ResolvedMailerOptions,
37
+ bindingsEnv: Record<string, unknown>,
38
+ eventName: MailerAnalyticsEvent,
39
+ emitOptions: EmitMailerAnalyticsOptions = {},
40
+ ): boolean {
41
+ if (!options.analyticsEnabled) return false
42
+
43
+ const analyticsBinding = bindingsEnv[options.analyticsBinding] as AnalyticsBinding | undefined
44
+ if (!analyticsBinding?.writeDataPoint) return false
45
+
46
+ const dataPoint = buildMailerAnalyticsDataPoint(options, eventName, emitOptions)
47
+ const write = analyticsBinding.writeDataPoint(dataPoint)
48
+ const waitUntil = emitOptions.context?.locals?.cfContext?.waitUntil
49
+ if (waitUntil) {
50
+ waitUntil(write.catch(() => undefined))
51
+ } else {
52
+ void write.catch(() => undefined)
53
+ }
54
+ return true
55
+ }
56
+
57
+ export function buildMailerAnalyticsDataPoint(
58
+ options: ResolvedMailerOptions,
59
+ eventName: MailerAnalyticsEvent,
60
+ emitOptions: EmitMailerAnalyticsOptions = {},
61
+ ): { blobs: string[]; doubles: number[]; indexes: string[] } {
62
+ const url = emitOptions.request ? new URL(emitOptions.request.url) : new URL(options.siteUrl)
63
+ const siteId = siteIdFromUrl(options.siteUrl)
64
+ const label = emitOptions.label ? JSON.stringify(emitOptions.label) : ''
65
+
66
+ return {
67
+ blobs: [
68
+ eventName,
69
+ siteId,
70
+ '',
71
+ '',
72
+ url.toString(),
73
+ url.pathname,
74
+ emitOptions.request?.headers.get('referer') ?? '',
75
+ '',
76
+ '',
77
+ '',
78
+ '',
79
+ emitOptions.request?.headers.get('cf-ipcountry') ?? '',
80
+ '',
81
+ '',
82
+ '',
83
+ emitOptions.contentSlug ?? '',
84
+ 'newsletter',
85
+ categoryForMailerEvent(eventName),
86
+ label,
87
+ 'false',
88
+ ],
89
+ doubles: [Date.now(), emitOptions.eventValue ?? 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
90
+ indexes: [eventName],
91
+ }
92
+ }
93
+
94
+ function siteIdFromUrl(siteUrl: string): string {
95
+ try {
96
+ return new URL(siteUrl).hostname.replace(/^www\./, '')
97
+ } catch {
98
+ return siteUrl
99
+ }
100
+ }
101
+
102
+ function categoryForMailerEvent(eventName: MailerAnalyticsEvent): string {
103
+ switch (eventName) {
104
+ case 'newsletter_subscribed':
105
+ case 'newsletter_confirmed':
106
+ return 'conversion'
107
+ case 'newsletter_opened':
108
+ case 'newsletter_clicked':
109
+ return 'interaction'
110
+ case 'newsletter_delivered':
111
+ case 'newsletter_bounced':
112
+ case 'newsletter_complained':
113
+ case 'newsletter_sent':
114
+ case 'newsletter_send_failed':
115
+ case 'newsletter_unsubscribed':
116
+ return 'newsletter'
117
+ }
118
+ }
@@ -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,
@@ -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)
package/src/virtual.d.ts CHANGED
@@ -26,5 +26,6 @@ declare module 'virtual:growth-labs/mailer/config' {
26
26
  }
27
27
  batchSize: number
28
28
  analyticsEnabled: boolean
29
+ analyticsBinding: string
29
30
  }
30
31
  }