@growth-labs/mailer 0.2.0 → 0.2.2
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 +32 -4
- package/dist/options.d.ts +61 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +23 -0
- 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 +22 -0
- 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 +52 -6
- 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 +75 -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 +3 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/providers.js +1 -1
- package/dist/utils/providers.js.map +1 -1
- package/dist/utils/webhook-signature.d.ts +6 -0
- package/dist/utils/webhook-signature.d.ts.map +1 -0
- package/dist/utils/webhook-signature.js +59 -0
- package/dist/utils/webhook-signature.js.map +1 -0
- package/package.json +7 -1
- package/src/cloudflare-workers.d.ts +3 -0
- package/src/options.ts +23 -0
- package/src/queue/consumer.ts +27 -1
- 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 +55 -11
- package/src/utils/analytics.ts +120 -0
- package/src/utils/bindings.ts +9 -11
- package/src/utils/index.ts +7 -5
- package/src/utils/providers.ts +1 -1
- package/src/utils/webhook-signature.ts +91 -0
- package/src/virtual.d.ts +15 -0
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,
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
1
2
|
import { config } from 'virtual:growth-labs/mailer/config'
|
|
2
3
|
import type { APIRoute } from 'astro'
|
|
3
4
|
import { drizzle } from 'drizzle-orm/d1'
|
|
4
|
-
import
|
|
5
|
+
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
5
6
|
import type { MailerEnv } from '../utils/send.js'
|
|
6
7
|
import { sendTransactional } from '../utils/send.js'
|
|
7
8
|
import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
|
|
8
9
|
import { generateToken, verifyToken } from '../utils/tokens.js'
|
|
9
10
|
import { buildSiteUrl } from '../utils/urls.js'
|
|
10
11
|
|
|
11
|
-
async function processUnsubscribe(
|
|
12
|
+
async function processUnsubscribe(
|
|
13
|
+
token: string,
|
|
14
|
+
context?: Parameters<APIRoute>[0],
|
|
15
|
+
request?: Request,
|
|
16
|
+
) {
|
|
12
17
|
// Verify token
|
|
13
18
|
const payload = await verifyToken(config.signingSecret, token)
|
|
14
19
|
if (!payload || payload.action !== 'unsubscribe') {
|
|
@@ -16,9 +21,9 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
|
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
// Resolve bindings
|
|
19
|
-
const
|
|
20
|
-
const d1 =
|
|
21
|
-
const queue =
|
|
24
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
25
|
+
const d1 = bindingsEnv[config.d1Binding] as D1Database
|
|
26
|
+
const queue = bindingsEnv[config.queueBinding] as Queue
|
|
22
27
|
const db = drizzle(d1)
|
|
23
28
|
const env: MailerEnv = { DB: d1, QUEUE: queue }
|
|
24
29
|
|
|
@@ -43,6 +48,16 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
|
|
|
43
48
|
},
|
|
44
49
|
})
|
|
45
50
|
|
|
51
|
+
emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
|
|
52
|
+
request,
|
|
53
|
+
context,
|
|
54
|
+
contentSlug: subscriber.id,
|
|
55
|
+
label: {
|
|
56
|
+
subscriberId: subscriber.id,
|
|
57
|
+
email: subscriber.email,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
46
61
|
return {
|
|
47
62
|
success: true,
|
|
48
63
|
subscriberId: payload.subscriberId,
|
|
@@ -50,13 +65,14 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
|
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
// GET: Link from email footer
|
|
53
|
-
export const GET: APIRoute = async (
|
|
68
|
+
export const GET: APIRoute = async (context) => {
|
|
69
|
+
const { url, request } = context
|
|
54
70
|
const token = url.searchParams.get('token')
|
|
55
71
|
if (!token) {
|
|
56
72
|
return Response.json({ error: 'Missing token' }, { status: 400 })
|
|
57
73
|
}
|
|
58
74
|
|
|
59
|
-
const result = await processUnsubscribe(
|
|
75
|
+
const result = await processUnsubscribe(token, context, request)
|
|
60
76
|
if ('error' in result) {
|
|
61
77
|
return Response.json({ error: result.error }, { status: result.status })
|
|
62
78
|
}
|
|
@@ -78,7 +94,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
// POST: RFC 8058 List-Unsubscribe-Post
|
|
81
|
-
export const POST: APIRoute = async (
|
|
97
|
+
export const POST: APIRoute = async (context) => {
|
|
98
|
+
const { request } = context
|
|
82
99
|
// RFC 8058: body is "List-Unsubscribe=One-Click"
|
|
83
100
|
// Token comes from List-Unsubscribe header URL
|
|
84
101
|
const url = new URL(request.url)
|
|
@@ -87,7 +104,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
87
104
|
return new Response('Missing token', { status: 400 })
|
|
88
105
|
}
|
|
89
106
|
|
|
90
|
-
const result = await processUnsubscribe(
|
|
107
|
+
const result = await processUnsubscribe(token, context, request)
|
|
91
108
|
if ('error' in result) {
|
|
92
109
|
return new Response(result.error, { status: result.status })
|
|
93
110
|
}
|
package/src/routes/webhook.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
1
2
|
import { config } from 'virtual:growth-labs/mailer/config'
|
|
2
3
|
import type { APIRoute } from 'astro'
|
|
3
4
|
import { drizzle } from 'drizzle-orm/d1'
|
|
5
|
+
import { emitMailerAnalyticsEvent, type MailerAnalyticsEvent } from '../utils/analytics.js'
|
|
4
6
|
import { handleBounce, handleComplaint, handleDelivery } from '../utils/bounce.js'
|
|
7
|
+
import { verifyWebhookSignature } from '../utils/webhook-signature.js'
|
|
5
8
|
|
|
6
9
|
interface WebhookPayload {
|
|
7
10
|
type: 'bounce' | 'complaint' | 'delivery'
|
|
@@ -11,25 +14,44 @@ interface WebhookPayload {
|
|
|
11
14
|
timestamp: string
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
export const POST: APIRoute = async (
|
|
15
|
-
const
|
|
17
|
+
export const POST: APIRoute = async (context) => {
|
|
18
|
+
const { request } = context
|
|
19
|
+
const rawBody = await request.text()
|
|
20
|
+
const signatureValid = await verifyWebhookSignature(config.webhookSignature, request, rawBody)
|
|
21
|
+
if (!signatureValid) {
|
|
22
|
+
return Response.json({ error: 'Invalid webhook signature' }, { status: 401 })
|
|
23
|
+
}
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
let body: WebhookPayload
|
|
26
|
+
try {
|
|
27
|
+
body = JSON.parse(rawBody) as WebhookPayload
|
|
28
|
+
} catch {
|
|
18
29
|
return Response.json({ error: 'Invalid payload' }, { status: 400 })
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
runtime?: { env: Record<string, unknown> }
|
|
24
|
-
}
|
|
25
|
-
).runtime?.env
|
|
26
|
-
if (!runtimeEnv) {
|
|
27
|
-
return Response.json({ error: 'Runtime not available' }, { status: 500 })
|
|
32
|
+
if (!body.type || !body.email) {
|
|
33
|
+
return Response.json({ error: 'Invalid payload' }, { status: 400 })
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
const
|
|
36
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
37
|
+
const d1 = bindingsEnv[config.d1Binding] as D1Database
|
|
38
|
+
if (!d1) {
|
|
39
|
+
return Response.json({ error: 'D1 binding not available' }, { status: 500 })
|
|
40
|
+
}
|
|
31
41
|
const db = drizzle(d1)
|
|
32
42
|
|
|
43
|
+
emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_webhook_received', {
|
|
44
|
+
request,
|
|
45
|
+
context,
|
|
46
|
+
contentSlug: body.trackingId ?? body.email,
|
|
47
|
+
label: {
|
|
48
|
+
type: body.type,
|
|
49
|
+
email: body.email,
|
|
50
|
+
trackingId: body.trackingId,
|
|
51
|
+
bounceType: body.bounceType,
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
33
55
|
switch (body.type) {
|
|
34
56
|
case 'delivery':
|
|
35
57
|
if (body.trackingId) {
|
|
@@ -44,5 +66,27 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
44
66
|
break
|
|
45
67
|
}
|
|
46
68
|
|
|
69
|
+
emitMailerAnalyticsEvent(config, bindingsEnv, eventForWebhookType(body.type), {
|
|
70
|
+
request,
|
|
71
|
+
context,
|
|
72
|
+
contentSlug: body.trackingId ?? body.email,
|
|
73
|
+
label: {
|
|
74
|
+
email: body.email,
|
|
75
|
+
trackingId: body.trackingId,
|
|
76
|
+
bounceType: body.bounceType,
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
|
|
47
80
|
return Response.json({ ok: true })
|
|
48
81
|
}
|
|
82
|
+
|
|
83
|
+
function eventForWebhookType(type: WebhookPayload['type']): MailerAnalyticsEvent {
|
|
84
|
+
switch (type) {
|
|
85
|
+
case 'delivery':
|
|
86
|
+
return 'newsletter_delivered'
|
|
87
|
+
case 'bounce':
|
|
88
|
+
return 'newsletter_bounced'
|
|
89
|
+
case 'complaint':
|
|
90
|
+
return 'newsletter_complained'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ResolvedMailerOptions } from '../options.js'
|
|
2
|
+
|
|
3
|
+
export type MailerAnalyticsEvent =
|
|
4
|
+
| 'newsletter_subscribed'
|
|
5
|
+
| 'newsletter_confirmed'
|
|
6
|
+
| 'newsletter_unsubscribed'
|
|
7
|
+
| 'newsletter_opened'
|
|
8
|
+
| 'newsletter_clicked'
|
|
9
|
+
| 'newsletter_webhook_received'
|
|
10
|
+
| 'newsletter_delivered'
|
|
11
|
+
| 'newsletter_bounced'
|
|
12
|
+
| 'newsletter_complained'
|
|
13
|
+
| 'newsletter_sent'
|
|
14
|
+
| 'newsletter_send_failed'
|
|
15
|
+
|
|
16
|
+
interface AnalyticsBinding {
|
|
17
|
+
writeDataPoint(dataPoint: unknown): Promise<unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AnalyticsContext {
|
|
21
|
+
locals?: {
|
|
22
|
+
cfContext?: {
|
|
23
|
+
waitUntil(promise: Promise<unknown>): void
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface EmitMailerAnalyticsOptions {
|
|
29
|
+
request?: Request
|
|
30
|
+
context?: AnalyticsContext
|
|
31
|
+
label?: Record<string, unknown>
|
|
32
|
+
contentSlug?: string
|
|
33
|
+
eventValue?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function emitMailerAnalyticsEvent(
|
|
37
|
+
options: ResolvedMailerOptions,
|
|
38
|
+
bindingsEnv: Record<string, unknown>,
|
|
39
|
+
eventName: MailerAnalyticsEvent,
|
|
40
|
+
emitOptions: EmitMailerAnalyticsOptions = {},
|
|
41
|
+
): boolean {
|
|
42
|
+
if (!options.analyticsEnabled) return false
|
|
43
|
+
|
|
44
|
+
const analyticsBinding = bindingsEnv[options.analyticsBinding] as AnalyticsBinding | undefined
|
|
45
|
+
if (!analyticsBinding?.writeDataPoint) return false
|
|
46
|
+
|
|
47
|
+
const dataPoint = buildMailerAnalyticsDataPoint(options, eventName, emitOptions)
|
|
48
|
+
const write = analyticsBinding.writeDataPoint(dataPoint)
|
|
49
|
+
const waitUntil = emitOptions.context?.locals?.cfContext?.waitUntil
|
|
50
|
+
if (waitUntil) {
|
|
51
|
+
waitUntil(write.catch(() => undefined))
|
|
52
|
+
} else {
|
|
53
|
+
void write.catch(() => undefined)
|
|
54
|
+
}
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildMailerAnalyticsDataPoint(
|
|
59
|
+
options: ResolvedMailerOptions,
|
|
60
|
+
eventName: MailerAnalyticsEvent,
|
|
61
|
+
emitOptions: EmitMailerAnalyticsOptions = {},
|
|
62
|
+
): { blobs: string[]; doubles: number[]; indexes: string[] } {
|
|
63
|
+
const url = emitOptions.request ? new URL(emitOptions.request.url) : new URL(options.siteUrl)
|
|
64
|
+
const siteId = siteIdFromUrl(options.siteUrl)
|
|
65
|
+
const label = emitOptions.label ? JSON.stringify(emitOptions.label) : ''
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
blobs: [
|
|
69
|
+
eventName,
|
|
70
|
+
siteId,
|
|
71
|
+
'',
|
|
72
|
+
'',
|
|
73
|
+
url.toString(),
|
|
74
|
+
url.pathname,
|
|
75
|
+
emitOptions.request?.headers.get('referer') ?? '',
|
|
76
|
+
'',
|
|
77
|
+
'',
|
|
78
|
+
'',
|
|
79
|
+
'',
|
|
80
|
+
emitOptions.request?.headers.get('cf-ipcountry') ?? '',
|
|
81
|
+
'',
|
|
82
|
+
'',
|
|
83
|
+
'',
|
|
84
|
+
emitOptions.contentSlug ?? '',
|
|
85
|
+
'newsletter',
|
|
86
|
+
categoryForMailerEvent(eventName),
|
|
87
|
+
label,
|
|
88
|
+
'false',
|
|
89
|
+
],
|
|
90
|
+
doubles: [Date.now(), emitOptions.eventValue ?? 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
91
|
+
indexes: [eventName],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function siteIdFromUrl(siteUrl: string): string {
|
|
96
|
+
try {
|
|
97
|
+
return new URL(siteUrl).hostname.replace(/^www\./, '')
|
|
98
|
+
} catch {
|
|
99
|
+
return siteUrl
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function categoryForMailerEvent(eventName: MailerAnalyticsEvent): string {
|
|
104
|
+
switch (eventName) {
|
|
105
|
+
case 'newsletter_subscribed':
|
|
106
|
+
case 'newsletter_confirmed':
|
|
107
|
+
return 'conversion'
|
|
108
|
+
case 'newsletter_opened':
|
|
109
|
+
case 'newsletter_clicked':
|
|
110
|
+
return 'interaction'
|
|
111
|
+
case 'newsletter_webhook_received':
|
|
112
|
+
case 'newsletter_delivered':
|
|
113
|
+
case 'newsletter_bounced':
|
|
114
|
+
case 'newsletter_complained':
|
|
115
|
+
case 'newsletter_sent':
|
|
116
|
+
case 'newsletter_send_failed':
|
|
117
|
+
case 'newsletter_unsubscribed':
|
|
118
|
+
return 'newsletter'
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/utils/bindings.ts
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
1
2
|
import { config } from 'virtual:growth-labs/mailer/config'
|
|
2
3
|
import { drizzle } from 'drizzle-orm/d1'
|
|
3
4
|
|
|
4
5
|
type DrizzleDB = ReturnType<typeof drizzle>
|
|
5
6
|
|
|
6
|
-
export interface RuntimeLocals {
|
|
7
|
-
runtime?: { env: Record<string, unknown> }
|
|
8
|
-
}
|
|
9
|
-
|
|
10
7
|
/**
|
|
11
|
-
* Resolve Cloudflare bindings from the
|
|
8
|
+
* Resolve Cloudflare bindings from the Astro 6 Cloudflare Workers env object.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the virtual config so they stay in sync with the consumer's `astro.config`.
|
|
10
|
+
* Binding names come from the virtual config so they stay in sync with the
|
|
11
|
+
* consumer's `astro.config`.
|
|
16
12
|
*/
|
|
17
|
-
export function resolveBindings(
|
|
13
|
+
export function resolveBindings(
|
|
14
|
+
bindingsEnv: Record<string, unknown> = cloudflareEnv as Record<string, unknown>,
|
|
15
|
+
): {
|
|
18
16
|
db: DrizzleDB
|
|
19
17
|
queue: Queue
|
|
20
18
|
} {
|
|
21
|
-
const d1 =
|
|
22
|
-
const queue =
|
|
19
|
+
const d1 = bindingsEnv[config.d1Binding] as D1Database
|
|
20
|
+
const queue = bindingsEnv[config.queueBinding] as Queue
|
|
23
21
|
|
|
24
22
|
if (!d1) throw new Error(`[mailer] D1 binding "${config.d1Binding}" not found`)
|
|
25
23
|
if (!queue) throw new Error(`[mailer] Queue binding "${config.queueBinding}" not found`)
|
package/src/utils/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export {
|
|
2
|
+
buildMailerAnalyticsDataPoint,
|
|
3
|
+
emitMailerAnalyticsEvent,
|
|
4
|
+
type MailerAnalyticsEvent,
|
|
5
|
+
} from './analytics.js'
|
|
1
6
|
export {
|
|
2
7
|
handleBounce,
|
|
3
8
|
handleComplaint,
|
|
@@ -5,11 +10,7 @@ export {
|
|
|
5
10
|
updateSendStatus,
|
|
6
11
|
} from './bounce.js'
|
|
7
12
|
export type { CloudflareEmailSender } from './providers.js'
|
|
8
|
-
export {
|
|
9
|
-
CloudflareEmailProvider,
|
|
10
|
-
getProvider,
|
|
11
|
-
sleep,
|
|
12
|
-
} from './providers.js'
|
|
13
|
+
export { CloudflareEmailProvider, getProvider, sleep } from './providers.js'
|
|
13
14
|
export type { CampaignSchedule, DigestSchedule } from './scheduling.js'
|
|
14
15
|
export {
|
|
15
16
|
executeCampaignSchedule,
|
|
@@ -43,3 +44,4 @@ export {
|
|
|
43
44
|
rewriteLinksForTracking,
|
|
44
45
|
TRANSPARENT_GIF,
|
|
45
46
|
} from './tracking.js'
|
|
47
|
+
export { signWebhookPayload, verifyWebhookSignature } from './webhook-signature.js'
|
package/src/utils/providers.ts
CHANGED
|
@@ -44,7 +44,7 @@ export class CloudflareEmailProvider implements EmailProvider {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// ─── Factory
|
|
47
|
+
// ─── Factory function ───
|
|
48
48
|
|
|
49
49
|
export function getProvider(emailSender: CloudflareEmailSender): EmailProvider {
|
|
50
50
|
return new CloudflareEmailProvider(emailSender)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ResolvedMailerOptions } from '../options.js'
|
|
2
|
+
|
|
3
|
+
type WebhookSignatureOptions = ResolvedMailerOptions['webhookSignature']
|
|
4
|
+
|
|
5
|
+
export async function verifyWebhookSignature(
|
|
6
|
+
options: WebhookSignatureOptions,
|
|
7
|
+
request: Request,
|
|
8
|
+
body: string,
|
|
9
|
+
nowMs: number = Date.now(),
|
|
10
|
+
): Promise<boolean> {
|
|
11
|
+
if (!options.enabled) return true
|
|
12
|
+
|
|
13
|
+
const timestamp = request.headers.get(options.timestampHeader)
|
|
14
|
+
const signatureHeader = request.headers.get(options.header)
|
|
15
|
+
if (!timestamp || !signatureHeader) return false
|
|
16
|
+
|
|
17
|
+
if (!timestampWithinTolerance(timestamp, options.toleranceSeconds, nowMs)) return false
|
|
18
|
+
|
|
19
|
+
const signature = signatureFromHeader(signatureHeader)
|
|
20
|
+
if (!signature) return false
|
|
21
|
+
|
|
22
|
+
const key = await importWebhookKey(options.secret)
|
|
23
|
+
const signedBody = `${timestamp}.${body}`
|
|
24
|
+
return crypto.subtle.verify(
|
|
25
|
+
'HMAC',
|
|
26
|
+
key,
|
|
27
|
+
fromBase64Url(signature),
|
|
28
|
+
new TextEncoder().encode(signedBody),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function signWebhookPayload(
|
|
33
|
+
secret: string,
|
|
34
|
+
timestamp: string,
|
|
35
|
+
body: string,
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
const key = await importWebhookKey(secret)
|
|
38
|
+
const signature = await crypto.subtle.sign(
|
|
39
|
+
'HMAC',
|
|
40
|
+
key,
|
|
41
|
+
new TextEncoder().encode(`${timestamp}.${body}`),
|
|
42
|
+
)
|
|
43
|
+
return toBase64Url(signature)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function timestampWithinTolerance(
|
|
47
|
+
timestamp: string,
|
|
48
|
+
toleranceSeconds: number,
|
|
49
|
+
nowMs: number,
|
|
50
|
+
): boolean {
|
|
51
|
+
if (toleranceSeconds === 0) return true
|
|
52
|
+
const parsed = Number(timestamp)
|
|
53
|
+
if (!Number.isFinite(parsed)) return false
|
|
54
|
+
const timestampMs = parsed > 1_000_000_000_000 ? parsed : parsed * 1000
|
|
55
|
+
return Math.abs(nowMs - timestampMs) <= toleranceSeconds * 1000
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function signatureFromHeader(header: string): string | null {
|
|
59
|
+
const parts = header.split(',').map((part) => part.trim())
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
if (part.startsWith('v1=')) return part.slice(3)
|
|
62
|
+
}
|
|
63
|
+
return parts[0] || null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function importWebhookKey(secret: string): Promise<CryptoKey> {
|
|
67
|
+
return crypto.subtle.importKey(
|
|
68
|
+
'raw',
|
|
69
|
+
new TextEncoder().encode(secret),
|
|
70
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
71
|
+
false,
|
|
72
|
+
['sign', 'verify'],
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {
|
|
77
|
+
const padded = value
|
|
78
|
+
.replace(/-/g, '+')
|
|
79
|
+
.replace(/_/g, '/')
|
|
80
|
+
.padEnd(Math.ceil(value.length / 4) * 4, '=')
|
|
81
|
+
const binary = atob(padded)
|
|
82
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
|
|
83
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
84
|
+
return bytes
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toBase64Url(bytes: ArrayBuffer): string {
|
|
88
|
+
let binary = ''
|
|
89
|
+
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
|
90
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
91
|
+
}
|
package/src/virtual.d.ts
CHANGED
|
@@ -15,6 +15,20 @@ declare module 'virtual:growth-labs/mailer/config' {
|
|
|
15
15
|
unsubscribePath: string
|
|
16
16
|
preferencesPath: string
|
|
17
17
|
webhookPath: string
|
|
18
|
+
webhookSignature:
|
|
19
|
+
| {
|
|
20
|
+
enabled: false
|
|
21
|
+
header: string
|
|
22
|
+
timestampHeader: string
|
|
23
|
+
toleranceSeconds: number
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
enabled: true
|
|
27
|
+
secret: string
|
|
28
|
+
header: string
|
|
29
|
+
timestampHeader: string
|
|
30
|
+
toleranceSeconds: number
|
|
31
|
+
}
|
|
18
32
|
trackOpenPath: string
|
|
19
33
|
trackClickPath: string
|
|
20
34
|
siteUrl: string
|
|
@@ -26,5 +40,6 @@ declare module 'virtual:growth-labs/mailer/config' {
|
|
|
26
40
|
}
|
|
27
41
|
batchSize: number
|
|
28
42
|
analyticsEnabled: boolean
|
|
43
|
+
analyticsBinding: string
|
|
29
44
|
}
|
|
30
45
|
}
|