@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,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, '&')
|
|
57
|
+
.replace(/</g, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"')
|
|
60
|
+
.replace(/'/g, ''')
|
|
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(' · '))}</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
|
+
' · ',
|
|
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
|
+
' · ',
|
|
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
|
+
' · ',
|
|
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
|
+
}
|