@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
package/package.json CHANGED
@@ -1,85 +1,87 @@
1
1
  {
2
- "name": "@growth-labs/mailer",
3
- "version": "0.1.3",
4
- "type": "module",
5
- "exports": {
6
- ".": {
7
- "types": "./dist/index.d.ts",
8
- "import": "./dist/index.js"
9
- },
10
- "./components": {
11
- "types": "./src/components/index.ts",
12
- "import": "./src/components/index.ts"
13
- },
14
- "./utils": {
15
- "types": "./dist/utils/index.d.ts",
16
- "import": "./dist/utils/index.js"
17
- },
18
- "./utils/*": {
19
- "types": "./dist/utils/*.d.ts",
20
- "import": "./dist/utils/*.js"
21
- },
22
- "./middleware/*": {
23
- "types": "./dist/middleware/*.d.ts",
24
- "import": "./dist/middleware/*.js",
25
- "default": "./dist/middleware/*.js"
26
- },
27
- "./routes/preferences": {
28
- "types": "./src/routes/preferences.astro",
29
- "import": "./src/routes/preferences.astro",
30
- "default": "./src/routes/preferences.astro"
31
- },
32
- "./routes/*": {
33
- "types": "./dist/routes/*.d.ts",
34
- "import": "./dist/routes/*.js",
35
- "default": "./dist/routes/*.js"
36
- },
37
- "./queue": {
38
- "types": "./dist/queue/consumer.d.ts",
39
- "import": "./dist/queue/consumer.js"
40
- },
41
- "./components/*": {
42
- "types": "./src/components/*.astro",
43
- "import": "./src/components/*.astro"
44
- },
45
- "./schema": {
46
- "types": "./dist/schema.d.ts",
47
- "import": "./dist/schema.js"
48
- }
49
- },
50
- "files": [
51
- "dist",
52
- "src",
53
- "README.md"
54
- ],
55
- "publishConfig": {
56
- "access": "public",
57
- "registry": "https://registry.npmjs.org/"
58
- },
59
- "scripts": {
60
- "build": "tsc",
61
- "test": "vitest run",
62
- "test:watch": "vitest"
63
- },
64
- "peerDependencies": {
65
- "astro": "^6.0.0"
66
- },
67
- "peerDependenciesMeta": {
68
- "@growth-labs/analytics": {
69
- "optional": true
70
- }
71
- },
72
- "dependencies": {
73
- "drizzle-orm": "^0.38.0",
74
- "ulidx": "^2.4.0",
75
- "zod": "^3.23.0"
76
- },
77
- "devDependencies": {
78
- "@cloudflare/workers-types": "^4.20260402.1",
79
- "astro": "^6.0.0",
80
- "drizzle-kit": "^0.30.0",
81
- "typescript": "^5.7.0",
82
- "vite": "^7.0.0",
83
- "vitest": "^3.0.0"
84
- }
85
- }
2
+ "name": "@growth-labs/mailer",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "types": "./dist/index.d.ts",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./components": {
12
+ "types": "./src/components/index.ts",
13
+ "import": "./src/components/index.ts"
14
+ },
15
+ "./utils": {
16
+ "types": "./dist/utils/index.d.ts",
17
+ "import": "./dist/utils/index.js"
18
+ },
19
+ "./utils/*": {
20
+ "types": "./dist/utils/*.d.ts",
21
+ "import": "./dist/utils/*.js"
22
+ },
23
+ "./middleware/*": {
24
+ "types": "./dist/middleware/*.d.ts",
25
+ "import": "./dist/middleware/*.js",
26
+ "default": "./dist/middleware/*.js"
27
+ },
28
+ "./routes/preferences": {
29
+ "types": "./src/routes/preferences.astro",
30
+ "import": "./src/routes/preferences.astro",
31
+ "default": "./src/routes/preferences.astro"
32
+ },
33
+ "./routes/*": {
34
+ "types": "./dist/routes/*.d.ts",
35
+ "import": "./dist/routes/*.js",
36
+ "default": "./dist/routes/*.js"
37
+ },
38
+ "./queue": {
39
+ "types": "./dist/queue/consumer.d.ts",
40
+ "import": "./dist/queue/consumer.js"
41
+ },
42
+ "./components/*": {
43
+ "types": "./src/components/*.astro",
44
+ "import": "./src/components/*.astro"
45
+ },
46
+ "./schema": {
47
+ "types": "./dist/schema.d.ts",
48
+ "import": "./dist/schema.js"
49
+ }
50
+ },
51
+ "files": [
52
+ "dist",
53
+ "src",
54
+ "README.md"
55
+ ],
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "registry": "https://registry.npmjs.org/"
59
+ },
60
+ "peerDependencies": {
61
+ "astro": "^6.0.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@growth-labs/analytics": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "dependencies": {
69
+ "drizzle-orm": "^0.38.0",
70
+ "ulidx": "^2.4.0",
71
+ "zod": "^3.23.0"
72
+ },
73
+ "devDependencies": {
74
+ "@cloudflare/workers-types": "^4.20260402.1",
75
+ "astro": "^6.0.0",
76
+ "drizzle-kit": "^0.30.0",
77
+ "typescript": "^5.7.0",
78
+ "vite": "^7.0.0",
79
+ "vitest": "^3.0.0"
80
+ },
81
+ "scripts": {
82
+ "build": "tsc",
83
+ "check": "biome check src/",
84
+ "test": "vitest run",
85
+ "test:watch": "vitest"
86
+ }
87
+ }
@@ -0,0 +1,3 @@
1
+ declare module 'cloudflare:workers' {
2
+ export const env: Record<string, unknown>
3
+ }
package/src/options.ts CHANGED
@@ -1,65 +1,57 @@
1
1
  import { z } from 'zod'
2
2
 
3
- export const mailerOptionsSchema = z
4
- .object({
5
- // ─── Required ───
6
- senderName: z.string(),
7
- fromAddress: z.string().email(),
8
-
9
- // ─── Reply-to ───
10
- replyTo: z.string().email().optional(),
11
-
12
- // ─── Cloudflare bindings ───
13
- d1Binding: z.string().default('SITE_DB'),
14
- queueBinding: z.string().default('EMAIL_QUEUE'),
15
-
16
- // ─── Turnstile (subscribe form) ───
17
- turnstileSiteKey: z.string(),
18
- turnstileSecretKey: z.string(),
19
-
20
- // ─── Subscriber lifecycle ───
21
- doubleOptIn: z.boolean().default(true),
22
- topics: z.array(z.string()).optional(),
23
-
24
- // ─── Token signing ───
25
- signingSecret: z.string(),
26
-
27
- // ─── Routes ───
28
- subscribePath: z.string().default('/api/newsletter/subscribe'),
29
- confirmPath: z.string().default('/api/newsletter/confirm'),
30
- unsubscribePath: z.string().default('/api/newsletter/unsubscribe'),
31
- preferencesPath: z.string().default('/email/preferences'),
32
- webhookPath: z.string().default('/api/email/webhook'),
33
-
34
- // ─── Tracking ───
35
- trackOpenPath: z.string().default('/api/email/open'),
36
- trackClickPath: z.string().default('/api/email/click'),
37
- siteUrl: z.string().url(),
38
-
39
- // ─── Branding (for built-in templates) ───
40
- brand: z
41
- .object({
42
- logoUrl: z.string().url().optional(),
43
- primaryColor: z.string().default('#1a365d'),
44
- accentColor: z.string().default('#e53e3e'),
45
- footerText: z.string().optional(),
46
- })
47
- .default({}),
48
-
49
- // ─── Fallback provider ───
50
- fallbackProvider: z.enum(['resend', 'none']).default('none'),
51
- resendApiKey: z.string().optional(),
52
-
53
- // ─── Queue batching ───
54
- batchSize: z.number().min(1).max(500).default(100),
55
-
56
- // ─── Optional peer: analytics ───
57
- analyticsEnabled: z.boolean().default(false),
58
- })
59
- .refine((data) => data.fallbackProvider !== 'resend' || data.resendApiKey, {
60
- message: 'resendApiKey is required when fallbackProvider is "resend"',
61
- path: ['resendApiKey'],
62
- })
3
+ export const mailerOptionsSchema = z.object({
4
+ // ─── Required ───
5
+ senderName: z.string(),
6
+ fromAddress: z.string().email(),
7
+
8
+ // ─── Reply-to ───
9
+ replyTo: z.string().email().optional(),
10
+
11
+ // ─── Cloudflare bindings ───
12
+ d1Binding: z.string().default('SITE_DB'),
13
+ queueBinding: z.string().default('EMAIL_QUEUE'),
14
+
15
+ // ─── Turnstile (subscribe form) ───
16
+ turnstileSiteKey: z.string(),
17
+ turnstileSecretKey: z.string(),
18
+
19
+ // ─── Subscriber lifecycle ───
20
+ doubleOptIn: z.boolean().default(true),
21
+ topics: z.array(z.string()).optional(),
22
+
23
+ // ─── Token signing ───
24
+ signingSecret: z.string(),
25
+
26
+ // ─── Routes ───
27
+ subscribePath: z.string().default('/api/newsletter/subscribe'),
28
+ confirmPath: z.string().default('/api/newsletter/confirm'),
29
+ unsubscribePath: z.string().default('/api/newsletter/unsubscribe'),
30
+ preferencesPath: z.string().default('/email/preferences'),
31
+ webhookPath: z.string().default('/api/email/webhook'),
32
+
33
+ // ─── Tracking ───
34
+ trackOpenPath: z.string().default('/api/email/open'),
35
+ trackClickPath: z.string().default('/api/email/click'),
36
+ siteUrl: z.string().url(),
37
+
38
+ // ─── Branding (for built-in templates) ───
39
+ brand: z
40
+ .object({
41
+ logoUrl: z.string().url().optional(),
42
+ primaryColor: z.string().default('#1a365d'),
43
+ accentColor: z.string().default('#e53e3e'),
44
+ footerText: z.string().optional(),
45
+ })
46
+ .default({}),
47
+
48
+ // ─── Queue batching ───
49
+ batchSize: z.number().min(1).max(500).default(100),
50
+
51
+ // ─── Optional peer: analytics ───
52
+ analyticsEnabled: z.boolean().default(false),
53
+ analyticsBinding: z.string().default('ANALYTICS'),
54
+ })
63
55
 
64
56
  export type MailerOptions = z.input<typeof mailerOptionsSchema>
65
57
  export type ResolvedMailerOptions = z.output<typeof mailerOptionsSchema>
@@ -1,13 +1,18 @@
1
1
  import { drizzle } from 'drizzle-orm/d1'
2
2
  import type { ResolvedMailerOptions } from '../options.js'
3
3
  import type { EmailProvider, EmailQueueMessage } from '../types.js'
4
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
4
5
  import { updateSendStatus } from '../utils/bounce.js'
5
6
  import type { CloudflareEmailSender } from '../utils/providers.js'
6
- import { CloudflareEmailProvider, getFallbackProvider, sleep } from '../utils/providers.js'
7
+ import { CloudflareEmailProvider, sleep } from '../utils/providers.js'
7
8
 
8
9
  export async function handleEmailQueue(
9
10
  batch: MessageBatch<EmailQueueMessage>,
10
- env: { DB: D1Database; EMAIL_SENDER?: CloudflareEmailSender },
11
+ env: {
12
+ DB: D1Database
13
+ EMAIL_SENDER?: CloudflareEmailSender
14
+ [key: string]: unknown
15
+ },
11
16
  options: ResolvedMailerOptions,
12
17
  ): Promise<void> {
13
18
  const db = drizzle(env.DB)
@@ -16,7 +21,6 @@ export async function handleEmailQueue(
16
21
  : new CloudflareEmailProvider({
17
22
  send: () => Promise.reject(new Error('No email sender configured')),
18
23
  })
19
- const fallback = getFallbackProvider(options)
20
24
 
21
25
  for (const message of batch.messages) {
22
26
  const { recipients, htmlTemplate, subject, from, replyTo, headers, type } = message.body
@@ -65,31 +69,36 @@ export async function handleEmailQueue(
65
69
  }
66
70
  }
67
71
 
68
- if (!result.success && fallback) {
69
- result = await fallback.send({
70
- to: recipient.email,
71
- from,
72
- replyTo,
73
- subject,
74
- html,
75
- headers: recipientHeaders,
76
- })
77
- if (result.success) {
78
- console.warn(
79
- `[mailer] Fallback provider used for ${recipient.email}: ${provider.name} → ${fallback.name}`,
80
- )
81
- }
82
- }
83
-
84
72
  if (result.success) {
85
73
  await updateSendStatus(db, recipient.trackingId, 'sent', {
86
74
  sentAt: new Date().toISOString(),
87
75
  })
76
+ emitMailerAnalyticsEvent(options, env, 'newsletter_sent', {
77
+ contentSlug: recipient.trackingId,
78
+ label: {
79
+ trackingId: recipient.trackingId,
80
+ subscriberId: recipient.subscriberId,
81
+ email: recipient.email,
82
+ campaignId: message.body.campaignId,
83
+ type,
84
+ },
85
+ })
88
86
  } else {
89
87
  await updateSendStatus(db, recipient.trackingId, 'bounced', {
90
88
  bouncedAt: new Date().toISOString(),
91
89
  bounceType: 'hard',
92
90
  })
91
+ emitMailerAnalyticsEvent(options, env, 'newsletter_send_failed', {
92
+ contentSlug: recipient.trackingId,
93
+ label: {
94
+ trackingId: recipient.trackingId,
95
+ subscriberId: recipient.subscriberId,
96
+ email: recipient.email,
97
+ campaignId: message.body.campaignId,
98
+ type,
99
+ error: result.error,
100
+ },
101
+ })
93
102
  console.error(`[mailer] Send failed for ${recipient.email}: ${result.error}`)
94
103
  }
95
104
  }
@@ -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 { confirmSubscriber, getSubscriberById } from '../utils/subscribers.js'
8
9
  import { verifyToken } from '../utils/tokens.js'
9
10
 
10
- export const GET: APIRoute = async ({ url, locals }) => {
11
+ export const GET: APIRoute = async (context) => {
12
+ const { url } = context
11
13
  const token = url.searchParams.get('token')
12
14
  if (!token) {
13
15
  return Response.json({ error: 'Missing token' }, { status: 400 })
@@ -20,9 +22,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
20
22
  }
21
23
 
22
24
  // Resolve bindings
23
- const runtimeEnv = (locals as RuntimeLocals).runtime?.env as Record<string, unknown>
24
- const d1 = runtimeEnv[config.d1Binding] as D1Database
25
- const queue = runtimeEnv[config.queueBinding] as Queue
25
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
26
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
27
+ const queue = bindingsEnv[config.queueBinding] as Queue
26
28
  const db = drizzle(d1)
27
29
  const env: MailerEnv = { DB: d1, QUEUE: queue }
28
30
 
@@ -58,6 +60,15 @@ export const GET: APIRoute = async ({ url, locals }) => {
58
60
  data: { name: subscriber.name },
59
61
  })
60
62
 
63
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_confirmed', {
64
+ context,
65
+ contentSlug: subscriber.id,
66
+ label: {
67
+ subscriberId: subscriber.id,
68
+ email: subscriber.email,
69
+ },
70
+ })
71
+
61
72
  // Redirect to site with confirmed flag
62
73
  return new Response(null, {
63
74
  status: 302,
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { config } from "virtual:growth-labs/mailer/config";
3
+ import { env as cloudflareEnv } from "cloudflare:workers";
3
4
  import { drizzle } from "drizzle-orm/d1";
4
5
  import PreferenceCenter from "../components/PreferenceCenter.astro";
5
6
  import {
@@ -50,16 +51,11 @@ if (Astro.request.method === "POST" && payload.action !== "preferences") {
50
51
  return new Response("Invalid or expired token", { status: 400 });
51
52
  }
52
53
 
53
- const runtimeEnv = (
54
- Astro.locals as Record<string, unknown> & {
55
- runtime?: { env: Record<string, unknown> };
56
- }
57
- ).runtime?.env;
58
- if (!runtimeEnv) {
59
- return new Response("Runtime not available", { status: 500 });
54
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>;
55
+ const d1 = bindingsEnv[config.d1Binding] as D1Database;
56
+ if (!d1) {
57
+ return new Response("D1 binding not available", { status: 500 });
60
58
  }
61
-
62
- const d1 = runtimeEnv[config.d1Binding] as D1Database;
63
59
  const db = drizzle(d1);
64
60
 
65
61
  // Handle POST (form submission)
@@ -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,