@growth-labs/mailer 0.1.3

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 (132) hide show
  1. package/README.md +89 -0
  2. package/dist/components/index.d.ts +3 -0
  3. package/dist/components/index.d.ts.map +1 -0
  4. package/dist/components/index.js +3 -0
  5. package/dist/components/index.js.map +1 -0
  6. package/dist/index.d.ts +14 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +65 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/middleware/tracking.d.ts +3 -0
  11. package/dist/middleware/tracking.d.ts.map +1 -0
  12. package/dist/middleware/tracking.js +13 -0
  13. package/dist/middleware/tracking.js.map +1 -0
  14. package/dist/options.d.ts +160 -0
  15. package/dist/options.d.ts.map +1 -0
  16. package/dist/options.js +51 -0
  17. package/dist/options.js.map +1 -0
  18. package/dist/queue/consumer.d.ts +8 -0
  19. package/dist/queue/consumer.d.ts.map +1 -0
  20. package/dist/queue/consumer.js +83 -0
  21. package/dist/queue/consumer.js.map +1 -0
  22. package/dist/routes/confirm.d.ts +3 -0
  23. package/dist/routes/confirm.d.ts.map +1 -0
  24. package/dist/routes/confirm.js +59 -0
  25. package/dist/routes/confirm.js.map +1 -0
  26. package/dist/routes/subscribe.d.ts +3 -0
  27. package/dist/routes/subscribe.d.ts.map +1 -0
  28. package/dist/routes/subscribe.js +87 -0
  29. package/dist/routes/subscribe.js.map +1 -0
  30. package/dist/routes/track-click.d.ts +3 -0
  31. package/dist/routes/track-click.d.ts.map +1 -0
  32. package/dist/routes/track-click.js +45 -0
  33. package/dist/routes/track-click.js.map +1 -0
  34. package/dist/routes/track-open.d.ts +3 -0
  35. package/dist/routes/track-open.d.ts.map +1 -0
  36. package/dist/routes/track-open.js +40 -0
  37. package/dist/routes/track-open.js.map +1 -0
  38. package/dist/routes/unsubscribe.d.ts +4 -0
  39. package/dist/routes/unsubscribe.d.ts.map +1 -0
  40. package/dist/routes/unsubscribe.js +81 -0
  41. package/dist/routes/unsubscribe.js.map +1 -0
  42. package/dist/routes/webhook.d.ts +3 -0
  43. package/dist/routes/webhook.d.ts.map +1 -0
  44. package/dist/routes/webhook.js +30 -0
  45. package/dist/routes/webhook.js.map +1 -0
  46. package/dist/schema.d.ts +564 -0
  47. package/dist/schema.d.ts.map +1 -0
  48. package/dist/schema.js +47 -0
  49. package/dist/schema.js.map +1 -0
  50. package/dist/types.d.ts +106 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +3 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/utils/bindings.d.ts +20 -0
  55. package/dist/utils/bindings.d.ts.map +1 -0
  56. package/dist/utils/bindings.js +19 -0
  57. package/dist/utils/bindings.js.map +1 -0
  58. package/dist/utils/bounce.d.ts +29 -0
  59. package/dist/utils/bounce.d.ts.map +1 -0
  60. package/dist/utils/bounce.js +59 -0
  61. package/dist/utils/bounce.js.map +1 -0
  62. package/dist/utils/index.d.ts +12 -0
  63. package/dist/utils/index.d.ts.map +1 -0
  64. package/dist/utils/index.js +9 -0
  65. package/dist/utils/index.js.map +1 -0
  66. package/dist/utils/providers.d.ts +31 -0
  67. package/dist/utils/providers.d.ts.map +1 -0
  68. package/dist/utils/providers.js +109 -0
  69. package/dist/utils/providers.js.map +1 -0
  70. package/dist/utils/scheduling.d.ts +89 -0
  71. package/dist/utils/scheduling.d.ts.map +1 -0
  72. package/dist/utils/scheduling.js +110 -0
  73. package/dist/utils/scheduling.js.map +1 -0
  74. package/dist/utils/send.d.ts +42 -0
  75. package/dist/utils/send.d.ts.map +1 -0
  76. package/dist/utils/send.js +193 -0
  77. package/dist/utils/send.js.map +1 -0
  78. package/dist/utils/subscribers.d.ts +23 -0
  79. package/dist/utils/subscribers.d.ts.map +1 -0
  80. package/dist/utils/subscribers.js +200 -0
  81. package/dist/utils/subscribers.js.map +1 -0
  82. package/dist/utils/templates.d.ts +16 -0
  83. package/dist/utils/templates.d.ts.map +1 -0
  84. package/dist/utils/templates.js +426 -0
  85. package/dist/utils/templates.js.map +1 -0
  86. package/dist/utils/tokens.d.ts +13 -0
  87. package/dist/utils/tokens.d.ts.map +1 -0
  88. package/dist/utils/tokens.js +62 -0
  89. package/dist/utils/tokens.js.map +1 -0
  90. package/dist/utils/tracking.d.ts +26 -0
  91. package/dist/utils/tracking.d.ts.map +1 -0
  92. package/dist/utils/tracking.js +49 -0
  93. package/dist/utils/tracking.js.map +1 -0
  94. package/dist/utils/urls.d.ts +7 -0
  95. package/dist/utils/urls.d.ts.map +1 -0
  96. package/dist/utils/urls.js +34 -0
  97. package/dist/utils/urls.js.map +1 -0
  98. package/dist/vite-plugin.d.ts +4 -0
  99. package/dist/vite-plugin.d.ts.map +1 -0
  100. package/dist/vite-plugin.js +18 -0
  101. package/dist/vite-plugin.js.map +1 -0
  102. package/package.json +85 -0
  103. package/src/astro.d.ts +4 -0
  104. package/src/components/PreferenceCenter.astro +147 -0
  105. package/src/components/SubscribeForm.astro +161 -0
  106. package/src/components/index.ts +2 -0
  107. package/src/index.ts +101 -0
  108. package/src/middleware/tracking.ts +18 -0
  109. package/src/options.ts +65 -0
  110. package/src/queue/consumer.ts +99 -0
  111. package/src/routes/confirm.ts +68 -0
  112. package/src/routes/preferences.astro +137 -0
  113. package/src/routes/subscribe.ts +107 -0
  114. package/src/routes/track-click.ts +57 -0
  115. package/src/routes/track-open.ts +51 -0
  116. package/src/routes/unsubscribe.ts +96 -0
  117. package/src/routes/webhook.ts +48 -0
  118. package/src/schema.ts +56 -0
  119. package/src/types.ts +145 -0
  120. package/src/utils/bindings.ts +28 -0
  121. package/src/utils/bounce.ts +77 -0
  122. package/src/utils/index.ts +47 -0
  123. package/src/utils/providers.ts +141 -0
  124. package/src/utils/scheduling.ts +188 -0
  125. package/src/utils/send.ts +282 -0
  126. package/src/utils/subscribers.ts +277 -0
  127. package/src/utils/templates.ts +459 -0
  128. package/src/utils/tokens.ts +91 -0
  129. package/src/utils/tracking.ts +58 -0
  130. package/src/utils/urls.ts +49 -0
  131. package/src/virtual.d.ts +32 -0
  132. package/src/vite-plugin.ts +21 -0
@@ -0,0 +1,99 @@
1
+ import { drizzle } from 'drizzle-orm/d1'
2
+ import type { ResolvedMailerOptions } from '../options.js'
3
+ import type { EmailProvider, EmailQueueMessage } from '../types.js'
4
+ import { updateSendStatus } from '../utils/bounce.js'
5
+ import type { CloudflareEmailSender } from '../utils/providers.js'
6
+ import { CloudflareEmailProvider, getFallbackProvider, sleep } from '../utils/providers.js'
7
+
8
+ export async function handleEmailQueue(
9
+ batch: MessageBatch<EmailQueueMessage>,
10
+ env: { DB: D1Database; EMAIL_SENDER?: CloudflareEmailSender },
11
+ options: ResolvedMailerOptions,
12
+ ): Promise<void> {
13
+ const db = drizzle(env.DB)
14
+ const provider: EmailProvider = env.EMAIL_SENDER
15
+ ? new CloudflareEmailProvider(env.EMAIL_SENDER)
16
+ : new CloudflareEmailProvider({
17
+ send: () => Promise.reject(new Error('No email sender configured')),
18
+ })
19
+ const fallback = getFallbackProvider(options)
20
+
21
+ for (const message of batch.messages) {
22
+ const { recipients, htmlTemplate, subject, from, replyTo, headers, type } = message.body
23
+
24
+ for (const recipient of recipients) {
25
+ let html = htmlTemplate
26
+ if (type !== 'transactional') {
27
+ const unsubscribeUrl = `${options.siteUrl}${options.unsubscribePath}?token=${recipient.unsubscribeToken}`
28
+ const preferencesUrl = `${options.siteUrl}${options.preferencesPath}?token=${recipient.preferencesToken}`
29
+ html = html
30
+ .replaceAll('{{TRACKING_ID}}', recipient.trackingId)
31
+ .replaceAll('{{UNSUBSCRIBE_URL}}', unsubscribeUrl)
32
+ .replaceAll('{{PREFERENCES_URL}}', preferencesUrl)
33
+ }
34
+
35
+ const recipientHeaders =
36
+ type !== 'transactional'
37
+ ? {
38
+ ...headers,
39
+ 'List-Unsubscribe': `<${options.siteUrl}${options.unsubscribePath}?token=${recipient.unsubscribeToken}>`,
40
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
41
+ }
42
+ : headers
43
+
44
+ let result = await provider.send({
45
+ to: recipient.email,
46
+ from,
47
+ replyTo,
48
+ subject,
49
+ html,
50
+ headers: recipientHeaders,
51
+ })
52
+
53
+ if (!result.success && result.retryable) {
54
+ for (let attempt = 1; attempt <= 3; attempt++) {
55
+ await sleep(2 ** attempt * 1000)
56
+ result = await provider.send({
57
+ to: recipient.email,
58
+ from,
59
+ replyTo,
60
+ subject,
61
+ html,
62
+ headers: recipientHeaders,
63
+ })
64
+ if (result.success) break
65
+ }
66
+ }
67
+
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
+ if (result.success) {
85
+ await updateSendStatus(db, recipient.trackingId, 'sent', {
86
+ sentAt: new Date().toISOString(),
87
+ })
88
+ } else {
89
+ await updateSendStatus(db, recipient.trackingId, 'bounced', {
90
+ bouncedAt: new Date().toISOString(),
91
+ bounceType: 'hard',
92
+ })
93
+ console.error(`[mailer] Send failed for ${recipient.email}: ${result.error}`)
94
+ }
95
+ }
96
+
97
+ message.ack()
98
+ }
99
+ }
@@ -0,0 +1,68 @@
1
+ import { config } from 'virtual:growth-labs/mailer/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { drizzle } from 'drizzle-orm/d1'
4
+ import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import type { MailerEnv } from '../utils/send.js'
6
+ import { sendTransactional } from '../utils/send.js'
7
+ import { confirmSubscriber, getSubscriberById } from '../utils/subscribers.js'
8
+ import { verifyToken } from '../utils/tokens.js'
9
+
10
+ export const GET: APIRoute = async ({ url, locals }) => {
11
+ const token = url.searchParams.get('token')
12
+ if (!token) {
13
+ return Response.json({ error: 'Missing token' }, { status: 400 })
14
+ }
15
+
16
+ // Verify token
17
+ const payload = await verifyToken(config.signingSecret, token)
18
+ if (!payload || payload.action !== 'confirm') {
19
+ return Response.json({ error: 'Invalid or expired token' }, { status: 400 })
20
+ }
21
+
22
+ // 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
26
+ const db = drizzle(d1)
27
+ const env: MailerEnv = { DB: d1, QUEUE: queue }
28
+
29
+ // Get subscriber to check status
30
+ const subscriber = await getSubscriberById(db, payload.subscriberId)
31
+ if (!subscriber) {
32
+ return Response.json({ error: 'Subscriber not found' }, { status: 404 })
33
+ }
34
+
35
+ if (subscriber.status === 'active') {
36
+ // Already confirmed — redirect with flag
37
+ return new Response(null, {
38
+ status: 302,
39
+ headers: {
40
+ Location: `${config.siteUrl}?already_confirmed=true`,
41
+ },
42
+ })
43
+ }
44
+
45
+ // Confirm subscriber
46
+ try {
47
+ await confirmSubscriber(db, payload.subscriberId)
48
+ } catch {
49
+ return Response.json({ error: 'Confirmation failed' }, { status: 400 })
50
+ }
51
+
52
+ // Send welcome email
53
+ await sendTransactional(env, config, {
54
+ to: subscriber.email,
55
+ subscriberId: subscriber.id,
56
+ subject: `Welcome to ${config.senderName}`,
57
+ template: 'welcome',
58
+ data: { name: subscriber.name },
59
+ })
60
+
61
+ // Redirect to site with confirmed flag
62
+ return new Response(null, {
63
+ status: 302,
64
+ headers: {
65
+ Location: `${config.siteUrl}?confirmed=true`,
66
+ },
67
+ })
68
+ }
@@ -0,0 +1,137 @@
1
+ ---
2
+ import { config } from "virtual:growth-labs/mailer/config";
3
+ import { drizzle } from "drizzle-orm/d1";
4
+ import PreferenceCenter from "../components/PreferenceCenter.astro";
5
+ import {
6
+ getSubscriberById,
7
+ unsubscribeSubscriber,
8
+ updatePreferences,
9
+ } from "../utils/subscribers.js";
10
+ import { generateToken, verifyToken } from "../utils/tokens.js";
11
+ import { buildSiteUrl } from "../utils/urls.js";
12
+
13
+ const url = Astro.url;
14
+ const unsubscribed = url.searchParams.get("unsubscribed") === "true";
15
+ const updated = url.searchParams.get("updated") === "true";
16
+ const formData = Astro.request.method === "POST" ? await Astro.request.formData() : null;
17
+ const submittedToken = formData?.get("token");
18
+ const token =
19
+ Astro.request.method === "POST"
20
+ ? typeof submittedToken === "string"
21
+ ? submittedToken
22
+ : null
23
+ : url.searchParams.get("token");
24
+
25
+ if (!token) {
26
+ return new Response("Missing token", { status: 400 });
27
+ }
28
+
29
+ const payload = await verifyToken(config.signingSecret, token);
30
+ if (!payload || !["preferences", "unsubscribe"].includes(payload.action)) {
31
+ return new Response("Invalid or expired token", { status: 400 });
32
+ }
33
+
34
+ if (Astro.request.method === "GET" && payload.action === "unsubscribe") {
35
+ const preferencesToken = await generateToken(config.signingSecret, {
36
+ subscriberId: payload.subscriberId,
37
+ action: "preferences",
38
+ });
39
+
40
+ return Astro.redirect(
41
+ buildSiteUrl(config.siteUrl, config.preferencesPath, {
42
+ token: preferencesToken,
43
+ unsubscribed: unsubscribed || undefined,
44
+ updated: updated || undefined,
45
+ }),
46
+ );
47
+ }
48
+
49
+ if (Astro.request.method === "POST" && payload.action !== "preferences") {
50
+ return new Response("Invalid or expired token", { status: 400 });
51
+ }
52
+
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 });
60
+ }
61
+
62
+ const d1 = runtimeEnv[config.d1Binding] as D1Database;
63
+ const db = drizzle(d1);
64
+
65
+ // Handle POST (form submission)
66
+ if (Astro.request.method === "POST") {
67
+ const action = formData.get("action");
68
+
69
+ if (action === "unsubscribe") {
70
+ await unsubscribeSubscriber(db, payload.subscriberId);
71
+ return Astro.redirect(
72
+ buildSiteUrl(config.siteUrl, config.preferencesPath, {
73
+ token,
74
+ unsubscribed: true,
75
+ }),
76
+ );
77
+ }
78
+
79
+ const newPreferences: string[] = [];
80
+ for (const topic of config.topics ?? []) {
81
+ if (formData.get(`pref_${topic}`)) {
82
+ newPreferences.push(topic);
83
+ }
84
+ }
85
+ await updatePreferences(db, payload.subscriberId, newPreferences);
86
+ return Astro.redirect(
87
+ buildSiteUrl(config.siteUrl, config.preferencesPath, {
88
+ token,
89
+ updated: true,
90
+ }),
91
+ );
92
+ }
93
+
94
+ const subscriber = await getSubscriberById(db, payload.subscriberId);
95
+ if (!subscriber) {
96
+ return new Response("Subscriber not found", { status: 404 });
97
+ }
98
+ ---
99
+
100
+ <html lang="en">
101
+ <head>
102
+ <meta charset="utf-8" />
103
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
104
+ <title>Email Preferences — {config.senderName}</title>
105
+ <style>
106
+ body {
107
+ margin: 0;
108
+ padding: 24px 16px;
109
+ background-color: #f4f4f5;
110
+ min-height: 100vh;
111
+ }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <PreferenceCenter
116
+ subscriber={subscriber}
117
+ topics={config.topics ?? []}
118
+ token={token}
119
+ siteUrl={config.siteUrl}
120
+ subscribePath={config.subscribePath}
121
+ senderName={config.senderName}
122
+ brand={config.brand}
123
+ />
124
+ {unsubscribed && (
125
+ <script>
126
+ document.querySelector('[data-toast="unsubscribed"]')
127
+ ?.removeAttribute('hidden')
128
+ </script>
129
+ )}
130
+ {updated && (
131
+ <script>
132
+ document.querySelector('[data-toast="updated"]')
133
+ ?.removeAttribute('hidden')
134
+ </script>
135
+ )}
136
+ </body>
137
+ </html>
@@ -0,0 +1,107 @@
1
+ import { config } from 'virtual:growth-labs/mailer/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { drizzle } from 'drizzle-orm/d1'
4
+ import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import type { MailerEnv } from '../utils/send.js'
6
+ import { sendTransactional } from '../utils/send.js'
7
+ import { createSubscriber } from '../utils/subscribers.js'
8
+ import { generateToken } from '../utils/tokens.js'
9
+
10
+ export const POST: APIRoute = async ({ request, locals }) => {
11
+ // 1. Parse request body
12
+ const body = (await request.json()) as {
13
+ email?: string
14
+ name?: string
15
+ source?: string
16
+ preferences?: string[]
17
+ turnstileToken?: string
18
+ }
19
+
20
+ // 2. Validate required fields
21
+ if (!body.email || !body.turnstileToken) {
22
+ return Response.json({ error: 'Missing required fields' }, { status: 400 })
23
+ }
24
+
25
+ // 3. Basic email format validation
26
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
27
+ return Response.json({ error: 'Invalid email format' }, { status: 400 })
28
+ }
29
+
30
+ // 4. Validate Turnstile token
31
+ const turnstileResponse = await fetch(
32
+ 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
33
+ {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({
37
+ secret: config.turnstileSecretKey,
38
+ response: body.turnstileToken,
39
+ remoteip: request.headers.get('cf-connecting-ip') ?? undefined,
40
+ }),
41
+ },
42
+ )
43
+ const turnstileResult = (await turnstileResponse.json()) as {
44
+ success: boolean
45
+ }
46
+ if (!turnstileResult.success) {
47
+ return Response.json({ error: 'Turnstile verification failed' }, { status: 400 })
48
+ }
49
+
50
+ // 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
54
+ const db = drizzle(d1)
55
+ const env: MailerEnv = { DB: d1, QUEUE: queue }
56
+
57
+ // 6. Create subscriber
58
+ try {
59
+ const { subscriber, isNew } = await createSubscriber(db, {
60
+ email: body.email,
61
+ name: body.name,
62
+ source: body.source ?? 'form',
63
+ preferences: body.preferences,
64
+ doubleOptIn: config.doubleOptIn,
65
+ })
66
+
67
+ // 7. Send confirmation or welcome email
68
+ if (config.doubleOptIn && (isNew || subscriber.status === 'pending')) {
69
+ const confirmToken = await generateToken(config.signingSecret, {
70
+ subscriberId: subscriber.id,
71
+ action: 'confirm',
72
+ })
73
+ const confirmUrl = `${config.siteUrl}${config.confirmPath}?token=${confirmToken}`
74
+
75
+ await sendTransactional(env, config, {
76
+ to: body.email,
77
+ subscriberId: subscriber.id,
78
+ subject: `Confirm your ${config.senderName} subscription`,
79
+ template: 'confirmation',
80
+ data: {
81
+ name: body.name,
82
+ confirmUrl,
83
+ },
84
+ })
85
+ } else if (!config.doubleOptIn && isNew) {
86
+ await sendTransactional(env, config, {
87
+ to: body.email,
88
+ subscriberId: subscriber.id,
89
+ subject: `Welcome to ${config.senderName}`,
90
+ template: 'welcome',
91
+ data: { name: body.name },
92
+ })
93
+ }
94
+
95
+ // Return success (always 200 to avoid email enumeration)
96
+ return Response.json({
97
+ success: true,
98
+ requiresConfirmation: config.doubleOptIn,
99
+ })
100
+ } catch (err) {
101
+ const message = err instanceof Error ? err.message : String(err)
102
+ if (message.includes('bounced') || message.includes('complained')) {
103
+ return Response.json({ error: 'This email address cannot be subscribed' }, { status: 422 })
104
+ }
105
+ throw err
106
+ }
107
+ }
@@ -0,0 +1,57 @@
1
+ import { config } from 'virtual:growth-labs/mailer/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { and, eq, inArray } from 'drizzle-orm'
4
+ import { drizzle } from 'drizzle-orm/d1'
5
+ import { emailSends } from '../schema.js'
6
+
7
+ export const GET: APIRoute = async ({ params, url, locals }) => {
8
+ const trackingId = params.trackingId
9
+ const destination = url.searchParams.get('url')
10
+
11
+ if (!trackingId || !destination) {
12
+ return new Response('Bad request', { status: 400 })
13
+ }
14
+
15
+ // Validate: must be http or https (prevent javascript:, data:, etc.)
16
+ try {
17
+ const destUrl = new URL(destination)
18
+ if (!['http:', 'https:'].includes(destUrl.protocol)) {
19
+ return new Response('Invalid redirect', { status: 400 })
20
+ }
21
+ } catch {
22
+ return new Response('Invalid URL', { status: 400 })
23
+ }
24
+
25
+ // Update status to 'clicked' only when it hasn't already reached 'clicked'
26
+ 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
34
+ const db = drizzle(d1)
35
+ await db
36
+ .update(emailSends)
37
+ .set({
38
+ status: 'clicked',
39
+ clickedAt: new Date().toISOString(),
40
+ })
41
+ .where(
42
+ and(
43
+ eq(emailSends.trackingId, trackingId),
44
+ inArray(emailSends.status, ['sent', 'delivered', 'opened']),
45
+ ),
46
+ )
47
+ }
48
+ } catch {
49
+ // Never fail the redirect on DB errors
50
+ }
51
+
52
+ // 302 redirect to original destination
53
+ return new Response(null, {
54
+ status: 302,
55
+ headers: { Location: destination },
56
+ })
57
+ }
@@ -0,0 +1,51 @@
1
+ import { config } from 'virtual:growth-labs/mailer/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { and, eq, inArray } from 'drizzle-orm'
4
+ import { drizzle } from 'drizzle-orm/d1'
5
+ import { emailSends } from '../schema.js'
6
+ import { TRANSPARENT_GIF } from '../utils/tracking.js'
7
+
8
+ export const GET: APIRoute = async ({ params, locals }) => {
9
+ const trackingId = params.trackingId
10
+ if (!trackingId) {
11
+ return new Response(null, { status: 400 })
12
+ }
13
+
14
+ // Update status to 'opened' only when currently 'sent' or 'delivered'
15
+ // to avoid downgrading from 'clicked'.
16
+ 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
24
+ const db = drizzle(d1)
25
+ await db
26
+ .update(emailSends)
27
+ .set({
28
+ status: 'opened',
29
+ openedAt: new Date().toISOString(),
30
+ })
31
+ .where(
32
+ and(
33
+ eq(emailSends.trackingId, trackingId),
34
+ inArray(emailSends.status, ['sent', 'delivered']),
35
+ ),
36
+ )
37
+ }
38
+ } catch {
39
+ // Never fail the pixel response on DB errors
40
+ }
41
+
42
+ // 1x1 transparent GIF
43
+ return new Response(TRANSPARENT_GIF, {
44
+ status: 200,
45
+ headers: {
46
+ 'Content-Type': 'image/gif',
47
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
48
+ 'Content-Length': String(TRANSPARENT_GIF.length),
49
+ },
50
+ })
51
+ }
@@ -0,0 +1,96 @@
1
+ import { config } from 'virtual:growth-labs/mailer/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { drizzle } from 'drizzle-orm/d1'
4
+ import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import type { MailerEnv } from '../utils/send.js'
6
+ import { sendTransactional } from '../utils/send.js'
7
+ import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
8
+ import { generateToken, verifyToken } from '../utils/tokens.js'
9
+ import { buildSiteUrl } from '../utils/urls.js'
10
+
11
+ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
12
+ // Verify token
13
+ const payload = await verifyToken(config.signingSecret, token)
14
+ if (!payload || payload.action !== 'unsubscribe') {
15
+ return { error: 'Invalid or expired token', status: 400 }
16
+ }
17
+
18
+ // 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
22
+ const db = drizzle(d1)
23
+ const env: MailerEnv = { DB: d1, QUEUE: queue }
24
+
25
+ // Get subscriber
26
+ const subscriber = await getSubscriberById(db, payload.subscriberId)
27
+ if (!subscriber) {
28
+ return { error: 'Subscriber not found', status: 404 }
29
+ }
30
+
31
+ // Unsubscribe
32
+ await unsubscribeSubscriber(db, payload.subscriberId)
33
+
34
+ // Send confirmation email
35
+ await sendTransactional(env, config, {
36
+ to: subscriber.email,
37
+ subscriberId: subscriber.id,
38
+ subject: `You've been unsubscribed from ${config.senderName}`,
39
+ template: 'unsubscribe-confirm',
40
+ data: {
41
+ name: subscriber.name,
42
+ resubscribeUrl: buildSiteUrl(config.siteUrl, config.subscribePath),
43
+ },
44
+ })
45
+
46
+ return {
47
+ success: true,
48
+ subscriberId: payload.subscriberId,
49
+ }
50
+ }
51
+
52
+ // GET: Link from email footer
53
+ export const GET: APIRoute = async ({ url, locals }) => {
54
+ const token = url.searchParams.get('token')
55
+ if (!token) {
56
+ return Response.json({ error: 'Missing token' }, { status: 400 })
57
+ }
58
+
59
+ const result = await processUnsubscribe(locals as RuntimeLocals, token)
60
+ if ('error' in result) {
61
+ return Response.json({ error: result.error }, { status: result.status })
62
+ }
63
+
64
+ // Generate preferences token for the redirect
65
+ const preferencesToken = await generateToken(config.signingSecret, {
66
+ subscriberId: result.subscriberId,
67
+ action: 'preferences',
68
+ })
69
+ const preferencesUrl = buildSiteUrl(config.siteUrl, config.preferencesPath, {
70
+ token: preferencesToken,
71
+ unsubscribed: true,
72
+ })
73
+
74
+ return new Response(null, {
75
+ status: 302,
76
+ headers: { Location: preferencesUrl },
77
+ })
78
+ }
79
+
80
+ // POST: RFC 8058 List-Unsubscribe-Post
81
+ export const POST: APIRoute = async ({ request, locals }) => {
82
+ // RFC 8058: body is "List-Unsubscribe=One-Click"
83
+ // Token comes from List-Unsubscribe header URL
84
+ const url = new URL(request.url)
85
+ const token = url.searchParams.get('token')
86
+ if (!token) {
87
+ return new Response('Missing token', { status: 400 })
88
+ }
89
+
90
+ const result = await processUnsubscribe(locals as RuntimeLocals, token)
91
+ if ('error' in result) {
92
+ return new Response(result.error, { status: result.status })
93
+ }
94
+
95
+ return new Response('OK', { status: 200 })
96
+ }
@@ -0,0 +1,48 @@
1
+ import { config } from 'virtual:growth-labs/mailer/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { drizzle } from 'drizzle-orm/d1'
4
+ import { handleBounce, handleComplaint, handleDelivery } from '../utils/bounce.js'
5
+
6
+ interface WebhookPayload {
7
+ type: 'bounce' | 'complaint' | 'delivery'
8
+ email: string
9
+ bounceType?: 'hard' | 'soft'
10
+ trackingId?: string
11
+ timestamp: string
12
+ }
13
+
14
+ export const POST: APIRoute = async ({ request, locals }) => {
15
+ const body = (await request.json()) as WebhookPayload
16
+
17
+ if (!body.type || !body.email) {
18
+ return Response.json({ error: 'Invalid payload' }, { status: 400 })
19
+ }
20
+
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 })
28
+ }
29
+
30
+ const d1 = runtimeEnv[config.d1Binding] as D1Database
31
+ const db = drizzle(d1)
32
+
33
+ switch (body.type) {
34
+ case 'delivery':
35
+ if (body.trackingId) {
36
+ await handleDelivery(db, body.trackingId)
37
+ }
38
+ break
39
+ case 'bounce':
40
+ await handleBounce(db, body.email, body.bounceType ?? 'hard')
41
+ break
42
+ case 'complaint':
43
+ await handleComplaint(db, body.email)
44
+ break
45
+ }
46
+
47
+ return Response.json({ ok: true })
48
+ }