@suphark/notify
v0.6.0
Published
ส่งอีเมล + แจ้งเตือน Telegram ผ่าน Pi Notify hub (notify.suphark.com) หรือส่งอีเมลตรงไป Resend/SMTP/Zoho/ZeptoMail ใน API เดียว พร้อม failover อัตโนมัติ
Maintainers
Readme
@suphark/notify
SDK ส่งอีเมล + แจ้งเตือน (Telegram / Web Push) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน Pi Notify hub (https://notify.suphark.com) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
📖 คู่มือการใช้งานและสูตรการเขียนโค้ดฉบับเต็ม: ดูที่ docs/guide.md
pnpm add @suphark/notify # หรือ npm i / bun addใช้งาน (โหมด hub — แนะนำ)
PI_NOTIFY_KEY=pn_xxx # ออกจากหน้า Apps & keys ของ Pi Notifyimport { sendMail } from "@suphark/notify";
await sendMail({
to: "[email protected]",
subject: "ยินดีต้อนรับ",
html: "<p>สวัสดี</p>",
});จบ — ไม่ต้องใส่ from (hub ใช้ from เริ่มต้นของแอป), ไม่ต้องรู้ว่าข้างหลังเป็น Resend หรือ SMTP, provider ล่มหรือโควต้าหมด hub ย้ายให้เอง
PI_NOTIFY_URLoverride ได้เมื่อรัน hub ที่อื่น (เช่นhttp://localhost:3300ตอน dev)- ผลลัพธ์
{ id, provider: "hub", failedOver, attempts }; ส่งไม่ได้เลยโยนAllProvidersFailedError(hub ตอบ 429 = โควต้าหมดทุกตัว, 502 = provider ล้มทุกตัว, 422 = ข้อความผิด)
ดูว่ามีตัวเลือกอะไร / ขอให้ส่งผ่านตัวไหนก่อน
import { listProviders, sendMail } from "@suphark/notify";
await listProviders();
// [{ name: "resend", type: "resend", available: true, via: "hub" },
// { name: "company-smtp", type: "smtp", available: false, via: "hub" }] ← แค่นี้ ไม่มี config/โควต้า
await sendMail({ to, subject, html, prefer: "company-smtp" }); // ลองตัวนี้ก่อน ไม่ได้ค่อย failover ตามลำดับปกติสำรองเผื่อ hub ล่ม (ไม่บังคับ)
ใส่ provider local ต่อท้ายได้ — hub มาก่อนเสมอ ถ้า hub ตอบไม่ได้ SDK จะ failover ไปตัวถัดไปเอง:
PI_NOTIFY_KEY=pn_xxx
MAIL_FROM="My App <[email protected]>"
MAIL_PROVIDERS=resend
MAIL_RESEND_API_KEY=re_xxxแจ้งเตือน (Telegram / อีเมล fallback)
ผู้รับระบุด้วย อีเมล (บัญชี Pi Auth) — hub ดูเองว่าคนนั้นผูก Telegram ไว้ไหม ผูกไว้ส่งแชท ไม่ผูกส่งอีเมล แอปไม่ต้องเก็บ chat id
import { notify } from "@suphark/notify";
const r = await notify({
to: "[email protected]", // หรือ array ≤ 50
title: "บิลใกล้ครบกำหนด",
body: "ค่าเน็ต 599 บาท ครบกำหนดพรุ่งนี้", // ข้อความล้วน หรือ HTML จำกัดแท็ก b/i/u/s/a/code/pre
link: "https://duebell.app/alerts", // ปุ่ม "เปิดลิงก์"
});
// r = { sent: 1, failed: 0, skipped: 0, results: [{ to, channel: "telegram", status: "sent", provider, id }] }format: "text"เมื่อ body เป็นข้อความล้วนที่อาจมี<&(เช่นชื่อรายการ "บัตร ") — ไม่ระบุ hub จะเดาจากแท็กที่รู้จัก;"html"= ตีความแท็ก b/i/u/s/a/code/prechannels: ["telegram"]= ส่งเฉพาะแชท ไม่ผูก →status: "skipped", reason: "channel_not_linked"(ไม่ตกไปอีเมล) ·["email"]= อีเมลอย่างเดียว ·["telegram", "email"]= ทั้งคู่- failover ไม่ข้ามช่องทาง (Telegram ส่งพังไม่ได้แปลว่าส่งอีเมลแทน) — แอปดู
resultsแล้วตัดสินใจเอง - hub ตอบ 502 เมื่อไม่มีช่องทางไหนสำเร็จเลย → โยน
MailError(err.cause.error.resultsมีรายละเอียด) - timeout:
notify()รอสูงสุด 65 วิ,getRecipient()/createLink()10 วิ (เปลี่ยนด้วย{ timeoutMs }) หมดเวลาโยนMailErrorkindnetwork— hub อาจส่งไปแล้ว อย่ายิงซ้ำทั้งก้อนทันที - 429/503 มี
err.retryAfterSec(จากRetry-After) บอกว่าควรรอกี่วินาทีก่อนลองใหม่
ให้ผู้ใช้ผูก Telegram จากหน้าตั้งค่าของแอป (ไม่ต้องเด้งไป hub):
import { createLink, getRecipient } from "@suphark/notify";
const { url, expiresAt } = await createLink(user.email); // https://t.me/<bot>?start=<code> (10 นาที)
// แสดงปุ่ม/QR → ผู้ใช้กด Start ในแชท → โพล
const { channels } = await getRecipient(user.email); // [{ type: "telegram", label: "@bob", enabled: true, linkedAt }]หรือส่งผู้ใช้ไปผูกเองที่ https://notify.suphark.com/me (ล็อกอิน Pi Auth) — ผูกครั้งเดียวใช้ได้ทุกแอป
createLink() คืน code ด้วย (รหัสเดียวกับ ?start= ใน url — ไว้ให้ผู้ใช้พิมพ์ /start <code> เองเมื่อเปิด deep link ไม่ได้) และ getChannelStatus() ตอบสถานะการผูกเลยโดยไม่ต้อง find เอง:
const { url, code } = await createLink(user); // รับ user object ได้
const { linked, label, manageUrl } = await getChannelStatus(user, "telegram");ให้ hub ส่งซ้ำเอง: deliverWithin + idempotencyKey (0.6.0)
const r = await notify({
to: user,
title: "ถึงกำหนดงาน",
body: "…",
channels: ["telegram"],
deliverWithin: 3600, // ให้ hub พยายามส่งภายใน 1 ชม.
idempotencyKey: `pi-done:reminder:${user.id}:${runId}`, // cron รันซ้ำก็ไม่เตือนซ้ำ
});
// r.results[i].status: "sent" | "queued" (hub รับไว้ส่งซ้ำเอง) | "failed" | "skipped"- นับ
queuedเป็น "รับไว้แล้ว" — แอปไม่ต้องส่งซ้ำเอง; ดูสถานะที่หน้า Logs / คิวส่งซ้ำของ Pi Notify failed= ผิดถาวร / ลองครบแล้ว / เลยเวลา — แอปตัดสินใจเอง (อย่าสลับไปอีเมลถ้าผู้ใช้ไม่ได้ขอ)- ใช้
idempotencyKeyที่ผูกกับเหตุการณ์ (ไม่ใช่สุ่มใหม่ทุกครั้ง) จึงกันซ้ำได้ — ส่งเนื้อหาเดิมด้วย key เดิมซ้ำจะได้ผลเดิมกลับมา
เขียนให้สั้น: ส่งแบบไม่โยน / ส่งเบื้องหลัง / to เป็น user object (0.5.0)
to ของ notify() รับ user object ที่มี email (และ array ของมัน) ได้เลย — ไม่ต้องแกะ .email เอง
ผู้รับเดี่ยวที่ไม่มีอีเมลโยน MailError kind invalid (เป็น bug ของแอป ไม่เงียบ); ใน array กรอง null/undefined ทิ้งให้
await notify({ to: user, title: "งานเสร็จแล้ว", body: "โปรเจกต์ A พร้อมส่งมอบ" });
await notify({ to: [owner, assignee], title: "มีการอัปเดต", body: "…" }); // null ใน array ถูกกรองเลือกตามว่า ต้องรู้ผลหรือไม่:
| ต้องการ | ใช้ | พฤติกรรม |
|---|---|---|
| ต้องรู้ผล / ให้ cron บันทึก FAILED แล้วลองใหม่ | notify() / sendMail() | โยน MailError เมื่อล้มเหลว |
| แจ้งเตือนเป็นผลพลอยได้ของงานหลัก (ไม่ให้งานหลักพัง) | notifySafe() / sendMailSafe() | ไม่โยน คืน { ok, sent, failed, skipped, results, error?, disabled? } |
| เหมือนข้างบน และไม่อยากให้หน้าเว็บรอ (Next.js) | notifyLater() / sendMailLater() จาก @suphark/notify/next | ส่งหลังตอบ response ด้วย after() คืน void |
import { notifySafe } from "@suphark/notify";
const { ok } = await notifySafe({ to: user, title: "แจ้งเตือน", body: "เนื้อหา" });
import { notifyLater } from "@suphark/notify/next"; // Next.js เท่านั้น (next เป็น optional peer)
notifyLater({ to: user, title: "บันทึกแล้ว", body: "ระบบอัปเดตงานแล้ว" });- ไม่มี
PI_NOTIFY_KEY→{ ok: false, disabled: true }ไม่ส่งและไม่ถือเป็น error (dev/test ไม่ต้องมี key) — เตือนครั้งเดียวใน dev, ทุกครั้งใน production เพื่อให้เห็นว่าลืมตั้ง ok= ไม่มี error และไม่มีรายการfailed—skipped(เช่นยังไม่ผูก Telegram) ไม่ใช่ ความล้มเหลว; ดูรายตัวที่resultsnotifyLaterใช้ timeout 20 วิ (งานในafter()นับรวมmaxDurationของ function) และ log เมื่อล้มเหลวโดยไม่มีอีเมลผู้รับ — ส่งต่อเข้า logger ของแอปได้ด้วย{ onError }; นอก request scope (test/script) รันแบบ detached- ห้ามใช้
…Laterเมื่อต้องรู้ผล (เช่น cron digest ที่บันทึกสถานะ) — ใช้await notify()
อีเมลหน้าตาเดียวกับที่ Pi Notify ส่งแทนแชท โดยไม่ต้องเขียน HTML เอง — renderNotifyEmail() (หัวข้อ + ย่อหน้า + ปุ่มเดียว; body เป็นข้อความล้วน ถูก escape ให้):
import { renderNotifyEmail, sendMail } from "@suphark/notify";
await sendMail({
to: user.email,
...renderNotifyEmail({
title: "บัญชีของคุณพร้อมใช้งานแล้ว",
body: "ผู้ดูแลอนุมัติบัญชีของคุณแล้ว",
link: "https://app.example/",
linkLabel: "เข้าสู่ระบบ", // ค่าเริ่มต้น "เปิดลิงก์"
accent: "#0f766e", // สีปุ่ม (hex เท่านั้น) ค่าเริ่มต้น #1d4ed8
appName: "Pi PDF", // ท้ายอีเมล
}),
});แจ้งเตือนบนเบราว์เซอร์ (web push)
hub ถือคู่กุญแจ VAPID ให้ (ไม่ต้องใช้ Firebase) — แอปเหลือแค่ service worker ของตัวเองกับปุ่มขอสิทธิ์ subscription ผูกกับแอปที่เรียกเสมอ เพราะ service worker อยู่ที่โดเมนของแอป ใช้ข้ามแอปไม่ได้
แพ็กเกจนี้รันฝั่งเซิร์ฟเวอร์เท่านั้น เพราะถือ
PI_NOTIFY_KEY— ห้าม import ใน component ที่ส่งถึงเบราว์เซอร์ เบราว์เซอร์คุยกับเซิร์ฟเวอร์ของแอปตัวเอง (server action / route) แล้วเซิร์ฟเวอร์ค่อยคุยกับ hub
ทางลัด (0.5.0): createPushHandlers({ getUser }) ทำ 3 ฟังก์ชันข้างล่างให้ — แอปส่งแค่วิธีดึงผู้ใช้จาก session
(อีเมลต้องมาจาก session เท่านั้น; ตรวจรูป subscription จากเบราว์เซอร์ให้; ไม่ล็อกอิน = MailError kind auth) ใช้คู่กับ <WebPushRow /> ของ @suphark/ui/pwa:
"use server";
import { createPushHandlers } from "@suphark/notify";
const push = createPushHandlers({
getUser: async () => {
const u = await getCurrentUser(); // ของแต่ละแอป
return u?.email ? { email: u.email, id: u.id } : null;
},
});
// ห่อเป็น async function ทีละตัว (ไฟล์ "use server" export ได้เฉพาะ async function)
export async function getVapidPublicKey() { return push.getPublicKey(); }
export async function registerPush(sub: PushSubscriptionJSON) { return push.subscribe(sub); }
export async function unregisterPush(endpoint: string) { return push.unsubscribe(endpoint); }Service worker เสิร์ฟจากแพ็กเกจเอง — ไม่ต้องเขียน public/sw.js และ ไม่ควร importScripts จากโดเมนอื่น
(service worker คุมทั้ง origin ของแอปและดักทุก request — โค้ดจากโดเมนอื่นรันเข้ามาได้ทุกแอปพร้อมกัน):
// app/sw.js/route.ts
import { pushServiceWorker } from "@suphark/notify";
export const GET = pushServiceWorker({ icon: "/icon-192.png" }); // เปิดเฉพาะลิงก์ใน origin ของแอปเขียนเองทีละฟังก์ชันก็ได้ (ตัวอย่างเดิม):
// ---- ฝั่งเซิร์ฟเวอร์ของแอป (server action) ----
"use server";
import { getPushConfig, savePushSubscription, deletePushSubscription, listPushDevices } from "@suphark/notify";
export async function pushPublicKey() {
const { publicKey } = await getPushConfig(); // VAPID public key (ค่าสาธารณะ ส่งให้ client ได้)
return publicKey;
}
export async function savePush(subscription: PushSubscriptionJson, label?: string) {
const user = await requireAuth(); // อีเมลมาจาก session เท่านั้น ห้ามรับจาก client
return savePushSubscription(user.email, subscription, { label, piUserId: user.id });
}
export async function removePush(endpoint: string) {
const user = await requireAuth();
return deletePushSubscription(user.email, endpoint);
}
export async function myDevices() {
const user = await requireAuth();
return listPushDevices(user.email); // { devices: [{ id, label, enabled, endpointTail }] }
}// ---- ฝั่งเบราว์เซอร์ (component ของแอป) ----
const reg = await navigator.serviceWorker.register("/sw.js");
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: await pushPublicKey(),
});
await savePush(sub.toJSON(), `${navigator.userAgent}`);ไม่ใช้ SDK ก็ได้ — ทั้งหมดเป็น REST ธรรมดา เรียกจากเซิร์ฟเวอร์ของแอปด้วย fetch ได้เลย
(Authorization: Bearer $PI_NOTIFY_KEY): GET /api/v1/push/config · POST/GET/DELETE /api/v1/recipients/:email/push
SDK ให้เพิ่มแค่ timeout, การแปลง error เป็น MailError พร้อม kind, type และค่าเริ่มต้นของ URL/คีย์
ส่งด้วย notify({ channels: ["push"] }) (ไม่ระบุ channels = ส่งทุกช่องทางที่ผูกไว้ รวม push ทุกเครื่อง)
service worker ของแอปจะได้ payload JSON { title, body, link? } — body เป็นข้อความล้วนเสมอ:
self.addEventListener("push", (event) => {
const { title, body, link } = event.data.json();
event.waitUntil(self.registration.showNotification(title, { body, data: { link } }));
});- ไม่มีเครื่องไหนเปิดไว้ →
status: "skipped", reason: "channel_not_linked"· hub ยังไม่ตั้ง provider →no_push_provider - เบราว์เซอร์ถอนสิทธิ์ (404/410) → hub ปิด subscription นั้นให้เอง ไม่ต้องตามลบ
- เปลี่ยนคู่กุญแจ VAPID ที่ hub = ผู้ใช้ทุกคนต้องกดอนุญาตใหม่ (subscription ผูกกับกุญแจที่ใช้ตอน subscribe)
โหมด local (ไม่ผ่าน hub)
ไม่มี PI_NOTIFY_KEY = SDK ส่งตรงด้วย provider ที่ตั้งใน env ของแอปเอง (engine เดียวกับที่ hub ใช้)
MAIL_FROM="My App <[email protected]>"
MAIL_PROVIDERS=resend,company,zoho # ลำดับ = ลำดับความสำคัญ
MAIL_RESEND_API_KEY=re_xxx
MAIL_RESEND_QUOTA_DAY=100 # เพดานที่ตั้งเอง → ถึงแล้วข้ามไปตัวถัดไปทันที
MAIL_COMPANY_TYPE=smtp
MAIL_COMPANY_HOST=mail.example.com
MAIL_COMPANY_PORT=587
[email protected]
MAIL_COMPANY_PASS=secret
[email protected] # ชื่อตรงกับ preset ไม่ต้องใส่ _TYPE
MAIL_ZOHO_PASS=app-passwordหรือตั้งค่าในโค้ด:
import { createMailer } from "@suphark/notify";
export const mailer = createMailer({
from: "My App <[email protected]>",
providers: [
{ type: "hub", apiKey: process.env.PI_NOTIFY_KEY! },
{ type: "resend", apiKey: process.env.RESEND_API_KEY!, quota: { perDay: 100 } },
{ type: "smtp", name: "company", host: "mail.example.com", port: 587, user: "…", pass: "…" },
{ type: "zoho", user: "[email protected]", pass: "…" },
{ type: "console" }, // dev: พิมพ์ลง console
],
strategy: "priority", // หรือ "round-robin"
onEvent: (e) => logger.info(e), // sent / failed / skipped / cooldown / recovered / exhausted
});configureMailer(options) ครั้งเดียวตอนบูตถ้าอยากใช้ sendMail() แบบ global กับ options เอง
เกิดอะไรขึ้นตอนส่ง
- ไล่ provider ตามลำดับ ข้ามตัวที่อยู่ใน cooldown (เพิ่งพัง) หรือ โควต้าที่ตั้งเต็ม
- ตัวที่ลองแล้วพัง → จำแนก error แล้วพัก (backoff คูณสองเมื่อพังซ้ำ)
| error | kind | พัก (ค่าเริ่มต้น) |
|---|---|---|
| ต่อไม่ติด / timeout | network | 1 นาที |
| 5xx, SMTP 421/45x | server | 1 นาที |
| 429 | rate_limit | 2 นาที |
| 401/403, SMTP 535, domain not verified | auth | 15 นาที |
| quota / sending limit / SMTP 552 | quota | 1 ชั่วโมง |
| 400/422, SMTP 550 recipient rejected | invalid | ไม่พัก |
- ปลายทางตอบ
Retry-After(Pi Notify ตอบมากับ 429/503) → พักเท่าที่ขอ ไม่คูณสอง (ไม่เกิน 1 ชม.) - ทุกตัวติด cooldown จนไม่ได้ลองส่งสักตัว → half-open: ลองตัวที่ cooldown จะหมดเร็วสุด 1 ครั้ง (เฉพาะที่พักเพราะ
network/server/unknown, ไม่เกิน 1 ครั้งต่อ 30 วิต่อ provider, ยังจองโควต้าตามปกติ) สำเร็จ = ล้าง cooldown ทันที — แอปที่มี hub ตัวเดียวจึงไม่ต้องรอ cooldown 30 นาทีหลัง Pi Notify กลับมา - คืน
{ id, provider, failedOver, attempts }หรือโยนAllProvidersFailedError(มีattempts)
maxCooldownSec — ไม่ตั้ง = backoff ไม่เกิน 30 นาที แต่ไม่ต่ำกว่าค่าพื้นของ kind (quota ยังพัก 1 ชม.) · ตั้งเอง = เพดานจริงของทุก kind รวม Retry-After เช่นแอปที่พึ่ง Pi Notify ตัวเดียว (Pi Auth) ตั้ง maxCooldownSec: 300 ให้พักไม่เกิน 5 นาที
timeoutMs (ค่าเริ่มต้น 20 วิ) หมดเวลาแล้ว mailer ยกเลิกคำขอด้วย AbortSignal ที่ส่งให้ provider (msg.signal — provider hub ส่งต่อเข้า fetch) แล้ว failover ต่อ; provider ที่เขียนเองควรส่ง msg.signal ต่อเข้า fetch เช่นกัน
provider type
| type | ต้องใส่ |
|---|---|
| hub | apiKey (url optional) |
| resend | apiKey |
| smtp | host (+ port secure user pass rejectUnauthorized pool) |
| zoho zohopro gmail office365 brevo mailtrap | user, pass (SMTP preset) |
| zeptomail | apiKey (+ region) |
| console | – |
| custom | provider (object ที่ implement Provider) |
ทุก type รับ name from senderDomains quota: { perHour, perDay, perMonth } disabled
ตัวตนผู้ส่ง: provider ที่ตั้ง from ไว้จะส่งจากที่อยู่นั้นเสมอ เว้นแต่ message.from อยู่ใน senderDomains (หรือเป็นที่อยู่เดียวกัน) — ถ้าไม่ผ่านจะใช้ที่อยู่ของ provider แต่คงชื่อจาก message (Pi Auth <[email protected]>) เพราะ SMTP/Resend ปฏิเสธ from ที่ไม่ใช่ของตัวเอง; provider ที่ไม่ตั้ง from (hub, console) ส่งจากที่อยู่ที่ message ขอ
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
Message
{
to: Address | Address[]; // "[email protected]" | "ชื่อ <[email protected]>" | { name, email }
subject: string;
html?: string; text?: string; // อย่างน้อยหนึ่ง
from?: Address; // ชนะ from ของ provider/แอป
cc?, bcc?, replyTo?;
attachments?: { filename, content: Buffer | string, contentType?, encoding?: "base64" }[];
headers?: Record<string, string>;
tags?: Record<string, string>; // Resend เท่านั้น
prefer?: string; // ลองตัวนี้ก่อน (ชื่อจาก listProviders) ยัง failover
provider?: string; // บังคับตัวนี้ตัวเดียว ไม่ failover (โหมด local เท่านั้น)
}อื่น ๆ
await mailer.verify(); // ทดสอบเชื่อมต่อทุกตัว (hub: เช็ค key)
await mailer.status(); // cooldown / โควต้าที่ใช้ไป
await mailer.reset(); // ล้าง cooldown- โหมด local หลาย instance แชร์ cooldown/โควต้า:
createMailer({ store: createRedisStore(redis) }) - provider เอง: implement
{ name, type, send(msg) }แล้วโยนMailErrorพร้อมkind - ESM only, Node ≥ 20
การออกเวอร์ชัน
แอปที่ใช้แพ็กเกจนี้: auth (pnpm), pdf (bun), duebell (npm), pi-done (npm) — ทุกรอบต้อง bump + deploy ทั้ง 4 แอป จึงมีกติกา:
- แก้ที่ Pi Notify ก่อนเสมอ อะไรแก้ฝั่ง server ได้ไม่ต้องออก SDK
- รวมการแก้ SDK ไว้ออกรอบเดียว (ไม่ออกทีละ fix)
- เปลี่ยนได้แค่แบบเพิ่ม ระหว่าง SDK รุ่นที่แอปยังใช้อยู่กับ Pi Notify:
- field ใหม่ใน request/response เป็น optional เสมอ
- ค่า enum ใหม่ใน response ออกได้เฉพาะเมื่อ request ขอ (opt-in)
- ห้ามเปลี่ยน HTTP status ของกรณีเดิม
error.kindที่ server ส่งต้องเป็นค่าที่ErrorKindของรุ่นเก่ารู้จัก (ค่าที่ไม่รู้จัก = SDK ไม่พัก provider เลย) — แก้ kind ที่จัดผิดได้- ต้องรับ key
pm_ต่อไป และชื่อ provider typehubคงเดิม
- additive ทั้งหมด → 0.3.x; ใช้ 0.4.0 เมื่อรูปของ type ที่แอปใช้อยู่ต้องเปลี่ยนเท่านั้น
- ห้ามเปลี่ยน
DEFAULT_HUB_URLและห้ามถอด hostname ที่ SDK รุ่นใดเคยใช้เป็นค่าเริ่มต้น — โดเมนใหม่เพิ่มเป็น alias (บทเรียนจากmail.suphark.comที่ถอดแล้วแอปรุ่นเก่าส่งไม่ออก) - ถอดของเก่าได้ก็ต่อเมื่อ lockfile ของทั้ง 4 แอปเป็นรุ่นใหม่และ deploy แล้ว
Pi Notify มี contract test (apps/notify/src/test/contract/) เก็บ body จริงของ SDK 0.3.0 ไว้ — แดง = เปลี่ยน API แบบไม่ additive ให้แก้ฝั่ง Pi Notify ไม่ใช่แก้ fixture
ขั้นตอนออกรุ่น
- แก้
versionในpackages/notify/package.json+ หัวข้อ Changelog ด้านล่าง →pnpm --filter @suphark/notify verify→npm pack --dry-runดูไฟล์ใน tarball - เจ้าของ:
npm publishจากpackages/notify(prepack build ให้, ต้องใช้ 2FA) - bump ทีละแอป เริ่มจาก pdf / duebell แล้ว pi-done และ Pi Auth เป็นตัวสุดท้าย — ยก range ขึ้นด้วยกัน lockfile ย้อนรุ่น:
- pdf:
bun add @suphark/notify@^<ver> - duebell, pi-done:
npm install @suphark/notify@^<ver> - auth:
pnpm --filter @pi/auth add @suphark/notify@^<ver>(จาก root ของ repo auth)
- pdf:
- ตรวจ: lockfile ทั้ง 4 แอปเป็นรุ่นใหม่, magic link ของ Pi Auth มาถึง, DueBell ส่งทดสอบ notify ได้, คำเชิญของ Pi Done ส่งออก
Changelog
0.6.0 — hub ส่งซ้ำเอง (เพิ่มอย่างเดียว — แอปที่ไม่ส่งตัวเลือกใหม่ได้พฤติกรรมเดิมทุกอย่าง; ต้องใช้ Pi Notify 2026-10-14+)
notify({ deliverWithin })(วินาที 60–86400): รายการที่ค้างหรือพลาดชั่วคราวเข้าคิวถาวรของ hub แล้วส่งซ้ำตาม backoff (1 นาที → 5 นาที → 30 นาที → 2 ชม., สูงสุด 5 ครั้ง) · ผิดถาวรไม่ส่งซ้ำ · เลยเวลาแล้วไม่ส่ง · ไม่สลับช่องทางnotify({ idempotencyKey })→ หัวIdempotency-Key: key+เนื้อหาเดิมได้ผลเดิม ไม่ส่งซ้ำ (จำ 7 วัน) · เนื้อหาต่างแต่ key เดิม =invalid(422) · คำขอเดิมยังทำอยู่ =rate_limit(409,retryAfterSec5)NotifyResultItem.statusเพิ่ม"queued"(+deliveryId),NotifyResult.queued?/NotifySafeResult.queued?—queuedนับเป็น "รับไว้แล้ว" ไม่ใช่ failed (notifySafe().okเป็น true) ·statusใหม่เกิดเฉพาะเมื่อแอปส่งdeliverWithin(ออกเป็น minor เพราะ union ของstatusที่แอปอาจ switch)
0.5.0 — เขียนฝั่งแอปให้สั้นลง (เพิ่มอย่างเดียว — ฟอร์มของ 0.4.0 ใช้ได้ทุกแบบ; Pi Notify รุ่นเก่าใช้ได้ ยกเว้น
codeที่แกะจาก url ให้เอง)notify({ to })รับ user object{ email }และ array (กรอง null); ผู้รับเดี่ยวไม่มีอีเมล =MailErrorinvalidnotifySafe()/sendMailSafe()ไม่โยน; ไม่มี key/provider =disabled@suphark/notify/next:notifyLater()/sendMailLater()ส่งหลังตอบด้วยafter()(optional peernext >= 15; core ไม่ import next)getChannelStatus();createLink()คืนcodecreatePushHandlers({ getUser })+pushServiceWorker()สำหรับ web pushrenderNotifyEmail()(+resolveFormat/toPlainText/looksLikeHtml) — เทมเพลตอีเมลเดียวกับที่ Pi Notify ใช้ แอปไม่ต้องเขียน HTML "หัวข้อ + ย่อหน้า + ปุ่ม" เองgetRecipient()/createLink()รับ user object;toEmails(),hasNotifyKey(), typeRecipient
0.4.0 — web push ผ่าน hub:
getPushConfig(),savePushSubscription(),deletePushSubscription(),listPushDevices()และnotify({ channels: ["push"] }); hub ถือคู่กุญแจ VAPID (มาตรฐาน ไม่ใช้ Firebase) subscription ผูกต่อแอป ·NotifyChannelเพิ่ม"push"และreasonเพิ่ม"no_push_provider"(เพิ่มอย่างเดียว แต่NotifyChannelเป็น type ที่แอปใช้ในลายเซ็นของตัวเอง จึงออกเป็น minor ตามกติกาข้อ 4)0.3.1 (additive ทั้งหมด — แอปที่ตั้ง
^0.3.0รับได้เลย, ใช้ร่วมกับ Pi Notify ที่ deploy อยู่ได้ทันที)- timeout:
notify()65 วิ,getRecipient()/createLink()10 วิ (NotifyOptions.timeoutMs, ค่าเริ่มต้นที่NOTIFY_TIMEOUT_MS) — เดิมไม่มี timeout เลย - abort: mailer หมด
timeoutMsแล้วยกเลิก fetch ของ providerhubผ่านNormalizedMessage.signal(field ใหม่ optional) ไม่ปล่อยคำขอค้าง - half-open cooldown: ทุกตัวติด cooldown (network/server/unknown) → ลองตัวที่ใกล้หมดเวลาพัก 1 ครั้ง/30 วิ — แอปที่มี hub ตัวเดียวกลับมาส่งได้ทันทีที่ Pi Notify กลับมา
- Retry-After: 429/503 ของ Pi Notify พักตาม
Retry-After(ไม่คูณสอง, ไม่เกิน 1 ชม.) และมีMailError.retryAfterSec maxCooldownSecที่ตั้งเอง = เพดานจริง ทุก kind (ไม่ตั้ง = พฤติกรรมเดิมทุกอย่าง)createLink(email, "telegram", { piUserId })ส่ง user id ของ Pi Auth ให้ Pi Notify ลบการผูกเมื่อบัญชีถูกลบ/ย้ายตามอีเมลใหม่NotifyResultItem.reasonเพิ่ม"app_daily_cap",deferred?+NotifyResult.deferred?(หมดงบเวลา 40 วิของ/api/v1/notify)- engine: ส่งสำเร็จแล้วไม่ failover ส่งซ้ำเมื่อ store (เช่น KvEntry บน DB) เขียน state ไม่ได้; store ล่มตอนบันทึกความล้มเหลวไม่หยุด failover
- timeout:
0.3.0 — เปลี่ยนชื่อ Pi Mail → Pi Notify (breaking): แพ็กเกจย้ายจาก
@suphark/mailerเป็น@suphark/notify(API เดิมทุกตัว เปลี่ยนแค่ import), envPI_MAIL_KEY/PI_MAIL_URL→PI_NOTIFY_KEY/PI_NOTIFY_URL(ชื่อเดิมไม่อ่านแล้ว), hub เริ่มต้นhttps://notify.suphark.com; key ใหม่ขึ้นต้นpn_(keypm_เดิมยังใช้ได้)0.2.1 —
notify()เพิ่มformat?: "text" | "html"ระบุชนิด body ชัดเจน (hub 2026-09-19+ ; ไม่ระบุยังเดาให้ แต่เดาเข้มขึ้น: นับเฉพาะแท็กที่รู้จัก)0.2.0 — Pi Notify:
notify()ส่งแจ้งเตือนถึงอีเมลผู้รับผ่าน hub (Telegram ที่ผู้รับผูกไว้ / อีเมล fallback),createLink()ขอ deep link ผูก Telegram,getRecipient()ดูช่องทางที่ผูก — ใช้PI_MAIL_KEY/PI_MAIL_URLเดิม;sendMail()ไม่เปลี่ยน0.1.1 — fix: โควต้าจองก่อนส่ง (คำขอพร้อมกันไม่ทะลุเพดาน, ส่งพังคืนโควต้า — Store มี
decroptional), from ผิดรูปแบบโยน invalid ทันที,hubใน MAIL_PROVIDERS ใช้ PI_MAIL_KEY/PI_MAIL_URL ได้, Resendverify()ไม่ต้องใช้สิทธิ์ list domains; nodemailer ^10 (ปิด advisory ของ 7.x); from policy: provider ที่มีfromเป็นตัวตนผู้ส่ง,senderDomainsอนุญาตทั้งโดเมน (message.from นอกโดเมนถูกแทนที่แต่คงชื่อ); configcustomรับfrom/nameoverride0.1.0 — รุ่นแรก: hub (
PI_MAIL_KEY), resend, smtp + presets, zeptomail, console, custom; failover/โควต้า/cooldown;listProviders()+prefer
