@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
package/src/schema.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
export const subscribers = sqliteTable(
|
|
4
|
+
'gl_subscribers',
|
|
5
|
+
{
|
|
6
|
+
id: text('id').primaryKey(),
|
|
7
|
+
email: text('email').notNull().unique(),
|
|
8
|
+
name: text('name'),
|
|
9
|
+
status: text('status').notNull().default('pending'),
|
|
10
|
+
preferences: text('preferences').notNull().default('[]'),
|
|
11
|
+
source: text('source').notNull(),
|
|
12
|
+
attribution: text('attribution'),
|
|
13
|
+
softBounceCount: integer('soft_bounce_count').notNull().default(0),
|
|
14
|
+
subscribedAt: text('subscribed_at').notNull(),
|
|
15
|
+
confirmedAt: text('confirmed_at'),
|
|
16
|
+
unsubscribedAt: text('unsubscribed_at'),
|
|
17
|
+
createdAt: text('created_at').notNull(),
|
|
18
|
+
updatedAt: text('updated_at').notNull(),
|
|
19
|
+
},
|
|
20
|
+
(table) => [
|
|
21
|
+
index('idx_subscribers_status').on(table.status),
|
|
22
|
+
index('idx_subscribers_email').on(table.email),
|
|
23
|
+
index('idx_subscribers_subscribed_at').on(table.subscribedAt),
|
|
24
|
+
],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export const emailSends = sqliteTable(
|
|
28
|
+
'gl_email_sends',
|
|
29
|
+
{
|
|
30
|
+
id: text('id').primaryKey(),
|
|
31
|
+
subscriberId: text('subscriber_id')
|
|
32
|
+
.notNull()
|
|
33
|
+
.references(() => subscribers.id),
|
|
34
|
+
campaignId: text('campaign_id'),
|
|
35
|
+
email: text('email').notNull(),
|
|
36
|
+
subject: text('subject').notNull(),
|
|
37
|
+
type: text('type').notNull(),
|
|
38
|
+
status: text('status').notNull().default('queued'),
|
|
39
|
+
sentAt: text('sent_at'),
|
|
40
|
+
deliveredAt: text('delivered_at'),
|
|
41
|
+
openedAt: text('opened_at'),
|
|
42
|
+
clickedAt: text('clicked_at'),
|
|
43
|
+
bouncedAt: text('bounced_at'),
|
|
44
|
+
bounceType: text('bounce_type'),
|
|
45
|
+
complainedAt: text('complained_at'),
|
|
46
|
+
trackingId: text('tracking_id').notNull().unique(),
|
|
47
|
+
createdAt: text('created_at').notNull(),
|
|
48
|
+
},
|
|
49
|
+
(table) => [
|
|
50
|
+
index('idx_sends_subscriber').on(table.subscriberId),
|
|
51
|
+
index('idx_sends_campaign').on(table.campaignId),
|
|
52
|
+
index('idx_sends_tracking').on(table.trackingId),
|
|
53
|
+
index('idx_sends_status').on(table.status),
|
|
54
|
+
index('idx_sends_type_created').on(table.type, table.createdAt),
|
|
55
|
+
],
|
|
56
|
+
)
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// ─── Subscriber ───
|
|
2
|
+
|
|
3
|
+
export interface Subscriber {
|
|
4
|
+
id: string
|
|
5
|
+
email: string
|
|
6
|
+
name?: string
|
|
7
|
+
status: SubscriberStatus
|
|
8
|
+
preferences: string[]
|
|
9
|
+
source: string
|
|
10
|
+
attribution?: SubscriberAttribution
|
|
11
|
+
softBounceCount: number
|
|
12
|
+
subscribedAt: string
|
|
13
|
+
confirmedAt?: string
|
|
14
|
+
unsubscribedAt?: string
|
|
15
|
+
createdAt: string
|
|
16
|
+
updatedAt: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SubscriberStatus = 'pending' | 'active' | 'unsubscribed' | 'bounced' | 'complained'
|
|
20
|
+
|
|
21
|
+
export interface SubscriberAttribution {
|
|
22
|
+
derivedSource: string
|
|
23
|
+
utmSource?: string
|
|
24
|
+
utmMedium?: string
|
|
25
|
+
utmCampaign?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Subscriber filter (for campaigns/digests) ───
|
|
29
|
+
|
|
30
|
+
export interface SubscriberFilter {
|
|
31
|
+
status?: SubscriberStatus[]
|
|
32
|
+
preferences?: string[]
|
|
33
|
+
subscribedAfter?: string
|
|
34
|
+
subscribedBefore?: string
|
|
35
|
+
source?: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Email Queue message ───
|
|
39
|
+
|
|
40
|
+
export interface EmailQueueMessage {
|
|
41
|
+
type: 'transactional' | 'campaign' | 'digest'
|
|
42
|
+
recipients: QueueRecipient[]
|
|
43
|
+
subject: string
|
|
44
|
+
htmlTemplate: string
|
|
45
|
+
from: string
|
|
46
|
+
replyTo?: string
|
|
47
|
+
headers?: Record<string, string>
|
|
48
|
+
campaignId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface QueueRecipient {
|
|
52
|
+
email: string
|
|
53
|
+
subscriberId: string
|
|
54
|
+
trackingId: string
|
|
55
|
+
unsubscribeToken: string
|
|
56
|
+
preferencesToken: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Email send record ───
|
|
60
|
+
|
|
61
|
+
export interface EmailSend {
|
|
62
|
+
id: string
|
|
63
|
+
subscriberId: string
|
|
64
|
+
campaignId?: string
|
|
65
|
+
email: string
|
|
66
|
+
subject: string
|
|
67
|
+
type: 'transactional' | 'campaign' | 'digest'
|
|
68
|
+
status: EmailSendStatus
|
|
69
|
+
sentAt?: string
|
|
70
|
+
deliveredAt?: string
|
|
71
|
+
openedAt?: string
|
|
72
|
+
clickedAt?: string
|
|
73
|
+
bouncedAt?: string
|
|
74
|
+
bounceType?: 'hard' | 'soft'
|
|
75
|
+
complainedAt?: string
|
|
76
|
+
trackingId: string
|
|
77
|
+
createdAt: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type EmailSendStatus =
|
|
81
|
+
| 'queued'
|
|
82
|
+
| 'sent'
|
|
83
|
+
| 'delivered'
|
|
84
|
+
| 'opened'
|
|
85
|
+
| 'clicked'
|
|
86
|
+
| 'bounced'
|
|
87
|
+
| 'complained'
|
|
88
|
+
|
|
89
|
+
// ─── Email provider interface ───
|
|
90
|
+
|
|
91
|
+
export interface EmailProvider {
|
|
92
|
+
send(email: OutboundEmail): Promise<SendResult>
|
|
93
|
+
readonly name: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface OutboundEmail {
|
|
97
|
+
to: string
|
|
98
|
+
from: string
|
|
99
|
+
replyTo?: string
|
|
100
|
+
subject: string
|
|
101
|
+
html: string
|
|
102
|
+
headers?: Record<string, string>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SendResult {
|
|
106
|
+
success: boolean
|
|
107
|
+
messageId?: string
|
|
108
|
+
error?: string
|
|
109
|
+
retryable: boolean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Template types ───
|
|
113
|
+
|
|
114
|
+
export type TemplateName =
|
|
115
|
+
| 'confirmation'
|
|
116
|
+
| 'welcome'
|
|
117
|
+
| 'campaign'
|
|
118
|
+
| 'digest'
|
|
119
|
+
| 'transactional'
|
|
120
|
+
| 'unsubscribe-confirm'
|
|
121
|
+
|
|
122
|
+
export interface TemplateData {
|
|
123
|
+
senderName: string
|
|
124
|
+
siteUrl: string
|
|
125
|
+
unsubscribeUrl: string
|
|
126
|
+
preferencesUrl: string
|
|
127
|
+
brand: {
|
|
128
|
+
logoUrl?: string
|
|
129
|
+
primaryColor: string
|
|
130
|
+
accentColor: string
|
|
131
|
+
footerText?: string
|
|
132
|
+
}
|
|
133
|
+
[key: string]: unknown
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Digest content item ───
|
|
137
|
+
|
|
138
|
+
export interface DigestItem {
|
|
139
|
+
title: string
|
|
140
|
+
url: string
|
|
141
|
+
description?: string
|
|
142
|
+
imageUrl?: string
|
|
143
|
+
publishedAt?: string
|
|
144
|
+
author?: string
|
|
145
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { config } from 'virtual:growth-labs/mailer/config'
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1'
|
|
3
|
+
|
|
4
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
5
|
+
|
|
6
|
+
export interface RuntimeLocals {
|
|
7
|
+
runtime?: { env: Record<string, unknown> }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve Cloudflare bindings from the runtime environment object.
|
|
12
|
+
*
|
|
13
|
+
* Route handlers call this with `context.locals.runtime.env` (the standard
|
|
14
|
+
* Astro 6 + @astrojs/cloudflare adapter pattern). Binding names come from
|
|
15
|
+
* the virtual config so they stay in sync with the consumer's `astro.config`.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveBindings(runtimeEnv: Record<string, unknown>): {
|
|
18
|
+
db: DrizzleDB
|
|
19
|
+
queue: Queue
|
|
20
|
+
} {
|
|
21
|
+
const d1 = runtimeEnv[config.d1Binding] as D1Database
|
|
22
|
+
const queue = runtimeEnv[config.queueBinding] as Queue
|
|
23
|
+
|
|
24
|
+
if (!d1) throw new Error(`[mailer] D1 binding "${config.d1Binding}" not found`)
|
|
25
|
+
if (!queue) throw new Error(`[mailer] Queue binding "${config.queueBinding}" not found`)
|
|
26
|
+
|
|
27
|
+
return { db: drizzle(d1), queue }
|
|
28
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { eq, sql } from 'drizzle-orm'
|
|
2
|
+
import type { drizzle } from 'drizzle-orm/d1'
|
|
3
|
+
import { emailSends, subscribers } from '../schema.js'
|
|
4
|
+
|
|
5
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handle an email bounce notification.
|
|
9
|
+
*
|
|
10
|
+
* - **Hard bounce**: immediately sets subscriber status to `bounced`.
|
|
11
|
+
* - **Soft bounce**: increments `softBounceCount`; if the count reaches 3
|
|
12
|
+
* the subscriber is marked `bounced`.
|
|
13
|
+
*
|
|
14
|
+
* No-op when the email address has no matching subscriber row.
|
|
15
|
+
*/
|
|
16
|
+
export async function handleBounce(
|
|
17
|
+
db: DrizzleDB,
|
|
18
|
+
email: string,
|
|
19
|
+
bounceType: 'hard' | 'soft',
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const rows = await db.select().from(subscribers).where(eq(subscribers.email, email))
|
|
22
|
+
|
|
23
|
+
if (rows.length === 0) return
|
|
24
|
+
|
|
25
|
+
const subscriber = rows[0]
|
|
26
|
+
|
|
27
|
+
if (bounceType === 'hard') {
|
|
28
|
+
await db.update(subscribers).set({ status: 'bounced' }).where(eq(subscribers.email, email))
|
|
29
|
+
} else {
|
|
30
|
+
const newCount = subscriber.softBounceCount + 1
|
|
31
|
+
const updates: Record<string, unknown> = { softBounceCount: newCount }
|
|
32
|
+
|
|
33
|
+
if (newCount >= 3) {
|
|
34
|
+
updates.status = 'bounced'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await db.update(subscribers).set(updates).where(eq(subscribers.email, email))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Handle an email complaint (spam report).
|
|
43
|
+
* Sets the subscriber status to `complained`.
|
|
44
|
+
*/
|
|
45
|
+
export async function handleComplaint(db: DrizzleDB, email: string): Promise<void> {
|
|
46
|
+
await db.update(subscribers).set({ status: 'complained' }).where(eq(subscribers.email, email))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Record a successful delivery for an email send.
|
|
51
|
+
* Only updates if the current status is `sent` — never downgrades
|
|
52
|
+
* from `opened` or `clicked`.
|
|
53
|
+
*/
|
|
54
|
+
export async function handleDelivery(db: DrizzleDB, trackingId: string): Promise<void> {
|
|
55
|
+
await db
|
|
56
|
+
.update(emailSends)
|
|
57
|
+
.set({
|
|
58
|
+
status: 'delivered',
|
|
59
|
+
deliveredAt: new Date().toISOString(),
|
|
60
|
+
})
|
|
61
|
+
.where(sql`${emailSends.trackingId} = ${trackingId} AND ${emailSends.status} = 'sent'`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generic helper to update a `gl_email_sends` row by tracking ID.
|
|
66
|
+
*/
|
|
67
|
+
export async function updateSendStatus(
|
|
68
|
+
db: DrizzleDB,
|
|
69
|
+
trackingId: string,
|
|
70
|
+
status: string,
|
|
71
|
+
fields: Record<string, string | null>,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
await db
|
|
74
|
+
.update(emailSends)
|
|
75
|
+
.set({ status, ...fields })
|
|
76
|
+
.where(eq(emailSends.trackingId, trackingId))
|
|
77
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export {
|
|
2
|
+
handleBounce,
|
|
3
|
+
handleComplaint,
|
|
4
|
+
handleDelivery,
|
|
5
|
+
updateSendStatus,
|
|
6
|
+
} from './bounce.js'
|
|
7
|
+
export type { CloudflareEmailSender } from './providers.js'
|
|
8
|
+
export {
|
|
9
|
+
CloudflareEmailProvider,
|
|
10
|
+
getFallbackProvider,
|
|
11
|
+
getProvider,
|
|
12
|
+
ResendFallbackProvider,
|
|
13
|
+
sleep,
|
|
14
|
+
} from './providers.js'
|
|
15
|
+
export type { CampaignSchedule, DigestSchedule } from './scheduling.js'
|
|
16
|
+
export {
|
|
17
|
+
executeCampaignSchedule,
|
|
18
|
+
executeDigestSchedule,
|
|
19
|
+
prepareCampaign,
|
|
20
|
+
prepareDigest,
|
|
21
|
+
sendBatchCampaigns,
|
|
22
|
+
} from './scheduling.js'
|
|
23
|
+
export type { MailerEnv } from './send.js'
|
|
24
|
+
export { sendCampaign, sendDigest, sendTransactional } from './send.js'
|
|
25
|
+
export {
|
|
26
|
+
confirmSubscriber,
|
|
27
|
+
countSubscribers,
|
|
28
|
+
createSubscriber,
|
|
29
|
+
getSubscriberBatch,
|
|
30
|
+
getSubscriberByEmail,
|
|
31
|
+
getSubscriberById,
|
|
32
|
+
unsubscribeSubscriber,
|
|
33
|
+
updatePreferences,
|
|
34
|
+
} from './subscribers.js'
|
|
35
|
+
export {
|
|
36
|
+
inlineStyles,
|
|
37
|
+
interpolate,
|
|
38
|
+
processConditionals,
|
|
39
|
+
renderDigestItems,
|
|
40
|
+
renderEmail,
|
|
41
|
+
} from './templates.js'
|
|
42
|
+
export { generateToken, verifyToken } from './tokens.js'
|
|
43
|
+
export {
|
|
44
|
+
injectTrackingPixel,
|
|
45
|
+
rewriteLinksForTracking,
|
|
46
|
+
TRANSPARENT_GIF,
|
|
47
|
+
} from './tracking.js'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { EmailProvider, OutboundEmail, SendResult } from '../types.js'
|
|
2
|
+
|
|
3
|
+
// ─── Cloudflare Email binding shape ───
|
|
4
|
+
|
|
5
|
+
export interface CloudflareEmailSender {
|
|
6
|
+
send(message: {
|
|
7
|
+
to: string
|
|
8
|
+
from: string
|
|
9
|
+
subject: string
|
|
10
|
+
content: { type: string; value: string }[]
|
|
11
|
+
}): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ─── CloudflareEmailProvider ───
|
|
15
|
+
|
|
16
|
+
export class CloudflareEmailProvider implements EmailProvider {
|
|
17
|
+
readonly name = 'cloudflare'
|
|
18
|
+
private readonly sender: CloudflareEmailSender
|
|
19
|
+
|
|
20
|
+
constructor(emailSender: CloudflareEmailSender) {
|
|
21
|
+
this.sender = emailSender
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async send(email: OutboundEmail): Promise<SendResult> {
|
|
25
|
+
try {
|
|
26
|
+
await this.sender.send({
|
|
27
|
+
to: email.to,
|
|
28
|
+
from: email.from,
|
|
29
|
+
subject: email.subject,
|
|
30
|
+
content: [{ type: 'text/html', value: email.html }],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return { success: true, retryable: false }
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
36
|
+
const isRetryable = isNetworkOrServerError(message)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
error: message,
|
|
41
|
+
retryable: isRetryable,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── ResendFallbackProvider ───
|
|
48
|
+
|
|
49
|
+
export class ResendFallbackProvider implements EmailProvider {
|
|
50
|
+
readonly name = 'resend'
|
|
51
|
+
private readonly apiKey: string
|
|
52
|
+
|
|
53
|
+
constructor(apiKey: string) {
|
|
54
|
+
this.apiKey = apiKey
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async send(email: OutboundEmail): Promise<SendResult> {
|
|
58
|
+
try {
|
|
59
|
+
const response = await fetch('https://api.resend.com/emails', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
to: email.to,
|
|
67
|
+
from: email.from,
|
|
68
|
+
reply_to: email.replyTo,
|
|
69
|
+
subject: email.subject,
|
|
70
|
+
html: email.html,
|
|
71
|
+
headers: email.headers,
|
|
72
|
+
}),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (response.ok) {
|
|
76
|
+
const data = (await response.json()) as { id?: string }
|
|
77
|
+
return {
|
|
78
|
+
success: true,
|
|
79
|
+
messageId: data.id,
|
|
80
|
+
retryable: false,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const retryable = response.status === 429 || response.status >= 500
|
|
85
|
+
const errorText = await response.text().catch(() => 'Unknown error')
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
error: `Resend API ${response.status}: ${errorText}`,
|
|
90
|
+
retryable,
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
error: message,
|
|
97
|
+
retryable: true,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Factory functions ───
|
|
104
|
+
|
|
105
|
+
export function getProvider(emailSender: CloudflareEmailSender): EmailProvider {
|
|
106
|
+
return new CloudflareEmailProvider(emailSender)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getFallbackProvider(options: {
|
|
110
|
+
fallbackProvider: string
|
|
111
|
+
resendApiKey?: string
|
|
112
|
+
}): EmailProvider | null {
|
|
113
|
+
if (options.fallbackProvider === 'resend' && options.resendApiKey) {
|
|
114
|
+
return new ResendFallbackProvider(options.resendApiKey)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Helpers ───
|
|
121
|
+
|
|
122
|
+
export function sleep(ms: number): Promise<void> {
|
|
123
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isNetworkOrServerError(message: string): boolean {
|
|
127
|
+
const patterns = [
|
|
128
|
+
'network',
|
|
129
|
+
'timeout',
|
|
130
|
+
'econnrefused',
|
|
131
|
+
'econnreset',
|
|
132
|
+
'enotfound',
|
|
133
|
+
'socket',
|
|
134
|
+
'5',
|
|
135
|
+
'server error',
|
|
136
|
+
'service unavailable',
|
|
137
|
+
'internal server',
|
|
138
|
+
]
|
|
139
|
+
const lower = message.toLowerCase()
|
|
140
|
+
return patterns.some((p) => lower.includes(p))
|
|
141
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { drizzle } from 'drizzle-orm/d1'
|
|
2
|
+
import { ulid } from 'ulidx'
|
|
3
|
+
import type { ResolvedMailerOptions } from '../options.js'
|
|
4
|
+
import type { DigestItem, SubscriberFilter } from '../types.js'
|
|
5
|
+
import type { MailerEnv } from './send.js'
|
|
6
|
+
import { sendCampaign, sendDigest } from './send.js'
|
|
7
|
+
import { countSubscribers } from './subscribers.js'
|
|
8
|
+
|
|
9
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
10
|
+
|
|
11
|
+
// ─── Campaign schedule entry ───
|
|
12
|
+
|
|
13
|
+
export interface CampaignSchedule {
|
|
14
|
+
campaignId: string
|
|
15
|
+
subject: string
|
|
16
|
+
html?: string
|
|
17
|
+
template?: 'campaign'
|
|
18
|
+
data?: Record<string, unknown>
|
|
19
|
+
filter?: SubscriberFilter
|
|
20
|
+
scheduledAt: string // ISO 8601
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DigestSchedule {
|
|
24
|
+
campaignId: string
|
|
25
|
+
subject: string
|
|
26
|
+
items: DigestItem[]
|
|
27
|
+
introText?: string
|
|
28
|
+
filter?: SubscriberFilter
|
|
29
|
+
scheduledAt: string // ISO 8601
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Schedule builder ───
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a campaign schedule entry.
|
|
36
|
+
* The consumer stores this in KV/D1/R2 and uses a Cron Trigger to
|
|
37
|
+
* call `executeCampaignSchedule()` at the appropriate time.
|
|
38
|
+
*
|
|
39
|
+
* This is a lightweight helper — it pre-generates the campaignId
|
|
40
|
+
* and validates the send would have recipients.
|
|
41
|
+
*/
|
|
42
|
+
export async function prepareCampaign(
|
|
43
|
+
db: DrizzleDB,
|
|
44
|
+
params: {
|
|
45
|
+
subject: string
|
|
46
|
+
html?: string
|
|
47
|
+
template?: 'campaign'
|
|
48
|
+
data?: Record<string, unknown>
|
|
49
|
+
filter?: SubscriberFilter
|
|
50
|
+
scheduledAt: string
|
|
51
|
+
},
|
|
52
|
+
): Promise<CampaignSchedule & { estimatedRecipients: number }> {
|
|
53
|
+
const campaignId = ulid()
|
|
54
|
+
const filter = params.filter ?? { status: ['active'] }
|
|
55
|
+
if (!filter.status) filter.status = ['active']
|
|
56
|
+
|
|
57
|
+
const estimatedRecipients = await countSubscribers(db, filter)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
campaignId,
|
|
61
|
+
subject: params.subject,
|
|
62
|
+
html: params.html,
|
|
63
|
+
template: params.template,
|
|
64
|
+
data: params.data,
|
|
65
|
+
filter: params.filter,
|
|
66
|
+
scheduledAt: params.scheduledAt,
|
|
67
|
+
estimatedRecipients,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a digest schedule entry with recipient estimate.
|
|
73
|
+
*/
|
|
74
|
+
export async function prepareDigest(
|
|
75
|
+
db: DrizzleDB,
|
|
76
|
+
params: {
|
|
77
|
+
subject: string
|
|
78
|
+
items: DigestItem[]
|
|
79
|
+
introText?: string
|
|
80
|
+
filter?: SubscriberFilter
|
|
81
|
+
scheduledAt: string
|
|
82
|
+
},
|
|
83
|
+
): Promise<DigestSchedule & { estimatedRecipients: number }> {
|
|
84
|
+
const campaignId = ulid()
|
|
85
|
+
const filter = params.filter ?? { status: ['active'] }
|
|
86
|
+
if (!filter.status) filter.status = ['active']
|
|
87
|
+
|
|
88
|
+
const estimatedRecipients = await countSubscribers(db, filter)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
campaignId,
|
|
92
|
+
subject: params.subject,
|
|
93
|
+
items: params.items,
|
|
94
|
+
introText: params.introText,
|
|
95
|
+
filter: params.filter,
|
|
96
|
+
scheduledAt: params.scheduledAt,
|
|
97
|
+
estimatedRecipients,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Execute scheduled sends ───
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Execute a previously prepared campaign schedule.
|
|
105
|
+
* Checks that the scheduled time has arrived before sending.
|
|
106
|
+
* Returns null if not yet due.
|
|
107
|
+
*/
|
|
108
|
+
export async function executeCampaignSchedule(
|
|
109
|
+
env: MailerEnv,
|
|
110
|
+
options: ResolvedMailerOptions,
|
|
111
|
+
schedule: CampaignSchedule,
|
|
112
|
+
): Promise<{ campaignId: string; recipientCount: number } | null> {
|
|
113
|
+
const now = Date.now()
|
|
114
|
+
const scheduledTime = new Date(schedule.scheduledAt).getTime()
|
|
115
|
+
|
|
116
|
+
if (scheduledTime > now) {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return sendCampaign(env, options, {
|
|
121
|
+
subject: schedule.subject,
|
|
122
|
+
html: schedule.html,
|
|
123
|
+
template: schedule.template,
|
|
124
|
+
data: schedule.data,
|
|
125
|
+
filter: schedule.filter,
|
|
126
|
+
campaignId: schedule.campaignId,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Execute a previously prepared digest schedule.
|
|
132
|
+
* Checks that the scheduled time has arrived before sending.
|
|
133
|
+
* Returns null if not yet due.
|
|
134
|
+
*/
|
|
135
|
+
export async function executeDigestSchedule(
|
|
136
|
+
env: MailerEnv,
|
|
137
|
+
options: ResolvedMailerOptions,
|
|
138
|
+
schedule: DigestSchedule,
|
|
139
|
+
): Promise<{ campaignId: string; recipientCount: number } | null> {
|
|
140
|
+
const now = Date.now()
|
|
141
|
+
const scheduledTime = new Date(schedule.scheduledAt).getTime()
|
|
142
|
+
|
|
143
|
+
if (scheduledTime > now) {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return sendDigest(env, options, {
|
|
148
|
+
subject: schedule.subject,
|
|
149
|
+
items: schedule.items,
|
|
150
|
+
introText: schedule.introText,
|
|
151
|
+
filter: schedule.filter,
|
|
152
|
+
campaignId: schedule.campaignId,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Batch send helper: sends a campaign to multiple topic-segmented groups
|
|
158
|
+
* in a single call. Each segment gets its own campaign ID for tracking.
|
|
159
|
+
*
|
|
160
|
+
* Useful for sites that want to send the same content to different
|
|
161
|
+
* topic segments with different subject lines or intros.
|
|
162
|
+
*/
|
|
163
|
+
export async function sendBatchCampaigns(
|
|
164
|
+
env: MailerEnv,
|
|
165
|
+
options: ResolvedMailerOptions,
|
|
166
|
+
segments: Array<{
|
|
167
|
+
subject: string
|
|
168
|
+
html?: string
|
|
169
|
+
template?: 'campaign'
|
|
170
|
+
data?: Record<string, unknown>
|
|
171
|
+
filter: SubscriberFilter
|
|
172
|
+
}>,
|
|
173
|
+
): Promise<Array<{ campaignId: string; recipientCount: number }>> {
|
|
174
|
+
const results: Array<{ campaignId: string; recipientCount: number }> = []
|
|
175
|
+
|
|
176
|
+
for (const segment of segments) {
|
|
177
|
+
const result = await sendCampaign(env, options, {
|
|
178
|
+
subject: segment.subject,
|
|
179
|
+
html: segment.html,
|
|
180
|
+
template: segment.template,
|
|
181
|
+
data: segment.data,
|
|
182
|
+
filter: segment.filter,
|
|
183
|
+
})
|
|
184
|
+
results.push(result)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return results
|
|
188
|
+
}
|