@suphark/notify 0.3.0 → 0.4.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Suphark
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suphark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,200 +1,280 @@
1
- # @suphark/notify
2
-
3
- SDK ส่งอีเมล + แจ้งเตือน (Telegram) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
4
-
5
- ```bash
6
- pnpm add @suphark/notify # หรือ npm i / bun add
7
- ```
8
-
9
- ## ใช้งาน (โหมด hub — แนะนำ)
10
-
11
- ```env
12
- PI_NOTIFY_KEY=pn_xxx # ออกจากหน้า Apps & keys ของ Pi Notify
13
- ```
14
-
15
- ```ts
16
- import { sendMail } from "@suphark/notify";
17
-
18
- await sendMail({
19
- to: "user@example.com",
20
- subject: "ยินดีต้อนรับ",
21
- html: "<p>สวัสดี</p>",
22
- });
23
- ```
24
-
25
- จบ — ไม่ต้องใส่ `from` (hub ใช้ from เริ่มต้นของแอป), ไม่ต้องรู้ว่าข้างหลังเป็น Resend หรือ SMTP, provider ล่มหรือโควต้าหมด hub ย้ายให้เอง
26
-
27
- - `PI_NOTIFY_URL` override ได้เมื่อรัน hub ที่อื่น (เช่น `http://localhost:3300` ตอน dev)
28
- - ผลลัพธ์ `{ id, provider: "hub", failedOver, attempts }`; ส่งไม่ได้เลยโยน `AllProvidersFailedError` (hub ตอบ 429 = โควต้าหมดทุกตัว, 502 = provider ล้มทุกตัว, 422 = ข้อความผิด)
29
-
30
- ### ดูว่ามีตัวเลือกอะไร / ขอให้ส่งผ่านตัวไหนก่อน
31
-
32
- ```ts
33
- import { listProviders, sendMail } from "@suphark/notify";
34
-
35
- await listProviders();
36
- // [{ name: "resend", type: "resend", available: true, via: "hub" },
37
- // { name: "company-smtp", type: "smtp", available: false, via: "hub" }] ← แค่นี้ ไม่มี config/โควต้า
38
-
39
- await sendMail({ to, subject, html, prefer: "company-smtp" }); // ลองตัวนี้ก่อน ไม่ได้ค่อย failover ตามลำดับปกติ
40
- ```
41
-
42
- ### สำรองเผื่อ hub ล่ม (ไม่บังคับ)
43
-
44
- ใส่ provider local ต่อท้ายได้ — hub มาก่อนเสมอ ถ้า hub ตอบไม่ได้ SDK จะ failover ไปตัวถัดไปเอง:
45
-
46
- ```env
47
- PI_NOTIFY_KEY=pn_xxx
48
- MAIL_FROM="My App <noreply@example.com>"
49
- MAIL_PROVIDERS=resend
50
- MAIL_RESEND_API_KEY=re_xxx
51
- ```
52
-
53
- ## แจ้งเตือน (Telegram / อีเมล fallback)
54
-
55
- ผู้รับระบุด้วย **อีเมล** (บัญชี Pi Auth) — hub ดูเองว่าคนนั้นผูก Telegram ไว้ไหม ผูกไว้ส่งแชท ไม่ผูกส่งอีเมล แอปไม่ต้องเก็บ chat id
56
-
57
- ```ts
58
- import { notify } from "@suphark/notify";
59
-
60
- const r = await notify({
61
- to: "user@example.com", // หรือ array ≤ 50
62
- title: "บิลใกล้ครบกำหนด",
63
- body: "ค่าเน็ต 599 บาท ครบกำหนดพรุ่งนี้", // ข้อความล้วน หรือ HTML จำกัดแท็ก b/i/u/s/a/code/pre
64
- link: "https://duebell.app/alerts", // ปุ่ม "เปิดลิงก์"
65
- });
66
- // r = { sent: 1, failed: 0, skipped: 0, results: [{ to, channel: "telegram", status: "sent", provider, id }] }
67
- ```
68
-
69
- - `format: "text"` เมื่อ body เป็นข้อความล้วนที่อาจมี `<` `&` (เช่นชื่อรายการ "บัตร <KBank>") — ไม่ระบุ hub จะเดาจากแท็กที่รู้จัก; `"html"` = ตีความแท็ก b/i/u/s/a/code/pre
70
- - `channels: ["telegram"]` = ส่งเฉพาะแชท ไม่ผูก → `status: "skipped", reason: "channel_not_linked"` (ไม่ตกไปอีเมล) · `["email"]` = อีเมลอย่างเดียว · `["telegram", "email"]` = ทั้งคู่
71
- - failover ไม่ข้ามช่องทาง (Telegram ส่งพังไม่ได้แปลว่าส่งอีเมลแทน) — แอปดู `results` แล้วตัดสินใจเอง
72
- - hub ตอบ 502 เมื่อไม่มีช่องทางไหนสำเร็จเลย → โยน `MailError` (`err.cause.error.results` มีรายละเอียด)
73
-
74
- **ให้ผู้ใช้ผูก Telegram จากหน้าตั้งค่าของแอป** (ไม่ต้องเด้งไป hub):
75
-
76
- ```ts
77
- import { createLink, getRecipient } from "@suphark/notify";
78
-
79
- const { url, expiresAt } = await createLink(user.email); // https://t.me/<bot>?start=<code> (10 นาที)
80
- // แสดงปุ่ม/QR → ผู้ใช้กด Start ในแชท → โพล
81
- const { channels } = await getRecipient(user.email); // [{ type: "telegram", label: "@bob", enabled: true, linkedAt }]
82
- ```
83
-
84
- หรือส่งผู้ใช้ไปผูกเองที่ `https://notify.suphark.com/me` (ล็อกอิน Pi Auth) — ผูกครั้งเดียวใช้ได้ทุกแอป
85
-
86
- ## โหมด local (ไม่ผ่าน hub)
87
-
88
- ไม่มี `PI_NOTIFY_KEY` = SDK ส่งตรงด้วย provider ที่ตั้งใน env ของแอปเอง (engine เดียวกับที่ hub ใช้)
89
-
90
- ```env
91
- MAIL_FROM="My App <noreply@example.com>"
92
- MAIL_PROVIDERS=resend,company,zoho # ลำดับ = ลำดับความสำคัญ
93
-
94
- MAIL_RESEND_API_KEY=re_xxx
95
- MAIL_RESEND_QUOTA_DAY=100 # เพดานที่ตั้งเอง → ถึงแล้วข้ามไปตัวถัดไปทันที
96
-
97
- MAIL_COMPANY_TYPE=smtp
98
- MAIL_COMPANY_HOST=mail.example.com
99
- MAIL_COMPANY_PORT=587
100
- MAIL_COMPANY_USER=noreply@example.com
101
- MAIL_COMPANY_PASS=secret
102
-
103
- MAIL_ZOHO_USER=me@example.com # ชื่อตรงกับ preset ไม่ต้องใส่ _TYPE
104
- MAIL_ZOHO_PASS=app-password
105
- ```
106
-
107
- หรือตั้งค่าในโค้ด:
108
-
109
- ```ts
110
- import { createMailer } from "@suphark/notify";
111
-
112
- export const mailer = createMailer({
113
- from: "My App <noreply@example.com>",
114
- providers: [
115
- { type: "hub", apiKey: process.env.PI_NOTIFY_KEY! },
116
- { type: "resend", apiKey: process.env.RESEND_API_KEY!, quota: { perDay: 100 } },
117
- { type: "smtp", name: "company", host: "mail.example.com", port: 587, user: "…", pass: "…" },
118
- { type: "zoho", user: "me@example.com", pass: "…" },
119
- { type: "console" }, // dev: พิมพ์ลง console
120
- ],
121
- strategy: "priority", // หรือ "round-robin"
122
- onEvent: (e) => logger.info(e), // sent / failed / skipped / cooldown / recovered / exhausted
123
- });
124
- ```
125
-
126
- `configureMailer(options)` ครั้งเดียวตอนบูตถ้าอยากใช้ `sendMail()` แบบ global กับ options เอง
127
-
128
- ### เกิดอะไรขึ้นตอนส่ง
129
-
130
- 1. ไล่ provider ตามลำดับ ข้ามตัวที่อยู่ใน **cooldown** (เพิ่งพัง) หรือ **โควต้าที่ตั้งเต็ม**
131
- 2. ตัวที่ลองแล้วพัง → จำแนก error แล้วพัก (backoff คูณสองเมื่อพังซ้ำ)
132
-
133
- | error | kind | พัก (ค่าเริ่มต้น) |
134
- |---|---|---|
135
- | ต่อไม่ติด / timeout | `network` | 1 นาที |
136
- | 5xx, SMTP 421/45x | `server` | 1 นาที |
137
- | 429 | `rate_limit` | 2 นาที |
138
- | 401/403, SMTP 535, domain not verified | `auth` | 15 นาที |
139
- | quota / sending limit / SMTP 552 | `quota` | 1 ชั่วโมง |
140
- | 400/422, SMTP 550 recipient rejected | `invalid` | ไม่พัก |
141
-
142
- 3. คืน `{ id, provider, failedOver, attempts }` หรือโยน `AllProvidersFailedError` (มี `attempts`)
143
-
144
- ### provider type
145
-
146
- | type | ต้องใส่ |
147
- |---|---|
148
- | `hub` | `apiKey` (`url` optional) |
149
- | `resend` | `apiKey` |
150
- | `smtp` | `host` (+ `port` `secure` `user` `pass` `rejectUnauthorized` `pool`) |
151
- | `zoho` `zohopro` `gmail` `office365` `brevo` `mailtrap` | `user`, `pass` (SMTP preset) |
152
- | `zeptomail` | `apiKey` (+ `region`) |
153
- | `console` | – |
154
- | `custom` | `provider` (object ที่ implement `Provider`) |
155
-
156
- ทุก type รับ `name` `from` `senderDomains` `quota: { perHour, perDay, perMonth }` `disabled`
157
-
158
- **ตัวตนผู้ส่ง**: provider ที่ตั้ง `from` ไว้จะส่งจากที่อยู่นั้นเสมอ เว้นแต่ `message.from` อยู่ใน `senderDomains` (หรือเป็นที่อยู่เดียวกัน) — ถ้าไม่ผ่านจะใช้ที่อยู่ของ provider แต่คงชื่อจาก message (`Pi Auth <noreply@hub.example>`) เพราะ SMTP/Resend ปฏิเสธ from ที่ไม่ใช่ของตัวเอง; provider ที่ไม่ตั้ง `from` (hub, console) ส่งจากที่อยู่ที่ message ขอ
159
-
160
- env: `MAIL_<NAME>_<FIELD>` — `TYPE FROM QUOTA_HOUR QUOTA_DAY QUOTA_MONTH DISABLED` · `API_KEY BASE_URL REGION URL` · `HOST PORT SECURE USER PASS POOL TIMEOUT_MS REJECT_UNAUTHORIZED`
161
-
162
- ## Message
163
-
164
- ```ts
165
- {
166
- to: Address | Address[]; // "a@b.com" | "ชื่อ <a@b.com>" | { name, email }
167
- subject: string;
168
- html?: string; text?: string; // อย่างน้อยหนึ่ง
169
- from?: Address; // ชนะ from ของ provider/แอป
170
- cc?, bcc?, replyTo?;
171
- attachments?: { filename, content: Buffer | string, contentType?, encoding?: "base64" }[];
172
- headers?: Record<string, string>;
173
- tags?: Record<string, string>; // Resend เท่านั้น
174
- prefer?: string; // ลองตัวนี้ก่อน (ชื่อจาก listProviders) ยัง failover
175
- provider?: string; // บังคับตัวนี้ตัวเดียว ไม่ failover (โหมด local เท่านั้น)
176
- }
177
- ```
178
-
179
- ## อื่น ๆ
180
-
181
- ```ts
182
- await mailer.verify(); // ทดสอบเชื่อมต่อทุกตัว (hub: เช็ค key)
183
- await mailer.status(); // cooldown / โควต้าที่ใช้ไป
184
- await mailer.reset(); // ล้าง cooldown
185
- ```
186
-
187
- - โหมด local หลาย instance แชร์ cooldown/โควต้า: `createMailer({ store: createRedisStore(redis) })`
188
- - provider เอง: implement `{ name, type, send(msg) }` แล้วโยน `MailError` พร้อม `kind`
189
- - ESM only, Node ≥ 20
190
-
191
- ## Changelog
192
-
193
- - 0.3.0 — **เปลี่ยนชื่อ Pi Mail → Pi Notify** (breaking): แพ็กเกจย้ายจาก `@suphark/mailer` เป็น `@suphark/notify` (API เดิมทุกตัว เปลี่ยนแค่ import), env `PI_MAIL_KEY`/`PI_MAIL_URL` → `PI_NOTIFY_KEY`/`PI_NOTIFY_URL` (ชื่อเดิมไม่อ่านแล้ว), hub เริ่มต้น `https://notify.suphark.com`; key ใหม่ขึ้นต้น `pn_` (key `pm_` เดิมยังใช้ได้)
194
-
195
- - 0.2.1 — `notify()` เพิ่ม `format?: "text" | "html"` ระบุชนิด body ชัดเจน (hub 2026-09-19+ ; ไม่ระบุยังเดาให้ แต่เดาเข้มขึ้น: นับเฉพาะแท็กที่รู้จัก)
196
-
197
- - 0.2.0 — **Pi Notify**: `notify()` ส่งแจ้งเตือนถึงอีเมลผู้รับผ่าน hub (Telegram ที่ผู้รับผูกไว้ / อีเมล fallback), `createLink()` ขอ deep link ผูก Telegram, `getRecipient()` ดูช่องทางที่ผูก — ใช้ `PI_MAIL_KEY`/`PI_MAIL_URL` เดิม; `sendMail()` ไม่เปลี่ยน
198
-
199
- - 0.1.1 — **fix**: โควต้าจองก่อนส่ง (คำขอพร้อมกันไม่ทะลุเพดาน, ส่งพังคืนโควต้า — Store มี `decr` optional), from ผิดรูปแบบโยน invalid ทันที, `hub` ใน MAIL_PROVIDERS ใช้ PI_MAIL_KEY/PI_MAIL_URL ได้, Resend `verify()` ไม่ต้องใช้สิทธิ์ list domains; nodemailer ^10 (ปิด advisory ของ 7.x); **from policy**: provider ที่มี `from` เป็นตัวตนผู้ส่ง, `senderDomains` อนุญาตทั้งโดเมน (message.from นอกโดเมนถูกแทนที่แต่คงชื่อ); config `custom` รับ `from`/`name` override
200
- - 0.1.0 — รุ่นแรก: hub (`PI_MAIL_KEY`), resend, smtp + presets, zeptomail, console, custom; failover/โควต้า/cooldown; `listProviders()` + `prefer`
1
+ # @suphark/notify
2
+
3
+ SDK ส่งอีเมล + แจ้งเตือน (Telegram) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
4
+
5
+ ```bash
6
+ pnpm add @suphark/notify # หรือ npm i / bun add
7
+ ```
8
+
9
+ ## ใช้งาน (โหมด hub — แนะนำ)
10
+
11
+ ```env
12
+ PI_NOTIFY_KEY=pn_xxx # ออกจากหน้า Apps & keys ของ Pi Notify
13
+ ```
14
+
15
+ ```ts
16
+ import { sendMail } from "@suphark/notify";
17
+
18
+ await sendMail({
19
+ to: "user@example.com",
20
+ subject: "ยินดีต้อนรับ",
21
+ html: "<p>สวัสดี</p>",
22
+ });
23
+ ```
24
+
25
+ จบ — ไม่ต้องใส่ `from` (hub ใช้ from เริ่มต้นของแอป), ไม่ต้องรู้ว่าข้างหลังเป็น Resend หรือ SMTP, provider ล่มหรือโควต้าหมด hub ย้ายให้เอง
26
+
27
+ - `PI_NOTIFY_URL` override ได้เมื่อรัน hub ที่อื่น (เช่น `http://localhost:3300` ตอน dev)
28
+ - ผลลัพธ์ `{ id, provider: "hub", failedOver, attempts }`; ส่งไม่ได้เลยโยน `AllProvidersFailedError` (hub ตอบ 429 = โควต้าหมดทุกตัว, 502 = provider ล้มทุกตัว, 422 = ข้อความผิด)
29
+
30
+ ### ดูว่ามีตัวเลือกอะไร / ขอให้ส่งผ่านตัวไหนก่อน
31
+
32
+ ```ts
33
+ import { listProviders, sendMail } from "@suphark/notify";
34
+
35
+ await listProviders();
36
+ // [{ name: "resend", type: "resend", available: true, via: "hub" },
37
+ // { name: "company-smtp", type: "smtp", available: false, via: "hub" }] ← แค่นี้ ไม่มี config/โควต้า
38
+
39
+ await sendMail({ to, subject, html, prefer: "company-smtp" }); // ลองตัวนี้ก่อน ไม่ได้ค่อย failover ตามลำดับปกติ
40
+ ```
41
+
42
+ ### สำรองเผื่อ hub ล่ม (ไม่บังคับ)
43
+
44
+ ใส่ provider local ต่อท้ายได้ — hub มาก่อนเสมอ ถ้า hub ตอบไม่ได้ SDK จะ failover ไปตัวถัดไปเอง:
45
+
46
+ ```env
47
+ PI_NOTIFY_KEY=pn_xxx
48
+ MAIL_FROM="My App <noreply@example.com>"
49
+ MAIL_PROVIDERS=resend
50
+ MAIL_RESEND_API_KEY=re_xxx
51
+ ```
52
+
53
+ ## แจ้งเตือน (Telegram / อีเมล fallback)
54
+
55
+ ผู้รับระบุด้วย **อีเมล** (บัญชี Pi Auth) — hub ดูเองว่าคนนั้นผูก Telegram ไว้ไหม ผูกไว้ส่งแชท ไม่ผูกส่งอีเมล แอปไม่ต้องเก็บ chat id
56
+
57
+ ```ts
58
+ import { notify } from "@suphark/notify";
59
+
60
+ const r = await notify({
61
+ to: "user@example.com", // หรือ array ≤ 50
62
+ title: "บิลใกล้ครบกำหนด",
63
+ body: "ค่าเน็ต 599 บาท ครบกำหนดพรุ่งนี้", // ข้อความล้วน หรือ HTML จำกัดแท็ก b/i/u/s/a/code/pre
64
+ link: "https://duebell.app/alerts", // ปุ่ม "เปิดลิงก์"
65
+ });
66
+ // r = { sent: 1, failed: 0, skipped: 0, results: [{ to, channel: "telegram", status: "sent", provider, id }] }
67
+ ```
68
+
69
+ - `format: "text"` เมื่อ body เป็นข้อความล้วนที่อาจมี `<` `&` (เช่นชื่อรายการ "บัตร <KBank>") — ไม่ระบุ hub จะเดาจากแท็กที่รู้จัก; `"html"` = ตีความแท็ก b/i/u/s/a/code/pre
70
+ - `channels: ["telegram"]` = ส่งเฉพาะแชท ไม่ผูก → `status: "skipped", reason: "channel_not_linked"` (ไม่ตกไปอีเมล) · `["email"]` = อีเมลอย่างเดียว · `["telegram", "email"]` = ทั้งคู่
71
+ - failover ไม่ข้ามช่องทาง (Telegram ส่งพังไม่ได้แปลว่าส่งอีเมลแทน) — แอปดู `results` แล้วตัดสินใจเอง
72
+ - hub ตอบ 502 เมื่อไม่มีช่องทางไหนสำเร็จเลย → โยน `MailError` (`err.cause.error.results` มีรายละเอียด)
73
+ - timeout: `notify()` รอสูงสุด 65 วิ, `getRecipient()`/`createLink()` 10 วิ (เปลี่ยนด้วย `{ timeoutMs }`) หมดเวลาโยน `MailError` kind `network` — hub อาจส่งไปแล้ว อย่ายิงซ้ำทั้งก้อนทันที
74
+ - 429/503 มี `err.retryAfterSec` (จาก `Retry-After`) บอกว่าควรรอกี่วินาทีก่อนลองใหม่
75
+
76
+ **ให้ผู้ใช้ผูก Telegram จากหน้าตั้งค่าของแอป** (ไม่ต้องเด้งไป hub):
77
+
78
+ ```ts
79
+ import { createLink, getRecipient } from "@suphark/notify";
80
+
81
+ const { url, expiresAt } = await createLink(user.email); // https://t.me/<bot>?start=<code> (10 นาที)
82
+ // แสดงปุ่ม/QR → ผู้ใช้กด Start ในแชท → โพล
83
+ const { channels } = await getRecipient(user.email); // [{ type: "telegram", label: "@bob", enabled: true, linkedAt }]
84
+ ```
85
+
86
+ หรือส่งผู้ใช้ไปผูกเองที่ `https://notify.suphark.com/me` (ล็อกอิน Pi Auth) — ผูกครั้งเดียวใช้ได้ทุกแอป
87
+
88
+ ### แจ้งเตือนบนเบราว์เซอร์ (web push)
89
+
90
+ hub ถือคู่กุญแจ VAPID ให้ (ไม่ต้องใช้ Firebase) — แอปเหลือแค่ service worker ของตัวเองกับปุ่มขอสิทธิ์
91
+ **subscription ผูกกับแอปที่เรียกเสมอ** เพราะ service worker อยู่ที่โดเมนของแอป ใช้ข้ามแอปไม่ได้
92
+
93
+ ```ts
94
+ import { getPushConfig, savePushSubscription, deletePushSubscription, listPushDevices } from "@suphark/notify";
95
+
96
+ // ฝั่ง client: ขอสิทธิ์แล้วส่ง subscription ไปเก็บที่ hub ผ่าน server action ของแอป
97
+ const { publicKey } = await getPushConfig(); // VAPID public key (ค่าสาธารณะ)
98
+ const reg = await navigator.serviceWorker.register("/sw.js");
99
+ const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey });
100
+ await savePushSubscription(user.email, sub.toJSON(), { label: "Chrome บน Windows", piUserId: user.id });
101
+
102
+ await listPushDevices(user.email); // { devices: [{ id, label, enabled, endpointTail }] }
103
+ await deletePushSubscription(user.email, sub.endpoint); // ผู้ใช้ปิดที่เครื่องนั้น
104
+ ```
105
+
106
+ ส่งด้วย `notify({ channels: ["push"] })` (ไม่ระบุ channels = ส่งทุกช่องทางที่ผูกไว้ รวม push ทุกเครื่อง)
107
+ service worker ของแอปจะได้ payload JSON `{ title, body, link? }` — `body` เป็นข้อความล้วนเสมอ:
108
+
109
+ ```js
110
+ self.addEventListener("push", (event) => {
111
+ const { title, body, link } = event.data.json();
112
+ event.waitUntil(self.registration.showNotification(title, { body, data: { link } }));
113
+ });
114
+ ```
115
+
116
+ - ไม่มีเครื่องไหนเปิดไว้ → `status: "skipped", reason: "channel_not_linked"` · hub ยังไม่ตั้ง provider → `no_push_provider`
117
+ - เบราว์เซอร์ถอนสิทธิ์ (404/410) → hub ปิด subscription นั้นให้เอง ไม่ต้องตามลบ
118
+ - เปลี่ยนคู่กุญแจ VAPID ที่ hub = ผู้ใช้ทุกคนต้องกดอนุญาตใหม่ (subscription ผูกกับกุญแจที่ใช้ตอน subscribe)
119
+
120
+ ## โหมด local (ไม่ผ่าน hub)
121
+
122
+ ไม่มี `PI_NOTIFY_KEY` = SDK ส่งตรงด้วย provider ที่ตั้งใน env ของแอปเอง (engine เดียวกับที่ hub ใช้)
123
+
124
+ ```env
125
+ MAIL_FROM="My App <noreply@example.com>"
126
+ MAIL_PROVIDERS=resend,company,zoho # ลำดับ = ลำดับความสำคัญ
127
+
128
+ MAIL_RESEND_API_KEY=re_xxx
129
+ MAIL_RESEND_QUOTA_DAY=100 # เพดานที่ตั้งเอง → ถึงแล้วข้ามไปตัวถัดไปทันที
130
+
131
+ MAIL_COMPANY_TYPE=smtp
132
+ MAIL_COMPANY_HOST=mail.example.com
133
+ MAIL_COMPANY_PORT=587
134
+ MAIL_COMPANY_USER=noreply@example.com
135
+ MAIL_COMPANY_PASS=secret
136
+
137
+ MAIL_ZOHO_USER=me@example.com # ชื่อตรงกับ preset ไม่ต้องใส่ _TYPE
138
+ MAIL_ZOHO_PASS=app-password
139
+ ```
140
+
141
+ หรือตั้งค่าในโค้ด:
142
+
143
+ ```ts
144
+ import { createMailer } from "@suphark/notify";
145
+
146
+ export const mailer = createMailer({
147
+ from: "My App <noreply@example.com>",
148
+ providers: [
149
+ { type: "hub", apiKey: process.env.PI_NOTIFY_KEY! },
150
+ { type: "resend", apiKey: process.env.RESEND_API_KEY!, quota: { perDay: 100 } },
151
+ { type: "smtp", name: "company", host: "mail.example.com", port: 587, user: "…", pass: "…" },
152
+ { type: "zoho", user: "me@example.com", pass: "…" },
153
+ { type: "console" }, // dev: พิมพ์ลง console
154
+ ],
155
+ strategy: "priority", // หรือ "round-robin"
156
+ onEvent: (e) => logger.info(e), // sent / failed / skipped / cooldown / recovered / exhausted
157
+ });
158
+ ```
159
+
160
+ `configureMailer(options)` ครั้งเดียวตอนบูตถ้าอยากใช้ `sendMail()` แบบ global กับ options เอง
161
+
162
+ ### เกิดอะไรขึ้นตอนส่ง
163
+
164
+ 1. ไล่ provider ตามลำดับ ข้ามตัวที่อยู่ใน **cooldown** (เพิ่งพัง) หรือ **โควต้าที่ตั้งเต็ม**
165
+ 2. ตัวที่ลองแล้วพัง → จำแนก error แล้วพัก (backoff คูณสองเมื่อพังซ้ำ)
166
+
167
+ | error | kind | พัก (ค่าเริ่มต้น) |
168
+ |---|---|---|
169
+ | ต่อไม่ติด / timeout | `network` | 1 นาที |
170
+ | 5xx, SMTP 421/45x | `server` | 1 นาที |
171
+ | 429 | `rate_limit` | 2 นาที |
172
+ | 401/403, SMTP 535, domain not verified | `auth` | 15 นาที |
173
+ | quota / sending limit / SMTP 552 | `quota` | 1 ชั่วโมง |
174
+ | 400/422, SMTP 550 recipient rejected | `invalid` | ไม่พัก |
175
+
176
+ 3. ปลายทางตอบ `Retry-After` (Pi Notify ตอบมากับ 429/503) → พักเท่าที่ขอ ไม่คูณสอง (ไม่เกิน 1 ชม.)
177
+ 4. ทุกตัวติด cooldown จนไม่ได้ลองส่งสักตัว → **half-open**: ลองตัวที่ cooldown จะหมดเร็วสุด 1 ครั้ง (เฉพาะที่พักเพราะ `network`/`server`/`unknown`, ไม่เกิน 1 ครั้งต่อ 30 วิต่อ provider, ยังจองโควต้าตามปกติ) สำเร็จ = ล้าง cooldown ทันที — แอปที่มี hub ตัวเดียวจึงไม่ต้องรอ cooldown 30 นาทีหลัง Pi Notify กลับมา
178
+ 5. คืน `{ id, provider, failedOver, attempts }` หรือโยน `AllProvidersFailedError` (มี `attempts`)
179
+
180
+ **`maxCooldownSec`** — ไม่ตั้ง = backoff ไม่เกิน 30 นาที แต่ไม่ต่ำกว่าค่าพื้นของ kind (quota ยังพัก 1 ชม.) · ตั้งเอง = เพดานจริงของทุก kind รวม Retry-After เช่นแอปที่พึ่ง Pi Notify ตัวเดียว (Pi Auth) ตั้ง `maxCooldownSec: 300` ให้พักไม่เกิน 5 นาที
181
+
182
+ **`timeoutMs`** (ค่าเริ่มต้น 20 วิ) หมดเวลาแล้ว mailer ยกเลิกคำขอด้วย `AbortSignal` ที่ส่งให้ provider (`msg.signal` — provider `hub` ส่งต่อเข้า fetch) แล้ว failover ต่อ; provider ที่เขียนเองควรส่ง `msg.signal` ต่อเข้า fetch เช่นกัน
183
+
184
+ ### provider type
185
+
186
+ | type | ต้องใส่ |
187
+ |---|---|
188
+ | `hub` | `apiKey` (`url` optional) |
189
+ | `resend` | `apiKey` |
190
+ | `smtp` | `host` (+ `port` `secure` `user` `pass` `rejectUnauthorized` `pool`) |
191
+ | `zoho` `zohopro` `gmail` `office365` `brevo` `mailtrap` | `user`, `pass` (SMTP preset) |
192
+ | `zeptomail` | `apiKey` (+ `region`) |
193
+ | `console` | – |
194
+ | `custom` | `provider` (object ที่ implement `Provider`) |
195
+
196
+ ทุก type รับ `name` `from` `senderDomains` `quota: { perHour, perDay, perMonth }` `disabled`
197
+
198
+ **ตัวตนผู้ส่ง**: provider ที่ตั้ง `from` ไว้จะส่งจากที่อยู่นั้นเสมอ เว้นแต่ `message.from` อยู่ใน `senderDomains` (หรือเป็นที่อยู่เดียวกัน) — ถ้าไม่ผ่านจะใช้ที่อยู่ของ provider แต่คงชื่อจาก message (`Pi Auth <noreply@hub.example>`) เพราะ SMTP/Resend ปฏิเสธ from ที่ไม่ใช่ของตัวเอง; provider ที่ไม่ตั้ง `from` (hub, console) ส่งจากที่อยู่ที่ message ขอ
199
+
200
+ env: `MAIL_<NAME>_<FIELD>` — `TYPE FROM QUOTA_HOUR QUOTA_DAY QUOTA_MONTH DISABLED` · `API_KEY BASE_URL REGION URL` · `HOST PORT SECURE USER PASS POOL TIMEOUT_MS REJECT_UNAUTHORIZED`
201
+
202
+ ## Message
203
+
204
+ ```ts
205
+ {
206
+ to: Address | Address[]; // "a@b.com" | "ชื่อ <a@b.com>" | { name, email }
207
+ subject: string;
208
+ html?: string; text?: string; // อย่างน้อยหนึ่ง
209
+ from?: Address; // ชนะ from ของ provider/แอป
210
+ cc?, bcc?, replyTo?;
211
+ attachments?: { filename, content: Buffer | string, contentType?, encoding?: "base64" }[];
212
+ headers?: Record<string, string>;
213
+ tags?: Record<string, string>; // Resend เท่านั้น
214
+ prefer?: string; // ลองตัวนี้ก่อน (ชื่อจาก listProviders) ยัง failover
215
+ provider?: string; // บังคับตัวนี้ตัวเดียว ไม่ failover (โหมด local เท่านั้น)
216
+ }
217
+ ```
218
+
219
+ ## อื่น ๆ
220
+
221
+ ```ts
222
+ await mailer.verify(); // ทดสอบเชื่อมต่อทุกตัว (hub: เช็ค key)
223
+ await mailer.status(); // cooldown / โควต้าที่ใช้ไป
224
+ await mailer.reset(); // ล้าง cooldown
225
+ ```
226
+
227
+ - โหมด local หลาย instance แชร์ cooldown/โควต้า: `createMailer({ store: createRedisStore(redis) })`
228
+ - provider เอง: implement `{ name, type, send(msg) }` แล้วโยน `MailError` พร้อม `kind`
229
+ - ESM only, Node ≥ 20
230
+
231
+ ## การออกเวอร์ชัน
232
+
233
+ แอปที่ใช้แพ็กเกจนี้: **auth** (pnpm), **pdf** (bun), **duebell** (npm), **pi-done** (npm) — ทุกรอบต้อง bump + deploy ทั้ง 4 แอป จึงมีกติกา:
234
+
235
+ 1. **แก้ที่ Pi Notify ก่อนเสมอ** อะไรแก้ฝั่ง server ได้ไม่ต้องออก SDK
236
+ 2. **รวมการแก้ SDK ไว้ออกรอบเดียว** (ไม่ออกทีละ fix)
237
+ 3. **เปลี่ยนได้แค่แบบเพิ่ม** ระหว่าง SDK รุ่นที่แอปยังใช้อยู่กับ Pi Notify:
238
+ - field ใหม่ใน request/response เป็น optional เสมอ
239
+ - ค่า enum ใหม่ใน response ออกได้เฉพาะเมื่อ request ขอ (opt-in)
240
+ - ห้ามเปลี่ยน HTTP status ของกรณีเดิม
241
+ - `error.kind` ที่ server ส่งต้องเป็นค่าที่ `ErrorKind` ของรุ่นเก่ารู้จัก (ค่าที่ไม่รู้จัก = SDK ไม่พัก provider เลย) — แก้ kind ที่จัดผิดได้
242
+ - ต้องรับ key `pm_` ต่อไป และชื่อ provider type `hub` คงเดิม
243
+ 4. additive ทั้งหมด → **0.3.x**; ใช้ 0.4.0 เมื่อรูปของ type ที่แอปใช้อยู่ต้องเปลี่ยนเท่านั้น
244
+ 5. **ห้ามเปลี่ยน `DEFAULT_HUB_URL`** และห้ามถอด hostname ที่ SDK รุ่นใดเคยใช้เป็นค่าเริ่มต้น — โดเมนใหม่เพิ่มเป็น alias (บทเรียนจาก `mail.suphark.com` ที่ถอดแล้วแอปรุ่นเก่าส่งไม่ออก)
245
+ 6. ถอดของเก่าได้ก็ต่อเมื่อ lockfile ของทั้ง 4 แอปเป็นรุ่นใหม่และ deploy แล้ว
246
+
247
+ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ body จริงของ SDK 0.3.0 ไว้ — แดง = เปลี่ยน API แบบไม่ additive ให้แก้ฝั่ง Pi Notify ไม่ใช่แก้ fixture
248
+
249
+ **ขั้นตอนออกรุ่น**
250
+
251
+ 1. แก้ `version` ใน `packages/notify/package.json` + หัวข้อ Changelog ด้านล่าง → `pnpm --filter @suphark/notify verify` → `npm pack --dry-run` ดูไฟล์ใน tarball
252
+ 2. เจ้าของ: `npm publish` จาก `packages/notify` (prepack build ให้, ต้องใช้ 2FA)
253
+ 3. bump ทีละแอป เริ่มจาก pdf / duebell แล้ว pi-done และ **Pi Auth เป็นตัวสุดท้าย** — ยก range ขึ้นด้วยกัน lockfile ย้อนรุ่น:
254
+ - pdf: `bun add @suphark/notify@^<ver>`
255
+ - duebell, pi-done: `npm install @suphark/notify@^<ver>`
256
+ - auth: `pnpm --filter @pi/auth add @suphark/notify@^<ver>` (จาก root ของ repo auth)
257
+ 4. ตรวจ: lockfile ทั้ง 4 แอปเป็นรุ่นใหม่, magic link ของ Pi Auth มาถึง, DueBell ส่งทดสอบ notify ได้, คำเชิญของ Pi Done ส่งออก
258
+
259
+ ## Changelog
260
+
261
+ - 0.4.0 — **web push ผ่าน hub**: `getPushConfig()`, `savePushSubscription()`, `deletePushSubscription()`, `listPushDevices()` และ `notify({ channels: ["push"] })`; hub ถือคู่กุญแจ VAPID (มาตรฐาน ไม่ใช้ Firebase) subscription ผูกต่อแอป · `NotifyChannel` เพิ่ม `"push"` และ `reason` เพิ่ม `"no_push_provider"` (เพิ่มอย่างเดียว แต่ `NotifyChannel` เป็น type ที่แอปใช้ในลายเซ็นของตัวเอง จึงออกเป็น minor ตามกติกาข้อ 4)
262
+
263
+ - 0.3.1 (additive ทั้งหมด — แอปที่ตั้ง `^0.3.0` รับได้เลย, ใช้ร่วมกับ Pi Notify ที่ deploy อยู่ได้ทันที)
264
+ - **timeout**: `notify()` 65 วิ, `getRecipient()`/`createLink()` 10 วิ (`NotifyOptions.timeoutMs`, ค่าเริ่มต้นที่ `NOTIFY_TIMEOUT_MS`) — เดิมไม่มี timeout เลย
265
+ - **abort**: mailer หมด `timeoutMs` แล้วยกเลิก fetch ของ provider `hub` ผ่าน `NormalizedMessage.signal` (field ใหม่ optional) ไม่ปล่อยคำขอค้าง
266
+ - **half-open cooldown**: ทุกตัวติด cooldown (network/server/unknown) → ลองตัวที่ใกล้หมดเวลาพัก 1 ครั้ง/30 วิ — แอปที่มี hub ตัวเดียวกลับมาส่งได้ทันทีที่ Pi Notify กลับมา
267
+ - **Retry-After**: 429/503 ของ Pi Notify พักตาม `Retry-After` (ไม่คูณสอง, ไม่เกิน 1 ชม.) และมี `MailError.retryAfterSec`
268
+ - **`maxCooldownSec` ที่ตั้งเอง = เพดานจริง** ทุก kind (ไม่ตั้ง = พฤติกรรมเดิมทุกอย่าง)
269
+ - `createLink(email, "telegram", { piUserId })` ส่ง user id ของ Pi Auth ให้ Pi Notify ลบการผูกเมื่อบัญชีถูกลบ/ย้ายตามอีเมลใหม่
270
+ - `NotifyResultItem.reason` เพิ่ม `"app_daily_cap"`, `deferred?` + `NotifyResult.deferred?` (หมดงบเวลา 40 วิของ `/api/v1/notify`)
271
+ - engine: ส่งสำเร็จแล้วไม่ failover ส่งซ้ำเมื่อ store (เช่น KvEntry บน DB) เขียน state ไม่ได้; store ล่มตอนบันทึกความล้มเหลวไม่หยุด failover
272
+
273
+ - 0.3.0 — **เปลี่ยนชื่อ Pi Mail → Pi Notify** (breaking): แพ็กเกจย้ายจาก `@suphark/mailer` เป็น `@suphark/notify` (API เดิมทุกตัว เปลี่ยนแค่ import), env `PI_MAIL_KEY`/`PI_MAIL_URL` → `PI_NOTIFY_KEY`/`PI_NOTIFY_URL` (ชื่อเดิมไม่อ่านแล้ว), hub เริ่มต้น `https://notify.suphark.com`; key ใหม่ขึ้นต้น `pn_` (key `pm_` เดิมยังใช้ได้)
274
+
275
+ - 0.2.1 — `notify()` เพิ่ม `format?: "text" | "html"` ระบุชนิด body ชัดเจน (hub 2026-09-19+ ; ไม่ระบุยังเดาให้ แต่เดาเข้มขึ้น: นับเฉพาะแท็กที่รู้จัก)
276
+
277
+ - 0.2.0 — **Pi Notify**: `notify()` ส่งแจ้งเตือนถึงอีเมลผู้รับผ่าน hub (Telegram ที่ผู้รับผูกไว้ / อีเมล fallback), `createLink()` ขอ deep link ผูก Telegram, `getRecipient()` ดูช่องทางที่ผูก — ใช้ `PI_MAIL_KEY`/`PI_MAIL_URL` เดิม; `sendMail()` ไม่เปลี่ยน
278
+
279
+ - 0.1.1 — **fix**: โควต้าจองก่อนส่ง (คำขอพร้อมกันไม่ทะลุเพดาน, ส่งพังคืนโควต้า — Store มี `decr` optional), from ผิดรูปแบบโยน invalid ทันที, `hub` ใน MAIL_PROVIDERS ใช้ PI_MAIL_KEY/PI_MAIL_URL ได้, Resend `verify()` ไม่ต้องใช้สิทธิ์ list domains; nodemailer ^10 (ปิด advisory ของ 7.x); **from policy**: provider ที่มี `from` เป็นตัวตนผู้ส่ง, `senderDomains` อนุญาตทั้งโดเมน (message.from นอกโดเมนถูกแทนที่แต่คงชื่อ); config `custom` รับ `from`/`name` override
280
+ - 0.1.0 — รุ่นแรก: hub (`PI_MAIL_KEY`), resend, smtp + presets, zeptomail, console, custom; failover/โควต้า/cooldown; `listProviders()` + `prefer`
package/dist/index.d.ts CHANGED
@@ -61,6 +61,11 @@ interface NormalizedMessage {
61
61
  headers: Record<string, string>;
62
62
  tags: Record<string, string>;
63
63
  prefer?: string;
64
+ /**
65
+ * ยกเลิกเมื่อ mailer หมดเวลา (timeoutMs) — provider ที่ใช้ fetch ควรส่งต่อเข้า fetch
66
+ * เพื่อไม่ให้คำขอค้างต่อหลัง mailer ไป failover แล้ว (ไม่ส่งมา = provider เขียนเองรุ่นเก่า ใช้ได้ตามเดิม)
67
+ */
68
+ signal?: AbortSignal;
64
69
  }
65
70
  /** ประเภทความผิดพลาด — ใช้ตัดสินใจว่าจะพัก provider นานแค่ไหน */
66
71
  type ErrorKind = "quota" | "rate_limit" | "auth" | "network" | "server" | "invalid" | "unknown";
@@ -219,12 +224,13 @@ interface HubSendResponse {
219
224
  provider: string;
220
225
  failedOver: boolean;
221
226
  }
222
- /** error body ของ hub: { error: { kind, message, attempts? } } */
227
+ /** error body ของ hub: { error: { kind, message, attempts?, retryAfterSec? } } */
223
228
  interface HubErrorResponse {
224
229
  error: {
225
230
  kind?: ErrorKind;
226
231
  message: string;
227
232
  attempts?: unknown[];
233
+ retryAfterSec?: number;
228
234
  };
229
235
  }
230
236
  /**
@@ -440,11 +446,14 @@ declare class MailError extends Error {
440
446
  readonly kind: ErrorKind;
441
447
  readonly provider?: string;
442
448
  readonly status?: number;
449
+ /** เวลาที่ปลายทางขอให้รอก่อนลองใหม่ (วินาที จาก Retry-After) — mailer ใช้เป็นเวลาพักแทน backoff */
450
+ readonly retryAfterSec?: number;
443
451
  constructor(message: string, opts?: {
444
452
  kind?: ErrorKind;
445
453
  provider?: string;
446
454
  status?: number;
447
455
  cause?: unknown;
456
+ retryAfterSec?: number;
448
457
  });
449
458
  }
450
459
  /** ส่งไม่สำเร็จเลยสักตัว — ดู attempts ว่าแต่ละตัวพังเพราะอะไร */
@@ -457,13 +466,17 @@ declare function classifyError(err: unknown): {
457
466
  kind: ErrorKind;
458
467
  message: string;
459
468
  status?: number;
469
+ retryAfterSec?: number;
460
470
  };
461
471
 
462
472
  /** เวลาพัก (วินาที) ต่อ error แต่ละแบบ ก่อนกลับมาลอง provider นั้นอีก — คูณสองทุกครั้งที่พังซ้ำ */
463
473
  declare const DEFAULT_COOLDOWN: Record<ErrorKind, number>;
464
474
 
465
- /** ช่องทางแจ้งเตือนที่ hub รองรับ — ผู้รับผูกแชทเองที่ hub (/me) หรือผ่านลิงก์ที่แอปขอจาก hub */
466
- type NotifyChannel = "telegram" | "line" | "email";
475
+ /**
476
+ * ช่องทางแจ้งเตือนที่ hub รองรับ — ผู้รับผูกแชทเองที่ hub (/me) หรือผ่านลิงก์ที่แอปขอจาก hub
477
+ * `push` = web push ของเบราว์เซอร์ ผูกต่อแอป (service worker อยู่ที่โดเมนของแอปเอง)
478
+ */
479
+ type NotifyChannel = "telegram" | "line" | "push" | "email";
467
480
  interface NotifyInput {
468
481
  /** อีเมลผู้รับ (ตรงกับบัญชี Pi Auth) — ระบุได้สูงสุด 50 คน */
469
482
  to: string | string[];
@@ -490,13 +503,21 @@ interface NotifyResultItem {
490
503
  id?: string;
491
504
  failedOver?: boolean;
492
505
  error?: string;
493
- reason?: "no_link" | "channel_not_linked" | "no_chat_provider";
506
+ /**
507
+ * app_daily_cap = แอปส่งอีเมลแทนแชทครบเพดานรายวันที่ตั้งใน Pi Notify แล้ว
508
+ * no_push_provider = hub ยังไม่ได้ตั้ง provider web push
509
+ */
510
+ reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "no_push_provider" | "app_daily_cap";
511
+ /** (status failed) คำขอหมดงบเวลาก่อนได้เริ่มส่งรายการนี้ — ส่งซ้ำเฉพาะรายการนี้ได้ */
512
+ deferred?: boolean;
494
513
  }
495
514
  interface NotifyResult {
496
515
  results: NotifyResultItem[];
497
516
  sent: number;
498
517
  failed: number;
499
518
  skipped: number;
519
+ /** จำนวนที่ยังไม่ได้ส่งเพราะหมดงบเวลา (นับซ้อนอยู่ใน failed; Pi Notify รุ่นเก่าไม่ส่งมา) */
520
+ deferred?: number;
500
521
  }
501
522
  interface NotifyOptions {
502
523
  /** ค่าเริ่มต้น PI_NOTIFY_KEY */
@@ -505,6 +526,31 @@ interface NotifyOptions {
505
526
  url?: string;
506
527
  fetch?: typeof fetch;
507
528
  env?: Record<string, string | undefined>;
529
+ /**
530
+ * เวลารอ Pi Notify สูงสุด (ms) หมดแล้วโยน MailError kind "network"
531
+ * ค่าเริ่มต้น notify() 65000 (ยาวกว่า maxDuration 60 วิของ /api/v1/notify เล็กน้อย) · getRecipient()/createLink() 10000
532
+ */
533
+ timeoutMs?: number;
534
+ }
535
+ /** เวลารอเริ่มต้นของแต่ละคำสั่ง (ms) */
536
+ declare const NOTIFY_TIMEOUT_MS: {
537
+ readonly notify: 65000;
538
+ readonly recipient: 10000;
539
+ };
540
+ /** subscription จากเบราว์เซอร์ (`PushSubscription.toJSON()`) */
541
+ interface PushSubscriptionJson {
542
+ endpoint: string;
543
+ keys: {
544
+ p256dh: string;
545
+ auth: string;
546
+ };
547
+ }
548
+ interface PushDevice {
549
+ id: string;
550
+ label: string | null;
551
+ enabled: boolean;
552
+ /** ท่อนท้ายของ endpoint ไว้แยกเครื่อง (ไม่คืนค่าเต็ม) */
553
+ endpointTail: string;
508
554
  }
509
555
  /** ช่องทางที่ผู้รับผูกไว้ (จาก GET /api/v1/recipients/:email) — ไม่มี chat id */
510
556
  interface RecipientChannelInfo {
@@ -531,12 +577,42 @@ declare function getRecipient(email: string, opts?: NotifyOptions): Promise<Reci
531
577
  /**
532
578
  * ขอลิงก์ผูก Telegram ให้ผู้รับ (t.me/<bot>?start=<code>, หมดอายุ 10 นาที)
533
579
  * แอปวางเป็นปุ่ม/QR ในหน้าตั้งค่าของตัวเอง แล้วโพล getRecipient() จนเห็น telegram
580
+ * `opts.piUserId` = user id ใน Pi Auth (sub) ของผู้ใช้ — ให้ Pi Notify ลบการผูกเมื่อบัญชีถูกลบ
534
581
  */
535
- declare function createLink(email: string, channel?: Exclude<NotifyChannel, "email">, opts?: NotifyOptions): Promise<{
582
+ declare function createLink(email: string, channel?: Exclude<NotifyChannel, "email">, opts?: NotifyOptions & {
583
+ piUserId?: string;
584
+ }): Promise<{
536
585
  channel: string;
537
586
  url: string;
538
587
  expiresAt: string;
539
588
  }>;
589
+ /**
590
+ * VAPID public key ของ hub — ใช้เป็น `applicationServerKey` ตอน `pushManager.subscribe()`
591
+ * (ค่าสาธารณะ แคชฝั่งแอปได้) · hub ยังไม่ได้ตั้ง provider push → MailError kind "server"
592
+ */
593
+ declare function getPushConfig(opts?: NotifyOptions): Promise<{
594
+ publicKey: string;
595
+ }>;
596
+ /**
597
+ * เก็บ subscription ของเบราว์เซอร์หนึ่งเครื่องไว้ที่ hub (ผูกกับแอปที่เรียกเสมอ)
598
+ * เรียกซ้ำด้วย endpoint เดิมได้ = อัปเดตกุญแจ/เปิดใช้คืน · `piUserId` ให้ hub ลบตามบัญชีที่ถูกลบได้
599
+ */
600
+ declare function savePushSubscription(email: string, subscription: PushSubscriptionJson, opts?: NotifyOptions & {
601
+ label?: string;
602
+ piUserId?: string;
603
+ }): Promise<{
604
+ ok: true;
605
+ created: boolean;
606
+ }>;
607
+ /** ผู้ใช้ปิดแจ้งเตือนที่เครื่องนั้น (ลบได้เฉพาะ subscription ของแอปตัวเอง) */
608
+ declare function deletePushSubscription(email: string, endpoint: string, opts?: NotifyOptions): Promise<{
609
+ ok: true;
610
+ deleted: number;
611
+ }>;
612
+ /** เครื่องที่ผู้รับเปิดแจ้งเตือนไว้กับแอปนี้ — ไว้แสดงรายการในหน้าตั้งค่า */
613
+ declare function listPushDevices(email: string, opts?: NotifyOptions): Promise<{
614
+ devices: PushDevice[];
615
+ }>;
540
616
 
541
617
  /** Store ใน memory ของ process (ค่าเริ่มต้น) — state หายเมื่อ restart และไม่แชร์ข้าม instance */
542
618
  declare function createMemoryStore(): Store;
@@ -561,4 +637,4 @@ declare function createRedisStore(client: RedisLike, opts?: {
561
637
  prefix?: string;
562
638
  }): Store;
563
639
 
564
- export { type Address, AllProvidersFailedError, type Attachment, type Attempt, type ConsoleOptions, DEFAULT_COOLDOWN, DEFAULT_HUB_URL, type Env, type ErrorKind, type HubErrorResponse, type HubOptions, type HubSendRequest, type HubSendResponse, MailError, type Mailer, type MailerEvent, type MailerOptions, type Message, type NormalizedMessage, type NotifyChannel, type NotifyInput, type NotifyOptions, type NotifyResult, type NotifyResultItem, type ParsedAddress, type Provider, type ProviderConfig, type ProviderInfo, type ProviderStatus, type Quota, type QuotaUsage, type RecipientChannelInfo, type RecipientInfo, type RedisLike, type ResendOptions, SMTP_PRESETS, type SendResult, type SkipReason, type SmtpOptions, type SmtpPreset, type Store, type ZeptoMailOptions, classifyError, configureMailer, createConsoleProvider, createHubProvider, createLink, createMailer, createMemoryStore, createPresetSmtpProvider, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, formatAddress, getMailer, getRecipient, listProviders, notify, parseAddress, parseAddressList, providersFromEnv, requireFrom, resetMailer, sendMail };
640
+ export { type Address, AllProvidersFailedError, type Attachment, type Attempt, type ConsoleOptions, DEFAULT_COOLDOWN, DEFAULT_HUB_URL, type Env, type ErrorKind, type HubErrorResponse, type HubOptions, type HubSendRequest, type HubSendResponse, MailError, type Mailer, type MailerEvent, type MailerOptions, type Message, NOTIFY_TIMEOUT_MS, type NormalizedMessage, type NotifyChannel, type NotifyInput, type NotifyOptions, type NotifyResult, type NotifyResultItem, type ParsedAddress, type Provider, type ProviderConfig, type ProviderInfo, type ProviderStatus, type PushDevice, type PushSubscriptionJson, type Quota, type QuotaUsage, type RecipientChannelInfo, type RecipientInfo, type RedisLike, type ResendOptions, SMTP_PRESETS, type SendResult, type SkipReason, type SmtpOptions, type SmtpPreset, type Store, type ZeptoMailOptions, classifyError, configureMailer, createConsoleProvider, createHubProvider, createLink, createMailer, createMemoryStore, createPresetSmtpProvider, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, deletePushSubscription, formatAddress, getMailer, getPushConfig, getRecipient, listProviders, listPushDevices, notify, parseAddress, parseAddressList, providersFromEnv, requireFrom, resetMailer, savePushSubscription, sendMail };
package/dist/index.js CHANGED
@@ -219,14 +219,24 @@ var MailError = class extends Error {
219
219
  kind;
220
220
  provider;
221
221
  status;
222
+ /** เวลาที่ปลายทางขอให้รอก่อนลองใหม่ (วินาที จาก Retry-After) — mailer ใช้เป็นเวลาพักแทน backoff */
223
+ retryAfterSec;
222
224
  constructor(message, opts = {}) {
223
225
  super(message, opts.cause === void 0 ? void 0 : { cause: opts.cause });
224
226
  this.name = "MailError";
225
227
  this.kind = opts.kind ?? "unknown";
226
228
  this.provider = opts.provider;
227
229
  this.status = opts.status;
230
+ this.retryAfterSec = opts.retryAfterSec;
228
231
  }
229
232
  };
233
+ function retryAfterOf(res, body) {
234
+ if (res.status !== 429 && res.status !== 503) return void 0;
235
+ const fromHeader = Number(res.headers?.get?.("Retry-After")?.trim() ?? "");
236
+ if (Number.isFinite(fromHeader) && fromHeader > 0) return Math.ceil(fromHeader);
237
+ const fromBody = body?.error?.retryAfterSec;
238
+ return typeof fromBody === "number" && fromBody > 0 ? Math.ceil(fromBody) : void 0;
239
+ }
230
240
  var AllProvidersFailedError = class extends MailError {
231
241
  attempts;
232
242
  constructor(attempts) {
@@ -246,7 +256,13 @@ var NET_CODE_RE = /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|ESOCKET|EDNS|ECON
246
256
  var NET_RE = /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket|timed? ?out|network|fetch failed/i;
247
257
  var INVALID_RE = /invalid.*(email|address|recipient)|no recipients|recipient.*(rejected|invalid)|malformed|missing.*(to|subject|from)/i;
248
258
  function classifyError(err) {
249
- if (err instanceof MailError) return { kind: err.kind, message: err.message, status: err.status };
259
+ if (err instanceof MailError)
260
+ return {
261
+ kind: err.kind,
262
+ message: err.message,
263
+ status: err.status,
264
+ retryAfterSec: err.retryAfterSec
265
+ };
250
266
  const e = typeof err === "object" && err !== null ? err : {};
251
267
  const message = String(e.message ?? e.response ?? err ?? "unknown error");
252
268
  const code = typeof e.code === "string" ? e.code : void 0;
@@ -291,15 +307,20 @@ function windowId(window, now = /* @__PURE__ */ new Date()) {
291
307
  if (window === "day") return iso.slice(0, 10);
292
308
  return iso.slice(0, 7);
293
309
  }
310
+ var MAX_RETRY_AFTER_SEC = 60 * 60;
311
+ var PROBE_INTERVAL_SEC = 30;
294
312
  var Health = class {
295
313
  store;
296
314
  cooldown;
297
315
  maxCooldownSec;
316
+ /** ค่าที่แอปตั้งเอง (ไม่ตั้ง = undefined) — แยกจากค่าเริ่มต้นเพื่อไม่เปลี่ยนพฤติกรรมของแอปที่ไม่ได้ตั้ง */
317
+ userMaxCooldownSec;
298
318
  emit;
299
319
  constructor(opts) {
300
320
  this.store = opts.store;
301
321
  this.cooldown = { ...DEFAULT_COOLDOWN, ...opts.cooldown };
302
322
  this.maxCooldownSec = opts.maxCooldownSec ?? 30 * 60;
323
+ this.userMaxCooldownSec = opts.maxCooldownSec;
303
324
  this.emit = opts.onEvent ?? (() => {
304
325
  });
305
326
  }
@@ -308,8 +329,12 @@ var Health = class {
308
329
  * จองก่อนส่ง (incr แล้วดูค่าที่ได้) เพื่อให้คำขอพร้อมกันหลายตัวไม่ทะลุเพดาน — ส่งพังต้องเรียก release
309
330
  */
310
331
  async check(p, quota) {
311
- const cd = await this.getCooldown(p.name);
332
+ const cd = await this.cooldownOf(p.name);
312
333
  if (cd) return "cooldown";
334
+ return this.reserve(p, quota);
335
+ }
336
+ /** จองโควต้าอย่างเดียว (ไม่ดู cooldown) — ใช้ตอน half-open ที่ตั้งใจลองตัวที่ติด cooldown */
337
+ async reserve(p, quota) {
313
338
  const reserved = [];
314
339
  for (const w of WINDOWS) {
315
340
  const limit = quota?.[w.key];
@@ -343,14 +368,17 @@ var Health = class {
343
368
  this.emit({ type: "recovered", provider: p.name });
344
369
  }
345
370
  }
346
- async recordFailure(p, kind) {
371
+ /**
372
+ * พักหลังพัง: ปลายทางบอก Retry-After มา = พักเท่านั้น (ไม่คูณสอง, ไม่เกิน 1 ชม.)
373
+ * ไม่บอก = backoff base, 2x, 4x, ... · แอปตั้ง maxCooldownSec เอง = เพดานของทั้งสองแบบ
374
+ */
375
+ async recordFailure(p, kind, retryAfterSec) {
347
376
  const base = this.cooldown[kind];
348
377
  if (!base) return;
349
378
  const failures = await this.store.incr(`fail:${p.name}`, 86400);
350
- const sec = Math.min(
351
- base * 2 ** Math.min(failures - 1, 10),
352
- Math.max(base, this.maxCooldownSec)
353
- );
379
+ let sec = retryAfterSec && retryAfterSec > 0 ? Math.min(retryAfterSec, MAX_RETRY_AFTER_SEC) : this.userMaxCooldownSec !== void 0 ? base * 2 ** Math.min(failures - 1, 10) : Math.min(base * 2 ** Math.min(failures - 1, 10), Math.max(base, this.maxCooldownSec));
380
+ if (this.userMaxCooldownSec !== void 0) sec = Math.min(sec, this.userMaxCooldownSec);
381
+ if (sec <= 0) return;
354
382
  const until = Date.now() + sec * 1e3;
355
383
  await this.store.set(
356
384
  `cd:${p.name}`,
@@ -364,8 +392,15 @@ var Health = class {
364
392
  await this.store.del(`cd:${name}`);
365
393
  await this.store.del(`fail:${name}`);
366
394
  }
395
+ /**
396
+ * half-open: อนุญาตให้ลองตัวที่ติด cooldown ได้ 1 ครั้งต่อ PROBE_INTERVAL_SEC (แชร์ข้าม instance ถ้า store แชร์)
397
+ * true = ได้สิทธิ์ลองรอบนี้
398
+ */
399
+ async allowProbe(name) {
400
+ return await this.store.incr(`probe:${name}`, PROBE_INTERVAL_SEC) === 1;
401
+ }
367
402
  async status(p, quota) {
368
- const cd = await this.getCooldown(p.name);
403
+ const cd = await this.cooldownOf(p.name);
369
404
  const usage = await this.usage(p, quota);
370
405
  const failures = Number(await this.store.get(`fail:${p.name}`) ?? 0);
371
406
  return {
@@ -378,7 +413,8 @@ var Health = class {
378
413
  quota: usage
379
414
  };
380
415
  }
381
- async getCooldown(name) {
416
+ /** cooldown ที่ยังไม่หมด (null = ใช้ได้) */
417
+ async cooldownOf(name) {
382
418
  const raw = await this.store.get(`cd:${name}`);
383
419
  if (!raw) return null;
384
420
  try {
@@ -458,7 +494,9 @@ function createHubProvider(opts) {
458
494
  const res = await doFetch(url, {
459
495
  method: "POST",
460
496
  headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
461
- body: JSON.stringify(body)
497
+ body: JSON.stringify(body),
498
+ // mailer หมดเวลา → abort คำขอนี้ทิ้ง (Pi Notify อาจส่งไปแล้ว — แอปที่มีตัวสำรองในเครื่องอาจได้เมลซ้ำ ยอมรับได้)
499
+ signal: msg.signal
462
500
  });
463
501
  const data = await res.json().catch(() => ({}));
464
502
  if (!res.ok) {
@@ -468,7 +506,8 @@ function createHubProvider(opts) {
468
506
  kind,
469
507
  provider: name,
470
508
  status: res.status,
471
- cause: data
509
+ cause: data,
510
+ retryAfterSec: retryAfterOf(res, data)
472
511
  });
473
512
  }
474
513
  return { id: data.id };
@@ -713,11 +752,17 @@ function createMemoryStore() {
713
752
  }
714
753
 
715
754
  // src/mailer.ts
755
+ var PROBE_KINDS = /* @__PURE__ */ new Set(["network", "server", "unknown"]);
716
756
  var timeoutError = (name, ms) => new MailError(`${name}: send timed out after ${ms}ms`, { kind: "network", provider: name });
717
- function withTimeout(p, ms, name) {
757
+ function withTimeout(run, ms, name) {
758
+ const ac = new AbortController();
718
759
  return new Promise((resolve2, reject) => {
719
- const t = setTimeout(() => reject(timeoutError(name, ms)), ms);
720
- p.then(
760
+ const t = setTimeout(() => {
761
+ const err = timeoutError(name, ms);
762
+ reject(err);
763
+ ac.abort(err);
764
+ }, ms);
765
+ Promise.resolve().then(() => run(ac.signal)).then(
721
766
  (v) => {
722
767
  clearTimeout(t);
723
768
  resolve2(v);
@@ -839,6 +884,21 @@ function createMailer(options = {}) {
839
884
  if (allowed) return requested;
840
885
  return requested.name ? { name: requested.name, email: p.from.email } : p.from;
841
886
  };
887
+ async function pickProbe(candidates, attempts) {
888
+ const cooled = candidates.filter(
889
+ (e) => attempts.some((a) => a.provider === e.provider.name && a.skipped === "cooldown")
890
+ );
891
+ const ranked = [];
892
+ for (const entry of cooled) {
893
+ const cd = await health.cooldownOf(entry.provider.name);
894
+ if (cd && PROBE_KINDS.has(cd.kind)) ranked.push({ entry, until: cd.until });
895
+ }
896
+ ranked.sort((a, b) => a.until - b.until);
897
+ const pick = ranked[0]?.entry;
898
+ if (!pick || !await health.allowProbe(pick.provider.name)) return null;
899
+ if (await health.reserve(pick.provider, pick.quota)) return null;
900
+ return pick;
901
+ }
842
902
  async function send(message) {
843
903
  if (!message?.to || Array.isArray(message.to) && message.to.length === 0)
844
904
  throw new MailError("Message has no recipients (to)", { kind: "invalid" });
@@ -857,9 +917,46 @@ function createMailer(options = {}) {
857
917
  }
858
918
  const { requested, ...base } = normalizeBase(message);
859
919
  const attempts = [];
920
+ const forced = message.provider !== void 0;
921
+ const attempt = async (entry, opts) => {
922
+ const p = entry.provider;
923
+ const started = Date.now();
924
+ let id;
925
+ try {
926
+ ({ id } = await withTimeout(
927
+ (signal) => {
928
+ const normalized = {
929
+ ...base,
930
+ from: resolveFrom(requested, entry),
931
+ signal
932
+ };
933
+ return p.send(normalized);
934
+ },
935
+ timeoutMs,
936
+ p.name
937
+ ));
938
+ } catch (err) {
939
+ const ms2 = Date.now() - started;
940
+ const { kind, message: error, retryAfterSec } = classifyError(err);
941
+ attempts.push({ provider: p.name, ok: false, error, kind, ms: ms2 });
942
+ emit({ type: "failed", provider: p.name, kind, error, ms: ms2 });
943
+ try {
944
+ if (opts.reserved) await health.release(p, entry.quota);
945
+ await health.recordFailure(p, kind, retryAfterSec);
946
+ } catch {
947
+ }
948
+ return null;
949
+ }
950
+ const ms = Date.now() - started;
951
+ attempts.push({ provider: p.name, ok: true, ms });
952
+ await health.recordSuccess(p).catch(() => {
953
+ });
954
+ const failedOver = opts.failedOver();
955
+ emit({ type: "sent", provider: p.name, id, ms, failedOver });
956
+ return { id, provider: p.name, failedOver, attempts };
957
+ };
860
958
  for (const entry of candidates) {
861
959
  const p = entry.provider;
862
- const forced = message.provider !== void 0;
863
960
  if (entry.disabled) {
864
961
  attempts.push({ provider: p.name, ok: false, skipped: "disabled", ms: 0 });
865
962
  emit({ type: "skipped", provider: p.name, reason: "disabled" });
@@ -871,23 +968,21 @@ function createMailer(options = {}) {
871
968
  emit({ type: "skipped", provider: p.name, reason: skip });
872
969
  continue;
873
970
  }
874
- const started = Date.now();
875
- try {
876
- const normalized = { ...base, from: resolveFrom(requested, entry) };
877
- const { id } = await withTimeout(p.send(normalized), timeoutMs, p.name);
878
- const ms = Date.now() - started;
879
- attempts.push({ provider: p.name, ok: true, ms });
880
- await health.recordSuccess(p);
881
- const failedOver = attempts.length > 1;
882
- emit({ type: "sent", provider: p.name, id, ms, failedOver });
883
- return { id, provider: p.name, failedOver, attempts };
884
- } catch (err) {
885
- const ms = Date.now() - started;
886
- const { kind, message: error } = classifyError(err);
887
- attempts.push({ provider: p.name, ok: false, error, kind, ms });
888
- emit({ type: "failed", provider: p.name, kind, error, ms });
889
- if (!forced) await health.release(p, entry.quota);
890
- await health.recordFailure(p, kind);
971
+ const result = await attempt(entry, {
972
+ reserved: !forced,
973
+ failedOver: () => attempts.length > 1
974
+ });
975
+ if (result) return result;
976
+ }
977
+ if (!forced && attempts.every((a) => a.skipped)) {
978
+ const probe = await pickProbe(candidates, attempts).catch(() => null);
979
+ if (probe) {
980
+ const first = candidates.find((e) => !e.disabled)?.provider.name;
981
+ const result = await attempt(probe, {
982
+ reserved: true,
983
+ failedOver: () => probe.provider.name !== first
984
+ });
985
+ if (result) return result;
891
986
  }
892
987
  }
893
988
  emit({ type: "exhausted", attempts });
@@ -918,8 +1013,8 @@ function createMailer(options = {}) {
918
1013
  await Promise.all(
919
1014
  entries.map(async (e) => {
920
1015
  try {
921
- if (e.provider.verify)
922
- await withTimeout(e.provider.verify(), timeoutMs, e.provider.name);
1016
+ const verify = e.provider.verify?.bind(e.provider);
1017
+ if (verify) await withTimeout(() => verify(), timeoutMs, e.provider.name);
923
1018
  out[e.provider.name] = { ok: true };
924
1019
  } catch (err) {
925
1020
  out[e.provider.name] = { ok: false, error: classifyError(err).message };
@@ -959,6 +1054,7 @@ function resetMailer() {
959
1054
  }
960
1055
 
961
1056
  // src/notify.ts
1057
+ var NOTIFY_TIMEOUT_MS = { notify: 65e3, recipient: 1e4 };
962
1058
  function resolve(opts) {
963
1059
  const env = opts.env ?? process.env;
964
1060
  const apiKey = opts.apiKey ?? env.PI_NOTIFY_KEY;
@@ -966,17 +1062,32 @@ function resolve(opts) {
966
1062
  const base = (opts.url ?? env.PI_NOTIFY_URL ?? DEFAULT_HUB_URL).replace(/\/$/, "");
967
1063
  return { apiKey, base, doFetch: opts.fetch ?? globalThis.fetch };
968
1064
  }
969
- async function hubRequest(opts, path, init = {}) {
1065
+ async function hubRequest(opts, path, init) {
970
1066
  const { apiKey, base, doFetch } = resolve(opts);
971
- const res = await doFetch(`${base}${path}`, {
972
- method: init.method ?? "GET",
973
- headers: {
974
- Authorization: `Bearer ${apiKey}`,
975
- ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
976
- },
977
- body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
978
- });
979
- const data = await res.json().catch(() => ({}));
1067
+ const ms = opts.timeoutMs ?? init.timeoutMs;
1068
+ const ac = new AbortController();
1069
+ const timer = setTimeout(() => ac.abort(), ms);
1070
+ const timedOut = () => new MailError(`Pi Notify timeout after ${ms}ms`, { kind: "network", provider: "hub" });
1071
+ let res;
1072
+ let data;
1073
+ try {
1074
+ res = await doFetch(`${base}${path}`, {
1075
+ method: init.method ?? "GET",
1076
+ headers: {
1077
+ Authorization: `Bearer ${apiKey}`,
1078
+ ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
1079
+ },
1080
+ body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
1081
+ signal: ac.signal
1082
+ });
1083
+ data = await res.json().catch(() => ({}));
1084
+ } catch (err) {
1085
+ if (ac.signal.aborted) throw timedOut();
1086
+ throw err;
1087
+ } finally {
1088
+ clearTimeout(timer);
1089
+ }
1090
+ if (ac.signal.aborted) throw timedOut();
980
1091
  if (!res.ok) {
981
1092
  const err = data?.error;
982
1093
  const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
@@ -984,22 +1095,54 @@ async function hubRequest(opts, path, init = {}) {
984
1095
  kind,
985
1096
  provider: "hub",
986
1097
  status: res.status,
987
- cause: data
1098
+ cause: data,
1099
+ retryAfterSec: retryAfterOf(res, data)
988
1100
  });
989
1101
  }
990
1102
  return data;
991
1103
  }
992
1104
  async function notify(input, opts = {}) {
993
- const data = await hubRequest(opts, "/api/v1/notify", { method: "POST", body: input });
1105
+ const data = await hubRequest(opts, "/api/v1/notify", {
1106
+ method: "POST",
1107
+ body: input,
1108
+ timeoutMs: NOTIFY_TIMEOUT_MS.notify
1109
+ });
994
1110
  return { results: data.results, ...data.summary };
995
1111
  }
996
1112
  async function getRecipient(email, opts = {}) {
997
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}`);
1113
+ return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}`, {
1114
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1115
+ });
998
1116
  }
999
1117
  async function createLink(email, channel = "telegram", opts = {}) {
1000
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/link`, {
1118
+ const { piUserId, ...rest } = opts;
1119
+ return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/link`, {
1001
1120
  method: "POST",
1002
- body: { channel }
1121
+ body: { channel, ...piUserId ? { piUserId } : {} },
1122
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1123
+ });
1124
+ }
1125
+ async function getPushConfig(opts = {}) {
1126
+ return hubRequest(opts, "/api/v1/push/config", { timeoutMs: NOTIFY_TIMEOUT_MS.recipient });
1127
+ }
1128
+ async function savePushSubscription(email, subscription, opts = {}) {
1129
+ const { label, piUserId, ...rest } = opts;
1130
+ return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1131
+ method: "POST",
1132
+ body: { subscription, ...label ? { label } : {}, ...piUserId ? { piUserId } : {} },
1133
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1134
+ });
1135
+ }
1136
+ async function deletePushSubscription(email, endpoint, opts = {}) {
1137
+ return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1138
+ method: "DELETE",
1139
+ body: { endpoint },
1140
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1141
+ });
1142
+ }
1143
+ async function listPushDevices(email, opts = {}) {
1144
+ return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1145
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1003
1146
  });
1004
1147
  }
1005
1148
 
@@ -1035,6 +1178,7 @@ export {
1035
1178
  DEFAULT_COOLDOWN,
1036
1179
  DEFAULT_HUB_URL,
1037
1180
  MailError,
1181
+ NOTIFY_TIMEOUT_MS,
1038
1182
  SMTP_PRESETS,
1039
1183
  classifyError,
1040
1184
  configureMailer,
@@ -1048,15 +1192,19 @@ export {
1048
1192
  createResendProvider,
1049
1193
  createSmtpProvider,
1050
1194
  createZeptoMailProvider,
1195
+ deletePushSubscription,
1051
1196
  formatAddress,
1052
1197
  getMailer,
1198
+ getPushConfig,
1053
1199
  getRecipient,
1054
1200
  listProviders,
1201
+ listPushDevices,
1055
1202
  notify,
1056
1203
  parseAddress,
1057
1204
  parseAddressList,
1058
1205
  providersFromEnv,
1059
1206
  requireFrom,
1060
1207
  resetMailer,
1208
+ savePushSubscription,
1061
1209
  sendMail
1062
1210
  };
package/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "@suphark/notify",
3
- "version": "0.3.0",
4
- "private": false,
5
- "type": "module",
6
- "description": "ส่งอีเมล + แจ้งเตือน Telegram ผ่าน Pi Notify hub (notify.suphark.com) หรือส่งอีเมลตรงไป Resend/SMTP/Zoho/ZeptoMail ใน API เดียว พร้อม failover อัตโนมัติ",
7
- "exports": {
8
- ".": {
9
- "types": "./dist/index.d.ts",
10
- "import": "./dist/index.js",
11
- "default": "./dist/index.js"
12
- }
13
- },
14
- "files": [
15
- "dist",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "engines": {
20
- "node": ">=20"
21
- },
22
- "packageManager": "pnpm@12.4.1",
23
- "scripts": {
24
- "build": "tsup",
25
- "dev": "tsup --watch",
26
- "typecheck": "tsc --noEmit",
27
- "test": "vitest run",
28
- "test:watch": "vitest",
29
- "verify": "pnpm typecheck && pnpm test && pnpm build",
30
- "send:test": "node --env-file-if-exists=.env scripts/send-test.mjs",
31
- "prepack": "pnpm run build"
32
- },
33
- "dependencies": {
34
- "nodemailer": "^10.0.9"
35
- },
36
- "devDependencies": {
37
- "@biomejs/biome": "^2.5.13",
38
- "@types/node": "^24.13.4",
39
- "@types/nodemailer": "^8.0.1",
40
- "tsup": "^8.5.1",
41
- "typescript": "^6.0.3",
42
- "vitest": "^4.1.11",
43
- "@pi/config": "workspace:*"
44
- },
45
- "license": "MIT",
46
- "repository": {
47
- "type": "git",
48
- "url": "https://github.com/suphark/notify",
49
- "directory": "packages/notify"
50
- },
51
- "keywords": [
52
- "email",
53
- "mailer",
54
- "resend",
55
- "smtp",
56
- "nodemailer",
57
- "zoho",
58
- "zeptomail",
59
- "failover",
60
- "telegram",
61
- "notify",
62
- "notification"
63
- ]
64
- }
1
+ {
2
+ "name": "@suphark/notify",
3
+ "version": "0.4.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "ส่งอีเมล + แจ้งเตือน Telegram ผ่าน Pi Notify hub (notify.suphark.com) หรือส่งอีเมลตรงไป Resend/SMTP/Zoho/ZeptoMail ใน API เดียว พร้อม failover อัตโนมัติ",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "packageManager": "pnpm@12.4.1",
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "dev": "tsup --watch",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "verify": "pnpm typecheck && pnpm test && pnpm build",
30
+ "send:test": "node --env-file-if-exists=.env scripts/send-test.mjs",
31
+ "prepack": "pnpm run build"
32
+ },
33
+ "dependencies": {
34
+ "nodemailer": "^10.0.14"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "^2.5.15",
38
+ "@pi/config": "workspace:*",
39
+ "@types/node": "^24.13.4",
40
+ "@types/nodemailer": "^8.0.1",
41
+ "tsup": "^8.5.1",
42
+ "typescript": "^6.0.3",
43
+ "vitest": "^4.1.11"
44
+ },
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/suphark/notify.git",
49
+ "directory": "packages/notify"
50
+ },
51
+ "keywords": [
52
+ "email",
53
+ "mailer",
54
+ "resend",
55
+ "smtp",
56
+ "nodemailer",
57
+ "zoho",
58
+ "zeptomail",
59
+ "failover",
60
+ "telegram",
61
+ "notify",
62
+ "notification"
63
+ ]
64
+ }