@fonderie/courier 2.0.0 → 4.0.0
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/brain/outcomes.md +28 -1
- package/brain/signatures.md +42 -0
- package/dist/index.cjs +383 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -2
- package/dist/index.d.ts +52 -2
- package/dist/index.js +374 -25
- package/dist/index.js.map +1 -1
- package/dist/migrations/sql/002_seed_templates.sql +167 -40
- package/dist/migrations/sql/004_template_versioning.sql +26 -0
- package/package.json +4 -4
package/brain/outcomes.md
CHANGED
|
@@ -9,6 +9,19 @@ downloading tarballs.
|
|
|
9
9
|
|
|
10
10
|
## Database tables (after all migrations)
|
|
11
11
|
|
|
12
|
+
### `fonderie_courier_template_revisions`
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
type TEXT NOT NULL
|
|
16
|
+
locale TEXT
|
|
17
|
+
subject TEXT
|
|
18
|
+
html TEXT
|
|
19
|
+
text TEXT NOT NULL
|
|
20
|
+
version INT NOT NULL
|
|
21
|
+
actor TEXT
|
|
22
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
23
|
+
```
|
|
24
|
+
|
|
12
25
|
### `fonderie_courier_templates`
|
|
13
26
|
|
|
14
27
|
```sql
|
|
@@ -21,6 +34,8 @@ text TEXT NOT NULL
|
|
|
21
34
|
active BOOLEAN NOT NULL DEFAULT true
|
|
22
35
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
23
36
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
37
|
+
version INT NOT NULL DEFAULT 1
|
|
38
|
+
updated_by TEXT
|
|
24
39
|
-- UNIQUE (type, locale)
|
|
25
40
|
```
|
|
26
41
|
|
|
@@ -48,8 +63,20 @@ bounce_reason TEXT
|
|
|
48
63
|
|
|
49
64
|
Raw SQL ships in `node_modules/@fonderie/courier/dist/migrations/sql/` — read it there if you must; never download tarballs.
|
|
50
65
|
|
|
66
|
+
## HTTP routes registered
|
|
67
|
+
|
|
68
|
+
| Method | Path | Middleware chain (auth / validation / handler) |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| GET | `/admin/templates` | `g(async () => { return setApiResponse(HTTP.OK, 'TEMPLATES_LISTED', 'Templates', await listTemplateEntries(store)); })` |
|
|
71
|
+
| DELETE | `/admin/templates/:type` | `g(async (ctx) => { const ok = await deleteTemplate(typeOf(ctx), localeOf(ctx), store); return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? 'DELETED' : 'NOT_FOUND', ok ? 'Deleted' : 'No such template'); })` |
|
|
72
|
+
| GET | `/admin/templates/:type` | `g(async (ctx) => { const row = await getTemplateEntry(typeOf(ctx), localeOf(ctx), store); return row ? setApiResponse(HTTP.OK, 'TEMPLATE', 'Template', row) : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such template'); })` |
|
|
73
|
+
| PUT | `/admin/templates/:type` | `g(async (ctx) => { const b = body(ctx); if (typeof b['text'] !== 'string') { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.text (string) is required'); } try { const opts: Parameters<typeof setTemplate>[0] = { type: typeOf(ctx), text: b['text'], locale: localeOf(ctx), actor: actorOf(ctx), }; if (typeof b['subject'] === 'string') opts.subject = b['subject']; if (typeof b['html'] === 'string') opts.html = b['html']; if (typeof b['active'] === 'boolean') opts.active = b['active']; if (typeof b['ifVersion'] === 'number') opts.ifVersion = b['ifVersion']; return setApiResponse(HTTP.OK, 'TEMPLATE_SET', 'Template saved', await setTemplate(opts, store)); } catch (err) { return conflictOr(err); } })` |
|
|
74
|
+
| GET | `/admin/templates/:type/revisions` | `g(async (ctx) => { return setApiResponse(HTTP.OK, 'REVISIONS', 'Template revisions', await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store)); })` |
|
|
75
|
+
| POST | `/admin/templates/:type/rollback` | `g(async (ctx) => { const b = body(ctx); const toVersion = Number(b['toVersion']); if (!Number.isInteger(toVersion)) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.toVersion (int) is required'); } const row = await rollbackTemplate( { type: typeOf(ctx), locale: localeOf(ctx), toVersion, actor: actorOf(ctx) }, store, ); return setApiResponse(HTTP.OK, 'ROLLED_BACK', `Rolled back to v${toVersion}`, row); })` |
|
|
76
|
+
|
|
51
77
|
## Migration statements not replayed (verify in raw SQL)
|
|
52
78
|
|
|
53
79
|
- `ELSE`
|
|
54
80
|
- `END IF`
|
|
55
|
-
- `END
|
|
81
|
+
- `END`
|
|
82
|
+
- `$fn$ LANGUAGE plpgsql`
|
package/brain/signatures.md
CHANGED
|
@@ -11,8 +11,11 @@ new CourierModule(config: ICourierConfig, store?: IStoreAdapter | undefined, bus
|
|
|
11
11
|
.name: "@fonderie/courier"
|
|
12
12
|
.deps: string[]
|
|
13
13
|
.dispatcher: Dispatcher
|
|
14
|
+
.checkReadiness(): IReadinessProblem[]
|
|
14
15
|
.install(app: IFonderieApp): void
|
|
15
16
|
|
|
17
|
+
function validateCourierConfig(config: ICourierConfig, registeredChannels: Iterable<string>): void
|
|
18
|
+
|
|
16
19
|
function handleSendGridDelivery(req: Request, store: IStoreAdapter, webhookSecret?: string | undefined): Promise<Response>
|
|
17
20
|
|
|
18
21
|
function handleMailgunDelivery(req: Request, store: IStoreAdapter, signingKey?: string | undefined): Promise<Response>
|
|
@@ -21,6 +24,7 @@ function handleMailtrapDelivery(req: Request, store: IStoreAdapter): Promise<Res
|
|
|
21
24
|
|
|
22
25
|
new Dispatcher(config: ICourierConfig, resolver: ITemplateResolver, store?: IStoreAdapter | undefined): Dispatcher
|
|
23
26
|
.registerChannel(channel: ICourierChannel): Dispatcher
|
|
27
|
+
.channelNames(): string[]
|
|
24
28
|
.dispatch(message: ICourierMessage): Promise<void>
|
|
25
29
|
|
|
26
30
|
new SmsChannel(config: ISmsChannelConfig): SmsChannel
|
|
@@ -41,6 +45,43 @@ new DBTemplateResolver(store: IStoreAdapter): DBTemplateResolver
|
|
|
41
45
|
new FSTemplateResolver(directory: string): FSTemplateResolver
|
|
42
46
|
.resolve(type: string, data: Record<string, unknown>, locale?: string | undefined): Promise<IRenderedTemplate>
|
|
43
47
|
|
|
48
|
+
function setTemplate(opts: { type: string; text: string; locale?: string | null; subject?: string | null; html?: string | null; active?: boolean; ifVersion?: number; actor?: string; }, store: IStoreAdapter): Promise<...>
|
|
49
|
+
|
|
50
|
+
function rollbackTemplate(opts: { type: string; locale?: string | null; toVersion: number; actor?: string; }, store: IStoreAdapter): Promise<ITemplateEntry>
|
|
51
|
+
|
|
52
|
+
function listTemplateRevisions(type: string, locale: string | null, store: IStoreAdapter): Promise<ITemplateRevision[]>
|
|
53
|
+
|
|
54
|
+
function getTemplateEntry(type: string, locale: string | null, store: IStoreAdapter): Promise<ITemplateEntry | null>
|
|
55
|
+
|
|
56
|
+
function listTemplateEntries(store: IStoreAdapter): Promise<ITemplateEntry[]>
|
|
57
|
+
|
|
58
|
+
function deleteTemplate(type: string, locale: string | null, store: IStoreAdapter): Promise<boolean>
|
|
59
|
+
|
|
60
|
+
function buildTemplateAdminRoutes(store: IStoreAdapter, adminToken: string): [string, string, Middleware][]
|
|
61
|
+
|
|
62
|
+
interface ITemplateEntry {
|
|
63
|
+
type: string;
|
|
64
|
+
locale: string | null;
|
|
65
|
+
subject: string | null;
|
|
66
|
+
html: string | null;
|
|
67
|
+
text: string;
|
|
68
|
+
active: boolean;
|
|
69
|
+
version: number;
|
|
70
|
+
updatedBy: string | null;
|
|
71
|
+
updatedAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ITemplateRevision {
|
|
75
|
+
type: string;
|
|
76
|
+
locale: string | null;
|
|
77
|
+
subject: string | null;
|
|
78
|
+
html: string | null;
|
|
79
|
+
text: string;
|
|
80
|
+
version: number;
|
|
81
|
+
actor: string | null;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
44
85
|
interface IMessageLog {
|
|
45
86
|
id: string;
|
|
46
87
|
messageType: string;
|
|
@@ -95,6 +136,7 @@ interface ICourierConfig {
|
|
|
95
136
|
sms?: ISmsChannelConfig;
|
|
96
137
|
push?: IPushChannelConfig;
|
|
97
138
|
email?: IEmailChannelConfig;
|
|
139
|
+
adminToken?: string;
|
|
98
140
|
templates?: {
|
|
99
141
|
source: 'db' | 'fs';
|
|
100
142
|
directory?: string;
|
package/dist/index.cjs
CHANGED
|
@@ -38,9 +38,17 @@ __export(index_exports, {
|
|
|
38
38
|
FSTemplateResolver: () => FSTemplateResolver,
|
|
39
39
|
PushChannel: () => PushChannel,
|
|
40
40
|
SmsChannel: () => SmsChannel,
|
|
41
|
+
buildTemplateAdminRoutes: () => buildTemplateAdminRoutes,
|
|
42
|
+
deleteTemplate: () => deleteTemplate,
|
|
43
|
+
getTemplateEntry: () => getTemplateEntry,
|
|
41
44
|
handleMailgunDelivery: () => handleMailgunDelivery,
|
|
42
45
|
handleMailtrapDelivery: () => handleMailtrapDelivery,
|
|
43
|
-
handleSendGridDelivery: () => handleSendGridDelivery
|
|
46
|
+
handleSendGridDelivery: () => handleSendGridDelivery,
|
|
47
|
+
listTemplateEntries: () => listTemplateEntries,
|
|
48
|
+
listTemplateRevisions: () => listTemplateRevisions,
|
|
49
|
+
rollbackTemplate: () => rollbackTemplate,
|
|
50
|
+
setTemplate: () => setTemplate,
|
|
51
|
+
validateCourierConfig: () => validateCourierConfig
|
|
44
52
|
});
|
|
45
53
|
module.exports = __toCommonJS(index_exports);
|
|
46
54
|
|
|
@@ -135,6 +143,11 @@ var Dispatcher = class {
|
|
|
135
143
|
this.channels.set(channel.name, channel);
|
|
136
144
|
return this;
|
|
137
145
|
}
|
|
146
|
+
// Names of the currently-registered channels — used by the boot-time config
|
|
147
|
+
// guard to detect message types routed to a channel with no provider.
|
|
148
|
+
channelNames() {
|
|
149
|
+
return [...this.channels.keys()];
|
|
150
|
+
}
|
|
138
151
|
async dispatch(message) {
|
|
139
152
|
const channelNames = this.config.channels[message.type];
|
|
140
153
|
if (!channelNames || channelNames.length === 0) {
|
|
@@ -211,8 +224,8 @@ var SmsChannel = class {
|
|
|
211
224
|
}
|
|
212
225
|
);
|
|
213
226
|
if (!res.ok) {
|
|
214
|
-
const
|
|
215
|
-
throw new Error(`[courier:sms] Twilio error ${res.status}: ${
|
|
227
|
+
const body2 = await res.text();
|
|
228
|
+
throw new Error(`[courier:sms] Twilio error ${res.status}: ${body2}`);
|
|
216
229
|
}
|
|
217
230
|
}
|
|
218
231
|
async sendViaVonage(to, text) {
|
|
@@ -232,8 +245,8 @@ var SmsChannel = class {
|
|
|
232
245
|
})
|
|
233
246
|
});
|
|
234
247
|
if (!res.ok) {
|
|
235
|
-
const
|
|
236
|
-
throw new Error(`[courier:sms] Vonage error ${res.status}: ${
|
|
248
|
+
const body2 = await res.text();
|
|
249
|
+
throw new Error(`[courier:sms] Vonage error ${res.status}: ${body2}`);
|
|
237
250
|
}
|
|
238
251
|
}
|
|
239
252
|
};
|
|
@@ -275,8 +288,8 @@ var PushChannel = class {
|
|
|
275
288
|
})
|
|
276
289
|
});
|
|
277
290
|
if (!res.ok) {
|
|
278
|
-
const
|
|
279
|
-
throw new Error(`[courier:push] FCM error ${res.status}: ${
|
|
291
|
+
const body2 = await res.text();
|
|
292
|
+
throw new Error(`[courier:push] FCM error ${res.status}: ${body2}`);
|
|
280
293
|
}
|
|
281
294
|
}
|
|
282
295
|
};
|
|
@@ -331,8 +344,8 @@ var EmailChannel = class {
|
|
|
331
344
|
})
|
|
332
345
|
});
|
|
333
346
|
if (!res.ok) {
|
|
334
|
-
const
|
|
335
|
-
throw new Error(`[courier:email] Resend error ${res.status}: ${
|
|
347
|
+
const body2 = await res.text();
|
|
348
|
+
throw new Error(`[courier:email] Resend error ${res.status}: ${body2}`);
|
|
336
349
|
}
|
|
337
350
|
}
|
|
338
351
|
async sendViaSMTP(to, template) {
|
|
@@ -349,13 +362,144 @@ var EmailChannel = class {
|
|
|
349
362
|
}
|
|
350
363
|
};
|
|
351
364
|
|
|
365
|
+
// src/templates/layout.ts
|
|
366
|
+
var EMAIL_THEME = {
|
|
367
|
+
brand: "Fonderie",
|
|
368
|
+
accent: "#171717",
|
|
369
|
+
// primary action (button) background — the product's primary
|
|
370
|
+
accentText: "#ffffff",
|
|
371
|
+
// text on the accent
|
|
372
|
+
brandAccent: "#171717",
|
|
373
|
+
// brand highlight (top rule) — monochrome near-black
|
|
374
|
+
link: "#009767",
|
|
375
|
+
// links — the accessible (darkened) brand teal
|
|
376
|
+
ink: "#171717",
|
|
377
|
+
// body copy
|
|
378
|
+
muted: "#5c5c5c",
|
|
379
|
+
// secondary copy
|
|
380
|
+
line: "#e0e0e0",
|
|
381
|
+
// hairline borders
|
|
382
|
+
canvas: "#fafafa",
|
|
383
|
+
// page background behind the card
|
|
384
|
+
card: "#ffffff"
|
|
385
|
+
// the card itself
|
|
386
|
+
};
|
|
387
|
+
var FONT_SANS = "Inter, 'Inter Fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif";
|
|
388
|
+
var FONT_MONO = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace";
|
|
389
|
+
var LAYOUT_CONTENT_SLOT = "{{content}}";
|
|
390
|
+
var DEFAULT_EMAIL_LAYOUT = `<!DOCTYPE html>
|
|
391
|
+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
392
|
+
<head>
|
|
393
|
+
<meta charset="utf-8">
|
|
394
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
395
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
396
|
+
<meta name="color-scheme" content="light dark">
|
|
397
|
+
<meta name="supported-color-schemes" content="light dark">
|
|
398
|
+
<title>{{subject}}</title>
|
|
399
|
+
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
400
|
+
<style>
|
|
401
|
+
body { margin: 0; padding: 0; width: 100% !important; background: ${EMAIL_THEME.canvas}; }
|
|
402
|
+
.email-body { background: ${EMAIL_THEME.canvas}; }
|
|
403
|
+
.email-container { width: 100%; max-width: 560px; margin: 0 auto; }
|
|
404
|
+
.email-card {
|
|
405
|
+
background: ${EMAIL_THEME.card};
|
|
406
|
+
border: 1px solid ${EMAIL_THEME.line};
|
|
407
|
+
border-radius: 8px;
|
|
408
|
+
overflow: hidden;
|
|
409
|
+
}
|
|
410
|
+
.email-accent { height: 3px; background: ${EMAIL_THEME.brandAccent}; font-size: 0; line-height: 0; }
|
|
411
|
+
.email-header { padding: 28px 32px 0 32px; }
|
|
412
|
+
.email-brand { font: 800 19px/1 ${FONT_SANS}; color: ${EMAIL_THEME.ink}; letter-spacing: -0.03em; }
|
|
413
|
+
.email-content {
|
|
414
|
+
padding: 20px 32px 8px 32px;
|
|
415
|
+
font: 400 16px/1.7 ${FONT_SANS};
|
|
416
|
+
color: ${EMAIL_THEME.ink};
|
|
417
|
+
letter-spacing: -0.01em;
|
|
418
|
+
}
|
|
419
|
+
.email-content h1 { margin: 0 0 12px 0; font-size: 22px; line-height: 1.3; font-weight: 700; letter-spacing: -0.03em; }
|
|
420
|
+
.email-content p { margin: 0 0 16px 0; }
|
|
421
|
+
.email-content a { color: ${EMAIL_THEME.link}; }
|
|
422
|
+
.email-footer {
|
|
423
|
+
padding: 16px 32px 28px 32px;
|
|
424
|
+
font: 400 13px/1.6 ${FONT_SANS};
|
|
425
|
+
color: ${EMAIL_THEME.muted};
|
|
426
|
+
letter-spacing: -0.01em;
|
|
427
|
+
}
|
|
428
|
+
.btn {
|
|
429
|
+
display: inline-block;
|
|
430
|
+
background: ${EMAIL_THEME.accent};
|
|
431
|
+
color: ${EMAIL_THEME.accentText} !important;
|
|
432
|
+
text-decoration: none;
|
|
433
|
+
font: 600 16px/1 ${FONT_SANS};
|
|
434
|
+
letter-spacing: -0.01em;
|
|
435
|
+
padding: 13px 26px;
|
|
436
|
+
border-radius: 6px;
|
|
437
|
+
}
|
|
438
|
+
.pin-code {
|
|
439
|
+
display: inline-block;
|
|
440
|
+
font: 700 30px/1 ${FONT_MONO};
|
|
441
|
+
letter-spacing: 0.28em;
|
|
442
|
+
color: ${EMAIL_THEME.ink};
|
|
443
|
+
background: ${EMAIL_THEME.canvas};
|
|
444
|
+
border: 1px solid ${EMAIL_THEME.line};
|
|
445
|
+
border-radius: 6px;
|
|
446
|
+
padding: 14px 22px 14px 30px;
|
|
447
|
+
}
|
|
448
|
+
.muted { color: ${EMAIL_THEME.muted}; }
|
|
449
|
+
@media only screen and (max-width: 599px) {
|
|
450
|
+
.email-header, .email-content, .email-footer { padding-left: 22px !important; padding-right: 22px !important; }
|
|
451
|
+
.pin-code { font-size: 26px !important; letter-spacing: 0.2em !important; }
|
|
452
|
+
}
|
|
453
|
+
</style>
|
|
454
|
+
</head>
|
|
455
|
+
<body class="email-body">
|
|
456
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">{{preheader}}</div>
|
|
457
|
+
<table role="presentation" class="email-body" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${EMAIL_THEME.canvas};">
|
|
458
|
+
<tr>
|
|
459
|
+
<td align="center" style="padding: 32px 16px;">
|
|
460
|
+
<table role="presentation" class="email-container" width="560" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
|
|
461
|
+
<tr>
|
|
462
|
+
<td class="email-card" style="background:${EMAIL_THEME.card};border:1px solid ${EMAIL_THEME.line};border-radius:8px;">
|
|
463
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
464
|
+
<tr><td class="email-accent" style="height:3px;background:${EMAIL_THEME.brandAccent};font-size:0;line-height:0;"> </td></tr>
|
|
465
|
+
<tr><td class="email-header" style="padding:28px 32px 0 32px;">
|
|
466
|
+
<span class="email-brand" style="font-weight:800;font-size:19px;color:${EMAIL_THEME.ink};letter-spacing:-0.03em;">${EMAIL_THEME.brand}</span>
|
|
467
|
+
</td></tr>
|
|
468
|
+
<tr><td class="email-content" style="padding:20px 32px 8px 32px;color:${EMAIL_THEME.ink};">
|
|
469
|
+
${LAYOUT_CONTENT_SLOT}
|
|
470
|
+
</td></tr>
|
|
471
|
+
<tr><td class="email-footer" style="padding:16px 32px 28px 32px;color:${EMAIL_THEME.muted};font-size:13px;">
|
|
472
|
+
You're receiving this because someone used this address at ${EMAIL_THEME.brand}. If that wasn't you, you can ignore it.
|
|
473
|
+
</td></tr>
|
|
474
|
+
</table>
|
|
475
|
+
</td>
|
|
476
|
+
</tr>
|
|
477
|
+
</table>
|
|
478
|
+
</td>
|
|
479
|
+
</tr>
|
|
480
|
+
</table>
|
|
481
|
+
</body>
|
|
482
|
+
</html>`;
|
|
483
|
+
function wrapLayout(bodyHtml, layoutHtml = DEFAULT_EMAIL_LAYOUT) {
|
|
484
|
+
const trimmed = bodyHtml.trimStart().toLowerCase();
|
|
485
|
+
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
|
|
486
|
+
return bodyHtml;
|
|
487
|
+
}
|
|
488
|
+
return layoutHtml.replace(LAYOUT_CONTENT_SLOT, bodyHtml);
|
|
489
|
+
}
|
|
490
|
+
|
|
352
491
|
// src/templates/resolver.ts
|
|
492
|
+
var LAYOUT_TYPE = "_layout";
|
|
353
493
|
function render(template, data) {
|
|
354
494
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
355
495
|
const value = data[key];
|
|
356
496
|
return value !== void 0 && value !== null ? String(value) : "";
|
|
357
497
|
});
|
|
358
498
|
}
|
|
499
|
+
function composeHtml(bodyHtml, layoutHtml, subject, data) {
|
|
500
|
+
const wrapped = wrapLayout(bodyHtml, layoutHtml);
|
|
501
|
+
return render(wrapped, { subject: subject ?? "", preheader: "", ...data });
|
|
502
|
+
}
|
|
359
503
|
var DBTemplateResolver = class {
|
|
360
504
|
constructor(store) {
|
|
361
505
|
this.store = store;
|
|
@@ -363,22 +507,39 @@ var DBTemplateResolver = class {
|
|
|
363
507
|
store;
|
|
364
508
|
async resolve(type, data, locale) {
|
|
365
509
|
const [row] = await this.store.query(
|
|
510
|
+
// Serve the exact locale, else the neutral NULL default — never a
|
|
511
|
+
// sibling region (en-CA must not fall back to en-US); the WHERE
|
|
512
|
+
// excludes other locales so legal/jurisdictional copy can't bleed.
|
|
366
513
|
`SELECT subject, html, text
|
|
367
514
|
FROM fonderie_courier_templates
|
|
368
|
-
WHERE type = $1 AND active = true
|
|
369
|
-
ORDER BY (locale
|
|
515
|
+
WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
|
|
516
|
+
ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
|
|
370
517
|
LIMIT 1`,
|
|
371
518
|
[type, locale ?? null]
|
|
372
519
|
);
|
|
373
520
|
if (!row) {
|
|
374
521
|
return { text: `${type}: ${JSON.stringify(data)}` };
|
|
375
522
|
}
|
|
523
|
+
const subject = row.subject ? render(row.subject, data) : void 0;
|
|
524
|
+
const layoutHtml = row.html ? await this.layout(locale) : void 0;
|
|
376
525
|
return {
|
|
377
526
|
text: render(row.text, data),
|
|
378
|
-
...
|
|
379
|
-
...row.html ? { html:
|
|
527
|
+
...subject ? { subject } : {},
|
|
528
|
+
...row.html ? { html: composeHtml(row.html, layoutHtml, subject, data) } : {}
|
|
380
529
|
};
|
|
381
530
|
}
|
|
531
|
+
// Optional founder-supplied layout shell; undefined → built-in default.
|
|
532
|
+
async layout(locale) {
|
|
533
|
+
const [row] = await this.store.query(
|
|
534
|
+
`SELECT html
|
|
535
|
+
FROM fonderie_courier_templates
|
|
536
|
+
WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
|
|
537
|
+
ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
|
|
538
|
+
LIMIT 1`,
|
|
539
|
+
[LAYOUT_TYPE, locale ?? null]
|
|
540
|
+
);
|
|
541
|
+
return row?.html ?? void 0;
|
|
542
|
+
}
|
|
382
543
|
};
|
|
383
544
|
var FSTemplateResolver = class {
|
|
384
545
|
constructor(directory) {
|
|
@@ -396,7 +557,8 @@ var FSTemplateResolver = class {
|
|
|
396
557
|
}
|
|
397
558
|
};
|
|
398
559
|
const localePrefix = locale ? `${type}.${locale}` : null;
|
|
399
|
-
const
|
|
560
|
+
const layoutPrefix = locale ? `${LAYOUT_TYPE}.${locale}` : null;
|
|
561
|
+
const [text, html, subject, layout] = await Promise.all([
|
|
400
562
|
localePrefix ? readOptional(join(this.directory, `${localePrefix}.txt`)).then(
|
|
401
563
|
(v) => v ?? readOptional(join(this.directory, `${type}.txt`))
|
|
402
564
|
) : readOptional(join(this.directory, `${type}.txt`)),
|
|
@@ -405,27 +567,57 @@ var FSTemplateResolver = class {
|
|
|
405
567
|
) : readOptional(join(this.directory, `${type}.html`)),
|
|
406
568
|
localePrefix ? readOptional(join(this.directory, `${localePrefix}.subject.txt`)).then(
|
|
407
569
|
(v) => v ?? readOptional(join(this.directory, `${type}.subject.txt`))
|
|
408
|
-
) : readOptional(join(this.directory, `${type}.subject.txt`))
|
|
570
|
+
) : readOptional(join(this.directory, `${type}.subject.txt`)),
|
|
571
|
+
layoutPrefix ? readOptional(join(this.directory, `${layoutPrefix}.html`)).then(
|
|
572
|
+
(v) => v ?? readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
|
|
573
|
+
) : readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
|
|
409
574
|
]);
|
|
575
|
+
const renderedSubject = subject ? render(subject, data) : void 0;
|
|
410
576
|
return {
|
|
411
577
|
text: text ? render(text, data) : `${type}: ${JSON.stringify(data)}`,
|
|
412
|
-
...
|
|
413
|
-
...html ? { html:
|
|
578
|
+
...renderedSubject ? { subject: renderedSubject } : {},
|
|
579
|
+
...html ? { html: composeHtml(html, layout ?? void 0, renderedSubject, data) } : {}
|
|
414
580
|
};
|
|
415
581
|
}
|
|
416
582
|
};
|
|
417
583
|
|
|
584
|
+
// src/config-guard.ts
|
|
585
|
+
var MODULE = "@fonderie/courier";
|
|
586
|
+
function collectCourierConfigProblems(config, registeredChannels) {
|
|
587
|
+
const registered = new Set(registeredChannels);
|
|
588
|
+
const gaps = /* @__PURE__ */ new Map();
|
|
589
|
+
for (const [type, channels] of Object.entries(config.channels ?? {})) {
|
|
590
|
+
for (const channel of channels) {
|
|
591
|
+
if (!registered.has(channel)) {
|
|
592
|
+
const types = gaps.get(channel) ?? [];
|
|
593
|
+
types.push(type);
|
|
594
|
+
gaps.set(channel, types);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return [...gaps].map(([channel, types]) => ({
|
|
599
|
+
module: MODULE,
|
|
600
|
+
severity: "warning",
|
|
601
|
+
message: `${types.length} message type(s) route to the '${channel}' channel but no '${channel}' provider is registered \u2014 these will be silently dropped: ${types.join(", ")}. Configure \`config.${channel}\` (or register a channel).`
|
|
602
|
+
}));
|
|
603
|
+
}
|
|
604
|
+
function validateCourierConfig(config, registeredChannels) {
|
|
605
|
+
for (const problem of collectCourierConfigProblems(config, registeredChannels)) {
|
|
606
|
+
console.warn(`[courier] ${problem.message}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
418
610
|
// src/delivery.ts
|
|
419
611
|
var import_node_crypto = require("crypto");
|
|
420
612
|
async function handleSendGridDelivery(req, store, webhookSecret) {
|
|
421
613
|
if (webhookSecret) {
|
|
422
614
|
const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
|
|
423
615
|
const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
|
|
424
|
-
const
|
|
425
|
-
if (!verifySendGridSignature(webhookSecret, ts,
|
|
616
|
+
const body2 = await req.text();
|
|
617
|
+
if (!verifySendGridSignature(webhookSecret, ts, body2, sig)) {
|
|
426
618
|
return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
|
|
427
619
|
}
|
|
428
|
-
const events2 = parseJson(
|
|
620
|
+
const events2 = parseJson(body2);
|
|
429
621
|
if (!Array.isArray(events2)) return Response.json({ ok: true });
|
|
430
622
|
await processSendGridEvents(events2, store);
|
|
431
623
|
return Response.json({ ok: true });
|
|
@@ -435,9 +627,9 @@ async function handleSendGridDelivery(req, store, webhookSecret) {
|
|
|
435
627
|
await processSendGridEvents(events, store);
|
|
436
628
|
return Response.json({ ok: true });
|
|
437
629
|
}
|
|
438
|
-
function verifySendGridSignature(secret, timestamp,
|
|
630
|
+
function verifySendGridSignature(secret, timestamp, body2, signature) {
|
|
439
631
|
try {
|
|
440
|
-
const payload = timestamp +
|
|
632
|
+
const payload = timestamp + body2;
|
|
441
633
|
const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("base64");
|
|
442
634
|
const sigBuf = Buffer.from(signature, "base64");
|
|
443
635
|
const expBuf = Buffer.from(expected, "base64");
|
|
@@ -471,14 +663,14 @@ async function processSendGridEvents(events, store) {
|
|
|
471
663
|
}
|
|
472
664
|
}
|
|
473
665
|
async function handleMailgunDelivery(req, store, signingKey) {
|
|
474
|
-
const
|
|
666
|
+
const body2 = await req.json();
|
|
475
667
|
if (signingKey) {
|
|
476
|
-
const { signature } =
|
|
668
|
+
const { signature } = body2;
|
|
477
669
|
if (!signature || !verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
|
|
478
670
|
return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
|
|
479
671
|
}
|
|
480
672
|
}
|
|
481
|
-
const event =
|
|
673
|
+
const event = body2["event-data"];
|
|
482
674
|
if (event) {
|
|
483
675
|
await processMailgunEvent(event, store);
|
|
484
676
|
}
|
|
@@ -549,6 +741,150 @@ function parseJson(text) {
|
|
|
549
741
|
}
|
|
550
742
|
}
|
|
551
743
|
|
|
744
|
+
// src/templates/admin-routes.ts
|
|
745
|
+
var import_core = require("@fonderie/core");
|
|
746
|
+
var import_store2 = require("@fonderie/store");
|
|
747
|
+
|
|
748
|
+
// src/templates/admin.ts
|
|
749
|
+
var import_store = require("@fonderie/store");
|
|
750
|
+
var ENTRY_COLS = `type, locale, subject, html, text, active, version, updated_by AS "updatedBy", updated_at AS "updatedAt"`;
|
|
751
|
+
var TEMPLATE_RESOURCE = {
|
|
752
|
+
table: "fonderie_courier_templates",
|
|
753
|
+
revisions: "fonderie_courier_template_revisions",
|
|
754
|
+
channel: "fonderie_courier_templates_changed",
|
|
755
|
+
keyColumns: ["type", "locale"],
|
|
756
|
+
contentColumns: ["subject", "html", "text"],
|
|
757
|
+
metaColumns: ["active"],
|
|
758
|
+
returning: ENTRY_COLS
|
|
759
|
+
};
|
|
760
|
+
async function setTemplate(opts, store) {
|
|
761
|
+
const data = {
|
|
762
|
+
subject: opts.subject ?? null,
|
|
763
|
+
html: opts.html ?? null,
|
|
764
|
+
text: opts.text
|
|
765
|
+
};
|
|
766
|
+
if (opts.active !== void 0) data["active"] = opts.active;
|
|
767
|
+
return (0, import_store.versionedWrite)(TEMPLATE_RESOURCE, store, {
|
|
768
|
+
key: opts.type,
|
|
769
|
+
scope: opts.locale ?? null,
|
|
770
|
+
data,
|
|
771
|
+
...opts.ifVersion !== void 0 ? { ifVersion: opts.ifVersion } : {},
|
|
772
|
+
actor: opts.actor ?? null
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async function rollbackTemplate(opts, store) {
|
|
776
|
+
return (0, import_store.versionedRollback)(TEMPLATE_RESOURCE, store, {
|
|
777
|
+
key: opts.type,
|
|
778
|
+
scope: opts.locale ?? null,
|
|
779
|
+
toVersion: opts.toVersion,
|
|
780
|
+
actor: opts.actor ?? null
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
async function listTemplateRevisions(type, locale, store) {
|
|
784
|
+
return store.query(
|
|
785
|
+
`SELECT type, locale, subject, html, text, version, actor, created_at AS "createdAt"
|
|
786
|
+
FROM fonderie_courier_template_revisions
|
|
787
|
+
WHERE type = $1 AND locale IS NOT DISTINCT FROM $2
|
|
788
|
+
ORDER BY version DESC`,
|
|
789
|
+
[type, locale]
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
async function getTemplateEntry(type, locale, store) {
|
|
793
|
+
const [row] = await store.query(
|
|
794
|
+
`SELECT ${ENTRY_COLS} FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2`,
|
|
795
|
+
[type, locale]
|
|
796
|
+
);
|
|
797
|
+
return row ?? null;
|
|
798
|
+
}
|
|
799
|
+
async function listTemplateEntries(store) {
|
|
800
|
+
return store.query(
|
|
801
|
+
`SELECT ${ENTRY_COLS} FROM fonderie_courier_templates ORDER BY type, locale NULLS FIRST`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
async function deleteTemplate(type, locale, store) {
|
|
805
|
+
const rows = await store.query(
|
|
806
|
+
`DELETE FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2 RETURNING type`,
|
|
807
|
+
[type, locale]
|
|
808
|
+
);
|
|
809
|
+
return rows.length > 0;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/templates/admin-routes.ts
|
|
813
|
+
function guarded(adminToken, handler) {
|
|
814
|
+
return async (ctx, next) => {
|
|
815
|
+
const header = ctx.request.headers.get("authorization") ?? "";
|
|
816
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
817
|
+
if (!token || token !== adminToken) {
|
|
818
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token");
|
|
819
|
+
}
|
|
820
|
+
return handler(ctx, next);
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
var actorOf = (ctx) => ctx.request.headers.get("x-actor") || "admin-token";
|
|
824
|
+
var typeOf = (ctx) => ctx.meta.params?.["type"] ?? "";
|
|
825
|
+
var localeOf = (ctx) => new URL(ctx.request.url).searchParams.get("locale");
|
|
826
|
+
var body = (ctx) => ctx.meta["body"] ?? {};
|
|
827
|
+
function conflictOr(err) {
|
|
828
|
+
if (err instanceof import_store2.VersionConflictError) {
|
|
829
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.CONFLICT, "VERSION_CONFLICT", err.message, {
|
|
830
|
+
currentVersion: err.currentVersion
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
throw err;
|
|
834
|
+
}
|
|
835
|
+
function buildTemplateAdminRoutes(store, adminToken) {
|
|
836
|
+
const g = (h) => guarded(adminToken, h);
|
|
837
|
+
return [
|
|
838
|
+
["GET", "/admin/templates", g(async () => {
|
|
839
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATES_LISTED", "Templates", await listTemplateEntries(store));
|
|
840
|
+
})],
|
|
841
|
+
["GET", "/admin/templates/:type", g(async (ctx) => {
|
|
842
|
+
const row = await getTemplateEntry(typeOf(ctx), localeOf(ctx), store);
|
|
843
|
+
return row ? (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATE", "Template", row) : (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "NOT_FOUND", "No such template");
|
|
844
|
+
})],
|
|
845
|
+
["PUT", "/admin/templates/:type", g(async (ctx) => {
|
|
846
|
+
const b = body(ctx);
|
|
847
|
+
if (typeof b["text"] !== "string") {
|
|
848
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID", "body.text (string) is required");
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
const opts = {
|
|
852
|
+
type: typeOf(ctx),
|
|
853
|
+
text: b["text"],
|
|
854
|
+
locale: localeOf(ctx),
|
|
855
|
+
actor: actorOf(ctx)
|
|
856
|
+
};
|
|
857
|
+
if (typeof b["subject"] === "string") opts.subject = b["subject"];
|
|
858
|
+
if (typeof b["html"] === "string") opts.html = b["html"];
|
|
859
|
+
if (typeof b["active"] === "boolean") opts.active = b["active"];
|
|
860
|
+
if (typeof b["ifVersion"] === "number") opts.ifVersion = b["ifVersion"];
|
|
861
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATE_SET", "Template saved", await setTemplate(opts, store));
|
|
862
|
+
} catch (err) {
|
|
863
|
+
return conflictOr(err);
|
|
864
|
+
}
|
|
865
|
+
})],
|
|
866
|
+
["DELETE", "/admin/templates/:type", g(async (ctx) => {
|
|
867
|
+
const ok = await deleteTemplate(typeOf(ctx), localeOf(ctx), store);
|
|
868
|
+
return (0, import_core.setApiResponse)(ok ? import_core.HTTP.OK : import_core.HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such template");
|
|
869
|
+
})],
|
|
870
|
+
["GET", "/admin/templates/:type/revisions", g(async (ctx) => {
|
|
871
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "REVISIONS", "Template revisions", await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store));
|
|
872
|
+
})],
|
|
873
|
+
["POST", "/admin/templates/:type/rollback", g(async (ctx) => {
|
|
874
|
+
const b = body(ctx);
|
|
875
|
+
const toVersion = Number(b["toVersion"]);
|
|
876
|
+
if (!Number.isInteger(toVersion)) {
|
|
877
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
|
|
878
|
+
}
|
|
879
|
+
const row = await rollbackTemplate(
|
|
880
|
+
{ type: typeOf(ctx), locale: localeOf(ctx), toVersion, actor: actorOf(ctx) },
|
|
881
|
+
store
|
|
882
|
+
);
|
|
883
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
|
|
884
|
+
})]
|
|
885
|
+
];
|
|
886
|
+
}
|
|
887
|
+
|
|
552
888
|
// src/module.ts
|
|
553
889
|
var CourierModule = class {
|
|
554
890
|
constructor(config, store, bus) {
|
|
@@ -573,7 +909,12 @@ var CourierModule = class {
|
|
|
573
909
|
name = "@fonderie/courier";
|
|
574
910
|
deps = ["@fonderie/events"];
|
|
575
911
|
dispatcher;
|
|
912
|
+
// Report config problems for app.checkProductionReadiness() (data, not warn).
|
|
913
|
+
checkReadiness() {
|
|
914
|
+
return collectCourierConfigProblems(this.config, this.dispatcher.channelNames());
|
|
915
|
+
}
|
|
576
916
|
install(app) {
|
|
917
|
+
validateCourierConfig(this.config, this.dispatcher.channelNames());
|
|
577
918
|
const store = this.store;
|
|
578
919
|
const signingKeys = this.config.delivery?.signingKeys;
|
|
579
920
|
app.addRoute(
|
|
@@ -591,6 +932,14 @@ var CourierModule = class {
|
|
|
591
932
|
"/courier/delivery/mailtrap",
|
|
592
933
|
(ctx) => handleMailtrapDelivery(ctx.request, store)
|
|
593
934
|
);
|
|
935
|
+
if (this.config.adminToken) {
|
|
936
|
+
if (!store) {
|
|
937
|
+
throw new Error("[courier] adminToken requires @fonderie/store (db templates)");
|
|
938
|
+
}
|
|
939
|
+
for (const [method, path, handler] of buildTemplateAdminRoutes(store, this.config.adminToken)) {
|
|
940
|
+
app.addRoute(method, path, handler);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
594
943
|
}
|
|
595
944
|
};
|
|
596
945
|
function createTemplateResolver(source, config, store) {
|
|
@@ -619,8 +968,16 @@ var Channel = {
|
|
|
619
968
|
FSTemplateResolver,
|
|
620
969
|
PushChannel,
|
|
621
970
|
SmsChannel,
|
|
971
|
+
buildTemplateAdminRoutes,
|
|
972
|
+
deleteTemplate,
|
|
973
|
+
getTemplateEntry,
|
|
622
974
|
handleMailgunDelivery,
|
|
623
975
|
handleMailtrapDelivery,
|
|
624
|
-
handleSendGridDelivery
|
|
976
|
+
handleSendGridDelivery,
|
|
977
|
+
listTemplateEntries,
|
|
978
|
+
listTemplateRevisions,
|
|
979
|
+
rollbackTemplate,
|
|
980
|
+
setTemplate,
|
|
981
|
+
validateCourierConfig
|
|
625
982
|
});
|
|
626
983
|
//# sourceMappingURL=index.cjs.map
|