@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
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
+ }