@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/dist/index.js
CHANGED
|
@@ -89,6 +89,11 @@ var Dispatcher = class {
|
|
|
89
89
|
this.channels.set(channel.name, channel);
|
|
90
90
|
return this;
|
|
91
91
|
}
|
|
92
|
+
// Names of the currently-registered channels — used by the boot-time config
|
|
93
|
+
// guard to detect message types routed to a channel with no provider.
|
|
94
|
+
channelNames() {
|
|
95
|
+
return [...this.channels.keys()];
|
|
96
|
+
}
|
|
92
97
|
async dispatch(message) {
|
|
93
98
|
const channelNames = this.config.channels[message.type];
|
|
94
99
|
if (!channelNames || channelNames.length === 0) {
|
|
@@ -165,8 +170,8 @@ var SmsChannel = class {
|
|
|
165
170
|
}
|
|
166
171
|
);
|
|
167
172
|
if (!res.ok) {
|
|
168
|
-
const
|
|
169
|
-
throw new Error(`[courier:sms] Twilio error ${res.status}: ${
|
|
173
|
+
const body2 = await res.text();
|
|
174
|
+
throw new Error(`[courier:sms] Twilio error ${res.status}: ${body2}`);
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
async sendViaVonage(to, text) {
|
|
@@ -186,8 +191,8 @@ var SmsChannel = class {
|
|
|
186
191
|
})
|
|
187
192
|
});
|
|
188
193
|
if (!res.ok) {
|
|
189
|
-
const
|
|
190
|
-
throw new Error(`[courier:sms] Vonage error ${res.status}: ${
|
|
194
|
+
const body2 = await res.text();
|
|
195
|
+
throw new Error(`[courier:sms] Vonage error ${res.status}: ${body2}`);
|
|
191
196
|
}
|
|
192
197
|
}
|
|
193
198
|
};
|
|
@@ -229,8 +234,8 @@ var PushChannel = class {
|
|
|
229
234
|
})
|
|
230
235
|
});
|
|
231
236
|
if (!res.ok) {
|
|
232
|
-
const
|
|
233
|
-
throw new Error(`[courier:push] FCM error ${res.status}: ${
|
|
237
|
+
const body2 = await res.text();
|
|
238
|
+
throw new Error(`[courier:push] FCM error ${res.status}: ${body2}`);
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
241
|
};
|
|
@@ -285,8 +290,8 @@ var EmailChannel = class {
|
|
|
285
290
|
})
|
|
286
291
|
});
|
|
287
292
|
if (!res.ok) {
|
|
288
|
-
const
|
|
289
|
-
throw new Error(`[courier:email] Resend error ${res.status}: ${
|
|
293
|
+
const body2 = await res.text();
|
|
294
|
+
throw new Error(`[courier:email] Resend error ${res.status}: ${body2}`);
|
|
290
295
|
}
|
|
291
296
|
}
|
|
292
297
|
async sendViaSMTP(to, template) {
|
|
@@ -303,13 +308,144 @@ var EmailChannel = class {
|
|
|
303
308
|
}
|
|
304
309
|
};
|
|
305
310
|
|
|
311
|
+
// src/templates/layout.ts
|
|
312
|
+
var EMAIL_THEME = {
|
|
313
|
+
brand: "Fonderie",
|
|
314
|
+
accent: "#171717",
|
|
315
|
+
// primary action (button) background — the product's primary
|
|
316
|
+
accentText: "#ffffff",
|
|
317
|
+
// text on the accent
|
|
318
|
+
brandAccent: "#171717",
|
|
319
|
+
// brand highlight (top rule) — monochrome near-black
|
|
320
|
+
link: "#009767",
|
|
321
|
+
// links — the accessible (darkened) brand teal
|
|
322
|
+
ink: "#171717",
|
|
323
|
+
// body copy
|
|
324
|
+
muted: "#5c5c5c",
|
|
325
|
+
// secondary copy
|
|
326
|
+
line: "#e0e0e0",
|
|
327
|
+
// hairline borders
|
|
328
|
+
canvas: "#fafafa",
|
|
329
|
+
// page background behind the card
|
|
330
|
+
card: "#ffffff"
|
|
331
|
+
// the card itself
|
|
332
|
+
};
|
|
333
|
+
var FONT_SANS = "Inter, 'Inter Fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif";
|
|
334
|
+
var FONT_MONO = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace";
|
|
335
|
+
var LAYOUT_CONTENT_SLOT = "{{content}}";
|
|
336
|
+
var DEFAULT_EMAIL_LAYOUT = `<!DOCTYPE html>
|
|
337
|
+
<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">
|
|
338
|
+
<head>
|
|
339
|
+
<meta charset="utf-8">
|
|
340
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
341
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
342
|
+
<meta name="color-scheme" content="light dark">
|
|
343
|
+
<meta name="supported-color-schemes" content="light dark">
|
|
344
|
+
<title>{{subject}}</title>
|
|
345
|
+
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
346
|
+
<style>
|
|
347
|
+
body { margin: 0; padding: 0; width: 100% !important; background: ${EMAIL_THEME.canvas}; }
|
|
348
|
+
.email-body { background: ${EMAIL_THEME.canvas}; }
|
|
349
|
+
.email-container { width: 100%; max-width: 560px; margin: 0 auto; }
|
|
350
|
+
.email-card {
|
|
351
|
+
background: ${EMAIL_THEME.card};
|
|
352
|
+
border: 1px solid ${EMAIL_THEME.line};
|
|
353
|
+
border-radius: 8px;
|
|
354
|
+
overflow: hidden;
|
|
355
|
+
}
|
|
356
|
+
.email-accent { height: 3px; background: ${EMAIL_THEME.brandAccent}; font-size: 0; line-height: 0; }
|
|
357
|
+
.email-header { padding: 28px 32px 0 32px; }
|
|
358
|
+
.email-brand { font: 800 19px/1 ${FONT_SANS}; color: ${EMAIL_THEME.ink}; letter-spacing: -0.03em; }
|
|
359
|
+
.email-content {
|
|
360
|
+
padding: 20px 32px 8px 32px;
|
|
361
|
+
font: 400 16px/1.7 ${FONT_SANS};
|
|
362
|
+
color: ${EMAIL_THEME.ink};
|
|
363
|
+
letter-spacing: -0.01em;
|
|
364
|
+
}
|
|
365
|
+
.email-content h1 { margin: 0 0 12px 0; font-size: 22px; line-height: 1.3; font-weight: 700; letter-spacing: -0.03em; }
|
|
366
|
+
.email-content p { margin: 0 0 16px 0; }
|
|
367
|
+
.email-content a { color: ${EMAIL_THEME.link}; }
|
|
368
|
+
.email-footer {
|
|
369
|
+
padding: 16px 32px 28px 32px;
|
|
370
|
+
font: 400 13px/1.6 ${FONT_SANS};
|
|
371
|
+
color: ${EMAIL_THEME.muted};
|
|
372
|
+
letter-spacing: -0.01em;
|
|
373
|
+
}
|
|
374
|
+
.btn {
|
|
375
|
+
display: inline-block;
|
|
376
|
+
background: ${EMAIL_THEME.accent};
|
|
377
|
+
color: ${EMAIL_THEME.accentText} !important;
|
|
378
|
+
text-decoration: none;
|
|
379
|
+
font: 600 16px/1 ${FONT_SANS};
|
|
380
|
+
letter-spacing: -0.01em;
|
|
381
|
+
padding: 13px 26px;
|
|
382
|
+
border-radius: 6px;
|
|
383
|
+
}
|
|
384
|
+
.pin-code {
|
|
385
|
+
display: inline-block;
|
|
386
|
+
font: 700 30px/1 ${FONT_MONO};
|
|
387
|
+
letter-spacing: 0.28em;
|
|
388
|
+
color: ${EMAIL_THEME.ink};
|
|
389
|
+
background: ${EMAIL_THEME.canvas};
|
|
390
|
+
border: 1px solid ${EMAIL_THEME.line};
|
|
391
|
+
border-radius: 6px;
|
|
392
|
+
padding: 14px 22px 14px 30px;
|
|
393
|
+
}
|
|
394
|
+
.muted { color: ${EMAIL_THEME.muted}; }
|
|
395
|
+
@media only screen and (max-width: 599px) {
|
|
396
|
+
.email-header, .email-content, .email-footer { padding-left: 22px !important; padding-right: 22px !important; }
|
|
397
|
+
.pin-code { font-size: 26px !important; letter-spacing: 0.2em !important; }
|
|
398
|
+
}
|
|
399
|
+
</style>
|
|
400
|
+
</head>
|
|
401
|
+
<body class="email-body">
|
|
402
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">{{preheader}}</div>
|
|
403
|
+
<table role="presentation" class="email-body" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${EMAIL_THEME.canvas};">
|
|
404
|
+
<tr>
|
|
405
|
+
<td align="center" style="padding: 32px 16px;">
|
|
406
|
+
<table role="presentation" class="email-container" width="560" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
|
|
407
|
+
<tr>
|
|
408
|
+
<td class="email-card" style="background:${EMAIL_THEME.card};border:1px solid ${EMAIL_THEME.line};border-radius:8px;">
|
|
409
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
410
|
+
<tr><td class="email-accent" style="height:3px;background:${EMAIL_THEME.brandAccent};font-size:0;line-height:0;"> </td></tr>
|
|
411
|
+
<tr><td class="email-header" style="padding:28px 32px 0 32px;">
|
|
412
|
+
<span class="email-brand" style="font-weight:800;font-size:19px;color:${EMAIL_THEME.ink};letter-spacing:-0.03em;">${EMAIL_THEME.brand}</span>
|
|
413
|
+
</td></tr>
|
|
414
|
+
<tr><td class="email-content" style="padding:20px 32px 8px 32px;color:${EMAIL_THEME.ink};">
|
|
415
|
+
${LAYOUT_CONTENT_SLOT}
|
|
416
|
+
</td></tr>
|
|
417
|
+
<tr><td class="email-footer" style="padding:16px 32px 28px 32px;color:${EMAIL_THEME.muted};font-size:13px;">
|
|
418
|
+
You're receiving this because someone used this address at ${EMAIL_THEME.brand}. If that wasn't you, you can ignore it.
|
|
419
|
+
</td></tr>
|
|
420
|
+
</table>
|
|
421
|
+
</td>
|
|
422
|
+
</tr>
|
|
423
|
+
</table>
|
|
424
|
+
</td>
|
|
425
|
+
</tr>
|
|
426
|
+
</table>
|
|
427
|
+
</body>
|
|
428
|
+
</html>`;
|
|
429
|
+
function wrapLayout(bodyHtml, layoutHtml = DEFAULT_EMAIL_LAYOUT) {
|
|
430
|
+
const trimmed = bodyHtml.trimStart().toLowerCase();
|
|
431
|
+
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
|
|
432
|
+
return bodyHtml;
|
|
433
|
+
}
|
|
434
|
+
return layoutHtml.replace(LAYOUT_CONTENT_SLOT, bodyHtml);
|
|
435
|
+
}
|
|
436
|
+
|
|
306
437
|
// src/templates/resolver.ts
|
|
438
|
+
var LAYOUT_TYPE = "_layout";
|
|
307
439
|
function render(template, data) {
|
|
308
440
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
309
441
|
const value = data[key];
|
|
310
442
|
return value !== void 0 && value !== null ? String(value) : "";
|
|
311
443
|
});
|
|
312
444
|
}
|
|
445
|
+
function composeHtml(bodyHtml, layoutHtml, subject, data) {
|
|
446
|
+
const wrapped = wrapLayout(bodyHtml, layoutHtml);
|
|
447
|
+
return render(wrapped, { subject: subject ?? "", preheader: "", ...data });
|
|
448
|
+
}
|
|
313
449
|
var DBTemplateResolver = class {
|
|
314
450
|
constructor(store) {
|
|
315
451
|
this.store = store;
|
|
@@ -317,22 +453,39 @@ var DBTemplateResolver = class {
|
|
|
317
453
|
store;
|
|
318
454
|
async resolve(type, data, locale) {
|
|
319
455
|
const [row] = await this.store.query(
|
|
456
|
+
// Serve the exact locale, else the neutral NULL default — never a
|
|
457
|
+
// sibling region (en-CA must not fall back to en-US); the WHERE
|
|
458
|
+
// excludes other locales so legal/jurisdictional copy can't bleed.
|
|
320
459
|
`SELECT subject, html, text
|
|
321
460
|
FROM fonderie_courier_templates
|
|
322
|
-
WHERE type = $1 AND active = true
|
|
323
|
-
ORDER BY (locale
|
|
461
|
+
WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
|
|
462
|
+
ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
|
|
324
463
|
LIMIT 1`,
|
|
325
464
|
[type, locale ?? null]
|
|
326
465
|
);
|
|
327
466
|
if (!row) {
|
|
328
467
|
return { text: `${type}: ${JSON.stringify(data)}` };
|
|
329
468
|
}
|
|
469
|
+
const subject = row.subject ? render(row.subject, data) : void 0;
|
|
470
|
+
const layoutHtml = row.html ? await this.layout(locale) : void 0;
|
|
330
471
|
return {
|
|
331
472
|
text: render(row.text, data),
|
|
332
|
-
...
|
|
333
|
-
...row.html ? { html:
|
|
473
|
+
...subject ? { subject } : {},
|
|
474
|
+
...row.html ? { html: composeHtml(row.html, layoutHtml, subject, data) } : {}
|
|
334
475
|
};
|
|
335
476
|
}
|
|
477
|
+
// Optional founder-supplied layout shell; undefined → built-in default.
|
|
478
|
+
async layout(locale) {
|
|
479
|
+
const [row] = await this.store.query(
|
|
480
|
+
`SELECT html
|
|
481
|
+
FROM fonderie_courier_templates
|
|
482
|
+
WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
|
|
483
|
+
ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
|
|
484
|
+
LIMIT 1`,
|
|
485
|
+
[LAYOUT_TYPE, locale ?? null]
|
|
486
|
+
);
|
|
487
|
+
return row?.html ?? void 0;
|
|
488
|
+
}
|
|
336
489
|
};
|
|
337
490
|
var FSTemplateResolver = class {
|
|
338
491
|
constructor(directory) {
|
|
@@ -350,7 +503,8 @@ var FSTemplateResolver = class {
|
|
|
350
503
|
}
|
|
351
504
|
};
|
|
352
505
|
const localePrefix = locale ? `${type}.${locale}` : null;
|
|
353
|
-
const
|
|
506
|
+
const layoutPrefix = locale ? `${LAYOUT_TYPE}.${locale}` : null;
|
|
507
|
+
const [text, html, subject, layout] = await Promise.all([
|
|
354
508
|
localePrefix ? readOptional(join(this.directory, `${localePrefix}.txt`)).then(
|
|
355
509
|
(v) => v ?? readOptional(join(this.directory, `${type}.txt`))
|
|
356
510
|
) : readOptional(join(this.directory, `${type}.txt`)),
|
|
@@ -359,27 +513,57 @@ var FSTemplateResolver = class {
|
|
|
359
513
|
) : readOptional(join(this.directory, `${type}.html`)),
|
|
360
514
|
localePrefix ? readOptional(join(this.directory, `${localePrefix}.subject.txt`)).then(
|
|
361
515
|
(v) => v ?? readOptional(join(this.directory, `${type}.subject.txt`))
|
|
362
|
-
) : readOptional(join(this.directory, `${type}.subject.txt`))
|
|
516
|
+
) : readOptional(join(this.directory, `${type}.subject.txt`)),
|
|
517
|
+
layoutPrefix ? readOptional(join(this.directory, `${layoutPrefix}.html`)).then(
|
|
518
|
+
(v) => v ?? readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
|
|
519
|
+
) : readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
|
|
363
520
|
]);
|
|
521
|
+
const renderedSubject = subject ? render(subject, data) : void 0;
|
|
364
522
|
return {
|
|
365
523
|
text: text ? render(text, data) : `${type}: ${JSON.stringify(data)}`,
|
|
366
|
-
...
|
|
367
|
-
...html ? { html:
|
|
524
|
+
...renderedSubject ? { subject: renderedSubject } : {},
|
|
525
|
+
...html ? { html: composeHtml(html, layout ?? void 0, renderedSubject, data) } : {}
|
|
368
526
|
};
|
|
369
527
|
}
|
|
370
528
|
};
|
|
371
529
|
|
|
530
|
+
// src/config-guard.ts
|
|
531
|
+
var MODULE = "@fonderie/courier";
|
|
532
|
+
function collectCourierConfigProblems(config, registeredChannels) {
|
|
533
|
+
const registered = new Set(registeredChannels);
|
|
534
|
+
const gaps = /* @__PURE__ */ new Map();
|
|
535
|
+
for (const [type, channels] of Object.entries(config.channels ?? {})) {
|
|
536
|
+
for (const channel of channels) {
|
|
537
|
+
if (!registered.has(channel)) {
|
|
538
|
+
const types = gaps.get(channel) ?? [];
|
|
539
|
+
types.push(type);
|
|
540
|
+
gaps.set(channel, types);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return [...gaps].map(([channel, types]) => ({
|
|
545
|
+
module: MODULE,
|
|
546
|
+
severity: "warning",
|
|
547
|
+
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).`
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
function validateCourierConfig(config, registeredChannels) {
|
|
551
|
+
for (const problem of collectCourierConfigProblems(config, registeredChannels)) {
|
|
552
|
+
console.warn(`[courier] ${problem.message}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
372
556
|
// src/delivery.ts
|
|
373
557
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
374
558
|
async function handleSendGridDelivery(req, store, webhookSecret) {
|
|
375
559
|
if (webhookSecret) {
|
|
376
560
|
const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
|
|
377
561
|
const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
|
|
378
|
-
const
|
|
379
|
-
if (!verifySendGridSignature(webhookSecret, ts,
|
|
562
|
+
const body2 = await req.text();
|
|
563
|
+
if (!verifySendGridSignature(webhookSecret, ts, body2, sig)) {
|
|
380
564
|
return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
|
|
381
565
|
}
|
|
382
|
-
const events2 = parseJson(
|
|
566
|
+
const events2 = parseJson(body2);
|
|
383
567
|
if (!Array.isArray(events2)) return Response.json({ ok: true });
|
|
384
568
|
await processSendGridEvents(events2, store);
|
|
385
569
|
return Response.json({ ok: true });
|
|
@@ -389,9 +573,9 @@ async function handleSendGridDelivery(req, store, webhookSecret) {
|
|
|
389
573
|
await processSendGridEvents(events, store);
|
|
390
574
|
return Response.json({ ok: true });
|
|
391
575
|
}
|
|
392
|
-
function verifySendGridSignature(secret, timestamp,
|
|
576
|
+
function verifySendGridSignature(secret, timestamp, body2, signature) {
|
|
393
577
|
try {
|
|
394
|
-
const payload = timestamp +
|
|
578
|
+
const payload = timestamp + body2;
|
|
395
579
|
const expected = createHmac("sha256", secret).update(payload).digest("base64");
|
|
396
580
|
const sigBuf = Buffer.from(signature, "base64");
|
|
397
581
|
const expBuf = Buffer.from(expected, "base64");
|
|
@@ -425,14 +609,14 @@ async function processSendGridEvents(events, store) {
|
|
|
425
609
|
}
|
|
426
610
|
}
|
|
427
611
|
async function handleMailgunDelivery(req, store, signingKey) {
|
|
428
|
-
const
|
|
612
|
+
const body2 = await req.json();
|
|
429
613
|
if (signingKey) {
|
|
430
|
-
const { signature } =
|
|
614
|
+
const { signature } = body2;
|
|
431
615
|
if (!signature || !verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
|
|
432
616
|
return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
|
|
433
617
|
}
|
|
434
618
|
}
|
|
435
|
-
const event =
|
|
619
|
+
const event = body2["event-data"];
|
|
436
620
|
if (event) {
|
|
437
621
|
await processMailgunEvent(event, store);
|
|
438
622
|
}
|
|
@@ -503,6 +687,150 @@ function parseJson(text) {
|
|
|
503
687
|
}
|
|
504
688
|
}
|
|
505
689
|
|
|
690
|
+
// src/templates/admin-routes.ts
|
|
691
|
+
import { setApiResponse, HTTP } from "@fonderie/core";
|
|
692
|
+
import { VersionConflictError } from "@fonderie/store";
|
|
693
|
+
|
|
694
|
+
// src/templates/admin.ts
|
|
695
|
+
import { versionedWrite, versionedRollback } from "@fonderie/store";
|
|
696
|
+
var ENTRY_COLS = `type, locale, subject, html, text, active, version, updated_by AS "updatedBy", updated_at AS "updatedAt"`;
|
|
697
|
+
var TEMPLATE_RESOURCE = {
|
|
698
|
+
table: "fonderie_courier_templates",
|
|
699
|
+
revisions: "fonderie_courier_template_revisions",
|
|
700
|
+
channel: "fonderie_courier_templates_changed",
|
|
701
|
+
keyColumns: ["type", "locale"],
|
|
702
|
+
contentColumns: ["subject", "html", "text"],
|
|
703
|
+
metaColumns: ["active"],
|
|
704
|
+
returning: ENTRY_COLS
|
|
705
|
+
};
|
|
706
|
+
async function setTemplate(opts, store) {
|
|
707
|
+
const data = {
|
|
708
|
+
subject: opts.subject ?? null,
|
|
709
|
+
html: opts.html ?? null,
|
|
710
|
+
text: opts.text
|
|
711
|
+
};
|
|
712
|
+
if (opts.active !== void 0) data["active"] = opts.active;
|
|
713
|
+
return versionedWrite(TEMPLATE_RESOURCE, store, {
|
|
714
|
+
key: opts.type,
|
|
715
|
+
scope: opts.locale ?? null,
|
|
716
|
+
data,
|
|
717
|
+
...opts.ifVersion !== void 0 ? { ifVersion: opts.ifVersion } : {},
|
|
718
|
+
actor: opts.actor ?? null
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
async function rollbackTemplate(opts, store) {
|
|
722
|
+
return versionedRollback(TEMPLATE_RESOURCE, store, {
|
|
723
|
+
key: opts.type,
|
|
724
|
+
scope: opts.locale ?? null,
|
|
725
|
+
toVersion: opts.toVersion,
|
|
726
|
+
actor: opts.actor ?? null
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
async function listTemplateRevisions(type, locale, store) {
|
|
730
|
+
return store.query(
|
|
731
|
+
`SELECT type, locale, subject, html, text, version, actor, created_at AS "createdAt"
|
|
732
|
+
FROM fonderie_courier_template_revisions
|
|
733
|
+
WHERE type = $1 AND locale IS NOT DISTINCT FROM $2
|
|
734
|
+
ORDER BY version DESC`,
|
|
735
|
+
[type, locale]
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
async function getTemplateEntry(type, locale, store) {
|
|
739
|
+
const [row] = await store.query(
|
|
740
|
+
`SELECT ${ENTRY_COLS} FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2`,
|
|
741
|
+
[type, locale]
|
|
742
|
+
);
|
|
743
|
+
return row ?? null;
|
|
744
|
+
}
|
|
745
|
+
async function listTemplateEntries(store) {
|
|
746
|
+
return store.query(
|
|
747
|
+
`SELECT ${ENTRY_COLS} FROM fonderie_courier_templates ORDER BY type, locale NULLS FIRST`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
async function deleteTemplate(type, locale, store) {
|
|
751
|
+
const rows = await store.query(
|
|
752
|
+
`DELETE FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2 RETURNING type`,
|
|
753
|
+
[type, locale]
|
|
754
|
+
);
|
|
755
|
+
return rows.length > 0;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// src/templates/admin-routes.ts
|
|
759
|
+
function guarded(adminToken, handler) {
|
|
760
|
+
return async (ctx, next) => {
|
|
761
|
+
const header = ctx.request.headers.get("authorization") ?? "";
|
|
762
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
763
|
+
if (!token || token !== adminToken) {
|
|
764
|
+
return setApiResponse(HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token");
|
|
765
|
+
}
|
|
766
|
+
return handler(ctx, next);
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
var actorOf = (ctx) => ctx.request.headers.get("x-actor") || "admin-token";
|
|
770
|
+
var typeOf = (ctx) => ctx.meta.params?.["type"] ?? "";
|
|
771
|
+
var localeOf = (ctx) => new URL(ctx.request.url).searchParams.get("locale");
|
|
772
|
+
var body = (ctx) => ctx.meta["body"] ?? {};
|
|
773
|
+
function conflictOr(err) {
|
|
774
|
+
if (err instanceof VersionConflictError) {
|
|
775
|
+
return setApiResponse(HTTP.CONFLICT, "VERSION_CONFLICT", err.message, {
|
|
776
|
+
currentVersion: err.currentVersion
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
throw err;
|
|
780
|
+
}
|
|
781
|
+
function buildTemplateAdminRoutes(store, adminToken) {
|
|
782
|
+
const g = (h) => guarded(adminToken, h);
|
|
783
|
+
return [
|
|
784
|
+
["GET", "/admin/templates", g(async () => {
|
|
785
|
+
return setApiResponse(HTTP.OK, "TEMPLATES_LISTED", "Templates", await listTemplateEntries(store));
|
|
786
|
+
})],
|
|
787
|
+
["GET", "/admin/templates/:type", g(async (ctx) => {
|
|
788
|
+
const row = await getTemplateEntry(typeOf(ctx), localeOf(ctx), store);
|
|
789
|
+
return row ? setApiResponse(HTTP.OK, "TEMPLATE", "Template", row) : setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "No such template");
|
|
790
|
+
})],
|
|
791
|
+
["PUT", "/admin/templates/:type", g(async (ctx) => {
|
|
792
|
+
const b = body(ctx);
|
|
793
|
+
if (typeof b["text"] !== "string") {
|
|
794
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.text (string) is required");
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
const opts = {
|
|
798
|
+
type: typeOf(ctx),
|
|
799
|
+
text: b["text"],
|
|
800
|
+
locale: localeOf(ctx),
|
|
801
|
+
actor: actorOf(ctx)
|
|
802
|
+
};
|
|
803
|
+
if (typeof b["subject"] === "string") opts.subject = b["subject"];
|
|
804
|
+
if (typeof b["html"] === "string") opts.html = b["html"];
|
|
805
|
+
if (typeof b["active"] === "boolean") opts.active = b["active"];
|
|
806
|
+
if (typeof b["ifVersion"] === "number") opts.ifVersion = b["ifVersion"];
|
|
807
|
+
return setApiResponse(HTTP.OK, "TEMPLATE_SET", "Template saved", await setTemplate(opts, store));
|
|
808
|
+
} catch (err) {
|
|
809
|
+
return conflictOr(err);
|
|
810
|
+
}
|
|
811
|
+
})],
|
|
812
|
+
["DELETE", "/admin/templates/:type", g(async (ctx) => {
|
|
813
|
+
const ok = await deleteTemplate(typeOf(ctx), localeOf(ctx), store);
|
|
814
|
+
return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such template");
|
|
815
|
+
})],
|
|
816
|
+
["GET", "/admin/templates/:type/revisions", g(async (ctx) => {
|
|
817
|
+
return setApiResponse(HTTP.OK, "REVISIONS", "Template revisions", await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store));
|
|
818
|
+
})],
|
|
819
|
+
["POST", "/admin/templates/:type/rollback", g(async (ctx) => {
|
|
820
|
+
const b = body(ctx);
|
|
821
|
+
const toVersion = Number(b["toVersion"]);
|
|
822
|
+
if (!Number.isInteger(toVersion)) {
|
|
823
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
|
|
824
|
+
}
|
|
825
|
+
const row = await rollbackTemplate(
|
|
826
|
+
{ type: typeOf(ctx), locale: localeOf(ctx), toVersion, actor: actorOf(ctx) },
|
|
827
|
+
store
|
|
828
|
+
);
|
|
829
|
+
return setApiResponse(HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
|
|
830
|
+
})]
|
|
831
|
+
];
|
|
832
|
+
}
|
|
833
|
+
|
|
506
834
|
// src/module.ts
|
|
507
835
|
var CourierModule = class {
|
|
508
836
|
constructor(config, store, bus) {
|
|
@@ -527,7 +855,12 @@ var CourierModule = class {
|
|
|
527
855
|
name = "@fonderie/courier";
|
|
528
856
|
deps = ["@fonderie/events"];
|
|
529
857
|
dispatcher;
|
|
858
|
+
// Report config problems for app.checkProductionReadiness() (data, not warn).
|
|
859
|
+
checkReadiness() {
|
|
860
|
+
return collectCourierConfigProblems(this.config, this.dispatcher.channelNames());
|
|
861
|
+
}
|
|
530
862
|
install(app) {
|
|
863
|
+
validateCourierConfig(this.config, this.dispatcher.channelNames());
|
|
531
864
|
const store = this.store;
|
|
532
865
|
const signingKeys = this.config.delivery?.signingKeys;
|
|
533
866
|
app.addRoute(
|
|
@@ -545,6 +878,14 @@ var CourierModule = class {
|
|
|
545
878
|
"/courier/delivery/mailtrap",
|
|
546
879
|
(ctx) => handleMailtrapDelivery(ctx.request, store)
|
|
547
880
|
);
|
|
881
|
+
if (this.config.adminToken) {
|
|
882
|
+
if (!store) {
|
|
883
|
+
throw new Error("[courier] adminToken requires @fonderie/store (db templates)");
|
|
884
|
+
}
|
|
885
|
+
for (const [method, path, handler] of buildTemplateAdminRoutes(store, this.config.adminToken)) {
|
|
886
|
+
app.addRoute(method, path, handler);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
548
889
|
}
|
|
549
890
|
};
|
|
550
891
|
function createTemplateResolver(source, config, store) {
|
|
@@ -572,8 +913,16 @@ export {
|
|
|
572
913
|
FSTemplateResolver,
|
|
573
914
|
PushChannel,
|
|
574
915
|
SmsChannel,
|
|
916
|
+
buildTemplateAdminRoutes,
|
|
917
|
+
deleteTemplate,
|
|
918
|
+
getTemplateEntry,
|
|
575
919
|
handleMailgunDelivery,
|
|
576
920
|
handleMailtrapDelivery,
|
|
577
|
-
handleSendGridDelivery
|
|
921
|
+
handleSendGridDelivery,
|
|
922
|
+
listTemplateEntries,
|
|
923
|
+
listTemplateRevisions,
|
|
924
|
+
rollbackTemplate,
|
|
925
|
+
setTemplate,
|
|
926
|
+
validateCourierConfig
|
|
578
927
|
};
|
|
579
928
|
//# sourceMappingURL=index.js.map
|