@suphark/notify 0.5.0 → 0.7.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,6 +1,6 @@
1
1
  # @suphark/notify
2
2
 
3
- SDK ส่งอีเมล + แจ้งเตือน (Telegram / Web Push) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
3
+ SDK ส่งอีเมล + แจ้งเตือน (Telegram / LINE / Web Push) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot/LINE OA ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
4
4
 
5
5
  > 📖 **คู่มือการใช้งานและสูตรการเขียนโค้ดฉบับเต็ม:** ดูที่ [docs/guide.md](../../docs/guide.md)
6
6
 
@@ -94,6 +94,61 @@ const { url, code } = await createLink(user); // รับ use
94
94
  const { linked, label, manageUrl } = await getChannelStatus(user, "telegram");
95
95
  ```
96
96
 
97
+ ### LINE (0.7.0)
98
+
99
+ ใช้เหมือน Telegram ทุกอย่าง — ผู้รับผูก LINE ของตัวเองครั้งเดียว (ที่ `/me` ของ hub หรือปุ่มในแอป) แล้วส่งด้วยอีเมล:
100
+
101
+ ```ts
102
+ const { url, code } = await createLink(user, "line");
103
+ // url เปิดแชทกับ OA พร้อมพิมพ์ "ผูก Pi Notify <code>" ไว้ — ผู้ใช้กดส่ง (ยังไม่เป็นเพื่อนกับ OA ให้เพิ่มเพื่อนด้วย)
104
+ const { linked } = await getChannelStatus(user, "line");
105
+ await notify({ to: user, title: "PO รออนุมัติ", body: "…", link, channels: ["line"] });
106
+ ```
107
+
108
+ - ข้อความ LINE เป็น text ล้วน: หัวเรื่อง / เนื้อหา / ลิงก์บรรทัดสุดท้าย (กดได้) — body ที่เป็น HTML ถูกแปลงเป็นข้อความ, ยาวเกิน 5,000 ตัดเนื้อหาแต่คงลิงก์
109
+ - ผู้ใช้บล็อก OA → hub ปิดการผูกให้เอง (ปลดบล็อกแล้วกลับมาเอง) · พิมพ์ "หยุดแจ้งเตือน" ในแชท OA = เลิกรับ
110
+
111
+ ### ส่งเข้ากลุ่ม: `notifyTopic()` (0.7.0)
112
+
113
+ แอปไม่ต้องถือ group id — admin เชิญ OA/bot เข้ากลุ่ม LINE/Telegram (hub เห็นกลุ่มเองจาก webhook) แล้วผูกกลุ่มกับ **topic ของแอป** ที่หน้า Topics ของ Pi Notify
114
+
115
+ ```ts
116
+ import { notifyTopic } from "@suphark/notify";
117
+
118
+ const r = await notifyTopic({
119
+ topic: "po-overdue", // topic ของแอปนี้ (a-z 0-9 . _ -)
120
+ title: "PO เกินกำหนด 3 รายการ",
121
+ body: "PO-2611-001, …",
122
+ link: "https://app.example.com/po?status=overdue",
123
+ deliverWithin: 3600, // ใช้ได้เหมือน notify()
124
+ idempotencyKey: `po-overdue:${date}`,
125
+ });
126
+ // r.results = [{ to: "topic:po-overdue", channel: "line", status: "sent", target: "ทีมจัดซื้อ" }, …] (รายกลุ่ม)
127
+ ```
128
+
129
+ - topic ไม่พบ → `MailError` `invalid` (404) · topic ถูกปิด/ยังไม่ผูกกลุ่ม → `status: "skipped", reason: "no_target"` (ไม่โยน)
130
+ - `channels: ["line"]` = เฉพาะกลุ่ม LINE ของ topic · ไม่ระบุ = ทุกกลุ่ม
131
+ - แบบไม่โยน/เบื้องหลัง: `notifyTopicSafe()` และ `notifyTopicLater()` จาก `@suphark/notify/next`
132
+ - LINE คิดโควต้าตามข้อความ push — ยอดของเดือนดูที่หน้า Providers ของ Pi Notify
133
+
134
+ ### ให้ hub ส่งซ้ำเอง: `deliverWithin` + `idempotencyKey` (0.6.0)
135
+
136
+ ```ts
137
+ const r = await notify({
138
+ to: user,
139
+ title: "ถึงกำหนดงาน",
140
+ body: "…",
141
+ channels: ["telegram"],
142
+ deliverWithin: 3600, // ให้ hub พยายามส่งภายใน 1 ชม.
143
+ idempotencyKey: `pi-done:reminder:${user.id}:${runId}`, // cron รันซ้ำก็ไม่เตือนซ้ำ
144
+ });
145
+ // r.results[i].status: "sent" | "queued" (hub รับไว้ส่งซ้ำเอง) | "failed" | "skipped"
146
+ ```
147
+
148
+ - นับ `queued` เป็น "รับไว้แล้ว" — แอปไม่ต้องส่งซ้ำเอง; ดูสถานะที่หน้า Logs / คิวส่งซ้ำของ Pi Notify
149
+ - `failed` = ผิดถาวร / ลองครบแล้ว / เลยเวลา — แอปตัดสินใจเอง (อย่าสลับไปอีเมลถ้าผู้ใช้ไม่ได้ขอ)
150
+ - ใช้ `idempotencyKey` ที่ผูกกับเหตุการณ์ (ไม่ใช่สุ่มใหม่ทุกครั้ง) จึงกันซ้ำได้ — ส่งเนื้อหาเดิมด้วย key เดิมซ้ำจะได้ผลเดิมกลับมา
151
+
97
152
  ### เขียนให้สั้น: ส่งแบบไม่โยน / ส่งเบื้องหลัง / `to` เป็น user object (0.5.0)
98
153
 
99
154
  `to` ของ `notify()` รับ **user object ที่มี `email`** (และ array ของมัน) ได้เลย — ไม่ต้องแกะ `.email` เอง
@@ -373,6 +428,16 @@ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ b
373
428
 
374
429
  ## Changelog
375
430
 
431
+ - 0.7.0 — **LINE + ส่งเข้ากลุ่มด้วย topic** (เพิ่มอย่างเดียว; ต้องใช้ Pi Notify 2026-11-11+)
432
+ - `createLink(email, "line")` / `getChannelStatus(email, "line")` / `notify({ channels: ["line"] })` — ช่องทาง LINE รายคน
433
+ - `notifyTopic({ topic, title, body, … })` + `notifyTopicSafe()` + `notifyTopicLater()` (`/next`) — ส่งเข้าทุกกลุ่ม LINE/Telegram ที่ admin ผูกกับ topic ของแอป
434
+ - `NotifyResultItem.target?` (ชื่อกลุ่ม) และ `reason` เพิ่ม `"no_target"` (ออกเป็น minor เพราะ union ของ `reason` ที่แอปอาจ switch)
435
+
436
+ - 0.6.0 — **hub ส่งซ้ำเอง** (เพิ่มอย่างเดียว — แอปที่ไม่ส่งตัวเลือกใหม่ได้พฤติกรรมเดิมทุกอย่าง; ต้องใช้ Pi Notify 2026-10-14+)
437
+ - `notify({ deliverWithin })` (วินาที 60–86400): รายการที่ค้างหรือพลาดชั่วคราวเข้าคิวถาวรของ hub แล้วส่งซ้ำตาม backoff (1 นาที → 5 นาที → 30 นาที → 2 ชม., สูงสุด 5 ครั้ง) · ผิดถาวรไม่ส่งซ้ำ · เลยเวลาแล้วไม่ส่ง · ไม่สลับช่องทาง
438
+ - `notify({ idempotencyKey })` → หัว `Idempotency-Key`: key+เนื้อหาเดิมได้ผลเดิม ไม่ส่งซ้ำ (จำ 7 วัน) · เนื้อหาต่างแต่ key เดิม = `invalid` (422) · คำขอเดิมยังทำอยู่ = `rate_limit` (409, `retryAfterSec` 5)
439
+ - `NotifyResultItem.status` เพิ่ม `"queued"` (+ `deliveryId`), `NotifyResult.queued?` / `NotifySafeResult.queued?` — `queued` นับเป็น "รับไว้แล้ว" ไม่ใช่ failed (`notifySafe().ok` เป็น true) · `status` ใหม่เกิดเฉพาะเมื่อแอปส่ง `deliverWithin` (ออกเป็น minor เพราะ union ของ `status` ที่แอปอาจ switch)
440
+
376
441
  - 0.5.0 — **เขียนฝั่งแอปให้สั้นลง** (เพิ่มอย่างเดียว — ฟอร์มของ 0.4.0 ใช้ได้ทุกแบบ; Pi Notify รุ่นเก่าใช้ได้ ยกเว้น `code` ที่แกะจาก url ให้เอง)
377
442
  - `notify({ to })` รับ user object `{ email }` และ array (กรอง null); ผู้รับเดี่ยวไม่มีอีเมล = `MailError` `invalid`
378
443
  - `notifySafe()` / `sendMailSafe()` ไม่โยน; ไม่มี key/provider = `disabled`
@@ -1101,6 +1101,7 @@ async function hubRequest(opts, path, init) {
1101
1101
  method: init.method ?? "GET",
1102
1102
  headers: {
1103
1103
  Authorization: `Bearer ${apiKey}`,
1104
+ ...init.headers,
1104
1105
  ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
1105
1106
  },
1106
1107
  body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
@@ -1127,14 +1128,23 @@ async function hubRequest(opts, path, init) {
1127
1128
  }
1128
1129
  return data;
1129
1130
  }
1130
- async function notify(input, opts = {}) {
1131
+ async function postNotify(body, idempotencyKey, opts) {
1131
1132
  const data = await hubRequest(opts, "/api/v1/notify", {
1132
1133
  method: "POST",
1133
- body: { ...input, to: toEmails(input.to) },
1134
+ body,
1135
+ headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : void 0,
1134
1136
  timeoutMs: NOTIFY_TIMEOUT_MS.notify
1135
1137
  });
1136
1138
  return { results: data.results, ...data.summary };
1137
1139
  }
1140
+ async function notify(input, opts = {}) {
1141
+ const { idempotencyKey, ...body } = input;
1142
+ return postNotify({ ...body, to: toEmails(input.to) }, idempotencyKey, opts);
1143
+ }
1144
+ async function notifyTopic(input, opts = {}) {
1145
+ const { idempotencyKey, ...body } = input;
1146
+ return postNotify(body, idempotencyKey, opts);
1147
+ }
1138
1148
  async function getRecipient(email, opts = {}) {
1139
1149
  return hubRequest(
1140
1150
  opts,
@@ -1213,13 +1223,19 @@ function resetSafeWarnings() {
1213
1223
  }
1214
1224
  var messageOf = (err) => err instanceof Error ? err.message : String(err);
1215
1225
  async function notifySafe(input, opts = {}) {
1226
+ return safely("notify()", () => notify(input, opts), opts);
1227
+ }
1228
+ async function notifyTopicSafe(input, opts = {}) {
1229
+ return safely("notifyTopic()", () => notifyTopic(input, opts), opts);
1230
+ }
1231
+ async function safely(what, run, opts) {
1216
1232
  const none = { sent: 0, failed: 0, skipped: 0, results: [] };
1217
1233
  if (!hasNotifyKey(opts)) {
1218
- warnDisabled("notify()", opts.env ?? process.env);
1234
+ warnDisabled(what, opts.env ?? process.env);
1219
1235
  return { ok: false, disabled: true, ...none };
1220
1236
  }
1221
1237
  try {
1222
- const r = await notify(input, opts);
1238
+ const r = await run();
1223
1239
  return { ok: r.failed === 0, ...r };
1224
1240
  } catch (err) {
1225
1241
  const body = err instanceof MailError ? err.cause : void 0;
@@ -1277,6 +1293,7 @@ export {
1277
1293
  NOTIFY_TIMEOUT_MS,
1278
1294
  hasNotifyKey,
1279
1295
  notify,
1296
+ notifyTopic,
1280
1297
  getRecipient,
1281
1298
  createLink,
1282
1299
  getChannelStatus,
@@ -1286,5 +1303,6 @@ export {
1286
1303
  listPushDevices,
1287
1304
  resetSafeWarnings,
1288
1305
  notifySafe,
1306
+ notifyTopicSafe,
1289
1307
  sendMailSafe
1290
1308
  };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as ParsedAddress, A as Address, a as Provider, E as ErrorKind, Q as Quota, M as Message, S as SendResult, b as ProviderStatus, c as ProviderInfo, d as Store, e as MailerEvent, f as Attempt, g as PushDevice, N as NotifyOptions, h as NotifyResultItem, i as NotifyInput } from './notify-L7JnpG0t.js';
2
- export { j as Attachment, C as ChannelStatus, k as NOTIFY_TIMEOUT_MS, l as NormalizedMessage, m as NotifyChannel, n as NotifyResult, o as PushSubscriptionJson, p as QuotaUsage, R as Recipient, q as RecipientChannelInfo, r as RecipientInfo, s as SkipReason, t as createLink, u as deletePushSubscription, v as getChannelStatus, w as getPushConfig, x as getRecipient, y as hasNotifyKey, z as listPushDevices, B as notify, D as savePushSubscription, F as toEmails } from './notify-L7JnpG0t.js';
1
+ import { P as ParsedAddress, A as Address, a as Provider, E as ErrorKind, Q as Quota, M as Message, S as SendResult, b as ProviderStatus, c as ProviderInfo, d as Store, e as MailerEvent, f as Attempt, g as PushDevice, N as NotifyOptions, h as NotifyResultItem, i as NotifyInput, j as NotifyTopicInput } from './notify-D4auDRfk.js';
2
+ export { k as Attachment, C as ChannelStatus, l as NOTIFY_TIMEOUT_MS, m as NormalizedMessage, n as NotifyChannel, o as NotifyResult, p as PushSubscriptionJson, q as QuotaUsage, R as Recipient, r as RecipientChannelInfo, s as RecipientInfo, t as SkipReason, u as createLink, v as deletePushSubscription, w as getChannelStatus, x as getPushConfig, y as getRecipient, z as hasNotifyKey, B as listPushDevices, D as notify, F as notifyTopic, G as savePushSubscription, H as toEmails } from './notify-D4auDRfk.js';
3
3
 
4
4
  /** แปลง "ชื่อ <a@b.com>" / "a@b.com" / { name, email } → { name, email } */
5
5
  declare function parseAddress(input: Address): ParsedAddress;
@@ -403,6 +403,8 @@ interface NotifySafeResult {
403
403
  failed: number;
404
404
  skipped: number;
405
405
  deferred?: number;
406
+ /** (0.6.0) hub รับไว้ส่งซ้ำเอง — ไม่นับเป็นความล้มเหลว */
407
+ queued?: number;
406
408
  /** ผลรายผู้รับ/ช่องทาง (มีแม้ Pi Notify ตอบ 502 — ดึงจาก body) */
407
409
  results: NotifyResultItem[];
408
410
  /** ข้อความ error (ไม่มีอีเมลผู้รับ/เนื้อหา) */
@@ -418,6 +420,8 @@ declare function resetSafeWarnings(): void;
418
420
  * ไม่มี PI_NOTIFY_KEY → { ok: false, disabled: true } ไม่ส่ง
419
421
  */
420
422
  declare function notifySafe(input: NotifyInput, opts?: NotifyOptions): Promise<NotifySafeResult>;
423
+ /** (0.7.0) notifyTopic() ที่ไม่โยนเด็ดขาด (ดู notifySafe) — topic ไม่พบก็คืน { ok: false, error } */
424
+ declare function notifyTopicSafe(input: NotifyTopicInput, opts?: NotifyOptions): Promise<NotifySafeResult>;
421
425
  interface SendMailSafeResult {
422
426
  ok: boolean;
423
427
  provider?: string;
@@ -453,4 +457,5 @@ declare function createRedisStore(client: RedisLike, opts?: {
453
457
  prefix?: string;
454
458
  }): Store;
455
459
 
456
- export { Address, AllProvidersFailedError, Attempt, type BodyFormat, type BrowserPushSubscription, type ConsoleOptions, DEFAULT_COOLDOWN, DEFAULT_HUB_URL, type Env, ErrorKind, type HubErrorResponse, type HubOptions, type HubSendRequest, type HubSendResponse, MailError, type Mailer, MailerEvent, type MailerOptions, Message, type NotifyEmailInput, NotifyInput, NotifyOptions, NotifyResultItem, type NotifySafeResult, ParsedAddress, Provider, type ProviderConfig, ProviderInfo, ProviderStatus, PushDevice, type PushHandlers, type PushHandlersOptions, type PushServiceWorkerOptions, type PushUser, Quota, type RedisLike, type ResendOptions, SMTP_PRESETS, type SendMailSafeResult, SendResult, type SmtpOptions, type SmtpPreset, Store, type ZeptoMailOptions, classifyError, configureMailer, createConsoleProvider, createHubProvider, createMailer, createMemoryStore, createPresetSmtpProvider, createPushHandlers, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, formatAddress, getMailer, listProviders, looksLikeHtml, notifySafe, parseAddress, parseAddressList, providersFromEnv, pushServiceWorker, pushServiceWorkerSource, renderNotifyEmail, requireFrom, resetMailer, resetSafeWarnings, resolveFormat, sendMail, sendMailSafe, toPlainText };
460
+ export { Address, AllProvidersFailedError, Attempt, DEFAULT_COOLDOWN, DEFAULT_HUB_URL, ErrorKind, MailError, MailerEvent, Message, NotifyInput, NotifyOptions, NotifyResultItem, NotifyTopicInput, ParsedAddress, Provider, ProviderInfo, ProviderStatus, PushDevice, Quota, SMTP_PRESETS, SendResult, Store, classifyError, configureMailer, createConsoleProvider, createHubProvider, createMailer, createMemoryStore, createPresetSmtpProvider, createPushHandlers, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, formatAddress, getMailer, listProviders, looksLikeHtml, notifySafe, notifyTopicSafe, parseAddress, parseAddressList, providersFromEnv, pushServiceWorker, pushServiceWorkerSource, renderNotifyEmail, requireFrom, resetMailer, resetSafeWarnings, resolveFormat, sendMail, sendMailSafe, toPlainText };
461
+ export type { BodyFormat, BrowserPushSubscription, ConsoleOptions, Env, HubErrorResponse, HubOptions, HubSendRequest, HubSendResponse, Mailer, MailerOptions, NotifyEmailInput, NotifySafeResult, ProviderConfig, PushHandlers, PushHandlersOptions, PushServiceWorkerOptions, PushUser, RedisLike, ResendOptions, SendMailSafeResult, SmtpOptions, SmtpPreset, ZeptoMailOptions };
package/dist/index.js CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  listPushDevices,
28
28
  notify,
29
29
  notifySafe,
30
+ notifyTopic,
31
+ notifyTopicSafe,
30
32
  parseAddress,
31
33
  parseAddressList,
32
34
  providersFromEnv,
@@ -37,7 +39,7 @@ import {
37
39
  sendMail,
38
40
  sendMailSafe,
39
41
  toEmails
40
- } from "./chunk-FL5ZV3S2.js";
42
+ } from "./chunk-2RXN5HXT.js";
41
43
 
42
44
  // src/format.ts
43
45
  var KNOWN_TAG_RE = /<\/?(b|strong|i|em|u|ins|s|strike|del|a|code|pre|p|br|div|span|ul|ol|li|h[1-6]|table|tr|td)(?=[\s/>])[^>]*>/i;
@@ -219,6 +221,8 @@ export {
219
221
  looksLikeHtml,
220
222
  notify,
221
223
  notifySafe,
224
+ notifyTopic,
225
+ notifyTopicSafe,
222
226
  parseAddress,
223
227
  parseAddressList,
224
228
  providersFromEnv,
package/dist/next.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { N as NotifyOptions, i as NotifyInput, M as Message } from './notify-L7JnpG0t.js';
1
+ import { NotifyOptions, NotifyInput, NotifyTopicInput, Message } from './index.js';
2
2
 
3
3
  /**
4
4
  * ส่งหลังตอบ response แล้ว (`after()` ของ Next.js) — entry แยก `@suphark/notify/next` เพราะ core ของ SDK
@@ -19,10 +19,16 @@ declare const LATER_TIMEOUT_MS = 20000;
19
19
  * @example notifyLater({ to: user, title: "บันทึกแล้ว", body: "ระบบอัปเดตงานแล้ว" });
20
20
  */
21
21
  declare function notifyLater(input: NotifyInput, opts?: LaterOptions): void;
22
+ /**
23
+ * (0.7.0) notifyTopic() เบื้องหลัง (ดู notifyLater) — คืน void
24
+ * @example notifyTopicLater({ topic: "po-overdue", title: "PO เกินกำหนด", body: "…" });
25
+ */
26
+ declare function notifyTopicLater(input: NotifyTopicInput, opts?: LaterOptions): void;
22
27
  /**
23
28
  * sendMail() เบื้องหลัง (ดู notifyLater) — คืน void
24
29
  * @example sendMailLater({ to: user.email, subject: "ยินดีต้อนรับ", html: "<p>…</p>" });
25
30
  */
26
31
  declare function sendMailLater(message: Message, opts?: Pick<LaterOptions, "onError">): void;
27
32
 
28
- export { LATER_TIMEOUT_MS, type LaterOptions, notifyLater, sendMailLater };
33
+ export { LATER_TIMEOUT_MS, notifyLater, notifyTopicLater, sendMailLater };
34
+ export type { LaterOptions };
package/dist/next.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  notifySafe,
3
+ notifyTopicSafe,
3
4
  sendMailSafe
4
- } from "./chunk-FL5ZV3S2.js";
5
+ } from "./chunk-2RXN5HXT.js";
5
6
 
6
7
  // src/next.ts
7
8
  import { after } from "next/server";
@@ -27,6 +28,20 @@ function notifyLater(input, opts = {}) {
27
28
  report("notify()", r.error ?? (r.failed > 0 ? `${r.failed} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27` : void 0), onError);
28
29
  });
29
30
  }
31
+ function notifyTopicLater(input, opts = {}) {
32
+ const { onError, ...rest } = opts;
33
+ runLater(async () => {
34
+ const r = await notifyTopicSafe(input, {
35
+ timeoutMs: LATER_TIMEOUT_MS,
36
+ ...rest
37
+ });
38
+ report(
39
+ "notifyTopic()",
40
+ r.error ?? (r.failed > 0 ? `${r.failed} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27` : void 0),
41
+ onError
42
+ );
43
+ });
44
+ }
30
45
  function sendMailLater(message, opts = {}) {
31
46
  runLater(async () => {
32
47
  const r = await sendMailSafe(message);
@@ -36,5 +51,6 @@ function sendMailLater(message, opts = {}) {
36
51
  export {
37
52
  LATER_TIMEOUT_MS,
38
53
  notifyLater,
54
+ notifyTopicLater,
39
55
  sendMailLater
40
56
  };
@@ -204,11 +204,35 @@ interface NotifyInput {
204
204
  channels?: NotifyChannel[];
205
205
  /** ชื่อ provider ฝั่ง hub ที่อยากให้ลองก่อน */
206
206
  prefer?: string;
207
+ /**
208
+ * (0.6.0) วินาที 60–86400 — ให้ hub รับผิดชอบส่งให้ถึงภายในเวลานี้: รายการที่ค้างหรือพลาดชั่วคราว (network/5xx/429)
209
+ * hub เก็บในคิวแล้วส่งซ้ำเองตาม backoff (ผลเป็น `status: "queued"` นับเป็น "รับไว้แล้ว") · ผิดถาวร (ผู้รับบล็อก ฯลฯ) ไม่ส่งซ้ำ
210
+ * เลยเวลาแล้วไม่ส่ง · ไม่สลับช่องทาง (ขอ telegram จะไม่ตกไปอีเมล) · ไม่ระบุ = พฤติกรรมเดิม (แอปส่งซ้ำเอง)
211
+ */
212
+ deliverWithin?: number;
213
+ /**
214
+ * (0.6.0) กันส่งซ้ำ — ส่งเป็นหัว `Idempotency-Key` (1–200 ตัวอักษร ต่อแอป) เรียกซ้ำด้วย key และเนื้อหาเดิม
215
+ * ได้ผลเดิมโดย hub ไม่ส่งอะไรอีก (เนื้อหาต่างแต่ key เดิม = MailError `invalid` 422) · จำไว้ 7 วัน
216
+ * ควรเป็นค่าที่ผูกกับเหตุการณ์ เช่น `pi-done:reminder:<userId>:<runId>`
217
+ */
218
+ idempotencyKey?: string;
219
+ }
220
+ /**
221
+ * (0.7.0) ส่งเข้า "topic" ของแอป — กลุ่ม/ห้อง LINE หรือกลุ่ม Telegram ที่ admin ผูกไว้ที่หน้า Topics ของ Pi Notify
222
+ * แอปไม่ต้องรู้ group id: เชิญ OA/bot เข้ากลุ่ม → hub เห็นกลุ่ม → admin ผูกกับ topic
223
+ */
224
+ interface NotifyTopicInput extends Omit<NotifyInput, "to" | "channels" | "prefer"> {
225
+ /** ชื่อ topic ของแอปนี้ (a-z 0-9 . _ -) เช่น "po-overdue" — ไม่พบ = MailError invalid (404) */
226
+ topic: string;
227
+ /** ไม่ระบุ = ทุกปลายทางของ topic · ระบุ = เฉพาะแพลตฟอร์มนี้ */
228
+ channels?: ("line" | "telegram")[];
207
229
  }
208
230
  interface NotifyResultItem {
231
+ /** อีเมลผู้รับ หรือ "topic:<key>" เมื่อส่งด้วย notifyTopic() */
209
232
  to: string;
210
233
  channel: NotifyChannel;
211
- status: "sent" | "failed" | "skipped";
234
+ /** `queued` (0.6.0) = hub รับไว้ส่งซ้ำเอง — เกิดเฉพาะเมื่อแอปส่ง `deliverWithin` */
235
+ status: "sent" | "failed" | "skipped" | "queued";
212
236
  provider?: string;
213
237
  id?: string;
214
238
  failedOver?: boolean;
@@ -216,10 +240,15 @@ interface NotifyResultItem {
216
240
  /**
217
241
  * app_daily_cap = แอปส่งอีเมลแทนแชทครบเพดานรายวันที่ตั้งใน Pi Notify แล้ว
218
242
  * no_push_provider = hub ยังไม่ได้ตั้ง provider web push
243
+ * no_target (0.7.0) = topic ถูกปิด หรือยังไม่มีกลุ่มที่ใช้ได้ผูกอยู่
219
244
  */
220
- reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "no_push_provider" | "app_daily_cap";
245
+ reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "no_push_provider" | "app_daily_cap" | "no_target";
221
246
  /** (status failed) คำขอหมดงบเวลาก่อนได้เริ่มส่งรายการนี้ — ส่งซ้ำเฉพาะรายการนี้ได้ */
222
247
  deferred?: boolean;
248
+ /** (0.6.0) id ของรายการในคิวส่งซ้ำของ hub — มีเมื่อ status เป็น queued */
249
+ deliveryId?: string;
250
+ /** (0.7.0) notifyTopic(): ชื่อกลุ่ม/ปลายทางของรายการนี้ */
251
+ target?: string;
223
252
  }
224
253
  interface NotifyResult {
225
254
  results: NotifyResultItem[];
@@ -228,6 +257,8 @@ interface NotifyResult {
228
257
  skipped: number;
229
258
  /** จำนวนที่ยังไม่ได้ส่งเพราะหมดงบเวลา (นับซ้อนอยู่ใน failed; Pi Notify รุ่นเก่าไม่ส่งมา) */
230
259
  deferred?: number;
260
+ /** (0.6.0) จำนวนที่ hub รับไว้ส่งซ้ำเอง (ไม่นับใน failed) — Pi Notify รุ่นเก่าไม่ส่งมา */
261
+ queued?: number;
231
262
  }
232
263
  interface NotifyOptions {
233
264
  /** ค่าเริ่มต้น PI_NOTIFY_KEY */
@@ -279,16 +310,25 @@ interface RecipientInfo {
279
310
  /** มี PI_NOTIFY_KEY (ใน opts หรือ env) หรือไม่ — ให้ helper แบบ safe/later ใช้ตัดสินว่า "ปิดอยู่" */
280
311
  declare function hasNotifyKey(opts?: NotifyOptions): boolean;
281
312
  /**
282
- * แจ้งเตือนผู้รับผ่าน Pi Notify hub — Telegram (ที่ผู้รับผูกไว้) หรืออีเมล
313
+ * แจ้งเตือนผู้รับผ่าน Pi Notify hub — แชทที่ผู้รับผูกไว้ (Telegram / LINE / web push) หรืออีเมล
283
314
  * ใช้ env เดียวกับ sendMail(): PI_NOTIFY_KEY (+ PI_NOTIFY_URL)
284
315
  * โยน MailError เมื่อ hub ตอบ non-2xx (502 = ไม่มีช่องทางไหนส่งสำเร็จเลย; results อยู่ใน err.cause)
285
316
  */
286
317
  declare function notify(input: NotifyInput, opts?: NotifyOptions): Promise<NotifyResult>;
318
+ /**
319
+ * (0.7.0) ส่งเข้า topic ของแอป — hub กระจายไปทุกกลุ่ม LINE/Telegram ที่ admin ผูกไว้ (ผลรายกลุ่มมี `target`)
320
+ * topic ไม่พบ → MailError invalid (404) · topic ปิด/ยังไม่ผูกกลุ่ม → ผล skipped reason "no_target" (ไม่โยน)
321
+ * ใช้ deliverWithin / idempotencyKey ได้เหมือน notify()
322
+ * @example await notifyTopic({ topic: "po-overdue", title: "PO เกินกำหนด 3 รายการ", body: "…", link });
323
+ */
324
+ declare function notifyTopic(input: NotifyTopicInput, opts?: NotifyOptions): Promise<NotifyResult>;
287
325
  /** ช่องทางที่ผู้รับผูกไว้ — ไว้แสดง "ผูกแล้ว/ยังไม่ผูก" ในหน้าตั้งค่าของแอป */
288
326
  declare function getRecipient(email: Recipient, opts?: NotifyOptions): Promise<RecipientInfo>;
289
327
  /**
290
- * ขอลิงก์ผูก Telegram ให้ผู้รับ (t.me/<bot>?start=<code>, หมดอายุ 10 นาที)
291
- * แอปวางเป็นปุ่ม/QR ในหน้าตั้งค่าของตัวเอง แล้วโพล getRecipient() จนเห็น telegram
328
+ * ขอลิงก์ผูกแชทให้ผู้รับ (หมดอายุ 10 นาที) — แอปวางเป็นปุ่ม/QR ในหน้าตั้งค่าของตัวเอง แล้วโพล getRecipient() จนเห็นช่องทางโผล่
329
+ * - telegram: `t.me/<bot>?start=<code>` (ผู้ใช้กด Start)
330
+ * - line (0.7.0): `line.me/R/oaMessage/@<oa>/?ผูก Pi Notify <code>` — เปิดแชท OA พร้อมพิมพ์ข้อความไว้ ผู้ใช้กดส่ง
331
+ * (ยังไม่เป็นเพื่อนกับ OA ต้องเพิ่มเพื่อนด้วยจึงจะรับ push ได้) · `code` ใช้แสดงให้พิมพ์ "ผูก Pi Notify <code>" เอง
292
332
  * `opts.piUserId` = user id ใน Pi Auth (sub) ของผู้ใช้ — ให้ Pi Notify ลบการผูกเมื่อบัญชีถูกลบ
293
333
  */
294
334
  declare function createLink(email: Recipient, channel?: Exclude<NotifyChannel, "email">, opts?: NotifyOptions & {
@@ -337,4 +377,5 @@ declare function listPushDevices(email: string, opts?: NotifyOptions): Promise<{
337
377
  devices: PushDevice[];
338
378
  }>;
339
379
 
340
- export { type Address as A, notify as B, type ChannelStatus as C, savePushSubscription as D, type ErrorKind as E, toEmails as F, type Message as M, type NotifyOptions as N, type ParsedAddress as P, type Quota as Q, type Recipient as R, type SendResult as S, type Provider as a, type ProviderStatus as b, type ProviderInfo as c, type Store as d, type MailerEvent as e, type Attempt as f, type PushDevice as g, type NotifyResultItem as h, type NotifyInput as i, type Attachment as j, NOTIFY_TIMEOUT_MS as k, type NormalizedMessage as l, type NotifyChannel as m, type NotifyResult as n, type PushSubscriptionJson as o, type QuotaUsage as p, type RecipientChannelInfo as q, type RecipientInfo as r, type SkipReason as s, createLink as t, deletePushSubscription as u, getChannelStatus as v, getPushConfig as w, getRecipient as x, hasNotifyKey as y, listPushDevices as z };
380
+ export { listPushDevices as B, notify as D, notifyTopic as F, savePushSubscription as G, toEmails as H, NOTIFY_TIMEOUT_MS as l, createLink as u, deletePushSubscription as v, getChannelStatus as w, getPushConfig as x, getRecipient as y, hasNotifyKey as z };
381
+ export type { Address as A, ChannelStatus as C, ErrorKind as E, Message as M, NotifyOptions as N, ParsedAddress as P, Quota as Q, Recipient as R, SendResult as S, Provider as a, ProviderStatus as b, ProviderInfo as c, Store as d, MailerEvent as e, Attempt as f, PushDevice as g, NotifyResultItem as h, NotifyInput as i, NotifyTopicInput as j, Attachment as k, NormalizedMessage as m, NotifyChannel as n, NotifyResult as o, PushSubscriptionJson as p, QuotaUsage as q, RecipientChannelInfo as r, RecipientInfo as s, SkipReason as t };
package/package.json CHANGED
@@ -1,12 +1,9 @@
1
1
  {
2
2
  "name": "@suphark/notify",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ส่งอีเมล + แจ้งเตือน Telegram ผ่าน Pi Notify hub (notify.suphark.com) หรือส่งอีเมลตรงไป Resend/SMTP/Zoho/ZeptoMail ใน API เดียว พร้อม failover อัตโนมัติ",
7
- "main": "./dist/index.js",
8
- "module": "./dist/index.js",
9
- "types": "./dist/index.d.ts",
10
7
  "exports": {
11
8
  ".": {
12
9
  "types": "./dist/index.d.ts",
@@ -39,7 +36,7 @@
39
36
  "prepack": "pnpm run build"
40
37
  },
41
38
  "dependencies": {
42
- "nodemailer": "^10.0.14"
39
+ "nodemailer": "^10.2.1"
43
40
  },
44
41
  "peerDependencies": {
45
42
  "next": ">=15"
@@ -50,14 +47,14 @@
50
47
  }
51
48
  },
52
49
  "devDependencies": {
53
- "@biomejs/biome": "^2.5.15",
54
- "@pi/config": "workspace:*",
50
+ "@biomejs/biome": "^2.6.2",
51
+ "@pi/tsconfig": "workspace:*",
55
52
  "@types/node": "^24.13.4",
56
53
  "@types/nodemailer": "^8.0.1",
57
- "next": "^16.3.8",
58
- "tsup": "^8.5.1",
54
+ "next": "^16.4.2",
55
+ "tsup": "^9.0.0",
59
56
  "typescript": "^6.0.3",
60
- "vitest": "^4.1.11"
57
+ "vitest": "^5.0.5"
61
58
  },
62
59
  "license": "MIT",
63
60
  "repository": {