@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,282 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1'
|
|
3
|
+
import { ulid } from 'ulidx'
|
|
4
|
+
import type { ResolvedMailerOptions } from '../options.js'
|
|
5
|
+
import { emailSends } from '../schema.js'
|
|
6
|
+
import type {
|
|
7
|
+
DigestItem,
|
|
8
|
+
EmailQueueMessage,
|
|
9
|
+
QueueRecipient,
|
|
10
|
+
SubscriberFilter,
|
|
11
|
+
TemplateData,
|
|
12
|
+
TemplateName,
|
|
13
|
+
} from '../types.js'
|
|
14
|
+
import { getSubscriberBatch } from './subscribers.js'
|
|
15
|
+
import { renderDigestItems, renderEmail } from './templates.js'
|
|
16
|
+
import { generateToken } from './tokens.js'
|
|
17
|
+
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js'
|
|
18
|
+
import { buildSubscriberManageUrls } from './urls.js'
|
|
19
|
+
|
|
20
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
21
|
+
|
|
22
|
+
// ─── MailerEnv ───
|
|
23
|
+
|
|
24
|
+
export interface MailerEnv {
|
|
25
|
+
DB: D1Database
|
|
26
|
+
QUEUE: Queue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── sendTransactional ───
|
|
30
|
+
|
|
31
|
+
export async function sendTransactional(
|
|
32
|
+
env: MailerEnv,
|
|
33
|
+
options: ResolvedMailerOptions,
|
|
34
|
+
params: {
|
|
35
|
+
to: string
|
|
36
|
+
subscriberId?: string
|
|
37
|
+
subject: string
|
|
38
|
+
html?: string
|
|
39
|
+
template?: TemplateName
|
|
40
|
+
data?: Record<string, unknown>
|
|
41
|
+
},
|
|
42
|
+
): Promise<{ trackingId: string }> {
|
|
43
|
+
const db = drizzle(env.DB)
|
|
44
|
+
const trackingId = ulid()
|
|
45
|
+
const subscriberId = params.subscriberId ?? ulid()
|
|
46
|
+
|
|
47
|
+
// Render HTML
|
|
48
|
+
let html: string
|
|
49
|
+
if (params.template) {
|
|
50
|
+
const manageUrls = await buildSubscriberManageUrls(options, params.subscriberId)
|
|
51
|
+
html = renderEmail(params.template, {
|
|
52
|
+
...params.data,
|
|
53
|
+
senderName: options.senderName,
|
|
54
|
+
siteUrl: options.siteUrl,
|
|
55
|
+
unsubscribeUrl: manageUrls.unsubscribeUrl,
|
|
56
|
+
preferencesUrl: manageUrls.preferencesUrl,
|
|
57
|
+
brand: options.brand,
|
|
58
|
+
} as TemplateData)
|
|
59
|
+
} else if (params.html) {
|
|
60
|
+
html = params.html
|
|
61
|
+
} else {
|
|
62
|
+
throw new Error('Either template or html must be provided')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Record the send
|
|
66
|
+
await db.insert(emailSends).values({
|
|
67
|
+
id: ulid(),
|
|
68
|
+
subscriberId,
|
|
69
|
+
email: params.to,
|
|
70
|
+
subject: params.subject,
|
|
71
|
+
type: 'transactional',
|
|
72
|
+
status: 'queued',
|
|
73
|
+
trackingId,
|
|
74
|
+
createdAt: new Date().toISOString(),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Build and enqueue the message
|
|
78
|
+
const message: EmailQueueMessage = {
|
|
79
|
+
type: 'transactional',
|
|
80
|
+
recipients: [
|
|
81
|
+
{
|
|
82
|
+
email: params.to,
|
|
83
|
+
subscriberId,
|
|
84
|
+
trackingId,
|
|
85
|
+
unsubscribeToken: '',
|
|
86
|
+
preferencesToken: '',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
subject: params.subject,
|
|
90
|
+
htmlTemplate: html,
|
|
91
|
+
from: `${options.senderName} <${options.fromAddress}>`,
|
|
92
|
+
replyTo: options.replyTo,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await env.QUEUE.send(message)
|
|
96
|
+
|
|
97
|
+
return { trackingId }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Fan-out helper (shared by campaign + digest) ───
|
|
101
|
+
|
|
102
|
+
async function fanOutToSubscribers(
|
|
103
|
+
env: MailerEnv,
|
|
104
|
+
db: DrizzleDB,
|
|
105
|
+
options: ResolvedMailerOptions,
|
|
106
|
+
params: {
|
|
107
|
+
subject: string
|
|
108
|
+
html: string
|
|
109
|
+
campaignId: string
|
|
110
|
+
type: 'campaign' | 'digest'
|
|
111
|
+
filter?: SubscriberFilter
|
|
112
|
+
},
|
|
113
|
+
): Promise<number> {
|
|
114
|
+
let cursor: string | null = null
|
|
115
|
+
let recipientCount = 0
|
|
116
|
+
const filter: SubscriberFilter = params.filter ?? { status: ['active'] }
|
|
117
|
+
if (!filter.status) filter.status = ['active']
|
|
118
|
+
|
|
119
|
+
while (true) {
|
|
120
|
+
const batch = await getSubscriberBatch(db, filter, options.batchSize, cursor)
|
|
121
|
+
if (batch.length === 0) break
|
|
122
|
+
|
|
123
|
+
const recipients: QueueRecipient[] = []
|
|
124
|
+
for (const sub of batch) {
|
|
125
|
+
const trackingId = ulid()
|
|
126
|
+
const unsubscribeToken = await generateToken(options.signingSecret, {
|
|
127
|
+
subscriberId: sub.id,
|
|
128
|
+
action: 'unsubscribe',
|
|
129
|
+
})
|
|
130
|
+
const preferencesToken = await generateToken(options.signingSecret, {
|
|
131
|
+
subscriberId: sub.id,
|
|
132
|
+
action: 'preferences',
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await db.insert(emailSends).values({
|
|
136
|
+
id: ulid(),
|
|
137
|
+
subscriberId: sub.id,
|
|
138
|
+
campaignId: params.campaignId,
|
|
139
|
+
email: sub.email,
|
|
140
|
+
subject: params.subject,
|
|
141
|
+
type: params.type,
|
|
142
|
+
status: 'queued',
|
|
143
|
+
trackingId,
|
|
144
|
+
createdAt: new Date().toISOString(),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
recipients.push({
|
|
148
|
+
email: sub.email,
|
|
149
|
+
subscriberId: sub.id,
|
|
150
|
+
trackingId,
|
|
151
|
+
unsubscribeToken,
|
|
152
|
+
preferencesToken,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const message: EmailQueueMessage = {
|
|
157
|
+
type: params.type,
|
|
158
|
+
recipients,
|
|
159
|
+
subject: params.subject,
|
|
160
|
+
htmlTemplate: params.html,
|
|
161
|
+
from: `${options.senderName} <${options.fromAddress}>`,
|
|
162
|
+
replyTo: options.replyTo,
|
|
163
|
+
campaignId: params.campaignId,
|
|
164
|
+
}
|
|
165
|
+
await env.QUEUE.send(message)
|
|
166
|
+
|
|
167
|
+
recipientCount += batch.length
|
|
168
|
+
cursor = batch[batch.length - 1].id
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return recipientCount
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── sendCampaign ───
|
|
175
|
+
|
|
176
|
+
export async function sendCampaign(
|
|
177
|
+
env: MailerEnv,
|
|
178
|
+
options: ResolvedMailerOptions,
|
|
179
|
+
params: {
|
|
180
|
+
subject: string
|
|
181
|
+
html?: string
|
|
182
|
+
template?: 'campaign'
|
|
183
|
+
data?: Record<string, unknown>
|
|
184
|
+
filter?: SubscriberFilter
|
|
185
|
+
campaignId?: string
|
|
186
|
+
},
|
|
187
|
+
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
188
|
+
const campaignId = params.campaignId ?? ulid()
|
|
189
|
+
const db = drizzle(env.DB)
|
|
190
|
+
|
|
191
|
+
// Render HTML
|
|
192
|
+
let html: string
|
|
193
|
+
if (params.template) {
|
|
194
|
+
html = renderEmail('campaign', {
|
|
195
|
+
...params.data,
|
|
196
|
+
senderName: options.senderName,
|
|
197
|
+
siteUrl: options.siteUrl,
|
|
198
|
+
unsubscribeUrl: '{{UNSUBSCRIBE_URL}}',
|
|
199
|
+
preferencesUrl: '{{PREFERENCES_URL}}',
|
|
200
|
+
brand: options.brand,
|
|
201
|
+
} as TemplateData)
|
|
202
|
+
} else if (params.html) {
|
|
203
|
+
html = params.html
|
|
204
|
+
} else {
|
|
205
|
+
throw new Error('Either template or html must be provided')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Inject tracking
|
|
209
|
+
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`
|
|
210
|
+
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`
|
|
211
|
+
html = injectTrackingPixel(html, trackOpenUrl)
|
|
212
|
+
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
213
|
+
|
|
214
|
+
const recipientCount = await fanOutToSubscribers(env, db, options, {
|
|
215
|
+
subject: params.subject,
|
|
216
|
+
html,
|
|
217
|
+
campaignId,
|
|
218
|
+
type: 'campaign',
|
|
219
|
+
filter: params.filter,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return { campaignId, recipientCount }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── sendDigest ───
|
|
226
|
+
|
|
227
|
+
export async function sendDigest(
|
|
228
|
+
env: MailerEnv,
|
|
229
|
+
options: ResolvedMailerOptions,
|
|
230
|
+
params: {
|
|
231
|
+
subject: string
|
|
232
|
+
items: DigestItem[]
|
|
233
|
+
introText?: string
|
|
234
|
+
filter?: SubscriberFilter
|
|
235
|
+
campaignId?: string
|
|
236
|
+
},
|
|
237
|
+
): Promise<{ campaignId: string; recipientCount: number }> {
|
|
238
|
+
const campaignId = params.campaignId ?? ulid()
|
|
239
|
+
const db = drizzle(env.DB)
|
|
240
|
+
|
|
241
|
+
// Render digest items then full template
|
|
242
|
+
const itemsHtml = renderDigestItems(params.items)
|
|
243
|
+
let html = renderEmail('digest', {
|
|
244
|
+
senderName: options.senderName,
|
|
245
|
+
siteUrl: options.siteUrl,
|
|
246
|
+
unsubscribeUrl: '{{UNSUBSCRIBE_URL}}',
|
|
247
|
+
preferencesUrl: '{{PREFERENCES_URL}}',
|
|
248
|
+
brand: options.brand,
|
|
249
|
+
introText: params.introText ?? '',
|
|
250
|
+
digestItems: itemsHtml,
|
|
251
|
+
} as TemplateData)
|
|
252
|
+
|
|
253
|
+
// Inject tracking
|
|
254
|
+
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`
|
|
255
|
+
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`
|
|
256
|
+
html = injectTrackingPixel(html, trackOpenUrl)
|
|
257
|
+
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
258
|
+
|
|
259
|
+
const recipientCount = await fanOutToSubscribers(env, db, options, {
|
|
260
|
+
subject: params.subject,
|
|
261
|
+
html,
|
|
262
|
+
campaignId,
|
|
263
|
+
type: 'digest',
|
|
264
|
+
filter: params.filter,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
return { campaignId, recipientCount }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── updateSendStatus ───
|
|
271
|
+
|
|
272
|
+
export async function updateSendStatus(
|
|
273
|
+
db: DrizzleDB,
|
|
274
|
+
trackingId: string,
|
|
275
|
+
status: string,
|
|
276
|
+
fields?: Record<string, string | null>,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
await db
|
|
279
|
+
.update(emailSends)
|
|
280
|
+
.set({ status, ...fields })
|
|
281
|
+
.where(eq(emailSends.trackingId, trackingId))
|
|
282
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { and, eq, gt, inArray, like, lt, or, sql } from 'drizzle-orm'
|
|
2
|
+
import type { drizzle } from 'drizzle-orm/d1'
|
|
3
|
+
import { ulid } from 'ulidx'
|
|
4
|
+
import { subscribers } from '../schema.js'
|
|
5
|
+
import type { Subscriber, SubscriberAttribution, SubscriberFilter } from '../types.js'
|
|
6
|
+
|
|
7
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ───
|
|
10
|
+
|
|
11
|
+
function rowToSubscriber(row: Record<string, unknown>): Subscriber {
|
|
12
|
+
return {
|
|
13
|
+
id: row.id as string,
|
|
14
|
+
email: row.email as string,
|
|
15
|
+
name: (row.name as string) ?? undefined,
|
|
16
|
+
status: row.status as Subscriber['status'],
|
|
17
|
+
preferences: JSON.parse((row.preferences as string) ?? '[]') as string[],
|
|
18
|
+
source: row.source as string,
|
|
19
|
+
attribution: row.attribution
|
|
20
|
+
? (JSON.parse(row.attribution as string) as SubscriberAttribution)
|
|
21
|
+
: undefined,
|
|
22
|
+
softBounceCount: (row.softBounceCount as number) ?? 0,
|
|
23
|
+
subscribedAt: row.subscribedAt as string,
|
|
24
|
+
confirmedAt: (row.confirmedAt as string) ?? undefined,
|
|
25
|
+
unsubscribedAt: (row.unsubscribedAt as string) ?? undefined,
|
|
26
|
+
createdAt: row.createdAt as string,
|
|
27
|
+
updatedAt: row.updatedAt as string,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── createSubscriber ───
|
|
32
|
+
|
|
33
|
+
export async function createSubscriber(
|
|
34
|
+
db: DrizzleDB,
|
|
35
|
+
params: {
|
|
36
|
+
email: string
|
|
37
|
+
name?: string
|
|
38
|
+
source: string
|
|
39
|
+
preferences?: string[]
|
|
40
|
+
attribution?: SubscriberAttribution
|
|
41
|
+
doubleOptIn?: boolean
|
|
42
|
+
},
|
|
43
|
+
): Promise<{ subscriber: Subscriber; isNew: boolean }> {
|
|
44
|
+
const doubleOptIn = params.doubleOptIn ?? true
|
|
45
|
+
|
|
46
|
+
const existing = await db.select().from(subscribers).where(eq(subscribers.email, params.email))
|
|
47
|
+
|
|
48
|
+
if (existing.length > 0) {
|
|
49
|
+
const row = existing[0]
|
|
50
|
+
|
|
51
|
+
if (row.status === 'active') {
|
|
52
|
+
return { subscriber: rowToSubscriber(row), isNew: false }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (row.status === 'pending') {
|
|
56
|
+
return { subscriber: rowToSubscriber(row), isNew: false }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (row.status === 'bounced' || row.status === 'complained') {
|
|
60
|
+
throw new Error('Cannot re-subscribe a bounced or complained address')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// status === 'unsubscribed' → reactivate
|
|
64
|
+
const now = new Date().toISOString()
|
|
65
|
+
const newStatus = doubleOptIn ? 'pending' : 'active'
|
|
66
|
+
|
|
67
|
+
const updated = await db
|
|
68
|
+
.update(subscribers)
|
|
69
|
+
.set({
|
|
70
|
+
status: newStatus,
|
|
71
|
+
subscribedAt: now,
|
|
72
|
+
unsubscribedAt: null,
|
|
73
|
+
updatedAt: now,
|
|
74
|
+
})
|
|
75
|
+
.where(eq(subscribers.id, row.id as string))
|
|
76
|
+
.returning()
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
subscriber: rowToSubscriber(updated[0]),
|
|
80
|
+
isNew: false,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// New subscriber
|
|
85
|
+
const now = new Date().toISOString()
|
|
86
|
+
const id = ulid()
|
|
87
|
+
const status = doubleOptIn ? 'pending' : 'active'
|
|
88
|
+
|
|
89
|
+
const inserted = await db
|
|
90
|
+
.insert(subscribers)
|
|
91
|
+
.values({
|
|
92
|
+
id,
|
|
93
|
+
email: params.email,
|
|
94
|
+
name: params.name ?? null,
|
|
95
|
+
status,
|
|
96
|
+
preferences: JSON.stringify(params.preferences ?? []),
|
|
97
|
+
source: params.source,
|
|
98
|
+
attribution: params.attribution ? JSON.stringify(params.attribution) : null,
|
|
99
|
+
softBounceCount: 0,
|
|
100
|
+
subscribedAt: now,
|
|
101
|
+
confirmedAt: null,
|
|
102
|
+
unsubscribedAt: null,
|
|
103
|
+
createdAt: now,
|
|
104
|
+
updatedAt: now,
|
|
105
|
+
})
|
|
106
|
+
.returning()
|
|
107
|
+
|
|
108
|
+
return { subscriber: rowToSubscriber(inserted[0]), isNew: true }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── confirmSubscriber ───
|
|
112
|
+
|
|
113
|
+
export async function confirmSubscriber(db: DrizzleDB, subscriberId: string): Promise<Subscriber> {
|
|
114
|
+
const rows = await db.select().from(subscribers).where(eq(subscribers.id, subscriberId))
|
|
115
|
+
|
|
116
|
+
if (rows.length === 0) {
|
|
117
|
+
throw new Error('Subscriber not found')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (rows[0].status !== 'pending') {
|
|
121
|
+
throw new Error('Subscriber is not in pending status')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const now = new Date().toISOString()
|
|
125
|
+
|
|
126
|
+
const updated = await db
|
|
127
|
+
.update(subscribers)
|
|
128
|
+
.set({ status: 'active', confirmedAt: now, updatedAt: now })
|
|
129
|
+
.where(eq(subscribers.id, subscriberId))
|
|
130
|
+
.returning()
|
|
131
|
+
|
|
132
|
+
return rowToSubscriber(updated[0])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── unsubscribeSubscriber ───
|
|
136
|
+
|
|
137
|
+
export async function unsubscribeSubscriber(db: DrizzleDB, subscriberId: string): Promise<void> {
|
|
138
|
+
const now = new Date().toISOString()
|
|
139
|
+
|
|
140
|
+
await db
|
|
141
|
+
.update(subscribers)
|
|
142
|
+
.set({
|
|
143
|
+
status: 'unsubscribed',
|
|
144
|
+
unsubscribedAt: now,
|
|
145
|
+
updatedAt: now,
|
|
146
|
+
})
|
|
147
|
+
.where(eq(subscribers.id, subscriberId))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── updatePreferences ───
|
|
151
|
+
|
|
152
|
+
export async function updatePreferences(
|
|
153
|
+
db: DrizzleDB,
|
|
154
|
+
subscriberId: string,
|
|
155
|
+
preferences: string[],
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const now = new Date().toISOString()
|
|
158
|
+
|
|
159
|
+
await db
|
|
160
|
+
.update(subscribers)
|
|
161
|
+
.set({
|
|
162
|
+
preferences: JSON.stringify(preferences),
|
|
163
|
+
updatedAt: now,
|
|
164
|
+
})
|
|
165
|
+
.where(eq(subscribers.id, subscriberId))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── getSubscriberByEmail ───
|
|
169
|
+
|
|
170
|
+
export async function getSubscriberByEmail(
|
|
171
|
+
db: DrizzleDB,
|
|
172
|
+
email: string,
|
|
173
|
+
): Promise<Subscriber | null> {
|
|
174
|
+
const rows = await db.select().from(subscribers).where(eq(subscribers.email, email))
|
|
175
|
+
|
|
176
|
+
if (rows.length === 0) return null
|
|
177
|
+
return rowToSubscriber(rows[0])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── getSubscriberById ───
|
|
181
|
+
|
|
182
|
+
export async function getSubscriberById(db: DrizzleDB, id: string): Promise<Subscriber | null> {
|
|
183
|
+
const rows = await db.select().from(subscribers).where(eq(subscribers.id, id))
|
|
184
|
+
|
|
185
|
+
if (rows.length === 0) return null
|
|
186
|
+
return rowToSubscriber(rows[0])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── getSubscriberBatch ───
|
|
190
|
+
|
|
191
|
+
export async function getSubscriberBatch(
|
|
192
|
+
db: DrizzleDB,
|
|
193
|
+
filter: SubscriberFilter,
|
|
194
|
+
limit: number,
|
|
195
|
+
cursor: string | null,
|
|
196
|
+
): Promise<Subscriber[]> {
|
|
197
|
+
const conditions = []
|
|
198
|
+
|
|
199
|
+
// Status filter (default: active only)
|
|
200
|
+
const statuses = filter.status ?? ['active']
|
|
201
|
+
conditions.push(inArray(subscribers.status, statuses))
|
|
202
|
+
|
|
203
|
+
// Preferences overlap — any match
|
|
204
|
+
if (filter.preferences && filter.preferences.length > 0) {
|
|
205
|
+
const prefConditions = filter.preferences.map((pref) =>
|
|
206
|
+
like(subscribers.preferences, `%"${pref}"%`),
|
|
207
|
+
)
|
|
208
|
+
const prefOr = or(...prefConditions)
|
|
209
|
+
if (prefOr) conditions.push(prefOr)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Date range
|
|
213
|
+
if (filter.subscribedAfter) {
|
|
214
|
+
conditions.push(gt(subscribers.subscribedAt, filter.subscribedAfter))
|
|
215
|
+
}
|
|
216
|
+
if (filter.subscribedBefore) {
|
|
217
|
+
conditions.push(lt(subscribers.subscribedAt, filter.subscribedBefore))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Source filter
|
|
221
|
+
if (filter.source && filter.source.length > 0) {
|
|
222
|
+
conditions.push(inArray(subscribers.source, filter.source))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Cursor-based pagination
|
|
226
|
+
if (cursor) {
|
|
227
|
+
conditions.push(gt(subscribers.id, cursor))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const rows = await db
|
|
231
|
+
.select()
|
|
232
|
+
.from(subscribers)
|
|
233
|
+
.where(and(...conditions))
|
|
234
|
+
.orderBy(subscribers.id)
|
|
235
|
+
.limit(limit)
|
|
236
|
+
|
|
237
|
+
return rows.map(rowToSubscriber)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── countSubscribers ───
|
|
241
|
+
|
|
242
|
+
export async function countSubscribers(db: DrizzleDB, filter?: SubscriberFilter): Promise<number> {
|
|
243
|
+
const conditions = []
|
|
244
|
+
|
|
245
|
+
if (filter) {
|
|
246
|
+
const statuses = filter.status ?? ['active']
|
|
247
|
+
conditions.push(inArray(subscribers.status, statuses))
|
|
248
|
+
|
|
249
|
+
if (filter.preferences && filter.preferences.length > 0) {
|
|
250
|
+
const prefConditions = filter.preferences.map((pref) =>
|
|
251
|
+
like(subscribers.preferences, `%"${pref}"%`),
|
|
252
|
+
)
|
|
253
|
+
const prefOr = or(...prefConditions)
|
|
254
|
+
if (prefOr) conditions.push(prefOr)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (filter.subscribedAfter) {
|
|
258
|
+
conditions.push(gt(subscribers.subscribedAt, filter.subscribedAfter))
|
|
259
|
+
}
|
|
260
|
+
if (filter.subscribedBefore) {
|
|
261
|
+
conditions.push(lt(subscribers.subscribedAt, filter.subscribedBefore))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (filter.source && filter.source.length > 0) {
|
|
265
|
+
conditions.push(inArray(subscribers.source, filter.source))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
|
270
|
+
|
|
271
|
+
const result = await db
|
|
272
|
+
.select({ count: sql<number>`count(*)` })
|
|
273
|
+
.from(subscribers)
|
|
274
|
+
.where(whereClause)
|
|
275
|
+
|
|
276
|
+
return result[0].count
|
|
277
|
+
}
|