@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,459 @@
1
+ import type { DigestItem, TemplateData, TemplateName } from '../types.js'
2
+
3
+ // ─── Inline style map (~40 utility classes → CSS) ───
4
+
5
+ const INLINE_STYLES: Record<string, string> = {
6
+ 'text-center': 'text-align: center;',
7
+ 'text-left': 'text-align: left;',
8
+ 'text-right': 'text-align: right;',
9
+ 'font-bold': 'font-weight: 700;',
10
+ 'font-normal': 'font-weight: 400;',
11
+ 'text-sm': 'font-size: 14px; line-height: 20px;',
12
+ 'text-base': 'font-size: 16px; line-height: 24px;',
13
+ 'text-lg': 'font-size: 18px; line-height: 28px;',
14
+ 'text-xl': 'font-size: 20px; line-height: 28px;',
15
+ 'text-2xl': 'font-size: 24px; line-height: 32px;',
16
+ 'text-3xl': 'font-size: 30px; line-height: 36px;',
17
+ 'p-2': 'padding: 8px;',
18
+ 'p-3': 'padding: 12px;',
19
+ 'p-4': 'padding: 16px;',
20
+ 'p-6': 'padding: 24px;',
21
+ 'p-8': 'padding: 32px;',
22
+ 'px-2': 'padding-left: 8px; padding-right: 8px;',
23
+ 'px-3': 'padding-left: 12px; padding-right: 12px;',
24
+ 'px-4': 'padding-left: 16px; padding-right: 16px;',
25
+ 'px-6': 'padding-left: 24px; padding-right: 24px;',
26
+ 'px-8': 'padding-left: 32px; padding-right: 32px;',
27
+ 'py-2': 'padding-top: 8px; padding-bottom: 8px;',
28
+ 'py-3': 'padding-top: 12px; padding-bottom: 12px;',
29
+ 'py-4': 'padding-top: 16px; padding-bottom: 16px;',
30
+ 'py-6': 'padding-top: 24px; padding-bottom: 24px;',
31
+ 'py-8': 'padding-top: 32px; padding-bottom: 32px;',
32
+ 'm-0': 'margin: 0;',
33
+ 'm-2': 'margin: 8px;',
34
+ 'm-3': 'margin: 12px;',
35
+ 'm-4': 'margin: 16px;',
36
+ 'mx-auto': 'margin-left: auto; margin-right: auto;',
37
+ 'my-2': 'margin-top: 8px; margin-bottom: 8px;',
38
+ 'my-3': 'margin-top: 12px; margin-bottom: 12px;',
39
+ 'my-4': 'margin-top: 16px; margin-bottom: 16px;',
40
+ 'w-full': 'width: 100%;',
41
+ 'max-w-xl': 'max-width: 576px;',
42
+ 'max-w-2xl': 'max-width: 672px;',
43
+ rounded: 'border-radius: 4px;',
44
+ 'rounded-lg': 'border-radius: 8px;',
45
+ border: 'border: 1px solid #e5e7eb;',
46
+ block: 'display: block;',
47
+ 'inline-block': 'display: inline-block;',
48
+ hidden: 'display: none;',
49
+ 'leading-relaxed': 'line-height: 1.625;',
50
+ }
51
+
52
+ // ─── Helpers ───
53
+
54
+ function escapeHtml(str: string): string {
55
+ return str
56
+ .replace(/&/g, '&amp;')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;')
60
+ .replace(/'/g, '&#39;')
61
+ }
62
+
63
+ function resolveValue(data: Record<string, unknown>, path: string): unknown {
64
+ return path.split('.').reduce<unknown>((obj, key) => {
65
+ if (obj !== null && obj !== undefined && typeof obj === 'object') {
66
+ return (obj as Record<string, unknown>)[key]
67
+ }
68
+ return undefined
69
+ }, data)
70
+ }
71
+
72
+ // ─── Conditional block processing ───
73
+
74
+ /**
75
+ * Process {{#key}}...{{/key}} conditional blocks.
76
+ * If the resolved value is truthy the inner content is kept;
77
+ * otherwise the entire block (including delimiters) is removed.
78
+ * Supports nested dot paths like {{#brand.logoUrl}}...{{/brand.logoUrl}}.
79
+ */
80
+ export function processConditionals(template: string, data: Record<string, unknown>): string {
81
+ return template.replace(
82
+ /\{\{#([^}]+)\}\}([\s\S]*?)\{\{\/\1\}\}/g,
83
+ (_, key: string, content: string) => {
84
+ const value = resolveValue(data, key.trim())
85
+ return value ? content : ''
86
+ },
87
+ )
88
+ }
89
+
90
+ // ─── Template interpolation ───
91
+
92
+ export function interpolate(template: string, data: Record<string, unknown>): string {
93
+ // Conditional blocks first
94
+ let result = processConditionals(template, data)
95
+
96
+ // Triple-brace: raw unescaped output
97
+ result = result.replace(/\{\{\{([^}]+)\}\}\}/g, (_, key: string) => {
98
+ const value = resolveValue(data, key.trim())
99
+ return value !== undefined && value !== null ? String(value) : ''
100
+ })
101
+
102
+ // Double-brace: HTML-escaped output
103
+ result = result.replace(/\{\{([^}]+)\}\}/g, (_, key: string) => {
104
+ const value = resolveValue(data, key.trim())
105
+ return value !== undefined && value !== null ? escapeHtml(String(value)) : ''
106
+ })
107
+
108
+ return result
109
+ }
110
+
111
+ // ─── Style inlining ───
112
+
113
+ export function inlineStyles(
114
+ html: string,
115
+ brand?: { primaryColor: string; accentColor: string },
116
+ ): string {
117
+ return html.replace(/class="([^"]*)"/g, (_, classAttr: string) => {
118
+ const classes = classAttr.split(/\s+/).filter(Boolean)
119
+ const styles: string[] = []
120
+
121
+ for (const cls of classes) {
122
+ if (INLINE_STYLES[cls]) {
123
+ styles.push(INLINE_STYLES[cls])
124
+ } else if (brand) {
125
+ if (cls === 'text-primary') {
126
+ styles.push(`color: ${brand.primaryColor};`)
127
+ } else if (cls === 'text-accent') {
128
+ styles.push(`color: ${brand.accentColor};`)
129
+ } else if (cls === 'bg-primary') {
130
+ styles.push(`background-color: ${brand.primaryColor};`)
131
+ } else if (cls === 'bg-accent') {
132
+ styles.push(`background-color: ${brand.accentColor};`)
133
+ }
134
+ // Unrecognized classes without brand match are dropped silently
135
+ }
136
+ // Unrecognized classes are dropped silently
137
+ }
138
+
139
+ if (styles.length === 0) return ''
140
+ return `style="${styles.join(' ')}"`
141
+ })
142
+ }
143
+
144
+ // ─── Digest item renderer ───
145
+
146
+ export function renderDigestItems(items: DigestItem[]): string {
147
+ return items
148
+ .map((item) => {
149
+ const thumbnail = item.imageUrl
150
+ ? `<td style="width: 120px; padding-right: 16px; vertical-align: top;"><a href="${item.url}"><img src="${item.imageUrl}" alt="" width="120" style="display: block; border-radius: 4px; width: 120px;" /></a></td>`
151
+ : ''
152
+
153
+ const meta: string[] = []
154
+ if (item.author) meta.push(item.author)
155
+ if (item.publishedAt) meta.push(item.publishedAt)
156
+ const metaLine =
157
+ meta.length > 0
158
+ ? `<p style="font-size: 12px; line-height: 16px; color: #6b7280; margin: 4px 0 0 0;">${escapeHtml(meta.join(' &middot; '))}</p>`
159
+ : ''
160
+
161
+ const description = item.description
162
+ ? `<p style="font-size: 14px; line-height: 20px; color: #374151; margin: 4px 0 0 0;">${escapeHtml(item.description)}</p>`
163
+ : ''
164
+
165
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 24px;"><tr>${thumbnail}<td style="vertical-align: top;"><a href="${item.url}" style="font-size: 16px; line-height: 24px; color: #111827; font-weight: 700; text-decoration: none;">${escapeHtml(item.title)}</a>${description}${metaLine}</td></tr></table>`
166
+ })
167
+ .join('\n')
168
+ }
169
+
170
+ // ─── Built-in templates ───
171
+
172
+ const BUILT_IN_TEMPLATES: Record<TemplateName, string> = {
173
+ confirmation: [
174
+ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
175
+ ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
176
+ '<html xmlns="http://www.w3.org/1999/xhtml">',
177
+ '<head>',
178
+ '<meta charset="utf-8" />',
179
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
180
+ '<title>Confirm your {{senderName}} subscription</title>',
181
+ '</head>',
182
+ '<body style="margin: 0; padding: 0;">',
183
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"',
184
+ ' class="w-full" style="background-color: #f4f4f5;">',
185
+ '<tr><td class="text-center p-4">',
186
+ '<table cellpadding="0" cellspacing="0" border="0"',
187
+ ' class="mx-auto" style="max-width: 600px; width: 100%;">',
188
+ // Header
189
+ '<tr><td class="bg-primary p-6 text-center rounded-lg"' +
190
+ ' style="border-radius: 8px 8px 0 0;">',
191
+ '{{#brand.logoUrl}}<img src="{{brand.logoUrl}}" alt="{{senderName}}"' +
192
+ ' style="max-height: 48px; margin-bottom: 8px;" />{{/brand.logoUrl}}',
193
+ '<p class="text-xl font-bold"' + ' style="color: #ffffff; margin: 0;">{{senderName}}</p>',
194
+ '</td></tr>',
195
+ // Body
196
+ '<tr><td style="background-color: #ffffff; padding: 32px 24px;">',
197
+ '<h1 class="text-2xl font-bold"' + ' style="margin: 0 0 16px 0; color: #111827;">',
198
+ 'Confirm your subscription</h1>',
199
+ '<p class="text-base leading-relaxed"' + ' style="margin: 0 0 24px 0; color: #374151;">',
200
+ 'Thanks for subscribing! Please confirm your email address',
201
+ ' by clicking the button below.</p>',
202
+ '<table cellpadding="0" cellspacing="0" border="0"><tr>',
203
+ '<td class="bg-primary rounded text-center"' + ' style="padding: 12px 24px;">',
204
+ '<a href="{{confirmUrl}}" style="color: #ffffff;' +
205
+ ' text-decoration: none; font-weight: 700;' +
206
+ ' font-size: 16px;">Confirm Subscription</a>',
207
+ '</td></tr></table>',
208
+ '<p class="text-sm"' + ' style="margin: 24px 0 0 0; color: #6b7280;">',
209
+ "If you didn't subscribe to {{senderName}},",
210
+ ' you can safely ignore this email.</p>',
211
+ '</td></tr>',
212
+ // Footer
213
+ '<tr><td class="text-center p-4">',
214
+ '{{#brand.footerText}}<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
215
+ '{{brand.footerText}}</p>{{/brand.footerText}}',
216
+ '<p class="text-sm" style="color: #9ca3af; margin: 0;">',
217
+ '{{senderName}}</p>',
218
+ '</td></tr>',
219
+ '</table>',
220
+ '</td></tr></table>',
221
+ '</body></html>',
222
+ ].join('\n'),
223
+
224
+ welcome: [
225
+ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
226
+ ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
227
+ '<html xmlns="http://www.w3.org/1999/xhtml">',
228
+ '<head>',
229
+ '<meta charset="utf-8" />',
230
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
231
+ '<title>Welcome to {{senderName}}</title>',
232
+ '</head>',
233
+ '<body style="margin: 0; padding: 0;">',
234
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"',
235
+ ' class="w-full" style="background-color: #f4f4f5;">',
236
+ '<tr><td class="text-center p-4">',
237
+ '<table cellpadding="0" cellspacing="0" border="0"',
238
+ ' class="mx-auto" style="max-width: 600px; width: 100%;">',
239
+ // Header
240
+ '<tr><td class="bg-primary p-6 text-center"' + ' style="border-radius: 8px 8px 0 0;">',
241
+ '{{#brand.logoUrl}}<img src="{{brand.logoUrl}}" alt="{{senderName}}"' +
242
+ ' style="max-height: 48px; margin-bottom: 8px;" />{{/brand.logoUrl}}',
243
+ '<p class="text-xl font-bold"' + ' style="color: #ffffff; margin: 0;">{{senderName}}</p>',
244
+ '</td></tr>',
245
+ // Body
246
+ '<tr><td style="background-color: #ffffff; padding: 32px 24px;">',
247
+ '<h1 class="text-2xl font-bold"' + ' style="margin: 0 0 16px 0; color: #111827;">',
248
+ "You're confirmed!</h1>",
249
+ '<p class="text-base leading-relaxed"' + ' style="margin: 0 0 24px 0; color: #374151;">',
250
+ '{{{welcomeMessage}}}</p>',
251
+ '<table cellpadding="0" cellspacing="0" border="0"><tr>',
252
+ '<td class="bg-primary rounded text-center"' + ' style="padding: 12px 24px;">',
253
+ '<a href="{{siteUrl}}" style="color: #ffffff;' +
254
+ ' text-decoration: none; font-weight: 700;' +
255
+ ' font-size: 16px;">Visit {{senderName}}</a>',
256
+ '</td></tr></table>',
257
+ '</td></tr>',
258
+ // Footer
259
+ '<tr><td class="text-center p-4">',
260
+ '{{#brand.footerText}}<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
261
+ '{{brand.footerText}}</p>{{/brand.footerText}}',
262
+ '<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
263
+ '{{senderName}}</p>',
264
+ '{{#unsubscribeUrl}}<p class="text-sm" style="color: #9ca3af; margin: 0;">',
265
+ '<a href="{{unsubscribeUrl}}"' + ' style="color: #9ca3af;">Unsubscribe</a>',
266
+ ' &middot; ',
267
+ '<a href="{{preferencesUrl}}"' + ' style="color: #9ca3af;">Preferences</a>',
268
+ '</p>{{/unsubscribeUrl}}',
269
+ '</td></tr>',
270
+ '</table>',
271
+ '</td></tr></table>',
272
+ '</body></html>',
273
+ ].join('\n'),
274
+
275
+ campaign: [
276
+ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
277
+ ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
278
+ '<html xmlns="http://www.w3.org/1999/xhtml">',
279
+ '<head>',
280
+ '<meta charset="utf-8" />',
281
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
282
+ '<title>{{subject}}</title>',
283
+ '</head>',
284
+ '<body style="margin: 0; padding: 0;">',
285
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"',
286
+ ' class="w-full" style="background-color: #f4f4f5;">',
287
+ '<tr><td class="text-center p-4">',
288
+ '<table cellpadding="0" cellspacing="0" border="0"',
289
+ ' class="mx-auto" style="max-width: 600px; width: 100%;">',
290
+ // Header
291
+ '<tr><td class="bg-primary p-6 text-center"' + ' style="border-radius: 8px 8px 0 0;">',
292
+ '{{#brand.logoUrl}}<img src="{{brand.logoUrl}}" alt="{{senderName}}"' +
293
+ ' style="max-height: 48px; margin-bottom: 8px;" />{{/brand.logoUrl}}',
294
+ '<p class="text-xl font-bold"' + ' style="color: #ffffff; margin: 0;">{{senderName}}</p>',
295
+ '</td></tr>',
296
+ // Body
297
+ '<tr><td style="background-color: #ffffff; padding: 32px 24px;">',
298
+ '{{{content}}}',
299
+ '</td></tr>',
300
+ // Footer
301
+ '<tr><td class="text-center p-4">',
302
+ '{{#brand.footerText}}<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
303
+ '{{brand.footerText}}</p>{{/brand.footerText}}',
304
+ '<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
305
+ '{{senderName}}</p>',
306
+ '{{#unsubscribeUrl}}<p class="text-sm" style="color: #9ca3af; margin: 0;">',
307
+ '<a href="{{unsubscribeUrl}}"' + ' style="color: #9ca3af;">Unsubscribe</a>',
308
+ ' &middot; ',
309
+ '<a href="{{preferencesUrl}}"' + ' style="color: #9ca3af;">Preferences</a>',
310
+ '</p>{{/unsubscribeUrl}}',
311
+ '</td></tr>',
312
+ '</table>',
313
+ '</td></tr></table>',
314
+ '{{#trackingPixelUrl}}<img src="{{trackingPixelUrl}}" width="1" height="1" alt=""' +
315
+ ' style="display:block;width:1px;height:1px;border:0;" />{{/trackingPixelUrl}}',
316
+ '</body></html>',
317
+ ].join('\n'),
318
+
319
+ digest: [
320
+ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
321
+ ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
322
+ '<html xmlns="http://www.w3.org/1999/xhtml">',
323
+ '<head>',
324
+ '<meta charset="utf-8" />',
325
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
326
+ '<title>{{subject}}</title>',
327
+ '</head>',
328
+ '<body style="margin: 0; padding: 0;">',
329
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"',
330
+ ' class="w-full" style="background-color: #f4f4f5;">',
331
+ '<tr><td class="text-center p-4">',
332
+ '<table cellpadding="0" cellspacing="0" border="0"',
333
+ ' class="mx-auto" style="max-width: 600px; width: 100%;">',
334
+ // Header
335
+ '<tr><td class="bg-primary p-6 text-center"' + ' style="border-radius: 8px 8px 0 0;">',
336
+ '{{#brand.logoUrl}}<img src="{{brand.logoUrl}}" alt="{{senderName}}"' +
337
+ ' style="max-height: 48px; margin-bottom: 8px;" />{{/brand.logoUrl}}',
338
+ '<p class="text-xl font-bold"' + ' style="color: #ffffff; margin: 0;">{{senderName}}</p>',
339
+ '</td></tr>',
340
+ // Body
341
+ '<tr><td style="background-color: #ffffff; padding: 32px 24px;">',
342
+ '{{#introText}}<p class="text-base leading-relaxed" style="margin: 0 0 24px 0; color: #374151;">',
343
+ '{{{introText}}}</p>{{/introText}}',
344
+ '{{{digestItems}}}',
345
+ '</td></tr>',
346
+ // Footer
347
+ '<tr><td class="text-center p-4">',
348
+ '{{#brand.footerText}}<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
349
+ '{{brand.footerText}}</p>{{/brand.footerText}}',
350
+ '<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
351
+ '{{senderName}}</p>',
352
+ '{{#unsubscribeUrl}}<p class="text-sm" style="color: #9ca3af; margin: 0;">',
353
+ '<a href="{{unsubscribeUrl}}"' + ' style="color: #9ca3af;">Unsubscribe</a>',
354
+ ' &middot; ',
355
+ '<a href="{{preferencesUrl}}"' + ' style="color: #9ca3af;">Preferences</a>',
356
+ '</p>{{/unsubscribeUrl}}',
357
+ '</td></tr>',
358
+ '</table>',
359
+ '</td></tr></table>',
360
+ '{{#trackingPixelUrl}}<img src="{{trackingPixelUrl}}" width="1" height="1" alt=""' +
361
+ ' style="display:block;width:1px;height:1px;border:0;" />{{/trackingPixelUrl}}',
362
+ '</body></html>',
363
+ ].join('\n'),
364
+
365
+ transactional: [
366
+ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
367
+ ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
368
+ '<html xmlns="http://www.w3.org/1999/xhtml">',
369
+ '<head>',
370
+ '<meta charset="utf-8" />',
371
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
372
+ '<title>{{subject}}</title>',
373
+ '</head>',
374
+ '<body style="margin: 0; padding: 0;">',
375
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"',
376
+ ' class="w-full" style="background-color: #f4f4f5;">',
377
+ '<tr><td class="text-center p-4">',
378
+ '<table cellpadding="0" cellspacing="0" border="0"',
379
+ ' class="mx-auto" style="max-width: 600px; width: 100%;">',
380
+ // Header — minimal, text only
381
+ '<tr><td class="p-4 text-center">',
382
+ '<p class="text-lg font-bold"' + ' style="color: #374151; margin: 0;">{{senderName}}</p>',
383
+ '</td></tr>',
384
+ // Body
385
+ '<tr><td style="background-color: #ffffff; padding: 32px 24px;">',
386
+ '{{{content}}}',
387
+ '</td></tr>',
388
+ // Footer — minimal, no unsubscribe
389
+ '<tr><td class="text-center p-4">',
390
+ '<p class="text-sm" style="color: #9ca3af; margin: 0;">',
391
+ '{{senderName}}</p>',
392
+ '</td></tr>',
393
+ '</table>',
394
+ '</td></tr></table>',
395
+ '</body></html>',
396
+ ].join('\n'),
397
+
398
+ 'unsubscribe-confirm': [
399
+ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
400
+ ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
401
+ '<html xmlns="http://www.w3.org/1999/xhtml">',
402
+ '<head>',
403
+ '<meta charset="utf-8" />',
404
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
405
+ '<title>Unsubscribed from {{senderName}}</title>',
406
+ '</head>',
407
+ '<body style="margin: 0; padding: 0;">',
408
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"',
409
+ ' class="w-full" style="background-color: #f4f4f5;">',
410
+ '<tr><td class="text-center p-4">',
411
+ '<table cellpadding="0" cellspacing="0" border="0"',
412
+ ' class="mx-auto" style="max-width: 600px; width: 100%;">',
413
+ // Header
414
+ '<tr><td class="bg-primary p-6 text-center"' + ' style="border-radius: 8px 8px 0 0;">',
415
+ '{{#brand.logoUrl}}<img src="{{brand.logoUrl}}" alt="{{senderName}}"' +
416
+ ' style="max-height: 48px; margin-bottom: 8px;" />{{/brand.logoUrl}}',
417
+ '<p class="text-xl font-bold"' + ' style="color: #ffffff; margin: 0;">{{senderName}}</p>',
418
+ '</td></tr>',
419
+ // Body
420
+ '<tr><td style="background-color: #ffffff; padding: 32px 24px;">',
421
+ '<h1 class="text-2xl font-bold"' + ' style="margin: 0 0 16px 0; color: #111827;">',
422
+ "You've been unsubscribed</h1>",
423
+ '<p class="text-base leading-relaxed"' + ' style="margin: 0 0 24px 0; color: #374151;">',
424
+ "You won't receive any more emails from {{senderName}}.</p>",
425
+ '<table cellpadding="0" cellspacing="0" border="0"><tr>',
426
+ '<td class="bg-primary rounded text-center"' + ' style="padding: 12px 24px;">',
427
+ '<a href="{{resubscribeUrl}}" style="color: #ffffff;' +
428
+ ' text-decoration: none; font-weight: 700;' +
429
+ ' font-size: 16px;">Re-subscribe</a>',
430
+ '</td></tr></table>',
431
+ '</td></tr>',
432
+ // Footer
433
+ '<tr><td class="text-center p-4">',
434
+ '{{#brand.footerText}}<p class="text-sm" style="color: #9ca3af; margin: 0 0 8px 0;">',
435
+ '{{brand.footerText}}</p>{{/brand.footerText}}',
436
+ '<p class="text-sm" style="color: #9ca3af; margin: 0;">',
437
+ '{{senderName}}</p>',
438
+ '</td></tr>',
439
+ '</table>',
440
+ '</td></tr></table>',
441
+ '</body></html>',
442
+ ].join('\n'),
443
+ }
444
+
445
+ // ─── Main render function ───
446
+
447
+ export function renderEmail(template: string | TemplateName, data: TemplateData): string {
448
+ const templateStr =
449
+ template in BUILT_IN_TEMPLATES ? BUILT_IN_TEMPLATES[template as TemplateName] : template
450
+
451
+ // Provide default welcome message if not supplied
452
+ const templateData = { ...data } as Record<string, unknown>
453
+ if (template === 'welcome' && !templateData.welcomeMessage) {
454
+ templateData.welcomeMessage = `Welcome to ${data.senderName}! You're now subscribed and will receive our latest updates directly in your inbox.`
455
+ }
456
+
457
+ const interpolated = interpolate(templateStr, templateData)
458
+ return inlineStyles(interpolated, data.brand)
459
+ }
@@ -0,0 +1,91 @@
1
+ const encoder = new TextEncoder()
2
+
3
+ function toBase64Url(buffer: ArrayBuffer | Uint8Array): string {
4
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
5
+ return btoa(String.fromCharCode(...bytes))
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=+$/, '')
9
+ }
10
+
11
+ function fromBase64Url(str: string): Uint8Array {
12
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (str.length % 4)) % 4)
13
+ const binary = atob(padded)
14
+ const bytes = new Uint8Array(binary.length)
15
+ for (let i = 0; i < binary.length; i++) {
16
+ bytes[i] = binary.charCodeAt(i)
17
+ }
18
+ return bytes
19
+ }
20
+
21
+ async function importKey(secret: string): Promise<CryptoKey> {
22
+ return crypto.subtle.importKey(
23
+ 'raw',
24
+ encoder.encode(secret),
25
+ { name: 'HMAC', hash: 'SHA-256' },
26
+ false,
27
+ ['sign', 'verify'],
28
+ )
29
+ }
30
+
31
+ async function hmacSign(secret: string, data: string): Promise<string> {
32
+ const key = await importKey(secret)
33
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data))
34
+ return toBase64Url(signature)
35
+ }
36
+
37
+ async function hmacVerify(secret: string, data: string, signature: string): Promise<boolean> {
38
+ const key = await importKey(secret)
39
+ const sigBytes = fromBase64Url(signature)
40
+ return crypto.subtle.verify('HMAC', key, sigBytes.buffer as ArrayBuffer, encoder.encode(data))
41
+ }
42
+
43
+ interface TokenPayload {
44
+ subscriberId: string
45
+ action: 'confirm' | 'unsubscribe' | 'preferences'
46
+ exp?: number
47
+ }
48
+
49
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
50
+
51
+ export async function generateToken(secret: string, payload: TokenPayload): Promise<string> {
52
+ const finalPayload = { ...payload }
53
+
54
+ if (finalPayload.action === 'confirm' && finalPayload.exp === undefined) {
55
+ finalPayload.exp = Date.now() + SEVEN_DAYS_MS
56
+ }
57
+
58
+ const payloadB64 = toBase64Url(encoder.encode(JSON.stringify(finalPayload)))
59
+ const signature = await hmacSign(secret, payloadB64)
60
+ return `${payloadB64}.${signature}`
61
+ }
62
+
63
+ export async function verifyToken(
64
+ secret: string,
65
+ token: string,
66
+ ): Promise<{ subscriberId: string; action: string; exp?: number } | null> {
67
+ const dotIndex = token.indexOf('.')
68
+ if (dotIndex === -1) return null
69
+
70
+ const payloadB64 = token.slice(0, dotIndex)
71
+ const signature = token.slice(dotIndex + 1)
72
+
73
+ if (!payloadB64 || !signature) return null
74
+
75
+ const valid = await hmacVerify(secret, payloadB64, signature)
76
+ if (!valid) return null
77
+
78
+ try {
79
+ const decoded = JSON.parse(new TextDecoder().decode(fromBase64Url(payloadB64))) as {
80
+ subscriberId: string
81
+ action: string
82
+ exp?: number
83
+ }
84
+
85
+ if (decoded.exp !== undefined && decoded.exp < Date.now()) return null
86
+
87
+ return decoded
88
+ } catch {
89
+ return null
90
+ }
91
+ }
@@ -0,0 +1,58 @@
1
+ // 43-byte 1×1 transparent GIF
2
+ export const TRANSPARENT_GIF = new Uint8Array([
3
+ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0xff,
4
+ 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00,
5
+ 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b,
6
+ ])
7
+
8
+ /**
9
+ * Inject a tracking pixel before the closing </body> tag.
10
+ * If no </body> tag exists, the pixel is appended to the end.
11
+ * The {{TRACKING_ID}} placeholder is substituted per-recipient at send time.
12
+ *
13
+ * Only used for campaign/digest emails — never transactional.
14
+ */
15
+ export function injectTrackingPixel(html: string, trackOpenUrl: string): string {
16
+ const pixel = `<img src="${trackOpenUrl}/{{TRACKING_ID}}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`
17
+
18
+ const bodyCloseIdx = html.lastIndexOf('</body>')
19
+ if (bodyCloseIdx !== -1) {
20
+ return html.slice(0, bodyCloseIdx) + pixel + html.slice(bodyCloseIdx)
21
+ }
22
+
23
+ return html + pixel
24
+ }
25
+
26
+ /**
27
+ * Rewrite all <a href="..."> links to pass through a tracking redirect.
28
+ * The rewritten href becomes:
29
+ * {trackClickUrl}/{{TRACKING_ID}}?url={encodeURIComponent(originalUrl)}
30
+ *
31
+ * Skipped links:
32
+ * - Template placeholders: {{UNSUBSCRIBE_URL}}, {{PREFERENCES_URL}}
33
+ * - mailto: links
34
+ * - Anchor links starting with #
35
+ * - Empty href or missing href
36
+ *
37
+ * The {{TRACKING_ID}} placeholder is substituted per-recipient at send time.
38
+ *
39
+ * Only used for campaign/digest emails — never transactional.
40
+ */
41
+ export function rewriteLinksForTracking(html: string, trackClickUrl: string): string {
42
+ return html.replace(
43
+ /<a\s([^>]*?)href\s*=\s*"([^"]*)"([^>]*?)>/gi,
44
+ (_match, before: string, href: string, after: string) => {
45
+ if (!href || href.startsWith('#') || href.startsWith('mailto:')) {
46
+ return _match
47
+ }
48
+
49
+ if (href.includes('{{UNSUBSCRIBE_URL}}') || href.includes('{{PREFERENCES_URL}}')) {
50
+ return _match
51
+ }
52
+
53
+ const tracked = `${trackClickUrl}/{{TRACKING_ID}}` + `?url=${encodeURIComponent(href)}`
54
+
55
+ return `<a ${before}href="${tracked}"${after}>`
56
+ },
57
+ )
58
+ }
@@ -0,0 +1,49 @@
1
+ import type { ResolvedMailerOptions } from '../options.js'
2
+ import { generateToken } from './tokens.js'
3
+
4
+ export function buildSiteUrl(
5
+ siteUrl: string,
6
+ path: string,
7
+ searchParams?: Record<string, string | boolean | undefined>,
8
+ ): string {
9
+ const url = new URL(path, siteUrl)
10
+
11
+ for (const [key, value] of Object.entries(searchParams ?? {})) {
12
+ if (value === undefined) continue
13
+ url.searchParams.set(key, typeof value === 'boolean' ? String(value) : value)
14
+ }
15
+
16
+ return url.toString()
17
+ }
18
+
19
+ export async function buildSubscriberManageUrls(
20
+ options: Pick<
21
+ ResolvedMailerOptions,
22
+ 'preferencesPath' | 'siteUrl' | 'signingSecret' | 'unsubscribePath'
23
+ >,
24
+ subscriberId?: string,
25
+ ): Promise<{ preferencesUrl: string; unsubscribeUrl: string }> {
26
+ if (!subscriberId) {
27
+ return { preferencesUrl: '', unsubscribeUrl: '' }
28
+ }
29
+
30
+ const [unsubscribeToken, preferencesToken] = await Promise.all([
31
+ generateToken(options.signingSecret, {
32
+ subscriberId,
33
+ action: 'unsubscribe',
34
+ }),
35
+ generateToken(options.signingSecret, {
36
+ subscriberId,
37
+ action: 'preferences',
38
+ }),
39
+ ])
40
+
41
+ return {
42
+ unsubscribeUrl: buildSiteUrl(options.siteUrl, options.unsubscribePath, {
43
+ token: unsubscribeToken,
44
+ }),
45
+ preferencesUrl: buildSiteUrl(options.siteUrl, options.preferencesPath, {
46
+ token: preferencesToken,
47
+ }),
48
+ }
49
+ }