@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.
Files changed (132) hide show
  1. package/README.md +89 -0
  2. package/dist/components/index.d.ts +3 -0
  3. package/dist/components/index.d.ts.map +1 -0
  4. package/dist/components/index.js +3 -0
  5. package/dist/components/index.js.map +1 -0
  6. package/dist/index.d.ts +14 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +65 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/middleware/tracking.d.ts +3 -0
  11. package/dist/middleware/tracking.d.ts.map +1 -0
  12. package/dist/middleware/tracking.js +13 -0
  13. package/dist/middleware/tracking.js.map +1 -0
  14. package/dist/options.d.ts +160 -0
  15. package/dist/options.d.ts.map +1 -0
  16. package/dist/options.js +51 -0
  17. package/dist/options.js.map +1 -0
  18. package/dist/queue/consumer.d.ts +8 -0
  19. package/dist/queue/consumer.d.ts.map +1 -0
  20. package/dist/queue/consumer.js +83 -0
  21. package/dist/queue/consumer.js.map +1 -0
  22. package/dist/routes/confirm.d.ts +3 -0
  23. package/dist/routes/confirm.d.ts.map +1 -0
  24. package/dist/routes/confirm.js +59 -0
  25. package/dist/routes/confirm.js.map +1 -0
  26. package/dist/routes/subscribe.d.ts +3 -0
  27. package/dist/routes/subscribe.d.ts.map +1 -0
  28. package/dist/routes/subscribe.js +87 -0
  29. package/dist/routes/subscribe.js.map +1 -0
  30. package/dist/routes/track-click.d.ts +3 -0
  31. package/dist/routes/track-click.d.ts.map +1 -0
  32. package/dist/routes/track-click.js +45 -0
  33. package/dist/routes/track-click.js.map +1 -0
  34. package/dist/routes/track-open.d.ts +3 -0
  35. package/dist/routes/track-open.d.ts.map +1 -0
  36. package/dist/routes/track-open.js +40 -0
  37. package/dist/routes/track-open.js.map +1 -0
  38. package/dist/routes/unsubscribe.d.ts +4 -0
  39. package/dist/routes/unsubscribe.d.ts.map +1 -0
  40. package/dist/routes/unsubscribe.js +81 -0
  41. package/dist/routes/unsubscribe.js.map +1 -0
  42. package/dist/routes/webhook.d.ts +3 -0
  43. package/dist/routes/webhook.d.ts.map +1 -0
  44. package/dist/routes/webhook.js +30 -0
  45. package/dist/routes/webhook.js.map +1 -0
  46. package/dist/schema.d.ts +564 -0
  47. package/dist/schema.d.ts.map +1 -0
  48. package/dist/schema.js +47 -0
  49. package/dist/schema.js.map +1 -0
  50. package/dist/types.d.ts +106 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +3 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/utils/bindings.d.ts +20 -0
  55. package/dist/utils/bindings.d.ts.map +1 -0
  56. package/dist/utils/bindings.js +19 -0
  57. package/dist/utils/bindings.js.map +1 -0
  58. package/dist/utils/bounce.d.ts +29 -0
  59. package/dist/utils/bounce.d.ts.map +1 -0
  60. package/dist/utils/bounce.js +59 -0
  61. package/dist/utils/bounce.js.map +1 -0
  62. package/dist/utils/index.d.ts +12 -0
  63. package/dist/utils/index.d.ts.map +1 -0
  64. package/dist/utils/index.js +9 -0
  65. package/dist/utils/index.js.map +1 -0
  66. package/dist/utils/providers.d.ts +31 -0
  67. package/dist/utils/providers.d.ts.map +1 -0
  68. package/dist/utils/providers.js +109 -0
  69. package/dist/utils/providers.js.map +1 -0
  70. package/dist/utils/scheduling.d.ts +89 -0
  71. package/dist/utils/scheduling.d.ts.map +1 -0
  72. package/dist/utils/scheduling.js +110 -0
  73. package/dist/utils/scheduling.js.map +1 -0
  74. package/dist/utils/send.d.ts +42 -0
  75. package/dist/utils/send.d.ts.map +1 -0
  76. package/dist/utils/send.js +193 -0
  77. package/dist/utils/send.js.map +1 -0
  78. package/dist/utils/subscribers.d.ts +23 -0
  79. package/dist/utils/subscribers.d.ts.map +1 -0
  80. package/dist/utils/subscribers.js +200 -0
  81. package/dist/utils/subscribers.js.map +1 -0
  82. package/dist/utils/templates.d.ts +16 -0
  83. package/dist/utils/templates.d.ts.map +1 -0
  84. package/dist/utils/templates.js +426 -0
  85. package/dist/utils/templates.js.map +1 -0
  86. package/dist/utils/tokens.d.ts +13 -0
  87. package/dist/utils/tokens.d.ts.map +1 -0
  88. package/dist/utils/tokens.js +62 -0
  89. package/dist/utils/tokens.js.map +1 -0
  90. package/dist/utils/tracking.d.ts +26 -0
  91. package/dist/utils/tracking.d.ts.map +1 -0
  92. package/dist/utils/tracking.js +49 -0
  93. package/dist/utils/tracking.js.map +1 -0
  94. package/dist/utils/urls.d.ts +7 -0
  95. package/dist/utils/urls.d.ts.map +1 -0
  96. package/dist/utils/urls.js +34 -0
  97. package/dist/utils/urls.js.map +1 -0
  98. package/dist/vite-plugin.d.ts +4 -0
  99. package/dist/vite-plugin.d.ts.map +1 -0
  100. package/dist/vite-plugin.js +18 -0
  101. package/dist/vite-plugin.js.map +1 -0
  102. package/package.json +85 -0
  103. package/src/astro.d.ts +4 -0
  104. package/src/components/PreferenceCenter.astro +147 -0
  105. package/src/components/SubscribeForm.astro +161 -0
  106. package/src/components/index.ts +2 -0
  107. package/src/index.ts +101 -0
  108. package/src/middleware/tracking.ts +18 -0
  109. package/src/options.ts +65 -0
  110. package/src/queue/consumer.ts +99 -0
  111. package/src/routes/confirm.ts +68 -0
  112. package/src/routes/preferences.astro +137 -0
  113. package/src/routes/subscribe.ts +107 -0
  114. package/src/routes/track-click.ts +57 -0
  115. package/src/routes/track-open.ts +51 -0
  116. package/src/routes/unsubscribe.ts +96 -0
  117. package/src/routes/webhook.ts +48 -0
  118. package/src/schema.ts +56 -0
  119. package/src/types.ts +145 -0
  120. package/src/utils/bindings.ts +28 -0
  121. package/src/utils/bounce.ts +77 -0
  122. package/src/utils/index.ts +47 -0
  123. package/src/utils/providers.ts +141 -0
  124. package/src/utils/scheduling.ts +188 -0
  125. package/src/utils/send.ts +282 -0
  126. package/src/utils/subscribers.ts +277 -0
  127. package/src/utils/templates.ts +459 -0
  128. package/src/utils/tokens.ts +91 -0
  129. package/src/utils/tracking.ts +58 -0
  130. package/src/utils/urls.ts +49 -0
  131. package/src/virtual.d.ts +32 -0
  132. 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
+ }