@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/LICENSE +21 -21
- package/README.md +132 -8
- package/dist/chunk-FL5ZV3S2.js +1290 -0
- package/dist/index.d.ts +114 -298
- package/dist/index.js +154 -1127
- package/dist/next.d.ts +28 -0
- package/dist/next.js +40 -0
- package/dist/notify-L7JnpG0t.d.ts +340 -0
- package/package.json +18 -1
|
@@ -0,0 +1,1290 @@
|
|
|
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
|
+
}
|
|
216
|
+
|
|
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;
|
|
239
|
+
}
|
|
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 };
|
|
286
|
+
}
|
|
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);
|
|
309
|
+
}
|
|
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
|
+
|
|
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
|
+
}
|
|
465
|
+
};
|
|
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
|
+
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 }));
|
|
527
|
+
},
|
|
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 };
|
|
590
|
+
},
|
|
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
|
+
);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
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
|
+
""
|
|
617
|
+
);
|
|
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
|
+
}
|
|
660
|
+
|
|
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;
|
|
695
|
+
}
|
|
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
|
+
};
|
|
704
|
+
}
|
|
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);
|
|
750
|
+
}
|
|
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
|
+
});
|
|
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
|
+
}
|
|
1032
|
+
|
|
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/recipients.ts
|
|
1057
|
+
var emailOf = (r) => {
|
|
1058
|
+
const raw = typeof r === "string" ? r : r?.email;
|
|
1059
|
+
const email = raw?.trim().toLowerCase();
|
|
1060
|
+
return email ? email : null;
|
|
1061
|
+
};
|
|
1062
|
+
function toEmails(to) {
|
|
1063
|
+
if (Array.isArray(to)) {
|
|
1064
|
+
const emails = [...new Set(to.map(emailOf).filter((e) => e !== null))];
|
|
1065
|
+
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");
|
|
1066
|
+
return emails;
|
|
1067
|
+
}
|
|
1068
|
+
const email = emailOf(to);
|
|
1069
|
+
if (!email) throw invalidRecipient("\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E35\u0E40\u0E21\u0E25");
|
|
1070
|
+
return email;
|
|
1071
|
+
}
|
|
1072
|
+
var invalidRecipient = (why) => new MailError(`notify(): ${why}`, { kind: "invalid", provider: "hub" });
|
|
1073
|
+
function toEmail(to) {
|
|
1074
|
+
const email = toEmails(to);
|
|
1075
|
+
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");
|
|
1076
|
+
return email;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/notify.ts
|
|
1080
|
+
var NOTIFY_TIMEOUT_MS = { notify: 65e3, recipient: 1e4 };
|
|
1081
|
+
function resolve(opts) {
|
|
1082
|
+
const env = opts.env ?? process.env;
|
|
1083
|
+
const apiKey = opts.apiKey ?? env.PI_NOTIFY_KEY;
|
|
1084
|
+
if (!apiKey) throw new MailError("notify(): PI_NOTIFY_KEY is required", { kind: "auth" });
|
|
1085
|
+
const base = (opts.url ?? env.PI_NOTIFY_URL ?? DEFAULT_HUB_URL).replace(/\/$/, "");
|
|
1086
|
+
return { apiKey, base, doFetch: opts.fetch ?? globalThis.fetch };
|
|
1087
|
+
}
|
|
1088
|
+
function hasNotifyKey(opts = {}) {
|
|
1089
|
+
return Boolean(opts.apiKey ?? (opts.env ?? process.env).PI_NOTIFY_KEY);
|
|
1090
|
+
}
|
|
1091
|
+
async function hubRequest(opts, path, init) {
|
|
1092
|
+
const { apiKey, base, doFetch } = resolve(opts);
|
|
1093
|
+
const ms = opts.timeoutMs ?? init.timeoutMs;
|
|
1094
|
+
const ac = new AbortController();
|
|
1095
|
+
const timer = setTimeout(() => ac.abort(), ms);
|
|
1096
|
+
const timedOut = () => new MailError(`Pi Notify timeout after ${ms}ms`, { kind: "network", provider: "hub" });
|
|
1097
|
+
let res;
|
|
1098
|
+
let data;
|
|
1099
|
+
try {
|
|
1100
|
+
res = await doFetch(`${base}${path}`, {
|
|
1101
|
+
method: init.method ?? "GET",
|
|
1102
|
+
headers: {
|
|
1103
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1104
|
+
...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
1105
|
+
},
|
|
1106
|
+
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
|
|
1107
|
+
signal: ac.signal
|
|
1108
|
+
});
|
|
1109
|
+
data = await res.json().catch(() => ({}));
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
if (ac.signal.aborted) throw timedOut();
|
|
1112
|
+
throw err;
|
|
1113
|
+
} finally {
|
|
1114
|
+
clearTimeout(timer);
|
|
1115
|
+
}
|
|
1116
|
+
if (ac.signal.aborted) throw timedOut();
|
|
1117
|
+
if (!res.ok) {
|
|
1118
|
+
const err = data?.error;
|
|
1119
|
+
const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
|
|
1120
|
+
throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
|
|
1121
|
+
kind,
|
|
1122
|
+
provider: "hub",
|
|
1123
|
+
status: res.status,
|
|
1124
|
+
cause: data,
|
|
1125
|
+
retryAfterSec: retryAfterOf(res, data)
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return data;
|
|
1129
|
+
}
|
|
1130
|
+
async function notify(input, opts = {}) {
|
|
1131
|
+
const data = await hubRequest(opts, "/api/v1/notify", {
|
|
1132
|
+
method: "POST",
|
|
1133
|
+
body: { ...input, to: toEmails(input.to) },
|
|
1134
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.notify
|
|
1135
|
+
});
|
|
1136
|
+
return { results: data.results, ...data.summary };
|
|
1137
|
+
}
|
|
1138
|
+
async function getRecipient(email, opts = {}) {
|
|
1139
|
+
return hubRequest(
|
|
1140
|
+
opts,
|
|
1141
|
+
`/api/v1/recipients/${encodeURIComponent(toEmail(email))}`,
|
|
1142
|
+
{
|
|
1143
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
async function createLink(email, channel = "telegram", opts = {}) {
|
|
1148
|
+
const { piUserId, ...rest } = opts;
|
|
1149
|
+
const link = await hubRequest(
|
|
1150
|
+
rest,
|
|
1151
|
+
`/api/v1/recipients/${encodeURIComponent(toEmail(email))}/link`,
|
|
1152
|
+
{
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
body: { channel, ...piUserId ? { piUserId } : {} },
|
|
1155
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
1156
|
+
}
|
|
1157
|
+
);
|
|
1158
|
+
const code = link.code ?? safeStartParam(link.url);
|
|
1159
|
+
return { ...link, code };
|
|
1160
|
+
}
|
|
1161
|
+
function safeStartParam(url) {
|
|
1162
|
+
try {
|
|
1163
|
+
return new URL(url).searchParams.get("start") ?? "";
|
|
1164
|
+
} catch {
|
|
1165
|
+
return "";
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
async function getChannelStatus(email, channel = "telegram", opts = {}) {
|
|
1169
|
+
const recipient2 = await getRecipient(email, opts);
|
|
1170
|
+
const found = recipient2.channels.find((c) => c.type === channel && c.enabled);
|
|
1171
|
+
return {
|
|
1172
|
+
linked: Boolean(found),
|
|
1173
|
+
label: found?.label ?? null,
|
|
1174
|
+
linkedAt: found?.linkedAt ?? null,
|
|
1175
|
+
manageUrl: recipient2.linkUrl
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
async function getPushConfig(opts = {}) {
|
|
1179
|
+
return hubRequest(opts, "/api/v1/push/config", { timeoutMs: NOTIFY_TIMEOUT_MS.recipient });
|
|
1180
|
+
}
|
|
1181
|
+
async function savePushSubscription(email, subscription, opts = {}) {
|
|
1182
|
+
const { label, piUserId, ...rest } = opts;
|
|
1183
|
+
return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
|
|
1184
|
+
method: "POST",
|
|
1185
|
+
body: { subscription, ...label ? { label } : {}, ...piUserId ? { piUserId } : {} },
|
|
1186
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
async function deletePushSubscription(email, endpoint, opts = {}) {
|
|
1190
|
+
return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
|
|
1191
|
+
method: "DELETE",
|
|
1192
|
+
body: { endpoint },
|
|
1193
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
async function listPushDevices(email, opts = {}) {
|
|
1197
|
+
return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
|
|
1198
|
+
timeoutMs: NOTIFY_TIMEOUT_MS.recipient
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/safe.ts
|
|
1203
|
+
var warnedDisabled = false;
|
|
1204
|
+
function warnDisabled(what, env) {
|
|
1205
|
+
if (env.NODE_ENV !== "production") {
|
|
1206
|
+
if (warnedDisabled) return;
|
|
1207
|
+
warnedDisabled = true;
|
|
1208
|
+
}
|
|
1209
|
+
console.warn(`[pi-notify] ${what} \u0E44\u0E21\u0E48\u0E17\u0E33\u0E07\u0E32\u0E19 \u2014 \u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07 PI_NOTIFY_KEY`);
|
|
1210
|
+
}
|
|
1211
|
+
function resetSafeWarnings() {
|
|
1212
|
+
warnedDisabled = false;
|
|
1213
|
+
}
|
|
1214
|
+
var messageOf = (err) => err instanceof Error ? err.message : String(err);
|
|
1215
|
+
async function notifySafe(input, opts = {}) {
|
|
1216
|
+
const none = { sent: 0, failed: 0, skipped: 0, results: [] };
|
|
1217
|
+
if (!hasNotifyKey(opts)) {
|
|
1218
|
+
warnDisabled("notify()", opts.env ?? process.env);
|
|
1219
|
+
return { ok: false, disabled: true, ...none };
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
const r = await notify(input, opts);
|
|
1223
|
+
return { ok: r.failed === 0, ...r };
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
const body = err instanceof MailError ? err.cause : void 0;
|
|
1226
|
+
return {
|
|
1227
|
+
ok: false,
|
|
1228
|
+
...none,
|
|
1229
|
+
...body?.error?.summary ?? {},
|
|
1230
|
+
results: body?.error?.results ?? [],
|
|
1231
|
+
error: messageOf(err)
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function noProvidersConfigured(err) {
|
|
1236
|
+
return err instanceof AllProvidersFailedError && err.attempts.length === 0 || err instanceof TypeError && /no providers configured/.test(err.message);
|
|
1237
|
+
}
|
|
1238
|
+
async function sendMailSafe(message) {
|
|
1239
|
+
try {
|
|
1240
|
+
const r = await sendMail(message);
|
|
1241
|
+
return { ok: true, provider: r.provider, id: r.id, failedOver: r.failedOver };
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
if (noProvidersConfigured(err)) {
|
|
1244
|
+
warnDisabled("sendMail()", process.env);
|
|
1245
|
+
return { ok: false, disabled: true };
|
|
1246
|
+
}
|
|
1247
|
+
return { ok: false, error: messageOf(err) };
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export {
|
|
1252
|
+
parseAddress,
|
|
1253
|
+
requireFrom,
|
|
1254
|
+
parseAddressList,
|
|
1255
|
+
formatAddress,
|
|
1256
|
+
createSmtpProvider,
|
|
1257
|
+
SMTP_PRESETS,
|
|
1258
|
+
createPresetSmtpProvider,
|
|
1259
|
+
providersFromEnv,
|
|
1260
|
+
MailError,
|
|
1261
|
+
AllProvidersFailedError,
|
|
1262
|
+
classifyError,
|
|
1263
|
+
DEFAULT_COOLDOWN,
|
|
1264
|
+
createConsoleProvider,
|
|
1265
|
+
DEFAULT_HUB_URL,
|
|
1266
|
+
createHubProvider,
|
|
1267
|
+
createResendProvider,
|
|
1268
|
+
createZeptoMailProvider,
|
|
1269
|
+
createMemoryStore,
|
|
1270
|
+
createMailer,
|
|
1271
|
+
configureMailer,
|
|
1272
|
+
getMailer,
|
|
1273
|
+
sendMail,
|
|
1274
|
+
listProviders,
|
|
1275
|
+
resetMailer,
|
|
1276
|
+
toEmails,
|
|
1277
|
+
NOTIFY_TIMEOUT_MS,
|
|
1278
|
+
hasNotifyKey,
|
|
1279
|
+
notify,
|
|
1280
|
+
getRecipient,
|
|
1281
|
+
createLink,
|
|
1282
|
+
getChannelStatus,
|
|
1283
|
+
getPushConfig,
|
|
1284
|
+
savePushSubscription,
|
|
1285
|
+
deletePushSubscription,
|
|
1286
|
+
listPushDevices,
|
|
1287
|
+
resetSafeWarnings,
|
|
1288
|
+
notifySafe,
|
|
1289
|
+
sendMailSafe
|
|
1290
|
+
};
|