@suphark/notify 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,1149 +1,163 @@
1
- // src/address.ts
2
- var NAMED_RE = /^\s*(?:"?([^"<]*?)"?\s*)?<([^<>\s]+@[^<>\s]+)>\s*$/;
3
- var EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
4
- function parseAddress(input) {
5
- if (typeof input !== "string") {
6
- const email2 = input.email.trim();
7
- if (!EMAIL_RE.test(email2)) throw new TypeError(`Invalid email address: "${email2}"`);
8
- return input.name?.trim() ? { name: input.name.trim(), email: email2 } : { email: email2 };
9
- }
10
- const named = NAMED_RE.exec(input);
11
- if (named) {
12
- const name = named[1]?.trim();
13
- const email2 = named[2];
14
- if (!EMAIL_RE.test(email2)) throw new TypeError(`Invalid email address: "${input}"`);
15
- return name ? { name, email: email2 } : { email: email2 };
16
- }
17
- const email = input.trim();
18
- if (!EMAIL_RE.test(email)) throw new TypeError(`Invalid email address: "${input}"`);
19
- return { email };
20
- }
21
- function requireFrom(msg, provider) {
22
- if (!msg.from) throw new TypeError(`${provider}: message has no "from" address`);
23
- return msg.from;
24
- }
25
- function parseAddressList(input) {
26
- if (input === void 0 || input === null) return [];
27
- return (Array.isArray(input) ? input : [input]).map(parseAddress);
28
- }
29
- function formatAddress(a) {
30
- if (!a.name) return a.email;
31
- const name = /[",<>@()[\]:;\\]/.test(a.name) ? `"${a.name.replace(/"/g, '\\"')}"` : a.name;
32
- return `${name} <${a.email}>`;
33
- }
34
-
35
- // src/providers/smtp.ts
36
- import nodemailer from "nodemailer";
37
- function createSmtpProvider(opts) {
38
- const name = opts.name ?? "smtp";
39
- const secure = opts.secure ?? (opts.port === void 0 ? true : opts.port === 465);
40
- const port = opts.port ?? (secure ? 465 : 587);
41
- const timeout = opts.timeoutMs ?? 15e3;
42
- let transporter;
43
- const getTransporter = () => {
44
- const common = {
45
- host: opts.host,
46
- port,
47
- secure,
48
- auth: opts.user ? { user: opts.user, pass: opts.pass ?? "" } : void 0,
49
- connectionTimeout: timeout,
50
- greetingTimeout: timeout,
51
- socketTimeout: timeout * 2,
52
- tls: opts.rejectUnauthorized === false ? { rejectUnauthorized: false } : void 0
53
- };
54
- transporter ??= opts.pool ? nodemailer.createTransport({ ...common, pool: true }) : nodemailer.createTransport(common);
55
- return transporter;
56
- };
57
- return {
58
- name,
59
- type: opts.type ?? "smtp",
60
- from: opts.from,
61
- async send(msg) {
62
- const info = await getTransporter().sendMail({
63
- from: formatAddress(requireFrom(msg, name)),
64
- to: msg.to.map(formatAddress),
65
- cc: msg.cc.map(formatAddress),
66
- bcc: msg.bcc.map(formatAddress),
67
- replyTo: msg.replyTo.map(formatAddress),
68
- subject: msg.subject,
69
- html: msg.html,
70
- text: msg.text,
71
- headers: msg.headers,
72
- attachments: msg.attachments.map((a) => ({
73
- filename: a.filename,
74
- content: a.base64,
75
- encoding: "base64",
76
- contentType: a.contentType
77
- }))
78
- });
79
- return { id: info.messageId };
80
- },
81
- async verify() {
82
- await getTransporter().verify();
83
- }
84
- };
85
- }
86
- var SMTP_PRESETS = {
87
- zoho: { host: "smtp.zoho.com", port: 465, secure: true },
88
- /** Zoho Mail แบบองค์กร (custom domain) — บาง region ต้องใช้ smtppro.zoho.com */
89
- zohopro: { host: "smtppro.zoho.com", port: 465, secure: true },
90
- gmail: { host: "smtp.gmail.com", port: 465, secure: true },
91
- office365: { host: "smtp.office365.com", port: 587, secure: false },
92
- /** Brevo (Sendinblue) SMTP */
93
- brevo: { host: "smtp-relay.brevo.com", port: 587, secure: false },
94
- /** Mailtrap sandbox (ทดสอบ) */
95
- mailtrap: { host: "sandbox.smtp.mailtrap.io", port: 2525, secure: false }
96
- };
97
- function createPresetSmtpProvider(preset, opts) {
98
- const base = SMTP_PRESETS[preset];
99
- return createSmtpProvider({
100
- ...base,
101
- ...opts,
102
- host: opts.host ?? base.host,
103
- port: opts.port ?? base.port,
104
- secure: opts.secure ?? base.secure,
105
- name: opts.name ?? preset,
106
- type: preset
107
- });
108
- }
109
-
110
- // src/env.ts
111
- var KNOWN_TYPES = /* @__PURE__ */ new Set([
112
- "hub",
113
- "resend",
114
- "smtp",
115
- "zeptomail",
116
- "console",
117
- ...Object.keys(SMTP_PRESETS)
118
- ]);
119
- var num = (v) => v === void 0 || v === "" ? void 0 : Number(v);
120
- var bool = (v) => v === void 0 || v === "" ? void 0 : /^(1|true|yes|on)$/i.test(v);
121
- function quotaFrom(get) {
122
- const q = {};
123
- const h = num(get("QUOTA_HOUR"));
124
- const d = num(get("QUOTA_DAY"));
125
- const m = num(get("QUOTA_MONTH"));
126
- if (h !== void 0) q.perHour = h;
127
- if (d !== void 0) q.perDay = d;
128
- if (m !== void 0) q.perMonth = m;
129
- return Object.keys(q).length ? q : void 0;
130
- }
131
- function providersFromEnv(env = process.env) {
132
- const list = env.MAIL_PROVIDERS?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
133
- const hub = env.PI_NOTIFY_KEY ? [{ type: "hub", apiKey: env.PI_NOTIFY_KEY, url: env.PI_NOTIFY_URL }] : [];
134
- if (list.length === 0) {
135
- const out = [...hub];
136
- if (env.RESEND_API_KEY) out.push({ type: "resend", apiKey: env.RESEND_API_KEY });
137
- if (env.SMTP_HOST)
138
- out.push({
139
- type: "smtp",
140
- host: env.SMTP_HOST,
141
- port: num(env.SMTP_PORT),
142
- secure: bool(env.SMTP_SECURE),
143
- user: env.SMTP_USER,
144
- pass: env.SMTP_PASS
145
- });
146
- return out;
147
- }
148
- const listed = list.map((name) => {
149
- const key = `MAIL_${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_`;
150
- const get = (k) => env[key + k];
151
- const explicit = get("TYPE")?.toLowerCase();
152
- const type = explicit ?? (KNOWN_TYPES.has(name.toLowerCase()) ? name.toLowerCase() : get("HOST") ? "smtp" : void 0);
153
- if (!type)
154
- throw new TypeError(
155
- `Mail provider "${name}": set ${key}TYPE (resend | smtp | zoho | gmail | zeptomail | ...)`
156
- );
157
- const common = {
158
- name,
159
- from: get("FROM"),
160
- senderDomains: get("SENDER_DOMAINS")?.split(",").map((d) => d.trim()).filter(Boolean),
161
- quota: quotaFrom(get),
162
- disabled: bool(get("DISABLED")) ?? false
163
- };
164
- const apiKey = get("API_KEY") ?? get("KEY") ?? get("TOKEN");
165
- switch (type) {
166
- case "hub": {
167
- const hubKey = apiKey ?? env.PI_NOTIFY_KEY;
168
- if (!hubKey)
169
- throw new TypeError(
170
- `Mail provider "${name}": PI_NOTIFY_KEY or ${key}API_KEY is required`
171
- );
172
- return { ...common, type, apiKey: hubKey, url: get("URL") ?? env.PI_NOTIFY_URL };
173
- }
174
- case "resend":
175
- if (!apiKey) throw new TypeError(`Mail provider "${name}": ${key}API_KEY is required`);
176
- return { ...common, type, apiKey, baseUrl: get("BASE_URL") };
177
- case "zeptomail":
178
- if (!apiKey) throw new TypeError(`Mail provider "${name}": ${key}API_KEY is required`);
179
- return { ...common, type, apiKey, region: get("REGION"), baseUrl: get("BASE_URL") };
180
- case "console":
181
- return { ...common, type, body: bool(get("BODY")) };
182
- case "smtp": {
183
- const host = get("HOST");
184
- if (!host) throw new TypeError(`Mail provider "${name}": ${key}HOST is required`);
185
- return {
186
- ...common,
187
- type,
188
- host,
189
- port: num(get("PORT")),
190
- secure: bool(get("SECURE")),
191
- user: get("USER"),
192
- pass: get("PASS") ?? get("PASSWORD"),
193
- pool: bool(get("POOL")),
194
- timeoutMs: num(get("TIMEOUT_MS")),
195
- rejectUnauthorized: bool(get("REJECT_UNAUTHORIZED"))
196
- };
197
- }
198
- default:
199
- if (type in SMTP_PRESETS)
200
- return {
201
- ...common,
202
- type,
203
- host: get("HOST"),
204
- port: num(get("PORT")),
205
- secure: bool(get("SECURE")),
206
- user: get("USER"),
207
- pass: get("PASS") ?? get("PASSWORD"),
208
- pool: bool(get("POOL")),
209
- timeoutMs: num(get("TIMEOUT_MS"))
210
- };
211
- throw new TypeError(`Mail provider "${name}": unknown type "${type}"`);
212
- }
213
- });
214
- return listed.some((p) => p.type === "hub") ? listed : [...hub, ...listed];
215
- }
1
+ import {
2
+ AllProvidersFailedError,
3
+ DEFAULT_COOLDOWN,
4
+ DEFAULT_HUB_URL,
5
+ MailError,
6
+ NOTIFY_TIMEOUT_MS,
7
+ SMTP_PRESETS,
8
+ classifyError,
9
+ configureMailer,
10
+ createConsoleProvider,
11
+ createHubProvider,
12
+ createLink,
13
+ createMailer,
14
+ createMemoryStore,
15
+ createPresetSmtpProvider,
16
+ createResendProvider,
17
+ createSmtpProvider,
18
+ createZeptoMailProvider,
19
+ deletePushSubscription,
20
+ formatAddress,
21
+ getChannelStatus,
22
+ getMailer,
23
+ getPushConfig,
24
+ getRecipient,
25
+ hasNotifyKey,
26
+ listProviders,
27
+ listPushDevices,
28
+ notify,
29
+ notifySafe,
30
+ parseAddress,
31
+ parseAddressList,
32
+ providersFromEnv,
33
+ requireFrom,
34
+ resetMailer,
35
+ resetSafeWarnings,
36
+ savePushSubscription,
37
+ sendMail,
38
+ sendMailSafe,
39
+ toEmails
40
+ } from "./chunk-FL5ZV3S2.js";
216
41
 
217
- // src/errors.ts
218
- var MailError = class extends Error {
219
- kind;
220
- provider;
221
- status;
222
- /** เวลาที่ปลายทางขอให้รอก่อนลองใหม่ (วินาที จาก Retry-After) — mailer ใช้เป็นเวลาพักแทน backoff */
223
- retryAfterSec;
224
- constructor(message, opts = {}) {
225
- super(message, opts.cause === void 0 ? void 0 : { cause: opts.cause });
226
- this.name = "MailError";
227
- this.kind = opts.kind ?? "unknown";
228
- this.provider = opts.provider;
229
- this.status = opts.status;
230
- this.retryAfterSec = opts.retryAfterSec;
231
- }
232
- };
233
- function retryAfterOf(res, body) {
234
- if (res.status !== 429 && res.status !== 503) return void 0;
235
- const fromHeader = Number(res.headers?.get?.("Retry-After")?.trim() ?? "");
236
- if (Number.isFinite(fromHeader) && fromHeader > 0) return Math.ceil(fromHeader);
237
- const fromBody = body?.error?.retryAfterSec;
238
- return typeof fromBody === "number" && fromBody > 0 ? Math.ceil(fromBody) : void 0;
42
+ // src/format.ts
43
+ var KNOWN_TAG_RE = /<\/?(b|strong|i|em|u|ins|s|strike|del|a|code|pre|p|br|div|span|ul|ol|li|h[1-6]|table|tr|td)(?=[\s/>])[^>]*>/i;
44
+ function looksLikeHtml(s) {
45
+ return KNOWN_TAG_RE.test(s);
239
46
  }
240
- var AllProvidersFailedError = class extends MailError {
241
- attempts;
242
- constructor(attempts) {
243
- const tried = attempts.filter((a) => !a.skipped);
244
- const summary = attempts.length === 0 ? "no providers configured" : attempts.map(
245
- (a) => a.skipped ? `${a.provider}: skipped (${a.skipped})` : `${a.provider}: ${a.error}`
246
- ).join(" | ");
247
- super(`All mail providers failed (${tried.length}/${attempts.length} tried): ${summary}`);
248
- this.name = "AllProvidersFailedError";
249
- this.attempts = attempts;
250
- }
251
- };
252
- var QUOTA_RE = /quota|limit exceeded|exceeded.*(limit|quota)|daily.*limit|monthly.*limit|sending limit|too many messages/i;
253
- var RATE_RE = /rate.?limit|too many requests|throttl|slow down/i;
254
- var AUTH_RE = /authenticat|credential|password|invalid.*(key|token)|api key|unauthori[sz]ed|forbidden|not verified|verify.*domain|domain.*verif/i;
255
- var NET_CODE_RE = /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|ESOCKET|EDNS|ECONNECTION)/i;
256
- var NET_RE = /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket|timed? ?out|network|fetch failed/i;
257
- var INVALID_RE = /invalid.*(email|address|recipient)|no recipients|recipient.*(rejected|invalid)|malformed|missing.*(to|subject|from)/i;
258
- function classifyError(err) {
259
- if (err instanceof MailError)
260
- return {
261
- kind: err.kind,
262
- message: err.message,
263
- status: err.status,
264
- retryAfterSec: err.retryAfterSec
265
- };
266
- const e = typeof err === "object" && err !== null ? err : {};
267
- const message = String(e.message ?? e.response ?? err ?? "unknown error");
268
- const code = typeof e.code === "string" ? e.code : void 0;
269
- const status = [e.responseCode, e.status, e.statusCode].find(
270
- (v) => typeof v === "number"
271
- );
272
- const text = `${code ?? ""} ${message} ${typeof e.response === "string" ? e.response : ""}`;
273
- if (code && NET_CODE_RE.test(code)) return { kind: "network", message, status };
274
- if (status === 552 || QUOTA_RE.test(text)) return { kind: "quota", message, status };
275
- if (status === 429 || RATE_RE.test(text)) return { kind: "rate_limit", message, status };
276
- if (status === 401 || status === 403 || status === 534 || status === 535 || AUTH_RE.test(text))
277
- return { kind: "auth", message, status };
278
- if (INVALID_RE.test(text)) return { kind: "invalid", message, status };
279
- if (status !== void 0) {
280
- if ([400, 422, 550, 553, 554].includes(status)) return { kind: "invalid", message, status };
281
- if (status >= 420 && status < 460) return { kind: "server", message, status };
282
- if (status >= 500) return { kind: "server", message, status };
283
- }
284
- if (NET_RE.test(text)) return { kind: "network", message, status };
285
- return { kind: "unknown", message, status };
47
+ function resolveFormat(body, format) {
48
+ if (format === "text" || format === "html") return format;
49
+ return looksLikeHtml(body) ? "html" : "text";
286
50
  }
287
-
288
- // src/health.ts
289
- var DEFAULT_COOLDOWN = {
290
- network: 60,
291
- server: 60,
292
- unknown: 60,
293
- rate_limit: 120,
294
- auth: 15 * 60,
295
- quota: 60 * 60,
296
- invalid: 0
297
- // ความผิดของข้อความ ไม่พัก provider
298
- };
299
- var WINDOWS = [
300
- { window: "hour", key: "perHour", ttlSec: 3600 },
301
- { window: "day", key: "perDay", ttlSec: 86400 },
302
- { window: "month", key: "perMonth", ttlSec: 32 * 86400 }
303
- ];
304
- function windowId(window, now = /* @__PURE__ */ new Date()) {
305
- const iso = now.toISOString();
306
- if (window === "hour") return iso.slice(0, 13);
307
- if (window === "day") return iso.slice(0, 10);
308
- return iso.slice(0, 7);
51
+ function toPlainText(html) {
52
+ return html.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&amp;", "&").trim();
309
53
  }
310
- var MAX_RETRY_AFTER_SEC = 60 * 60;
311
- var PROBE_INTERVAL_SEC = 30;
312
- var Health = class {
313
- store;
314
- cooldown;
315
- maxCooldownSec;
316
- /** ค่าที่แอปตั้งเอง (ไม่ตั้ง = undefined) — แยกจากค่าเริ่มต้นเพื่อไม่เปลี่ยนพฤติกรรมของแอปที่ไม่ได้ตั้ง */
317
- userMaxCooldownSec;
318
- emit;
319
- constructor(opts) {
320
- this.store = opts.store;
321
- this.cooldown = { ...DEFAULT_COOLDOWN, ...opts.cooldown };
322
- this.maxCooldownSec = opts.maxCooldownSec ?? 30 * 60;
323
- this.userMaxCooldownSec = opts.maxCooldownSec;
324
- this.emit = opts.onEvent ?? (() => {
325
- });
326
- }
327
- /**
328
- * เหตุผลที่ต้องข้าม provider นี้ตอนนี้ (null = ใช้ได้ และ **จองโควต้า** ไว้แล้ว 1 หน่วยทุก window)
329
- * จองก่อนส่ง (incr แล้วดูค่าที่ได้) เพื่อให้คำขอพร้อมกันหลายตัวไม่ทะลุเพดาน — ส่งพังต้องเรียก release
330
- */
331
- async check(p, quota) {
332
- const cd = await this.cooldownOf(p.name);
333
- if (cd) return "cooldown";
334
- return this.reserve(p, quota);
335
- }
336
- /** จองโควต้าอย่างเดียว (ไม่ดู cooldown) — ใช้ตอน half-open ที่ตั้งใจลองตัวที่ติด cooldown */
337
- async reserve(p, quota) {
338
- const reserved = [];
339
- for (const w of WINDOWS) {
340
- const limit = quota?.[w.key];
341
- if (limit === void 0) continue;
342
- const key = `q:${p.name}:${w.window}:${windowId(w.window)}`;
343
- const used = await this.store.incr(key, w.ttlSec);
344
- reserved.push(key);
345
- if (used > limit) {
346
- await this.releaseKeys(reserved);
347
- return "quota";
348
- }
349
- }
350
- return null;
351
- }
352
- /** คืนโควต้าที่ check() จองไว้ (เรียกเมื่อส่งไม่สำเร็จ) */
353
- async release(p, quota) {
354
- const keys = WINDOWS.filter((w) => quota?.[w.key] !== void 0).map(
355
- (w) => `q:${p.name}:${w.window}:${windowId(w.window)}`
356
- );
357
- await this.releaseKeys(keys);
358
- }
359
- async releaseKeys(keys) {
360
- if (!this.store.decr) return;
361
- for (const key of keys) await this.store.decr(key);
362
- }
363
- async recordSuccess(p) {
364
- const failures = await this.store.get(`fail:${p.name}`);
365
- if (failures) {
366
- await this.store.del(`fail:${p.name}`);
367
- await this.store.del(`cd:${p.name}`);
368
- this.emit({ type: "recovered", provider: p.name });
369
- }
370
- }
371
- /**
372
- * พักหลังพัง: ปลายทางบอก Retry-After มา = พักเท่านั้น (ไม่คูณสอง, ไม่เกิน 1 ชม.)
373
- * ไม่บอก = backoff base, 2x, 4x, ... · แอปตั้ง maxCooldownSec เอง = เพดานของทั้งสองแบบ
374
- */
375
- async recordFailure(p, kind, retryAfterSec) {
376
- const base = this.cooldown[kind];
377
- if (!base) return;
378
- const failures = await this.store.incr(`fail:${p.name}`, 86400);
379
- let sec = retryAfterSec && retryAfterSec > 0 ? Math.min(retryAfterSec, MAX_RETRY_AFTER_SEC) : this.userMaxCooldownSec !== void 0 ? base * 2 ** Math.min(failures - 1, 10) : Math.min(base * 2 ** Math.min(failures - 1, 10), Math.max(base, this.maxCooldownSec));
380
- if (this.userMaxCooldownSec !== void 0) sec = Math.min(sec, this.userMaxCooldownSec);
381
- if (sec <= 0) return;
382
- const until = Date.now() + sec * 1e3;
383
- await this.store.set(
384
- `cd:${p.name}`,
385
- JSON.stringify({ until, kind }),
386
- Math.ceil(sec)
387
- );
388
- this.emit({ type: "cooldown", provider: p.name, kind, untilMs: until, failures });
389
- }
390
- /** ล้าง cooldown/ตัวนับพัง (ไม่ล้างโควต้าที่ใช้ไปแล้ว) */
391
- async reset(name) {
392
- await this.store.del(`cd:${name}`);
393
- await this.store.del(`fail:${name}`);
394
- }
395
- /**
396
- * half-open: อนุญาตให้ลองตัวที่ติด cooldown ได้ 1 ครั้งต่อ PROBE_INTERVAL_SEC (แชร์ข้าม instance ถ้า store แชร์)
397
- * true = ได้สิทธิ์ลองรอบนี้
398
- */
399
- async allowProbe(name) {
400
- return await this.store.incr(`probe:${name}`, PROBE_INTERVAL_SEC) === 1;
401
- }
402
- async status(p, quota) {
403
- const cd = await this.cooldownOf(p.name);
404
- const usage = await this.usage(p, quota);
405
- const failures = Number(await this.store.get(`fail:${p.name}`) ?? 0);
406
- return {
407
- name: p.name,
408
- type: p.type,
409
- available: !cd && usage.every((u) => u.used < u.limit),
410
- cooldownUntil: cd?.until,
411
- cooldownKind: cd?.kind,
412
- failures,
413
- quota: usage
414
- };
415
- }
416
- /** cooldown ที่ยังไม่หมด (null = ใช้ได้) */
417
- async cooldownOf(name) {
418
- const raw = await this.store.get(`cd:${name}`);
419
- if (!raw) return null;
420
- try {
421
- const cd = JSON.parse(raw);
422
- return cd.until > Date.now() ? cd : null;
423
- } catch {
424
- return null;
425
- }
426
- }
427
- async usage(p, quota) {
428
- const out = [];
429
- for (const w of WINDOWS) {
430
- const limit = quota?.[w.key];
431
- if (limit === void 0) continue;
432
- const used = Number(
433
- await this.store.get(`q:${p.name}:${w.window}:${windowId(w.window)}`) ?? 0
434
- );
435
- out.push({ window: w.window, used, limit });
436
- }
437
- return out;
438
- }
439
- };
440
54
 
441
- // src/providers/console.ts
442
- function createConsoleProvider(opts = {}) {
443
- const name = opts.name ?? "console";
444
- const log = opts.log ?? ((l) => console.log(l));
445
- let seq = 0;
446
- return {
447
- name,
448
- type: "console",
449
- from: opts.from,
450
- async send(msg) {
451
- const id = `console-${Date.now()}-${++seq}`;
452
- const lines = [
453
- `[mailer:${name}] ${id}`,
454
- ` from: ${msg.from ? formatAddress(msg.from) : "(hub default)"}`,
455
- ` to: ${msg.to.map(formatAddress).join(", ")}`,
456
- ...msg.cc.length ? [` cc: ${msg.cc.map(formatAddress).join(", ")}`] : [],
457
- ...msg.bcc.length ? [` bcc: ${msg.bcc.map(formatAddress).join(", ")}`] : [],
458
- ` subject: ${msg.subject}`,
459
- ...msg.attachments.length ? [` files: ${msg.attachments.map((a) => a.filename).join(", ")}`] : [],
460
- ...opts.body ? [` ---`, msg.text ?? msg.html ?? "(empty)"] : []
461
- ];
462
- log(lines.join("\n"));
463
- return { id };
464
- }
55
+ // src/push.ts
56
+ function createPushHandlers(options) {
57
+ const { getUser, ...opts } = options;
58
+ const requireUser = async () => {
59
+ const user = await getUser();
60
+ if (!user?.email)
61
+ throw new MailError("push: \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E25\u0E47\u0E2D\u0E01\u0E2D\u0E34\u0E19", { kind: "auth", status: 401, provider: "hub" });
62
+ return user;
465
63
  };
466
- }
467
-
468
- // src/providers/hub.ts
469
- var DEFAULT_HUB_URL = "https://notify.suphark.com";
470
- function createHubProvider(opts) {
471
- const name = opts.name ?? "hub";
472
- const url = `${(opts.url ?? DEFAULT_HUB_URL).replace(/\/$/, "")}/api/v1/send`;
473
- const doFetch = opts.fetch ?? globalThis.fetch;
474
64
  return {
475
- name,
476
- type: "hub",
477
- from: opts.from,
478
- optionalFrom: true,
479
- async send(msg) {
480
- const body = {
481
- from: msg.from,
482
- to: msg.to,
483
- cc: msg.cc.length ? msg.cc : void 0,
484
- bcc: msg.bcc.length ? msg.bcc : void 0,
485
- replyTo: msg.replyTo.length ? msg.replyTo : void 0,
486
- subject: msg.subject,
487
- html: msg.html,
488
- text: msg.text,
489
- attachments: msg.attachments.length ? msg.attachments : void 0,
490
- headers: Object.keys(msg.headers).length ? msg.headers : void 0,
491
- tags: Object.keys(msg.tags).length ? msg.tags : void 0,
492
- prefer: msg.prefer
493
- };
494
- const res = await doFetch(url, {
495
- method: "POST",
496
- headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
497
- body: JSON.stringify(body),
498
- // mailer หมดเวลา → abort คำขอนี้ทิ้ง (Pi Notify อาจส่งไปแล้ว — แอปที่มีตัวสำรองในเครื่องอาจได้เมลซ้ำ ยอมรับได้)
499
- signal: msg.signal
500
- });
501
- const data = await res.json().catch(() => ({}));
502
- if (!res.ok) {
503
- const err = data.error;
504
- const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
505
- throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
506
- kind,
507
- provider: name,
508
- status: res.status,
509
- cause: data,
510
- retryAfterSec: retryAfterOf(res, data)
511
- });
512
- }
513
- return { id: data.id };
514
- },
515
- async listProviders() {
516
- const res = await doFetch(url.replace(/\/send$/, "/providers"), {
517
- headers: { Authorization: `Bearer ${opts.apiKey}` }
518
- });
519
- if (!res.ok)
520
- throw new MailError(`Pi Notify ${res.status}: ${res.statusText}`, {
521
- kind: res.status === 401 ? "auth" : "server",
522
- provider: name,
523
- status: res.status
524
- });
525
- const data = await res.json();
526
- return data.providers.map((p) => ({ ...p, via: name }));
65
+ getPublicKey: async () => (await getPushConfig(opts)).publicKey,
66
+ async subscribe(subscription, extra = {}) {
67
+ const user = await requireUser();
68
+ const { endpoint, keys } = subscription ?? {};
69
+ if (!endpoint?.startsWith("https://") || !keys?.p256dh || !keys.auth)
70
+ throw new MailError("push: subscription \u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07", { kind: "invalid", provider: "hub" });
71
+ return savePushSubscription(
72
+ user.email,
73
+ { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } },
74
+ { ...opts, label: extra.label, piUserId: user.id }
75
+ );
527
76
  },
528
- async verify() {
529
- const res = await doFetch(url.replace(/\/send$/, "/status"), {
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
- }
539
- }
540
- };
541
- }
542
-
543
- // src/providers/resend.ts
544
- function createResendProvider(opts) {
545
- const name = opts.name ?? "resend";
546
- const baseUrl = (opts.baseUrl ?? "https://api.resend.com").replace(/\/$/, "");
547
- const doFetch = opts.fetch ?? globalThis.fetch;
548
- async function request(path, init) {
549
- const res = await doFetch(`${baseUrl}${path}`, {
550
- ...init,
551
- headers: {
552
- Authorization: `Bearer ${opts.apiKey}`,
553
- "Content-Type": "application/json",
554
- ...init.headers ?? {}
555
- }
556
- });
557
- const body = await res.json().catch(() => ({}));
558
- if (!res.ok) {
559
- const msg = `Resend ${res.status} ${body?.name ?? ""}: ${body?.message ?? res.statusText}`;
560
- const errName = String(body?.name ?? "");
561
- const kind = /quota/i.test(errName) ? "quota" : /rate_limit/i.test(errName) || res.status === 429 ? "rate_limit" : res.status === 401 || res.status === 403 ? "auth" : res.status === 422 || errName === "validation_error" ? "invalid" : res.status >= 500 ? "server" : "unknown";
562
- throw new MailError(msg, { kind, provider: name, status: res.status, cause: body });
563
- }
564
- return body;
565
- }
566
- return {
567
- name,
568
- type: "resend",
569
- from: opts.from,
570
- async send(msg) {
571
- const body = {
572
- from: formatAddress(requireFrom(msg, name)),
573
- to: msg.to.map(formatAddress),
574
- cc: msg.cc.length ? msg.cc.map(formatAddress) : void 0,
575
- bcc: msg.bcc.length ? msg.bcc.map(formatAddress) : void 0,
576
- reply_to: msg.replyTo.length ? msg.replyTo.map(formatAddress) : void 0,
577
- subject: msg.subject,
578
- html: msg.html,
579
- text: msg.text,
580
- headers: Object.keys(msg.headers).length ? msg.headers : void 0,
581
- attachments: msg.attachments.length ? msg.attachments.map((a) => ({
582
- filename: a.filename,
583
- content: a.base64,
584
- content_type: a.contentType
585
- })) : void 0,
586
- tags: Object.keys(msg.tags).length ? Object.entries(msg.tags).map(([k, v]) => ({ name: k, value: v })) : void 0
587
- };
588
- const data = await request("/emails", { method: "POST", body: JSON.stringify(body) });
589
- return { id: data?.id };
77
+ async unsubscribe(endpoint) {
78
+ const user = await requireUser();
79
+ return deletePushSubscription(user.email, endpoint, opts);
590
80
  },
591
- async verify() {
592
- const res = await doFetch(`${baseUrl}/emails/00000000-0000-4000-8000-000000000000`, {
593
- headers: { Authorization: `Bearer ${opts.apiKey}` }
594
- });
595
- if (res.ok || res.status === 404) return;
596
- const body = await res.json().catch(() => ({}));
597
- throw new MailError(
598
- `Resend ${res.status} ${body?.name ?? ""}: ${body?.message ?? res.statusText}`,
599
- {
600
- kind: res.status === 401 || res.status === 403 ? "auth" : "server",
601
- provider: name,
602
- status: res.status
603
- }
604
- );
81
+ async listDevices() {
82
+ const user = await requireUser();
83
+ return listPushDevices(user.email, opts);
605
84
  }
606
85
  };
607
86
  }
608
-
609
- // src/providers/zeptomail.ts
610
- var addr = (a) => ({ address: a.email, name: a.name });
611
- var recipient = (a) => ({ email_address: addr(a) });
612
- function createZeptoMailProvider(opts) {
613
- const name = opts.name ?? "zeptomail";
614
- const baseUrl = (opts.baseUrl ?? `https://api.zeptomail.${opts.region ?? "com"}`).replace(
615
- /\/$/,
616
- ""
87
+ function pushServiceWorkerSource(options = {}) {
88
+ const icon = JSON.stringify(options.icon ?? "/icon-192.png");
89
+ const home = JSON.stringify(options.defaultLink ?? "/");
90
+ return `// \u0E2A\u0E23\u0E49\u0E32\u0E07\u0E42\u0E14\u0E22 @suphark/notify \u2014 service worker \u0E23\u0E31\u0E1A Web Push \u0E08\u0E32\u0E01 Pi Notify
91
+ self.addEventListener("push", (event) => {
92
+ let data = {};
93
+ try {
94
+ data = event.data ? event.data.json() : {};
95
+ } catch {
96
+ data = { body: event.data ? event.data.text() : "" };
97
+ }
98
+ event.waitUntil(
99
+ self.registration.showNotification(data.title || "Pi Notify", {
100
+ body: data.body || "",
101
+ icon: ${icon},
102
+ data: { link: data.link || ${home} },
103
+ }),
617
104
  );
618
- const doFetch = opts.fetch ?? globalThis.fetch;
619
- return {
620
- name,
621
- type: "zeptomail",
622
- from: opts.from,
623
- async send(msg) {
624
- const body = {
625
- from: addr(requireFrom(msg, name)),
626
- to: msg.to.map(recipient),
627
- cc: msg.cc.length ? msg.cc.map(recipient) : void 0,
628
- bcc: msg.bcc.length ? msg.bcc.map(recipient) : void 0,
629
- reply_to: msg.replyTo.length ? msg.replyTo.map(addr) : void 0,
630
- subject: msg.subject,
631
- htmlbody: msg.html,
632
- textbody: msg.text,
633
- attachments: msg.attachments.length ? msg.attachments.map((a) => ({
634
- name: a.filename,
635
- content: a.base64,
636
- mime_type: a.contentType ?? "application/octet-stream"
637
- })) : void 0
638
- };
639
- const res = await doFetch(`${baseUrl}/v1.1/email`, {
640
- method: "POST",
641
- headers: {
642
- Authorization: `Zoho-enczapikey ${opts.apiKey}`,
643
- "Content-Type": "application/json",
644
- Accept: "application/json"
645
- },
646
- body: JSON.stringify(body)
647
- });
648
- const data = await res.json().catch(() => ({}));
649
- if (!res.ok) {
650
- const code = String(data?.error?.code ?? "");
651
- const detail = data?.error?.details?.[0]?.message ?? data?.error?.message ?? res.statusText;
652
- const msg2 = `ZeptoMail ${res.status} ${code}: ${detail}`;
653
- const kind = /credit|quota|limit/i.test(detail) ? "quota" : res.status === 429 ? "rate_limit" : res.status === 401 || res.status === 403 || /TM_4001|token|key/i.test(`${code} ${detail}`) ? "auth" : res.status === 400 || res.status === 422 ? "invalid" : res.status >= 500 ? "server" : "unknown";
654
- throw new MailError(msg2, { kind, provider: name, status: res.status, cause: data });
655
- }
656
- return { id: data?.request_id };
657
- }
658
- };
659
- }
105
+ });
660
106
 
661
- // src/providers/index.ts
662
- function isProvider(x) {
663
- return typeof x === "object" && x !== null && typeof x.send === "function" && typeof x.name === "string";
664
- }
665
- function buildProvider(cfg) {
666
- if (isProvider(cfg)) return { provider: cfg, disabled: false };
667
- const { type, name, from, quota, disabled, senderDomains, ...rest } = cfg;
668
- const base = { name, from: from === void 0 ? void 0 : parseAddress(from) };
669
- let provider;
670
- switch (type) {
671
- case "hub":
672
- provider = createHubProvider({ ...rest, ...base });
673
- break;
674
- case "resend":
675
- provider = createResendProvider({ ...rest, ...base });
676
- break;
677
- case "smtp":
678
- provider = createSmtpProvider({ ...rest, ...base });
679
- break;
680
- case "zeptomail":
681
- provider = createZeptoMailProvider({ ...rest, ...base });
682
- break;
683
- case "console":
684
- provider = createConsoleProvider({ ...rest, ...base });
685
- break;
686
- case "custom": {
687
- const custom = cfg.provider;
688
- provider = base.from || name ? { ...custom, name: name ?? custom.name, from: base.from ?? custom.from } : custom;
689
- break;
690
- }
691
- default:
692
- if (type in SMTP_PRESETS) {
693
- provider = createPresetSmtpProvider(type, { ...rest, ...base });
694
- break;
107
+ self.addEventListener("notificationclick", (event) => {
108
+ event.notification.close();
109
+ // \u0E40\u0E1B\u0E34\u0E14\u0E44\u0E14\u0E49\u0E40\u0E09\u0E1E\u0E32\u0E30\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E43\u0E19 origin \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E40\u0E2D\u0E07
110
+ let target = new URL(${home}, self.location.origin);
111
+ try {
112
+ const url = new URL(event.notification.data && event.notification.data.link, self.location.origin);
113
+ if (url.origin === self.location.origin) target = url;
114
+ } catch {}
115
+ event.waitUntil(
116
+ self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windows) => {
117
+ for (const w of windows) {
118
+ if (w.url === target.href && "focus" in w) return w.focus();
695
119
  }
696
- throw new TypeError(`Unknown mail provider type: "${String(type)}"`);
697
- }
698
- return {
699
- provider,
700
- quota,
701
- disabled: disabled ?? false,
702
- senderDomains: senderDomains?.map((d) => d.trim().toLowerCase()).filter(Boolean)
703
- };
120
+ return self.clients.openWindow(target.href);
121
+ }),
122
+ );
123
+ });
124
+ `;
704
125
  }
705
-
706
- // src/stores/memory.ts
707
- function createMemoryStore() {
708
- const data = /* @__PURE__ */ new Map();
709
- const read = (key) => {
710
- const hit = data.get(key);
711
- if (!hit) return void 0;
712
- if (hit.expiresAt <= Date.now()) {
713
- data.delete(key);
714
- return void 0;
715
- }
716
- return hit;
717
- };
718
- let ops = 0;
719
- const sweep = () => {
720
- if (++ops % 500 !== 0) return;
721
- const now = Date.now();
722
- for (const [k, v] of data) if (v.expiresAt <= now) data.delete(k);
723
- };
724
- return {
725
- async get(key) {
726
- sweep();
727
- return read(key)?.value ?? null;
728
- },
729
- async set(key, value, ttlSec) {
730
- sweep();
731
- data.set(key, { value, expiresAt: Date.now() + ttlSec * 1e3 });
732
- },
733
- async incr(key, ttlSec) {
734
- sweep();
735
- const hit = read(key);
736
- if (!hit) {
737
- data.set(key, { value: "1", expiresAt: Date.now() + ttlSec * 1e3 });
738
- return 1;
739
- }
740
- const next = Number(hit.value) + 1;
741
- hit.value = String(next);
742
- return next;
743
- },
744
- async decr(key) {
745
- const hit = read(key);
746
- if (hit) hit.value = String(Math.max(0, Number(hit.value) - 1));
747
- },
748
- async del(key) {
749
- data.delete(key);
126
+ function pushServiceWorker(options = {}) {
127
+ const source = pushServiceWorkerSource(options);
128
+ return () => new Response(source, {
129
+ headers: {
130
+ "Content-Type": "text/javascript; charset=utf-8",
131
+ "Service-Worker-Allowed": "/",
132
+ "Cache-Control": "no-cache"
750
133
  }
751
- };
752
- }
753
-
754
- // src/mailer.ts
755
- var PROBE_KINDS = /* @__PURE__ */ new Set(["network", "server", "unknown"]);
756
- var timeoutError = (name, ms) => new MailError(`${name}: send timed out after ${ms}ms`, { kind: "network", provider: name });
757
- function withTimeout(run, ms, name) {
758
- const ac = new AbortController();
759
- return new Promise((resolve2, reject) => {
760
- const t = setTimeout(() => {
761
- const err = timeoutError(name, ms);
762
- reject(err);
763
- ac.abort(err);
764
- }, ms);
765
- Promise.resolve().then(() => run(ac.signal)).then(
766
- (v) => {
767
- clearTimeout(t);
768
- resolve2(v);
769
- },
770
- (e) => {
771
- clearTimeout(t);
772
- reject(e);
773
- }
774
- );
775
- });
776
- }
777
- function toBase64(content, encoding) {
778
- if (Buffer.isBuffer(content)) return content.toString("base64");
779
- return encoding === "base64" ? content : Buffer.from(content, "utf8").toString("base64");
780
- }
781
- function debugLogger(e) {
782
- const tag = "[mailer]";
783
- switch (e.type) {
784
- case "sent":
785
- console.log(
786
- `${tag} sent via ${e.provider} (${e.ms}ms)${e.failedOver ? " [failover]" : ""}`,
787
- e.id ?? ""
788
- );
789
- break;
790
- case "failed":
791
- console.warn(`${tag} ${e.provider} failed [${e.kind}] ${e.error} (${e.ms}ms)`);
792
- break;
793
- case "skipped":
794
- console.warn(`${tag} ${e.provider} skipped (${e.reason})`);
795
- break;
796
- case "cooldown":
797
- console.warn(
798
- `${tag} ${e.provider} cooldown [${e.kind}] until ${new Date(e.untilMs).toISOString()} (failures=${e.failures})`
799
- );
800
- break;
801
- case "recovered":
802
- console.log(`${tag} ${e.provider} recovered`);
803
- break;
804
- case "exhausted":
805
- console.error(`${tag} all providers failed`, e.attempts);
806
- break;
807
- }
808
- }
809
- function createMailer(options = {}) {
810
- const env = options.env ?? process.env;
811
- const entries = (options.providers ?? providersFromEnv(env)).map(buildProvider);
812
- if (entries.length === 0) {
813
- throw new TypeError(
814
- "createMailer: no providers configured \u2014 pass { providers } or set MAIL_PROVIDERS / RESEND_API_KEY / SMTP_HOST"
815
- );
816
- }
817
- const names = /* @__PURE__ */ new Set();
818
- for (const e of entries) {
819
- if (names.has(e.provider.name))
820
- throw new TypeError(
821
- `createMailer: duplicate provider name "${e.provider.name}" \u2014 set a unique name`
822
- );
823
- names.add(e.provider.name);
824
- }
825
- const fromRaw = options.from ?? env.MAIL_FROM ?? env.EMAIL_FROM;
826
- const defaultFrom = fromRaw === void 0 ? void 0 : parseAddress(fromRaw);
827
- const strategy = options.strategy ?? env.MAIL_STRATEGY ?? "priority";
828
- const timeoutMs = options.timeoutMs ?? 2e4;
829
- const debug = options.debug ?? /^(1|true)$/i.test(env.MAIL_DEBUG ?? "");
830
- const emit = (e) => {
831
- if (debug) debugLogger(e);
832
- options.onEvent?.(e);
833
- };
834
- const health = new Health({
835
- store: options.store ?? createMemoryStore(),
836
- cooldown: options.cooldown,
837
- maxCooldownSec: options.maxCooldownSec,
838
- onEvent: emit
839
134
  });
840
- let rr = 0;
841
- const order = () => {
842
- if (strategy !== "round-robin" || entries.length < 2) return entries;
843
- const start = rr++ % entries.length;
844
- return [...entries.slice(start), ...entries.slice(0, start)];
845
- };
846
- const normalizeBase = (m) => {
847
- try {
848
- return {
849
- requested: m.from !== void 0 ? parseAddress(m.from) : void 0,
850
- to: parseAddressList(m.to),
851
- cc: parseAddressList(m.cc),
852
- bcc: parseAddressList(m.bcc),
853
- replyTo: parseAddressList(m.replyTo),
854
- subject: m.subject,
855
- html: m.html,
856
- text: m.text,
857
- attachments: (m.attachments ?? []).map((a) => ({
858
- filename: a.filename,
859
- base64: toBase64(a.content, a.encoding),
860
- contentType: a.contentType
861
- })),
862
- headers: m.headers ?? {},
863
- tags: m.tags ?? {},
864
- prefer: m.prefer
865
- };
866
- } catch (err) {
867
- throw new MailError(err.message, { kind: "invalid", cause: err });
868
- }
869
- };
870
- const resolveFrom = (requested, e) => {
871
- const p = e.provider;
872
- if (!p.from) {
873
- const from = requested ?? defaultFrom;
874
- if (!from && !p.optionalFrom)
875
- throw new MailError(`No "from" address \u2014 set message.from, provider from, or MAIL_FROM`, {
876
- kind: "invalid",
877
- provider: p.name
878
- });
879
- return from;
880
- }
881
- if (!requested) return p.from;
882
- const domain = requested.email.slice(requested.email.indexOf("@") + 1).toLowerCase();
883
- const allowed = e.senderDomains?.length ? e.senderDomains.includes(domain) : requested.email.toLowerCase() === p.from.email.toLowerCase();
884
- if (allowed) return requested;
885
- return requested.name ? { name: requested.name, email: p.from.email } : p.from;
886
- };
887
- async function pickProbe(candidates, attempts) {
888
- const cooled = candidates.filter(
889
- (e) => attempts.some((a) => a.provider === e.provider.name && a.skipped === "cooldown")
890
- );
891
- const ranked = [];
892
- for (const entry of cooled) {
893
- const cd = await health.cooldownOf(entry.provider.name);
894
- if (cd && PROBE_KINDS.has(cd.kind)) ranked.push({ entry, until: cd.until });
895
- }
896
- ranked.sort((a, b) => a.until - b.until);
897
- const pick = ranked[0]?.entry;
898
- if (!pick || !await health.allowProbe(pick.provider.name)) return null;
899
- if (await health.reserve(pick.provider, pick.quota)) return null;
900
- return pick;
901
- }
902
- async function send(message) {
903
- if (!message?.to || Array.isArray(message.to) && message.to.length === 0)
904
- throw new MailError("Message has no recipients (to)", { kind: "invalid" });
905
- if (!message.subject) throw new MailError("Message has no subject", { kind: "invalid" });
906
- if (message.html === void 0 && message.text === void 0)
907
- throw new MailError("Message needs html or text", { kind: "invalid" });
908
- let candidates = order();
909
- if (message.prefer) {
910
- const i = candidates.findIndex((e) => e.provider.name === message.prefer);
911
- if (i > 0) candidates = [candidates[i], ...candidates.filter((_, j) => j !== i)];
912
- }
913
- if (message.provider) {
914
- candidates = candidates.filter((e) => e.provider.name === message.provider);
915
- if (candidates.length === 0)
916
- throw new MailError(`Unknown provider "${message.provider}"`, { kind: "invalid" });
917
- }
918
- const { requested, ...base } = normalizeBase(message);
919
- const attempts = [];
920
- const forced = message.provider !== void 0;
921
- const attempt = async (entry, opts) => {
922
- const p = entry.provider;
923
- const started = Date.now();
924
- let id;
925
- try {
926
- ({ id } = await withTimeout(
927
- (signal) => {
928
- const normalized = {
929
- ...base,
930
- from: resolveFrom(requested, entry),
931
- signal
932
- };
933
- return p.send(normalized);
934
- },
935
- timeoutMs,
936
- p.name
937
- ));
938
- } catch (err) {
939
- const ms2 = Date.now() - started;
940
- const { kind, message: error, retryAfterSec } = classifyError(err);
941
- attempts.push({ provider: p.name, ok: false, error, kind, ms: ms2 });
942
- emit({ type: "failed", provider: p.name, kind, error, ms: ms2 });
943
- try {
944
- if (opts.reserved) await health.release(p, entry.quota);
945
- await health.recordFailure(p, kind, retryAfterSec);
946
- } catch {
947
- }
948
- return null;
949
- }
950
- const ms = Date.now() - started;
951
- attempts.push({ provider: p.name, ok: true, ms });
952
- await health.recordSuccess(p).catch(() => {
953
- });
954
- const failedOver = opts.failedOver();
955
- emit({ type: "sent", provider: p.name, id, ms, failedOver });
956
- return { id, provider: p.name, failedOver, attempts };
957
- };
958
- for (const entry of candidates) {
959
- const p = entry.provider;
960
- if (entry.disabled) {
961
- attempts.push({ provider: p.name, ok: false, skipped: "disabled", ms: 0 });
962
- emit({ type: "skipped", provider: p.name, reason: "disabled" });
963
- continue;
964
- }
965
- const skip = forced ? null : await health.check(p, entry.quota);
966
- if (skip) {
967
- attempts.push({ provider: p.name, ok: false, skipped: skip, ms: 0 });
968
- emit({ type: "skipped", provider: p.name, reason: skip });
969
- continue;
970
- }
971
- const result = await attempt(entry, {
972
- reserved: !forced,
973
- failedOver: () => attempts.length > 1
974
- });
975
- if (result) return result;
976
- }
977
- if (!forced && attempts.every((a) => a.skipped)) {
978
- const probe = await pickProbe(candidates, attempts).catch(() => null);
979
- if (probe) {
980
- const first = candidates.find((e) => !e.disabled)?.provider.name;
981
- const result = await attempt(probe, {
982
- reserved: true,
983
- failedOver: () => probe.provider.name !== first
984
- });
985
- if (result) return result;
986
- }
987
- }
988
- emit({ type: "exhausted", attempts });
989
- throw new AllProvidersFailedError(attempts);
990
- }
991
- return {
992
- send,
993
- providers: entries.map((e) => e.provider.name),
994
- status: () => Promise.all(entries.map((e) => health.status(e.provider, e.quota))),
995
- async listProviders() {
996
- const out = [];
997
- for (const e of entries) {
998
- if (e.disabled) continue;
999
- const st = await health.status(e.provider, e.quota);
1000
- if (e.provider.listProviders) {
1001
- try {
1002
- out.push(...await e.provider.listProviders());
1003
- continue;
1004
- } catch {
1005
- }
1006
- }
1007
- out.push({ name: e.provider.name, type: e.provider.type, available: st.available });
1008
- }
1009
- return out;
1010
- },
1011
- async verify() {
1012
- const out = {};
1013
- await Promise.all(
1014
- entries.map(async (e) => {
1015
- try {
1016
- const verify = e.provider.verify?.bind(e.provider);
1017
- if (verify) await withTimeout(() => verify(), timeoutMs, e.provider.name);
1018
- out[e.provider.name] = { ok: true };
1019
- } catch (err) {
1020
- out[e.provider.name] = { ok: false, error: classifyError(err).message };
1021
- }
1022
- })
1023
- );
1024
- return out;
1025
- },
1026
- async reset(name) {
1027
- for (const e of entries)
1028
- if (!name || e.provider.name === name) await health.reset(e.provider.name);
1029
- }
1030
- };
1031
135
  }
1032
136
 
1033
- // src/default.ts
1034
- var instance;
1035
- var pending;
1036
- function configureMailer(options) {
1037
- pending = options;
1038
- instance = createMailer(options);
1039
- return instance;
1040
- }
1041
- function getMailer() {
1042
- instance ??= createMailer(pending);
1043
- return instance;
1044
- }
1045
- function sendMail(message) {
1046
- return getMailer().send(message);
1047
- }
1048
- function listProviders() {
1049
- return getMailer().listProviders();
1050
- }
1051
- function resetMailer() {
1052
- instance = void 0;
1053
- pending = void 0;
1054
- }
1055
-
1056
- // src/notify.ts
1057
- var NOTIFY_TIMEOUT_MS = { notify: 65e3, recipient: 1e4 };
1058
- function resolve(opts) {
1059
- const env = opts.env ?? process.env;
1060
- const apiKey = opts.apiKey ?? env.PI_NOTIFY_KEY;
1061
- if (!apiKey) throw new MailError("notify(): PI_NOTIFY_KEY is required", { kind: "auth" });
1062
- const base = (opts.url ?? env.PI_NOTIFY_URL ?? DEFAULT_HUB_URL).replace(/\/$/, "");
1063
- return { apiKey, base, doFetch: opts.fetch ?? globalThis.fetch };
1064
- }
1065
- async function hubRequest(opts, path, init) {
1066
- const { apiKey, base, doFetch } = resolve(opts);
1067
- const ms = opts.timeoutMs ?? init.timeoutMs;
1068
- const ac = new AbortController();
1069
- const timer = setTimeout(() => ac.abort(), ms);
1070
- const timedOut = () => new MailError(`Pi Notify timeout after ${ms}ms`, { kind: "network", provider: "hub" });
1071
- let res;
1072
- let data;
1073
- try {
1074
- res = await doFetch(`${base}${path}`, {
1075
- method: init.method ?? "GET",
1076
- headers: {
1077
- Authorization: `Bearer ${apiKey}`,
1078
- ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
1079
- },
1080
- body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
1081
- signal: ac.signal
1082
- });
1083
- data = await res.json().catch(() => ({}));
1084
- } catch (err) {
1085
- if (ac.signal.aborted) throw timedOut();
1086
- throw err;
1087
- } finally {
1088
- clearTimeout(timer);
1089
- }
1090
- if (ac.signal.aborted) throw timedOut();
1091
- if (!res.ok) {
1092
- const err = data?.error;
1093
- const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
1094
- throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
1095
- kind,
1096
- provider: "hub",
1097
- status: res.status,
1098
- cause: data,
1099
- retryAfterSec: retryAfterOf(res, data)
1100
- });
1101
- }
1102
- return data;
1103
- }
1104
- async function notify(input, opts = {}) {
1105
- const data = await hubRequest(opts, "/api/v1/notify", {
1106
- method: "POST",
1107
- body: input,
1108
- timeoutMs: NOTIFY_TIMEOUT_MS.notify
1109
- });
1110
- return { results: data.results, ...data.summary };
1111
- }
1112
- async function getRecipient(email, opts = {}) {
1113
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}`, {
1114
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1115
- });
1116
- }
1117
- async function createLink(email, channel = "telegram", opts = {}) {
1118
- const { piUserId, ...rest } = opts;
1119
- return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/link`, {
1120
- method: "POST",
1121
- body: { channel, ...piUserId ? { piUserId } : {} },
1122
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1123
- });
1124
- }
1125
- async function getPushConfig(opts = {}) {
1126
- return hubRequest(opts, "/api/v1/push/config", { timeoutMs: NOTIFY_TIMEOUT_MS.recipient });
1127
- }
1128
- async function savePushSubscription(email, subscription, opts = {}) {
1129
- const { label, piUserId, ...rest } = opts;
1130
- return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1131
- method: "POST",
1132
- body: { subscription, ...label ? { label } : {}, ...piUserId ? { piUserId } : {} },
1133
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1134
- });
1135
- }
1136
- async function deletePushSubscription(email, endpoint, opts = {}) {
1137
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1138
- method: "DELETE",
1139
- body: { endpoint },
1140
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1141
- });
1142
- }
1143
- async function listPushDevices(email, opts = {}) {
1144
- return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1145
- timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1146
- });
137
+ // src/render.ts
138
+ function escapeHtml(s) {
139
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
140
+ }
141
+ var DEFAULT_ACCENT = "#1d4ed8";
142
+ function renderNotifyEmail(input) {
143
+ const plainBody = resolveFormat(input.body, input.format) === "html" ? toPlainText(input.body) : input.body;
144
+ const label = input.linkLabel ?? "\u0E40\u0E1B\u0E34\u0E14\u0E25\u0E34\u0E07\u0E01\u0E4C";
145
+ const accent = input.accent && /^#[0-9a-f]{3,8}$/i.test(input.accent) ? input.accent : DEFAULT_ACCENT;
146
+ const text = [
147
+ input.title,
148
+ "",
149
+ plainBody,
150
+ ...input.link ? ["", `${label}: ${input.link}`] : [],
151
+ ...input.appName ? ["", `\u2014 ${input.appName}`] : [],
152
+ ""
153
+ ].join("\n");
154
+ const html = `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.6;color:#0f172a;max-width:520px">
155
+ <h2 style="font-size:18px;margin:0 0 12px">${escapeHtml(input.title)}</h2>
156
+ <p style="margin:0 0 20px;white-space:pre-wrap">${escapeHtml(plainBody)}</p>
157
+ ${input.link ? `<a href="${escapeHtml(input.link)}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 16px;border-radius:8px;font-weight:600">${escapeHtml(label)}</a>` : ""}
158
+ ${input.appName ? `<p style="margin:24px 0 0;font-size:12px;color:#64748b">${escapeHtml(input.appName)}</p>` : ""}
159
+ </div>`;
160
+ return { subject: input.title, text, html };
1147
161
  }
1148
162
 
1149
163
  // src/stores/redis.ts
@@ -1188,23 +202,36 @@ export {
1188
202
  createMailer,
1189
203
  createMemoryStore,
1190
204
  createPresetSmtpProvider,
205
+ createPushHandlers,
1191
206
  createRedisStore,
1192
207
  createResendProvider,
1193
208
  createSmtpProvider,
1194
209
  createZeptoMailProvider,
1195
210
  deletePushSubscription,
1196
211
  formatAddress,
212
+ getChannelStatus,
1197
213
  getMailer,
1198
214
  getPushConfig,
1199
215
  getRecipient,
216
+ hasNotifyKey,
1200
217
  listProviders,
1201
218
  listPushDevices,
219
+ looksLikeHtml,
1202
220
  notify,
221
+ notifySafe,
1203
222
  parseAddress,
1204
223
  parseAddressList,
1205
224
  providersFromEnv,
225
+ pushServiceWorker,
226
+ pushServiceWorkerSource,
227
+ renderNotifyEmail,
1206
228
  requireFrom,
1207
229
  resetMailer,
230
+ resetSafeWarnings,
231
+ resolveFormat,
1208
232
  savePushSubscription,
1209
- sendMail
233
+ sendMail,
234
+ sendMailSafe,
235
+ toEmails,
236
+ toPlainText
1210
237
  };