@growth-labs/mailer 0.1.3 → 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 (59) hide show
  1. package/README.md +27 -5
  2. package/dist/options.d.ts +4 -65
  3. package/dist/options.d.ts.map +1 -1
  4. package/dist/options.js +2 -9
  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 +23 -15
  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.d.ts +0 -10
  41. package/dist/utils/providers.d.ts.map +1 -1
  42. package/dist/utils/providers.js +1 -57
  43. package/dist/utils/providers.js.map +1 -1
  44. package/package.json +86 -84
  45. package/src/cloudflare-workers.d.ts +3 -0
  46. package/src/options.ts +52 -60
  47. package/src/queue/consumer.ts +28 -19
  48. package/src/routes/confirm.ts +16 -5
  49. package/src/routes/preferences.astro +5 -9
  50. package/src/routes/subscribe.ts +21 -5
  51. package/src/routes/track-click.ts +14 -8
  52. package/src/routes/track-open.ts +14 -8
  53. package/src/routes/unsubscribe.ts +26 -9
  54. package/src/routes/webhook.ts +30 -10
  55. package/src/utils/analytics.ts +118 -0
  56. package/src/utils/bindings.ts +9 -11
  57. package/src/utils/index.ts +6 -7
  58. package/src/utils/providers.ts +1 -68
  59. package/src/virtual.d.ts +1 -2
@@ -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,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,13 +10,7 @@ export {
5
10
  updateSendStatus,
6
11
  } from './bounce.js'
7
12
  export type { CloudflareEmailSender } from './providers.js'
8
- export {
9
- CloudflareEmailProvider,
10
- getFallbackProvider,
11
- getProvider,
12
- ResendFallbackProvider,
13
- sleep,
14
- } from './providers.js'
13
+ export { CloudflareEmailProvider, getProvider, sleep } from './providers.js'
15
14
  export type { CampaignSchedule, DigestSchedule } from './scheduling.js'
16
15
  export {
17
16
  executeCampaignSchedule,
@@ -44,79 +44,12 @@ export class CloudflareEmailProvider implements EmailProvider {
44
44
  }
45
45
  }
46
46
 
47
- // ─── ResendFallbackProvider ───
48
-
49
- export class ResendFallbackProvider implements EmailProvider {
50
- readonly name = 'resend'
51
- private readonly apiKey: string
52
-
53
- constructor(apiKey: string) {
54
- this.apiKey = apiKey
55
- }
56
-
57
- async send(email: OutboundEmail): Promise<SendResult> {
58
- try {
59
- const response = await fetch('https://api.resend.com/emails', {
60
- method: 'POST',
61
- headers: {
62
- Authorization: `Bearer ${this.apiKey}`,
63
- 'Content-Type': 'application/json',
64
- },
65
- body: JSON.stringify({
66
- to: email.to,
67
- from: email.from,
68
- reply_to: email.replyTo,
69
- subject: email.subject,
70
- html: email.html,
71
- headers: email.headers,
72
- }),
73
- })
74
-
75
- if (response.ok) {
76
- const data = (await response.json()) as { id?: string }
77
- return {
78
- success: true,
79
- messageId: data.id,
80
- retryable: false,
81
- }
82
- }
83
-
84
- const retryable = response.status === 429 || response.status >= 500
85
- const errorText = await response.text().catch(() => 'Unknown error')
86
-
87
- return {
88
- success: false,
89
- error: `Resend API ${response.status}: ${errorText}`,
90
- retryable,
91
- }
92
- } catch (err) {
93
- const message = err instanceof Error ? err.message : String(err)
94
- return {
95
- success: false,
96
- error: message,
97
- retryable: true,
98
- }
99
- }
100
- }
101
- }
102
-
103
- // ─── Factory functions ───
47
+ // ─── Factory function ───
104
48
 
105
49
  export function getProvider(emailSender: CloudflareEmailSender): EmailProvider {
106
50
  return new CloudflareEmailProvider(emailSender)
107
51
  }
108
52
 
109
- export function getFallbackProvider(options: {
110
- fallbackProvider: string
111
- resendApiKey?: string
112
- }): EmailProvider | null {
113
- if (options.fallbackProvider === 'resend' && options.resendApiKey) {
114
- return new ResendFallbackProvider(options.resendApiKey)
115
- }
116
-
117
- return null
118
- }
119
-
120
53
  // ─── Helpers ───
121
54
 
122
55
  export function sleep(ms: number): Promise<void> {
package/src/virtual.d.ts CHANGED
@@ -24,9 +24,8 @@ declare module 'virtual:growth-labs/mailer/config' {
24
24
  accentColor: string
25
25
  footerText?: string
26
26
  }
27
- fallbackProvider: 'resend' | 'none'
28
- resendApiKey?: string
29
27
  batchSize: number
30
28
  analyticsEnabled: boolean
29
+ analyticsBinding: string
31
30
  }
32
31
  }