@suphark/notify 0.10.1 → 0.11.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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  builtinFallback
3
- } from "./chunk-NNMV7FZ3.js";
3
+ } from "./chunk-WXINJXF4.js";
4
4
 
5
5
  // src/address.ts
6
6
  var NAMED_RE = /^\s*(?:"?([^"<]*?)"?\s*)?<([^<>\s]+@[^<>\s]+)>\s*$/;
@@ -36,6 +36,346 @@ function formatAddress(a) {
36
36
  return `${name} <${a.email}>`;
37
37
  }
38
38
 
39
+ // src/errors.ts
40
+ var MailError = class extends Error {
41
+ kind;
42
+ provider;
43
+ status;
44
+ /** เวลาที่ปลายทางขอให้รอก่อนลองใหม่ (วินาที จาก Retry-After) — mailer ใช้เป็นเวลาพักแทน backoff */
45
+ retryAfterSec;
46
+ constructor(message, opts = {}) {
47
+ super(message, opts.cause === void 0 ? void 0 : { cause: opts.cause });
48
+ this.name = "MailError";
49
+ this.kind = opts.kind ?? "unknown";
50
+ this.provider = opts.provider;
51
+ this.status = opts.status;
52
+ this.retryAfterSec = opts.retryAfterSec;
53
+ }
54
+ };
55
+ function retryAfterOf(res, body) {
56
+ if (res.status !== 429 && res.status !== 503) return void 0;
57
+ const fromHeader = Number(res.headers?.get?.("Retry-After")?.trim() ?? "");
58
+ if (Number.isFinite(fromHeader) && fromHeader > 0) return Math.ceil(fromHeader);
59
+ const fromBody = body?.error?.retryAfterSec;
60
+ return typeof fromBody === "number" && fromBody > 0 ? Math.ceil(fromBody) : void 0;
61
+ }
62
+ var AllProvidersFailedError = class extends MailError {
63
+ attempts;
64
+ constructor(attempts) {
65
+ const tried = attempts.filter((a) => !a.skipped);
66
+ const summary = attempts.length === 0 ? "no providers configured" : attempts.map(
67
+ (a) => a.skipped ? `${a.provider}: skipped (${a.skipped})` : `${a.provider}: ${a.error}`
68
+ ).join(" | ");
69
+ super(`All mail providers failed (${tried.length}/${attempts.length} tried): ${summary}`);
70
+ this.name = "AllProvidersFailedError";
71
+ this.attempts = attempts;
72
+ }
73
+ };
74
+ var QUOTA_RE = /quota|limit exceeded|exceeded.*(limit|quota)|daily.*limit|monthly.*limit|sending limit|too many messages/i;
75
+ var RATE_RE = /rate.?limit|too many requests|throttl|slow down/i;
76
+ var AUTH_RE = /authenticat|credential|password|invalid.*(key|token)|api key|unauthori[sz]ed|forbidden|not verified|verify.*domain|domain.*verif/i;
77
+ var NET_CODE_RE = /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|ESOCKET|EDNS|ECONNECTION)/i;
78
+ var NET_RE = /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket|timed? ?out|network|fetch failed/i;
79
+ var INVALID_RE = /invalid.*(email|address|recipient)|no recipients|recipient.*(rejected|invalid)|malformed|missing.*(to|subject|from)/i;
80
+ function classifyError(err) {
81
+ if (err instanceof MailError)
82
+ return {
83
+ kind: err.kind,
84
+ message: err.message,
85
+ status: err.status,
86
+ retryAfterSec: err.retryAfterSec
87
+ };
88
+ const e = typeof err === "object" && err !== null ? err : {};
89
+ const message = String(e.message ?? e.response ?? err ?? "unknown error");
90
+ const code = typeof e.code === "string" ? e.code : void 0;
91
+ const status = [e.responseCode, e.status, e.statusCode].find(
92
+ (v) => typeof v === "number"
93
+ );
94
+ const text = `${code ?? ""} ${message} ${typeof e.response === "string" ? e.response : ""}`;
95
+ if (code && NET_CODE_RE.test(code)) return { kind: "network", message, status };
96
+ if (status === 552 || QUOTA_RE.test(text)) return { kind: "quota", message, status };
97
+ if (status === 429 || RATE_RE.test(text)) return { kind: "rate_limit", message, status };
98
+ if (status === 401 || status === 403 || status === 534 || status === 535 || AUTH_RE.test(text))
99
+ return { kind: "auth", message, status };
100
+ if (INVALID_RE.test(text)) return { kind: "invalid", message, status };
101
+ if (status !== void 0) {
102
+ if ([400, 422, 550, 553, 554].includes(status)) return { kind: "invalid", message, status };
103
+ if (status >= 420 && status < 460) return { kind: "server", message, status };
104
+ if (status >= 500) return { kind: "server", message, status };
105
+ }
106
+ if (NET_RE.test(text)) return { kind: "network", message, status };
107
+ return { kind: "unknown", message, status };
108
+ }
109
+
110
+ // src/providers/hub.ts
111
+ var DEFAULT_HUB_URL = "https://notify.suphark.com";
112
+ function createHubProvider(opts) {
113
+ const name = opts.name ?? "hub";
114
+ const url = `${(opts.url ?? DEFAULT_HUB_URL).replace(/\/$/, "")}/api/v1/send`;
115
+ const doFetch = opts.fetch ?? globalThis.fetch;
116
+ return {
117
+ name,
118
+ type: "hub",
119
+ from: opts.from,
120
+ optionalFrom: true,
121
+ async send(msg) {
122
+ const body = {
123
+ from: msg.from,
124
+ to: msg.to,
125
+ cc: msg.cc.length ? msg.cc : void 0,
126
+ bcc: msg.bcc.length ? msg.bcc : void 0,
127
+ replyTo: msg.replyTo.length ? msg.replyTo : void 0,
128
+ subject: msg.subject || (msg.template ? void 0 : msg.subject),
129
+ html: msg.html,
130
+ text: msg.text,
131
+ attachments: msg.attachments.length ? msg.attachments : void 0,
132
+ headers: Object.keys(msg.headers).length ? msg.headers : void 0,
133
+ tags: Object.keys(msg.tags).length ? msg.tags : void 0,
134
+ prefer: msg.prefer,
135
+ deliverWithin: msg.deliverWithin,
136
+ template: msg.template,
137
+ data: msg.template ? msg.data ?? {} : void 0
138
+ };
139
+ const res = await doFetch(url, {
140
+ method: "POST",
141
+ headers: {
142
+ Authorization: `Bearer ${opts.apiKey}`,
143
+ "Content-Type": "application/json",
144
+ ...msg.idempotencyKey ? { "Idempotency-Key": msg.idempotencyKey } : {}
145
+ },
146
+ body: JSON.stringify(body),
147
+ // mailer หมดเวลา → abort คำขอนี้ทิ้ง (Pi Notify อาจส่งไปแล้ว — แอปที่มีตัวสำรองในเครื่องอาจได้เมลซ้ำ ยอมรับได้)
148
+ signal: msg.signal
149
+ });
150
+ const data = await res.json().catch(() => ({}));
151
+ if (!res.ok) {
152
+ const err = data.error;
153
+ const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
154
+ throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
155
+ kind,
156
+ provider: name,
157
+ status: res.status,
158
+ cause: data,
159
+ retryAfterSec: retryAfterOf(res, data)
160
+ });
161
+ }
162
+ const ok = data;
163
+ if (ok.status === "queued") return { id: ok.deliveryId, queued: true };
164
+ return { id: ok.id };
165
+ },
166
+ async listProviders() {
167
+ const res = await doFetch(url.replace(/\/send$/, "/providers"), {
168
+ headers: { Authorization: `Bearer ${opts.apiKey}` }
169
+ });
170
+ if (!res.ok)
171
+ throw new MailError(`Pi Notify ${res.status}: ${res.statusText}`, {
172
+ kind: res.status === 401 ? "auth" : "server",
173
+ provider: name,
174
+ status: res.status
175
+ });
176
+ const data = await res.json();
177
+ return data.providers.map((p) => ({ ...p, via: name }));
178
+ },
179
+ async verify() {
180
+ const res = await doFetch(url.replace(/\/send$/, "/status"), {
181
+ headers: { Authorization: `Bearer ${opts.apiKey}` }
182
+ });
183
+ if (!res.ok) {
184
+ throw new MailError(`Pi Notify ${res.status}: ${res.statusText}`, {
185
+ kind: res.status === 401 ? "auth" : "server",
186
+ provider: name,
187
+ status: res.status
188
+ });
189
+ }
190
+ }
191
+ };
192
+ }
193
+
194
+ // src/recipients.ts
195
+ var emailOf = (r) => {
196
+ const raw = typeof r === "string" ? r : r?.email;
197
+ const email = raw?.trim().toLowerCase();
198
+ return email ? email : null;
199
+ };
200
+ function toEmails(to) {
201
+ if (Array.isArray(to)) {
202
+ const emails = [...new Set(to.map(emailOf).filter((e) => e !== null))];
203
+ if (emails.length === 0) throw invalidRecipient("\u0E44\u0E21\u0E48\u0E21\u0E35\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E17\u0E35\u0E48\u0E21\u0E35\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E40\u0E25\u0E22");
204
+ return emails;
205
+ }
206
+ const email = emailOf(to);
207
+ if (!email) throw invalidRecipient("\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E35\u0E40\u0E21\u0E25");
208
+ return email;
209
+ }
210
+ var invalidRecipient = (why) => new MailError(`notify(): ${why}`, { kind: "invalid", provider: "hub" });
211
+ function toEmail(to) {
212
+ const email = toEmails(to);
213
+ if (Array.isArray(email)) throw invalidRecipient("\u0E15\u0E49\u0E2D\u0E07\u0E40\u0E1B\u0E47\u0E19\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E04\u0E19\u0E40\u0E14\u0E35\u0E22\u0E27");
214
+ return email;
215
+ }
216
+
217
+ // src/notify.ts
218
+ var NOTIFY_TIMEOUT_MS = { notify: 65e3, recipient: 1e4 };
219
+ function resolve(opts) {
220
+ const env = opts.env ?? process.env;
221
+ const apiKey = opts.apiKey ?? env.PI_NOTIFY_KEY;
222
+ if (!apiKey) throw new MailError("notify(): PI_NOTIFY_KEY is required", { kind: "auth" });
223
+ const base = (opts.url ?? env.PI_NOTIFY_URL ?? DEFAULT_HUB_URL).replace(/\/$/, "");
224
+ return { apiKey, base, doFetch: opts.fetch ?? globalThis.fetch };
225
+ }
226
+ function hasNotifyKey(opts = {}) {
227
+ return Boolean(opts.apiKey ?? (opts.env ?? process.env).PI_NOTIFY_KEY);
228
+ }
229
+ async function hubRequest(opts, path, init) {
230
+ const { apiKey, base, doFetch } = resolve(opts);
231
+ const ms = opts.timeoutMs ?? init.timeoutMs;
232
+ const ac = new AbortController();
233
+ const timer = setTimeout(() => ac.abort(), ms);
234
+ const timedOut = () => new MailError(`Pi Notify timeout after ${ms}ms`, { kind: "network", provider: "hub" });
235
+ let res;
236
+ let data;
237
+ try {
238
+ res = await doFetch(`${base}${path}`, {
239
+ method: init.method ?? "GET",
240
+ headers: {
241
+ Authorization: `Bearer ${apiKey}`,
242
+ ...init.headers,
243
+ ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
244
+ },
245
+ body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
246
+ signal: ac.signal
247
+ });
248
+ data = await res.json().catch(() => ({}));
249
+ } catch (err) {
250
+ if (ac.signal.aborted) throw timedOut();
251
+ throw err;
252
+ } finally {
253
+ clearTimeout(timer);
254
+ }
255
+ if (ac.signal.aborted) throw timedOut();
256
+ if (!res.ok) {
257
+ const err = data?.error;
258
+ const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
259
+ throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
260
+ kind,
261
+ provider: "hub",
262
+ status: res.status,
263
+ cause: data,
264
+ retryAfterSec: retryAfterOf(res, data)
265
+ });
266
+ }
267
+ return data;
268
+ }
269
+ function absoluteLink(link, opts = {}) {
270
+ if (!link.startsWith("/") || link.startsWith("//")) return link;
271
+ const env = opts.env ?? process.env;
272
+ const base = opts.appUrl ?? env.APP_URL ?? env.NEXT_PUBLIC_APP_URL;
273
+ if (!base)
274
+ throw new MailError(
275
+ `link "${link}" \u0E40\u0E1B\u0E47\u0E19\u0E25\u0E34\u0E07\u0E01\u0E4C relative \u2014 \u0E15\u0E31\u0E49\u0E07 APP_URL (\u0E2B\u0E23\u0E37\u0E2D NEXT_PUBLIC_APP_URL / opts.appUrl) \u0E2B\u0E23\u0E37\u0E2D\u0E2A\u0E48\u0E07 URL \u0E40\u0E15\u0E47\u0E21`,
276
+ { kind: "invalid", provider: "hub" }
277
+ );
278
+ return new URL(link, base.endsWith("/") ? base : `${base}/`).toString();
279
+ }
280
+ async function postNotify(body, idempotencyKey, opts) {
281
+ if (typeof body.link === "string") body = { ...body, link: absoluteLink(body.link, opts) };
282
+ const data = await hubRequest(opts, "/api/v1/notify", {
283
+ method: "POST",
284
+ body,
285
+ headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : void 0,
286
+ timeoutMs: NOTIFY_TIMEOUT_MS.notify
287
+ });
288
+ return { results: data.results, ...data.summary };
289
+ }
290
+ async function notify(input, opts = {}) {
291
+ const { idempotencyKey, ...body } = input;
292
+ return postNotify({ ...body, to: toEmails(input.to) }, idempotencyKey, opts);
293
+ }
294
+ async function notifyTopic(input, opts = {}) {
295
+ const { idempotencyKey, ...body } = input;
296
+ return postNotify(body, idempotencyKey, opts);
297
+ }
298
+ async function getRequestStatus(idempotencyKey, opts = {}) {
299
+ try {
300
+ return await hubRequest(
301
+ opts,
302
+ `/api/v1/requests/${encodeURIComponent(idempotencyKey)}`,
303
+ { timeoutMs: NOTIFY_TIMEOUT_MS.recipient }
304
+ );
305
+ } catch (err) {
306
+ if (err instanceof MailError && err.status === 404) return null;
307
+ throw err;
308
+ }
309
+ }
310
+ async function getRecipient(email, opts = {}) {
311
+ return hubRequest(
312
+ opts,
313
+ `/api/v1/recipients/${encodeURIComponent(toEmail(email))}`,
314
+ {
315
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
316
+ }
317
+ );
318
+ }
319
+ async function createLink(email, channel = "telegram", opts = {}) {
320
+ const { piUserId, ...rest } = opts;
321
+ const link = await hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(toEmail(email))}/link`, {
322
+ method: "POST",
323
+ body: { channel, ...piUserId ? { piUserId } : {} },
324
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
325
+ });
326
+ const code = link.code ?? safeStartParam(link.url);
327
+ return { ...link, code };
328
+ }
329
+ function safeStartParam(url) {
330
+ try {
331
+ return new URL(url).searchParams.get("start") ?? "";
332
+ } catch {
333
+ return "";
334
+ }
335
+ }
336
+ async function pauseChannel(email, channel, opts = {}) {
337
+ return hubRequest(
338
+ opts,
339
+ `/api/v1/recipients/${encodeURIComponent(toEmail(email))}/pause`,
340
+ { method: "POST", body: { channel }, timeoutMs: NOTIFY_TIMEOUT_MS.recipient }
341
+ );
342
+ }
343
+ async function getChannelStatus(email, channel = "telegram", opts = {}) {
344
+ const recipient2 = await getRecipient(email, opts);
345
+ const found = recipient2.channels.find((c) => c.type === channel && c.enabled);
346
+ const paused = !found && recipient2.channels.some((c) => c.type === channel && !c.enabled);
347
+ return {
348
+ linked: Boolean(found),
349
+ ...paused ? { paused: true } : {},
350
+ label: found?.label ?? null,
351
+ linkedAt: found?.linkedAt ?? null,
352
+ manageUrl: recipient2.linkUrl
353
+ };
354
+ }
355
+ async function getPushConfig(opts = {}) {
356
+ return hubRequest(opts, "/api/v1/push/config", { timeoutMs: NOTIFY_TIMEOUT_MS.recipient });
357
+ }
358
+ async function savePushSubscription(email, subscription, opts = {}) {
359
+ const { label, piUserId, ...rest } = opts;
360
+ return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
361
+ method: "POST",
362
+ body: { subscription, ...label ? { label } : {}, ...piUserId ? { piUserId } : {} },
363
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
364
+ });
365
+ }
366
+ async function deletePushSubscription(email, endpoint, opts = {}) {
367
+ return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
368
+ method: "DELETE",
369
+ body: { endpoint },
370
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
371
+ });
372
+ }
373
+ async function listPushDevices(email, opts = {}) {
374
+ return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
375
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
376
+ });
377
+ }
378
+
39
379
  // src/providers/smtp.ts
40
380
  import nodemailer from "nodemailer";
41
381
  function createSmtpProvider(opts) {
@@ -218,77 +558,6 @@ function providersFromEnv(env = process.env) {
218
558
  return listed.some((p) => p.type === "hub") ? listed : [...hub, ...listed];
219
559
  }
220
560
 
221
- // src/errors.ts
222
- var MailError = class extends Error {
223
- kind;
224
- provider;
225
- status;
226
- /** เวลาที่ปลายทางขอให้รอก่อนลองใหม่ (วินาที จาก Retry-After) — mailer ใช้เป็นเวลาพักแทน backoff */
227
- retryAfterSec;
228
- constructor(message, opts = {}) {
229
- super(message, opts.cause === void 0 ? void 0 : { cause: opts.cause });
230
- this.name = "MailError";
231
- this.kind = opts.kind ?? "unknown";
232
- this.provider = opts.provider;
233
- this.status = opts.status;
234
- this.retryAfterSec = opts.retryAfterSec;
235
- }
236
- };
237
- function retryAfterOf(res, body) {
238
- if (res.status !== 429 && res.status !== 503) return void 0;
239
- const fromHeader = Number(res.headers?.get?.("Retry-After")?.trim() ?? "");
240
- if (Number.isFinite(fromHeader) && fromHeader > 0) return Math.ceil(fromHeader);
241
- const fromBody = body?.error?.retryAfterSec;
242
- return typeof fromBody === "number" && fromBody > 0 ? Math.ceil(fromBody) : void 0;
243
- }
244
- var AllProvidersFailedError = class extends MailError {
245
- attempts;
246
- constructor(attempts) {
247
- const tried = attempts.filter((a) => !a.skipped);
248
- const summary = attempts.length === 0 ? "no providers configured" : attempts.map(
249
- (a) => a.skipped ? `${a.provider}: skipped (${a.skipped})` : `${a.provider}: ${a.error}`
250
- ).join(" | ");
251
- super(`All mail providers failed (${tried.length}/${attempts.length} tried): ${summary}`);
252
- this.name = "AllProvidersFailedError";
253
- this.attempts = attempts;
254
- }
255
- };
256
- var QUOTA_RE = /quota|limit exceeded|exceeded.*(limit|quota)|daily.*limit|monthly.*limit|sending limit|too many messages/i;
257
- var RATE_RE = /rate.?limit|too many requests|throttl|slow down/i;
258
- var AUTH_RE = /authenticat|credential|password|invalid.*(key|token)|api key|unauthori[sz]ed|forbidden|not verified|verify.*domain|domain.*verif/i;
259
- var NET_CODE_RE = /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|ESOCKET|EDNS|ECONNECTION)/i;
260
- var NET_RE = /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket|timed? ?out|network|fetch failed/i;
261
- var INVALID_RE = /invalid.*(email|address|recipient)|no recipients|recipient.*(rejected|invalid)|malformed|missing.*(to|subject|from)/i;
262
- function classifyError(err) {
263
- if (err instanceof MailError)
264
- return {
265
- kind: err.kind,
266
- message: err.message,
267
- status: err.status,
268
- retryAfterSec: err.retryAfterSec
269
- };
270
- const e = typeof err === "object" && err !== null ? err : {};
271
- const message = String(e.message ?? e.response ?? err ?? "unknown error");
272
- const code = typeof e.code === "string" ? e.code : void 0;
273
- const status = [e.responseCode, e.status, e.statusCode].find(
274
- (v) => typeof v === "number"
275
- );
276
- const text = `${code ?? ""} ${message} ${typeof e.response === "string" ? e.response : ""}`;
277
- if (code && NET_CODE_RE.test(code)) return { kind: "network", message, status };
278
- if (status === 552 || QUOTA_RE.test(text)) return { kind: "quota", message, status };
279
- if (status === 429 || RATE_RE.test(text)) return { kind: "rate_limit", message, status };
280
- if (status === 401 || status === 403 || status === 534 || status === 535 || AUTH_RE.test(text))
281
- return { kind: "auth", message, status };
282
- if (INVALID_RE.test(text)) return { kind: "invalid", message, status };
283
- if (status !== void 0) {
284
- if ([400, 422, 550, 553, 554].includes(status)) return { kind: "invalid", message, status };
285
- if (status >= 420 && status < 460) return { kind: "server", message, status };
286
- if (status >= 500) return { kind: "server", message, status };
287
- }
288
- if (NET_RE.test(text)) return { kind: "network", message, status };
289
- return { kind: "unknown", message, status };
290
- }
291
-
292
561
  // src/health.ts
293
562
  var DEFAULT_COOLDOWN = {
294
563
  network: 60,
@@ -402,153 +671,69 @@ var Health = class {
402
671
  */
403
672
  async allowProbe(name) {
404
673
  return await this.store.incr(`probe:${name}`, PROBE_INTERVAL_SEC) === 1;
405
- }
406
- async status(p, quota) {
407
- const cd = await this.cooldownOf(p.name);
408
- const usage = await this.usage(p, quota);
409
- const failures = Number(await this.store.get(`fail:${p.name}`) ?? 0);
410
- return {
411
- name: p.name,
412
- type: p.type,
413
- available: !cd && usage.every((u) => u.used < u.limit),
414
- cooldownUntil: cd?.until,
415
- cooldownKind: cd?.kind,
416
- failures,
417
- quota: usage
418
- };
419
- }
420
- /** cooldown ที่ยังไม่หมด (null = ใช้ได้) */
421
- async cooldownOf(name) {
422
- const raw = await this.store.get(`cd:${name}`);
423
- if (!raw) return null;
424
- try {
425
- const cd = JSON.parse(raw);
426
- return cd.until > Date.now() ? cd : null;
427
- } catch {
428
- return null;
429
- }
430
- }
431
- async usage(p, quota) {
432
- const out = [];
433
- for (const w of WINDOWS) {
434
- const limit = quota?.[w.key];
435
- if (limit === void 0) continue;
436
- const used = Number(
437
- await this.store.get(`q:${p.name}:${w.window}:${windowId(w.window)}`) ?? 0
438
- );
439
- out.push({ window: w.window, used, limit });
440
- }
441
- return out;
442
- }
443
- };
444
-
445
- // src/providers/console.ts
446
- function createConsoleProvider(opts = {}) {
447
- const name = opts.name ?? "console";
448
- const log = opts.log ?? ((l) => console.log(l));
449
- let seq = 0;
450
- return {
451
- name,
452
- type: "console",
453
- from: opts.from,
454
- async send(msg) {
455
- const id = `console-${Date.now()}-${++seq}`;
456
- const lines = [
457
- `[mailer:${name}] ${id}`,
458
- ` from: ${msg.from ? formatAddress(msg.from) : "(hub default)"}`,
459
- ` to: ${msg.to.map(formatAddress).join(", ")}`,
460
- ...msg.cc.length ? [` cc: ${msg.cc.map(formatAddress).join(", ")}`] : [],
461
- ...msg.bcc.length ? [` bcc: ${msg.bcc.map(formatAddress).join(", ")}`] : [],
462
- ` subject: ${msg.subject}`,
463
- ...msg.attachments.length ? [` files: ${msg.attachments.map((a) => a.filename).join(", ")}`] : [],
464
- ...opts.body ? [` ---`, msg.text ?? msg.html ?? "(empty)"] : []
465
- ];
466
- log(lines.join("\n"));
467
- return { id };
468
- }
469
- };
470
- }
471
-
472
- // src/providers/hub.ts
473
- var DEFAULT_HUB_URL = "https://notify.suphark.com";
474
- function createHubProvider(opts) {
475
- const name = opts.name ?? "hub";
476
- const url = `${(opts.url ?? DEFAULT_HUB_URL).replace(/\/$/, "")}/api/v1/send`;
477
- const doFetch = opts.fetch ?? globalThis.fetch;
478
- return {
479
- name,
480
- type: "hub",
481
- from: opts.from,
482
- optionalFrom: true,
483
- async send(msg) {
484
- const body = {
485
- from: msg.from,
486
- to: msg.to,
487
- cc: msg.cc.length ? msg.cc : void 0,
488
- bcc: msg.bcc.length ? msg.bcc : void 0,
489
- replyTo: msg.replyTo.length ? msg.replyTo : void 0,
490
- subject: msg.subject || (msg.template ? void 0 : msg.subject),
491
- html: msg.html,
492
- text: msg.text,
493
- attachments: msg.attachments.length ? msg.attachments : void 0,
494
- headers: Object.keys(msg.headers).length ? msg.headers : void 0,
495
- tags: Object.keys(msg.tags).length ? msg.tags : void 0,
496
- prefer: msg.prefer,
497
- deliverWithin: msg.deliverWithin,
498
- template: msg.template,
499
- data: msg.template ? msg.data ?? {} : void 0
500
- };
501
- const res = await doFetch(url, {
502
- method: "POST",
503
- headers: {
504
- Authorization: `Bearer ${opts.apiKey}`,
505
- "Content-Type": "application/json",
506
- ...msg.idempotencyKey ? { "Idempotency-Key": msg.idempotencyKey } : {}
507
- },
508
- body: JSON.stringify(body),
509
- // mailer หมดเวลา → abort คำขอนี้ทิ้ง (Pi Notify อาจส่งไปแล้ว — แอปที่มีตัวสำรองในเครื่องอาจได้เมลซ้ำ ยอมรับได้)
510
- signal: msg.signal
511
- });
512
- const data = await res.json().catch(() => ({}));
513
- if (!res.ok) {
514
- const err = data.error;
515
- const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
516
- throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
517
- kind,
518
- provider: name,
519
- status: res.status,
520
- cause: data,
521
- retryAfterSec: retryAfterOf(res, data)
522
- });
523
- }
524
- const ok = data;
525
- if (ok.status === "queued") return { id: ok.deliveryId, queued: true };
526
- return { id: ok.id };
527
- },
528
- async listProviders() {
529
- const res = await doFetch(url.replace(/\/send$/, "/providers"), {
530
- headers: { Authorization: `Bearer ${opts.apiKey}` }
531
- });
532
- if (!res.ok)
533
- throw new MailError(`Pi Notify ${res.status}: ${res.statusText}`, {
534
- kind: res.status === 401 ? "auth" : "server",
535
- provider: name,
536
- status: res.status
537
- });
538
- const data = await res.json();
539
- return data.providers.map((p) => ({ ...p, via: name }));
540
- },
541
- async verify() {
542
- const res = await doFetch(url.replace(/\/send$/, "/status"), {
543
- headers: { Authorization: `Bearer ${opts.apiKey}` }
544
- });
545
- if (!res.ok) {
546
- throw new MailError(`Pi Notify ${res.status}: ${res.statusText}`, {
547
- kind: res.status === 401 ? "auth" : "server",
548
- provider: name,
549
- status: res.status
550
- });
551
- }
674
+ }
675
+ async status(p, quota) {
676
+ const cd = await this.cooldownOf(p.name);
677
+ const usage = await this.usage(p, quota);
678
+ const failures = Number(await this.store.get(`fail:${p.name}`) ?? 0);
679
+ return {
680
+ name: p.name,
681
+ type: p.type,
682
+ available: !cd && usage.every((u) => u.used < u.limit),
683
+ cooldownUntil: cd?.until,
684
+ cooldownKind: cd?.kind,
685
+ failures,
686
+ quota: usage
687
+ };
688
+ }
689
+ /** cooldown ที่ยังไม่หมด (null = ใช้ได้) */
690
+ async cooldownOf(name) {
691
+ const raw = await this.store.get(`cd:${name}`);
692
+ if (!raw) return null;
693
+ try {
694
+ const cd = JSON.parse(raw);
695
+ return cd.until > Date.now() ? cd : null;
696
+ } catch {
697
+ return null;
698
+ }
699
+ }
700
+ async usage(p, quota) {
701
+ const out = [];
702
+ for (const w of WINDOWS) {
703
+ const limit = quota?.[w.key];
704
+ if (limit === void 0) continue;
705
+ const used = Number(
706
+ await this.store.get(`q:${p.name}:${w.window}:${windowId(w.window)}`) ?? 0
707
+ );
708
+ out.push({ window: w.window, used, limit });
709
+ }
710
+ return out;
711
+ }
712
+ };
713
+
714
+ // src/providers/console.ts
715
+ function createConsoleProvider(opts = {}) {
716
+ const name = opts.name ?? "console";
717
+ const log = opts.log ?? ((l) => console.log(l));
718
+ let seq = 0;
719
+ return {
720
+ name,
721
+ type: "console",
722
+ from: opts.from,
723
+ async send(msg) {
724
+ const id = `console-${Date.now()}-${++seq}`;
725
+ const lines = [
726
+ `[mailer:${name}] ${id}`,
727
+ ` from: ${msg.from ? formatAddress(msg.from) : "(hub default)"}`,
728
+ ` to: ${msg.to.map(formatAddress).join(", ")}`,
729
+ ...msg.cc.length ? [` cc: ${msg.cc.map(formatAddress).join(", ")}`] : [],
730
+ ...msg.bcc.length ? [` bcc: ${msg.bcc.map(formatAddress).join(", ")}`] : [],
731
+ ` subject: ${msg.subject}`,
732
+ ...msg.attachments.length ? [` files: ${msg.attachments.map((a) => a.filename).join(", ")}`] : [],
733
+ ...opts.body ? [` ---`, msg.text ?? msg.html ?? "(empty)"] : []
734
+ ];
735
+ log(lines.join("\n"));
736
+ return { id };
552
737
  }
553
738
  };
554
739
  }
@@ -956,7 +1141,8 @@ function createMailer(options = {}) {
956
1141
  } catch (err) {
957
1142
  const attempts = err instanceof AllProvidersFailedError ? err.attempts : [];
958
1143
  const invalid = err instanceof MailError && err.kind === "invalid" && attempts.length === 0 || attempts.some((a) => a.kind === "invalid");
959
- if (!fallback || !hasLocal || invalid) throw err;
1144
+ const ambiguous = attempts.some((a) => a.error?.includes("send timed out after"));
1145
+ if (!fallback || !hasLocal || invalid || ambiguous) throw err;
960
1146
  emit({ type: "fallback", template: message.template });
961
1147
  const r = await sendVia(asFallback(fallback), "local");
962
1148
  return { ...r, failedOver: true, fallback: true, attempts: [...attempts, ...r.attempts] };
@@ -1121,174 +1307,6 @@ function resetMailer() {
1121
1307
  pending = void 0;
1122
1308
  }
1123
1309
 
1124
- // src/recipients.ts
1125
- var emailOf = (r) => {
1126
- const raw = typeof r === "string" ? r : r?.email;
1127
- const email = raw?.trim().toLowerCase();
1128
- return email ? email : null;
1129
- };
1130
- function toEmails(to) {
1131
- if (Array.isArray(to)) {
1132
- const emails = [...new Set(to.map(emailOf).filter((e) => e !== null))];
1133
- if (emails.length === 0) throw invalidRecipient("\u0E44\u0E21\u0E48\u0E21\u0E35\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E17\u0E35\u0E48\u0E21\u0E35\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E40\u0E25\u0E22");
1134
- return emails;
1135
- }
1136
- const email = emailOf(to);
1137
- if (!email) throw invalidRecipient("\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E35\u0E40\u0E21\u0E25");
1138
- return email;
1139
- }
1140
- var invalidRecipient = (why) => new MailError(`notify(): ${why}`, { kind: "invalid", provider: "hub" });
1141
- function toEmail(to) {
1142
- const email = toEmails(to);
1143
- if (Array.isArray(email)) throw invalidRecipient("\u0E15\u0E49\u0E2D\u0E07\u0E40\u0E1B\u0E47\u0E19\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E04\u0E19\u0E40\u0E14\u0E35\u0E22\u0E27");
1144
- return email;
1145
- }
1146
-
1147
- // src/notify.ts
1148
- var NOTIFY_TIMEOUT_MS = { notify: 65e3, recipient: 1e4 };
1149
- function resolve(opts) {
1150
- const env = opts.env ?? process.env;
1151
- const apiKey = opts.apiKey ?? env.PI_NOTIFY_KEY;
1152
- if (!apiKey) throw new MailError("notify(): PI_NOTIFY_KEY is required", { kind: "auth" });
1153
- const base = (opts.url ?? env.PI_NOTIFY_URL ?? DEFAULT_HUB_URL).replace(/\/$/, "");
1154
- return { apiKey, base, doFetch: opts.fetch ?? globalThis.fetch };
1155
- }
1156
- function hasNotifyKey(opts = {}) {
1157
- return Boolean(opts.apiKey ?? (opts.env ?? process.env).PI_NOTIFY_KEY);
1158
- }
1159
- async function hubRequest(opts, path, init) {
1160
- const { apiKey, base, doFetch } = resolve(opts);
1161
- const ms = opts.timeoutMs ?? init.timeoutMs;
1162
- const ac = new AbortController();
1163
- const timer = setTimeout(() => ac.abort(), ms);
1164
- const timedOut = () => new MailError(`Pi Notify timeout after ${ms}ms`, { kind: "network", provider: "hub" });
1165
- let res;
1166
- let data;
1167
- try {
1168
- res = await doFetch(`${base}${path}`, {
1169
- method: init.method ?? "GET",
1170
- headers: {
1171
- Authorization: `Bearer ${apiKey}`,
1172
- ...init.headers,
1173
- ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
1174
- },
1175
- body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
1176
- signal: ac.signal
1177
- });
1178
- data = await res.json().catch(() => ({}));
1179
- } catch (err) {
1180
- if (ac.signal.aborted) throw timedOut();
1181
- throw err;
1182
- } finally {
1183
- clearTimeout(timer);
1184
- }
1185
- if (ac.signal.aborted) throw timedOut();
1186
- if (!res.ok) {
1187
- const err = data?.error;
1188
- const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
1189
- throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
1190
- kind,
1191
- provider: "hub",
1192
- status: res.status,
1193
- cause: data,
1194
- retryAfterSec: retryAfterOf(res, data)
1195
- });
1196
- }
1197
- return data;
1198
- }
1199
- async function postNotify(body, idempotencyKey, opts) {
1200
- const data = await hubRequest(opts, "/api/v1/notify", {
1201
- method: "POST",
1202
- body,
1203
- headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : void 0,
1204
- timeoutMs: NOTIFY_TIMEOUT_MS.notify
1205
- });
1206
- return { results: data.results, ...data.summary };
1207
- }
1208
- async function notify(input, opts = {}) {
1209
- const { idempotencyKey, ...body } = input;
1210
- return postNotify({ ...body, to: toEmails(input.to) }, idempotencyKey, opts);
1211
- }
1212
- async function notifyTopic(input, opts = {}) {
1213
- const { idempotencyKey, ...body } = input;
1214
- return postNotify(body, idempotencyKey, opts);
1215
- }
1216
- async function getRequestStatus(idempotencyKey, opts = {}) {
1217
- try {
1218
- return await hubRequest(
1219
- opts,
1220
- `/api/v1/requests/${encodeURIComponent(idempotencyKey)}`,
1221
- { timeoutMs: NOTIFY_TIMEOUT_MS.recipient }
1222
- );
1223
- } catch (err) {
1224
- if (err instanceof MailError && err.status === 404) return null;
1225
- throw err;
1226
- }
1227
- }
1228
- async function getRecipient(email, opts = {}) {
1229
- return hubRequest(
1230
- opts,
1231
- `/api/v1/recipients/${encodeURIComponent(toEmail(email))}`,
1232
- {
1233
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1234
- }
1235
- );
1236
- }
1237
- async function createLink(email, channel = "telegram", opts = {}) {
1238
- const { piUserId, ...rest } = opts;
1239
- const link = await hubRequest(
1240
- rest,
1241
- `/api/v1/recipients/${encodeURIComponent(toEmail(email))}/link`,
1242
- {
1243
- method: "POST",
1244
- body: { channel, ...piUserId ? { piUserId } : {} },
1245
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1246
- }
1247
- );
1248
- const code = link.code ?? safeStartParam(link.url);
1249
- return { ...link, code };
1250
- }
1251
- function safeStartParam(url) {
1252
- try {
1253
- return new URL(url).searchParams.get("start") ?? "";
1254
- } catch {
1255
- return "";
1256
- }
1257
- }
1258
- async function getChannelStatus(email, channel = "telegram", opts = {}) {
1259
- const recipient2 = await getRecipient(email, opts);
1260
- const found = recipient2.channels.find((c) => c.type === channel && c.enabled);
1261
- return {
1262
- linked: Boolean(found),
1263
- label: found?.label ?? null,
1264
- linkedAt: found?.linkedAt ?? null,
1265
- manageUrl: recipient2.linkUrl
1266
- };
1267
- }
1268
- async function getPushConfig(opts = {}) {
1269
- return hubRequest(opts, "/api/v1/push/config", { timeoutMs: NOTIFY_TIMEOUT_MS.recipient });
1270
- }
1271
- async function savePushSubscription(email, subscription, opts = {}) {
1272
- const { label, piUserId, ...rest } = opts;
1273
- return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1274
- method: "POST",
1275
- body: { subscription, ...label ? { label } : {}, ...piUserId ? { piUserId } : {} },
1276
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1277
- });
1278
- }
1279
- async function deletePushSubscription(email, endpoint, opts = {}) {
1280
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1281
- method: "DELETE",
1282
- body: { endpoint },
1283
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1284
- });
1285
- }
1286
- async function listPushDevices(email, opts = {}) {
1287
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1288
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1289
- });
1290
- }
1291
-
1292
1310
  // src/safe.ts
1293
1311
  var warnedDisabled = false;
1294
1312
  function warnDisabled(what, env) {
@@ -1349,40 +1367,42 @@ export {
1349
1367
  requireFrom,
1350
1368
  parseAddressList,
1351
1369
  formatAddress,
1352
- createSmtpProvider,
1353
- SMTP_PRESETS,
1354
- createPresetSmtpProvider,
1355
- providersFromEnv,
1356
1370
  MailError,
1357
1371
  AllProvidersFailedError,
1358
1372
  classifyError,
1359
- DEFAULT_COOLDOWN,
1360
- createConsoleProvider,
1361
1373
  DEFAULT_HUB_URL,
1362
1374
  createHubProvider,
1363
- createResendProvider,
1364
- createZeptoMailProvider,
1365
- createMemoryStore,
1366
- createMailer,
1367
- configureMailer,
1368
- getMailer,
1369
- sendMail,
1370
- listProviders,
1371
- resetMailer,
1372
1375
  toEmails,
1373
1376
  NOTIFY_TIMEOUT_MS,
1374
1377
  hasNotifyKey,
1375
1378
  hubRequest,
1379
+ absoluteLink,
1376
1380
  notify,
1377
1381
  notifyTopic,
1378
1382
  getRequestStatus,
1379
1383
  getRecipient,
1380
1384
  createLink,
1385
+ pauseChannel,
1381
1386
  getChannelStatus,
1382
1387
  getPushConfig,
1383
1388
  savePushSubscription,
1384
1389
  deletePushSubscription,
1385
1390
  listPushDevices,
1391
+ createSmtpProvider,
1392
+ SMTP_PRESETS,
1393
+ createPresetSmtpProvider,
1394
+ providersFromEnv,
1395
+ DEFAULT_COOLDOWN,
1396
+ createConsoleProvider,
1397
+ createResendProvider,
1398
+ createZeptoMailProvider,
1399
+ createMemoryStore,
1400
+ createMailer,
1401
+ configureMailer,
1402
+ getMailer,
1403
+ sendMail,
1404
+ listProviders,
1405
+ resetMailer,
1386
1406
  resetSafeWarnings,
1387
1407
  notifySafe,
1388
1408
  notifyTopicSafe,