@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 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 four Scaleway calls (find the sender domain, create
60
- an email, read an email, read the blocklist of one address).
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
- | `EMAIL_MAX_ATTACHMENTS` / `EMAIL_MAX_ATTACHMENT_BYTES` | | 5 / 2 000 000 | caps 10 / 10 000 000 (total) |
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) |
@@ -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}
@@ -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
- export const FETCH_TIMEOUT_MS = 30_000;
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: intIn(env.EMAIL_MAX_ATTACHMENT_BYTES?.trim() || undefined, "EMAIL_MAX_ATTACHMENT_BYTES", DEFAULT_MAX_ATTACHMENT_BYTES, HARD_MAX_ATTACHMENT_BYTES, warnings),
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
+ }
@@ -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
- return {
113
- msg: {
114
- from: config.from,
115
- replyTo: config.replyTo,
116
- to,
117
- cc,
118
- subject,
119
- text: body,
120
- html,
121
- template,
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
- msg = (await buildMessage(ctx, a)).msg;
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
- emails = await ctx.tem.createEmail({
256
- from: msg.from,
257
- to: msg.to.map((email) => ({ email })),
258
- cc: msg.cc.map((email) => ({ email })),
259
- subject: msg.subject,
260
- text: msg.text,
261
- html: msg.html,
262
- project_id: config.projectId,
263
- additional_headers: msg.replyTo ? [{ key: "Reply-To", value: msg.replyTo }] : undefined,
264
- attachments: built.files.length ? built.files.map((f) => ({ name: f.name, type: f.type, content: f.content.toString("base64") })) : undefined,
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: { name: "Scaleway Transactional Email", region: c.region },
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,
@@ -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), 2 Mo au total
135
- par défaut, à ajuster à la limite de taille de TEM.
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.2.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"