@suphark/notify 0.3.0 → 0.3.1
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 +21 -21
- package/README.md +246 -200
- package/dist/index.d.ts +32 -4
- package/dist/index.js +170 -49
- package/package.json +64 -64
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,246 @@
|
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
{ type: "
|
|
118
|
-
{ type: "
|
|
119
|
-
{ type: "
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
|
136
|
-
|
|
137
|
-
|
|
|
138
|
-
|
|
|
139
|
-
|
|
|
140
|
-
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
-
|
|
196
|
-
|
|
197
|
-
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
- 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
|
+
## โหมด local (ไม่ผ่าน hub)
|
|
89
|
+
|
|
90
|
+
ไม่มี `PI_NOTIFY_KEY` = SDK ส่งตรงด้วย provider ที่ตั้งใน env ของแอปเอง (engine เดียวกับที่ hub ใช้)
|
|
91
|
+
|
|
92
|
+
```env
|
|
93
|
+
MAIL_FROM="My App <noreply@example.com>"
|
|
94
|
+
MAIL_PROVIDERS=resend,company,zoho # ลำดับ = ลำดับความสำคัญ
|
|
95
|
+
|
|
96
|
+
MAIL_RESEND_API_KEY=re_xxx
|
|
97
|
+
MAIL_RESEND_QUOTA_DAY=100 # เพดานที่ตั้งเอง → ถึงแล้วข้ามไปตัวถัดไปทันที
|
|
98
|
+
|
|
99
|
+
MAIL_COMPANY_TYPE=smtp
|
|
100
|
+
MAIL_COMPANY_HOST=mail.example.com
|
|
101
|
+
MAIL_COMPANY_PORT=587
|
|
102
|
+
MAIL_COMPANY_USER=noreply@example.com
|
|
103
|
+
MAIL_COMPANY_PASS=secret
|
|
104
|
+
|
|
105
|
+
MAIL_ZOHO_USER=me@example.com # ชื่อตรงกับ preset ไม่ต้องใส่ _TYPE
|
|
106
|
+
MAIL_ZOHO_PASS=app-password
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
หรือตั้งค่าในโค้ด:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { createMailer } from "@suphark/notify";
|
|
113
|
+
|
|
114
|
+
export const mailer = createMailer({
|
|
115
|
+
from: "My App <noreply@example.com>",
|
|
116
|
+
providers: [
|
|
117
|
+
{ type: "hub", apiKey: process.env.PI_NOTIFY_KEY! },
|
|
118
|
+
{ type: "resend", apiKey: process.env.RESEND_API_KEY!, quota: { perDay: 100 } },
|
|
119
|
+
{ type: "smtp", name: "company", host: "mail.example.com", port: 587, user: "…", pass: "…" },
|
|
120
|
+
{ type: "zoho", user: "me@example.com", pass: "…" },
|
|
121
|
+
{ type: "console" }, // dev: พิมพ์ลง console
|
|
122
|
+
],
|
|
123
|
+
strategy: "priority", // หรือ "round-robin"
|
|
124
|
+
onEvent: (e) => logger.info(e), // sent / failed / skipped / cooldown / recovered / exhausted
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`configureMailer(options)` ครั้งเดียวตอนบูตถ้าอยากใช้ `sendMail()` แบบ global กับ options เอง
|
|
129
|
+
|
|
130
|
+
### เกิดอะไรขึ้นตอนส่ง
|
|
131
|
+
|
|
132
|
+
1. ไล่ provider ตามลำดับ ข้ามตัวที่อยู่ใน **cooldown** (เพิ่งพัง) หรือ **โควต้าที่ตั้งเต็ม**
|
|
133
|
+
2. ตัวที่ลองแล้วพัง → จำแนก error แล้วพัก (backoff คูณสองเมื่อพังซ้ำ)
|
|
134
|
+
|
|
135
|
+
| error | kind | พัก (ค่าเริ่มต้น) |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| ต่อไม่ติด / timeout | `network` | 1 นาที |
|
|
138
|
+
| 5xx, SMTP 421/45x | `server` | 1 นาที |
|
|
139
|
+
| 429 | `rate_limit` | 2 นาที |
|
|
140
|
+
| 401/403, SMTP 535, domain not verified | `auth` | 15 นาที |
|
|
141
|
+
| quota / sending limit / SMTP 552 | `quota` | 1 ชั่วโมง |
|
|
142
|
+
| 400/422, SMTP 550 recipient rejected | `invalid` | ไม่พัก |
|
|
143
|
+
|
|
144
|
+
3. ปลายทางตอบ `Retry-After` (Pi Notify ตอบมากับ 429/503) → พักเท่าที่ขอ ไม่คูณสอง (ไม่เกิน 1 ชม.)
|
|
145
|
+
4. ทุกตัวติด cooldown จนไม่ได้ลองส่งสักตัว → **half-open**: ลองตัวที่ cooldown จะหมดเร็วสุด 1 ครั้ง (เฉพาะที่พักเพราะ `network`/`server`/`unknown`, ไม่เกิน 1 ครั้งต่อ 30 วิต่อ provider, ยังจองโควต้าตามปกติ) สำเร็จ = ล้าง cooldown ทันที — แอปที่มี hub ตัวเดียวจึงไม่ต้องรอ cooldown 30 นาทีหลัง Pi Notify กลับมา
|
|
146
|
+
5. คืน `{ id, provider, failedOver, attempts }` หรือโยน `AllProvidersFailedError` (มี `attempts`)
|
|
147
|
+
|
|
148
|
+
**`maxCooldownSec`** — ไม่ตั้ง = backoff ไม่เกิน 30 นาที แต่ไม่ต่ำกว่าค่าพื้นของ kind (quota ยังพัก 1 ชม.) · ตั้งเอง = เพดานจริงของทุก kind รวม Retry-After เช่นแอปที่พึ่ง Pi Notify ตัวเดียว (Pi Auth) ตั้ง `maxCooldownSec: 300` ให้พักไม่เกิน 5 นาที
|
|
149
|
+
|
|
150
|
+
**`timeoutMs`** (ค่าเริ่มต้น 20 วิ) หมดเวลาแล้ว mailer ยกเลิกคำขอด้วย `AbortSignal` ที่ส่งให้ provider (`msg.signal` — provider `hub` ส่งต่อเข้า fetch) แล้ว failover ต่อ; provider ที่เขียนเองควรส่ง `msg.signal` ต่อเข้า fetch เช่นกัน
|
|
151
|
+
|
|
152
|
+
### provider type
|
|
153
|
+
|
|
154
|
+
| type | ต้องใส่ |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `hub` | `apiKey` (`url` optional) |
|
|
157
|
+
| `resend` | `apiKey` |
|
|
158
|
+
| `smtp` | `host` (+ `port` `secure` `user` `pass` `rejectUnauthorized` `pool`) |
|
|
159
|
+
| `zoho` `zohopro` `gmail` `office365` `brevo` `mailtrap` | `user`, `pass` (SMTP preset) |
|
|
160
|
+
| `zeptomail` | `apiKey` (+ `region`) |
|
|
161
|
+
| `console` | – |
|
|
162
|
+
| `custom` | `provider` (object ที่ implement `Provider`) |
|
|
163
|
+
|
|
164
|
+
ทุก type รับ `name` `from` `senderDomains` `quota: { perHour, perDay, perMonth }` `disabled`
|
|
165
|
+
|
|
166
|
+
**ตัวตนผู้ส่ง**: provider ที่ตั้ง `from` ไว้จะส่งจากที่อยู่นั้นเสมอ เว้นแต่ `message.from` อยู่ใน `senderDomains` (หรือเป็นที่อยู่เดียวกัน) — ถ้าไม่ผ่านจะใช้ที่อยู่ของ provider แต่คงชื่อจาก message (`Pi Auth <noreply@hub.example>`) เพราะ SMTP/Resend ปฏิเสธ from ที่ไม่ใช่ของตัวเอง; provider ที่ไม่ตั้ง `from` (hub, console) ส่งจากที่อยู่ที่ message ขอ
|
|
167
|
+
|
|
168
|
+
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`
|
|
169
|
+
|
|
170
|
+
## Message
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
{
|
|
174
|
+
to: Address | Address[]; // "a@b.com" | "ชื่อ <a@b.com>" | { name, email }
|
|
175
|
+
subject: string;
|
|
176
|
+
html?: string; text?: string; // อย่างน้อยหนึ่ง
|
|
177
|
+
from?: Address; // ชนะ from ของ provider/แอป
|
|
178
|
+
cc?, bcc?, replyTo?;
|
|
179
|
+
attachments?: { filename, content: Buffer | string, contentType?, encoding?: "base64" }[];
|
|
180
|
+
headers?: Record<string, string>;
|
|
181
|
+
tags?: Record<string, string>; // Resend เท่านั้น
|
|
182
|
+
prefer?: string; // ลองตัวนี้ก่อน (ชื่อจาก listProviders) ยัง failover
|
|
183
|
+
provider?: string; // บังคับตัวนี้ตัวเดียว ไม่ failover (โหมด local เท่านั้น)
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## อื่น ๆ
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
await mailer.verify(); // ทดสอบเชื่อมต่อทุกตัว (hub: เช็ค key)
|
|
191
|
+
await mailer.status(); // cooldown / โควต้าที่ใช้ไป
|
|
192
|
+
await mailer.reset(); // ล้าง cooldown
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- โหมด local หลาย instance แชร์ cooldown/โควต้า: `createMailer({ store: createRedisStore(redis) })`
|
|
196
|
+
- provider เอง: implement `{ name, type, send(msg) }` แล้วโยน `MailError` พร้อม `kind`
|
|
197
|
+
- ESM only, Node ≥ 20
|
|
198
|
+
|
|
199
|
+
## การออกเวอร์ชัน
|
|
200
|
+
|
|
201
|
+
แอปที่ใช้แพ็กเกจนี้: **auth** (pnpm), **pdf** (bun), **duebell** (npm), **pi-done** (npm) — ทุกรอบต้อง bump + deploy ทั้ง 4 แอป จึงมีกติกา:
|
|
202
|
+
|
|
203
|
+
1. **แก้ที่ Pi Notify ก่อนเสมอ** อะไรแก้ฝั่ง server ได้ไม่ต้องออก SDK
|
|
204
|
+
2. **รวมการแก้ SDK ไว้ออกรอบเดียว** (ไม่ออกทีละ fix)
|
|
205
|
+
3. **เปลี่ยนได้แค่แบบเพิ่ม** ระหว่าง SDK รุ่นที่แอปยังใช้อยู่กับ Pi Notify:
|
|
206
|
+
- field ใหม่ใน request/response เป็น optional เสมอ
|
|
207
|
+
- ค่า enum ใหม่ใน response ออกได้เฉพาะเมื่อ request ขอ (opt-in)
|
|
208
|
+
- ห้ามเปลี่ยน HTTP status ของกรณีเดิม
|
|
209
|
+
- `error.kind` ที่ server ส่งต้องเป็นค่าที่ `ErrorKind` ของรุ่นเก่ารู้จัก (ค่าที่ไม่รู้จัก = SDK ไม่พัก provider เลย) — แก้ kind ที่จัดผิดได้
|
|
210
|
+
- ต้องรับ key `pm_` ต่อไป และชื่อ provider type `hub` คงเดิม
|
|
211
|
+
4. additive ทั้งหมด → **0.3.x**; ใช้ 0.4.0 เมื่อรูปของ type ที่แอปใช้อยู่ต้องเปลี่ยนเท่านั้น
|
|
212
|
+
5. **ห้ามเปลี่ยน `DEFAULT_HUB_URL`** และห้ามถอด hostname ที่ SDK รุ่นใดเคยใช้เป็นค่าเริ่มต้น — โดเมนใหม่เพิ่มเป็น alias (บทเรียนจาก `mail.suphark.com` ที่ถอดแล้วแอปรุ่นเก่าส่งไม่ออก)
|
|
213
|
+
6. ถอดของเก่าได้ก็ต่อเมื่อ lockfile ของทั้ง 4 แอปเป็นรุ่นใหม่และ deploy แล้ว
|
|
214
|
+
|
|
215
|
+
Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ body จริงของ SDK 0.3.0 ไว้ — แดง = เปลี่ยน API แบบไม่ additive ให้แก้ฝั่ง Pi Notify ไม่ใช่แก้ fixture
|
|
216
|
+
|
|
217
|
+
**ขั้นตอนออกรุ่น**
|
|
218
|
+
|
|
219
|
+
1. แก้ `version` ใน `packages/notify/package.json` + หัวข้อ Changelog ด้านล่าง → `pnpm --filter @suphark/notify verify` → `npm pack --dry-run` ดูไฟล์ใน tarball
|
|
220
|
+
2. เจ้าของ: `npm publish` จาก `packages/notify` (prepack build ให้, ต้องใช้ 2FA)
|
|
221
|
+
3. bump ทีละแอป เริ่มจาก pdf / duebell แล้ว pi-done และ **Pi Auth เป็นตัวสุดท้าย** — ยก range ขึ้นด้วยกัน lockfile ย้อนรุ่น:
|
|
222
|
+
- pdf: `bun add @suphark/notify@^<ver>`
|
|
223
|
+
- duebell, pi-done: `npm install @suphark/notify@^<ver>`
|
|
224
|
+
- auth: `pnpm --filter @pi/auth add @suphark/notify@^<ver>` (จาก root ของ repo auth)
|
|
225
|
+
4. ตรวจ: lockfile ทั้ง 4 แอปเป็นรุ่นใหม่, magic link ของ Pi Auth มาถึง, DueBell ส่งทดสอบ notify ได้, คำเชิญของ Pi Done ส่งออก
|
|
226
|
+
|
|
227
|
+
## Changelog
|
|
228
|
+
|
|
229
|
+
- 0.3.1 (additive ทั้งหมด — แอปที่ตั้ง `^0.3.0` รับได้เลย, ใช้ร่วมกับ Pi Notify ที่ deploy อยู่ได้ทันที)
|
|
230
|
+
- **timeout**: `notify()` 65 วิ, `getRecipient()`/`createLink()` 10 วิ (`NotifyOptions.timeoutMs`, ค่าเริ่มต้นที่ `NOTIFY_TIMEOUT_MS`) — เดิมไม่มี timeout เลย
|
|
231
|
+
- **abort**: mailer หมด `timeoutMs` แล้วยกเลิก fetch ของ provider `hub` ผ่าน `NormalizedMessage.signal` (field ใหม่ optional) ไม่ปล่อยคำขอค้าง
|
|
232
|
+
- **half-open cooldown**: ทุกตัวติด cooldown (network/server/unknown) → ลองตัวที่ใกล้หมดเวลาพัก 1 ครั้ง/30 วิ — แอปที่มี hub ตัวเดียวกลับมาส่งได้ทันทีที่ Pi Notify กลับมา
|
|
233
|
+
- **Retry-After**: 429/503 ของ Pi Notify พักตาม `Retry-After` (ไม่คูณสอง, ไม่เกิน 1 ชม.) และมี `MailError.retryAfterSec`
|
|
234
|
+
- **`maxCooldownSec` ที่ตั้งเอง = เพดานจริง** ทุก kind (ไม่ตั้ง = พฤติกรรมเดิมทุกอย่าง)
|
|
235
|
+
- `createLink(email, "telegram", { piUserId })` ส่ง user id ของ Pi Auth ให้ Pi Notify ลบการผูกเมื่อบัญชีถูกลบ/ย้ายตามอีเมลใหม่
|
|
236
|
+
- `NotifyResultItem.reason` เพิ่ม `"app_daily_cap"`, `deferred?` + `NotifyResult.deferred?` (หมดงบเวลา 40 วิของ `/api/v1/notify`)
|
|
237
|
+
- engine: ส่งสำเร็จแล้วไม่ failover ส่งซ้ำเมื่อ store (เช่น KvEntry บน DB) เขียน state ไม่ได้; store ล่มตอนบันทึกความล้มเหลวไม่หยุด failover
|
|
238
|
+
|
|
239
|
+
- 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_` เดิมยังใช้ได้)
|
|
240
|
+
|
|
241
|
+
- 0.2.1 — `notify()` เพิ่ม `format?: "text" | "html"` ระบุชนิด body ชัดเจน (hub 2026-09-19+ ; ไม่ระบุยังเดาให้ แต่เดาเข้มขึ้น: นับเฉพาะแท็กที่รู้จัก)
|
|
242
|
+
|
|
243
|
+
- 0.2.0 — **Pi Notify**: `notify()` ส่งแจ้งเตือนถึงอีเมลผู้รับผ่าน hub (Telegram ที่ผู้รับผูกไว้ / อีเมล fallback), `createLink()` ขอ deep link ผูก Telegram, `getRecipient()` ดูช่องทางที่ผูก — ใช้ `PI_MAIL_KEY`/`PI_MAIL_URL` เดิม; `sendMail()` ไม่เปลี่ยน
|
|
244
|
+
|
|
245
|
+
- 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
|
|
246
|
+
- 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,6 +466,7 @@ 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 นั้นอีก — คูณสองทุกครั้งที่พังซ้ำ */
|
|
@@ -490,13 +500,18 @@ interface NotifyResultItem {
|
|
|
490
500
|
id?: string;
|
|
491
501
|
failedOver?: boolean;
|
|
492
502
|
error?: string;
|
|
493
|
-
|
|
503
|
+
/** app_daily_cap = แอปส่งอีเมลแทนแชทครบเพดานรายวันที่ตั้งใน Pi Notify แล้ว */
|
|
504
|
+
reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "app_daily_cap";
|
|
505
|
+
/** (status failed) คำขอหมดงบเวลาก่อนได้เริ่มส่งรายการนี้ — ส่งซ้ำเฉพาะรายการนี้ได้ */
|
|
506
|
+
deferred?: boolean;
|
|
494
507
|
}
|
|
495
508
|
interface NotifyResult {
|
|
496
509
|
results: NotifyResultItem[];
|
|
497
510
|
sent: number;
|
|
498
511
|
failed: number;
|
|
499
512
|
skipped: number;
|
|
513
|
+
/** จำนวนที่ยังไม่ได้ส่งเพราะหมดงบเวลา (นับซ้อนอยู่ใน failed; Pi Notify รุ่นเก่าไม่ส่งมา) */
|
|
514
|
+
deferred?: number;
|
|
500
515
|
}
|
|
501
516
|
interface NotifyOptions {
|
|
502
517
|
/** ค่าเริ่มต้น PI_NOTIFY_KEY */
|
|
@@ -505,7 +520,17 @@ interface NotifyOptions {
|
|
|
505
520
|
url?: string;
|
|
506
521
|
fetch?: typeof fetch;
|
|
507
522
|
env?: Record<string, string | undefined>;
|
|
523
|
+
/**
|
|
524
|
+
* เวลารอ Pi Notify สูงสุด (ms) หมดแล้วโยน MailError kind "network"
|
|
525
|
+
* ค่าเริ่มต้น notify() 65000 (ยาวกว่า maxDuration 60 วิของ /api/v1/notify เล็กน้อย) · getRecipient()/createLink() 10000
|
|
526
|
+
*/
|
|
527
|
+
timeoutMs?: number;
|
|
508
528
|
}
|
|
529
|
+
/** เวลารอเริ่มต้นของแต่ละคำสั่ง (ms) */
|
|
530
|
+
declare const NOTIFY_TIMEOUT_MS: {
|
|
531
|
+
readonly notify: 65000;
|
|
532
|
+
readonly recipient: 10000;
|
|
533
|
+
};
|
|
509
534
|
/** ช่องทางที่ผู้รับผูกไว้ (จาก GET /api/v1/recipients/:email) — ไม่มี chat id */
|
|
510
535
|
interface RecipientChannelInfo {
|
|
511
536
|
type: string;
|
|
@@ -531,8 +556,11 @@ declare function getRecipient(email: string, opts?: NotifyOptions): Promise<Reci
|
|
|
531
556
|
/**
|
|
532
557
|
* ขอลิงก์ผูก Telegram ให้ผู้รับ (t.me/<bot>?start=<code>, หมดอายุ 10 นาที)
|
|
533
558
|
* แอปวางเป็นปุ่ม/QR ในหน้าตั้งค่าของตัวเอง แล้วโพล getRecipient() จนเห็น telegram
|
|
559
|
+
* `opts.piUserId` = user id ใน Pi Auth (sub) ของผู้ใช้ — ให้ Pi Notify ลบการผูกเมื่อบัญชีถูกลบ
|
|
534
560
|
*/
|
|
535
|
-
declare function createLink(email: string, channel?: Exclude<NotifyChannel, "email">, opts?: NotifyOptions
|
|
561
|
+
declare function createLink(email: string, channel?: Exclude<NotifyChannel, "email">, opts?: NotifyOptions & {
|
|
562
|
+
piUserId?: string;
|
|
563
|
+
}): Promise<{
|
|
536
564
|
channel: string;
|
|
537
565
|
url: string;
|
|
538
566
|
expiresAt: string;
|
|
@@ -561,4 +589,4 @@ declare function createRedisStore(client: RedisLike, opts?: {
|
|
|
561
589
|
prefix?: string;
|
|
562
590
|
}): Store;
|
|
563
591
|
|
|
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 };
|
|
592
|
+
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 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 };
|
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)
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
757
|
+
function withTimeout(run, ms, name) {
|
|
758
|
+
const ac = new AbortController();
|
|
718
759
|
return new Promise((resolve2, reject) => {
|
|
719
|
-
const t = setTimeout(() =>
|
|
720
|
-
|
|
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
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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,31 @@ 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", {
|
|
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
|
-
|
|
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
|
|
1003
1123
|
});
|
|
1004
1124
|
}
|
|
1005
1125
|
|
|
@@ -1035,6 +1155,7 @@ export {
|
|
|
1035
1155
|
DEFAULT_COOLDOWN,
|
|
1036
1156
|
DEFAULT_HUB_URL,
|
|
1037
1157
|
MailError,
|
|
1158
|
+
NOTIFY_TIMEOUT_MS,
|
|
1038
1159
|
SMTP_PRESETS,
|
|
1039
1160
|
classifyError,
|
|
1040
1161
|
configureMailer,
|
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@suphark/notify",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
35
|
-
},
|
|
36
|
-
"devDependencies": {
|
|
37
|
-
"@biomejs/biome": "^2.5.
|
|
38
|
-
"@
|
|
39
|
-
"@types/
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
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.3.1",
|
|
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
|
+
}
|