@aidalinfo/email-mcp 0.1.1 → 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/README.md +148 -65
- package/deploy/clients.example.json +4 -0
- package/deploy/compose.yaml +50 -0
- package/deploy/templates/rapport-hebdo@1.json +13 -0
- package/dist/attachments.js +207 -0
- package/dist/audit.js +4 -31
- package/dist/clients.js +144 -0
- package/dist/config.js +42 -20
- package/dist/errors.js +3 -1
- package/dist/http.js +131 -0
- package/dist/index.js +65 -14
- package/dist/ledger.js +138 -0
- package/dist/quota.js +77 -0
- package/dist/redact.js +23 -0
- package/dist/smtp-client.js +70 -0
- package/dist/tem-client.js +14 -0
- package/dist/templates.js +118 -0
- package/dist/tools.js +440 -102
- package/dist/webhook.js +67 -0
- package/docs/exploitation.md +140 -0
- package/package.json +7 -3
package/dist/quota.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { EmailError } from "./errors.js";
|
|
2
|
+
export const HOUR_MS = 3_600_000;
|
|
3
|
+
export const DAY_MS = 24 * HOUR_MS;
|
|
4
|
+
/** Timestamps of the sends inside a sliding window, per process. */
|
|
5
|
+
export class SlidingWindow {
|
|
6
|
+
windowMs;
|
|
7
|
+
now;
|
|
8
|
+
sends = [];
|
|
9
|
+
constructor(windowMs, now = Date.now) {
|
|
10
|
+
this.windowMs = windowMs;
|
|
11
|
+
this.now = now;
|
|
12
|
+
}
|
|
13
|
+
count() {
|
|
14
|
+
const floor = this.now() - this.windowMs;
|
|
15
|
+
while (this.sends.length && this.sends[0] <= floor)
|
|
16
|
+
this.sends.shift();
|
|
17
|
+
return this.sends.length;
|
|
18
|
+
}
|
|
19
|
+
/** Takes a slot; returns a function giving it back (for a send that certainly did not leave). */
|
|
20
|
+
reserve() {
|
|
21
|
+
const t = this.now();
|
|
22
|
+
this.sends.push(t);
|
|
23
|
+
return () => {
|
|
24
|
+
const i = this.sends.lastIndexOf(t);
|
|
25
|
+
if (i >= 0)
|
|
26
|
+
this.sends.splice(i, 1);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Two windows (hour, day) for the whole server and two for each client: a send needs a slot in
|
|
32
|
+
* all four. Counters live in memory — a restart resets them (the ledger still blocks duplicates).
|
|
33
|
+
*/
|
|
34
|
+
export class Quotas {
|
|
35
|
+
limits;
|
|
36
|
+
now;
|
|
37
|
+
global;
|
|
38
|
+
clients = new Map();
|
|
39
|
+
constructor(limits, now = Date.now) {
|
|
40
|
+
this.limits = limits;
|
|
41
|
+
this.now = now;
|
|
42
|
+
this.global = this.pair();
|
|
43
|
+
}
|
|
44
|
+
pair() {
|
|
45
|
+
return { hour: new SlidingWindow(HOUR_MS, this.now), day: new SlidingWindow(DAY_MS, this.now) };
|
|
46
|
+
}
|
|
47
|
+
of(clientId) {
|
|
48
|
+
let p = this.clients.get(clientId);
|
|
49
|
+
if (!p)
|
|
50
|
+
this.clients.set(clientId, (p = this.pair()));
|
|
51
|
+
return p;
|
|
52
|
+
}
|
|
53
|
+
remaining(clientId, client) {
|
|
54
|
+
const c = this.of(clientId);
|
|
55
|
+
return {
|
|
56
|
+
client: { hour: Math.max(0, client.maxPerHour - c.hour.count()), day: Math.max(0, client.maxPerDay - c.day.count()) },
|
|
57
|
+
global: { hour: Math.max(0, this.limits.maxPerHour - this.global.hour.count()), day: Math.max(0, this.limits.maxPerDay - this.global.day.count()) },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Throws `rate_limited` naming the exhausted window. */
|
|
61
|
+
check(clientId, client) {
|
|
62
|
+
const r = this.remaining(clientId, client);
|
|
63
|
+
if (r.client.hour <= 0)
|
|
64
|
+
throw new EmailError("rate_limited", `rate_limited: client "${clientId}" reached ${client.maxPerHour} emails in the last hour`);
|
|
65
|
+
if (r.client.day <= 0)
|
|
66
|
+
throw new EmailError("rate_limited", `rate_limited: client "${clientId}" reached ${client.maxPerDay} emails in the last 24 hours`);
|
|
67
|
+
if (r.global.hour <= 0)
|
|
68
|
+
throw new EmailError("rate_limited", `rate_limited: ${this.limits.maxPerHour} emails already sent in the last hour (EMAIL_MAX_PER_HOUR)`);
|
|
69
|
+
if (r.global.day <= 0)
|
|
70
|
+
throw new EmailError("rate_limited", `rate_limited: ${this.limits.maxPerDay} emails already sent in the last 24 hours (EMAIL_MAX_PER_DAY)`);
|
|
71
|
+
}
|
|
72
|
+
reserve(clientId) {
|
|
73
|
+
const c = this.of(clientId);
|
|
74
|
+
const releases = [c.hour.reserve(), c.day.reserve(), this.global.hour.reserve(), this.global.day.reserve()];
|
|
75
|
+
return () => releases.forEach((r) => r());
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central redaction for everything that leaves the process as text (tool errors, audit lines,
|
|
3
|
+
* HTTP error bodies): registered secrets, auth headers and client keys, whatever their source.
|
|
4
|
+
*/
|
|
5
|
+
export class Redactor {
|
|
6
|
+
secrets = new Set();
|
|
7
|
+
/** Registers a secret value; values under 8 characters are ignored (too likely to hit plain text). */
|
|
8
|
+
add(secret) {
|
|
9
|
+
if (secret && secret.length >= 8)
|
|
10
|
+
this.secrets.add(secret);
|
|
11
|
+
}
|
|
12
|
+
apply(text) {
|
|
13
|
+
let t = text;
|
|
14
|
+
for (const s of this.secrets)
|
|
15
|
+
t = t.split(s).join("[REDACTED]");
|
|
16
|
+
return t
|
|
17
|
+
.replace(/(x-auth-token["']?\s*[:=]\s*["']?)[^"'\s,}]+/gi, "$1[REDACTED]")
|
|
18
|
+
.replace(/(authorization["']?\s*[:=]\s*["']?)(?:(bearer|basic)\s+)?[^"'\s,}]+/gi, (_, h, scheme) => `${h}${scheme ? `${scheme} ` : ""}[REDACTED]`)
|
|
19
|
+
.replace(/\bemk_[A-Za-z0-9_-]{16,}/g, "[REDACTED]");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Process-wide instance: the entry point registers the secrets it loads. */
|
|
23
|
+
export const redactor = new Redactor();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import nodemailer from "nodemailer";
|
|
2
|
+
import { EmailError } from "./errors.js";
|
|
3
|
+
import { redactSecret } from "./tem-client.js";
|
|
4
|
+
export const SMTP_HOST = "smtp.tem.scaleway.com";
|
|
5
|
+
/** TEM refuses an email above 50 MB over SMTP, headers, bodies and encoded attachments included. */
|
|
6
|
+
export const SMTP_MAX_EMAIL_BYTES = 50_000_000;
|
|
7
|
+
export const SMTP_TIMEOUT_MS = 120_000;
|
|
8
|
+
/** Stages before the message body leaves: a failure there means nothing was sent. */
|
|
9
|
+
const BEFORE_DATA = new Set(["CONN", "GREETING", "EHLO", "HELO", "LHLO", "STARTTLS", "AUTH", "AUTH PLAIN", "AUTH LOGIN", "MAIL FROM", "RCPT TO"]);
|
|
10
|
+
const NOT_CONNECTED = new Set(["ECONNECTION", "EDNS", "ETLS", "EAUTH", "EENVELOPE"]);
|
|
11
|
+
/** Same outcome rules as the API: a refusal is certain, a cut after the body started is unknown. */
|
|
12
|
+
export function smtpError(err, secret) {
|
|
13
|
+
const e = (err ?? {});
|
|
14
|
+
const detail = redactSecret(String(e.response ?? e.message ?? err), secret).slice(0, 300);
|
|
15
|
+
const code = e.responseCode;
|
|
16
|
+
if (e.code === "EAUTH" || code === 530 || code === 535) {
|
|
17
|
+
return new EmailError("permission_missing", `permission_missing: SMTP authentication refused — the API key needs Transactional Email permissions on the project (${detail})`, code);
|
|
18
|
+
}
|
|
19
|
+
if (code && code >= 400) {
|
|
20
|
+
if (code === 552)
|
|
21
|
+
return new EmailError("invalid_request", `invalid_request: TEM refused the email as too large over SMTP (${detail})`, code);
|
|
22
|
+
if (code < 500)
|
|
23
|
+
return new EmailError("transient", `transient: SMTP send refused for now (${detail}) — retry later`, code);
|
|
24
|
+
return new EmailError("invalid_request", `invalid_request: TEM refused the email over SMTP (${detail})`, code);
|
|
25
|
+
}
|
|
26
|
+
if (NOT_CONNECTED.has(e.code ?? "") || BEFORE_DATA.has(e.command ?? "")) {
|
|
27
|
+
return new EmailError("transient", `transient: SMTP connection to ${SMTP_HOST} failed before sending (${detail})`);
|
|
28
|
+
}
|
|
29
|
+
return new EmailError("unknown_outcome", `unknown_outcome: the SMTP send was interrupted (${detail}) — the email may have been sent; check email_status and the TEM console before sending again`);
|
|
30
|
+
}
|
|
31
|
+
/** TEM over SMTP: user = project id, password = the same secret key as the API. */
|
|
32
|
+
export class SmtpClient {
|
|
33
|
+
secretKey;
|
|
34
|
+
transport;
|
|
35
|
+
constructor(projectId, secretKey, port) {
|
|
36
|
+
this.secretKey = secretKey;
|
|
37
|
+
this.transport = nodemailer.createTransport({
|
|
38
|
+
host: SMTP_HOST,
|
|
39
|
+
port,
|
|
40
|
+
secure: port === 465 || port === 2465,
|
|
41
|
+
requireTLS: true,
|
|
42
|
+
auth: { user: projectId, pass: secretKey },
|
|
43
|
+
connectionTimeout: 30_000,
|
|
44
|
+
greetingTimeout: 30_000,
|
|
45
|
+
socketTimeout: SMTP_TIMEOUT_MS,
|
|
46
|
+
// contents come from the resolver only: never let the library read a path or a URL
|
|
47
|
+
disableFileAccess: true,
|
|
48
|
+
disableUrlAccess: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async send(m) {
|
|
52
|
+
try {
|
|
53
|
+
const info = await this.transport.sendMail({
|
|
54
|
+
from: m.from.name ? { name: m.from.name, address: m.from.email } : m.from.email,
|
|
55
|
+
replyTo: m.replyTo,
|
|
56
|
+
to: m.to,
|
|
57
|
+
cc: m.cc.length ? m.cc : undefined,
|
|
58
|
+
subject: m.subject,
|
|
59
|
+
text: m.text,
|
|
60
|
+
html: m.html,
|
|
61
|
+
attachments: m.attachments.map((a) => ({ filename: a.name, contentType: a.type, content: a.content })),
|
|
62
|
+
});
|
|
63
|
+
const list = (v) => (Array.isArray(v) ? v.map((x) => String(typeof x === "object" && x && "address" in x ? x.address : x).toLowerCase()) : []);
|
|
64
|
+
return { response: String(info.response ?? ""), accepted: list(info.accepted), rejected: list(info.rejected) };
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw smtpError(err, this.secretKey);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/tem-client.js
CHANGED
|
@@ -73,4 +73,18 @@ export class TemClient {
|
|
|
73
73
|
async getEmail(id) {
|
|
74
74
|
return this.call("GET", `${this.base}/emails/${encodeURIComponent(id)}`, "get email");
|
|
75
75
|
}
|
|
76
|
+
async listEmails(q) {
|
|
77
|
+
const qs = new URLSearchParams({ project_id: this.projectId, mail_from: q.mailFrom, since: q.since, page_size: "100" });
|
|
78
|
+
if (q.messageId)
|
|
79
|
+
qs.set("message_id", q.messageId);
|
|
80
|
+
if (q.subject)
|
|
81
|
+
qs.set("subject", q.subject);
|
|
82
|
+
const res = await this.call("GET", `${this.base}/emails?${qs}`, "list emails");
|
|
83
|
+
return (res.emails ?? []).filter((e) => e.project_id === this.projectId);
|
|
84
|
+
}
|
|
85
|
+
async listBlocklists(domainId, email) {
|
|
86
|
+
const qs = new URLSearchParams({ domain_id: domainId, email, page_size: "20" });
|
|
87
|
+
const res = await this.call("GET", `${this.base}/blocklists?${qs}`, "list TEM blocklists");
|
|
88
|
+
return (res.blocklists ?? []).filter((b) => b.domain_id === domainId && b.email.toLowerCase() === email);
|
|
89
|
+
}
|
|
76
90
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { EmailError } from "./errors.js";
|
|
4
|
+
export const TEMPLATE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
5
|
+
const VARIABLE_RE = /^[a-z][a-z0-9_]{0,39}$/;
|
|
6
|
+
const PLACEHOLDER_RE = /\{\{\s*([a-z][a-z0-9_]{0,39})\s*\}\}/g;
|
|
7
|
+
export const DEFAULT_VARIABLE_LENGTH = 1_000;
|
|
8
|
+
export const HARD_VARIABLE_LENGTH = 10_000;
|
|
9
|
+
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
10
|
+
function parseTemplate(raw, file) {
|
|
11
|
+
const t = raw;
|
|
12
|
+
const fail = (m) => {
|
|
13
|
+
throw new Error(`template ${file}: ${m}`);
|
|
14
|
+
};
|
|
15
|
+
if (!t || typeof t !== "object")
|
|
16
|
+
fail("not a JSON object");
|
|
17
|
+
if (typeof t.name !== "string" || !TEMPLATE_NAME_RE.test(t.name))
|
|
18
|
+
fail("name must match [a-z0-9-], 64 characters max");
|
|
19
|
+
if (!Number.isInteger(t.version) || t.version < 1)
|
|
20
|
+
fail("version must be a positive integer");
|
|
21
|
+
for (const k of ["subject", "text"])
|
|
22
|
+
if (typeof t[k] !== "string" || !t[k].trim())
|
|
23
|
+
fail(`${k} is required`);
|
|
24
|
+
if (t.html !== undefined && (typeof t.html !== "string" || !t.html.trim()))
|
|
25
|
+
fail("html must be a non-empty string when present");
|
|
26
|
+
if (/[\u0000-\u001f\u007f]/.test(t.subject))
|
|
27
|
+
fail("subject must not contain line breaks or control characters");
|
|
28
|
+
const variables = {};
|
|
29
|
+
for (const [name, spec] of Object.entries((t.variables ?? {}))) {
|
|
30
|
+
if (!VARIABLE_RE.test(name))
|
|
31
|
+
fail(`variable "${name}" must match [a-z][a-z0-9_]*`);
|
|
32
|
+
const maxLength = spec?.maxLength ?? DEFAULT_VARIABLE_LENGTH;
|
|
33
|
+
if (!Number.isInteger(maxLength) || maxLength < 1 || maxLength > HARD_VARIABLE_LENGTH)
|
|
34
|
+
fail(`variable "${name}": maxLength must be 1..${HARD_VARIABLE_LENGTH}`);
|
|
35
|
+
variables[name] = { required: spec?.required !== false, maxLength: maxLength, description: typeof spec?.description === "string" ? spec.description : undefined };
|
|
36
|
+
}
|
|
37
|
+
for (const k of ["subject", "text", "html"]) {
|
|
38
|
+
for (const m of (t[k] ?? "").matchAll(PLACEHOLDER_RE)) {
|
|
39
|
+
if (!Object.hasOwn(variables, m[1]))
|
|
40
|
+
fail(`${k} uses {{${m[1]}}}, which is not declared in variables`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (t.clients !== undefined && (!Array.isArray(t.clients) || t.clients.some((c) => typeof c !== "string")))
|
|
44
|
+
fail("clients must be a list of client ids");
|
|
45
|
+
return {
|
|
46
|
+
name: t.name,
|
|
47
|
+
version: t.version,
|
|
48
|
+
description: typeof t.description === "string" ? t.description : undefined,
|
|
49
|
+
subject: t.subject,
|
|
50
|
+
text: t.text,
|
|
51
|
+
html: t.html,
|
|
52
|
+
variables,
|
|
53
|
+
clients: t.clients,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Versioned templates for recurring sends, loaded once at startup from EMAIL_TEMPLATES_DIR
|
|
58
|
+
* (one JSON file per name and version). A send names the exact version: changing a template means
|
|
59
|
+
* adding a new version, never editing the content a routine was approved with.
|
|
60
|
+
*/
|
|
61
|
+
export class TemplateCatalog {
|
|
62
|
+
dir;
|
|
63
|
+
byKey = new Map();
|
|
64
|
+
constructor(dir) {
|
|
65
|
+
this.dir = dir;
|
|
66
|
+
if (!dir)
|
|
67
|
+
return;
|
|
68
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
throw new Error(`template ${f}: invalid JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
75
|
+
}
|
|
76
|
+
const t = parseTemplate(raw, f);
|
|
77
|
+
const key = `${t.name}@${t.version}`;
|
|
78
|
+
if (this.byKey.has(key))
|
|
79
|
+
throw new Error(`template ${f}: ${key} is defined twice`);
|
|
80
|
+
this.byKey.set(key, t);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
get configured() {
|
|
84
|
+
return !!this.dir;
|
|
85
|
+
}
|
|
86
|
+
list(clientId) {
|
|
87
|
+
return [...this.byKey.values()].filter((t) => !t.clients || t.clients.includes(clientId));
|
|
88
|
+
}
|
|
89
|
+
/** Renders a template for a client. Variables are escaped in the HTML body and refused with control characters in the subject. */
|
|
90
|
+
render(clientId, name, version, vars) {
|
|
91
|
+
if (!this.configured)
|
|
92
|
+
throw new EmailError("capability_unavailable", "capability_unavailable: no template directory is configured (EMAIL_TEMPLATES_DIR)");
|
|
93
|
+
const t = this.byKey.get(`${name}@${version}`);
|
|
94
|
+
if (!t || (t.clients && !t.clients.includes(clientId)))
|
|
95
|
+
throw new EmailError("not_found", `not_found: template ${name}@${version} does not exist for this client — see email_templates`);
|
|
96
|
+
for (const k of Object.keys(vars)) {
|
|
97
|
+
if (!Object.hasOwn(t.variables, k))
|
|
98
|
+
throw new EmailError("invalid_request", `invalid_request: template ${name}@${version} has no variable "${k.slice(0, 40)}"`);
|
|
99
|
+
}
|
|
100
|
+
for (const [k, spec] of Object.entries(t.variables)) {
|
|
101
|
+
const v = Object.hasOwn(vars, k) ? vars[k] : undefined;
|
|
102
|
+
if (v === undefined || v === "") {
|
|
103
|
+
if (spec.required)
|
|
104
|
+
throw new EmailError("invalid_request", `invalid_request: variable "${k}" is required by ${name}@${version}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (v.length > spec.maxLength)
|
|
108
|
+
throw new EmailError("invalid_request", `invalid_request: variable "${k}" is longer than ${spec.maxLength} characters`);
|
|
109
|
+
}
|
|
110
|
+
const fill = (s, esc) => s.replace(PLACEHOLDER_RE, (_, k) => esc(Object.hasOwn(vars, k) ? vars[k] : ""));
|
|
111
|
+
const subject = fill(t.subject, (v) => {
|
|
112
|
+
if (/[\u0000-\u001f\u007f]/.test(v))
|
|
113
|
+
throw new EmailError("invalid_request", "invalid_request: a variable used in the subject contains a line break or a control character");
|
|
114
|
+
return v;
|
|
115
|
+
});
|
|
116
|
+
return { subject, text: fill(t.text, (v) => v), html: t.html === undefined ? undefined : fill(t.html, escapeHtml) };
|
|
117
|
+
}
|
|
118
|
+
}
|