@suphark/notify 0.7.0 → 0.9.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 CHANGED
@@ -41,6 +41,65 @@ 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
+ ### อีเมลที่ต้องถึงแน่: `deliverWithin` + `idempotencyKey` กับ `sendMail` (0.8.0)
76
+
77
+ ```ts
78
+ import { getRequestStatus, sendMail } from "@suphark/notify";
79
+
80
+ const key = `memo:${memo.id}:final-pdf`; // ผูกกับเหตุการณ์ ไม่สุ่มใหม่ทุกครั้ง
81
+ const r = await sendMail({
82
+ to: approvers,
83
+ subject: `Memo ${memo.no} ฉบับสุดท้าย`,
84
+ html,
85
+ attachments: [{ filename: "memo.pdf", content: pdfBuffer }],
86
+ deliverWithin: 6 * 3600, // ส่งไม่สำเร็จชั่วคราว → hub เก็บเข้าคิวส่งซ้ำเองภายใน 6 ชม.
87
+ idempotencyKey: key, // เรียกซ้ำ (cron/กดซ้ำ) ด้วยเนื้อหาเดิม = ได้ผลเดิม ไม่ส่งอีเมลซ้ำ
88
+ });
89
+ r.queued; // true = hub รับไว้ส่งซ้ำแล้ว (r.id = deliveryId) — แอปไม่ต้องมีคิวของตัวเอง
90
+
91
+ // หน้าแอปแสดงสถานะ — ไม่ต้องส่งเนื้อหา/ไฟล์แนบซ้ำ
92
+ const s = await getRequestStatus(key); // null = ไม่รู้จัก key (หรือเกิน 7 วัน)
93
+ s?.status; // "queued" | "sent" | "failed" | "partial" | "processing"
94
+ ```
95
+
96
+ - ใช้ได้เฉพาะเมื่อส่งผ่าน hub (`PI_NOTIFY_KEY`) — provider ในเครื่องไม่สนสองค่านี้
97
+ - เข้าคิวเฉพาะความผิดพลาดชั่วคราว (เครือข่าย, 5xx, rate limit, provider ล้มทุกตัว) — ข้อความผิด/ที่อยู่ผิดโยน error ทันทีเหมือนเดิม
98
+ - `queued` นับเป็นสำเร็จฝั่งแอป: mailer **ไม่** failover ไป provider สำรองในเครื่อง (กันอีเมลซ้ำ)
99
+ - เนื้ออีเมลและไฟล์แนบเก็บใน hub เฉพาะระหว่างรอส่งซ้ำ ส่งได้/เลิกส่งแล้วลบทิ้ง เหลือแค่สถานะ
100
+ - ขนาดคำขอทั้งหมด (รวมไฟล์แนบ base64) ต้องไม่เกิน ~4.5MB (เพดานของ Vercel) → ไฟล์แนบดิบรวมราว 3MB
101
+ - `getRequestStatus(key)` ใช้กับ `notify()` / `notifyTopic()` ที่ส่ง `idempotencyKey` ได้ด้วย
102
+
44
103
  ### สำรองเผื่อ hub ล่ม (ไม่บังคับ)
45
104
 
46
105
  ใส่ provider local ต่อท้ายได้ — hub มาก่อนเสมอ ถ้า hub ตอบไม่ได้ SDK จะ failover ไปตัวถัดไปเอง:
@@ -428,6 +487,13 @@ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ b
428
487
 
429
488
  ## Changelog
430
489
 
490
+ - 0.9.0 — **เทมเพลตอีเมลที่ hub** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
491
+ - `sendMail({ to, template, data })` — hub render เทมเพลตของแอป (`subject` ไม่บังคับเมื่อใช้ template)
492
+ - `listTemplates` / `getTemplate` / `saveTemplate` / `deleteTemplate` / `previewTemplate` — จัดการเทมเพลตจากแอป
493
+ - `@suphark/notify/template`: `renderEmailTemplate`, `renderTemplate`, `sampleData`, `templateVariableNames`, `TemplateError`
494
+ - 0.8.0 — **อีเมลส่งซ้ำผ่าน hub + ถามสถานะ** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
495
+ - `sendMail({ …, deliverWithin, idempotencyKey })` — hub เก็บเข้าคิวส่งซ้ำเมื่อล้มชั่วคราว (`SendResult.queued`) และกันส่งซ้ำด้วย key
496
+ - `getRequestStatus(key)` — สถานะล่าสุดของคำขอ sendMail / notify / notifyTopic ที่ส่ง key (`GET /api/v1/requests/:key`)
431
497
  - 0.7.0 — **LINE + ส่งเข้ากลุ่มด้วย topic** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
432
498
  - `createLink(email, "line")` / `getChannelStatus(email, "line")` / `notify({ channels: ["line"] })` — ช่องทาง LINE รายคน
433
499
  - `notifyTopic({ topic, title, body, … })` + `notifyTopicSafe()` + `notifyTopicLater()` (`/next`) — ส่งเข้าทุกกลุ่ม LINE/Telegram ที่ admin ผูกกับ topic ของแอป
@@ -483,17 +483,24 @@ function createHubProvider(opts) {
483
483
  cc: msg.cc.length ? msg.cc : void 0,
484
484
  bcc: msg.bcc.length ? msg.bcc : void 0,
485
485
  replyTo: msg.replyTo.length ? msg.replyTo : void 0,
486
- subject: msg.subject,
486
+ subject: msg.subject || (msg.template ? void 0 : msg.subject),
487
487
  html: msg.html,
488
488
  text: msg.text,
489
489
  attachments: msg.attachments.length ? msg.attachments : void 0,
490
490
  headers: Object.keys(msg.headers).length ? msg.headers : void 0,
491
491
  tags: Object.keys(msg.tags).length ? msg.tags : void 0,
492
- prefer: msg.prefer
492
+ prefer: msg.prefer,
493
+ deliverWithin: msg.deliverWithin,
494
+ template: msg.template,
495
+ data: msg.template ? msg.data ?? {} : void 0
493
496
  };
494
497
  const res = await doFetch(url, {
495
498
  method: "POST",
496
- headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
499
+ headers: {
500
+ Authorization: `Bearer ${opts.apiKey}`,
501
+ "Content-Type": "application/json",
502
+ ...msg.idempotencyKey ? { "Idempotency-Key": msg.idempotencyKey } : {}
503
+ },
497
504
  body: JSON.stringify(body),
498
505
  // mailer หมดเวลา → abort คำขอนี้ทิ้ง (Pi Notify อาจส่งไปแล้ว — แอปที่มีตัวสำรองในเครื่องอาจได้เมลซ้ำ ยอมรับได้)
499
506
  signal: msg.signal
@@ -510,7 +517,9 @@ function createHubProvider(opts) {
510
517
  retryAfterSec: retryAfterOf(res, data)
511
518
  });
512
519
  }
513
- return { id: data.id };
520
+ const ok = data;
521
+ if (ok.status === "queued") return { id: ok.deliveryId, queued: true };
522
+ return { id: ok.id };
514
523
  },
515
524
  async listProviders() {
516
525
  const res = await doFetch(url.replace(/\/send$/, "/providers"), {
@@ -851,7 +860,7 @@ function createMailer(options = {}) {
851
860
  cc: parseAddressList(m.cc),
852
861
  bcc: parseAddressList(m.bcc),
853
862
  replyTo: parseAddressList(m.replyTo),
854
- subject: m.subject,
863
+ subject: m.subject ?? "",
855
864
  html: m.html,
856
865
  text: m.text,
857
866
  attachments: (m.attachments ?? []).map((a) => ({
@@ -861,7 +870,11 @@ function createMailer(options = {}) {
861
870
  })),
862
871
  headers: m.headers ?? {},
863
872
  tags: m.tags ?? {},
864
- prefer: m.prefer
873
+ prefer: m.prefer,
874
+ deliverWithin: m.deliverWithin,
875
+ idempotencyKey: m.idempotencyKey,
876
+ template: m.template,
877
+ data: m.data
865
878
  };
866
879
  } catch (err) {
867
880
  throw new MailError(err.message, { kind: "invalid", cause: err });
@@ -902,10 +915,20 @@ function createMailer(options = {}) {
902
915
  async function send(message) {
903
916
  if (!message?.to || Array.isArray(message.to) && message.to.length === 0)
904
917
  throw new MailError("Message has no recipients (to)", { kind: "invalid" });
905
- if (!message.subject) throw new MailError("Message has no subject", { kind: "invalid" });
906
- if (message.html === void 0 && message.text === void 0)
907
- throw new MailError("Message needs html or text", { kind: "invalid" });
918
+ if (!message.template) {
919
+ if (!message.subject) throw new MailError("Message has no subject", { kind: "invalid" });
920
+ if (message.html === void 0 && message.text === void 0)
921
+ throw new MailError("Message needs html or text", { kind: "invalid" });
922
+ }
908
923
  let candidates = order();
924
+ if (message.template) {
925
+ candidates = candidates.filter((e) => e.provider.type === "hub");
926
+ if (candidates.length === 0)
927
+ throw new MailError(
928
+ `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`,
929
+ { kind: "invalid" }
930
+ );
931
+ }
909
932
  if (message.prefer) {
910
933
  const i = candidates.findIndex((e) => e.provider.name === message.prefer);
911
934
  if (i > 0) candidates = [candidates[i], ...candidates.filter((_, j) => j !== i)];
@@ -922,8 +945,9 @@ function createMailer(options = {}) {
922
945
  const p = entry.provider;
923
946
  const started = Date.now();
924
947
  let id;
948
+ let queued;
925
949
  try {
926
- ({ id } = await withTimeout(
950
+ ({ id, queued } = await withTimeout(
927
951
  (signal) => {
928
952
  const normalized = {
929
953
  ...base,
@@ -953,7 +977,7 @@ function createMailer(options = {}) {
953
977
  });
954
978
  const failedOver = opts.failedOver();
955
979
  emit({ type: "sent", provider: p.name, id, ms, failedOver });
956
- return { id, provider: p.name, failedOver, attempts };
980
+ return { id, provider: p.name, failedOver, attempts, ...queued ? { queued: true } : {} };
957
981
  };
958
982
  for (const entry of candidates) {
959
983
  const p = entry.provider;
@@ -1145,6 +1169,18 @@ async function notifyTopic(input, opts = {}) {
1145
1169
  const { idempotencyKey, ...body } = input;
1146
1170
  return postNotify(body, idempotencyKey, opts);
1147
1171
  }
1172
+ async function getRequestStatus(idempotencyKey, opts = {}) {
1173
+ try {
1174
+ return await hubRequest(
1175
+ opts,
1176
+ `/api/v1/requests/${encodeURIComponent(idempotencyKey)}`,
1177
+ { timeoutMs: NOTIFY_TIMEOUT_MS.recipient }
1178
+ );
1179
+ } catch (err) {
1180
+ if (err instanceof MailError && err.status === 404) return null;
1181
+ throw err;
1182
+ }
1183
+ }
1148
1184
  async function getRecipient(email, opts = {}) {
1149
1185
  return hubRequest(
1150
1186
  opts,
@@ -1292,8 +1328,10 @@ export {
1292
1328
  toEmails,
1293
1329
  NOTIFY_TIMEOUT_MS,
1294
1330
  hasNotifyKey,
1331
+ hubRequest,
1295
1332
  notify,
1296
1333
  notifyTopic,
1334
+ getRequestStatus,
1297
1335
  getRecipient,
1298
1336
  createLink,
1299
1337
  getChannelStatus,
@@ -0,0 +1,193 @@
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("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&amp;", "&").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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
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
+
182
+ export {
183
+ looksLikeHtml,
184
+ resolveFormat,
185
+ toPlainText,
186
+ TemplateError,
187
+ parseTemplate,
188
+ renderTemplate,
189
+ templateVariableNames,
190
+ sampleData,
191
+ wrapEmailLayout,
192
+ renderEmailTemplate
193
+ };
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-D4auDRfk.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 SkipReason, u as createLink, v as deletePushSubscription, w as getChannelStatus, x as getPushConfig, y as getRecipient, z as hasNotifyKey, B as listPushDevices, D as notify, F as notifyTopic, G as savePushSubscription, H as toEmails } from './notify-D4auDRfk.js';
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-Dgzxhh-U.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-Dgzxhh-U.js';
3
+ import { TemplateVariable } from './template.js';
4
+ export { TemplateError, 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
- subject: string;
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,52 @@ declare function createRedisStore(client: RedisLike, opts?: {
457
468
  prefix?: string;
458
469
  }): Store;
459
470
 
460
- 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, classifyError, configureMailer, createConsoleProvider, createHubProvider, createMailer, createMemoryStore, createPresetSmtpProvider, createPushHandlers, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, formatAddress, getMailer, listProviders, looksLikeHtml, notifySafe, notifyTopicSafe, parseAddress, parseAddressList, providersFromEnv, pushServiceWorker, pushServiceWorkerSource, renderNotifyEmail, requireFrom, resetMailer, resetSafeWarnings, resolveFormat, sendMail, sendMailSafe, toPlainText };
461
- export type { BodyFormat, BrowserPushSubscription, ConsoleOptions, Env, HubErrorResponse, HubOptions, HubSendRequest, HubSendResponse, Mailer, MailerOptions, NotifyEmailInput, NotifySafeResult, ProviderConfig, PushHandlers, PushHandlersOptions, PushServiceWorkerOptions, PushUser, RedisLike, ResendOptions, SendMailSafeResult, SmtpOptions, SmtpPreset, ZeptoMailOptions };
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
+ }
483
+ interface EmailTemplateInput {
484
+ name: string;
485
+ /** ใส่ {{ตัวแปร}} ได้ */
486
+ subject: string;
487
+ /** {{ตัวแปร}} ถูก escape เสมอ · {{#if}} / {{#each}} ได้ · ไม่มี <html> = hub ครอบ layout มาตรฐานให้ */
488
+ html: string;
489
+ /** ว่าง = สร้างจาก HTML */
490
+ text?: string | null;
491
+ variables?: TemplateVariable[];
492
+ enabled?: boolean;
493
+ }
494
+ /** (0.9.0) เทมเพลตทั้งหมดของแอป */
495
+ declare function listTemplates(opts?: NotifyOptions): Promise<EmailTemplateInfo[]>;
496
+ /** (0.9.0) เทมเพลตหนึ่งตัว — ไม่มี = null */
497
+ declare function getTemplate(key: string, opts?: NotifyOptions): Promise<EmailTemplateInfo | null>;
498
+ /**
499
+ * (0.9.0) สร้าง/แก้เทมเพลต (upsert ตาม key) — hub ตรวจไวยากรณ์ก่อนบันทึก (ผิด = MailError invalid 422)
500
+ * @example await saveTemplate("po-approved", { name: "PO อนุมัติ", subject: "PO {{po}} อนุมัติแล้ว", html })
501
+ */
502
+ declare function saveTemplate(key: string, input: EmailTemplateInput, opts?: NotifyOptions): Promise<EmailTemplateInfo>;
503
+ /** (0.9.0) ลบเทมเพลต — คืน false เมื่อไม่มีอยู่แล้ว */
504
+ declare function deleteTemplate(key: string, opts?: NotifyOptions): Promise<boolean>;
505
+ /**
506
+ * (0.9.0) ดูตัวอย่างอีเมลที่ render แล้ว (ไม่ส่ง) — ไม่ส่ง data = ใช้ค่าตัวอย่าง (`variables[].sample`)
507
+ * ส่ง `template` = ลองเนื้อหาที่ยังไม่บันทึก
508
+ */
509
+ declare function previewTemplate(key: string, input?: {
510
+ data?: Record<string, unknown>;
511
+ template?: EmailTemplateInput;
512
+ }, opts?: NotifyOptions): Promise<{
513
+ subject: string;
514
+ html: string;
515
+ text: string;
516
+ }>;
517
+
518
+ 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 };
519
+ 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,14 @@ import {
39
41
  sendMail,
40
42
  sendMailSafe,
41
43
  toEmails
42
- } from "./chunk-2RXN5HXT.js";
43
-
44
- // src/format.ts
45
- 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;
46
- function looksLikeHtml(s) {
47
- return KNOWN_TAG_RE.test(s);
48
- }
49
- function resolveFormat(body, format) {
50
- if (format === "text" || format === "html") return format;
51
- return looksLikeHtml(body) ? "html" : "text";
52
- }
53
- function toPlainText(html) {
54
- return html.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&amp;", "&").trim();
55
- }
44
+ } from "./chunk-AAB6GK3Z.js";
45
+ import {
46
+ TemplateError,
47
+ looksLikeHtml,
48
+ renderEmailTemplate,
49
+ resolveFormat,
50
+ toPlainText
51
+ } from "./chunk-XVD6P4HN.js";
56
52
 
57
53
  // src/push.ts
58
54
  function createPushHandlers(options) {
@@ -189,6 +185,46 @@ function createRedisStore(client, opts = {}) {
189
185
  }
190
186
  };
191
187
  }
188
+
189
+ // src/templates-api.ts
190
+ var path = (key) => `/api/v1/templates/${encodeURIComponent(key)}`;
191
+ async function listTemplates(opts = {}) {
192
+ const data = await hubRequest(opts, "/api/v1/templates", {
193
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
194
+ });
195
+ return data.templates;
196
+ }
197
+ async function getTemplate(key, opts = {}) {
198
+ try {
199
+ return await hubRequest(opts, path(key), {
200
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
201
+ });
202
+ } catch (err) {
203
+ if (err instanceof MailError && err.status === 404) return null;
204
+ throw err;
205
+ }
206
+ }
207
+ async function saveTemplate(key, input, opts = {}) {
208
+ return hubRequest(opts, path(key), {
209
+ method: "PUT",
210
+ body: input,
211
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
212
+ });
213
+ }
214
+ async function deleteTemplate(key, opts = {}) {
215
+ const data = await hubRequest(opts, path(key), {
216
+ method: "DELETE",
217
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
218
+ });
219
+ return data.deleted;
220
+ }
221
+ async function previewTemplate(key, input = {}, opts = {}) {
222
+ return hubRequest(opts, `${path(key)}/preview`, {
223
+ method: "POST",
224
+ body: input,
225
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
226
+ });
227
+ }
192
228
  export {
193
229
  AllProvidersFailedError,
194
230
  DEFAULT_COOLDOWN,
@@ -196,6 +232,7 @@ export {
196
232
  MailError,
197
233
  NOTIFY_TIMEOUT_MS,
198
234
  SMTP_PRESETS,
235
+ TemplateError,
199
236
  classifyError,
200
237
  configureMailer,
201
238
  createConsoleProvider,
@@ -210,14 +247,18 @@ export {
210
247
  createSmtpProvider,
211
248
  createZeptoMailProvider,
212
249
  deletePushSubscription,
250
+ deleteTemplate,
213
251
  formatAddress,
214
252
  getChannelStatus,
215
253
  getMailer,
216
254
  getPushConfig,
217
255
  getRecipient,
256
+ getRequestStatus,
257
+ getTemplate,
218
258
  hasNotifyKey,
219
259
  listProviders,
220
260
  listPushDevices,
261
+ listTemplates,
221
262
  looksLikeHtml,
222
263
  notify,
223
264
  notifySafe,
@@ -225,15 +266,18 @@ export {
225
266
  notifyTopicSafe,
226
267
  parseAddress,
227
268
  parseAddressList,
269
+ previewTemplate,
228
270
  providersFromEnv,
229
271
  pushServiceWorker,
230
272
  pushServiceWorkerSource,
273
+ renderEmailTemplate,
231
274
  renderNotifyEmail,
232
275
  requireFrom,
233
276
  resetMailer,
234
277
  resetSafeWarnings,
235
278
  resolveFormat,
236
279
  savePushSubscription,
280
+ saveTemplate,
237
281
  sendMail,
238
282
  sendMailSafe,
239
283
  toEmails,
package/dist/next.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  notifySafe,
3
3
  notifyTopicSafe,
4
4
  sendMailSafe
5
- } from "./chunk-2RXN5HXT.js";
5
+ } from "./chunk-AAB6GK3Z.js";
6
6
 
7
7
  // src/next.ts
8
8
  import { after } from "next/server";
@@ -13,7 +13,8 @@ interface Attachment {
13
13
  /** ข้อความที่แอปส่งเข้ามา (ไม่ผูกกับ provider ใด ๆ) */
14
14
  interface Message {
15
15
  to: Address | Address[];
16
- subject: string;
16
+ /** ไม่ต้องใส่เมื่อใช้ `template` (หัวเรื่องมาจากเทมเพลต) */
17
+ subject?: string;
17
18
  html?: string;
18
19
  text?: string;
19
20
  /** override from ของ provider/ค่าเริ่มต้น */
@@ -29,6 +30,23 @@ 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>;
32
50
  }
33
51
  /** ข้อมูลตัวเลือกที่แอปควรรู้ — แค่ชื่อ/ประเภท/พร้อมใช้ไหม */
34
52
  interface ProviderInfo {
@@ -61,6 +79,12 @@ interface NormalizedMessage {
61
79
  headers: Record<string, string>;
62
80
  tags: Record<string, string>;
63
81
  prefer?: string;
82
+ /** (0.8.0) ส่งต่อไป hub — provider อื่นไม่ใช้ */
83
+ deliverWithin?: number;
84
+ idempotencyKey?: string;
85
+ /** (0.9.0) ส่งต่อไป hub — hub render เทมเพลตเอง */
86
+ template?: string;
87
+ data?: Record<string, unknown>;
64
88
  /**
65
89
  * ยกเลิกเมื่อ mailer หมดเวลา (timeoutMs) — provider ที่ใช้ fetch ควรส่งต่อเข้า fetch
66
90
  * เพื่อไม่ให้คำขอค้างต่อหลัง mailer ไป failover แล้ว (ไม่ส่งมา = provider เขียนเองรุ่นเก่า ใช้ได้ตามเดิม)
@@ -87,6 +111,8 @@ interface SendResult {
87
111
  /** true ถ้าตัวแรกที่ควรใช้ล้ม/ถูกข้าม แล้วไปสำเร็จที่ตัวสำรอง */
88
112
  failedOver: boolean;
89
113
  attempts: Attempt[];
114
+ /** (0.8.0) hub รับไว้ส่งซ้ำเอง (ส่ง deliverWithin) — ยังไม่ถึงผู้รับ, `id` = deliveryId ในคิวของ hub */
115
+ queued?: boolean;
90
116
  }
91
117
  interface Quota {
92
118
  perHour?: number;
@@ -103,6 +129,7 @@ interface Provider {
103
129
  readonly optionalFrom?: boolean;
104
130
  send(msg: NormalizedMessage): Promise<{
105
131
  id?: string;
132
+ queued?: boolean;
106
133
  }>;
107
134
  /** ทดสอบการเชื่อมต่อ/credential (ไม่ส่งอีเมล) */
108
135
  verify?(): Promise<void>;
@@ -322,6 +349,32 @@ declare function notify(input: NotifyInput, opts?: NotifyOptions): Promise<Notif
322
349
  * @example await notifyTopic({ topic: "po-overdue", title: "PO เกินกำหนด 3 รายการ", body: "…", link });
323
350
  */
324
351
  declare function notifyTopic(input: NotifyTopicInput, opts?: NotifyOptions): Promise<NotifyResult>;
352
+ /** (0.8.0) สถานะล่าสุดของคำขอที่ส่ง Idempotency-Key (ทั้ง sendMail และ notify/notifyTopic) */
353
+ interface RequestStatus {
354
+ key: string;
355
+ /** mail = sendMail, notify = notify()/notifyTopic() */
356
+ type: "mail" | "notify";
357
+ /** processing = ยังตอบคำขอแรกไม่เสร็จ · queued = ยังมีรายการรอส่งซ้ำ · partial = สำเร็จบางรายการ */
358
+ status: "processing" | "queued" | "sent" | "failed" | "partial";
359
+ /** รายการต่อผู้รับ/ช่องทาง — รายการที่เคย queued แสดงสถานะล่าสุดจริง (sent/failed/queued) */
360
+ results: NotifyResultItem[];
361
+ summary: {
362
+ sent: number;
363
+ failed: number;
364
+ skipped: number;
365
+ queued?: number;
366
+ deferred?: number;
367
+ };
368
+ createdAt: string;
369
+ /** เส้นตายการส่ง (createdAt + deliverWithin) */
370
+ expiresAt: string;
371
+ }
372
+ /**
373
+ * (0.8.0) ถามสถานะล่าสุดของคำขอจาก Idempotency-Key — ไว้แสดง "ส่งแล้ว/รอส่งซ้ำ/ส่งไม่สำเร็จ" ในหน้าแอป
374
+ * โดยไม่ต้องยิงคำขอเดิม (ไฟล์แนบ) ซ้ำ · ไม่รู้จัก key (หรือเกิน 7 วัน hub ลบแล้ว) → `null`
375
+ * @example const s = await getRequestStatus(`memo-${memo.id}-final`); s?.status === "sent"
376
+ */
377
+ declare function getRequestStatus(idempotencyKey: string, opts?: NotifyOptions): Promise<RequestStatus | null>;
325
378
  /** ช่องทางที่ผู้รับผูกไว้ — ไว้แสดง "ผูกแล้ว/ยังไม่ผูก" ในหน้าตั้งค่าของแอป */
326
379
  declare function getRecipient(email: Recipient, opts?: NotifyOptions): Promise<RecipientInfo>;
327
380
  /**
@@ -377,5 +430,5 @@ declare function listPushDevices(email: string, opts?: NotifyOptions): Promise<{
377
430
  devices: PushDevice[];
378
431
  }>;
379
432
 
380
- export { listPushDevices as B, notify as D, notifyTopic as F, savePushSubscription as G, toEmails as H, NOTIFY_TIMEOUT_MS as l, createLink as u, deletePushSubscription as v, getChannelStatus as w, getPushConfig as x, getRecipient as y, hasNotifyKey as z };
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, SkipReason as t };
433
+ 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 };
434
+ 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,79 @@
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
+
78
+ export { TemplateError, parseTemplate, renderEmailTemplate, renderTemplate, sampleData, templateVariableNames, wrapEmailLayout };
79
+ export type { EmailTemplateSource, TemplateVariable };
@@ -0,0 +1,18 @@
1
+ import {
2
+ TemplateError,
3
+ parseTemplate,
4
+ renderEmailTemplate,
5
+ renderTemplate,
6
+ sampleData,
7
+ templateVariableNames,
8
+ wrapEmailLayout
9
+ } from "./chunk-XVD6P4HN.js";
10
+ export {
11
+ TemplateError,
12
+ parseTemplate,
13
+ renderEmailTemplate,
14
+ renderTemplate,
15
+ sampleData,
16
+ templateVariableNames,
17
+ wrapEmailLayout
18
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/notify",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": [