@suphark/notify 0.3.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 ADDED
@@ -0,0 +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.
package/README.md ADDED
@@ -0,0 +1,200 @@
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`