@suphark/notify 0.10.0 → 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,
@@ -405,150 +674,66 @@ var Health = class {
405
674
  }
406
675
  async status(p, quota) {
407
676
  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
- }
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
  }
@@ -921,7 +1106,17 @@ function createMailer(options = {}) {
921
1106
  }
922
1107
  async function send(message) {
923
1108
  if (!message?.template) return sendVia(message, "all");
924
- const fallback = message.fallback ?? builtinFallback(message.template, message.data);
1109
+ const fromName = (() => {
1110
+ try {
1111
+ return (message.from !== void 0 ? parseAddress(message.from) : defaultFrom)?.name;
1112
+ } catch {
1113
+ return void 0;
1114
+ }
1115
+ })();
1116
+ const fallback = message.fallback ?? builtinFallback(message.template, {
1117
+ ...fromName ? { appName: fromName } : {},
1118
+ ...message.data
1119
+ });
925
1120
  const hasHub = entries.some((e) => e.provider.type === "hub");
926
1121
  const hasLocal = entries.some((e) => e.provider.type !== "hub" && !e.disabled);
927
1122
  const asFallback = (fb) => ({
@@ -946,7 +1141,8 @@ function createMailer(options = {}) {
946
1141
  } catch (err) {
947
1142
  const attempts = err instanceof AllProvidersFailedError ? err.attempts : [];
948
1143
  const invalid = err instanceof MailError && err.kind === "invalid" && attempts.length === 0 || attempts.some((a) => a.kind === "invalid");
949
- 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;
950
1146
  emit({ type: "fallback", template: message.template });
951
1147
  const r = await sendVia(asFallback(fallback), "local");
952
1148
  return { ...r, failedOver: true, fallback: true, attempts: [...attempts, ...r.attempts] };
@@ -1111,174 +1307,6 @@ function resetMailer() {
1111
1307
  pending = void 0;
1112
1308
  }
1113
1309
 
1114
- // src/recipients.ts
1115
- var emailOf = (r) => {
1116
- const raw = typeof r === "string" ? r : r?.email;
1117
- const email = raw?.trim().toLowerCase();
1118
- return email ? email : null;
1119
- };
1120
- function toEmails(to) {
1121
- if (Array.isArray(to)) {
1122
- const emails = [...new Set(to.map(emailOf).filter((e) => e !== null))];
1123
- 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");
1124
- return emails;
1125
- }
1126
- const email = emailOf(to);
1127
- if (!email) throw invalidRecipient("\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E35\u0E40\u0E21\u0E25");
1128
- return email;
1129
- }
1130
- var invalidRecipient = (why) => new MailError(`notify(): ${why}`, { kind: "invalid", provider: "hub" });
1131
- function toEmail(to) {
1132
- const email = toEmails(to);
1133
- 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");
1134
- return email;
1135
- }
1136
-
1137
- // src/notify.ts
1138
- var NOTIFY_TIMEOUT_MS = { notify: 65e3, recipient: 1e4 };
1139
- function resolve(opts) {
1140
- const env = opts.env ?? process.env;
1141
- const apiKey = opts.apiKey ?? env.PI_NOTIFY_KEY;
1142
- if (!apiKey) throw new MailError("notify(): PI_NOTIFY_KEY is required", { kind: "auth" });
1143
- const base = (opts.url ?? env.PI_NOTIFY_URL ?? DEFAULT_HUB_URL).replace(/\/$/, "");
1144
- return { apiKey, base, doFetch: opts.fetch ?? globalThis.fetch };
1145
- }
1146
- function hasNotifyKey(opts = {}) {
1147
- return Boolean(opts.apiKey ?? (opts.env ?? process.env).PI_NOTIFY_KEY);
1148
- }
1149
- async function hubRequest(opts, path, init) {
1150
- const { apiKey, base, doFetch } = resolve(opts);
1151
- const ms = opts.timeoutMs ?? init.timeoutMs;
1152
- const ac = new AbortController();
1153
- const timer = setTimeout(() => ac.abort(), ms);
1154
- const timedOut = () => new MailError(`Pi Notify timeout after ${ms}ms`, { kind: "network", provider: "hub" });
1155
- let res;
1156
- let data;
1157
- try {
1158
- res = await doFetch(`${base}${path}`, {
1159
- method: init.method ?? "GET",
1160
- headers: {
1161
- Authorization: `Bearer ${apiKey}`,
1162
- ...init.headers,
1163
- ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
1164
- },
1165
- body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
1166
- signal: ac.signal
1167
- });
1168
- data = await res.json().catch(() => ({}));
1169
- } catch (err) {
1170
- if (ac.signal.aborted) throw timedOut();
1171
- throw err;
1172
- } finally {
1173
- clearTimeout(timer);
1174
- }
1175
- if (ac.signal.aborted) throw timedOut();
1176
- if (!res.ok) {
1177
- const err = data?.error;
1178
- const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
1179
- throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
1180
- kind,
1181
- provider: "hub",
1182
- status: res.status,
1183
- cause: data,
1184
- retryAfterSec: retryAfterOf(res, data)
1185
- });
1186
- }
1187
- return data;
1188
- }
1189
- async function postNotify(body, idempotencyKey, opts) {
1190
- const data = await hubRequest(opts, "/api/v1/notify", {
1191
- method: "POST",
1192
- body,
1193
- headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : void 0,
1194
- timeoutMs: NOTIFY_TIMEOUT_MS.notify
1195
- });
1196
- return { results: data.results, ...data.summary };
1197
- }
1198
- async function notify(input, opts = {}) {
1199
- const { idempotencyKey, ...body } = input;
1200
- return postNotify({ ...body, to: toEmails(input.to) }, idempotencyKey, opts);
1201
- }
1202
- async function notifyTopic(input, opts = {}) {
1203
- const { idempotencyKey, ...body } = input;
1204
- return postNotify(body, idempotencyKey, opts);
1205
- }
1206
- async function getRequestStatus(idempotencyKey, opts = {}) {
1207
- try {
1208
- return await hubRequest(
1209
- opts,
1210
- `/api/v1/requests/${encodeURIComponent(idempotencyKey)}`,
1211
- { timeoutMs: NOTIFY_TIMEOUT_MS.recipient }
1212
- );
1213
- } catch (err) {
1214
- if (err instanceof MailError && err.status === 404) return null;
1215
- throw err;
1216
- }
1217
- }
1218
- async function getRecipient(email, opts = {}) {
1219
- return hubRequest(
1220
- opts,
1221
- `/api/v1/recipients/${encodeURIComponent(toEmail(email))}`,
1222
- {
1223
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1224
- }
1225
- );
1226
- }
1227
- async function createLink(email, channel = "telegram", opts = {}) {
1228
- const { piUserId, ...rest } = opts;
1229
- const link = await hubRequest(
1230
- rest,
1231
- `/api/v1/recipients/${encodeURIComponent(toEmail(email))}/link`,
1232
- {
1233
- method: "POST",
1234
- body: { channel, ...piUserId ? { piUserId } : {} },
1235
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1236
- }
1237
- );
1238
- const code = link.code ?? safeStartParam(link.url);
1239
- return { ...link, code };
1240
- }
1241
- function safeStartParam(url) {
1242
- try {
1243
- return new URL(url).searchParams.get("start") ?? "";
1244
- } catch {
1245
- return "";
1246
- }
1247
- }
1248
- async function getChannelStatus(email, channel = "telegram", opts = {}) {
1249
- const recipient2 = await getRecipient(email, opts);
1250
- const found = recipient2.channels.find((c) => c.type === channel && c.enabled);
1251
- return {
1252
- linked: Boolean(found),
1253
- label: found?.label ?? null,
1254
- linkedAt: found?.linkedAt ?? null,
1255
- manageUrl: recipient2.linkUrl
1256
- };
1257
- }
1258
- async function getPushConfig(opts = {}) {
1259
- return hubRequest(opts, "/api/v1/push/config", { timeoutMs: NOTIFY_TIMEOUT_MS.recipient });
1260
- }
1261
- async function savePushSubscription(email, subscription, opts = {}) {
1262
- const { label, piUserId, ...rest } = opts;
1263
- return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1264
- method: "POST",
1265
- body: { subscription, ...label ? { label } : {}, ...piUserId ? { piUserId } : {} },
1266
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1267
- });
1268
- }
1269
- async function deletePushSubscription(email, endpoint, opts = {}) {
1270
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1271
- method: "DELETE",
1272
- body: { endpoint },
1273
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1274
- });
1275
- }
1276
- async function listPushDevices(email, opts = {}) {
1277
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1278
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1279
- });
1280
- }
1281
-
1282
1310
  // src/safe.ts
1283
1311
  var warnedDisabled = false;
1284
1312
  function warnDisabled(what, env) {
@@ -1339,40 +1367,42 @@ export {
1339
1367
  requireFrom,
1340
1368
  parseAddressList,
1341
1369
  formatAddress,
1342
- createSmtpProvider,
1343
- SMTP_PRESETS,
1344
- createPresetSmtpProvider,
1345
- providersFromEnv,
1346
1370
  MailError,
1347
1371
  AllProvidersFailedError,
1348
1372
  classifyError,
1349
- DEFAULT_COOLDOWN,
1350
- createConsoleProvider,
1351
1373
  DEFAULT_HUB_URL,
1352
1374
  createHubProvider,
1353
- createResendProvider,
1354
- createZeptoMailProvider,
1355
- createMemoryStore,
1356
- createMailer,
1357
- configureMailer,
1358
- getMailer,
1359
- sendMail,
1360
- listProviders,
1361
- resetMailer,
1362
1375
  toEmails,
1363
1376
  NOTIFY_TIMEOUT_MS,
1364
1377
  hasNotifyKey,
1365
1378
  hubRequest,
1379
+ absoluteLink,
1366
1380
  notify,
1367
1381
  notifyTopic,
1368
1382
  getRequestStatus,
1369
1383
  getRecipient,
1370
1384
  createLink,
1385
+ pauseChannel,
1371
1386
  getChannelStatus,
1372
1387
  getPushConfig,
1373
1388
  savePushSubscription,
1374
1389
  deletePushSubscription,
1375
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,
1376
1406
  resetSafeWarnings,
1377
1407
  notifySafe,
1378
1408
  notifyTopicSafe,