@aidalinfo/email-mcp 0.1.1 → 0.2.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 +135 -65
- package/deploy/clients.example.json +4 -0
- package/deploy/compose.yaml +47 -0
- package/deploy/templates/rapport-hebdo@1.json +13 -0
- package/dist/attachments.js +203 -0
- package/dist/audit.js +4 -31
- package/dist/clients.js +144 -0
- package/dist/config.js +30 -20
- package/dist/errors.js +3 -1
- package/dist/http.js +131 -0
- package/dist/index.js +63 -14
- package/dist/ledger.js +128 -0
- package/dist/quota.js +77 -0
- package/dist/redact.js +23 -0
- package/dist/tem-client.js +5 -0
- package/dist/templates.js +118 -0
- package/dist/tools.js +349 -90
- package/dist/webhook.js +67 -0
- package/docs/exploitation.md +135 -0
- package/package.json +5 -3
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();
|
package/dist/tem-client.js
CHANGED
|
@@ -73,4 +73,9 @@ 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 listBlocklists(domainId, email) {
|
|
77
|
+
const qs = new URLSearchParams({ domain_id: domainId, email, page_size: "20" });
|
|
78
|
+
const res = await this.call("GET", `${this.base}/blocklists?${qs}`, "list TEM blocklists");
|
|
79
|
+
return (res.blocklists ?? []).filter((b) => b.domain_id === domainId && b.email.toLowerCase() === email);
|
|
80
|
+
}
|
|
76
81
|
}
|
|
@@ -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
|
+
}
|