@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.
- package/README.md +89 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +3 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +65 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/tracking.d.ts +3 -0
- package/dist/middleware/tracking.d.ts.map +1 -0
- package/dist/middleware/tracking.js +13 -0
- package/dist/middleware/tracking.js.map +1 -0
- package/dist/options.d.ts +160 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +51 -0
- package/dist/options.js.map +1 -0
- package/dist/queue/consumer.d.ts +8 -0
- package/dist/queue/consumer.d.ts.map +1 -0
- package/dist/queue/consumer.js +83 -0
- package/dist/queue/consumer.js.map +1 -0
- package/dist/routes/confirm.d.ts +3 -0
- package/dist/routes/confirm.d.ts.map +1 -0
- package/dist/routes/confirm.js +59 -0
- package/dist/routes/confirm.js.map +1 -0
- package/dist/routes/subscribe.d.ts +3 -0
- package/dist/routes/subscribe.d.ts.map +1 -0
- package/dist/routes/subscribe.js +87 -0
- package/dist/routes/subscribe.js.map +1 -0
- package/dist/routes/track-click.d.ts +3 -0
- package/dist/routes/track-click.d.ts.map +1 -0
- package/dist/routes/track-click.js +45 -0
- package/dist/routes/track-click.js.map +1 -0
- package/dist/routes/track-open.d.ts +3 -0
- package/dist/routes/track-open.d.ts.map +1 -0
- package/dist/routes/track-open.js +40 -0
- package/dist/routes/track-open.js.map +1 -0
- package/dist/routes/unsubscribe.d.ts +4 -0
- package/dist/routes/unsubscribe.d.ts.map +1 -0
- package/dist/routes/unsubscribe.js +81 -0
- package/dist/routes/unsubscribe.js.map +1 -0
- package/dist/routes/webhook.d.ts +3 -0
- package/dist/routes/webhook.d.ts.map +1 -0
- package/dist/routes/webhook.js +30 -0
- package/dist/routes/webhook.js.map +1 -0
- package/dist/schema.d.ts +564 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +47 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +106 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/bindings.d.ts +20 -0
- package/dist/utils/bindings.d.ts.map +1 -0
- package/dist/utils/bindings.js +19 -0
- package/dist/utils/bindings.js.map +1 -0
- package/dist/utils/bounce.d.ts +29 -0
- package/dist/utils/bounce.d.ts.map +1 -0
- package/dist/utils/bounce.js +59 -0
- package/dist/utils/bounce.js.map +1 -0
- package/dist/utils/index.d.ts +12 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/providers.d.ts +31 -0
- package/dist/utils/providers.d.ts.map +1 -0
- package/dist/utils/providers.js +109 -0
- package/dist/utils/providers.js.map +1 -0
- package/dist/utils/scheduling.d.ts +89 -0
- package/dist/utils/scheduling.d.ts.map +1 -0
- package/dist/utils/scheduling.js +110 -0
- package/dist/utils/scheduling.js.map +1 -0
- package/dist/utils/send.d.ts +42 -0
- package/dist/utils/send.d.ts.map +1 -0
- package/dist/utils/send.js +193 -0
- package/dist/utils/send.js.map +1 -0
- package/dist/utils/subscribers.d.ts +23 -0
- package/dist/utils/subscribers.d.ts.map +1 -0
- package/dist/utils/subscribers.js +200 -0
- package/dist/utils/subscribers.js.map +1 -0
- package/dist/utils/templates.d.ts +16 -0
- package/dist/utils/templates.d.ts.map +1 -0
- package/dist/utils/templates.js +426 -0
- package/dist/utils/templates.js.map +1 -0
- package/dist/utils/tokens.d.ts +13 -0
- package/dist/utils/tokens.d.ts.map +1 -0
- package/dist/utils/tokens.js +62 -0
- package/dist/utils/tokens.js.map +1 -0
- package/dist/utils/tracking.d.ts +26 -0
- package/dist/utils/tracking.d.ts.map +1 -0
- package/dist/utils/tracking.js +49 -0
- package/dist/utils/tracking.js.map +1 -0
- package/dist/utils/urls.d.ts +7 -0
- package/dist/utils/urls.d.ts.map +1 -0
- package/dist/utils/urls.js +34 -0
- package/dist/utils/urls.js.map +1 -0
- package/dist/vite-plugin.d.ts +4 -0
- package/dist/vite-plugin.d.ts.map +1 -0
- package/dist/vite-plugin.js +18 -0
- package/dist/vite-plugin.js.map +1 -0
- package/package.json +85 -0
- package/src/astro.d.ts +4 -0
- package/src/components/PreferenceCenter.astro +147 -0
- package/src/components/SubscribeForm.astro +161 -0
- package/src/components/index.ts +2 -0
- package/src/index.ts +101 -0
- package/src/middleware/tracking.ts +18 -0
- package/src/options.ts +65 -0
- package/src/queue/consumer.ts +99 -0
- package/src/routes/confirm.ts +68 -0
- package/src/routes/preferences.astro +137 -0
- package/src/routes/subscribe.ts +107 -0
- package/src/routes/track-click.ts +57 -0
- package/src/routes/track-open.ts +51 -0
- package/src/routes/unsubscribe.ts +96 -0
- package/src/routes/webhook.ts +48 -0
- package/src/schema.ts +56 -0
- package/src/types.ts +145 -0
- package/src/utils/bindings.ts +28 -0
- package/src/utils/bounce.ts +77 -0
- package/src/utils/index.ts +47 -0
- package/src/utils/providers.ts +141 -0
- package/src/utils/scheduling.ts +188 -0
- package/src/utils/send.ts +282 -0
- package/src/utils/subscribers.ts +277 -0
- package/src/utils/templates.ts +459 -0
- package/src/utils/tokens.ts +91 -0
- package/src/utils/tracking.ts +58 -0
- package/src/utils/urls.ts +49 -0
- package/src/virtual.d.ts +32 -0
- 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
|
+
}
|