@suphark/notify 0.6.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 +109 -1
- package/dist/{chunk-AQWFHD5T.js → chunk-AAB6GK3Z.js} +69 -16
- package/dist/chunk-XVD6P4HN.js +193 -0
- package/dist/index.d.ts +65 -4
- package/dist/index.js +62 -14
- package/dist/next.d.ts +8 -2
- package/dist/next.js +17 -1
- package/dist/{notify-BM6FBQVP.d.ts → notify-Dgzxhh-U.d.ts} +83 -6
- package/dist/template.d.ts +79 -0
- package/dist/template.js +18 -0
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @suphark/notify
|
|
2
2
|
|
|
3
|
-
SDK ส่งอีเมล + แจ้งเตือน (Telegram / Web Push) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
|
|
3
|
+
SDK ส่งอีเมล + แจ้งเตือน (Telegram / LINE / Web Push) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot/LINE OA ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
|
|
4
4
|
|
|
5
5
|
> 📖 **คู่มือการใช้งานและสูตรการเขียนโค้ดฉบับเต็ม:** ดูที่ [docs/guide.md](../../docs/guide.md)
|
|
6
6
|
|
|
@@ -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 ไปตัวถัดไปเอง:
|
|
@@ -94,6 +153,43 @@ const { url, code } = await createLink(user); // รับ use
|
|
|
94
153
|
const { linked, label, manageUrl } = await getChannelStatus(user, "telegram");
|
|
95
154
|
```
|
|
96
155
|
|
|
156
|
+
### LINE (0.7.0)
|
|
157
|
+
|
|
158
|
+
ใช้เหมือน Telegram ทุกอย่าง — ผู้รับผูก LINE ของตัวเองครั้งเดียว (ที่ `/me` ของ hub หรือปุ่มในแอป) แล้วส่งด้วยอีเมล:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const { url, code } = await createLink(user, "line");
|
|
162
|
+
// url เปิดแชทกับ OA พร้อมพิมพ์ "ผูก Pi Notify <code>" ไว้ — ผู้ใช้กดส่ง (ยังไม่เป็นเพื่อนกับ OA ให้เพิ่มเพื่อนด้วย)
|
|
163
|
+
const { linked } = await getChannelStatus(user, "line");
|
|
164
|
+
await notify({ to: user, title: "PO รออนุมัติ", body: "…", link, channels: ["line"] });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- ข้อความ LINE เป็น text ล้วน: หัวเรื่อง / เนื้อหา / ลิงก์บรรทัดสุดท้าย (กดได้) — body ที่เป็น HTML ถูกแปลงเป็นข้อความ, ยาวเกิน 5,000 ตัดเนื้อหาแต่คงลิงก์
|
|
168
|
+
- ผู้ใช้บล็อก OA → hub ปิดการผูกให้เอง (ปลดบล็อกแล้วกลับมาเอง) · พิมพ์ "หยุดแจ้งเตือน" ในแชท OA = เลิกรับ
|
|
169
|
+
|
|
170
|
+
### ส่งเข้ากลุ่ม: `notifyTopic()` (0.7.0)
|
|
171
|
+
|
|
172
|
+
แอปไม่ต้องถือ group id — admin เชิญ OA/bot เข้ากลุ่ม LINE/Telegram (hub เห็นกลุ่มเองจาก webhook) แล้วผูกกลุ่มกับ **topic ของแอป** ที่หน้า Topics ของ Pi Notify
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { notifyTopic } from "@suphark/notify";
|
|
176
|
+
|
|
177
|
+
const r = await notifyTopic({
|
|
178
|
+
topic: "po-overdue", // topic ของแอปนี้ (a-z 0-9 . _ -)
|
|
179
|
+
title: "PO เกินกำหนด 3 รายการ",
|
|
180
|
+
body: "PO-2611-001, …",
|
|
181
|
+
link: "https://app.example.com/po?status=overdue",
|
|
182
|
+
deliverWithin: 3600, // ใช้ได้เหมือน notify()
|
|
183
|
+
idempotencyKey: `po-overdue:${date}`,
|
|
184
|
+
});
|
|
185
|
+
// r.results = [{ to: "topic:po-overdue", channel: "line", status: "sent", target: "ทีมจัดซื้อ" }, …] (รายกลุ่ม)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- topic ไม่พบ → `MailError` `invalid` (404) · topic ถูกปิด/ยังไม่ผูกกลุ่ม → `status: "skipped", reason: "no_target"` (ไม่โยน)
|
|
189
|
+
- `channels: ["line"]` = เฉพาะกลุ่ม LINE ของ topic · ไม่ระบุ = ทุกกลุ่ม
|
|
190
|
+
- แบบไม่โยน/เบื้องหลัง: `notifyTopicSafe()` และ `notifyTopicLater()` จาก `@suphark/notify/next`
|
|
191
|
+
- LINE คิดโควต้าตามข้อความ push — ยอดของเดือนดูที่หน้า Providers ของ Pi Notify
|
|
192
|
+
|
|
97
193
|
### ให้ hub ส่งซ้ำเอง: `deliverWithin` + `idempotencyKey` (0.6.0)
|
|
98
194
|
|
|
99
195
|
```ts
|
|
@@ -391,6 +487,18 @@ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ b
|
|
|
391
487
|
|
|
392
488
|
## Changelog
|
|
393
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`)
|
|
497
|
+
- 0.7.0 — **LINE + ส่งเข้ากลุ่มด้วย topic** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
|
|
498
|
+
- `createLink(email, "line")` / `getChannelStatus(email, "line")` / `notify({ channels: ["line"] })` — ช่องทาง LINE รายคน
|
|
499
|
+
- `notifyTopic({ topic, title, body, … })` + `notifyTopicSafe()` + `notifyTopicLater()` (`/next`) — ส่งเข้าทุกกลุ่ม LINE/Telegram ที่ admin ผูกกับ topic ของแอป
|
|
500
|
+
- `NotifyResultItem.target?` (ชื่อกลุ่ม) และ `reason` เพิ่ม `"no_target"` (ออกเป็น minor เพราะ union ของ `reason` ที่แอปอาจ switch)
|
|
501
|
+
|
|
394
502
|
- 0.6.0 — **hub ส่งซ้ำเอง** (เพิ่มอย่างเดียว — แอปที่ไม่ส่งตัวเลือกใหม่ได้พฤติกรรมเดิมทุกอย่าง; ต้องใช้ Pi Notify 2026-10-14+)
|
|
395
503
|
- `notify({ deliverWithin })` (วินาที 60–86400): รายการที่ค้างหรือพลาดชั่วคราวเข้าคิวถาวรของ hub แล้วส่งซ้ำตาม backoff (1 นาที → 5 นาที → 30 นาที → 2 ชม., สูงสุด 5 ครั้ง) · ผิดถาวรไม่ส่งซ้ำ · เลยเวลาแล้วไม่ส่ง · ไม่สลับช่องทาง
|
|
396
504
|
- `notify({ idempotencyKey })` → หัว `Idempotency-Key`: key+เนื้อหาเดิมได้ผลเดิม ไม่ส่งซ้ำ (จำ 7 วัน) · เนื้อหาต่างแต่ key เดิม = `invalid` (422) · คำขอเดิมยังทำอยู่ = `rate_limit` (409, `retryAfterSec` 5)
|
|
@@ -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: {
|
|
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
|
-
|
|
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.
|
|
906
|
-
|
|
907
|
-
|
|
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;
|
|
@@ -1128,16 +1152,35 @@ async function hubRequest(opts, path, init) {
|
|
|
1128
1152
|
}
|
|
1129
1153
|
return data;
|
|
1130
1154
|
}
|
|
1131
|
-
async function
|
|
1132
|
-
const { idempotencyKey, ...body } = input;
|
|
1155
|
+
async function postNotify(body, idempotencyKey, opts) {
|
|
1133
1156
|
const data = await hubRequest(opts, "/api/v1/notify", {
|
|
1134
1157
|
method: "POST",
|
|
1135
|
-
body
|
|
1158
|
+
body,
|
|
1136
1159
|
headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : void 0,
|
|
1137
1160
|
timeoutMs: NOTIFY_TIMEOUT_MS.notify
|
|
1138
1161
|
});
|
|
1139
1162
|
return { results: data.results, ...data.summary };
|
|
1140
1163
|
}
|
|
1164
|
+
async function notify(input, opts = {}) {
|
|
1165
|
+
const { idempotencyKey, ...body } = input;
|
|
1166
|
+
return postNotify({ ...body, to: toEmails(input.to) }, idempotencyKey, opts);
|
|
1167
|
+
}
|
|
1168
|
+
async function notifyTopic(input, opts = {}) {
|
|
1169
|
+
const { idempotencyKey, ...body } = input;
|
|
1170
|
+
return postNotify(body, idempotencyKey, opts);
|
|
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
|
+
}
|
|
1141
1184
|
async function getRecipient(email, opts = {}) {
|
|
1142
1185
|
return hubRequest(
|
|
1143
1186
|
opts,
|
|
@@ -1216,13 +1259,19 @@ function resetSafeWarnings() {
|
|
|
1216
1259
|
}
|
|
1217
1260
|
var messageOf = (err) => err instanceof Error ? err.message : String(err);
|
|
1218
1261
|
async function notifySafe(input, opts = {}) {
|
|
1262
|
+
return safely("notify()", () => notify(input, opts), opts);
|
|
1263
|
+
}
|
|
1264
|
+
async function notifyTopicSafe(input, opts = {}) {
|
|
1265
|
+
return safely("notifyTopic()", () => notifyTopic(input, opts), opts);
|
|
1266
|
+
}
|
|
1267
|
+
async function safely(what, run, opts) {
|
|
1219
1268
|
const none = { sent: 0, failed: 0, skipped: 0, results: [] };
|
|
1220
1269
|
if (!hasNotifyKey(opts)) {
|
|
1221
|
-
warnDisabled(
|
|
1270
|
+
warnDisabled(what, opts.env ?? process.env);
|
|
1222
1271
|
return { ok: false, disabled: true, ...none };
|
|
1223
1272
|
}
|
|
1224
1273
|
try {
|
|
1225
|
-
const r = await
|
|
1274
|
+
const r = await run();
|
|
1226
1275
|
return { ok: r.failed === 0, ...r };
|
|
1227
1276
|
} catch (err) {
|
|
1228
1277
|
const body = err instanceof MailError ? err.cause : void 0;
|
|
@@ -1279,7 +1328,10 @@ export {
|
|
|
1279
1328
|
toEmails,
|
|
1280
1329
|
NOTIFY_TIMEOUT_MS,
|
|
1281
1330
|
hasNotifyKey,
|
|
1331
|
+
hubRequest,
|
|
1282
1332
|
notify,
|
|
1333
|
+
notifyTopic,
|
|
1334
|
+
getRequestStatus,
|
|
1283
1335
|
getRecipient,
|
|
1284
1336
|
createLink,
|
|
1285
1337
|
getChannelStatus,
|
|
@@ -1289,5 +1341,6 @@ export {
|
|
|
1289
1341
|
listPushDevices,
|
|
1290
1342
|
resetSafeWarnings,
|
|
1291
1343
|
notifySafe,
|
|
1344
|
+
notifyTopicSafe,
|
|
1292
1345
|
sendMailSafe
|
|
1293
1346
|
};
|
|
@@ -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("<", "<").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
|
+
|
|
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 } from './notify-
|
|
2
|
-
export {
|
|
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
|
-
|
|
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 {
|
|
@@ -420,6 +431,8 @@ declare function resetSafeWarnings(): void;
|
|
|
420
431
|
* ไม่มี PI_NOTIFY_KEY → { ok: false, disabled: true } ไม่ส่ง
|
|
421
432
|
*/
|
|
422
433
|
declare function notifySafe(input: NotifyInput, opts?: NotifyOptions): Promise<NotifySafeResult>;
|
|
434
|
+
/** (0.7.0) notifyTopic() ที่ไม่โยนเด็ดขาด (ดู notifySafe) — topic ไม่พบก็คืน { ok: false, error } */
|
|
435
|
+
declare function notifyTopicSafe(input: NotifyTopicInput, opts?: NotifyOptions): Promise<NotifySafeResult>;
|
|
423
436
|
interface SendMailSafeResult {
|
|
424
437
|
ok: boolean;
|
|
425
438
|
provider?: string;
|
|
@@ -455,4 +468,52 @@ declare function createRedisStore(client: RedisLike, opts?: {
|
|
|
455
468
|
prefix?: string;
|
|
456
469
|
}): Store;
|
|
457
470
|
|
|
458
|
-
|
|
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,11 +22,15 @@ 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,
|
|
29
31
|
notifySafe,
|
|
32
|
+
notifyTopic,
|
|
33
|
+
notifyTopicSafe,
|
|
30
34
|
parseAddress,
|
|
31
35
|
parseAddressList,
|
|
32
36
|
providersFromEnv,
|
|
@@ -37,20 +41,14 @@ import {
|
|
|
37
41
|
sendMail,
|
|
38
42
|
sendMailSafe,
|
|
39
43
|
toEmails
|
|
40
|
-
} from "./chunk-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (format === "text" || format === "html") return format;
|
|
49
|
-
return looksLikeHtml(body) ? "html" : "text";
|
|
50
|
-
}
|
|
51
|
-
function toPlainText(html) {
|
|
52
|
-
return html.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("&", "&").trim();
|
|
53
|
-
}
|
|
44
|
+
} from "./chunk-AAB6GK3Z.js";
|
|
45
|
+
import {
|
|
46
|
+
TemplateError,
|
|
47
|
+
looksLikeHtml,
|
|
48
|
+
renderEmailTemplate,
|
|
49
|
+
resolveFormat,
|
|
50
|
+
toPlainText
|
|
51
|
+
} from "./chunk-XVD6P4HN.js";
|
|
54
52
|
|
|
55
53
|
// src/push.ts
|
|
56
54
|
function createPushHandlers(options) {
|
|
@@ -187,6 +185,46 @@ function createRedisStore(client, opts = {}) {
|
|
|
187
185
|
}
|
|
188
186
|
};
|
|
189
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
|
+
}
|
|
190
228
|
export {
|
|
191
229
|
AllProvidersFailedError,
|
|
192
230
|
DEFAULT_COOLDOWN,
|
|
@@ -194,6 +232,7 @@ export {
|
|
|
194
232
|
MailError,
|
|
195
233
|
NOTIFY_TIMEOUT_MS,
|
|
196
234
|
SMTP_PRESETS,
|
|
235
|
+
TemplateError,
|
|
197
236
|
classifyError,
|
|
198
237
|
configureMailer,
|
|
199
238
|
createConsoleProvider,
|
|
@@ -208,28 +247,37 @@ export {
|
|
|
208
247
|
createSmtpProvider,
|
|
209
248
|
createZeptoMailProvider,
|
|
210
249
|
deletePushSubscription,
|
|
250
|
+
deleteTemplate,
|
|
211
251
|
formatAddress,
|
|
212
252
|
getChannelStatus,
|
|
213
253
|
getMailer,
|
|
214
254
|
getPushConfig,
|
|
215
255
|
getRecipient,
|
|
256
|
+
getRequestStatus,
|
|
257
|
+
getTemplate,
|
|
216
258
|
hasNotifyKey,
|
|
217
259
|
listProviders,
|
|
218
260
|
listPushDevices,
|
|
261
|
+
listTemplates,
|
|
219
262
|
looksLikeHtml,
|
|
220
263
|
notify,
|
|
221
264
|
notifySafe,
|
|
265
|
+
notifyTopic,
|
|
266
|
+
notifyTopicSafe,
|
|
222
267
|
parseAddress,
|
|
223
268
|
parseAddressList,
|
|
269
|
+
previewTemplate,
|
|
224
270
|
providersFromEnv,
|
|
225
271
|
pushServiceWorker,
|
|
226
272
|
pushServiceWorkerSource,
|
|
273
|
+
renderEmailTemplate,
|
|
227
274
|
renderNotifyEmail,
|
|
228
275
|
requireFrom,
|
|
229
276
|
resetMailer,
|
|
230
277
|
resetSafeWarnings,
|
|
231
278
|
resolveFormat,
|
|
232
279
|
savePushSubscription,
|
|
280
|
+
saveTemplate,
|
|
233
281
|
sendMail,
|
|
234
282
|
sendMailSafe,
|
|
235
283
|
toEmails,
|
package/dist/next.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NotifyOptions, NotifyInput, NotifyTopicInput, Message } from './index.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ส่งหลังตอบ response แล้ว (`after()` ของ Next.js) — entry แยก `@suphark/notify/next` เพราะ core ของ SDK
|
|
@@ -19,10 +19,16 @@ declare const LATER_TIMEOUT_MS = 20000;
|
|
|
19
19
|
* @example notifyLater({ to: user, title: "บันทึกแล้ว", body: "ระบบอัปเดตงานแล้ว" });
|
|
20
20
|
*/
|
|
21
21
|
declare function notifyLater(input: NotifyInput, opts?: LaterOptions): void;
|
|
22
|
+
/**
|
|
23
|
+
* (0.7.0) notifyTopic() เบื้องหลัง (ดู notifyLater) — คืน void
|
|
24
|
+
* @example notifyTopicLater({ topic: "po-overdue", title: "PO เกินกำหนด", body: "…" });
|
|
25
|
+
*/
|
|
26
|
+
declare function notifyTopicLater(input: NotifyTopicInput, opts?: LaterOptions): void;
|
|
22
27
|
/**
|
|
23
28
|
* sendMail() เบื้องหลัง (ดู notifyLater) — คืน void
|
|
24
29
|
* @example sendMailLater({ to: user.email, subject: "ยินดีต้อนรับ", html: "<p>…</p>" });
|
|
25
30
|
*/
|
|
26
31
|
declare function sendMailLater(message: Message, opts?: Pick<LaterOptions, "onError">): void;
|
|
27
32
|
|
|
28
|
-
export { LATER_TIMEOUT_MS,
|
|
33
|
+
export { LATER_TIMEOUT_MS, notifyLater, notifyTopicLater, sendMailLater };
|
|
34
|
+
export type { LaterOptions };
|
package/dist/next.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
notifySafe,
|
|
3
|
+
notifyTopicSafe,
|
|
3
4
|
sendMailSafe
|
|
4
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-AAB6GK3Z.js";
|
|
5
6
|
|
|
6
7
|
// src/next.ts
|
|
7
8
|
import { after } from "next/server";
|
|
@@ -27,6 +28,20 @@ function notifyLater(input, opts = {}) {
|
|
|
27
28
|
report("notify()", r.error ?? (r.failed > 0 ? `${r.failed} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27` : void 0), onError);
|
|
28
29
|
});
|
|
29
30
|
}
|
|
31
|
+
function notifyTopicLater(input, opts = {}) {
|
|
32
|
+
const { onError, ...rest } = opts;
|
|
33
|
+
runLater(async () => {
|
|
34
|
+
const r = await notifyTopicSafe(input, {
|
|
35
|
+
timeoutMs: LATER_TIMEOUT_MS,
|
|
36
|
+
...rest
|
|
37
|
+
});
|
|
38
|
+
report(
|
|
39
|
+
"notifyTopic()",
|
|
40
|
+
r.error ?? (r.failed > 0 ? `${r.failed} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27` : void 0),
|
|
41
|
+
onError
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
30
45
|
function sendMailLater(message, opts = {}) {
|
|
31
46
|
runLater(async () => {
|
|
32
47
|
const r = await sendMailSafe(message);
|
|
@@ -36,5 +51,6 @@ function sendMailLater(message, opts = {}) {
|
|
|
36
51
|
export {
|
|
37
52
|
LATER_TIMEOUT_MS,
|
|
38
53
|
notifyLater,
|
|
54
|
+
notifyTopicLater,
|
|
39
55
|
sendMailLater
|
|
40
56
|
};
|
|
@@ -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,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>;
|
|
@@ -217,7 +244,18 @@ interface NotifyInput {
|
|
|
217
244
|
*/
|
|
218
245
|
idempotencyKey?: string;
|
|
219
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* (0.7.0) ส่งเข้า "topic" ของแอป — กลุ่ม/ห้อง LINE หรือกลุ่ม Telegram ที่ admin ผูกไว้ที่หน้า Topics ของ Pi Notify
|
|
249
|
+
* แอปไม่ต้องรู้ group id: เชิญ OA/bot เข้ากลุ่ม → hub เห็นกลุ่ม → admin ผูกกับ topic
|
|
250
|
+
*/
|
|
251
|
+
interface NotifyTopicInput extends Omit<NotifyInput, "to" | "channels" | "prefer"> {
|
|
252
|
+
/** ชื่อ topic ของแอปนี้ (a-z 0-9 . _ -) เช่น "po-overdue" — ไม่พบ = MailError invalid (404) */
|
|
253
|
+
topic: string;
|
|
254
|
+
/** ไม่ระบุ = ทุกปลายทางของ topic · ระบุ = เฉพาะแพลตฟอร์มนี้ */
|
|
255
|
+
channels?: ("line" | "telegram")[];
|
|
256
|
+
}
|
|
220
257
|
interface NotifyResultItem {
|
|
258
|
+
/** อีเมลผู้รับ หรือ "topic:<key>" เมื่อส่งด้วย notifyTopic() */
|
|
221
259
|
to: string;
|
|
222
260
|
channel: NotifyChannel;
|
|
223
261
|
/** `queued` (0.6.0) = hub รับไว้ส่งซ้ำเอง — เกิดเฉพาะเมื่อแอปส่ง `deliverWithin` */
|
|
@@ -229,12 +267,15 @@ interface NotifyResultItem {
|
|
|
229
267
|
/**
|
|
230
268
|
* app_daily_cap = แอปส่งอีเมลแทนแชทครบเพดานรายวันที่ตั้งใน Pi Notify แล้ว
|
|
231
269
|
* no_push_provider = hub ยังไม่ได้ตั้ง provider web push
|
|
270
|
+
* no_target (0.7.0) = topic ถูกปิด หรือยังไม่มีกลุ่มที่ใช้ได้ผูกอยู่
|
|
232
271
|
*/
|
|
233
|
-
reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "no_push_provider" | "app_daily_cap";
|
|
272
|
+
reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "no_push_provider" | "app_daily_cap" | "no_target";
|
|
234
273
|
/** (status failed) คำขอหมดงบเวลาก่อนได้เริ่มส่งรายการนี้ — ส่งซ้ำเฉพาะรายการนี้ได้ */
|
|
235
274
|
deferred?: boolean;
|
|
236
275
|
/** (0.6.0) id ของรายการในคิวส่งซ้ำของ hub — มีเมื่อ status เป็น queued */
|
|
237
276
|
deliveryId?: string;
|
|
277
|
+
/** (0.7.0) notifyTopic(): ชื่อกลุ่ม/ปลายทางของรายการนี้ */
|
|
278
|
+
target?: string;
|
|
238
279
|
}
|
|
239
280
|
interface NotifyResult {
|
|
240
281
|
results: NotifyResultItem[];
|
|
@@ -296,16 +337,51 @@ interface RecipientInfo {
|
|
|
296
337
|
/** มี PI_NOTIFY_KEY (ใน opts หรือ env) หรือไม่ — ให้ helper แบบ safe/later ใช้ตัดสินว่า "ปิดอยู่" */
|
|
297
338
|
declare function hasNotifyKey(opts?: NotifyOptions): boolean;
|
|
298
339
|
/**
|
|
299
|
-
* แจ้งเตือนผู้รับผ่าน Pi Notify hub — Telegram
|
|
340
|
+
* แจ้งเตือนผู้รับผ่าน Pi Notify hub — แชทที่ผู้รับผูกไว้ (Telegram / LINE / web push) หรืออีเมล
|
|
300
341
|
* ใช้ env เดียวกับ sendMail(): PI_NOTIFY_KEY (+ PI_NOTIFY_URL)
|
|
301
342
|
* โยน MailError เมื่อ hub ตอบ non-2xx (502 = ไม่มีช่องทางไหนส่งสำเร็จเลย; results อยู่ใน err.cause)
|
|
302
343
|
*/
|
|
303
344
|
declare function notify(input: NotifyInput, opts?: NotifyOptions): Promise<NotifyResult>;
|
|
345
|
+
/**
|
|
346
|
+
* (0.7.0) ส่งเข้า topic ของแอป — hub กระจายไปทุกกลุ่ม LINE/Telegram ที่ admin ผูกไว้ (ผลรายกลุ่มมี `target`)
|
|
347
|
+
* topic ไม่พบ → MailError invalid (404) · topic ปิด/ยังไม่ผูกกลุ่ม → ผล skipped reason "no_target" (ไม่โยน)
|
|
348
|
+
* ใช้ deliverWithin / idempotencyKey ได้เหมือน notify()
|
|
349
|
+
* @example await notifyTopic({ topic: "po-overdue", title: "PO เกินกำหนด 3 รายการ", body: "…", link });
|
|
350
|
+
*/
|
|
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>;
|
|
304
378
|
/** ช่องทางที่ผู้รับผูกไว้ — ไว้แสดง "ผูกแล้ว/ยังไม่ผูก" ในหน้าตั้งค่าของแอป */
|
|
305
379
|
declare function getRecipient(email: Recipient, opts?: NotifyOptions): Promise<RecipientInfo>;
|
|
306
380
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
381
|
+
* ขอลิงก์ผูกแชทให้ผู้รับ (หมดอายุ 10 นาที) — แอปวางเป็นปุ่ม/QR ในหน้าตั้งค่าของตัวเอง แล้วโพล getRecipient() จนเห็นช่องทางโผล่
|
|
382
|
+
* - telegram: `t.me/<bot>?start=<code>` (ผู้ใช้กด Start)
|
|
383
|
+
* - line (0.7.0): `line.me/R/oaMessage/@<oa>/?ผูก Pi Notify <code>` — เปิดแชท OA พร้อมพิมพ์ข้อความไว้ ผู้ใช้กดส่ง
|
|
384
|
+
* (ยังไม่เป็นเพื่อนกับ OA ต้องเพิ่มเพื่อนด้วยจึงจะรับ push ได้) · `code` ใช้แสดงให้พิมพ์ "ผูก Pi Notify <code>" เอง
|
|
309
385
|
* `opts.piUserId` = user id ใน Pi Auth (sub) ของผู้ใช้ — ให้ Pi Notify ลบการผูกเมื่อบัญชีถูกลบ
|
|
310
386
|
*/
|
|
311
387
|
declare function createLink(email: Recipient, channel?: Exclude<NotifyChannel, "email">, opts?: NotifyOptions & {
|
|
@@ -354,4 +430,5 @@ declare function listPushDevices(email: string, opts?: NotifyOptions): Promise<{
|
|
|
354
430
|
devices: PushDevice[];
|
|
355
431
|
}>;
|
|
356
432
|
|
|
357
|
-
export {
|
|
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 };
|
package/dist/template.js
ADDED
|
@@ -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.
|
|
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": [
|
|
@@ -36,7 +41,7 @@
|
|
|
36
41
|
"prepack": "pnpm run build"
|
|
37
42
|
},
|
|
38
43
|
"dependencies": {
|
|
39
|
-
"nodemailer": "^10.
|
|
44
|
+
"nodemailer": "^10.2.1"
|
|
40
45
|
},
|
|
41
46
|
"peerDependencies": {
|
|
42
47
|
"next": ">=15"
|
|
@@ -47,14 +52,14 @@
|
|
|
47
52
|
}
|
|
48
53
|
},
|
|
49
54
|
"devDependencies": {
|
|
50
|
-
"@biomejs/biome": "^2.
|
|
55
|
+
"@biomejs/biome": "^2.6.2",
|
|
51
56
|
"@pi/tsconfig": "workspace:*",
|
|
52
57
|
"@types/node": "^24.13.4",
|
|
53
58
|
"@types/nodemailer": "^8.0.1",
|
|
54
|
-
"next": "^16.
|
|
55
|
-
"tsup": "^
|
|
59
|
+
"next": "^16.4.2",
|
|
60
|
+
"tsup": "^9.0.0",
|
|
56
61
|
"typescript": "^6.0.3",
|
|
57
|
-
"vitest": "^
|
|
62
|
+
"vitest": "^5.0.5"
|
|
58
63
|
},
|
|
59
64
|
"license": "MIT",
|
|
60
65
|
"repository": {
|