@suphark/notify 0.3.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 -0
- package/README.md +200 -0
- package/dist/index.d.ts +564 -0
- package/dist/index.js +1062 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
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
|
+
constructor(message, opts = {}) {
|
|
223
|
+
super(message, opts.cause === void 0 ? void 0 : { cause: opts.cause });
|
|
224
|
+
this.name = "MailError";
|
|
225
|
+
this.kind = opts.kind ?? "unknown";
|
|
226
|
+
this.provider = opts.provider;
|
|
227
|
+
this.status = opts.status;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
var AllProvidersFailedError = class extends MailError {
|
|
231
|
+
attempts;
|
|
232
|
+
constructor(attempts) {
|
|
233
|
+
const tried = attempts.filter((a) => !a.skipped);
|
|
234
|
+
const summary = attempts.length === 0 ? "no providers configured" : attempts.map(
|
|
235
|
+
(a) => a.skipped ? `${a.provider}: skipped (${a.skipped})` : `${a.provider}: ${a.error}`
|
|
236
|
+
).join(" | ");
|
|
237
|
+
super(`All mail providers failed (${tried.length}/${attempts.length} tried): ${summary}`);
|
|
238
|
+
this.name = "AllProvidersFailedError";
|
|
239
|
+
this.attempts = attempts;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
var QUOTA_RE = /quota|limit exceeded|exceeded.*(limit|quota)|daily.*limit|monthly.*limit|sending limit|too many messages/i;
|
|
243
|
+
var RATE_RE = /rate.?limit|too many requests|throttl|slow down/i;
|
|
244
|
+
var AUTH_RE = /authenticat|credential|password|invalid.*(key|token)|api key|unauthori[sz]ed|forbidden|not verified|verify.*domain|domain.*verif/i;
|
|
245
|
+
var NET_CODE_RE = /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|ESOCKET|EDNS|ECONNECTION)/i;
|
|
246
|
+
var NET_RE = /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket|timed? ?out|network|fetch failed/i;
|
|
247
|
+
var INVALID_RE = /invalid.*(email|address|recipient)|no recipients|recipient.*(rejected|invalid)|malformed|missing.*(to|subject|from)/i;
|
|
248
|
+
function classifyError(err) {
|
|
249
|
+
if (err instanceof MailError) return { kind: err.kind, message: err.message, status: err.status };
|
|
250
|
+
const e = typeof err === "object" && err !== null ? err : {};
|
|
251
|
+
const message = String(e.message ?? e.response ?? err ?? "unknown error");
|
|
252
|
+
const code = typeof e.code === "string" ? e.code : void 0;
|
|
253
|
+
const status = [e.responseCode, e.status, e.statusCode].find(
|
|
254
|
+
(v) => typeof v === "number"
|
|
255
|
+
);
|
|
256
|
+
const text = `${code ?? ""} ${message} ${typeof e.response === "string" ? e.response : ""}`;
|
|
257
|
+
if (code && NET_CODE_RE.test(code)) return { kind: "network", message, status };
|
|
258
|
+
if (status === 552 || QUOTA_RE.test(text)) return { kind: "quota", message, status };
|
|
259
|
+
if (status === 429 || RATE_RE.test(text)) return { kind: "rate_limit", message, status };
|
|
260
|
+
if (status === 401 || status === 403 || status === 534 || status === 535 || AUTH_RE.test(text))
|
|
261
|
+
return { kind: "auth", message, status };
|
|
262
|
+
if (INVALID_RE.test(text)) return { kind: "invalid", message, status };
|
|
263
|
+
if (status !== void 0) {
|
|
264
|
+
if ([400, 422, 550, 553, 554].includes(status)) return { kind: "invalid", message, status };
|
|
265
|
+
if (status >= 420 && status < 460) return { kind: "server", message, status };
|
|
266
|
+
if (status >= 500) return { kind: "server", message, status };
|
|
267
|
+
}
|
|
268
|
+
if (NET_RE.test(text)) return { kind: "network", message, status };
|
|
269
|
+
return { kind: "unknown", message, status };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/health.ts
|
|
273
|
+
var DEFAULT_COOLDOWN = {
|
|
274
|
+
network: 60,
|
|
275
|
+
server: 60,
|
|
276
|
+
unknown: 60,
|
|
277
|
+
rate_limit: 120,
|
|
278
|
+
auth: 15 * 60,
|
|
279
|
+
quota: 60 * 60,
|
|
280
|
+
invalid: 0
|
|
281
|
+
// ความผิดของข้อความ ไม่พัก provider
|
|
282
|
+
};
|
|
283
|
+
var WINDOWS = [
|
|
284
|
+
{ window: "hour", key: "perHour", ttlSec: 3600 },
|
|
285
|
+
{ window: "day", key: "perDay", ttlSec: 86400 },
|
|
286
|
+
{ window: "month", key: "perMonth", ttlSec: 32 * 86400 }
|
|
287
|
+
];
|
|
288
|
+
function windowId(window, now = /* @__PURE__ */ new Date()) {
|
|
289
|
+
const iso = now.toISOString();
|
|
290
|
+
if (window === "hour") return iso.slice(0, 13);
|
|
291
|
+
if (window === "day") return iso.slice(0, 10);
|
|
292
|
+
return iso.slice(0, 7);
|
|
293
|
+
}
|
|
294
|
+
var Health = class {
|
|
295
|
+
store;
|
|
296
|
+
cooldown;
|
|
297
|
+
maxCooldownSec;
|
|
298
|
+
emit;
|
|
299
|
+
constructor(opts) {
|
|
300
|
+
this.store = opts.store;
|
|
301
|
+
this.cooldown = { ...DEFAULT_COOLDOWN, ...opts.cooldown };
|
|
302
|
+
this.maxCooldownSec = opts.maxCooldownSec ?? 30 * 60;
|
|
303
|
+
this.emit = opts.onEvent ?? (() => {
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* เหตุผลที่ต้องข้าม provider นี้ตอนนี้ (null = ใช้ได้ และ **จองโควต้า** ไว้แล้ว 1 หน่วยทุก window)
|
|
308
|
+
* จองก่อนส่ง (incr แล้วดูค่าที่ได้) เพื่อให้คำขอพร้อมกันหลายตัวไม่ทะลุเพดาน — ส่งพังต้องเรียก release
|
|
309
|
+
*/
|
|
310
|
+
async check(p, quota) {
|
|
311
|
+
const cd = await this.getCooldown(p.name);
|
|
312
|
+
if (cd) return "cooldown";
|
|
313
|
+
const reserved = [];
|
|
314
|
+
for (const w of WINDOWS) {
|
|
315
|
+
const limit = quota?.[w.key];
|
|
316
|
+
if (limit === void 0) continue;
|
|
317
|
+
const key = `q:${p.name}:${w.window}:${windowId(w.window)}`;
|
|
318
|
+
const used = await this.store.incr(key, w.ttlSec);
|
|
319
|
+
reserved.push(key);
|
|
320
|
+
if (used > limit) {
|
|
321
|
+
await this.releaseKeys(reserved);
|
|
322
|
+
return "quota";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
/** คืนโควต้าที่ check() จองไว้ (เรียกเมื่อส่งไม่สำเร็จ) */
|
|
328
|
+
async release(p, quota) {
|
|
329
|
+
const keys = WINDOWS.filter((w) => quota?.[w.key] !== void 0).map(
|
|
330
|
+
(w) => `q:${p.name}:${w.window}:${windowId(w.window)}`
|
|
331
|
+
);
|
|
332
|
+
await this.releaseKeys(keys);
|
|
333
|
+
}
|
|
334
|
+
async releaseKeys(keys) {
|
|
335
|
+
if (!this.store.decr) return;
|
|
336
|
+
for (const key of keys) await this.store.decr(key);
|
|
337
|
+
}
|
|
338
|
+
async recordSuccess(p) {
|
|
339
|
+
const failures = await this.store.get(`fail:${p.name}`);
|
|
340
|
+
if (failures) {
|
|
341
|
+
await this.store.del(`fail:${p.name}`);
|
|
342
|
+
await this.store.del(`cd:${p.name}`);
|
|
343
|
+
this.emit({ type: "recovered", provider: p.name });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async recordFailure(p, kind) {
|
|
347
|
+
const base = this.cooldown[kind];
|
|
348
|
+
if (!base) return;
|
|
349
|
+
const failures = await this.store.incr(`fail:${p.name}`, 86400);
|
|
350
|
+
const sec = Math.min(
|
|
351
|
+
base * 2 ** Math.min(failures - 1, 10),
|
|
352
|
+
Math.max(base, this.maxCooldownSec)
|
|
353
|
+
);
|
|
354
|
+
const until = Date.now() + sec * 1e3;
|
|
355
|
+
await this.store.set(
|
|
356
|
+
`cd:${p.name}`,
|
|
357
|
+
JSON.stringify({ until, kind }),
|
|
358
|
+
Math.ceil(sec)
|
|
359
|
+
);
|
|
360
|
+
this.emit({ type: "cooldown", provider: p.name, kind, untilMs: until, failures });
|
|
361
|
+
}
|
|
362
|
+
/** ล้าง cooldown/ตัวนับพัง (ไม่ล้างโควต้าที่ใช้ไปแล้ว) */
|
|
363
|
+
async reset(name) {
|
|
364
|
+
await this.store.del(`cd:${name}`);
|
|
365
|
+
await this.store.del(`fail:${name}`);
|
|
366
|
+
}
|
|
367
|
+
async status(p, quota) {
|
|
368
|
+
const cd = await this.getCooldown(p.name);
|
|
369
|
+
const usage = await this.usage(p, quota);
|
|
370
|
+
const failures = Number(await this.store.get(`fail:${p.name}`) ?? 0);
|
|
371
|
+
return {
|
|
372
|
+
name: p.name,
|
|
373
|
+
type: p.type,
|
|
374
|
+
available: !cd && usage.every((u) => u.used < u.limit),
|
|
375
|
+
cooldownUntil: cd?.until,
|
|
376
|
+
cooldownKind: cd?.kind,
|
|
377
|
+
failures,
|
|
378
|
+
quota: usage
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async getCooldown(name) {
|
|
382
|
+
const raw = await this.store.get(`cd:${name}`);
|
|
383
|
+
if (!raw) return null;
|
|
384
|
+
try {
|
|
385
|
+
const cd = JSON.parse(raw);
|
|
386
|
+
return cd.until > Date.now() ? cd : null;
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async usage(p, quota) {
|
|
392
|
+
const out = [];
|
|
393
|
+
for (const w of WINDOWS) {
|
|
394
|
+
const limit = quota?.[w.key];
|
|
395
|
+
if (limit === void 0) continue;
|
|
396
|
+
const used = Number(
|
|
397
|
+
await this.store.get(`q:${p.name}:${w.window}:${windowId(w.window)}`) ?? 0
|
|
398
|
+
);
|
|
399
|
+
out.push({ window: w.window, used, limit });
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// src/providers/console.ts
|
|
406
|
+
function createConsoleProvider(opts = {}) {
|
|
407
|
+
const name = opts.name ?? "console";
|
|
408
|
+
const log = opts.log ?? ((l) => console.log(l));
|
|
409
|
+
let seq = 0;
|
|
410
|
+
return {
|
|
411
|
+
name,
|
|
412
|
+
type: "console",
|
|
413
|
+
from: opts.from,
|
|
414
|
+
async send(msg) {
|
|
415
|
+
const id = `console-${Date.now()}-${++seq}`;
|
|
416
|
+
const lines = [
|
|
417
|
+
`[mailer:${name}] ${id}`,
|
|
418
|
+
` from: ${msg.from ? formatAddress(msg.from) : "(hub default)"}`,
|
|
419
|
+
` to: ${msg.to.map(formatAddress).join(", ")}`,
|
|
420
|
+
...msg.cc.length ? [` cc: ${msg.cc.map(formatAddress).join(", ")}`] : [],
|
|
421
|
+
...msg.bcc.length ? [` bcc: ${msg.bcc.map(formatAddress).join(", ")}`] : [],
|
|
422
|
+
` subject: ${msg.subject}`,
|
|
423
|
+
...msg.attachments.length ? [` files: ${msg.attachments.map((a) => a.filename).join(", ")}`] : [],
|
|
424
|
+
...opts.body ? [` ---`, msg.text ?? msg.html ?? "(empty)"] : []
|
|
425
|
+
];
|
|
426
|
+
log(lines.join("\n"));
|
|
427
|
+
return { id };
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/providers/hub.ts
|
|
433
|
+
var DEFAULT_HUB_URL = "https://notify.suphark.com";
|
|
434
|
+
function createHubProvider(opts) {
|
|
435
|
+
const name = opts.name ?? "hub";
|
|
436
|
+
const url = `${(opts.url ?? DEFAULT_HUB_URL).replace(/\/$/, "")}/api/v1/send`;
|
|
437
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
438
|
+
return {
|
|
439
|
+
name,
|
|
440
|
+
type: "hub",
|
|
441
|
+
from: opts.from,
|
|
442
|
+
optionalFrom: true,
|
|
443
|
+
async send(msg) {
|
|
444
|
+
const body = {
|
|
445
|
+
from: msg.from,
|
|
446
|
+
to: msg.to,
|
|
447
|
+
cc: msg.cc.length ? msg.cc : void 0,
|
|
448
|
+
bcc: msg.bcc.length ? msg.bcc : void 0,
|
|
449
|
+
replyTo: msg.replyTo.length ? msg.replyTo : void 0,
|
|
450
|
+
subject: msg.subject,
|
|
451
|
+
html: msg.html,
|
|
452
|
+
text: msg.text,
|
|
453
|
+
attachments: msg.attachments.length ? msg.attachments : void 0,
|
|
454
|
+
headers: Object.keys(msg.headers).length ? msg.headers : void 0,
|
|
455
|
+
tags: Object.keys(msg.tags).length ? msg.tags : void 0,
|
|
456
|
+
prefer: msg.prefer
|
|
457
|
+
};
|
|
458
|
+
const res = await doFetch(url, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
461
|
+
body: JSON.stringify(body)
|
|
462
|
+
});
|
|
463
|
+
const data = await res.json().catch(() => ({}));
|
|
464
|
+
if (!res.ok) {
|
|
465
|
+
const err = data.error;
|
|
466
|
+
const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
|
|
467
|
+
throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
|
|
468
|
+
kind,
|
|
469
|
+
provider: name,
|
|
470
|
+
status: res.status,
|
|
471
|
+
cause: data
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return { id: data.id };
|
|
475
|
+
},
|
|
476
|
+
async listProviders() {
|
|
477
|
+
const res = await doFetch(url.replace(/\/send$/, "/providers"), {
|
|
478
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` }
|
|
479
|
+
});
|
|
480
|
+
if (!res.ok)
|
|
481
|
+
throw new MailError(`Pi Notify ${res.status}: ${res.statusText}`, {
|
|
482
|
+
kind: res.status === 401 ? "auth" : "server",
|
|
483
|
+
provider: name,
|
|
484
|
+
status: res.status
|
|
485
|
+
});
|
|
486
|
+
const data = await res.json();
|
|
487
|
+
return data.providers.map((p) => ({ ...p, via: name }));
|
|
488
|
+
},
|
|
489
|
+
async verify() {
|
|
490
|
+
const res = await doFetch(url.replace(/\/send$/, "/status"), {
|
|
491
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` }
|
|
492
|
+
});
|
|
493
|
+
if (!res.ok) {
|
|
494
|
+
throw new MailError(`Pi Notify ${res.status}: ${res.statusText}`, {
|
|
495
|
+
kind: res.status === 401 ? "auth" : "server",
|
|
496
|
+
provider: name,
|
|
497
|
+
status: res.status
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/providers/resend.ts
|
|
505
|
+
function createResendProvider(opts) {
|
|
506
|
+
const name = opts.name ?? "resend";
|
|
507
|
+
const baseUrl = (opts.baseUrl ?? "https://api.resend.com").replace(/\/$/, "");
|
|
508
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
509
|
+
async function request(path, init) {
|
|
510
|
+
const res = await doFetch(`${baseUrl}${path}`, {
|
|
511
|
+
...init,
|
|
512
|
+
headers: {
|
|
513
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
514
|
+
"Content-Type": "application/json",
|
|
515
|
+
...init.headers ?? {}
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
const body = await res.json().catch(() => ({}));
|
|
519
|
+
if (!res.ok) {
|
|
520
|
+
const msg = `Resend ${res.status} ${body?.name ?? ""}: ${body?.message ?? res.statusText}`;
|
|
521
|
+
const errName = String(body?.name ?? "");
|
|
522
|
+
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";
|
|
523
|
+
throw new MailError(msg, { kind, provider: name, status: res.status, cause: body });
|
|
524
|
+
}
|
|
525
|
+
return body;
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
name,
|
|
529
|
+
type: "resend",
|
|
530
|
+
from: opts.from,
|
|
531
|
+
async send(msg) {
|
|
532
|
+
const body = {
|
|
533
|
+
from: formatAddress(requireFrom(msg, name)),
|
|
534
|
+
to: msg.to.map(formatAddress),
|
|
535
|
+
cc: msg.cc.length ? msg.cc.map(formatAddress) : void 0,
|
|
536
|
+
bcc: msg.bcc.length ? msg.bcc.map(formatAddress) : void 0,
|
|
537
|
+
reply_to: msg.replyTo.length ? msg.replyTo.map(formatAddress) : void 0,
|
|
538
|
+
subject: msg.subject,
|
|
539
|
+
html: msg.html,
|
|
540
|
+
text: msg.text,
|
|
541
|
+
headers: Object.keys(msg.headers).length ? msg.headers : void 0,
|
|
542
|
+
attachments: msg.attachments.length ? msg.attachments.map((a) => ({
|
|
543
|
+
filename: a.filename,
|
|
544
|
+
content: a.base64,
|
|
545
|
+
content_type: a.contentType
|
|
546
|
+
})) : void 0,
|
|
547
|
+
tags: Object.keys(msg.tags).length ? Object.entries(msg.tags).map(([k, v]) => ({ name: k, value: v })) : void 0
|
|
548
|
+
};
|
|
549
|
+
const data = await request("/emails", { method: "POST", body: JSON.stringify(body) });
|
|
550
|
+
return { id: data?.id };
|
|
551
|
+
},
|
|
552
|
+
async verify() {
|
|
553
|
+
const res = await doFetch(`${baseUrl}/emails/00000000-0000-4000-8000-000000000000`, {
|
|
554
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` }
|
|
555
|
+
});
|
|
556
|
+
if (res.ok || res.status === 404) return;
|
|
557
|
+
const body = await res.json().catch(() => ({}));
|
|
558
|
+
throw new MailError(
|
|
559
|
+
`Resend ${res.status} ${body?.name ?? ""}: ${body?.message ?? res.statusText}`,
|
|
560
|
+
{
|
|
561
|
+
kind: res.status === 401 || res.status === 403 ? "auth" : "server",
|
|
562
|
+
provider: name,
|
|
563
|
+
status: res.status
|
|
564
|
+
}
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/providers/zeptomail.ts
|
|
571
|
+
var addr = (a) => ({ address: a.email, name: a.name });
|
|
572
|
+
var recipient = (a) => ({ email_address: addr(a) });
|
|
573
|
+
function createZeptoMailProvider(opts) {
|
|
574
|
+
const name = opts.name ?? "zeptomail";
|
|
575
|
+
const baseUrl = (opts.baseUrl ?? `https://api.zeptomail.${opts.region ?? "com"}`).replace(
|
|
576
|
+
/\/$/,
|
|
577
|
+
""
|
|
578
|
+
);
|
|
579
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
580
|
+
return {
|
|
581
|
+
name,
|
|
582
|
+
type: "zeptomail",
|
|
583
|
+
from: opts.from,
|
|
584
|
+
async send(msg) {
|
|
585
|
+
const body = {
|
|
586
|
+
from: addr(requireFrom(msg, name)),
|
|
587
|
+
to: msg.to.map(recipient),
|
|
588
|
+
cc: msg.cc.length ? msg.cc.map(recipient) : void 0,
|
|
589
|
+
bcc: msg.bcc.length ? msg.bcc.map(recipient) : void 0,
|
|
590
|
+
reply_to: msg.replyTo.length ? msg.replyTo.map(addr) : void 0,
|
|
591
|
+
subject: msg.subject,
|
|
592
|
+
htmlbody: msg.html,
|
|
593
|
+
textbody: msg.text,
|
|
594
|
+
attachments: msg.attachments.length ? msg.attachments.map((a) => ({
|
|
595
|
+
name: a.filename,
|
|
596
|
+
content: a.base64,
|
|
597
|
+
mime_type: a.contentType ?? "application/octet-stream"
|
|
598
|
+
})) : void 0
|
|
599
|
+
};
|
|
600
|
+
const res = await doFetch(`${baseUrl}/v1.1/email`, {
|
|
601
|
+
method: "POST",
|
|
602
|
+
headers: {
|
|
603
|
+
Authorization: `Zoho-enczapikey ${opts.apiKey}`,
|
|
604
|
+
"Content-Type": "application/json",
|
|
605
|
+
Accept: "application/json"
|
|
606
|
+
},
|
|
607
|
+
body: JSON.stringify(body)
|
|
608
|
+
});
|
|
609
|
+
const data = await res.json().catch(() => ({}));
|
|
610
|
+
if (!res.ok) {
|
|
611
|
+
const code = String(data?.error?.code ?? "");
|
|
612
|
+
const detail = data?.error?.details?.[0]?.message ?? data?.error?.message ?? res.statusText;
|
|
613
|
+
const msg2 = `ZeptoMail ${res.status} ${code}: ${detail}`;
|
|
614
|
+
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";
|
|
615
|
+
throw new MailError(msg2, { kind, provider: name, status: res.status, cause: data });
|
|
616
|
+
}
|
|
617
|
+
return { id: data?.request_id };
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/providers/index.ts
|
|
623
|
+
function isProvider(x) {
|
|
624
|
+
return typeof x === "object" && x !== null && typeof x.send === "function" && typeof x.name === "string";
|
|
625
|
+
}
|
|
626
|
+
function buildProvider(cfg) {
|
|
627
|
+
if (isProvider(cfg)) return { provider: cfg, disabled: false };
|
|
628
|
+
const { type, name, from, quota, disabled, senderDomains, ...rest } = cfg;
|
|
629
|
+
const base = { name, from: from === void 0 ? void 0 : parseAddress(from) };
|
|
630
|
+
let provider;
|
|
631
|
+
switch (type) {
|
|
632
|
+
case "hub":
|
|
633
|
+
provider = createHubProvider({ ...rest, ...base });
|
|
634
|
+
break;
|
|
635
|
+
case "resend":
|
|
636
|
+
provider = createResendProvider({ ...rest, ...base });
|
|
637
|
+
break;
|
|
638
|
+
case "smtp":
|
|
639
|
+
provider = createSmtpProvider({ ...rest, ...base });
|
|
640
|
+
break;
|
|
641
|
+
case "zeptomail":
|
|
642
|
+
provider = createZeptoMailProvider({ ...rest, ...base });
|
|
643
|
+
break;
|
|
644
|
+
case "console":
|
|
645
|
+
provider = createConsoleProvider({ ...rest, ...base });
|
|
646
|
+
break;
|
|
647
|
+
case "custom": {
|
|
648
|
+
const custom = cfg.provider;
|
|
649
|
+
provider = base.from || name ? { ...custom, name: name ?? custom.name, from: base.from ?? custom.from } : custom;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
default:
|
|
653
|
+
if (type in SMTP_PRESETS) {
|
|
654
|
+
provider = createPresetSmtpProvider(type, { ...rest, ...base });
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
throw new TypeError(`Unknown mail provider type: "${String(type)}"`);
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
provider,
|
|
661
|
+
quota,
|
|
662
|
+
disabled: disabled ?? false,
|
|
663
|
+
senderDomains: senderDomains?.map((d) => d.trim().toLowerCase()).filter(Boolean)
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/stores/memory.ts
|
|
668
|
+
function createMemoryStore() {
|
|
669
|
+
const data = /* @__PURE__ */ new Map();
|
|
670
|
+
const read = (key) => {
|
|
671
|
+
const hit = data.get(key);
|
|
672
|
+
if (!hit) return void 0;
|
|
673
|
+
if (hit.expiresAt <= Date.now()) {
|
|
674
|
+
data.delete(key);
|
|
675
|
+
return void 0;
|
|
676
|
+
}
|
|
677
|
+
return hit;
|
|
678
|
+
};
|
|
679
|
+
let ops = 0;
|
|
680
|
+
const sweep = () => {
|
|
681
|
+
if (++ops % 500 !== 0) return;
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
for (const [k, v] of data) if (v.expiresAt <= now) data.delete(k);
|
|
684
|
+
};
|
|
685
|
+
return {
|
|
686
|
+
async get(key) {
|
|
687
|
+
sweep();
|
|
688
|
+
return read(key)?.value ?? null;
|
|
689
|
+
},
|
|
690
|
+
async set(key, value, ttlSec) {
|
|
691
|
+
sweep();
|
|
692
|
+
data.set(key, { value, expiresAt: Date.now() + ttlSec * 1e3 });
|
|
693
|
+
},
|
|
694
|
+
async incr(key, ttlSec) {
|
|
695
|
+
sweep();
|
|
696
|
+
const hit = read(key);
|
|
697
|
+
if (!hit) {
|
|
698
|
+
data.set(key, { value: "1", expiresAt: Date.now() + ttlSec * 1e3 });
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
const next = Number(hit.value) + 1;
|
|
702
|
+
hit.value = String(next);
|
|
703
|
+
return next;
|
|
704
|
+
},
|
|
705
|
+
async decr(key) {
|
|
706
|
+
const hit = read(key);
|
|
707
|
+
if (hit) hit.value = String(Math.max(0, Number(hit.value) - 1));
|
|
708
|
+
},
|
|
709
|
+
async del(key) {
|
|
710
|
+
data.delete(key);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/mailer.ts
|
|
716
|
+
var timeoutError = (name, ms) => new MailError(`${name}: send timed out after ${ms}ms`, { kind: "network", provider: name });
|
|
717
|
+
function withTimeout(p, ms, name) {
|
|
718
|
+
return new Promise((resolve2, reject) => {
|
|
719
|
+
const t = setTimeout(() => reject(timeoutError(name, ms)), ms);
|
|
720
|
+
p.then(
|
|
721
|
+
(v) => {
|
|
722
|
+
clearTimeout(t);
|
|
723
|
+
resolve2(v);
|
|
724
|
+
},
|
|
725
|
+
(e) => {
|
|
726
|
+
clearTimeout(t);
|
|
727
|
+
reject(e);
|
|
728
|
+
}
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
function toBase64(content, encoding) {
|
|
733
|
+
if (Buffer.isBuffer(content)) return content.toString("base64");
|
|
734
|
+
return encoding === "base64" ? content : Buffer.from(content, "utf8").toString("base64");
|
|
735
|
+
}
|
|
736
|
+
function debugLogger(e) {
|
|
737
|
+
const tag = "[mailer]";
|
|
738
|
+
switch (e.type) {
|
|
739
|
+
case "sent":
|
|
740
|
+
console.log(
|
|
741
|
+
`${tag} sent via ${e.provider} (${e.ms}ms)${e.failedOver ? " [failover]" : ""}`,
|
|
742
|
+
e.id ?? ""
|
|
743
|
+
);
|
|
744
|
+
break;
|
|
745
|
+
case "failed":
|
|
746
|
+
console.warn(`${tag} ${e.provider} failed [${e.kind}] ${e.error} (${e.ms}ms)`);
|
|
747
|
+
break;
|
|
748
|
+
case "skipped":
|
|
749
|
+
console.warn(`${tag} ${e.provider} skipped (${e.reason})`);
|
|
750
|
+
break;
|
|
751
|
+
case "cooldown":
|
|
752
|
+
console.warn(
|
|
753
|
+
`${tag} ${e.provider} cooldown [${e.kind}] until ${new Date(e.untilMs).toISOString()} (failures=${e.failures})`
|
|
754
|
+
);
|
|
755
|
+
break;
|
|
756
|
+
case "recovered":
|
|
757
|
+
console.log(`${tag} ${e.provider} recovered`);
|
|
758
|
+
break;
|
|
759
|
+
case "exhausted":
|
|
760
|
+
console.error(`${tag} all providers failed`, e.attempts);
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function createMailer(options = {}) {
|
|
765
|
+
const env = options.env ?? process.env;
|
|
766
|
+
const entries = (options.providers ?? providersFromEnv(env)).map(buildProvider);
|
|
767
|
+
if (entries.length === 0) {
|
|
768
|
+
throw new TypeError(
|
|
769
|
+
"createMailer: no providers configured \u2014 pass { providers } or set MAIL_PROVIDERS / RESEND_API_KEY / SMTP_HOST"
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
const names = /* @__PURE__ */ new Set();
|
|
773
|
+
for (const e of entries) {
|
|
774
|
+
if (names.has(e.provider.name))
|
|
775
|
+
throw new TypeError(
|
|
776
|
+
`createMailer: duplicate provider name "${e.provider.name}" \u2014 set a unique name`
|
|
777
|
+
);
|
|
778
|
+
names.add(e.provider.name);
|
|
779
|
+
}
|
|
780
|
+
const fromRaw = options.from ?? env.MAIL_FROM ?? env.EMAIL_FROM;
|
|
781
|
+
const defaultFrom = fromRaw === void 0 ? void 0 : parseAddress(fromRaw);
|
|
782
|
+
const strategy = options.strategy ?? env.MAIL_STRATEGY ?? "priority";
|
|
783
|
+
const timeoutMs = options.timeoutMs ?? 2e4;
|
|
784
|
+
const debug = options.debug ?? /^(1|true)$/i.test(env.MAIL_DEBUG ?? "");
|
|
785
|
+
const emit = (e) => {
|
|
786
|
+
if (debug) debugLogger(e);
|
|
787
|
+
options.onEvent?.(e);
|
|
788
|
+
};
|
|
789
|
+
const health = new Health({
|
|
790
|
+
store: options.store ?? createMemoryStore(),
|
|
791
|
+
cooldown: options.cooldown,
|
|
792
|
+
maxCooldownSec: options.maxCooldownSec,
|
|
793
|
+
onEvent: emit
|
|
794
|
+
});
|
|
795
|
+
let rr = 0;
|
|
796
|
+
const order = () => {
|
|
797
|
+
if (strategy !== "round-robin" || entries.length < 2) return entries;
|
|
798
|
+
const start = rr++ % entries.length;
|
|
799
|
+
return [...entries.slice(start), ...entries.slice(0, start)];
|
|
800
|
+
};
|
|
801
|
+
const normalizeBase = (m) => {
|
|
802
|
+
try {
|
|
803
|
+
return {
|
|
804
|
+
requested: m.from !== void 0 ? parseAddress(m.from) : void 0,
|
|
805
|
+
to: parseAddressList(m.to),
|
|
806
|
+
cc: parseAddressList(m.cc),
|
|
807
|
+
bcc: parseAddressList(m.bcc),
|
|
808
|
+
replyTo: parseAddressList(m.replyTo),
|
|
809
|
+
subject: m.subject,
|
|
810
|
+
html: m.html,
|
|
811
|
+
text: m.text,
|
|
812
|
+
attachments: (m.attachments ?? []).map((a) => ({
|
|
813
|
+
filename: a.filename,
|
|
814
|
+
base64: toBase64(a.content, a.encoding),
|
|
815
|
+
contentType: a.contentType
|
|
816
|
+
})),
|
|
817
|
+
headers: m.headers ?? {},
|
|
818
|
+
tags: m.tags ?? {},
|
|
819
|
+
prefer: m.prefer
|
|
820
|
+
};
|
|
821
|
+
} catch (err) {
|
|
822
|
+
throw new MailError(err.message, { kind: "invalid", cause: err });
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
const resolveFrom = (requested, e) => {
|
|
826
|
+
const p = e.provider;
|
|
827
|
+
if (!p.from) {
|
|
828
|
+
const from = requested ?? defaultFrom;
|
|
829
|
+
if (!from && !p.optionalFrom)
|
|
830
|
+
throw new MailError(`No "from" address \u2014 set message.from, provider from, or MAIL_FROM`, {
|
|
831
|
+
kind: "invalid",
|
|
832
|
+
provider: p.name
|
|
833
|
+
});
|
|
834
|
+
return from;
|
|
835
|
+
}
|
|
836
|
+
if (!requested) return p.from;
|
|
837
|
+
const domain = requested.email.slice(requested.email.indexOf("@") + 1).toLowerCase();
|
|
838
|
+
const allowed = e.senderDomains?.length ? e.senderDomains.includes(domain) : requested.email.toLowerCase() === p.from.email.toLowerCase();
|
|
839
|
+
if (allowed) return requested;
|
|
840
|
+
return requested.name ? { name: requested.name, email: p.from.email } : p.from;
|
|
841
|
+
};
|
|
842
|
+
async function send(message) {
|
|
843
|
+
if (!message?.to || Array.isArray(message.to) && message.to.length === 0)
|
|
844
|
+
throw new MailError("Message has no recipients (to)", { kind: "invalid" });
|
|
845
|
+
if (!message.subject) throw new MailError("Message has no subject", { kind: "invalid" });
|
|
846
|
+
if (message.html === void 0 && message.text === void 0)
|
|
847
|
+
throw new MailError("Message needs html or text", { kind: "invalid" });
|
|
848
|
+
let candidates = order();
|
|
849
|
+
if (message.prefer) {
|
|
850
|
+
const i = candidates.findIndex((e) => e.provider.name === message.prefer);
|
|
851
|
+
if (i > 0) candidates = [candidates[i], ...candidates.filter((_, j) => j !== i)];
|
|
852
|
+
}
|
|
853
|
+
if (message.provider) {
|
|
854
|
+
candidates = candidates.filter((e) => e.provider.name === message.provider);
|
|
855
|
+
if (candidates.length === 0)
|
|
856
|
+
throw new MailError(`Unknown provider "${message.provider}"`, { kind: "invalid" });
|
|
857
|
+
}
|
|
858
|
+
const { requested, ...base } = normalizeBase(message);
|
|
859
|
+
const attempts = [];
|
|
860
|
+
for (const entry of candidates) {
|
|
861
|
+
const p = entry.provider;
|
|
862
|
+
const forced = message.provider !== void 0;
|
|
863
|
+
if (entry.disabled) {
|
|
864
|
+
attempts.push({ provider: p.name, ok: false, skipped: "disabled", ms: 0 });
|
|
865
|
+
emit({ type: "skipped", provider: p.name, reason: "disabled" });
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
const skip = forced ? null : await health.check(p, entry.quota);
|
|
869
|
+
if (skip) {
|
|
870
|
+
attempts.push({ provider: p.name, ok: false, skipped: skip, ms: 0 });
|
|
871
|
+
emit({ type: "skipped", provider: p.name, reason: skip });
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const started = Date.now();
|
|
875
|
+
try {
|
|
876
|
+
const normalized = { ...base, from: resolveFrom(requested, entry) };
|
|
877
|
+
const { id } = await withTimeout(p.send(normalized), timeoutMs, p.name);
|
|
878
|
+
const ms = Date.now() - started;
|
|
879
|
+
attempts.push({ provider: p.name, ok: true, ms });
|
|
880
|
+
await health.recordSuccess(p);
|
|
881
|
+
const failedOver = attempts.length > 1;
|
|
882
|
+
emit({ type: "sent", provider: p.name, id, ms, failedOver });
|
|
883
|
+
return { id, provider: p.name, failedOver, attempts };
|
|
884
|
+
} catch (err) {
|
|
885
|
+
const ms = Date.now() - started;
|
|
886
|
+
const { kind, message: error } = classifyError(err);
|
|
887
|
+
attempts.push({ provider: p.name, ok: false, error, kind, ms });
|
|
888
|
+
emit({ type: "failed", provider: p.name, kind, error, ms });
|
|
889
|
+
if (!forced) await health.release(p, entry.quota);
|
|
890
|
+
await health.recordFailure(p, kind);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
emit({ type: "exhausted", attempts });
|
|
894
|
+
throw new AllProvidersFailedError(attempts);
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
send,
|
|
898
|
+
providers: entries.map((e) => e.provider.name),
|
|
899
|
+
status: () => Promise.all(entries.map((e) => health.status(e.provider, e.quota))),
|
|
900
|
+
async listProviders() {
|
|
901
|
+
const out = [];
|
|
902
|
+
for (const e of entries) {
|
|
903
|
+
if (e.disabled) continue;
|
|
904
|
+
const st = await health.status(e.provider, e.quota);
|
|
905
|
+
if (e.provider.listProviders) {
|
|
906
|
+
try {
|
|
907
|
+
out.push(...await e.provider.listProviders());
|
|
908
|
+
continue;
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
out.push({ name: e.provider.name, type: e.provider.type, available: st.available });
|
|
913
|
+
}
|
|
914
|
+
return out;
|
|
915
|
+
},
|
|
916
|
+
async verify() {
|
|
917
|
+
const out = {};
|
|
918
|
+
await Promise.all(
|
|
919
|
+
entries.map(async (e) => {
|
|
920
|
+
try {
|
|
921
|
+
if (e.provider.verify)
|
|
922
|
+
await withTimeout(e.provider.verify(), timeoutMs, e.provider.name);
|
|
923
|
+
out[e.provider.name] = { ok: true };
|
|
924
|
+
} catch (err) {
|
|
925
|
+
out[e.provider.name] = { ok: false, error: classifyError(err).message };
|
|
926
|
+
}
|
|
927
|
+
})
|
|
928
|
+
);
|
|
929
|
+
return out;
|
|
930
|
+
},
|
|
931
|
+
async reset(name) {
|
|
932
|
+
for (const e of entries)
|
|
933
|
+
if (!name || e.provider.name === name) await health.reset(e.provider.name);
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/default.ts
|
|
939
|
+
var instance;
|
|
940
|
+
var pending;
|
|
941
|
+
function configureMailer(options) {
|
|
942
|
+
pending = options;
|
|
943
|
+
instance = createMailer(options);
|
|
944
|
+
return instance;
|
|
945
|
+
}
|
|
946
|
+
function getMailer() {
|
|
947
|
+
instance ??= createMailer(pending);
|
|
948
|
+
return instance;
|
|
949
|
+
}
|
|
950
|
+
function sendMail(message) {
|
|
951
|
+
return getMailer().send(message);
|
|
952
|
+
}
|
|
953
|
+
function listProviders() {
|
|
954
|
+
return getMailer().listProviders();
|
|
955
|
+
}
|
|
956
|
+
function resetMailer() {
|
|
957
|
+
instance = void 0;
|
|
958
|
+
pending = void 0;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// src/notify.ts
|
|
962
|
+
function resolve(opts) {
|
|
963
|
+
const env = opts.env ?? process.env;
|
|
964
|
+
const apiKey = opts.apiKey ?? env.PI_NOTIFY_KEY;
|
|
965
|
+
if (!apiKey) throw new MailError("notify(): PI_NOTIFY_KEY is required", { kind: "auth" });
|
|
966
|
+
const base = (opts.url ?? env.PI_NOTIFY_URL ?? DEFAULT_HUB_URL).replace(/\/$/, "");
|
|
967
|
+
return { apiKey, base, doFetch: opts.fetch ?? globalThis.fetch };
|
|
968
|
+
}
|
|
969
|
+
async function hubRequest(opts, path, init = {}) {
|
|
970
|
+
const { apiKey, base, doFetch } = resolve(opts);
|
|
971
|
+
const res = await doFetch(`${base}${path}`, {
|
|
972
|
+
method: init.method ?? "GET",
|
|
973
|
+
headers: {
|
|
974
|
+
Authorization: `Bearer ${apiKey}`,
|
|
975
|
+
...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
976
|
+
},
|
|
977
|
+
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
|
|
978
|
+
});
|
|
979
|
+
const data = await res.json().catch(() => ({}));
|
|
980
|
+
if (!res.ok) {
|
|
981
|
+
const err = data?.error;
|
|
982
|
+
const kind = err?.kind ?? (res.status === 401 || res.status === 403 ? "auth" : res.status === 429 ? "rate_limit" : res.status === 400 || res.status === 422 ? "invalid" : "server");
|
|
983
|
+
throw new MailError(`Pi Notify ${res.status}: ${err?.message ?? res.statusText}`, {
|
|
984
|
+
kind,
|
|
985
|
+
provider: "hub",
|
|
986
|
+
status: res.status,
|
|
987
|
+
cause: data
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
return data;
|
|
991
|
+
}
|
|
992
|
+
async function notify(input, opts = {}) {
|
|
993
|
+
const data = await hubRequest(opts, "/api/v1/notify", { method: "POST", body: input });
|
|
994
|
+
return { results: data.results, ...data.summary };
|
|
995
|
+
}
|
|
996
|
+
async function getRecipient(email, opts = {}) {
|
|
997
|
+
return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}`);
|
|
998
|
+
}
|
|
999
|
+
async function createLink(email, channel = "telegram", opts = {}) {
|
|
1000
|
+
return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/link`, {
|
|
1001
|
+
method: "POST",
|
|
1002
|
+
body: { channel }
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// src/stores/redis.ts
|
|
1007
|
+
function createRedisStore(client, opts = {}) {
|
|
1008
|
+
const prefix = opts.prefix ?? "mailer:";
|
|
1009
|
+
const k = (key) => prefix + key;
|
|
1010
|
+
return {
|
|
1011
|
+
async get(key) {
|
|
1012
|
+
const v = await client.get(k(key));
|
|
1013
|
+
return v === null || v === void 0 ? null : String(v);
|
|
1014
|
+
},
|
|
1015
|
+
async set(key, value, ttlSec) {
|
|
1016
|
+
await client.set(k(key), value);
|
|
1017
|
+
await client.expire(k(key), ttlSec);
|
|
1018
|
+
},
|
|
1019
|
+
async incr(key, ttlSec) {
|
|
1020
|
+
const n = await client.incr(k(key));
|
|
1021
|
+
if (n === 1) await client.expire(k(key), ttlSec);
|
|
1022
|
+
return n;
|
|
1023
|
+
},
|
|
1024
|
+
async decr(key) {
|
|
1025
|
+
const n = await client.decr(k(key));
|
|
1026
|
+
if (n < 0) await client.del(k(key));
|
|
1027
|
+
},
|
|
1028
|
+
async del(key) {
|
|
1029
|
+
await client.del(k(key));
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
export {
|
|
1034
|
+
AllProvidersFailedError,
|
|
1035
|
+
DEFAULT_COOLDOWN,
|
|
1036
|
+
DEFAULT_HUB_URL,
|
|
1037
|
+
MailError,
|
|
1038
|
+
SMTP_PRESETS,
|
|
1039
|
+
classifyError,
|
|
1040
|
+
configureMailer,
|
|
1041
|
+
createConsoleProvider,
|
|
1042
|
+
createHubProvider,
|
|
1043
|
+
createLink,
|
|
1044
|
+
createMailer,
|
|
1045
|
+
createMemoryStore,
|
|
1046
|
+
createPresetSmtpProvider,
|
|
1047
|
+
createRedisStore,
|
|
1048
|
+
createResendProvider,
|
|
1049
|
+
createSmtpProvider,
|
|
1050
|
+
createZeptoMailProvider,
|
|
1051
|
+
formatAddress,
|
|
1052
|
+
getMailer,
|
|
1053
|
+
getRecipient,
|
|
1054
|
+
listProviders,
|
|
1055
|
+
notify,
|
|
1056
|
+
parseAddress,
|
|
1057
|
+
parseAddressList,
|
|
1058
|
+
providersFromEnv,
|
|
1059
|
+
requireFrom,
|
|
1060
|
+
resetMailer,
|
|
1061
|
+
sendMail
|
|
1062
|
+
};
|