@suphark/notify 0.7.0 → 0.10.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/README.md +86 -0
- package/dist/{chunk-2RXN5HXT.js → chunk-CWZP54ZN.js} +83 -11
- package/dist/chunk-NNMV7FZ3.js +284 -0
- package/dist/index.d.ts +65 -5
- package/dist/index.js +64 -14
- package/dist/next.js +2 -1
- package/dist/{notify-D4auDRfk.d.ts → notify-D-PhiDzT.d.ts} +72 -3
- package/dist/template.d.ts +96 -0
- package/dist/template.js +26 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -41,6 +41,81 @@ await listProviders();
|
|
|
41
41
|
await sendMail({ to, subject, html, prefer: "company-smtp" }); // ลองตัวนี้ก่อน ไม่ได้ค่อย failover ตามลำดับปกติ
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
### เทมเพลตอีเมล: `sendMail({ template, data })` (0.9.0)
|
|
45
|
+
|
|
46
|
+
เก็บเทมเพลตไว้ที่ Pi Notify (แยกต่อแอป) — แก้ข้อความได้โดยไม่ต้อง deploy แอป และทุกแอปได้ layout เดียวกัน
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { saveTemplate, sendMail } from "@suphark/notify";
|
|
50
|
+
|
|
51
|
+
// สร้าง/แก้จากแอป (หรือแก้ที่หน้า "เทมเพลตอีเมล" ในคอนโซลของ Pi Notify)
|
|
52
|
+
await saveTemplate("po-approved", {
|
|
53
|
+
name: "แจ้ง PO อนุมัติ",
|
|
54
|
+
subject: "PO {{po.no}} อนุมัติแล้ว",
|
|
55
|
+
html: `<p>เรียน {{name}}</p>
|
|
56
|
+
{{#each items}}<p>{{@number}}. {{name}} x{{qty}}</p>{{/each}}
|
|
57
|
+
{{#if note}}<p>หมายเหตุ: {{note}}</p>{{/if}}`,
|
|
58
|
+
variables: [
|
|
59
|
+
{ name: "po.no", label: "เลขที่ PO", sample: "PO-001", required: true },
|
|
60
|
+
{ name: "items", sample: '[{"name":"ปากกา","qty":2}]' },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ส่ง — hub render หัวเรื่อง/HTML/ข้อความล้วนให้ (ใช้คู่ deliverWithin / idempotencyKey ได้)
|
|
65
|
+
await sendMail({ to, template: "po-approved", data: { name, po: { no: "PO-9" }, items } });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- ไวยากรณ์: `{{name}}` `{{a.b}}` (HTML ถูก escape เสมอ) · `{{#if x}}…{{else}}…{{/if}}` · `{{#each items}}…{{/each}}` (ข้างในใช้ฟิลด์ของรายการตรง ๆ, `{{this}}`, `{{@index}}`, `{{@number}}`)
|
|
69
|
+
- HTML ที่ไม่มี `<html>` จะถูกครอบด้วย layout มาตรฐาน (กว้าง 600px + "ส่งจาก <ชื่อแอป>") · ไม่ใส่ `text` = สร้างจาก HTML
|
|
70
|
+
- ตัวแปร `required` ที่ไม่ได้ส่งมา → `MailError` invalid (422) ไม่ส่งอีเมล · ไม่พบเทมเพลต/ปิดอยู่ → 404
|
|
71
|
+
- จัดการ: `listTemplates()` / `getTemplate(key)` / `saveTemplate(key, input)` / `deleteTemplate(key)` / `previewTemplate(key, { data })`
|
|
72
|
+
- render เองฝั่งแอป (เช่นหน้าดูตัวอย่าง): `import { renderEmailTemplate } from "@suphark/notify/template"` — ตัวเดียวกับที่ hub ใช้ ไม่พึ่ง node
|
|
73
|
+
- `template` ส่งได้เฉพาะผ่าน hub — provider ในเครื่องไม่ถูกใช้ (ไม่มีทางสำรองเมื่อ hub ล่ม) อีเมลสำคัญที่ต้องมีสำรองให้ render เองแล้วส่ง `html`
|
|
74
|
+
|
|
75
|
+
### อีเมลล็อกอินมาตรฐาน + ข้อความสำรองเมื่อ Pi Notify ล่ม (0.10.0)
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
await sendMail({
|
|
79
|
+
to: user.email,
|
|
80
|
+
template: "auth.magic-link", // หรือ auth.reset-password / auth.verify-email
|
|
81
|
+
data: { url: magicLinkUrl, expiresIn: "15 นาที" }, // appName ไม่ต้องส่ง — hub เติมชื่อแอปให้
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- ใช้ได้ทุกแอปทันทีโดยไม่ต้องสร้างเทมเพลต (ฉบับสวยอยู่ที่ Pi Notify) — อยากปรับข้อความเฉพาะแอป สร้างเทมเพลตชื่อเดียวกันทับ (คอนโซล: แอป → เทมเพลตอีเมล → "ปรับสำหรับแอป")
|
|
86
|
+
- **ติดต่อ Pi Notify ไม่ได้** (ล่ม/เครือข่าย/ไม่ตอบ) และแอปตั้ง provider ในเครื่องไว้ (SMTP/Resend) → SDK ส่งข้อความพื้นฐาน (หัวเรื่อง + ลิงก์ + อายุลิงก์) ผ่าน provider ในเครื่องแทน ผล `fallback: true` — ผู้ใช้ยังล็อกอินได้
|
|
87
|
+
- Pi Notify ตอบว่าข้อมูลผิด (เช่นไม่ส่ง `url`) → โยน error ไม่ส่งข้อความสำรอง
|
|
88
|
+
- เทมเพลตของแอปเองใส่ข้อความสำรองได้: `sendMail({ template: "po-approved", data, fallback: { subject, text, html? } })`
|
|
89
|
+
- ข้อความที่ส่งทางสำรองไม่มีใน log ของ Pi Notify (ดูจาก `onEvent` type `"fallback"`)
|
|
90
|
+
|
|
91
|
+
### อีเมลที่ต้องถึงแน่: `deliverWithin` + `idempotencyKey` กับ `sendMail` (0.8.0)
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { getRequestStatus, sendMail } from "@suphark/notify";
|
|
95
|
+
|
|
96
|
+
const key = `memo:${memo.id}:final-pdf`; // ผูกกับเหตุการณ์ ไม่สุ่มใหม่ทุกครั้ง
|
|
97
|
+
const r = await sendMail({
|
|
98
|
+
to: approvers,
|
|
99
|
+
subject: `Memo ${memo.no} ฉบับสุดท้าย`,
|
|
100
|
+
html,
|
|
101
|
+
attachments: [{ filename: "memo.pdf", content: pdfBuffer }],
|
|
102
|
+
deliverWithin: 6 * 3600, // ส่งไม่สำเร็จชั่วคราว → hub เก็บเข้าคิวส่งซ้ำเองภายใน 6 ชม.
|
|
103
|
+
idempotencyKey: key, // เรียกซ้ำ (cron/กดซ้ำ) ด้วยเนื้อหาเดิม = ได้ผลเดิม ไม่ส่งอีเมลซ้ำ
|
|
104
|
+
});
|
|
105
|
+
r.queued; // true = hub รับไว้ส่งซ้ำแล้ว (r.id = deliveryId) — แอปไม่ต้องมีคิวของตัวเอง
|
|
106
|
+
|
|
107
|
+
// หน้าแอปแสดงสถานะ — ไม่ต้องส่งเนื้อหา/ไฟล์แนบซ้ำ
|
|
108
|
+
const s = await getRequestStatus(key); // null = ไม่รู้จัก key (หรือเกิน 7 วัน)
|
|
109
|
+
s?.status; // "queued" | "sent" | "failed" | "partial" | "processing"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- ใช้ได้เฉพาะเมื่อส่งผ่าน hub (`PI_NOTIFY_KEY`) — provider ในเครื่องไม่สนสองค่านี้
|
|
113
|
+
- เข้าคิวเฉพาะความผิดพลาดชั่วคราว (เครือข่าย, 5xx, rate limit, provider ล้มทุกตัว) — ข้อความผิด/ที่อยู่ผิดโยน error ทันทีเหมือนเดิม
|
|
114
|
+
- `queued` นับเป็นสำเร็จฝั่งแอป: mailer **ไม่** failover ไป provider สำรองในเครื่อง (กันอีเมลซ้ำ)
|
|
115
|
+
- เนื้ออีเมลและไฟล์แนบเก็บใน hub เฉพาะระหว่างรอส่งซ้ำ ส่งได้/เลิกส่งแล้วลบทิ้ง เหลือแค่สถานะ
|
|
116
|
+
- ขนาดคำขอทั้งหมด (รวมไฟล์แนบ base64) ต้องไม่เกิน ~4.5MB (เพดานของ Vercel) → ไฟล์แนบดิบรวมราว 3MB
|
|
117
|
+
- `getRequestStatus(key)` ใช้กับ `notify()` / `notifyTopic()` ที่ส่ง `idempotencyKey` ได้ด้วย
|
|
118
|
+
|
|
44
119
|
### สำรองเผื่อ hub ล่ม (ไม่บังคับ)
|
|
45
120
|
|
|
46
121
|
ใส่ provider local ต่อท้ายได้ — hub มาก่อนเสมอ ถ้า hub ตอบไม่ได้ SDK จะ failover ไปตัวถัดไปเอง:
|
|
@@ -428,6 +503,17 @@ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ b
|
|
|
428
503
|
|
|
429
504
|
## Changelog
|
|
430
505
|
|
|
506
|
+
- 0.10.0 — **อีเมลล็อกอินมาตรฐาน + ข้อความสำรอง** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
|
|
507
|
+
- เทมเพลตมาตรฐาน `auth.magic-link` / `auth.reset-password` / `auth.verify-email` ใช้ได้ทุกแอปโดยไม่ต้องสร้าง (สร้างชื่อเดียวกันเพื่อปรับ)
|
|
508
|
+
- ติดต่อ hub ไม่ได้ → ส่งข้อความพื้นฐานผ่าน provider ในเครื่อง (`SendResult.fallback`, event `"fallback"`) · `Message.fallback` สำหรับเทมเพลตของแอปเอง
|
|
509
|
+
- `listTemplates()` / `getTemplate()` คืนเทมเพลตมาตรฐานด้วย (`builtin: true`) · hub เติม `appName` ให้ทุกเทมเพลต
|
|
510
|
+
- 0.9.0 — **เทมเพลตอีเมลที่ hub** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
|
|
511
|
+
- `sendMail({ to, template, data })` — hub render เทมเพลตของแอป (`subject` ไม่บังคับเมื่อใช้ template)
|
|
512
|
+
- `listTemplates` / `getTemplate` / `saveTemplate` / `deleteTemplate` / `previewTemplate` — จัดการเทมเพลตจากแอป
|
|
513
|
+
- `@suphark/notify/template`: `renderEmailTemplate`, `renderTemplate`, `sampleData`, `templateVariableNames`, `TemplateError`
|
|
514
|
+
- 0.8.0 — **อีเมลส่งซ้ำผ่าน hub + ถามสถานะ** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
|
|
515
|
+
- `sendMail({ …, deliverWithin, idempotencyKey })` — hub เก็บเข้าคิวส่งซ้ำเมื่อล้มชั่วคราว (`SendResult.queued`) และกันส่งซ้ำด้วย key
|
|
516
|
+
- `getRequestStatus(key)` — สถานะล่าสุดของคำขอ sendMail / notify / notifyTopic ที่ส่ง key (`GET /api/v1/requests/:key`)
|
|
431
517
|
- 0.7.0 — **LINE + ส่งเข้ากลุ่มด้วย topic** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
|
|
432
518
|
- `createLink(email, "line")` / `getChannelStatus(email, "line")` / `notify({ channels: ["line"] })` — ช่องทาง LINE รายคน
|
|
433
519
|
- `notifyTopic({ topic, title, body, … })` + `notifyTopicSafe()` + `notifyTopicLater()` (`/next`) — ส่งเข้าทุกกลุ่ม LINE/Telegram ที่ admin ผูกกับ topic ของแอป
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
builtinFallback
|
|
3
|
+
} from "./chunk-NNMV7FZ3.js";
|
|
4
|
+
|
|
1
5
|
// src/address.ts
|
|
2
6
|
var NAMED_RE = /^\s*(?:"?([^"<]*?)"?\s*)?<([^<>\s]+@[^<>\s]+)>\s*$/;
|
|
3
7
|
var EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
|
|
@@ -483,17 +487,24 @@ function createHubProvider(opts) {
|
|
|
483
487
|
cc: msg.cc.length ? msg.cc : void 0,
|
|
484
488
|
bcc: msg.bcc.length ? msg.bcc : void 0,
|
|
485
489
|
replyTo: msg.replyTo.length ? msg.replyTo : void 0,
|
|
486
|
-
subject: msg.subject,
|
|
490
|
+
subject: msg.subject || (msg.template ? void 0 : msg.subject),
|
|
487
491
|
html: msg.html,
|
|
488
492
|
text: msg.text,
|
|
489
493
|
attachments: msg.attachments.length ? msg.attachments : void 0,
|
|
490
494
|
headers: Object.keys(msg.headers).length ? msg.headers : void 0,
|
|
491
495
|
tags: Object.keys(msg.tags).length ? msg.tags : void 0,
|
|
492
|
-
prefer: msg.prefer
|
|
496
|
+
prefer: msg.prefer,
|
|
497
|
+
deliverWithin: msg.deliverWithin,
|
|
498
|
+
template: msg.template,
|
|
499
|
+
data: msg.template ? msg.data ?? {} : void 0
|
|
493
500
|
};
|
|
494
501
|
const res = await doFetch(url, {
|
|
495
502
|
method: "POST",
|
|
496
|
-
headers: {
|
|
503
|
+
headers: {
|
|
504
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
505
|
+
"Content-Type": "application/json",
|
|
506
|
+
...msg.idempotencyKey ? { "Idempotency-Key": msg.idempotencyKey } : {}
|
|
507
|
+
},
|
|
497
508
|
body: JSON.stringify(body),
|
|
498
509
|
// mailer หมดเวลา → abort คำขอนี้ทิ้ง (Pi Notify อาจส่งไปแล้ว — แอปที่มีตัวสำรองในเครื่องอาจได้เมลซ้ำ ยอมรับได้)
|
|
499
510
|
signal: msg.signal
|
|
@@ -510,7 +521,9 @@ function createHubProvider(opts) {
|
|
|
510
521
|
retryAfterSec: retryAfterOf(res, data)
|
|
511
522
|
});
|
|
512
523
|
}
|
|
513
|
-
|
|
524
|
+
const ok = data;
|
|
525
|
+
if (ok.status === "queued") return { id: ok.deliveryId, queued: true };
|
|
526
|
+
return { id: ok.id };
|
|
514
527
|
},
|
|
515
528
|
async listProviders() {
|
|
516
529
|
const res = await doFetch(url.replace(/\/send$/, "/providers"), {
|
|
@@ -798,6 +811,9 @@ function debugLogger(e) {
|
|
|
798
811
|
`${tag} ${e.provider} cooldown [${e.kind}] until ${new Date(e.untilMs).toISOString()} (failures=${e.failures})`
|
|
799
812
|
);
|
|
800
813
|
break;
|
|
814
|
+
case "fallback":
|
|
815
|
+
console.warn(`${tag} hub unreachable \u2192 sent fallback text for template ${e.template}`);
|
|
816
|
+
break;
|
|
801
817
|
case "recovered":
|
|
802
818
|
console.log(`${tag} ${e.provider} recovered`);
|
|
803
819
|
break;
|
|
@@ -851,7 +867,7 @@ function createMailer(options = {}) {
|
|
|
851
867
|
cc: parseAddressList(m.cc),
|
|
852
868
|
bcc: parseAddressList(m.bcc),
|
|
853
869
|
replyTo: parseAddressList(m.replyTo),
|
|
854
|
-
subject: m.subject,
|
|
870
|
+
subject: m.subject ?? "",
|
|
855
871
|
html: m.html,
|
|
856
872
|
text: m.text,
|
|
857
873
|
attachments: (m.attachments ?? []).map((a) => ({
|
|
@@ -861,7 +877,11 @@ function createMailer(options = {}) {
|
|
|
861
877
|
})),
|
|
862
878
|
headers: m.headers ?? {},
|
|
863
879
|
tags: m.tags ?? {},
|
|
864
|
-
prefer: m.prefer
|
|
880
|
+
prefer: m.prefer,
|
|
881
|
+
deliverWithin: m.deliverWithin,
|
|
882
|
+
idempotencyKey: m.idempotencyKey,
|
|
883
|
+
template: m.template,
|
|
884
|
+
data: m.data
|
|
865
885
|
};
|
|
866
886
|
} catch (err) {
|
|
867
887
|
throw new MailError(err.message, { kind: "invalid", cause: err });
|
|
@@ -900,12 +920,49 @@ function createMailer(options = {}) {
|
|
|
900
920
|
return pick;
|
|
901
921
|
}
|
|
902
922
|
async function send(message) {
|
|
923
|
+
if (!message?.template) return sendVia(message, "all");
|
|
924
|
+
const fallback = message.fallback ?? builtinFallback(message.template, message.data);
|
|
925
|
+
const hasHub = entries.some((e) => e.provider.type === "hub");
|
|
926
|
+
const hasLocal = entries.some((e) => e.provider.type !== "hub" && !e.disabled);
|
|
927
|
+
const asFallback = (fb) => ({
|
|
928
|
+
...message,
|
|
929
|
+
template: void 0,
|
|
930
|
+
data: void 0,
|
|
931
|
+
fallback: void 0,
|
|
932
|
+
subject: fb.subject,
|
|
933
|
+
text: fb.text,
|
|
934
|
+
html: fb.html
|
|
935
|
+
});
|
|
936
|
+
if (!hasHub) {
|
|
937
|
+
if (fallback && hasLocal)
|
|
938
|
+
return { ...await sendVia(asFallback(fallback), "local"), fallback: true };
|
|
939
|
+
throw new MailError(
|
|
940
|
+
`template "${message.template}" \u0E15\u0E49\u0E2D\u0E07\u0E2A\u0E48\u0E07\u0E1C\u0E48\u0E32\u0E19 Pi Notify hub \u2014 \u0E15\u0E31\u0E49\u0E07 PI_NOTIFY_KEY`,
|
|
941
|
+
{ kind: "invalid" }
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
return await sendVia(message, "hub");
|
|
946
|
+
} catch (err) {
|
|
947
|
+
const attempts = err instanceof AllProvidersFailedError ? err.attempts : [];
|
|
948
|
+
const invalid = err instanceof MailError && err.kind === "invalid" && attempts.length === 0 || attempts.some((a) => a.kind === "invalid");
|
|
949
|
+
if (!fallback || !hasLocal || invalid) throw err;
|
|
950
|
+
emit({ type: "fallback", template: message.template });
|
|
951
|
+
const r = await sendVia(asFallback(fallback), "local");
|
|
952
|
+
return { ...r, failedOver: true, fallback: true, attempts: [...attempts, ...r.attempts] };
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async function sendVia(message, via) {
|
|
903
956
|
if (!message?.to || Array.isArray(message.to) && message.to.length === 0)
|
|
904
957
|
throw new MailError("Message has no recipients (to)", { kind: "invalid" });
|
|
905
|
-
if (!message.
|
|
906
|
-
|
|
907
|
-
|
|
958
|
+
if (!message.template) {
|
|
959
|
+
if (!message.subject) throw new MailError("Message has no subject", { kind: "invalid" });
|
|
960
|
+
if (message.html === void 0 && message.text === void 0)
|
|
961
|
+
throw new MailError("Message needs html or text", { kind: "invalid" });
|
|
962
|
+
}
|
|
908
963
|
let candidates = order();
|
|
964
|
+
if (via !== "all")
|
|
965
|
+
candidates = candidates.filter((e) => e.provider.type === "hub" === (via === "hub"));
|
|
909
966
|
if (message.prefer) {
|
|
910
967
|
const i = candidates.findIndex((e) => e.provider.name === message.prefer);
|
|
911
968
|
if (i > 0) candidates = [candidates[i], ...candidates.filter((_, j) => j !== i)];
|
|
@@ -922,8 +979,9 @@ function createMailer(options = {}) {
|
|
|
922
979
|
const p = entry.provider;
|
|
923
980
|
const started = Date.now();
|
|
924
981
|
let id;
|
|
982
|
+
let queued;
|
|
925
983
|
try {
|
|
926
|
-
({ id } = await withTimeout(
|
|
984
|
+
({ id, queued } = await withTimeout(
|
|
927
985
|
(signal) => {
|
|
928
986
|
const normalized = {
|
|
929
987
|
...base,
|
|
@@ -953,7 +1011,7 @@ function createMailer(options = {}) {
|
|
|
953
1011
|
});
|
|
954
1012
|
const failedOver = opts.failedOver();
|
|
955
1013
|
emit({ type: "sent", provider: p.name, id, ms, failedOver });
|
|
956
|
-
return { id, provider: p.name, failedOver, attempts };
|
|
1014
|
+
return { id, provider: p.name, failedOver, attempts, ...queued ? { queued: true } : {} };
|
|
957
1015
|
};
|
|
958
1016
|
for (const entry of candidates) {
|
|
959
1017
|
const p = entry.provider;
|
|
@@ -1145,6 +1203,18 @@ async function notifyTopic(input, opts = {}) {
|
|
|
1145
1203
|
const { idempotencyKey, ...body } = input;
|
|
1146
1204
|
return postNotify(body, idempotencyKey, opts);
|
|
1147
1205
|
}
|
|
1206
|
+
async function getRequestStatus(idempotencyKey, opts = {}) {
|
|
1207
|
+
try {
|
|
1208
|
+
return await hubRequest(
|
|
1209
|
+
opts,
|
|
1210
|
+
`/api/v1/requests/${encodeURIComponent(idempotencyKey)}`,
|
|
1211
|
+
{ timeoutMs: NOTIFY_TIMEOUT_MS.recipient }
|
|
1212
|
+
);
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
if (err instanceof MailError && err.status === 404) return null;
|
|
1215
|
+
throw err;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1148
1218
|
async function getRecipient(email, opts = {}) {
|
|
1149
1219
|
return hubRequest(
|
|
1150
1220
|
opts,
|
|
@@ -1292,8 +1362,10 @@ export {
|
|
|
1292
1362
|
toEmails,
|
|
1293
1363
|
NOTIFY_TIMEOUT_MS,
|
|
1294
1364
|
hasNotifyKey,
|
|
1365
|
+
hubRequest,
|
|
1295
1366
|
notify,
|
|
1296
1367
|
notifyTopic,
|
|
1368
|
+
getRequestStatus,
|
|
1297
1369
|
getRecipient,
|
|
1298
1370
|
createLink,
|
|
1299
1371
|
getChannelStatus,
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// src/format.ts
|
|
2
|
+
var KNOWN_TAG_RE = /<\/?(b|strong|i|em|u|ins|s|strike|del|a|code|pre|p|br|div|span|ul|ol|li|h[1-6]|table|tr|td)(?=[\s/>])[^>]*>/i;
|
|
3
|
+
function looksLikeHtml(s) {
|
|
4
|
+
return KNOWN_TAG_RE.test(s);
|
|
5
|
+
}
|
|
6
|
+
function resolveFormat(body, format) {
|
|
7
|
+
if (format === "text" || format === "html") return format;
|
|
8
|
+
return looksLikeHtml(body) ? "html" : "text";
|
|
9
|
+
}
|
|
10
|
+
function toPlainText(html) {
|
|
11
|
+
return html.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("&", "&").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/template.ts
|
|
15
|
+
var TemplateError = class extends Error {
|
|
16
|
+
constructor(message, missing = []) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.missing = missing;
|
|
19
|
+
this.name = "TemplateError";
|
|
20
|
+
}
|
|
21
|
+
missing;
|
|
22
|
+
};
|
|
23
|
+
var TAG = /\{\{\s*([#/]?)\s*(?:(if|each|else)(?![\w-.-]))?\s*([^}]*?)\s*\}\}/g;
|
|
24
|
+
var PATH = /^(@?[A-Za-z_-][\w--]*|this)(\.[\w--]+)*$/;
|
|
25
|
+
function parseTemplate(source) {
|
|
26
|
+
const root = [];
|
|
27
|
+
const stack = [];
|
|
28
|
+
const target = () => {
|
|
29
|
+
const top = stack.at(-1);
|
|
30
|
+
if (!top) return root;
|
|
31
|
+
if (top.node.t === "each") return top.node.body;
|
|
32
|
+
return top.inElse ? top.node.no : top.node.yes;
|
|
33
|
+
};
|
|
34
|
+
let last = 0;
|
|
35
|
+
for (const m of source.matchAll(TAG)) {
|
|
36
|
+
const [raw, sigil, keyword, rest] = m;
|
|
37
|
+
const at = m.index ?? 0;
|
|
38
|
+
if (at > last) target().push({ t: "text", v: source.slice(last, at) });
|
|
39
|
+
last = at + raw.length;
|
|
40
|
+
const path = (rest ?? "").trim();
|
|
41
|
+
if (sigil === "#") {
|
|
42
|
+
if (keyword !== "if" && keyword !== "each")
|
|
43
|
+
throw new TemplateError(`\u0E44\u0E21\u0E48\u0E23\u0E39\u0E49\u0E08\u0E31\u0E01 {{#${keyword ?? path}}} \u0E17\u0E35\u0E48\u0E15\u0E33\u0E41\u0E2B\u0E19\u0E48\u0E07 ${at}`);
|
|
44
|
+
if (!PATH.test(path))
|
|
45
|
+
throw new TemplateError(`{{#${keyword}}} \u0E15\u0E49\u0E2D\u0E07\u0E15\u0E32\u0E21\u0E14\u0E49\u0E27\u0E22\u0E0A\u0E37\u0E48\u0E2D\u0E15\u0E31\u0E27\u0E41\u0E1B\u0E23 (\u0E15\u0E33\u0E41\u0E2B\u0E19\u0E48\u0E07 ${at})`);
|
|
46
|
+
const node = keyword === "if" ? { t: "if", path, yes: [], no: [] } : { t: "each", path, body: [] };
|
|
47
|
+
target().push(node);
|
|
48
|
+
stack.push({ node, inElse: false });
|
|
49
|
+
} else if (sigil === "/") {
|
|
50
|
+
const top = stack.pop();
|
|
51
|
+
if (!top || top.node.t !== keyword)
|
|
52
|
+
throw new TemplateError(`{{/${keyword ?? path}}} \u0E44\u0E21\u0E48\u0E21\u0E35\u0E41\u0E17\u0E47\u0E01\u0E40\u0E1B\u0E34\u0E14\u0E17\u0E35\u0E48\u0E15\u0E23\u0E07\u0E01\u0E31\u0E19 (\u0E15\u0E33\u0E41\u0E2B\u0E19\u0E48\u0E07 ${at})`);
|
|
53
|
+
} else if (keyword === "else" && !path) {
|
|
54
|
+
const top = stack.at(-1);
|
|
55
|
+
if (!top || top.node.t !== "if" || top.inElse)
|
|
56
|
+
throw new TemplateError(`{{else}} \u0E15\u0E49\u0E2D\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E43\u0E19 {{#if}} (\u0E15\u0E33\u0E41\u0E2B\u0E19\u0E48\u0E07 ${at})`);
|
|
57
|
+
top.inElse = true;
|
|
58
|
+
} else {
|
|
59
|
+
const name = `${keyword ?? ""}${keyword && path ? " " : ""}${path}`.trim();
|
|
60
|
+
if (!PATH.test(name)) throw new TemplateError(`\u0E0A\u0E37\u0E48\u0E2D\u0E15\u0E31\u0E27\u0E41\u0E1B\u0E23 "{{${name}}}" \u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07 (\u0E15\u0E33\u0E41\u0E2B\u0E19\u0E48\u0E07 ${at})`);
|
|
61
|
+
target().push({ t: "var", path: name });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (last < source.length) target().push({ t: "text", v: source.slice(last) });
|
|
65
|
+
const open = stack.at(-1);
|
|
66
|
+
if (open) throw new TemplateError(`{{#${open.node.t} ${open.node.path}}} \u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E1B\u0E34\u0E14`);
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
function escapeHtml(s) {
|
|
70
|
+
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
71
|
+
}
|
|
72
|
+
function lookup(scopes, path) {
|
|
73
|
+
const parts = path.split(".");
|
|
74
|
+
const head = parts[0];
|
|
75
|
+
for (let i = scopes.length - 1; i >= 0; i--) {
|
|
76
|
+
const scope = scopes[i];
|
|
77
|
+
let value;
|
|
78
|
+
if (head === "this") value = scope;
|
|
79
|
+
else if (scope && typeof scope === "object" && head in scope)
|
|
80
|
+
value = scope[head];
|
|
81
|
+
else continue;
|
|
82
|
+
for (const p of parts.slice(1)) {
|
|
83
|
+
if (value && typeof value === "object" && p in value) value = value[p];
|
|
84
|
+
else return void 0;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
function truthy(v) {
|
|
91
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
92
|
+
return v !== void 0 && v !== null && v !== false && v !== "" && v !== 0;
|
|
93
|
+
}
|
|
94
|
+
function stringify(v) {
|
|
95
|
+
if (v === void 0 || v === null) return "";
|
|
96
|
+
if (v instanceof Date) return v.toISOString();
|
|
97
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
98
|
+
return String(v);
|
|
99
|
+
}
|
|
100
|
+
function renderNodes(nodes, scopes, esc, used) {
|
|
101
|
+
let out = "";
|
|
102
|
+
for (const n of nodes) {
|
|
103
|
+
if (n.t === "text") out += n.v;
|
|
104
|
+
else if (n.t === "var") {
|
|
105
|
+
used.add(n.path);
|
|
106
|
+
const s = stringify(lookup(scopes, n.path));
|
|
107
|
+
out += esc ? escapeHtml(s) : s;
|
|
108
|
+
} else if (n.t === "if") {
|
|
109
|
+
used.add(n.path);
|
|
110
|
+
out += renderNodes(truthy(lookup(scopes, n.path)) ? n.yes : n.no, scopes, esc, used);
|
|
111
|
+
} else {
|
|
112
|
+
used.add(n.path);
|
|
113
|
+
const list = lookup(scopes, n.path);
|
|
114
|
+
if (!Array.isArray(list)) continue;
|
|
115
|
+
list.forEach((item, i) => {
|
|
116
|
+
out += renderNodes(n.body, [...scopes, { "@index": i, "@number": i + 1 }, item], esc, used);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
function renderTemplate(source, data, opts) {
|
|
123
|
+
return renderNodes(parseTemplate(source), [data], opts.escape, /* @__PURE__ */ new Set());
|
|
124
|
+
}
|
|
125
|
+
function templateVariableNames(...sources) {
|
|
126
|
+
const names = /* @__PURE__ */ new Set();
|
|
127
|
+
const walk = (nodes) => {
|
|
128
|
+
for (const n of nodes) {
|
|
129
|
+
if (n.t === "text") continue;
|
|
130
|
+
if (!n.path.startsWith("@") && n.path !== "this") names.add(n.path);
|
|
131
|
+
if (n.t === "if") {
|
|
132
|
+
walk(n.yes);
|
|
133
|
+
walk(n.no);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
for (const s of sources) walk(parseTemplate(s));
|
|
138
|
+
return [...names];
|
|
139
|
+
}
|
|
140
|
+
function sampleData(variables) {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const v of variables ?? []) {
|
|
143
|
+
if (v.sample === void 0) continue;
|
|
144
|
+
const parts = v.name.split(".");
|
|
145
|
+
let cur = out;
|
|
146
|
+
parts.slice(0, -1).forEach((p) => {
|
|
147
|
+
if (!cur[p] || typeof cur[p] !== "object") cur[p] = {};
|
|
148
|
+
cur = cur[p];
|
|
149
|
+
});
|
|
150
|
+
let sample = v.sample;
|
|
151
|
+
if (/^\s*[[{]/.test(v.sample)) {
|
|
152
|
+
try {
|
|
153
|
+
sample = JSON.parse(v.sample);
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
cur[parts.at(-1)] = sample;
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
function wrapEmailLayout(body, opts = {}) {
|
|
162
|
+
const footer = opts.appName ? `<p style="margin:24px 0 0;font-size:12px;color:#64748b">\u0E2A\u0E48\u0E07\u0E08\u0E32\u0E01 ${escapeHtml(opts.appName)}</p>` : "";
|
|
163
|
+
return `<!doctype html>
|
|
164
|
+
<html lang="th"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${opts.title ? `<title>${escapeHtml(opts.title)}</title>` : ""}</head>
|
|
165
|
+
<body style="margin:0;padding:24px 12px;background:#f8fafc">
|
|
166
|
+
<div style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:24px;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;font-size:15px;line-height:1.6;color:#0f172a">
|
|
167
|
+
${body}
|
|
168
|
+
${footer}
|
|
169
|
+
</div>
|
|
170
|
+
</body></html>`;
|
|
171
|
+
}
|
|
172
|
+
function renderEmailTemplate(t, data, opts = {}) {
|
|
173
|
+
const missing = (t.variables ?? []).filter((v) => v.required && !truthy(lookup([data], v.name)) && lookup([data], v.name) !== 0).map((v) => v.name);
|
|
174
|
+
if (missing.length) throw new TemplateError(`\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E04\u0E23\u0E1A: ${missing.join(", ")}`, missing);
|
|
175
|
+
const subject = renderTemplate(t.subject, data, { escape: false }).replace(/\s+/g, " ").trim();
|
|
176
|
+
const body = renderTemplate(t.html, data, { escape: true });
|
|
177
|
+
const html = /<html[\s>]/i.test(body) ? body : wrapEmailLayout(body, { ...opts, title: subject });
|
|
178
|
+
const text = t.text?.trim() ? renderTemplate(t.text, data, { escape: false }) : toPlainText(body);
|
|
179
|
+
return { subject, html, text };
|
|
180
|
+
}
|
|
181
|
+
var BUILTIN_TEMPLATE_KEYS = [
|
|
182
|
+
"auth.magic-link",
|
|
183
|
+
"auth.reset-password",
|
|
184
|
+
"auth.verify-email"
|
|
185
|
+
];
|
|
186
|
+
function isBuiltinTemplate(key) {
|
|
187
|
+
return BUILTIN_TEMPLATE_KEYS.includes(key);
|
|
188
|
+
}
|
|
189
|
+
var AUTH_VARIABLES = [
|
|
190
|
+
{
|
|
191
|
+
name: "url",
|
|
192
|
+
label: "\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E17\u0E35\u0E48\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E14",
|
|
193
|
+
sample: "https://app.example.com/auth/verify?token=abc",
|
|
194
|
+
required: true
|
|
195
|
+
},
|
|
196
|
+
{ name: "appName", label: "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B (hub \u0E40\u0E15\u0E34\u0E21\u0E43\u0E2B\u0E49\u0E16\u0E49\u0E32\u0E44\u0E21\u0E48\u0E2A\u0E48\u0E07)", sample: "ESTAR Supply Chain" },
|
|
197
|
+
{ name: "expiresIn", label: "\u0E2D\u0E32\u0E22\u0E38\u0E25\u0E34\u0E07\u0E01\u0E4C \u0E40\u0E0A\u0E48\u0E19 15 \u0E19\u0E32\u0E17\u0E35", sample: "15 \u0E19\u0E32\u0E17\u0E35" }
|
|
198
|
+
];
|
|
199
|
+
var BUTTON = "display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600";
|
|
200
|
+
function authHtml(heading, lead, button, note) {
|
|
201
|
+
return `<h2 style="margin:0 0 12px;font-size:20px">${heading}</h2>
|
|
202
|
+
<p style="margin:0 0 20px">${lead}</p>
|
|
203
|
+
<p style="margin:0 0 20px"><a href="{{url}}" style="${BUTTON}">${button}</a></p>
|
|
204
|
+
<p style="margin:0 0 8px;font-size:13px;color:#475569">${note}{{#if expiresIn}} \u0E25\u0E34\u0E07\u0E01\u0E4C\u0E2B\u0E21\u0E14\u0E2D\u0E32\u0E22\u0E38\u0E43\u0E19 {{expiresIn}}{{/if}}</p>
|
|
205
|
+
<p style="margin:0 0 8px;font-size:13px;color:#475569">\u0E16\u0E49\u0E32\u0E1B\u0E38\u0E48\u0E21\u0E01\u0E14\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49 \u0E43\u0E2B\u0E49\u0E04\u0E31\u0E14\u0E25\u0E2D\u0E01\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E44\u0E1B\u0E40\u0E1B\u0E34\u0E14\u0E43\u0E19\u0E40\u0E1A\u0E23\u0E32\u0E27\u0E4C\u0E40\u0E0B\u0E2D\u0E23\u0E4C:<br><span style="word-break:break-all">{{url}}</span></p>
|
|
206
|
+
<p style="margin:16px 0 0;font-size:13px;color:#475569">\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E40\u0E1B\u0E47\u0E19\u0E04\u0E19\u0E02\u0E2D \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E17\u0E33\u0E2D\u0E30\u0E44\u0E23 \u2014 \u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E22\u0E31\u0E07\u0E1B\u0E25\u0E2D\u0E14\u0E20\u0E31\u0E22</p>`;
|
|
207
|
+
}
|
|
208
|
+
var BUILTIN_TEMPLATES = {
|
|
209
|
+
"auth.magic-link": {
|
|
210
|
+
name: "\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A (\u0E21\u0E32\u0E15\u0E23\u0E10\u0E32\u0E19)",
|
|
211
|
+
subject: "\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A{{#if appName}} {{appName}}{{/if}}",
|
|
212
|
+
html: authHtml(
|
|
213
|
+
"\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A{{#if appName}} {{appName}}{{/if}}",
|
|
214
|
+
"\u0E01\u0E14\u0E1B\u0E38\u0E48\u0E21\u0E14\u0E49\u0E32\u0E19\u0E25\u0E48\u0E32\u0E07\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E43\u0E0A\u0E49\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19",
|
|
215
|
+
"\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A",
|
|
216
|
+
"\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E04\u0E23\u0E31\u0E49\u0E07\u0E40\u0E14\u0E35\u0E22\u0E27"
|
|
217
|
+
),
|
|
218
|
+
variables: AUTH_VARIABLES
|
|
219
|
+
},
|
|
220
|
+
"auth.reset-password": {
|
|
221
|
+
name: "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48 (\u0E21\u0E32\u0E15\u0E23\u0E10\u0E32\u0E19)",
|
|
222
|
+
subject: "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48{{#if appName}} {{appName}}{{/if}}",
|
|
223
|
+
html: authHtml(
|
|
224
|
+
"\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
|
|
225
|
+
"\u0E21\u0E35\u0E04\u0E33\u0E02\u0E2D\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13{{#if appName}} \u0E43\u0E19 {{appName}}{{/if}} \u0E01\u0E14\u0E1B\u0E38\u0E48\u0E21\u0E14\u0E49\u0E32\u0E19\u0E25\u0E48\u0E32\u0E07\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
|
|
226
|
+
"\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
|
|
227
|
+
"\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E04\u0E23\u0E31\u0E49\u0E07\u0E40\u0E14\u0E35\u0E22\u0E27"
|
|
228
|
+
),
|
|
229
|
+
variables: AUTH_VARIABLES
|
|
230
|
+
},
|
|
231
|
+
"auth.verify-email": {
|
|
232
|
+
name: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25 (\u0E21\u0E32\u0E15\u0E23\u0E10\u0E32\u0E19)",
|
|
233
|
+
subject: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13{{#if appName}} \u2014 {{appName}}{{/if}}",
|
|
234
|
+
html: authHtml(
|
|
235
|
+
"\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",
|
|
236
|
+
"\u0E01\u0E14\u0E1B\u0E38\u0E48\u0E21\u0E14\u0E49\u0E32\u0E19\u0E25\u0E48\u0E32\u0E07\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E27\u0E48\u0E32\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E19\u0E35\u0E49\u0E40\u0E1B\u0E47\u0E19\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13{{#if appName}} \u0E41\u0E25\u0E30\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49 {{appName}}{{/if}}",
|
|
237
|
+
"\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25",
|
|
238
|
+
"\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E04\u0E23\u0E31\u0E49\u0E07\u0E40\u0E14\u0E35\u0E22\u0E27\u0E01\u0E47\u0E1E\u0E2D"
|
|
239
|
+
),
|
|
240
|
+
variables: AUTH_VARIABLES
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
var FALLBACK_SUBJECT = {
|
|
244
|
+
"auth.magic-link": "\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A",
|
|
245
|
+
"auth.reset-password": "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
|
|
246
|
+
"auth.verify-email": "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25"
|
|
247
|
+
};
|
|
248
|
+
function builtinFallback(key, data) {
|
|
249
|
+
if (!isBuiltinTemplate(key)) return null;
|
|
250
|
+
const url = typeof data?.url === "string" ? data.url : "";
|
|
251
|
+
if (!url) return null;
|
|
252
|
+
const app = typeof data?.appName === "string" && data.appName ? data.appName : "";
|
|
253
|
+
const expires = typeof data?.expiresIn === "string" && data.expiresIn ? data.expiresIn : "";
|
|
254
|
+
const subject = `${FALLBACK_SUBJECT[key]}${app ? ` ${app}` : ""}`;
|
|
255
|
+
const lines = [
|
|
256
|
+
`${FALLBACK_SUBJECT[key]}${app ? ` (${app})` : ""}`,
|
|
257
|
+
"",
|
|
258
|
+
"\u0E40\u0E1B\u0E34\u0E14\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49:",
|
|
259
|
+
url,
|
|
260
|
+
"",
|
|
261
|
+
...expires ? [`\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E2B\u0E21\u0E14\u0E2D\u0E32\u0E22\u0E38\u0E43\u0E19 ${expires}`] : [],
|
|
262
|
+
"\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E40\u0E1B\u0E47\u0E19\u0E04\u0E19\u0E02\u0E2D \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E17\u0E33\u0E2D\u0E30\u0E44\u0E23"
|
|
263
|
+
];
|
|
264
|
+
const text = lines.join("\n");
|
|
265
|
+
const html = `<p>${escapeHtml(lines[0])}</p><p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>${expires ? `<p>\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E2B\u0E21\u0E14\u0E2D\u0E32\u0E22\u0E38\u0E43\u0E19 ${escapeHtml(expires)}</p>` : ""}<p>\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E40\u0E1B\u0E47\u0E19\u0E04\u0E19\u0E02\u0E2D \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E17\u0E33\u0E2D\u0E30\u0E44\u0E23</p>`;
|
|
266
|
+
return { subject, text, html };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export {
|
|
270
|
+
looksLikeHtml,
|
|
271
|
+
resolveFormat,
|
|
272
|
+
toPlainText,
|
|
273
|
+
TemplateError,
|
|
274
|
+
parseTemplate,
|
|
275
|
+
renderTemplate,
|
|
276
|
+
templateVariableNames,
|
|
277
|
+
sampleData,
|
|
278
|
+
wrapEmailLayout,
|
|
279
|
+
renderEmailTemplate,
|
|
280
|
+
BUILTIN_TEMPLATE_KEYS,
|
|
281
|
+
isBuiltinTemplate,
|
|
282
|
+
BUILTIN_TEMPLATES,
|
|
283
|
+
builtinFallback
|
|
284
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { P as ParsedAddress, A as Address, a as Provider, E as ErrorKind, Q as Quota, M as Message, S as SendResult, b as ProviderStatus, c as ProviderInfo, d as Store, e as MailerEvent, f as Attempt, g as PushDevice, N as NotifyOptions, h as NotifyResultItem, i as NotifyInput, j as NotifyTopicInput } from './notify-
|
|
2
|
-
export { k as Attachment, C as ChannelStatus, l as NOTIFY_TIMEOUT_MS, m as NormalizedMessage, n as NotifyChannel, o as NotifyResult, p as PushSubscriptionJson, q as QuotaUsage, R as Recipient, r as RecipientChannelInfo, s as RecipientInfo, t as
|
|
1
|
+
import { P as ParsedAddress, A as Address, a as Provider, E as ErrorKind, Q as Quota, M as Message, S as SendResult, b as ProviderStatus, c as ProviderInfo, d as Store, e as MailerEvent, f as Attempt, g as PushDevice, N as NotifyOptions, h as NotifyResultItem, i as NotifyInput, j as NotifyTopicInput } from './notify-D-PhiDzT.js';
|
|
2
|
+
export { k as Attachment, C as ChannelStatus, l as NOTIFY_TIMEOUT_MS, m as NormalizedMessage, n as NotifyChannel, o as NotifyResult, p as PushSubscriptionJson, q as QuotaUsage, R as Recipient, r as RecipientChannelInfo, s as RecipientInfo, t as RequestStatus, u as SkipReason, v as createLink, w as deletePushSubscription, x as getChannelStatus, y as getPushConfig, z as getRecipient, B as getRequestStatus, D as hasNotifyKey, F as listPushDevices, G as notify, H as notifyTopic, I as savePushSubscription, J as toEmails } from './notify-D-PhiDzT.js';
|
|
3
|
+
import { TemplateVariable } from './template.js';
|
|
4
|
+
export { BUILTIN_TEMPLATE_KEYS, BuiltinTemplateKey, TemplateError, builtinFallback, isBuiltinTemplate, renderEmailTemplate } from './template.js';
|
|
3
5
|
|
|
4
6
|
/** แปลง "ชื่อ <a@b.com>" / "a@b.com" / { name, email } → { name, email } */
|
|
5
7
|
declare function parseAddress(input: Address): ParsedAddress;
|
|
@@ -39,7 +41,8 @@ interface HubSendRequest {
|
|
|
39
41
|
cc?: ParsedAddress[];
|
|
40
42
|
bcc?: ParsedAddress[];
|
|
41
43
|
replyTo?: ParsedAddress[];
|
|
42
|
-
|
|
44
|
+
/** ไม่ส่งเมื่อใช้ template */
|
|
45
|
+
subject?: string;
|
|
43
46
|
html?: string;
|
|
44
47
|
text?: string;
|
|
45
48
|
attachments?: {
|
|
@@ -51,11 +54,19 @@ interface HubSendRequest {
|
|
|
51
54
|
tags?: Record<string, string>;
|
|
52
55
|
/** ชื่อ provider ที่อยากให้ hub ลองก่อน */
|
|
53
56
|
prefer?: string;
|
|
57
|
+
/** (0.8.0) วินาที — ล้มชั่วคราวแล้วให้ hub ส่งซ้ำเอง */
|
|
58
|
+
deliverWithin?: number;
|
|
59
|
+
/** (0.9.0) เทมเพลตของแอป — hub render subject/html/text จาก data */
|
|
60
|
+
template?: string;
|
|
61
|
+
data?: Record<string, unknown>;
|
|
54
62
|
}
|
|
55
63
|
interface HubSendResponse {
|
|
56
64
|
id?: string;
|
|
57
65
|
provider: string;
|
|
58
66
|
failedOver: boolean;
|
|
67
|
+
/** (0.8.0) "queued" = hub รับไว้ส่งซ้ำเอง (HTTP 202) */
|
|
68
|
+
status?: "sent" | "queued";
|
|
69
|
+
deliveryId?: string;
|
|
59
70
|
}
|
|
60
71
|
/** error body ของ hub: { error: { kind, message, attempts?, retryAfterSec? } } */
|
|
61
72
|
interface HubErrorResponse {
|
|
@@ -457,5 +468,54 @@ declare function createRedisStore(client: RedisLike, opts?: {
|
|
|
457
468
|
prefix?: string;
|
|
458
469
|
}): Store;
|
|
459
470
|
|
|
460
|
-
|
|
461
|
-
|
|
471
|
+
/** (0.9.0) เทมเพลตอีเมลของแอปที่เก็บใน Pi Notify */
|
|
472
|
+
interface EmailTemplateInfo {
|
|
473
|
+
key: string;
|
|
474
|
+
name: string;
|
|
475
|
+
subject: string;
|
|
476
|
+
html: string;
|
|
477
|
+
text: string | null;
|
|
478
|
+
variables: TemplateVariable[];
|
|
479
|
+
enabled: boolean;
|
|
480
|
+
version: number;
|
|
481
|
+
updatedAt: string;
|
|
482
|
+
/** (0.10.0) true = เทมเพลตมาตรฐานของ Pi Notify (auth.*) ที่แอปยังไม่ได้สร้างทับ */
|
|
483
|
+
builtin?: boolean;
|
|
484
|
+
}
|
|
485
|
+
interface EmailTemplateInput {
|
|
486
|
+
name: string;
|
|
487
|
+
/** ใส่ {{ตัวแปร}} ได้ */
|
|
488
|
+
subject: string;
|
|
489
|
+
/** {{ตัวแปร}} ถูก escape เสมอ · {{#if}} / {{#each}} ได้ · ไม่มี <html> = hub ครอบ layout มาตรฐานให้ */
|
|
490
|
+
html: string;
|
|
491
|
+
/** ว่าง = สร้างจาก HTML */
|
|
492
|
+
text?: string | null;
|
|
493
|
+
variables?: TemplateVariable[];
|
|
494
|
+
enabled?: boolean;
|
|
495
|
+
}
|
|
496
|
+
/** (0.9.0) เทมเพลตทั้งหมดของแอป */
|
|
497
|
+
declare function listTemplates(opts?: NotifyOptions): Promise<EmailTemplateInfo[]>;
|
|
498
|
+
/** (0.9.0) เทมเพลตหนึ่งตัว — ไม่มี = null */
|
|
499
|
+
declare function getTemplate(key: string, opts?: NotifyOptions): Promise<EmailTemplateInfo | null>;
|
|
500
|
+
/**
|
|
501
|
+
* (0.9.0) สร้าง/แก้เทมเพลต (upsert ตาม key) — hub ตรวจไวยากรณ์ก่อนบันทึก (ผิด = MailError invalid 422)
|
|
502
|
+
* @example await saveTemplate("po-approved", { name: "PO อนุมัติ", subject: "PO {{po}} อนุมัติแล้ว", html })
|
|
503
|
+
*/
|
|
504
|
+
declare function saveTemplate(key: string, input: EmailTemplateInput, opts?: NotifyOptions): Promise<EmailTemplateInfo>;
|
|
505
|
+
/** (0.9.0) ลบเทมเพลต — คืน false เมื่อไม่มีอยู่แล้ว */
|
|
506
|
+
declare function deleteTemplate(key: string, opts?: NotifyOptions): Promise<boolean>;
|
|
507
|
+
/**
|
|
508
|
+
* (0.9.0) ดูตัวอย่างอีเมลที่ render แล้ว (ไม่ส่ง) — ไม่ส่ง data = ใช้ค่าตัวอย่าง (`variables[].sample`)
|
|
509
|
+
* ส่ง `template` = ลองเนื้อหาที่ยังไม่บันทึก
|
|
510
|
+
*/
|
|
511
|
+
declare function previewTemplate(key: string, input?: {
|
|
512
|
+
data?: Record<string, unknown>;
|
|
513
|
+
template?: EmailTemplateInput;
|
|
514
|
+
}, opts?: NotifyOptions): Promise<{
|
|
515
|
+
subject: string;
|
|
516
|
+
html: string;
|
|
517
|
+
text: string;
|
|
518
|
+
}>;
|
|
519
|
+
|
|
520
|
+
export { Address, AllProvidersFailedError, Attempt, DEFAULT_COOLDOWN, DEFAULT_HUB_URL, ErrorKind, MailError, MailerEvent, Message, NotifyInput, NotifyOptions, NotifyResultItem, NotifyTopicInput, ParsedAddress, Provider, ProviderInfo, ProviderStatus, PushDevice, Quota, SMTP_PRESETS, SendResult, Store, TemplateVariable, classifyError, configureMailer, createConsoleProvider, createHubProvider, createMailer, createMemoryStore, createPresetSmtpProvider, createPushHandlers, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, deleteTemplate, formatAddress, getMailer, getTemplate, listProviders, listTemplates, looksLikeHtml, notifySafe, notifyTopicSafe, parseAddress, parseAddressList, previewTemplate, providersFromEnv, pushServiceWorker, pushServiceWorkerSource, renderNotifyEmail, requireFrom, resetMailer, resetSafeWarnings, resolveFormat, saveTemplate, sendMail, sendMailSafe, toPlainText };
|
|
521
|
+
export type { BodyFormat, BrowserPushSubscription, ConsoleOptions, EmailTemplateInfo, EmailTemplateInput, Env, HubErrorResponse, HubOptions, HubSendRequest, HubSendResponse, Mailer, MailerOptions, NotifyEmailInput, NotifySafeResult, ProviderConfig, PushHandlers, PushHandlersOptions, PushServiceWorkerOptions, PushUser, RedisLike, ResendOptions, SendMailSafeResult, SmtpOptions, SmtpPreset, ZeptoMailOptions };
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
getMailer,
|
|
23
23
|
getPushConfig,
|
|
24
24
|
getRecipient,
|
|
25
|
+
getRequestStatus,
|
|
25
26
|
hasNotifyKey,
|
|
27
|
+
hubRequest,
|
|
26
28
|
listProviders,
|
|
27
29
|
listPushDevices,
|
|
28
30
|
notify,
|
|
@@ -39,20 +41,17 @@ import {
|
|
|
39
41
|
sendMail,
|
|
40
42
|
sendMailSafe,
|
|
41
43
|
toEmails
|
|
42
|
-
} from "./chunk-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
function toPlainText(html) {
|
|
54
|
-
return html.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("&", "&").trim();
|
|
55
|
-
}
|
|
44
|
+
} from "./chunk-CWZP54ZN.js";
|
|
45
|
+
import {
|
|
46
|
+
BUILTIN_TEMPLATE_KEYS,
|
|
47
|
+
TemplateError,
|
|
48
|
+
builtinFallback,
|
|
49
|
+
isBuiltinTemplate,
|
|
50
|
+
looksLikeHtml,
|
|
51
|
+
renderEmailTemplate,
|
|
52
|
+
resolveFormat,
|
|
53
|
+
toPlainText
|
|
54
|
+
} from "./chunk-NNMV7FZ3.js";
|
|
56
55
|
|
|
57
56
|
// src/push.ts
|
|
58
57
|
function createPushHandlers(options) {
|
|
@@ -189,13 +188,56 @@ function createRedisStore(client, opts = {}) {
|
|
|
189
188
|
}
|
|
190
189
|
};
|
|
191
190
|
}
|
|
191
|
+
|
|
192
|
+
// src/templates-api.ts
|
|
193
|
+
var path = (key) => `/api/v1/templates/${encodeURIComponent(key)}`;
|
|
194
|
+
async function listTemplates(opts = {}) {
|
|
195
|
+
const data = await hubRequest(opts, "/api/v1/templates", {
|
|
196
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
197
|
+
});
|
|
198
|
+
return data.templates;
|
|
199
|
+
}
|
|
200
|
+
async function getTemplate(key, opts = {}) {
|
|
201
|
+
try {
|
|
202
|
+
return await hubRequest(opts, path(key), {
|
|
203
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err instanceof MailError && err.status === 404) return null;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function saveTemplate(key, input, opts = {}) {
|
|
211
|
+
return hubRequest(opts, path(key), {
|
|
212
|
+
method: "PUT",
|
|
213
|
+
body: input,
|
|
214
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async function deleteTemplate(key, opts = {}) {
|
|
218
|
+
const data = await hubRequest(opts, path(key), {
|
|
219
|
+
method: "DELETE",
|
|
220
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
221
|
+
});
|
|
222
|
+
return data.deleted;
|
|
223
|
+
}
|
|
224
|
+
async function previewTemplate(key, input = {}, opts = {}) {
|
|
225
|
+
return hubRequest(opts, `${path(key)}/preview`, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
body: input,
|
|
228
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
229
|
+
});
|
|
230
|
+
}
|
|
192
231
|
export {
|
|
193
232
|
AllProvidersFailedError,
|
|
233
|
+
BUILTIN_TEMPLATE_KEYS,
|
|
194
234
|
DEFAULT_COOLDOWN,
|
|
195
235
|
DEFAULT_HUB_URL,
|
|
196
236
|
MailError,
|
|
197
237
|
NOTIFY_TIMEOUT_MS,
|
|
198
238
|
SMTP_PRESETS,
|
|
239
|
+
TemplateError,
|
|
240
|
+
builtinFallback,
|
|
199
241
|
classifyError,
|
|
200
242
|
configureMailer,
|
|
201
243
|
createConsoleProvider,
|
|
@@ -210,14 +252,19 @@ export {
|
|
|
210
252
|
createSmtpProvider,
|
|
211
253
|
createZeptoMailProvider,
|
|
212
254
|
deletePushSubscription,
|
|
255
|
+
deleteTemplate,
|
|
213
256
|
formatAddress,
|
|
214
257
|
getChannelStatus,
|
|
215
258
|
getMailer,
|
|
216
259
|
getPushConfig,
|
|
217
260
|
getRecipient,
|
|
261
|
+
getRequestStatus,
|
|
262
|
+
getTemplate,
|
|
218
263
|
hasNotifyKey,
|
|
264
|
+
isBuiltinTemplate,
|
|
219
265
|
listProviders,
|
|
220
266
|
listPushDevices,
|
|
267
|
+
listTemplates,
|
|
221
268
|
looksLikeHtml,
|
|
222
269
|
notify,
|
|
223
270
|
notifySafe,
|
|
@@ -225,15 +272,18 @@ export {
|
|
|
225
272
|
notifyTopicSafe,
|
|
226
273
|
parseAddress,
|
|
227
274
|
parseAddressList,
|
|
275
|
+
previewTemplate,
|
|
228
276
|
providersFromEnv,
|
|
229
277
|
pushServiceWorker,
|
|
230
278
|
pushServiceWorkerSource,
|
|
279
|
+
renderEmailTemplate,
|
|
231
280
|
renderNotifyEmail,
|
|
232
281
|
requireFrom,
|
|
233
282
|
resetMailer,
|
|
234
283
|
resetSafeWarnings,
|
|
235
284
|
resolveFormat,
|
|
236
285
|
savePushSubscription,
|
|
286
|
+
saveTemplate,
|
|
237
287
|
sendMail,
|
|
238
288
|
sendMailSafe,
|
|
239
289
|
toEmails,
|
package/dist/next.js
CHANGED
|
@@ -13,7 +13,8 @@ interface Attachment {
|
|
|
13
13
|
/** ข้อความที่แอปส่งเข้ามา (ไม่ผูกกับ provider ใด ๆ) */
|
|
14
14
|
interface Message {
|
|
15
15
|
to: Address | Address[];
|
|
16
|
-
|
|
16
|
+
/** ไม่ต้องใส่เมื่อใช้ `template` (หัวเรื่องมาจากเทมเพลต) */
|
|
17
|
+
subject?: string;
|
|
17
18
|
html?: string;
|
|
18
19
|
text?: string;
|
|
19
20
|
/** override from ของ provider/ค่าเริ่มต้น */
|
|
@@ -29,6 +30,32 @@ interface Message {
|
|
|
29
30
|
provider?: string;
|
|
30
31
|
/** อยากให้ลองตัวนี้ก่อน (ยัง failover ตามปกติถ้าไม่ได้) — ส่งต่อไป hub ด้วย */
|
|
31
32
|
prefer?: string;
|
|
33
|
+
/**
|
|
34
|
+
* (0.8.0, hub เท่านั้น) วินาที 60–86400 — ส่งไม่สำเร็จชั่วคราวแล้วให้ Pi Notify เก็บเข้าคิวส่งซ้ำเองภายในเวลานี้
|
|
35
|
+
* ผล `queued: true` (`id` = deliveryId) · provider ในเครื่อง (SMTP/Resend ตรง) ไม่สนค่านี้
|
|
36
|
+
*/
|
|
37
|
+
deliverWithin?: number;
|
|
38
|
+
/**
|
|
39
|
+
* (0.8.0, hub เท่านั้น) กันส่งซ้ำ: เรียกซ้ำด้วย key + เนื้อหาเดิมได้ผลเดิม ไม่ส่งอีเมลซ้ำ (1–200 ตัวอักษร)
|
|
40
|
+
* ดูสถานะล่าสุดด้วย `getRequestStatus(key)`
|
|
41
|
+
*/
|
|
42
|
+
idempotencyKey?: string;
|
|
43
|
+
/**
|
|
44
|
+
* (0.9.0, hub เท่านั้น) ชื่อเทมเพลตอีเมลของแอปใน Pi Notify — hub render หัวเรื่อง/HTML/ข้อความจาก `data`
|
|
45
|
+
* ไม่ต้องใส่ subject/html/text · ส่งผ่าน provider ในเครื่องไม่ได้ (mailer ใช้เฉพาะ hub)
|
|
46
|
+
*/
|
|
47
|
+
template?: string;
|
|
48
|
+
/** ค่าตัวแปรของเทมเพลต (`{{name}}`, `{{#each items}}` …) */
|
|
49
|
+
data?: Record<string, unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* (0.10.0) ข้อความสำรองเมื่อติดต่อ Pi Notify ไม่ได้ — ส่งผ่าน provider ในเครื่อง (SMTP/Resend)
|
|
52
|
+
* เทมเพลตมาตรฐาน `auth.*` มีข้อความสำรองในตัวแล้ว ไม่ต้องใส่
|
|
53
|
+
*/
|
|
54
|
+
fallback?: {
|
|
55
|
+
subject: string;
|
|
56
|
+
text: string;
|
|
57
|
+
html?: string;
|
|
58
|
+
};
|
|
32
59
|
}
|
|
33
60
|
/** ข้อมูลตัวเลือกที่แอปควรรู้ — แค่ชื่อ/ประเภท/พร้อมใช้ไหม */
|
|
34
61
|
interface ProviderInfo {
|
|
@@ -61,6 +88,12 @@ interface NormalizedMessage {
|
|
|
61
88
|
headers: Record<string, string>;
|
|
62
89
|
tags: Record<string, string>;
|
|
63
90
|
prefer?: string;
|
|
91
|
+
/** (0.8.0) ส่งต่อไป hub — provider อื่นไม่ใช้ */
|
|
92
|
+
deliverWithin?: number;
|
|
93
|
+
idempotencyKey?: string;
|
|
94
|
+
/** (0.9.0) ส่งต่อไป hub — hub render เทมเพลตเอง */
|
|
95
|
+
template?: string;
|
|
96
|
+
data?: Record<string, unknown>;
|
|
64
97
|
/**
|
|
65
98
|
* ยกเลิกเมื่อ mailer หมดเวลา (timeoutMs) — provider ที่ใช้ fetch ควรส่งต่อเข้า fetch
|
|
66
99
|
* เพื่อไม่ให้คำขอค้างต่อหลัง mailer ไป failover แล้ว (ไม่ส่งมา = provider เขียนเองรุ่นเก่า ใช้ได้ตามเดิม)
|
|
@@ -87,6 +120,10 @@ interface SendResult {
|
|
|
87
120
|
/** true ถ้าตัวแรกที่ควรใช้ล้ม/ถูกข้าม แล้วไปสำเร็จที่ตัวสำรอง */
|
|
88
121
|
failedOver: boolean;
|
|
89
122
|
attempts: Attempt[];
|
|
123
|
+
/** (0.8.0) hub รับไว้ส่งซ้ำเอง (ส่ง deliverWithin) — ยังไม่ถึงผู้รับ, `id` = deliveryId ในคิวของ hub */
|
|
124
|
+
queued?: boolean;
|
|
125
|
+
/** (0.10.0) ส่งข้อความสำรองของเทมเพลตผ่าน provider ในเครื่อง เพราะติดต่อ Pi Notify ไม่ได้ */
|
|
126
|
+
fallback?: boolean;
|
|
90
127
|
}
|
|
91
128
|
interface Quota {
|
|
92
129
|
perHour?: number;
|
|
@@ -103,6 +140,7 @@ interface Provider {
|
|
|
103
140
|
readonly optionalFrom?: boolean;
|
|
104
141
|
send(msg: NormalizedMessage): Promise<{
|
|
105
142
|
id?: string;
|
|
143
|
+
queued?: boolean;
|
|
106
144
|
}>;
|
|
107
145
|
/** ทดสอบการเชื่อมต่อ/credential (ไม่ส่งอีเมล) */
|
|
108
146
|
verify?(): Promise<void>;
|
|
@@ -151,6 +189,11 @@ type MailerEvent = {
|
|
|
151
189
|
} | {
|
|
152
190
|
type: "exhausted";
|
|
153
191
|
attempts: Attempt[];
|
|
192
|
+
}
|
|
193
|
+
/** (0.10.0) ติดต่อ hub ไม่ได้ จึงส่งข้อความสำรองของเทมเพลตผ่าน provider ในเครื่อง */
|
|
194
|
+
| {
|
|
195
|
+
type: "fallback";
|
|
196
|
+
template: string;
|
|
154
197
|
};
|
|
155
198
|
interface QuotaUsage {
|
|
156
199
|
window: "hour" | "day" | "month";
|
|
@@ -322,6 +365,32 @@ declare function notify(input: NotifyInput, opts?: NotifyOptions): Promise<Notif
|
|
|
322
365
|
* @example await notifyTopic({ topic: "po-overdue", title: "PO เกินกำหนด 3 รายการ", body: "…", link });
|
|
323
366
|
*/
|
|
324
367
|
declare function notifyTopic(input: NotifyTopicInput, opts?: NotifyOptions): Promise<NotifyResult>;
|
|
368
|
+
/** (0.8.0) สถานะล่าสุดของคำขอที่ส่ง Idempotency-Key (ทั้ง sendMail และ notify/notifyTopic) */
|
|
369
|
+
interface RequestStatus {
|
|
370
|
+
key: string;
|
|
371
|
+
/** mail = sendMail, notify = notify()/notifyTopic() */
|
|
372
|
+
type: "mail" | "notify";
|
|
373
|
+
/** processing = ยังตอบคำขอแรกไม่เสร็จ · queued = ยังมีรายการรอส่งซ้ำ · partial = สำเร็จบางรายการ */
|
|
374
|
+
status: "processing" | "queued" | "sent" | "failed" | "partial";
|
|
375
|
+
/** รายการต่อผู้รับ/ช่องทาง — รายการที่เคย queued แสดงสถานะล่าสุดจริง (sent/failed/queued) */
|
|
376
|
+
results: NotifyResultItem[];
|
|
377
|
+
summary: {
|
|
378
|
+
sent: number;
|
|
379
|
+
failed: number;
|
|
380
|
+
skipped: number;
|
|
381
|
+
queued?: number;
|
|
382
|
+
deferred?: number;
|
|
383
|
+
};
|
|
384
|
+
createdAt: string;
|
|
385
|
+
/** เส้นตายการส่ง (createdAt + deliverWithin) */
|
|
386
|
+
expiresAt: string;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* (0.8.0) ถามสถานะล่าสุดของคำขอจาก Idempotency-Key — ไว้แสดง "ส่งแล้ว/รอส่งซ้ำ/ส่งไม่สำเร็จ" ในหน้าแอป
|
|
390
|
+
* โดยไม่ต้องยิงคำขอเดิม (ไฟล์แนบ) ซ้ำ · ไม่รู้จัก key (หรือเกิน 7 วัน hub ลบแล้ว) → `null`
|
|
391
|
+
* @example const s = await getRequestStatus(`memo-${memo.id}-final`); s?.status === "sent"
|
|
392
|
+
*/
|
|
393
|
+
declare function getRequestStatus(idempotencyKey: string, opts?: NotifyOptions): Promise<RequestStatus | null>;
|
|
325
394
|
/** ช่องทางที่ผู้รับผูกไว้ — ไว้แสดง "ผูกแล้ว/ยังไม่ผูก" ในหน้าตั้งค่าของแอป */
|
|
326
395
|
declare function getRecipient(email: Recipient, opts?: NotifyOptions): Promise<RecipientInfo>;
|
|
327
396
|
/**
|
|
@@ -377,5 +446,5 @@ declare function listPushDevices(email: string, opts?: NotifyOptions): Promise<{
|
|
|
377
446
|
devices: PushDevice[];
|
|
378
447
|
}>;
|
|
379
448
|
|
|
380
|
-
export {
|
|
381
|
-
export type { Address as A, ChannelStatus as C, ErrorKind as E, Message as M, NotifyOptions as N, ParsedAddress as P, Quota as Q, Recipient as R, SendResult as S, Provider as a, ProviderStatus as b, ProviderInfo as c, Store as d, MailerEvent as e, Attempt as f, PushDevice as g, NotifyResultItem as h, NotifyInput as i, NotifyTopicInput as j, Attachment as k, NormalizedMessage as m, NotifyChannel as n, NotifyResult as o, PushSubscriptionJson as p, QuotaUsage as q, RecipientChannelInfo as r, RecipientInfo as s,
|
|
449
|
+
export { getRequestStatus as B, hasNotifyKey as D, listPushDevices as F, notify as G, notifyTopic as H, savePushSubscription as I, toEmails as J, NOTIFY_TIMEOUT_MS as l, createLink as v, deletePushSubscription as w, getChannelStatus as x, getPushConfig as y, getRecipient as z };
|
|
450
|
+
export type { Address as A, ChannelStatus as C, ErrorKind as E, Message as M, NotifyOptions as N, ParsedAddress as P, Quota as Q, Recipient as R, SendResult as S, Provider as a, ProviderStatus as b, ProviderInfo as c, Store as d, MailerEvent as e, Attempt as f, PushDevice as g, NotifyResultItem as h, NotifyInput as i, NotifyTopicInput as j, Attachment as k, NormalizedMessage as m, NotifyChannel as n, NotifyResult as o, PushSubscriptionJson as p, QuotaUsage as q, RecipientChannelInfo as r, RecipientInfo as s, RequestStatus as t, SkipReason as u };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (0.9.0) ตัว render เทมเพลตอีเมล — ตัวเดียวกับที่ Pi Notify hub ใช้ (import จาก "@suphark/notify/template"
|
|
3
|
+
* ได้ทั้งฝั่ง server และ browser เพราะไม่พึ่ง node) ไวยากรณ์แบบ Mustache อย่างย่อ ไม่มีโค้ดรันได้:
|
|
4
|
+
*
|
|
5
|
+
* {{name}} {{po.no}} แทนค่า (HTML ถูก escape เสมอ — ใส่ HTML ดิบจากข้อมูลไม่ได้โดยตั้งใจ)
|
|
6
|
+
* {{#if note}}…{{else}}…{{/if}} แสดงเมื่อมีค่า (สตริงว่าง/0/false/null/array ว่าง = ไม่มี)
|
|
7
|
+
* {{#each items}}{{name}} {{@number}}{{/each}} วนรายการ — ข้างในอ้างฟิลด์ของรายการตรง ๆ หรือ {{this}}
|
|
8
|
+
* {{@index}} เริ่ม 0, {{@number}} เริ่ม 1; ชื่อที่ไม่มีในรายการหาจากข้อมูลชั้นนอก
|
|
9
|
+
*/
|
|
10
|
+
interface TemplateVariable {
|
|
11
|
+
/** ชื่อตัวแปร (path แบบ a.b ได้) */
|
|
12
|
+
name: string;
|
|
13
|
+
/** คำอธิบายสำหรับคนแก้เทมเพลต */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** ค่าตัวอย่างตอนดูตัวอย่าง */
|
|
16
|
+
sample?: string;
|
|
17
|
+
/** true = ส่งมาไม่ครบ → TemplateError (ไม่ส่งอีเมลที่ข้อมูลหาย) */
|
|
18
|
+
required?: boolean;
|
|
19
|
+
}
|
|
20
|
+
interface EmailTemplateSource {
|
|
21
|
+
subject: string;
|
|
22
|
+
html: string;
|
|
23
|
+
/** ว่าง = สร้างจาก HTML ที่ render แล้ว */
|
|
24
|
+
text?: string | null;
|
|
25
|
+
variables?: TemplateVariable[] | null;
|
|
26
|
+
}
|
|
27
|
+
declare class TemplateError extends Error {
|
|
28
|
+
/** ตัวแปรที่จำเป็นแต่ไม่ได้ส่งมา */
|
|
29
|
+
readonly missing: string[];
|
|
30
|
+
constructor(message: string,
|
|
31
|
+
/** ตัวแปรที่จำเป็นแต่ไม่ได้ส่งมา */
|
|
32
|
+
missing?: string[]);
|
|
33
|
+
}
|
|
34
|
+
type Data = Record<string, unknown>;
|
|
35
|
+
type Node = {
|
|
36
|
+
t: "text";
|
|
37
|
+
v: string;
|
|
38
|
+
} | {
|
|
39
|
+
t: "var";
|
|
40
|
+
path: string;
|
|
41
|
+
} | {
|
|
42
|
+
t: "if";
|
|
43
|
+
path: string;
|
|
44
|
+
yes: Node[];
|
|
45
|
+
no: Node[];
|
|
46
|
+
} | {
|
|
47
|
+
t: "each";
|
|
48
|
+
path: string;
|
|
49
|
+
body: Node[];
|
|
50
|
+
};
|
|
51
|
+
/** แยกเทมเพลตเป็นต้นไม้ — แท็กเปิด/ปิดไม่ครบหรือชื่อแปลกโยน TemplateError พร้อมตำแหน่ง */
|
|
52
|
+
declare function parseTemplate(source: string): Node[];
|
|
53
|
+
/** render เทมเพลตหนึ่งช่อง — `escape: true` สำหรับ HTML, false สำหรับหัวเรื่อง/ข้อความล้วน */
|
|
54
|
+
declare function renderTemplate(source: string, data: Data, opts: {
|
|
55
|
+
escape: boolean;
|
|
56
|
+
}): string;
|
|
57
|
+
/** ชื่อตัวแปรชั้นบนสุดที่เทมเพลตใช้ (ไม่รวมที่อยู่ใน {{#each}}) — ไว้ช่วยกรอกรายการตัวแปร */
|
|
58
|
+
declare function templateVariableNames(...sources: string[]): string[];
|
|
59
|
+
/** ค่าตัวอย่างจากรายการตัวแปร (path a.b → ซ้อน object) */
|
|
60
|
+
declare function sampleData(variables: TemplateVariable[] | null | undefined): Data;
|
|
61
|
+
/** layout มาตรฐานเมื่อ HTML ไม่มี <html> ของตัวเอง — กว้าง 600px อ่านง่ายทุกโปรแกรมอีเมล */
|
|
62
|
+
declare function wrapEmailLayout(body: string, opts?: {
|
|
63
|
+
appName?: string;
|
|
64
|
+
title?: string;
|
|
65
|
+
}): string;
|
|
66
|
+
/**
|
|
67
|
+
* render อีเมลทั้งฉบับจากเทมเพลต: หัวเรื่อง (ไม่ escape) · HTML (escape + ครอบ layout ถ้าไม่มี <html>) · ข้อความล้วน
|
|
68
|
+
* ตัวแปร `required` ที่ไม่มีค่า → TemplateError (`missing`) — ไม่ส่งอีเมลที่ข้อมูลหาย
|
|
69
|
+
*/
|
|
70
|
+
declare function renderEmailTemplate(t: EmailTemplateSource, data: Data, opts?: {
|
|
71
|
+
appName?: string;
|
|
72
|
+
}): {
|
|
73
|
+
subject: string;
|
|
74
|
+
html: string;
|
|
75
|
+
text: string;
|
|
76
|
+
};
|
|
77
|
+
/** ชื่อเทมเพลตมาตรฐาน — ทุกแอปใช้ได้โดยไม่ต้องสร้าง (สร้างชื่อเดียวกันใน Pi Notify เพื่อปรับเฉพาะแอป) */
|
|
78
|
+
declare const BUILTIN_TEMPLATE_KEYS: readonly ["auth.magic-link", "auth.reset-password", "auth.verify-email"];
|
|
79
|
+
type BuiltinTemplateKey = (typeof BUILTIN_TEMPLATE_KEYS)[number];
|
|
80
|
+
declare function isBuiltinTemplate(key: string): key is BuiltinTemplateKey;
|
|
81
|
+
/** ฉบับเต็ม (hub ใช้เมื่อแอปไม่ได้สร้างเทมเพลตชื่อนี้เอง) */
|
|
82
|
+
declare const BUILTIN_TEMPLATES: Record<BuiltinTemplateKey, EmailTemplateSource & {
|
|
83
|
+
name: string;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* ข้อความสำรองแบบพื้นฐาน (ไม่สวย แต่ใช้ล็อกอินได้) — SDK ส่งผ่าน provider ในเครื่องเมื่อติดต่อ Pi Notify ไม่ได้
|
|
87
|
+
* คืน null เมื่อไม่ใช่เทมเพลตมาตรฐานหรือไม่มี url
|
|
88
|
+
*/
|
|
89
|
+
declare function builtinFallback(key: string, data: Record<string, unknown> | undefined): {
|
|
90
|
+
subject: string;
|
|
91
|
+
text: string;
|
|
92
|
+
html: string;
|
|
93
|
+
} | null;
|
|
94
|
+
|
|
95
|
+
export { BUILTIN_TEMPLATES, BUILTIN_TEMPLATE_KEYS, TemplateError, builtinFallback, isBuiltinTemplate, parseTemplate, renderEmailTemplate, renderTemplate, sampleData, templateVariableNames, wrapEmailLayout };
|
|
96
|
+
export type { BuiltinTemplateKey, EmailTemplateSource, TemplateVariable };
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILTIN_TEMPLATES,
|
|
3
|
+
BUILTIN_TEMPLATE_KEYS,
|
|
4
|
+
TemplateError,
|
|
5
|
+
builtinFallback,
|
|
6
|
+
isBuiltinTemplate,
|
|
7
|
+
parseTemplate,
|
|
8
|
+
renderEmailTemplate,
|
|
9
|
+
renderTemplate,
|
|
10
|
+
sampleData,
|
|
11
|
+
templateVariableNames,
|
|
12
|
+
wrapEmailLayout
|
|
13
|
+
} from "./chunk-NNMV7FZ3.js";
|
|
14
|
+
export {
|
|
15
|
+
BUILTIN_TEMPLATES,
|
|
16
|
+
BUILTIN_TEMPLATE_KEYS,
|
|
17
|
+
TemplateError,
|
|
18
|
+
builtinFallback,
|
|
19
|
+
isBuiltinTemplate,
|
|
20
|
+
parseTemplate,
|
|
21
|
+
renderEmailTemplate,
|
|
22
|
+
renderTemplate,
|
|
23
|
+
sampleData,
|
|
24
|
+
templateVariableNames,
|
|
25
|
+
wrapEmailLayout
|
|
26
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ส่งอีเมล + แจ้งเตือน Telegram ผ่าน Pi Notify hub (notify.suphark.com) หรือส่งอีเมลตรงไป Resend/SMTP/Zoho/ZeptoMail ใน API เดียว พร้อม failover อัตโนมัติ",
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
"types": "./dist/next.d.ts",
|
|
15
15
|
"import": "./dist/next.js",
|
|
16
16
|
"default": "./dist/next.js"
|
|
17
|
+
},
|
|
18
|
+
"./template": {
|
|
19
|
+
"types": "./dist/template.d.ts",
|
|
20
|
+
"import": "./dist/template.js",
|
|
21
|
+
"default": "./dist/template.js"
|
|
17
22
|
}
|
|
18
23
|
},
|
|
19
24
|
"files": [
|