@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.
- package/README.md +27 -5
- package/dist/options.d.ts +4 -65
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +2 -9
- package/dist/options.js.map +1 -1
- package/dist/queue/consumer.d.ts +1 -0
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +23 -15
- package/dist/queue/consumer.js.map +1 -1
- package/dist/routes/confirm.d.ts.map +1 -1
- package/dist/routes/confirm.js +15 -4
- package/dist/routes/confirm.js.map +1 -1
- package/dist/routes/subscribe.d.ts.map +1 -1
- package/dist/routes/subscribe.js +20 -4
- package/dist/routes/subscribe.js.map +1 -1
- package/dist/routes/track-click.d.ts.map +1 -1
- package/dist/routes/track-click.js +13 -4
- package/dist/routes/track-click.js.map +1 -1
- package/dist/routes/track-open.d.ts.map +1 -1
- package/dist/routes/track-open.js +13 -4
- package/dist/routes/track-open.js.map +1 -1
- package/dist/routes/unsubscribe.d.ts.map +1 -1
- package/dist/routes/unsubscribe.js +21 -8
- package/dist/routes/unsubscribe.js.map +1 -1
- package/dist/routes/webhook.d.ts.map +1 -1
- package/dist/routes/webhook.js +28 -5
- package/dist/routes/webhook.js.map +1 -1
- package/dist/utils/analytics.d.ts +24 -0
- package/dist/utils/analytics.d.ts.map +1 -0
- package/dist/utils/analytics.js +74 -0
- package/dist/utils/analytics.js.map +1 -0
- package/dist/utils/bindings.d.ts +4 -10
- package/dist/utils/bindings.d.ts.map +1 -1
- package/dist/utils/bindings.js +7 -7
- package/dist/utils/bindings.js.map +1 -1
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/providers.d.ts +0 -10
- package/dist/utils/providers.d.ts.map +1 -1
- package/dist/utils/providers.js +1 -57
- package/dist/utils/providers.js.map +1 -1
- package/package.json +86 -84
- package/src/cloudflare-workers.d.ts +3 -0
- package/src/options.ts +52 -60
- package/src/queue/consumer.ts +28 -19
- package/src/routes/confirm.ts +16 -5
- package/src/routes/preferences.astro +5 -9
- package/src/routes/subscribe.ts +21 -5
- package/src/routes/track-click.ts +14 -8
- package/src/routes/track-open.ts +14 -8
- package/src/routes/unsubscribe.ts +26 -9
- package/src/routes/webhook.ts +30 -10
- package/src/utils/analytics.ts +118 -0
- package/src/utils/bindings.ts +9 -11
- package/src/utils/index.ts +6 -7
- package/src/utils/providers.ts +1 -68
- package/src/virtual.d.ts +1 -2
package/package.json
CHANGED
|
@@ -1,85 +1,87 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
package/src/options.ts
CHANGED
|
@@ -1,65 +1,57 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
export const mailerOptionsSchema = z
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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>
|
package/src/queue/consumer.ts
CHANGED
|
@@ -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,
|
|
7
|
+
import { CloudflareEmailProvider, sleep } from '../utils/providers.js'
|
|
7
8
|
|
|
8
9
|
export async function handleEmailQueue(
|
|
9
10
|
batch: MessageBatch<EmailQueueMessage>,
|
|
10
|
-
env: {
|
|
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
|
}
|
package/src/routes/confirm.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
24
|
-
const d1 =
|
|
25
|
-
const 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
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
package/src/routes/subscribe.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
52
|
-
const d1 =
|
|
53
|
-
const 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 (
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
package/src/routes/track-open.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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,
|