@aidalinfo/email-mcp 0.2.0 → 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 +16 -3
- package/deploy/compose.yaml +3 -0
- package/dist/attachments.js +7 -3
- package/dist/config.js +12 -0
- package/dist/index.js +6 -4
- package/dist/ledger.js +11 -1
- package/dist/smtp-client.js +70 -0
- package/dist/tem-client.js +9 -0
- package/dist/tools.js +108 -29
- package/docs/exploitation.md +7 -2
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -16,8 +16,18 @@ Agent Hermes B ─┤
|
|
|
16
16
|
│ X-Auth-Token (dedicated IAM application, TEM on one project) ▲
|
|
17
17
|
▼ │ webhook (Topics and Events)
|
|
18
18
|
Scaleway TEM /transactional-email/v1alpha1/regions/{region}/… ───────────┘
|
|
19
|
+
smtp.tem.scaleway.com:2465 (emails above the 2 MB of the API)
|
|
19
20
|
```
|
|
20
21
|
|
|
22
|
+
**Two ways out.** The TEM API refuses any email above **2 MB** (attachments included, base64
|
|
23
|
+
counted, not raisable); TEM over SMTP accepts **50 MB**. By default (`EMAIL_SEND_VIA=auto`) an email
|
|
24
|
+
leaves through the API up to ~1.9 MB and through SMTP above, with the same credentials (SMTP user =
|
|
25
|
+
project id, password = the secret key) — attachments then go up to **35 MB** in total. `api` keeps
|
|
26
|
+
everything on the API (2 MB), `smtp` sends everything over SMTP. SMTP returns no TEM id:
|
|
27
|
+
`email_status` with the `idempotency_key` finds them once TEM lists the email (by the message id of
|
|
28
|
+
the SMTP reply, else by sender, subject and date), usually within a minute; webhook events of an
|
|
29
|
+
SMTP send are kept from then on.
|
|
30
|
+
|
|
21
31
|
Guarantees, all enforced server-side:
|
|
22
32
|
|
|
23
33
|
- **The sender is configuration, not an argument.** `EMAIL_FROM` / `EMAIL_FROM_NAME` / `EMAIL_REPLY_TO`
|
|
@@ -56,8 +66,9 @@ Guarantees, all enforced server-side:
|
|
|
56
66
|
key, client keys, source tokens, webhook secret, Authorization headers).
|
|
57
67
|
- **Bounded reads:** `email_status` (own sends only, 10 emails per key, 5 last attempts, 20 events),
|
|
58
68
|
`email_suppressions` (20 allowed addresses at most — no probing of arbitrary addresses).
|
|
59
|
-
- **No raw API tool**: the server makes exactly
|
|
60
|
-
an email, read an email, read the blocklist of one
|
|
69
|
+
- **No raw API tool**: the server makes exactly five Scaleway API calls (find the sender domain,
|
|
70
|
+
create an email, read an email, list its own emails after an SMTP send, read the blocklist of one
|
|
71
|
+
address), plus the SMTP submission of emails above 2 MB.
|
|
61
72
|
|
|
62
73
|
## Tools
|
|
63
74
|
|
|
@@ -160,7 +171,9 @@ startup (fail-closed) instead of guessing.
|
|
|
160
171
|
| `EMAIL_TEMPLATES_DIR` | | | `*.json` templates, loaded at startup |
|
|
161
172
|
| `EMAIL_ATTACHMENT_SOURCES` | | | JSON `[{id, baseUrl (https), tokenEnv?, authScheme?, clients?}]` |
|
|
162
173
|
| `EMAIL_ATTACHMENT_TYPES` | | pdf, png, jpeg, txt, csv | also docx, xlsx |
|
|
163
|
-
| `
|
|
174
|
+
| `EMAIL_SEND_VIA` | | `auto` | `auto` (API ≤ 2 MB, SMTP above), `api`, `smtp` |
|
|
175
|
+
| `EMAIL_SMTP_PORT` | | 2465 | 2465 / 465 (TLS), 2587 / 587 (STARTTLS) — Scaleway Instances block 465 and 587 by default |
|
|
176
|
+
| `EMAIL_MAX_ATTACHMENTS` / `EMAIL_MAX_ATTACHMENT_BYTES` | | 5 / 35 000 000 (2 000 000 with `api`) | caps 10 / 35 000 000 (10 000 000 with `api`), total |
|
|
164
177
|
| `EMAIL_MCP_TRANSPORT` | | `stdio` | `http` in the image |
|
|
165
178
|
| `EMAIL_MCP_HOST` / `EMAIL_MCP_PORT` | | `127.0.0.1` / 8080 | `0.0.0.0` in the image |
|
|
166
179
|
| `EMAIL_MCP_CLIENTS_FILE` or `EMAIL_MCP_CLIENTS` | HTTP | | clients JSON (below) |
|
package/deploy/compose.yaml
CHANGED
|
@@ -19,6 +19,9 @@ services:
|
|
|
19
19
|
EMAIL_FROM: noreply@pulsemyit.fr
|
|
20
20
|
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Pulse myIT}
|
|
21
21
|
EMAIL_REGION: ${EMAIL_REGION:-fr-par}
|
|
22
|
+
# API up to 2 MB per email, SMTP (port 2465, outbound) above — see README
|
|
23
|
+
EMAIL_SEND_VIA: ${EMAIL_SEND_VIA:-auto}
|
|
24
|
+
EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-2465}
|
|
22
25
|
# required, no default — the container exits with a clear message when one is missing
|
|
23
26
|
SCW_SECRET_KEY: ${SCW_SECRET_KEY:?SCW_SECRET_KEY is required}
|
|
24
27
|
EMAIL_PROJECT_ID: ${EMAIL_PROJECT_ID:?EMAIL_PROJECT_ID is required}
|
package/dist/attachments.js
CHANGED
|
@@ -17,7 +17,9 @@ export const DEFAULT_MAX_ATTACHMENTS = 5;
|
|
|
17
17
|
export const HARD_MAX_ATTACHMENTS = 10;
|
|
18
18
|
export const DEFAULT_MAX_ATTACHMENT_BYTES = 2_000_000;
|
|
19
19
|
export const HARD_MAX_ATTACHMENT_BYTES = 10_000_000;
|
|
20
|
-
|
|
20
|
+
/** With SMTP (TEM: 50 MB per email): 35 MB of files, ~47 MB once base64-encoded, leaves room for the bodies. */
|
|
21
|
+
export const SMTP_MAX_ATTACHMENT_BYTES = 35_000_000;
|
|
22
|
+
export const FETCH_TIMEOUT_MS = 60_000;
|
|
21
23
|
const SOURCE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
22
24
|
/** Path segments starting with a letter or digit: no "..", no "/x", no "//", no query. */
|
|
23
25
|
export const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
|
|
@@ -26,7 +28,7 @@ const FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._()-]{0,99}$/;
|
|
|
26
28
|
* EMAIL_ATTACHMENT_SOURCES: JSON list of `{ "id", "baseUrl", "tokenEnv"?, "authScheme"?, "clients"? }`.
|
|
27
29
|
* The token itself never sits in that JSON: `tokenEnv` names the env var holding it.
|
|
28
30
|
*/
|
|
29
|
-
export function loadAttachmentConfig(env, warnings = []) {
|
|
31
|
+
export function loadAttachmentConfig(env, warnings = [], sendVia = "api") {
|
|
30
32
|
const raw = env.EMAIL_ATTACHMENT_SOURCES?.trim();
|
|
31
33
|
const sources = [];
|
|
32
34
|
if (raw) {
|
|
@@ -81,7 +83,9 @@ export function loadAttachmentConfig(env, warnings = []) {
|
|
|
81
83
|
sources,
|
|
82
84
|
types,
|
|
83
85
|
maxCount: intIn(env.EMAIL_MAX_ATTACHMENTS?.trim() || undefined, "EMAIL_MAX_ATTACHMENTS", DEFAULT_MAX_ATTACHMENTS, HARD_MAX_ATTACHMENTS, warnings),
|
|
84
|
-
maxTotalBytes:
|
|
86
|
+
maxTotalBytes: sendVia === "api"
|
|
87
|
+
? intIn(env.EMAIL_MAX_ATTACHMENT_BYTES?.trim() || undefined, "EMAIL_MAX_ATTACHMENT_BYTES", DEFAULT_MAX_ATTACHMENT_BYTES, HARD_MAX_ATTACHMENT_BYTES, warnings)
|
|
88
|
+
: intIn(env.EMAIL_MAX_ATTACHMENT_BYTES?.trim() || undefined, "EMAIL_MAX_ATTACHMENT_BYTES", SMTP_MAX_ATTACHMENT_BYTES, SMTP_MAX_ATTACHMENT_BYTES, warnings),
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
const refused = (m) => new EmailError("attachment_refused", `attachment_refused: ${m}`);
|
package/dist/config.js
CHANGED
|
@@ -45,6 +45,10 @@ export const DEFAULT_MAX_PER_HOUR = 20;
|
|
|
45
45
|
export const HARD_MAX_PER_HOUR = 500;
|
|
46
46
|
export const DEFAULT_MAX_PER_DAY = 200;
|
|
47
47
|
export const HARD_MAX_PER_DAY = 5000;
|
|
48
|
+
/** How a message leaves: the TEM API (2 MB max per email, not raisable), SMTP (50 MB), or the API up to its limit then SMTP. */
|
|
49
|
+
export const SEND_VIA = ["auto", "api", "smtp"];
|
|
50
|
+
/** Implicit TLS (465, 2465) or STARTTLS (587, 2587). 2465 and 2587 stay open on Scaleway Instances, which block 465 and 587 by default. */
|
|
51
|
+
export const SMTP_PORTS = [2465, 465, 2587, 587];
|
|
48
52
|
const get = (env, k) => env[k]?.trim() || undefined;
|
|
49
53
|
export function intIn(raw, name, def, max, warnings) {
|
|
50
54
|
if (raw === undefined)
|
|
@@ -93,6 +97,12 @@ export function loadConfig(env) {
|
|
|
93
97
|
if (!allowlist.addresses.size && !allowlist.domains.size) {
|
|
94
98
|
throw new Error("EMAIL_ALLOWED_RECIPIENTS is empty — list the addresses and/or @domains the agent may write to");
|
|
95
99
|
}
|
|
100
|
+
const sendVia = (get(env, "EMAIL_SEND_VIA") ?? "auto");
|
|
101
|
+
if (!SEND_VIA.includes(sendVia))
|
|
102
|
+
throw new Error(`EMAIL_SEND_VIA must be one of ${SEND_VIA.join(", ")} (got "${sendVia}")`);
|
|
103
|
+
const smtpPort = Number(get(env, "EMAIL_SMTP_PORT") ?? SMTP_PORTS[0]);
|
|
104
|
+
if (!SMTP_PORTS.includes(smtpPort))
|
|
105
|
+
throw new Error(`EMAIL_SMTP_PORT must be one of ${SMTP_PORTS.join(", ")} (got "${get(env, "EMAIL_SMTP_PORT")}")`);
|
|
96
106
|
return {
|
|
97
107
|
secretKey,
|
|
98
108
|
projectId: projectId.toLowerCase(),
|
|
@@ -103,6 +113,8 @@ export function loadConfig(env) {
|
|
|
103
113
|
maxRecipients: intIn(get(env, "EMAIL_MAX_RECIPIENTS"), "EMAIL_MAX_RECIPIENTS", DEFAULT_MAX_RECIPIENTS, HARD_MAX_RECIPIENTS, warnings),
|
|
104
114
|
maxPerHour: intIn(get(env, "EMAIL_MAX_PER_HOUR"), "EMAIL_MAX_PER_HOUR", DEFAULT_MAX_PER_HOUR, HARD_MAX_PER_HOUR, warnings),
|
|
105
115
|
maxPerDay: intIn(get(env, "EMAIL_MAX_PER_DAY"), "EMAIL_MAX_PER_DAY", DEFAULT_MAX_PER_DAY, HARD_MAX_PER_DAY, warnings),
|
|
116
|
+
sendVia,
|
|
117
|
+
smtpPort,
|
|
106
118
|
killSwitchFile: get(env, "EMAIL_KILL_SWITCH_FILE"),
|
|
107
119
|
auditFile: get(env, "EMAIL_AUDIT_LOG_FILE"),
|
|
108
120
|
stateDir: get(env, "EMAIL_STATE_DIR"),
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { CLIENT_ID_RE, ClientRegistry, generateKey } from "./clients.js";
|
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
8
|
import { createHttpServer, loadHttpConfig } from "./http.js";
|
|
9
9
|
import { redactor } from "./redact.js";
|
|
10
|
+
import { SmtpClient } from "./smtp-client.js";
|
|
10
11
|
import { TemClient } from "./tem-client.js";
|
|
11
12
|
import { createService, localClient, registerTools, SERVER_INSTRUCTIONS } from "./tools.js";
|
|
12
13
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
@@ -30,15 +31,16 @@ async function main() {
|
|
|
30
31
|
throw new Error(`EMAIL_MCP_TRANSPORT must be stdio or http (got "${mode}")`);
|
|
31
32
|
const config = loadConfig(process.env);
|
|
32
33
|
redactor.add(config.secretKey);
|
|
33
|
-
const attachments = loadAttachmentConfig(process.env, config.warnings);
|
|
34
|
+
const attachments = loadAttachmentConfig(process.env, config.warnings, config.sendVia);
|
|
34
35
|
const tem = new TemClient(config.secretKey, config.projectId, config.region);
|
|
36
|
+
const smtp = config.sendVia === "api" ? undefined : new SmtpClient(config.projectId, config.secretKey, config.smtpPort);
|
|
35
37
|
const a = config.allowlist;
|
|
36
|
-
const summary = `sender: ${config.from.email}, TEM ${config.region}, allowlist: ${a.addresses.size} address(es) + ${a.domains.size} domain(s), ` +
|
|
38
|
+
const summary = `sender: ${config.from.email}, TEM ${config.region} via ${config.sendVia === "auto" ? `API ≤ 2 MB, SMTP :${config.smtpPort} above` : config.sendVia === "smtp" ? `SMTP :${config.smtpPort}` : "API"}, allowlist: ${a.addresses.size} address(es) + ${a.domains.size} domain(s), ` +
|
|
37
39
|
`${config.maxRecipients} recipients/email, ${config.maxPerHour}/hour, ${config.maxPerDay}/day, ` +
|
|
38
40
|
`kill switch: ${config.killSwitchFile ?? "not configured"}, attachment sources: ${attachments.sources.length}, ` +
|
|
39
41
|
`templates: ${config.templatesDir ?? "not configured"}, state: ${config.stateDir ?? "memory only"}`;
|
|
40
42
|
if (mode === "stdio") {
|
|
41
|
-
const svc = createService(config, tem, { attachments });
|
|
43
|
+
const svc = createService(config, tem, { attachments, smtp });
|
|
42
44
|
const server = new McpServer({ name: "email", version: pkg.version }, { instructions: SERVER_INSTRUCTIONS });
|
|
43
45
|
registerTools(server, { ...svc, client: localClient(config) });
|
|
44
46
|
for (const w of config.warnings)
|
|
@@ -52,7 +54,7 @@ async function main() {
|
|
|
52
54
|
if (!config.stateDir)
|
|
53
55
|
throw new Error("Missing required env var for HTTP: EMAIL_STATE_DIR (persistent volume for the send ledger)");
|
|
54
56
|
const clients = new ClientRegistry({ file: http.clientsFile, inline: http.clientsInline }, config);
|
|
55
|
-
const svc = createService(config, tem, { attachments, multiClient: true, webhookConfigured: !!http.webhookSecret });
|
|
57
|
+
const svc = createService(config, tem, { attachments, smtp, multiClient: true, webhookConfigured: !!http.webhookSecret });
|
|
56
58
|
const server = createHttpServer(svc, clients, http, pkg.version);
|
|
57
59
|
for (const w of config.warnings)
|
|
58
60
|
log(w);
|
package/dist/ledger.js
CHANGED
|
@@ -88,9 +88,19 @@ export class SendLedger {
|
|
|
88
88
|
return r;
|
|
89
89
|
}
|
|
90
90
|
/** After the provider answered. Never throws: the email has left, a disk error must not hide it. */
|
|
91
|
-
settle(r, state, emailIds = []) {
|
|
91
|
+
settle(r, state, emailIds = [], smtp) {
|
|
92
92
|
r.state = state;
|
|
93
93
|
r.emailIds = emailIds;
|
|
94
|
+
r.smtp = smtp;
|
|
95
|
+
r.updatedAt = new Date(this.now()).toISOString();
|
|
96
|
+
for (const id of emailIds)
|
|
97
|
+
this.owners.set(id, r);
|
|
98
|
+
this.safePersist();
|
|
99
|
+
}
|
|
100
|
+
/** The TEM ids of an SMTP send, once TEM lists them. */
|
|
101
|
+
attach(r, emailIds) {
|
|
102
|
+
r.emailIds = emailIds;
|
|
103
|
+
delete r.smtp;
|
|
94
104
|
r.updatedAt = new Date(this.now()).toISOString();
|
|
95
105
|
for (const id of emailIds)
|
|
96
106
|
this.owners.set(id, r);
|
|
@@ -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,6 +73,15 @@ 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
|
+
}
|
|
76
85
|
async listBlocklists(domainId, email) {
|
|
77
86
|
const qs = new URLSearchParams({ domain_id: domainId, email, page_size: "20" });
|
|
78
87
|
const res = await this.call("GET", `${this.base}/blocklists?${qs}`, "list TEM blocklists");
|
package/dist/tools.js
CHANGED
|
@@ -9,10 +9,13 @@ import { Quotas } from "./quota.js";
|
|
|
9
9
|
import { IDEMPOTENCY_KEY_RE, SendLedger } from "./ledger.js";
|
|
10
10
|
import { HARD_VARIABLE_LENGTH, TemplateCatalog } from "./templates.js";
|
|
11
11
|
import { AttachmentResolver, DEFAULT_MAX_ATTACHMENT_BYTES, DEFAULT_MAX_ATTACHMENTS, HARD_MAX_ATTACHMENTS } from "./attachments.js";
|
|
12
|
+
import { SMTP_MAX_EMAIL_BYTES } from "./smtp-client.js";
|
|
12
13
|
export const MAX_SUBJECT = 200;
|
|
13
14
|
export const MAX_TEXT = 100_000;
|
|
14
15
|
export const MAX_HTML = 200_000;
|
|
15
16
|
export const MAX_SUPPRESSION_LOOKUPS = 20;
|
|
17
|
+
/** TEM refuses an email above 2 MB through its API (not raisable); kept a little under for the JSON envelope. */
|
|
18
|
+
export const API_MAX_EMAIL_BYTES = 1_900_000;
|
|
16
19
|
/** Above this, the preview shows the start of the HTML only (the digest still covers all of it). */
|
|
17
20
|
const PREVIEW_HTML = 20_000;
|
|
18
21
|
export const SERVER_INSTRUCTIONS = "Sends email from ONE fixed sender (set by the operator, not by you) to an allowlist of recipients, through Scaleway Transactional Email. " +
|
|
@@ -29,6 +32,7 @@ export function createService(config, tem, opts = {}) {
|
|
|
29
32
|
return {
|
|
30
33
|
config,
|
|
31
34
|
tem,
|
|
35
|
+
smtp: opts.smtp,
|
|
32
36
|
confirmations: new Confirmations(),
|
|
33
37
|
audit: new AuditLogger(config.auditFile),
|
|
34
38
|
quotas: new Quotas(config),
|
|
@@ -51,7 +55,7 @@ const fence = (s) => {
|
|
|
51
55
|
return `${ticks}\n${s}\n${ticks}`;
|
|
52
56
|
};
|
|
53
57
|
const allowedFor = (ctx, address) => isAllowed(ctx.config.allowlist, address) && (!ctx.client.allowlist || isAllowed(ctx.client.allowlist, address));
|
|
54
|
-
/** Validates the arguments into the exact message that will be sent. Throws on any guard. */
|
|
58
|
+
/** Validates the arguments into the exact message that will be sent, and how it will leave. Throws on any guard. */
|
|
55
59
|
export async function buildMessage(ctx, a) {
|
|
56
60
|
const { config } = ctx;
|
|
57
61
|
if (!IDEMPOTENCY_KEY_RE.test(a.idempotency_key ?? "")) {
|
|
@@ -109,20 +113,34 @@ export async function buildMessage(ctx, a) {
|
|
|
109
113
|
if (html !== undefined && html.length > MAX_HTML)
|
|
110
114
|
throw new EmailError("invalid_request", `invalid_request: HTML body longer than ${MAX_HTML} characters`);
|
|
111
115
|
const files = await ctx.attachments.resolve(ctx.client.id, a.attachments ?? []);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
attachments: files.map(({ name, type, size, sha256 }) => ({ name, type, size, sha256 })),
|
|
123
|
-
},
|
|
124
|
-
files,
|
|
116
|
+
const msg = {
|
|
117
|
+
from: config.from,
|
|
118
|
+
replyTo: config.replyTo,
|
|
119
|
+
to,
|
|
120
|
+
cc,
|
|
121
|
+
subject,
|
|
122
|
+
text: body,
|
|
123
|
+
html,
|
|
124
|
+
template,
|
|
125
|
+
attachments: files.map(({ name, type, size, sha256 }) => ({ name, type, size, sha256 })),
|
|
125
126
|
};
|
|
127
|
+
return { msg, files, via: viaFor(ctx, msg) };
|
|
128
|
+
}
|
|
129
|
+
/** The TEM API up to its 2 MB, SMTP above (EMAIL_SEND_VIA=auto), or the one transport the operator chose. */
|
|
130
|
+
function viaFor(ctx, msg) {
|
|
131
|
+
const size = estimatedSize(msg);
|
|
132
|
+
const mode = ctx.config.sendVia;
|
|
133
|
+
if (mode === "api" || (mode === "auto" && size <= API_MAX_EMAIL_BYTES)) {
|
|
134
|
+
if (size > API_MAX_EMAIL_BYTES) {
|
|
135
|
+
throw new EmailError("invalid_request", `invalid_request: about ${size} bytes once encoded, above the ${API_MAX_EMAIL_BYTES}-byte limit of the TEM API (EMAIL_SEND_VIA=api) — lighten the attachments, or let the operator enable SMTP (EMAIL_SEND_VIA=auto)`);
|
|
136
|
+
}
|
|
137
|
+
return "api";
|
|
138
|
+
}
|
|
139
|
+
if (!ctx.smtp)
|
|
140
|
+
throw new EmailError("capability_unavailable", "capability_unavailable: this email needs SMTP (above the 2 MB of the TEM API) and SMTP is not configured");
|
|
141
|
+
if (size > SMTP_MAX_EMAIL_BYTES)
|
|
142
|
+
throw new EmailError("invalid_request", `invalid_request: about ${size} bytes once encoded, above the ${SMTP_MAX_EMAIL_BYTES}-byte limit of TEM over SMTP`);
|
|
143
|
+
return "smtp";
|
|
126
144
|
}
|
|
127
145
|
function header(m) {
|
|
128
146
|
const from = m.from.name ? `${m.from.name} <${m.from.email}>` : m.from.email;
|
|
@@ -191,7 +209,8 @@ export async function previewEmail(ctx, a) {
|
|
|
191
209
|
let msg;
|
|
192
210
|
let messageDigest;
|
|
193
211
|
try {
|
|
194
|
-
|
|
212
|
+
let via;
|
|
213
|
+
({ msg, via } = await buildMessage(ctx, a));
|
|
195
214
|
messageDigest = digest(msg);
|
|
196
215
|
const prior = ctx.ledger.find(ctx.client.id, a.idempotency_key);
|
|
197
216
|
if (prior)
|
|
@@ -212,6 +231,7 @@ export async function previewEmail(ctx, a) {
|
|
|
212
231
|
recipients: msg.to.length + msg.cc.length,
|
|
213
232
|
format: msg.html === undefined ? "text" : "text + html",
|
|
214
233
|
estimatedBytes: estimatedSize(msg),
|
|
234
|
+
transport: via === "smtp" ? "TEM over SMTP (above the 2 MB of the API)" : "TEM API",
|
|
215
235
|
messageDigest: `sha256:${messageDigest}`,
|
|
216
236
|
policy: { recipientsAllowed: true, senderDomain: domain.status, killSwitch: "inactive" },
|
|
217
237
|
quotaLeft: { client: q.client, server: q.global },
|
|
@@ -250,19 +270,35 @@ export async function sendEmail(ctx, a) {
|
|
|
250
270
|
throw killSwitchError(ctx);
|
|
251
271
|
const record = ctx.ledger.begin(ctx.client.id, a.idempotency_key, messageDigest);
|
|
252
272
|
const release = ctx.quotas.reserve(ctx.client.id);
|
|
253
|
-
let emails;
|
|
273
|
+
let emails = [];
|
|
274
|
+
let smtp;
|
|
275
|
+
// TEM lists the email after it arrived: look from a little before, for clock skew
|
|
276
|
+
const since = new Date(Date.now() - 5 * 60_000).toISOString();
|
|
254
277
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
278
|
+
if (built.via === "smtp") {
|
|
279
|
+
smtp = await ctx.smtp.send({
|
|
280
|
+
from: msg.from,
|
|
281
|
+
replyTo: msg.replyTo,
|
|
282
|
+
to: msg.to,
|
|
283
|
+
cc: msg.cc,
|
|
284
|
+
subject: msg.subject,
|
|
285
|
+
text: msg.text,
|
|
286
|
+
html: msg.html,
|
|
287
|
+
attachments: built.files.map((f) => ({ name: f.name, type: f.type, content: f.content })),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
else
|
|
291
|
+
emails = await ctx.tem.createEmail({
|
|
292
|
+
from: msg.from,
|
|
293
|
+
to: msg.to.map((email) => ({ email })),
|
|
294
|
+
cc: msg.cc.map((email) => ({ email })),
|
|
295
|
+
subject: msg.subject,
|
|
296
|
+
text: msg.text,
|
|
297
|
+
html: msg.html,
|
|
298
|
+
project_id: config.projectId,
|
|
299
|
+
additional_headers: msg.replyTo ? [{ key: "Reply-To", value: msg.replyTo }] : undefined,
|
|
300
|
+
attachments: built.files.length ? built.files.map((f) => ({ name: f.name, type: f.type, content: f.content.toString("base64") })) : undefined,
|
|
301
|
+
});
|
|
266
302
|
}
|
|
267
303
|
catch (err) {
|
|
268
304
|
// a send that may have left keeps its quota slot and burns its key
|
|
@@ -274,10 +310,18 @@ export async function sendEmail(ctx, a) {
|
|
|
274
310
|
}
|
|
275
311
|
throw err;
|
|
276
312
|
}
|
|
313
|
+
const q = ctx.quotas.remaining(ctx.client.id, ctx.client);
|
|
314
|
+
if (smtp) {
|
|
315
|
+
const recipients = smtp.accepted.length ? smtp.accepted : [...msg.to, ...msg.cc];
|
|
316
|
+
ctx.ledger.settle(record, "sent", [], { since, subject: msg.subject, recipients, messageId: UUID_IN.exec(smtp.response)?.[0].toLowerCase() });
|
|
317
|
+
ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: "sent", via: "smtp", smtpResponse: smtp.response.slice(0, 200), rejected: smtp.rejected.length ? smtp.rejected : undefined });
|
|
318
|
+
return text(`# email_send — accepted by Scaleway TEM over SMTP\n\n${fence(header(msg))}\n\n` +
|
|
319
|
+
json({ accepted: smtp.accepted, rejected: smtp.rejected.length ? smtp.rejected : undefined, smtpResponse: smtp.response.slice(0, 200) }) +
|
|
320
|
+
`\n\nSMTP does not return TEM email ids: follow delivery with email_status (idempotency_key), which finds them once TEM lists the email (usually within a minute). Quota left: ${q.client.hour} this hour, ${q.client.day} today.`);
|
|
321
|
+
}
|
|
277
322
|
const ids = emails.map((e) => e.id);
|
|
278
323
|
ctx.ledger.settle(record, "sent", ids);
|
|
279
324
|
ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: "sent", temEmailIds: ids });
|
|
280
|
-
const q = ctx.quotas.remaining(ctx.client.id, ctx.client);
|
|
281
325
|
return text(`# email_send — accepted by Scaleway TEM\n\n${fence(header(msg))}\n\n` +
|
|
282
326
|
json(emails.map((e) => ({ id: e.id, rcptTo: e.rcpt_to, rcptType: e.rcpt_type, status: e.status }))) +
|
|
283
327
|
`\n\nDelivery is asynchronous: follow it with email_status (emailId or idempotency_key). Quota left: ${q.client.hour} this hour, ${q.client.day} today.`);
|
|
@@ -290,6 +334,30 @@ export async function sendEmail(ctx, a) {
|
|
|
290
334
|
}
|
|
291
335
|
}
|
|
292
336
|
const addressOf = (s) => (/<([^>]+)>/.exec(s)?.[1] ?? s).trim().toLowerCase();
|
|
337
|
+
const UUID_IN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
338
|
+
/**
|
|
339
|
+
* SMTP does not return the TEM ids: look the email up by the TEM message id of the SMTP reply, or by
|
|
340
|
+
* sender, subject and date, one email per recipient, the oldest not already owned by another send.
|
|
341
|
+
* Nothing is attached until every recipient is listed.
|
|
342
|
+
*/
|
|
343
|
+
async function findSmtpIds(ctx, r) {
|
|
344
|
+
const s = r.smtp;
|
|
345
|
+
const from = ctx.config.from.email;
|
|
346
|
+
let list = s.messageId ? await ctx.tem.listEmails({ messageId: s.messageId, mailFrom: from, since: s.since }) : [];
|
|
347
|
+
if (!list.length)
|
|
348
|
+
list = await ctx.tem.listEmails({ mailFrom: from, subject: s.subject, since: s.since });
|
|
349
|
+
const candidates = list
|
|
350
|
+
.filter((e) => addressOf(e.mail_from ?? "") === from && e.subject === s.subject && !ctx.ledger.ownerOf(e.id))
|
|
351
|
+
.sort((x, y) => (x.created_at ?? "").localeCompare(y.created_at ?? ""));
|
|
352
|
+
const ids = [];
|
|
353
|
+
for (const rcpt of s.recipients) {
|
|
354
|
+
const e = candidates.find((c) => addressOf(c.rcpt_to ?? "") === rcpt && !ids.includes(c.id));
|
|
355
|
+
if (!e)
|
|
356
|
+
return;
|
|
357
|
+
ids.push(e.id);
|
|
358
|
+
}
|
|
359
|
+
ctx.ledger.attach(r, ids);
|
|
360
|
+
}
|
|
293
361
|
async function statusOf(ctx, id) {
|
|
294
362
|
const e = await ctx.tem.getEmail(id);
|
|
295
363
|
// only this server's own sends: the project may also carry other applications' mail
|
|
@@ -318,6 +386,12 @@ export async function emailStatus(ctx, a) {
|
|
|
318
386
|
const r = ctx.ledger.find(ctx.client.id, a.idempotency_key);
|
|
319
387
|
if (!r)
|
|
320
388
|
throw new EmailError("not_found", "not_found: no send with this idempotency_key for this client (kept 30 days)");
|
|
389
|
+
if (r.smtp && r.state === "sent") {
|
|
390
|
+
await findSmtpIds(ctx, r);
|
|
391
|
+
if (r.smtp) {
|
|
392
|
+
return text(json({ idempotencyKey: r.key, state: r.state, transport: "smtp", createdAt: r.createdAt, emails: [], note: "accepted over SMTP; TEM does not list this email yet — ask again in a minute" }));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
321
395
|
const emails = [];
|
|
322
396
|
for (const id of r.emailIds.slice(0, 10))
|
|
323
397
|
emails.push(await statusOf(ctx, id).catch((err) => ({ id, error: toEmailError(err).message })));
|
|
@@ -400,7 +474,12 @@ export async function emailConfig(ctx) {
|
|
|
400
474
|
sender: c.from.name ? `${c.from.name} <${c.from.email}>` : c.from.email,
|
|
401
475
|
replyTo: c.replyTo,
|
|
402
476
|
domain,
|
|
403
|
-
provider: {
|
|
477
|
+
provider: {
|
|
478
|
+
name: "Scaleway Transactional Email",
|
|
479
|
+
region: c.region,
|
|
480
|
+
sendVia: c.sendVia === "auto" ? (ctx.smtp ? "API up to 2 MB, SMTP above" : "API") : c.sendVia === "smtp" ? "SMTP" : "API",
|
|
481
|
+
maxEmailBytes: c.sendVia === "api" || !ctx.smtp ? API_MAX_EMAIL_BYTES : SMTP_MAX_EMAIL_BYTES,
|
|
482
|
+
},
|
|
404
483
|
allowedRecipients: cl.allowlist ? { server: fmt(c.allowlist), client: fmt(cl.allowlist), rule: "a recipient must be in both" } : fmt(c.allowlist),
|
|
405
484
|
limits: {
|
|
406
485
|
maxRecipientsPerEmail: c.maxRecipients,
|
package/docs/exploitation.md
CHANGED
|
@@ -131,5 +131,10 @@ d'authentification sont journalisés (`tool: "http"`, `errorCode: "unauthorized"
|
|
|
131
131
|
|
|
132
132
|
- Quotas en mémoire : un redémarrage les remet à zéro (le registre empêche toujours les doublons).
|
|
133
133
|
- Une seule instance : le registre est un fichier local, pas de réplication.
|
|
134
|
-
- Pièces jointes : jointes au message (pas de lien de téléchargement temporaire)
|
|
135
|
-
|
|
134
|
+
- Pièces jointes : jointes au message (pas de lien de téléchargement temporaire). L'API TEM refuse
|
|
135
|
+
tout e-mail de plus de 2 Mo (non augmentable) : au-delà, le serveur passe par le SMTP de TEM
|
|
136
|
+
(`EMAIL_SEND_VIA=auto`, port sortant 2465 à ouvrir), limité à 50 Mo par e-mail, soit 35 Mo de
|
|
137
|
+
pièces jointes par défaut. Avec `EMAIL_SEND_VIA=api`, la limite reste 2 Mo.
|
|
138
|
+
- Envois SMTP : pas d'identifiant TEM au moment de l'envoi. `email_status` (par
|
|
139
|
+
`idempotency_key`) les retrouve quand TEM liste l'e-mail ; les événements webhook reçus avant
|
|
140
|
+
ne sont pas rattachés.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aidalinfo/email-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Outbound email MCP server over Scaleway Transactional Email (TEM) — stdio or central Streamable HTTP with per-client Bearer keys, fixed sender, recipient allowlist, preview then confirmed idempotent sends, quotas, kill switch, versioned templates, controlled attachments, delivery webhook and audit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -44,11 +44,13 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
47
|
+
"nodemailer": "^10.5.0",
|
|
47
48
|
"zod": "^4.4.3"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@jest/globals": "^30.3.0",
|
|
51
52
|
"@types/node": "^26.6.5",
|
|
53
|
+
"@types/nodemailer": "^8.0.2",
|
|
52
54
|
"jest": "^30.3.0",
|
|
53
55
|
"ts-jest": "^29.4.6",
|
|
54
56
|
"typescript": "^5.7.0"
|