@objectstack/plugin-email 4.0.1
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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +256 -0
- package/dist/index.d.ts +256 -0
- package/dist/index.js +877 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +835 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +35 -0
- package/src/email-plugin.ts +361 -0
- package/src/email-service.test.ts +142 -0
- package/src/email-service.ts +366 -0
- package/src/index.ts +32 -0
- package/src/send-template.test.ts +273 -0
- package/src/template-engine.test.ts +76 -0
- package/src/template-engine.ts +94 -0
- package/src/templates/auth-templates.ts +177 -0
- package/src/transports/index.ts +39 -0
- package/src/transports/postmark.ts +94 -0
- package/src/transports/resend.ts +89 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { EmailService, type TemplateLoader, type EmailTemplateRow } from './email-service.js';
|
|
5
|
+
import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts';
|
|
6
|
+
|
|
7
|
+
class CaptureTransport implements IEmailTransport {
|
|
8
|
+
public sent: NormalizedEmailMessage[] = [];
|
|
9
|
+
async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
|
|
10
|
+
this.sent.push(message);
|
|
11
|
+
return { messageId: `msg-${this.sent.length}` };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeLoader(rows: EmailTemplateRow[]): TemplateLoader {
|
|
16
|
+
return {
|
|
17
|
+
async load(name, locale) {
|
|
18
|
+
const exact = rows.find((r) => r.name === name && r.locale === locale);
|
|
19
|
+
if (exact) return exact;
|
|
20
|
+
const fb = rows.find((r) => r.name === name && r.locale === 'en-US');
|
|
21
|
+
return fb || null;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('EmailService.sendTemplate', () => {
|
|
27
|
+
const sampleTemplate: EmailTemplateRow = {
|
|
28
|
+
name: 'auth.password_reset',
|
|
29
|
+
locale: 'en-US',
|
|
30
|
+
subject: 'Reset {{user.name}}',
|
|
31
|
+
body_html: '<p>Hi {{user.name}}, <a href="{{{resetUrl}}}">reset</a></p>',
|
|
32
|
+
body_text: 'Hi {{user.name}}, reset: {{resetUrl}}',
|
|
33
|
+
active: true,
|
|
34
|
+
variables_json: JSON.stringify([
|
|
35
|
+
{ name: 'user.name', required: true },
|
|
36
|
+
{ name: 'resetUrl', required: true },
|
|
37
|
+
]),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it('renders template + delivers via transport', async () => {
|
|
41
|
+
const transport = new CaptureTransport();
|
|
42
|
+
const svc = new EmailService({
|
|
43
|
+
transport,
|
|
44
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
45
|
+
templateLoader: makeLoader([sampleTemplate]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const res = await svc.sendTemplate({
|
|
49
|
+
template: 'auth.password_reset',
|
|
50
|
+
to: 'alice@x.com',
|
|
51
|
+
data: { user: { name: 'Alice' }, resetUrl: 'https://x.com/r/abc' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(res.status).toBe('sent');
|
|
55
|
+
expect(transport.sent).toHaveLength(1);
|
|
56
|
+
const msg = transport.sent[0];
|
|
57
|
+
expect(msg.subject).toBe('Reset Alice');
|
|
58
|
+
expect(msg.html).toContain('Hi Alice');
|
|
59
|
+
expect(msg.html).toContain('href="https://x.com/r/abc"');
|
|
60
|
+
expect(msg.text).toContain('reset: https://x.com/r/abc');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws TEMPLATE_NOT_FOUND when loader returns null', async () => {
|
|
64
|
+
const svc = new EmailService({
|
|
65
|
+
transport: new CaptureTransport(),
|
|
66
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
67
|
+
templateLoader: makeLoader([]),
|
|
68
|
+
});
|
|
69
|
+
await expect(svc.sendTemplate({
|
|
70
|
+
template: 'auth.unknown',
|
|
71
|
+
to: 'a@x.com',
|
|
72
|
+
data: {},
|
|
73
|
+
})).rejects.toThrow(/TEMPLATE_NOT_FOUND/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('throws TEMPLATE_INACTIVE for active=false rows', async () => {
|
|
77
|
+
const svc = new EmailService({
|
|
78
|
+
transport: new CaptureTransport(),
|
|
79
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
80
|
+
templateLoader: makeLoader([{ ...sampleTemplate, active: false }]),
|
|
81
|
+
});
|
|
82
|
+
await expect(svc.sendTemplate({
|
|
83
|
+
template: 'auth.password_reset',
|
|
84
|
+
to: 'a@x.com',
|
|
85
|
+
data: { user: { name: 'A' }, resetUrl: 'https://x' },
|
|
86
|
+
})).rejects.toThrow(/TEMPLATE_INACTIVE/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('throws MISSING_VARIABLES when required vars absent', async () => {
|
|
90
|
+
const svc = new EmailService({
|
|
91
|
+
transport: new CaptureTransport(),
|
|
92
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
93
|
+
templateLoader: makeLoader([sampleTemplate]),
|
|
94
|
+
});
|
|
95
|
+
await expect(svc.sendTemplate({
|
|
96
|
+
template: 'auth.password_reset',
|
|
97
|
+
to: 'a@x.com',
|
|
98
|
+
data: { user: { name: 'A' } }, // missing resetUrl
|
|
99
|
+
})).rejects.toThrow(/MISSING_VARIABLES: resetUrl/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('falls back to en-US when requested locale missing', async () => {
|
|
103
|
+
const transport = new CaptureTransport();
|
|
104
|
+
const svc = new EmailService({
|
|
105
|
+
transport,
|
|
106
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
107
|
+
templateLoader: makeLoader([sampleTemplate]),
|
|
108
|
+
});
|
|
109
|
+
await svc.sendTemplate({
|
|
110
|
+
template: 'auth.password_reset',
|
|
111
|
+
to: 'a@x.com',
|
|
112
|
+
locale: 'zh-CN',
|
|
113
|
+
data: { user: { name: 'A' }, resetUrl: 'https://x' },
|
|
114
|
+
});
|
|
115
|
+
expect(transport.sent[0].subject).toBe('Reset A'); // en-US row used
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('uses template fromOverride when supplied', async () => {
|
|
119
|
+
const transport = new CaptureTransport();
|
|
120
|
+
const svc = new EmailService({
|
|
121
|
+
transport,
|
|
122
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
123
|
+
templateLoader: makeLoader([{
|
|
124
|
+
...sampleTemplate,
|
|
125
|
+
from_address: 'security@x.com',
|
|
126
|
+
from_name: 'Security Team',
|
|
127
|
+
}]),
|
|
128
|
+
});
|
|
129
|
+
await svc.sendTemplate({
|
|
130
|
+
template: 'auth.password_reset',
|
|
131
|
+
to: 'a@x.com',
|
|
132
|
+
data: { user: { name: 'A' }, resetUrl: 'https://x' },
|
|
133
|
+
});
|
|
134
|
+
expect(transport.sent[0].from).toBe('Security Team <security@x.com>');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('merges defaultTemplateContext into data', async () => {
|
|
138
|
+
const transport = new CaptureTransport();
|
|
139
|
+
const svc = new EmailService({
|
|
140
|
+
transport,
|
|
141
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
142
|
+
templateLoader: makeLoader([{
|
|
143
|
+
...sampleTemplate,
|
|
144
|
+
subject: '{{appName}}: reset',
|
|
145
|
+
body_html: '<p>{{appName}}</p>',
|
|
146
|
+
variables_json: '[]',
|
|
147
|
+
}]),
|
|
148
|
+
defaultTemplateContext: { appName: 'Acme' },
|
|
149
|
+
});
|
|
150
|
+
await svc.sendTemplate({
|
|
151
|
+
template: 'auth.password_reset',
|
|
152
|
+
to: 'a@x.com',
|
|
153
|
+
data: {},
|
|
154
|
+
});
|
|
155
|
+
expect(transport.sent[0].subject).toBe('Acme: reset');
|
|
156
|
+
expect(transport.sent[0].html).toContain('Acme');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('throws if no templateLoader configured', async () => {
|
|
160
|
+
const svc = new EmailService({
|
|
161
|
+
transport: new CaptureTransport(),
|
|
162
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
163
|
+
});
|
|
164
|
+
await expect(svc.sendTemplate({
|
|
165
|
+
template: 'x',
|
|
166
|
+
to: 'a@x.com',
|
|
167
|
+
data: {},
|
|
168
|
+
})).rejects.toThrow(/templateLoader/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('auto-derives plain text from HTML when body_text omitted', async () => {
|
|
172
|
+
const transport = new CaptureTransport();
|
|
173
|
+
const svc = new EmailService({
|
|
174
|
+
transport,
|
|
175
|
+
defaultFrom: { address: 'no-reply@x.com' },
|
|
176
|
+
templateLoader: makeLoader([{
|
|
177
|
+
...sampleTemplate,
|
|
178
|
+
body_text: null,
|
|
179
|
+
}]),
|
|
180
|
+
});
|
|
181
|
+
await svc.sendTemplate({
|
|
182
|
+
template: 'auth.password_reset',
|
|
183
|
+
to: 'a@x.com',
|
|
184
|
+
data: { user: { name: 'A' }, resetUrl: 'https://x' },
|
|
185
|
+
});
|
|
186
|
+
expect(transport.sent[0].text).toContain('Hi A');
|
|
187
|
+
expect(transport.sent[0].text).not.toContain('<a href');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('ResendTransport', () => {
|
|
192
|
+
let originalFetch: typeof globalThis.fetch;
|
|
193
|
+
beforeEach(() => { originalFetch = globalThis.fetch; });
|
|
194
|
+
afterEach(() => { globalThis.fetch = originalFetch; });
|
|
195
|
+
|
|
196
|
+
it('POSTs JSON with bearer auth and returns messageId', async () => {
|
|
197
|
+
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ id: 'res-123' }), {
|
|
198
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
}));
|
|
200
|
+
globalThis.fetch = fetchSpy as any;
|
|
201
|
+
const { ResendTransport } = await import('./transports/resend.js');
|
|
202
|
+
const t = new ResendTransport('sk_test_key');
|
|
203
|
+
const res = await t.send({
|
|
204
|
+
to: ['a@x.com'],
|
|
205
|
+
from: 'no-reply@x.com',
|
|
206
|
+
subject: 'Hi',
|
|
207
|
+
text: 'body',
|
|
208
|
+
});
|
|
209
|
+
expect(res.messageId).toBe('res-123');
|
|
210
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
211
|
+
const [url, init] = fetchSpy.mock.calls[0] as any;
|
|
212
|
+
expect(url).toBe('https://api.resend.com/emails');
|
|
213
|
+
expect(init.headers.Authorization).toBe('Bearer sk_test_key');
|
|
214
|
+
const body = JSON.parse(init.body);
|
|
215
|
+
expect(body).toMatchObject({ to: ['a@x.com'], from: 'no-reply@x.com', subject: 'Hi', text: 'body' });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('throws on non-2xx', async () => {
|
|
219
|
+
globalThis.fetch = (async () => new Response('bad key', { status: 401 })) as any;
|
|
220
|
+
const { ResendTransport } = await import('./transports/resend.js');
|
|
221
|
+
const t = new ResendTransport('bad');
|
|
222
|
+
await expect(t.send({ to: ['a@x.com'], from: 'b@x.com', subject: 's', text: 'x' }))
|
|
223
|
+
.rejects.toThrow(/Resend 401/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('throws when constructor called with empty apiKey', async () => {
|
|
227
|
+
const { ResendTransport } = await import('./transports/resend.js');
|
|
228
|
+
expect(() => new ResendTransport('')).toThrow(/apiKey is required/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('PostmarkTransport', () => {
|
|
233
|
+
let originalFetch: typeof globalThis.fetch;
|
|
234
|
+
beforeEach(() => { originalFetch = globalThis.fetch; });
|
|
235
|
+
afterEach(() => { globalThis.fetch = originalFetch; });
|
|
236
|
+
|
|
237
|
+
it('POSTs Postmark-shaped JSON and returns MessageID', async () => {
|
|
238
|
+
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ MessageID: 'pm-9' }), {
|
|
239
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
}));
|
|
241
|
+
globalThis.fetch = fetchSpy as any;
|
|
242
|
+
const { PostmarkTransport } = await import('./transports/postmark.js');
|
|
243
|
+
const t = new PostmarkTransport({ apiKey: 'tok', messageStream: 'broadcast' });
|
|
244
|
+
const res = await t.send({
|
|
245
|
+
to: ['a@x.com', 'b@x.com'],
|
|
246
|
+
from: 'no-reply@x.com',
|
|
247
|
+
subject: 'Hi',
|
|
248
|
+
html: '<p>x</p>',
|
|
249
|
+
});
|
|
250
|
+
expect(res.messageId).toBe('pm-9');
|
|
251
|
+
const [url, init] = fetchSpy.mock.calls[0] as any;
|
|
252
|
+
expect(url).toBe('https://api.postmarkapp.com/email');
|
|
253
|
+
expect(init.headers['X-Postmark-Server-Token']).toBe('tok');
|
|
254
|
+
const body = JSON.parse(init.body);
|
|
255
|
+
expect(body.To).toBe('a@x.com, b@x.com');
|
|
256
|
+
expect(body.MessageStream).toBe('broadcast');
|
|
257
|
+
expect(body.HtmlBody).toBe('<p>x</p>');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('makeTransport factory', () => {
|
|
262
|
+
it('builds LogTransport for log provider', async () => {
|
|
263
|
+
const { makeTransport } = await import('./transports/index.js');
|
|
264
|
+
const { LogTransport } = await import('./email-service.js');
|
|
265
|
+
expect(makeTransport({ provider: 'log' })).toBeInstanceOf(LogTransport);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('rejects resend/postmark without apiKey', async () => {
|
|
269
|
+
const { makeTransport } = await import('./transports/index.js');
|
|
270
|
+
expect(() => makeTransport({ provider: 'resend' })).toThrow(/apiKey/);
|
|
271
|
+
expect(() => makeTransport({ provider: 'postmark' })).toThrow(/apiKey/);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { renderTemplate, requireVars, htmlToText } from './template-engine.js';
|
|
5
|
+
|
|
6
|
+
describe('template-engine', () => {
|
|
7
|
+
describe('renderTemplate', () => {
|
|
8
|
+
it('substitutes dotted paths', () => {
|
|
9
|
+
expect(renderTemplate('Hi {{user.name}}', { user: { name: 'Alice' } }))
|
|
10
|
+
.toBe('Hi Alice');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('escapes HTML by default', () => {
|
|
14
|
+
expect(renderTemplate('<p>{{x}}</p>', { x: '<script>alert(1)</script>' }))
|
|
15
|
+
.toBe('<p><script>alert(1)</script></p>');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('does not escape with triple braces', () => {
|
|
19
|
+
expect(renderTemplate('<a href="{{{url}}}">go</a>', { url: 'https://x.com/?a=1&b=2' }))
|
|
20
|
+
.toBe('<a href="https://x.com/?a=1&b=2">go</a>');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders missing variables as empty strings', () => {
|
|
24
|
+
expect(renderTemplate('a={{a}} b={{b}}', { a: 'A' })).toBe('a=A b=');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('handles deeply nested paths', () => {
|
|
28
|
+
expect(renderTemplate('{{a.b.c.d}}', { a: { b: { c: { d: 'deep' } } } }))
|
|
29
|
+
.toBe('deep');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('stringifies non-string scalars', () => {
|
|
33
|
+
expect(renderTemplate('count={{n}}', { n: 42 })).toBe('count=42');
|
|
34
|
+
expect(renderTemplate('flag={{f}}', { f: true })).toBe('flag=true');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('escapes all standard HTML entities', () => {
|
|
38
|
+
expect(renderTemplate('{{s}}', { s: `&<>"'` })).toBe('&<>"'');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('requireVars', () => {
|
|
43
|
+
it('passes when all present', () => {
|
|
44
|
+
expect(() => requireVars({ a: 1, b: 'x' }, ['a', 'b'])).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('throws MISSING_VARIABLES listing the gaps', () => {
|
|
48
|
+
expect(() => requireVars({ a: 1 }, ['a', 'b', 'c']))
|
|
49
|
+
.toThrow('MISSING_VARIABLES: b, c');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('supports dotted paths', () => {
|
|
53
|
+
expect(() => requireVars({ user: { name: 'a' } }, ['user.name'])).not.toThrow();
|
|
54
|
+
expect(() => requireVars({ user: {} }, ['user.name']))
|
|
55
|
+
.toThrow('MISSING_VARIABLES: user.name');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('htmlToText', () => {
|
|
60
|
+
it('strips tags and collapses whitespace', () => {
|
|
61
|
+
expect(htmlToText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('converts <br> to newlines', () => {
|
|
65
|
+
expect(htmlToText('a<br>b<br/>c')).toBe('a\nb\nc');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles common entities', () => {
|
|
69
|
+
expect(htmlToText('<p>1 < 2 && 3 > 2</p>')).toBe('1 < 2 && 3 > 2');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('collapses 3+ newlines to 2', () => {
|
|
73
|
+
expect(htmlToText('<p>a</p><p>b</p>')).toBe('a\nb');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal mustache-style template renderer.
|
|
5
|
+
*
|
|
6
|
+
* Supports `{{path.to.value}}` placeholders resolved against a plain
|
|
7
|
+
* JS object via dotted-path lookup. Values are HTML-escaped by
|
|
8
|
+
* default; use `{{{path}}}` (triple braces) to opt out of escaping
|
|
9
|
+
* (e.g. when injecting pre-rendered HTML fragments such as URLs in
|
|
10
|
+
* `<a href="">`).
|
|
11
|
+
*
|
|
12
|
+
* Deliberately tiny (no loops / conditionals / partials) — the design
|
|
13
|
+
* stance is that email templates SHOULD be data-only renderings; any
|
|
14
|
+
* branching belongs in the caller. If we ever need more, swap for
|
|
15
|
+
* Handlebars, but bringing it in costs ~50KB and pulls a parser at
|
|
16
|
+
* runtime; we resist that until a real use case demands it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PLACEHOLDER = /(\{\{\{?)\s*([\w.]+)\s*(\}?\}\})/g;
|
|
20
|
+
|
|
21
|
+
function lookup(data: Record<string, any>, path: string): unknown {
|
|
22
|
+
if (!path) return undefined;
|
|
23
|
+
const parts = path.split('.');
|
|
24
|
+
let cur: any = data;
|
|
25
|
+
for (const p of parts) {
|
|
26
|
+
if (cur == null) return undefined;
|
|
27
|
+
cur = cur[p];
|
|
28
|
+
}
|
|
29
|
+
return cur;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function escapeHtml(s: string): string {
|
|
33
|
+
return s
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'/g, ''');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render `template` with values from `data`. Missing placeholders
|
|
43
|
+
* render as empty strings (no throw); call `requireVars()` first if
|
|
44
|
+
* you need strict validation.
|
|
45
|
+
*/
|
|
46
|
+
export function renderTemplate(template: string, data: Record<string, any>): string {
|
|
47
|
+
if (!template) return '';
|
|
48
|
+
return template.replace(PLACEHOLDER, (_match, open: string, path: string, close: string) => {
|
|
49
|
+
const isUnescaped = open === '{{{' && close === '}}}';
|
|
50
|
+
const raw = lookup(data, path);
|
|
51
|
+
if (raw == null) return '';
|
|
52
|
+
const str = typeof raw === 'string' ? raw : String(raw);
|
|
53
|
+
return isUnescaped ? str : escapeHtml(str);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Throw `Error('MISSING_VARIABLES: a, b')` when required vars are
|
|
59
|
+
* absent from `data`. Used by `IEmailService.sendTemplate()` to
|
|
60
|
+
* fail fast rather than send a half-rendered email.
|
|
61
|
+
*/
|
|
62
|
+
export function requireVars(
|
|
63
|
+
data: Record<string, any>,
|
|
64
|
+
required: ReadonlyArray<string>,
|
|
65
|
+
): void {
|
|
66
|
+
const missing = required.filter((name) => lookup(data, name) == null);
|
|
67
|
+
if (missing.length > 0) {
|
|
68
|
+
throw new Error(`MISSING_VARIABLES: ${missing.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip HTML tags + collapse whitespace to derive a plain-text body
|
|
74
|
+
* from an HTML template. Conservative: keeps line breaks at block
|
|
75
|
+
* boundaries (<br>, </p>, </div>) so the resulting text is at least
|
|
76
|
+
* paragraph-shaped.
|
|
77
|
+
*/
|
|
78
|
+
export function htmlToText(html: string): string {
|
|
79
|
+
if (!html) return '';
|
|
80
|
+
return html
|
|
81
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
82
|
+
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
|
|
83
|
+
.replace(/<[^>]+>/g, '')
|
|
84
|
+
.replace(/ /g, ' ')
|
|
85
|
+
.replace(/&/g, '&')
|
|
86
|
+
.replace(/</g, '<')
|
|
87
|
+
.replace(/>/g, '>')
|
|
88
|
+
.replace(/"/g, '"')
|
|
89
|
+
.replace(/'/g, "'")
|
|
90
|
+
.replace(/[ \t]+/g, ' ')
|
|
91
|
+
.replace(/\n[ \t]+/g, '\n')
|
|
92
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
93
|
+
.trim();
|
|
94
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { EmailTemplateDefinition as EmailTemplate } from '@objectstack/spec/system';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Built-in auth email templates seeded into `sys_email_template` on
|
|
7
|
+
* EmailServicePlugin startup. Each template is `isSystem: true` so
|
|
8
|
+
* tenants may overlay subject/body but should not delete the row.
|
|
9
|
+
*
|
|
10
|
+
* Templates use `{{path.to.value}}` placeholders; `{{{...}}}` for
|
|
11
|
+
* unescaped URLs (see template-engine.ts).
|
|
12
|
+
*
|
|
13
|
+
* Authoring conventions:
|
|
14
|
+
* - Subject: plain, max ~80 chars, no markup.
|
|
15
|
+
* - HTML body: single column, ~600px max width, inline styles only
|
|
16
|
+
* (most clients strip <head>).
|
|
17
|
+
* - Always include a plain-text fallback (good for spam scoring).
|
|
18
|
+
* - Provide an `{{appName}}` variable everywhere for brand override.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const baseStyles = 'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#1f2937';
|
|
22
|
+
const buttonStyles = 'display:inline-block;padding:12px 24px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600';
|
|
23
|
+
const footerStyles = 'margin-top:32px;padding-top:16px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:12px';
|
|
24
|
+
|
|
25
|
+
function wrap(title: string, bodyHtml: string): string {
|
|
26
|
+
return `<!doctype html><html><body style="${baseStyles};margin:0;padding:24px;background:#f9fafb">
|
|
27
|
+
<div style="max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb">
|
|
28
|
+
<h1 style="margin:0 0 16px 0;font-size:20px;font-weight:600">${title}</h1>
|
|
29
|
+
${bodyHtml}
|
|
30
|
+
<div style="${footerStyles}">
|
|
31
|
+
You received this email because of activity on your {{appName}} account.<br>
|
|
32
|
+
If this wasn't you, you can safely ignore this message.
|
|
33
|
+
</div>
|
|
34
|
+
</div></body></html>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const AUTH_PASSWORD_RESET_TEMPLATE: EmailTemplate = {
|
|
38
|
+
name: 'auth.password_reset',
|
|
39
|
+
label: 'Password Reset',
|
|
40
|
+
category: 'auth',
|
|
41
|
+
locale: 'en-US',
|
|
42
|
+
subject: 'Reset your {{appName}} password',
|
|
43
|
+
bodyHtml: wrap('Reset your password', `
|
|
44
|
+
<p>Hi {{user.name}},</p>
|
|
45
|
+
<p>We received a request to reset the password for the account associated with <strong>{{user.email}}</strong>.</p>
|
|
46
|
+
<p>Click the button below to choose a new password. This link expires in {{expiresInMinutes}} minutes.</p>
|
|
47
|
+
<p style="margin:24px 0"><a href="{{{resetUrl}}}" style="${buttonStyles}">Reset password</a></p>
|
|
48
|
+
<p style="font-size:13px;color:#6b7280">Or copy and paste this URL into your browser:<br><span style="word-break:break-all">{{resetUrl}}</span></p>
|
|
49
|
+
<p>If you didn't request this, no action is needed — your password stays the same.</p>
|
|
50
|
+
`),
|
|
51
|
+
bodyText: `Hi {{user.name}},
|
|
52
|
+
|
|
53
|
+
We received a request to reset the password for {{user.email}}.
|
|
54
|
+
|
|
55
|
+
Reset your password (link expires in {{expiresInMinutes}} minutes):
|
|
56
|
+
{{resetUrl}}
|
|
57
|
+
|
|
58
|
+
If you didn't request this, ignore this email.`,
|
|
59
|
+
variables: [
|
|
60
|
+
{ name: 'user.name', type: 'string', required: false, description: 'Recipient display name' },
|
|
61
|
+
{ name: 'user.email', type: 'string', required: true, description: 'Recipient email' },
|
|
62
|
+
{ name: 'resetUrl', type: 'url', required: true, description: 'Password reset URL' },
|
|
63
|
+
{ name: 'expiresInMinutes', type: 'number', required: false, description: 'Link TTL in minutes' },
|
|
64
|
+
{ name: 'appName', type: 'string', required: false, description: 'Product/app name (brand override)' },
|
|
65
|
+
],
|
|
66
|
+
active: true,
|
|
67
|
+
isSystem: true,
|
|
68
|
+
description: 'Sent when a user requests a password reset via better-auth.',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const AUTH_VERIFY_EMAIL_TEMPLATE: EmailTemplate = {
|
|
72
|
+
name: 'auth.verify_email',
|
|
73
|
+
label: 'Verify Email Address',
|
|
74
|
+
category: 'auth',
|
|
75
|
+
locale: 'en-US',
|
|
76
|
+
subject: 'Verify your {{appName}} email address',
|
|
77
|
+
bodyHtml: wrap('Verify your email', `
|
|
78
|
+
<p>Hi {{user.name}},</p>
|
|
79
|
+
<p>Thanks for signing up for {{appName}}! Please confirm <strong>{{user.email}}</strong> belongs to you.</p>
|
|
80
|
+
<p style="margin:24px 0"><a href="{{{verificationUrl}}}" style="${buttonStyles}">Verify email</a></p>
|
|
81
|
+
<p style="font-size:13px;color:#6b7280">Or copy and paste this URL into your browser:<br><span style="word-break:break-all">{{verificationUrl}}</span></p>
|
|
82
|
+
`),
|
|
83
|
+
bodyText: `Hi {{user.name}},
|
|
84
|
+
|
|
85
|
+
Please verify your email ({{user.email}}) by opening this link:
|
|
86
|
+
{{verificationUrl}}`,
|
|
87
|
+
variables: [
|
|
88
|
+
{ name: 'user.name', type: 'string', required: false },
|
|
89
|
+
{ name: 'user.email', type: 'string', required: true },
|
|
90
|
+
{ name: 'verificationUrl', type: 'url', required: true },
|
|
91
|
+
{ name: 'appName', type: 'string', required: false },
|
|
92
|
+
],
|
|
93
|
+
active: true,
|
|
94
|
+
isSystem: true,
|
|
95
|
+
description: 'Sent when better-auth needs to verify a newly-registered email address.',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const AUTH_MAGIC_LINK_TEMPLATE: EmailTemplate = {
|
|
99
|
+
name: 'auth.magic_link',
|
|
100
|
+
label: 'Magic Link Sign-In',
|
|
101
|
+
category: 'auth',
|
|
102
|
+
locale: 'en-US',
|
|
103
|
+
subject: 'Your {{appName}} sign-in link',
|
|
104
|
+
bodyHtml: wrap('Sign in to {{appName}}', `
|
|
105
|
+
<p>Click the button below to sign in. This link expires in {{expiresInMinutes}} minutes and may only be used once.</p>
|
|
106
|
+
<p style="margin:24px 0"><a href="{{{magicLinkUrl}}}" style="${buttonStyles}">Sign in</a></p>
|
|
107
|
+
<p style="font-size:13px;color:#6b7280">Or paste:<br><span style="word-break:break-all">{{magicLinkUrl}}</span></p>
|
|
108
|
+
`),
|
|
109
|
+
bodyText: `Sign in to {{appName}} (expires in {{expiresInMinutes}} min):
|
|
110
|
+
{{magicLinkUrl}}`,
|
|
111
|
+
variables: [
|
|
112
|
+
{ name: 'magicLinkUrl', type: 'url', required: true },
|
|
113
|
+
{ name: 'expiresInMinutes', type: 'number', required: false },
|
|
114
|
+
{ name: 'appName', type: 'string', required: false },
|
|
115
|
+
],
|
|
116
|
+
active: true,
|
|
117
|
+
isSystem: true,
|
|
118
|
+
description: 'Passwordless sign-in link sent by the magic-link plugin.',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const AUTH_INVITATION_TEMPLATE: EmailTemplate = {
|
|
122
|
+
name: 'auth.invitation',
|
|
123
|
+
label: 'Organization Invitation',
|
|
124
|
+
category: 'auth',
|
|
125
|
+
locale: 'en-US',
|
|
126
|
+
subject: '{{inviter.name}} invited you to {{organization.name}}',
|
|
127
|
+
bodyHtml: wrap('You have been invited', `
|
|
128
|
+
<p><strong>{{inviter.name}}</strong> ({{inviter.email}}) has invited you to join <strong>{{organization.name}}</strong> on {{appName}} as <em>{{role}}</em>.</p>
|
|
129
|
+
<p style="margin:24px 0"><a href="{{{acceptUrl}}}" style="${buttonStyles}">Accept invitation</a></p>
|
|
130
|
+
<p style="font-size:13px;color:#6b7280">Or paste:<br><span style="word-break:break-all">{{acceptUrl}}</span></p>
|
|
131
|
+
`),
|
|
132
|
+
bodyText: `{{inviter.name}} ({{inviter.email}}) invited you to join {{organization.name}} on {{appName}}.
|
|
133
|
+
|
|
134
|
+
Accept: {{acceptUrl}}`,
|
|
135
|
+
variables: [
|
|
136
|
+
{ name: 'inviter.name', type: 'string', required: false },
|
|
137
|
+
{ name: 'inviter.email', type: 'string', required: false },
|
|
138
|
+
{ name: 'organization.name', type: 'string', required: true },
|
|
139
|
+
{ name: 'role', type: 'string', required: false },
|
|
140
|
+
{ name: 'acceptUrl', type: 'url', required: true },
|
|
141
|
+
{ name: 'appName', type: 'string', required: false },
|
|
142
|
+
],
|
|
143
|
+
active: true,
|
|
144
|
+
isSystem: true,
|
|
145
|
+
description: 'Sent by better-auth organization plugin when a user is invited to an org.',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const AUTH_TWO_FACTOR_OTP_TEMPLATE: EmailTemplate = {
|
|
149
|
+
name: 'auth.two_factor_otp',
|
|
150
|
+
label: 'Two-Factor Verification Code',
|
|
151
|
+
category: 'auth',
|
|
152
|
+
locale: 'en-US',
|
|
153
|
+
subject: 'Your {{appName}} verification code',
|
|
154
|
+
bodyHtml: wrap('Your verification code', `
|
|
155
|
+
<p>Use this code to complete sign-in:</p>
|
|
156
|
+
<p style="font-size:32px;font-weight:700;letter-spacing:6px;background:#f3f4f6;padding:16px;text-align:center;border-radius:6px;margin:24px 0">{{otp}}</p>
|
|
157
|
+
<p style="color:#6b7280;font-size:13px">This code expires in {{expiresInMinutes}} minutes. If you didn't try to sign in, change your password — your account may be at risk.</p>
|
|
158
|
+
`),
|
|
159
|
+
bodyText: `Your {{appName}} verification code: {{otp}}
|
|
160
|
+
(expires in {{expiresInMinutes}} minutes)`,
|
|
161
|
+
variables: [
|
|
162
|
+
{ name: 'otp', type: 'string', required: true },
|
|
163
|
+
{ name: 'expiresInMinutes', type: 'number', required: false },
|
|
164
|
+
{ name: 'appName', type: 'string', required: false },
|
|
165
|
+
],
|
|
166
|
+
active: true,
|
|
167
|
+
isSystem: true,
|
|
168
|
+
description: 'Time-based OTP delivered for two-factor / email-OTP login.',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const BUILTIN_AUTH_TEMPLATES: EmailTemplate[] = [
|
|
172
|
+
AUTH_PASSWORD_RESET_TEMPLATE,
|
|
173
|
+
AUTH_VERIFY_EMAIL_TEMPLATE,
|
|
174
|
+
AUTH_MAGIC_LINK_TEMPLATE,
|
|
175
|
+
AUTH_INVITATION_TEMPLATE,
|
|
176
|
+
AUTH_TWO_FACTOR_OTP_TEMPLATE,
|
|
177
|
+
];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { IEmailTransport } from '@objectstack/spec/contracts';
|
|
4
|
+
import { LogTransport } from '../email-service.js';
|
|
5
|
+
import { ResendTransport } from './resend.js';
|
|
6
|
+
import { PostmarkTransport } from './postmark.js';
|
|
7
|
+
|
|
8
|
+
export { ResendTransport, type ResendTransportOptions } from './resend.js';
|
|
9
|
+
export { PostmarkTransport, type PostmarkTransportOptions } from './postmark.js';
|
|
10
|
+
|
|
11
|
+
export interface MakeTransportOptions {
|
|
12
|
+
provider: 'log' | 'resend' | 'postmark';
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
options?: Record<string, unknown>;
|
|
15
|
+
logger?: { info: (msg: string, meta?: any) => void };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build an IEmailTransport from a provider tag + opts. Used by
|
|
20
|
+
* EmailServicePlugin to materialise the transport selected by
|
|
21
|
+
* `EmailServiceConfig.provider`.
|
|
22
|
+
*
|
|
23
|
+
* Throws when a non-`log` provider is requested without an `apiKey`.
|
|
24
|
+
*/
|
|
25
|
+
export function makeTransport(opts: MakeTransportOptions): IEmailTransport {
|
|
26
|
+
const { provider, apiKey, options = {}, logger } = opts;
|
|
27
|
+
switch (provider) {
|
|
28
|
+
case 'log':
|
|
29
|
+
return new LogTransport(logger);
|
|
30
|
+
case 'resend':
|
|
31
|
+
if (!apiKey) throw new Error("makeTransport: provider='resend' requires apiKey (OS_EMAIL_API_KEY)");
|
|
32
|
+
return new ResendTransport({ apiKey, ...(options as any) });
|
|
33
|
+
case 'postmark':
|
|
34
|
+
if (!apiKey) throw new Error("makeTransport: provider='postmark' requires apiKey (OS_EMAIL_API_KEY)");
|
|
35
|
+
return new PostmarkTransport({ apiKey, ...(options as any) });
|
|
36
|
+
default:
|
|
37
|
+
throw new Error(`makeTransport: unknown provider '${provider}'`);
|
|
38
|
+
}
|
|
39
|
+
}
|