npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@suphark/notify

v0.6.0

Published

ส่งอีเมล + แจ้งเตือน Telegram ผ่าน Pi Notify hub (notify.suphark.com) หรือส่งอีเมลตรงไป Resend/SMTP/Zoho/ZeptoMail ใน API เดียว พร้อม failover อัตโนมัติ

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 Notify
import { sendMail } from "@suphark/notify";

await sendMail({
  to: "[email protected]",
  subject: "ยินดีต้อนรับ",
  html: "<p>สวัสดี</p>",
});

จบ — ไม่ต้องใส่ from (hub ใช้ from เริ่มต้นของแอป), ไม่ต้องรู้ว่าข้างหลังเป็น Resend หรือ SMTP, provider ล่มหรือโควต้าหมด hub ย้ายให้เอง

  • PI_NOTIFY_URL override ได้เมื่อรัน 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/pre
  • channels: ["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 }) หมดเวลาโยน MailError kind network — 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) ไม่ใช่ ความล้มเหลว; ดูรายตัวที่ results
  • notifyLater ใช้ 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 เอง

เกิดอะไรขึ้นตอนส่ง

  1. ไล่ provider ตามลำดับ ข้ามตัวที่อยู่ใน cooldown (เพิ่งพัง) หรือ โควต้าที่ตั้งเต็ม
  2. ตัวที่ลองแล้วพัง → จำแนก 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 | ไม่พัก |

  1. ปลายทางตอบ Retry-After (Pi Notify ตอบมากับ 429/503) → พักเท่าที่ขอ ไม่คูณสอง (ไม่เกิน 1 ชม.)
  2. ทุกตัวติด cooldown จนไม่ได้ลองส่งสักตัว → half-open: ลองตัวที่ cooldown จะหมดเร็วสุด 1 ครั้ง (เฉพาะที่พักเพราะ network/server/unknown, ไม่เกิน 1 ครั้งต่อ 30 วิต่อ provider, ยังจองโควต้าตามปกติ) สำเร็จ = ล้าง cooldown ทันที — แอปที่มี hub ตัวเดียวจึงไม่ต้องรอ cooldown 30 นาทีหลัง Pi Notify กลับมา
  3. คืน { 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 แอป จึงมีกติกา:

  1. แก้ที่ Pi Notify ก่อนเสมอ อะไรแก้ฝั่ง server ได้ไม่ต้องออก SDK
  2. รวมการแก้ SDK ไว้ออกรอบเดียว (ไม่ออกทีละ fix)
  3. เปลี่ยนได้แค่แบบเพิ่ม ระหว่าง SDK รุ่นที่แอปยังใช้อยู่กับ Pi Notify:
    • field ใหม่ใน request/response เป็น optional เสมอ
    • ค่า enum ใหม่ใน response ออกได้เฉพาะเมื่อ request ขอ (opt-in)
    • ห้ามเปลี่ยน HTTP status ของกรณีเดิม
    • error.kind ที่ server ส่งต้องเป็นค่าที่ ErrorKind ของรุ่นเก่ารู้จัก (ค่าที่ไม่รู้จัก = SDK ไม่พัก provider เลย) — แก้ kind ที่จัดผิดได้
    • ต้องรับ key pm_ ต่อไป และชื่อ provider type hub คงเดิม
  4. additive ทั้งหมด → 0.3.x; ใช้ 0.4.0 เมื่อรูปของ type ที่แอปใช้อยู่ต้องเปลี่ยนเท่านั้น
  5. ห้ามเปลี่ยน DEFAULT_HUB_URL และห้ามถอด hostname ที่ SDK รุ่นใดเคยใช้เป็นค่าเริ่มต้น — โดเมนใหม่เพิ่มเป็น alias (บทเรียนจาก mail.suphark.com ที่ถอดแล้วแอปรุ่นเก่าส่งไม่ออก)
  6. ถอดของเก่าได้ก็ต่อเมื่อ lockfile ของทั้ง 4 แอปเป็นรุ่นใหม่และ deploy แล้ว

Pi Notify มี contract test (apps/notify/src/test/contract/) เก็บ body จริงของ SDK 0.3.0 ไว้ — แดง = เปลี่ยน API แบบไม่ additive ให้แก้ฝั่ง Pi Notify ไม่ใช่แก้ fixture

ขั้นตอนออกรุ่น

  1. แก้ version ใน packages/notify/package.json + หัวข้อ Changelog ด้านล่าง → pnpm --filter @suphark/notify verify → npm pack --dry-run ดูไฟล์ใน tarball
  2. เจ้าของ: npm publish จาก packages/notify (prepack build ให้, ต้องใช้ 2FA)
  3. 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)
  4. ตรวจ: 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, retryAfterSec 5)
    • 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); ผู้รับเดี่ยวไม่มีอีเมล = MailError invalid
    • notifySafe() / sendMailSafe() ไม่โยน; ไม่มี key/provider = disabled
    • @suphark/notify/next: notifyLater() / sendMailLater() ส่งหลังตอบด้วย after() (optional peer next >= 15; core ไม่ import next)
    • getChannelStatus(); createLink() คืน code
    • createPushHandlers({ getUser }) + pushServiceWorker() สำหรับ web push
    • renderNotifyEmail() (+ resolveFormat / toPlainText / looksLikeHtml) — เทมเพลตอีเมลเดียวกับที่ Pi Notify ใช้ แอปไม่ต้องเขียน HTML "หัวข้อ + ย่อหน้า + ปุ่ม" เอง
    • getRecipient() / createLink() รับ user object; toEmails(), hasNotifyKey(), type Recipient
  • 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 ของ provider hub ผ่าน 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
  • 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_ เดิมยังใช้ได้)

  • 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 มี 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

  • 0.1.0 — รุ่นแรก: hub (PI_MAIL_KEY), resend, smtp + presets, zeptomail, console, custom; failover/โควต้า/cooldown; listProviders() + prefer