@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/dist/tools.js CHANGED
@@ -1,37 +1,66 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { z } from "zod";
3
4
  import { domainOf, isAllowed, normalizeAddress, UUID_RE } from "./config.js";
4
5
  import { Confirmations, digest } from "./confirm.js";
5
- import { AuditLogger, RateLimiter } from "./audit.js";
6
+ import { AuditLogger } from "./audit.js";
6
7
  import { EmailError, errorResult, toEmailError } from "./errors.js";
8
+ import { Quotas } from "./quota.js";
9
+ import { IDEMPOTENCY_KEY_RE, SendLedger } from "./ledger.js";
10
+ import { HARD_VARIABLE_LENGTH, TemplateCatalog } from "./templates.js";
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";
7
13
  export const MAX_SUBJECT = 200;
8
14
  export const MAX_TEXT = 100_000;
9
15
  export const MAX_HTML = 200_000;
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;
10
19
  /** Above this, the preview shows the start of the HTML only (the digest still covers all of it). */
11
20
  const PREVIEW_HTML = 20_000;
12
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. " +
13
- "Call email_config first to see the sender, the allowed recipients and the remaining hourly quota. " +
14
- "email_send works in two steps: without confirmToken it only previews (nothing is sent) and returns a token; show the preview to the human, " +
15
- "and only after their explicit approval call email_send again with the SAME arguments plus confirmToken. Never confirm on your own initiative. " +
22
+ "Call email_config first to see the sender, the recipients you may write to, your quotas, templates and attachment sources. " +
23
+ "Sequence: email_preview (nothing is sent, returns a confirmToken) → show the preview to the human and wait for their explicit approval → " +
24
+ "email_send with the SAME arguments, the SAME idempotency_key and the confirmToken → email_status to follow delivery. " +
25
+ "Never confirm on your own initiative. Never reuse an idempotency_key for another message; after an unknown_outcome, check email_status before anything else. " +
16
26
  "Recipients outside the allowlist are refused — do not try to work around it.";
17
- export function createContext(config, tem) {
27
+ export const NO_ATTACHMENTS = { sources: [], maxCount: DEFAULT_MAX_ATTACHMENTS, maxTotalBytes: DEFAULT_MAX_ATTACHMENT_BYTES, types: [] };
28
+ export function localClient(config) {
29
+ return { id: "local", maxPerHour: config.maxPerHour, maxPerDay: config.maxPerDay, templatesOnly: false };
30
+ }
31
+ export function createService(config, tem, opts = {}) {
18
32
  return {
19
33
  config,
20
34
  tem,
35
+ smtp: opts.smtp,
21
36
  confirmations: new Confirmations(),
22
37
  audit: new AuditLogger(config.auditFile),
23
- limiter: new RateLimiter(config.maxPerHour),
38
+ quotas: new Quotas(config),
39
+ ledger: new SendLedger(config.stateDir ? join(config.stateDir, "ledger.json") : undefined),
40
+ templates: new TemplateCatalog(config.templatesDir),
41
+ attachments: new AttachmentResolver(opts.attachments ?? NO_ATTACHMENTS, opts.fetchImpl),
42
+ multiClient: !!opts.multiClient,
43
+ webhookConfigured: !!opts.webhookConfigured,
24
44
  killSwitchActive: () => !!config.killSwitchFile && existsSync(config.killSwitchFile),
25
45
  };
26
46
  }
47
+ /** Stdio: one implicit client, `local`. */
48
+ export function createContext(config, tem, opts = {}) {
49
+ return { ...createService(config, tem, opts), client: localClient(config) };
50
+ }
27
51
  const text = (t) => ({ content: [{ type: "text", text: t }] });
28
52
  const json = (v) => "```json\n" + JSON.stringify(v, null, 2) + "\n```";
29
53
  const fence = (s) => {
30
54
  const ticks = "`".repeat(Math.max(3, ...[...s.matchAll(/`+/g)].map((m) => m[0].length + 1)));
31
55
  return `${ticks}\n${s}\n${ticks}`;
32
56
  };
33
- /** Validates the arguments into the exact message that will be sent. Throws on any guard. */
34
- export function buildMessage(config, a) {
57
+ const allowedFor = (ctx, address) => isAllowed(ctx.config.allowlist, address) && (!ctx.client.allowlist || isAllowed(ctx.client.allowlist, address));
58
+ /** Validates the arguments into the exact message that will be sent, and how it will leave. Throws on any guard. */
59
+ export async function buildMessage(ctx, a) {
60
+ const { config } = ctx;
61
+ if (!IDEMPOTENCY_KEY_RE.test(a.idempotency_key ?? "")) {
62
+ throw new EmailError("invalid_request", "invalid_request: idempotency_key is required (8 to 128 characters among letters, digits and . _ : -)");
63
+ }
35
64
  const seen = new Set();
36
65
  const norm = (list, field) => list.map((raw) => {
37
66
  const addr = normalizeAddress(raw);
@@ -49,20 +78,69 @@ export function buildMessage(config, a) {
49
78
  if (seen.size > config.maxRecipients) {
50
79
  throw new EmailError("invalid_request", `invalid_request: ${seen.size} recipients, the maximum is ${config.maxRecipients} (EMAIL_MAX_RECIPIENTS)`);
51
80
  }
52
- const denied = [...seen].filter((r) => !isAllowed(config.allowlist, r));
81
+ const denied = [...seen].filter((r) => !allowedFor(ctx, r));
53
82
  if (denied.length) {
54
- throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${denied.join(", ")} not in EMAIL_ALLOWED_RECIPIENTS — nothing was sent`);
83
+ throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${denied.join(", ")} not allowed for this client — nothing was sent`);
55
84
  }
56
- if (!a.subject.trim())
85
+ let subject, body, html, template;
86
+ if (a.template) {
87
+ if (a.subject !== undefined || a.text !== undefined || a.html !== undefined) {
88
+ throw new EmailError("invalid_request", "invalid_request: give either a template or subject/text/html, not both");
89
+ }
90
+ ({ subject, text: body, html } = ctx.templates.render(ctx.client.id, a.template.name, a.template.version, a.template.variables ?? {}));
91
+ template = `${a.template.name}@${a.template.version}`;
92
+ }
93
+ else {
94
+ if (ctx.client.templatesOnly)
95
+ throw new EmailError("refused", `refused: client "${ctx.client.id}" may only send templates — see email_templates`);
96
+ subject = a.subject ?? "";
97
+ body = a.text ?? "";
98
+ html = a.html;
99
+ }
100
+ if (!subject.trim())
57
101
  throw new EmailError("invalid_request", "invalid_request: empty subject");
58
102
  // a line break in a header value is how header injection starts
59
- if (/[\u0000-\u001f\u007f]/.test(a.subject))
103
+ if (/[\u0000-\u001f\u007f]/.test(subject))
60
104
  throw new EmailError("invalid_request", "invalid_request: the subject must not contain line breaks or control characters");
61
- if (!a.text.trim())
105
+ if (subject.length > MAX_SUBJECT)
106
+ throw new EmailError("invalid_request", `invalid_request: subject longer than ${MAX_SUBJECT} characters`);
107
+ if (!body.trim())
62
108
  throw new EmailError("invalid_request", "invalid_request: empty text body (a plain-text version is always required)");
63
- if (a.html !== undefined && !a.html.trim())
109
+ if (body.length > MAX_TEXT)
110
+ throw new EmailError("invalid_request", `invalid_request: text body longer than ${MAX_TEXT} characters`);
111
+ if (html !== undefined && !html.trim())
64
112
  throw new EmailError("invalid_request", "invalid_request: html is empty — omit it instead");
65
- return { from: config.from, replyTo: config.replyTo, to, cc, subject: a.subject, text: a.text, html: a.html };
113
+ if (html !== undefined && html.length > MAX_HTML)
114
+ throw new EmailError("invalid_request", `invalid_request: HTML body longer than ${MAX_HTML} characters`);
115
+ const files = await ctx.attachments.resolve(ctx.client.id, a.attachments ?? []);
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 })),
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";
66
144
  }
67
145
  function header(m) {
68
146
  const from = m.from.name ? `${m.from.name} <${m.from.email}>` : m.from.email;
@@ -70,99 +148,310 @@ function header(m) {
70
148
  .filter(Boolean)
71
149
  .join("\n");
72
150
  }
151
+ /** Bytes on the wire, roughly: bodies plus base64-encoded attachments. */
152
+ const estimatedSize = (m) => Buffer.byteLength(m.subject) + Buffer.byteLength(m.text) + (m.html ? Buffer.byteLength(m.html) : 0) + m.attachments.reduce((n, a) => n + Math.ceil(a.size / 3) * 4, 0);
153
+ const killSwitchError = (ctx) => new EmailError("refused", `refused: sending is disabled by the kill switch${ctx.config.killSwitchFile ? ` (${ctx.config.killSwitchFile} exists)` : ""}`);
154
+ /** Kill switch, sender domain, quotas: the server-side state a confirmation is bound to. */
155
+ async function checkSendable(ctx) {
156
+ const { config } = ctx;
157
+ if (ctx.killSwitchActive())
158
+ throw killSwitchError(ctx);
159
+ const domain = await ctx.tem.findDomain(domainOf(config.from.email));
160
+ if (!domain) {
161
+ throw new EmailError("domain_not_ready", `domain_not_ready: ${domainOf(config.from.email)} is not a TEM domain of project ${config.projectId} (${config.region})`);
162
+ }
163
+ if (domain.status !== "checked") {
164
+ throw new EmailError("domain_not_ready", `domain_not_ready: TEM domain ${domain.name} is "${domain.status}", it must be "checked" (SPF/DKIM verified) before sending`);
165
+ }
166
+ ctx.quotas.check(ctx.client.id, ctx.client);
167
+ return domain;
168
+ }
169
+ const bindingOf = (ctx, domain, key, msg) => ({
170
+ tool: "email_send",
171
+ state: { domainId: domain.id, status: domain.status },
172
+ plan: { client: ctx.client.id, idempotencyKey: key, msg },
173
+ });
174
+ /** A key already in the ledger: same message and sent → the earlier result; anything else → refused. */
175
+ function priorSend(prior, messageDigest) {
176
+ if (prior.digest !== messageDigest) {
177
+ throw new EmailError("idempotency_conflict", "idempotency_conflict: this idempotency_key was already used for a different message — use a new key for a new message");
178
+ }
179
+ if (prior.state === "in_flight")
180
+ throw new EmailError("idempotency_conflict", "idempotency_conflict: a send with this idempotency_key is in progress");
181
+ if (prior.state === "unknown_outcome") {
182
+ throw new EmailError("unknown_outcome", "unknown_outcome: an earlier send with this idempotency_key may or may not have left — check email_status (idempotency_key) and the TEM console; do not send it again under another key without checking");
183
+ }
184
+ return text(`# Already sent — idempotency_key reused with the same message\n\nNothing was sent again. TEM email ids from the original send (${prior.updatedAt}):\n` +
185
+ json(prior.emailIds) +
186
+ "\n\nFollow delivery with email_status.");
187
+ }
188
+ const auditBase = (ctx, tool, a) => ({
189
+ tool,
190
+ client: ctx.client.id,
191
+ from: ctx.config.from.email,
192
+ approvalId: a.approvalId,
193
+ idempotencyKey: IDEMPOTENCY_KEY_RE.test(a.idempotency_key ?? "") ? a.idempotency_key : undefined,
194
+ });
195
+ const auditMsg = (m, messageDigest) => m
196
+ ? {
197
+ to: m.to,
198
+ cc: m.cc.length ? m.cc : undefined,
199
+ subject: m.subject,
200
+ template: m.template,
201
+ attachments: m.attachments.length ? m.attachments.map(({ type, size, sha256 }) => ({ type, size, sha256 })) : undefined,
202
+ messageDigest,
203
+ }
204
+ : {};
205
+ const REFUSALS = ["invalid_request", "recipient_not_allowed", "domain_not_ready", "rate_limited", "refused", "confirmation_invalid", "idempotency_conflict", "attachment_refused", "capability_unavailable", "not_found"];
206
+ export async function previewEmail(ctx, a) {
207
+ const tool = "email_preview";
208
+ const { config } = ctx;
209
+ let msg;
210
+ let messageDigest;
211
+ try {
212
+ let via;
213
+ ({ msg, via } = await buildMessage(ctx, a));
214
+ messageDigest = digest(msg);
215
+ const prior = ctx.ledger.find(ctx.client.id, a.idempotency_key);
216
+ if (prior)
217
+ return priorSend(prior, messageDigest);
218
+ const domain = await checkSendable(ctx);
219
+ const { token, expiresAt } = ctx.confirmations.issue(bindingOf(ctx, domain, a.idempotency_key, msg));
220
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: "preview" });
221
+ const q = ctx.quotas.remaining(ctx.client.id, ctx.client);
222
+ const html = msg.html === undefined
223
+ ? ""
224
+ : `\n\nHTML body (${msg.html.length} characters${msg.html.length > PREVIEW_HTML ? `, first ${PREVIEW_HTML} shown` : ""}):\n${fence(msg.html.slice(0, PREVIEW_HTML))}`;
225
+ const files = msg.attachments.length
226
+ ? `\n\nAttachments (resolved by the server):\n${json(msg.attachments.map((f) => ({ name: f.name, type: f.type, bytes: f.size, sha256: f.sha256 })))}`
227
+ : "";
228
+ return text(`# Preview — nothing sent\n\n${fence(header(msg))}\n\nText body${msg.template ? ` (template ${msg.template})` : ""}:\n${fence(msg.text)}${html}${files}\n\n` +
229
+ json({
230
+ effectiveSender: config.from.email,
231
+ recipients: msg.to.length + msg.cc.length,
232
+ format: msg.html === undefined ? "text" : "text + html",
233
+ estimatedBytes: estimatedSize(msg),
234
+ transport: via === "smtp" ? "TEM over SMTP (above the 2 MB of the API)" : "TEM API",
235
+ messageDigest: `sha256:${messageDigest}`,
236
+ policy: { recipientsAllowed: true, senderDomain: domain.status, killSwitch: "inactive" },
237
+ quotaLeft: { client: q.client, server: q.global },
238
+ idempotencyKey: a.idempotency_key,
239
+ }) +
240
+ `\n\nShow this preview to the human. Only after their explicit approval, call email_send with the same arguments, ` +
241
+ `the same idempotency_key and confirmToken: "${token}" (valid until ${expiresAt}, single use).`);
242
+ }
243
+ catch (err) {
244
+ const e = toEmailError(err);
245
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: REFUSALS.includes(e.code) ? "refused" : "failed", errorCode: e.code });
246
+ return errorResult(e);
247
+ }
248
+ }
73
249
  export async function sendEmail(ctx, a) {
74
250
  const tool = "email_send";
75
251
  const { config } = ctx;
76
- const base = { tool, from: config.from.email, approvalId: a.approvalId };
77
252
  let msg;
78
253
  let messageDigest;
79
254
  try {
80
- if (ctx.killSwitchActive())
81
- throw new EmailError("refused", `refused: sending is disabled by the kill switch (${config.killSwitchFile} exists)`);
82
- msg = buildMessage(config, a);
255
+ if (!a.confirmToken)
256
+ throw new EmailError("confirmation_invalid", "confirmation_invalid: confirmToken is required — call email_preview first and get the human's approval");
257
+ const built = await buildMessage(ctx, a);
258
+ msg = built.msg;
83
259
  messageDigest = digest(msg);
84
- const domain = await ctx.tem.findDomain(domainOf(config.from.email));
85
- if (!domain) {
86
- throw new EmailError("domain_not_ready", `domain_not_ready: ${domainOf(config.from.email)} is not a TEM domain of project ${config.projectId} (${config.region})`);
87
- }
88
- if (domain.status !== "checked") {
89
- throw new EmailError("domain_not_ready", `domain_not_ready: TEM domain ${domain.name} is "${domain.status}", it must be "checked" (SPF/DKIM verified) before sending`);
90
- }
91
- if (ctx.limiter.remaining() <= 0) {
92
- throw new EmailError("rate_limited", `rate_limited: ${config.maxPerHour} emails already sent in the last hour (EMAIL_MAX_PER_HOUR)`);
260
+ const prior = ctx.ledger.find(ctx.client.id, a.idempotency_key);
261
+ if (prior) {
262
+ const r = priorSend(prior, messageDigest);
263
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: "replayed", temEmailIds: prior.emailIds });
264
+ return r;
93
265
  }
94
- const binding = { tool, state: { domainId: domain.id, status: domain.status }, plan: msg };
95
- const who = { to: msg.to, cc: msg.cc.length ? msg.cc : undefined, subject: msg.subject, messageDigest };
96
- if (!a.confirmToken) {
97
- const { token, expiresAt } = ctx.confirmations.issue(binding);
98
- ctx.audit.log({ ...base, ...who, result: "preview" });
99
- const html = msg.html === undefined
100
- ? ""
101
- : `\n\nHTML body (${msg.html.length} characters${msg.html.length > PREVIEW_HTML ? `, first ${PREVIEW_HTML} shown` : ""}):\n${fence(msg.html.slice(0, PREVIEW_HTML))}`;
102
- return text(`# Preview — ${tool} (nothing sent yet)\n\n${fence(header(msg))}\n\nText body:\n${fence(msg.text)}${html}\n\n` +
103
- `Message digest: sha256:${messageDigest} · quota left this hour: ${ctx.limiter.remaining()}/${config.maxPerHour}\n\n` +
104
- `Show this preview to the human. Only after their explicit approval, call ${tool} again with the same arguments and ` +
105
- `confirmToken: "${token}" (valid until ${expiresAt}, single use).`);
106
- }
107
- ctx.confirmations.verify(a.confirmToken, binding);
266
+ const domain = await checkSendable(ctx);
267
+ ctx.confirmations.verify(a.confirmToken, bindingOf(ctx, domain, a.idempotency_key, msg));
108
268
  // the switch may have been set while the human was reading the preview
109
269
  if (ctx.killSwitchActive())
110
- throw new EmailError("refused", `refused: sending is disabled by the kill switch (${config.killSwitchFile} exists)`);
111
- const release = ctx.limiter.reserve();
112
- let emails;
270
+ throw killSwitchError(ctx);
271
+ const record = ctx.ledger.begin(ctx.client.id, a.idempotency_key, messageDigest);
272
+ const release = ctx.quotas.reserve(ctx.client.id);
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();
113
277
  try {
114
- emails = await ctx.tem.createEmail({
115
- from: msg.from,
116
- to: msg.to.map((email) => ({ email })),
117
- cc: msg.cc.map((email) => ({ email })),
118
- subject: msg.subject,
119
- text: msg.text,
120
- html: msg.html,
121
- project_id: config.projectId,
122
- additional_headers: msg.replyTo ? [{ key: "Reply-To", value: msg.replyTo }] : undefined,
123
- });
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
+ });
124
302
  }
125
303
  catch (err) {
126
- // a send that may have left keeps its quota slot
127
- if (toEmailError(err).code !== "unknown_outcome")
304
+ // a send that may have left keeps its quota slot and burns its key
305
+ if (toEmailError(err).code === "unknown_outcome")
306
+ ctx.ledger.settle(record, "unknown_outcome");
307
+ else {
128
308
  release();
309
+ ctx.ledger.drop(record);
310
+ }
129
311
  throw err;
130
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
+ }
131
322
  const ids = emails.map((e) => e.id);
132
- ctx.audit.log({ ...base, ...who, result: "sent", temEmailIds: ids });
133
- return text(`# ${tool} — accepted by Scaleway TEM\n\n${fence(header(msg))}\n\n` +
323
+ ctx.ledger.settle(record, "sent", ids);
324
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: "sent", temEmailIds: ids });
325
+ return text(`# email_send — accepted by Scaleway TEM\n\n${fence(header(msg))}\n\n` +
134
326
  json(emails.map((e) => ({ id: e.id, rcptTo: e.rcpt_to, rcptType: e.rcpt_type, status: e.status }))) +
135
- `\n\nDelivery is asynchronous: check it with email_status. Quota left this hour: ${ctx.limiter.remaining()}/${config.maxPerHour}.`);
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.`);
136
328
  }
137
329
  catch (err) {
138
330
  const e = toEmailError(err);
139
- const result = e.code === "unknown_outcome" ? "unknown_outcome" : ["invalid_request", "recipient_not_allowed", "domain_not_ready", "rate_limited", "refused", "confirmation_invalid"].includes(e.code) ? "refused" : "failed";
140
- ctx.audit.log({ ...base, to: msg?.to, cc: msg?.cc.length ? msg.cc : undefined, subject: msg?.subject, messageDigest, result, errorCode: e.code });
331
+ const result = e.code === "unknown_outcome" ? "unknown_outcome" : REFUSALS.includes(e.code) ? "refused" : "failed";
332
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result, errorCode: e.code });
141
333
  return errorResult(e);
142
334
  }
143
335
  }
144
336
  const addressOf = (s) => (/<([^>]+)>/.exec(s)?.[1] ?? s).trim().toLowerCase();
145
- export async function emailStatus(ctx, emailId) {
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
+ }
361
+ async function statusOf(ctx, id) {
362
+ const e = await ctx.tem.getEmail(id);
363
+ // only this server's own sends: the project may also carry other applications' mail
364
+ if (e.project_id !== ctx.config.projectId || addressOf(e.mail_from ?? "") !== ctx.config.from.email) {
365
+ throw new EmailError("not_found", "not_found: no email with this id was sent by this sender");
366
+ }
367
+ return {
368
+ id: e.id,
369
+ status: e.status,
370
+ statusDetails: e.status_details || undefined,
371
+ rcptTo: e.rcpt_to,
372
+ rcptType: e.rcpt_type,
373
+ subject: e.subject,
374
+ createdAt: e.created_at,
375
+ updatedAt: e.updated_at,
376
+ tryCount: e.try_count,
377
+ lastTries: e.last_tries?.slice(-5).map((t) => ({ triedAt: t.tried_at, code: t.code, message: t.message?.slice(0, 300) })),
378
+ deliveryEvents: ctx.webhookConfigured ? (ctx.ledger.ownerOf(e.id)?.events[e.id] ?? []) : "webhook not configured",
379
+ };
380
+ }
381
+ export async function emailStatus(ctx, a) {
146
382
  try {
147
- if (!UUID_RE.test(emailId))
383
+ if (!!a.emailId === !!a.idempotency_key)
384
+ throw new EmailError("invalid_request", "invalid_request: give either emailId or idempotency_key");
385
+ if (a.idempotency_key) {
386
+ const r = ctx.ledger.find(ctx.client.id, a.idempotency_key);
387
+ if (!r)
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
+ }
395
+ const emails = [];
396
+ for (const id of r.emailIds.slice(0, 10))
397
+ emails.push(await statusOf(ctx, id).catch((err) => ({ id, error: toEmailError(err).message })));
398
+ return text(json({ idempotencyKey: r.key, state: r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, emails, more: r.emailIds.length > 10 ? r.emailIds.length - 10 : undefined }));
399
+ }
400
+ const id = a.emailId;
401
+ if (!UUID_RE.test(id))
148
402
  throw new EmailError("invalid_request", "invalid_request: emailId must be a TEM email id (UUID) as returned by email_send");
149
- const e = await ctx.tem.getEmail(emailId.toLowerCase());
150
- // only this server's own sends: the project may also carry other applications' mail
151
- if (e.project_id !== ctx.config.projectId || addressOf(e.mail_from ?? "") !== ctx.config.from.email) {
152
- throw new EmailError("not_found", "not_found: no email with this id was sent by this sender");
403
+ if (ctx.multiClient && ctx.ledger.ownerOf(id.toLowerCase())?.client !== ctx.client.id) {
404
+ throw new EmailError("not_found", "not_found: no email with this id was sent by this client");
153
405
  }
154
- return text(json({
155
- id: e.id,
156
- status: e.status,
157
- statusDetails: e.status_details || undefined,
158
- rcptTo: e.rcpt_to,
159
- rcptType: e.rcpt_type,
160
- subject: e.subject,
161
- createdAt: e.created_at,
162
- updatedAt: e.updated_at,
163
- tryCount: e.try_count,
164
- lastTries: e.last_tries?.map((t) => ({ triedAt: t.tried_at, code: t.code, message: t.message })),
165
- }));
406
+ return text(json(await statusOf(ctx, id.toLowerCase())));
407
+ }
408
+ catch (err) {
409
+ return errorResult(err);
410
+ }
411
+ }
412
+ export async function emailSuppressions(ctx, addresses) {
413
+ try {
414
+ if (!addresses.length || addresses.length > MAX_SUPPRESSION_LOOKUPS)
415
+ throw new EmailError("invalid_request", `invalid_request: 1 to ${MAX_SUPPRESSION_LOOKUPS} addresses`);
416
+ const list = addresses.map((raw) => {
417
+ const a = normalizeAddress(raw);
418
+ if (!a)
419
+ throw new EmailError("invalid_request", `invalid_request: invalid address "${raw.slice(0, 100)}"`);
420
+ // no probing: only addresses this client may write to
421
+ if (!allowedFor(ctx, a))
422
+ throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${a} is not a recipient this client may write to`);
423
+ return a;
424
+ });
425
+ const domain = await ctx.tem.findDomain(domainOf(ctx.config.from.email));
426
+ if (!domain)
427
+ throw new EmailError("domain_not_ready", `domain_not_ready: ${domainOf(ctx.config.from.email)} is not a TEM domain of project ${ctx.config.projectId}`);
428
+ const out = [];
429
+ for (const address of [...new Set(list)]) {
430
+ const entries = await ctx.tem.listBlocklists(domain.id, address);
431
+ out.push({
432
+ address,
433
+ suppressed: entries.some((b) => !b.ends_at || Date.parse(b.ends_at) > Date.now()),
434
+ entries: entries.slice(0, 5).map((b) => ({ type: b.type, reason: b.reason?.slice(0, 200), createdAt: b.created_at, endsAt: b.ends_at, custom: b.custom })),
435
+ });
436
+ }
437
+ return text(json(out));
438
+ }
439
+ catch (err) {
440
+ return errorResult(err);
441
+ }
442
+ }
443
+ export function emailTemplates(ctx) {
444
+ try {
445
+ if (!ctx.templates.configured)
446
+ throw new EmailError("capability_unavailable", "capability_unavailable: no template directory is configured (EMAIL_TEMPLATES_DIR)");
447
+ return text(json(ctx.templates.list(ctx.client.id).map((t) => ({
448
+ name: t.name,
449
+ version: t.version,
450
+ description: t.description,
451
+ subject: t.subject,
452
+ html: t.html !== undefined,
453
+ variables: t.variables,
454
+ }))));
166
455
  }
167
456
  catch (err) {
168
457
  return errorResult(err);
@@ -178,35 +467,84 @@ export async function emailConfig(ctx) {
178
467
  catch (err) {
179
468
  domain = { name: domainOf(c.from.email), error: toEmailError(err).message };
180
469
  }
470
+ const cl = ctx.client;
471
+ const fmt = (l) => ({ addresses: [...l.addresses], domains: [...l.domains].map((d) => `@${d}`) });
181
472
  return text(json({
473
+ client: cl.id,
182
474
  sender: c.from.name ? `${c.from.name} <${c.from.email}>` : c.from.email,
183
475
  replyTo: c.replyTo,
184
476
  domain,
185
- provider: { name: "Scaleway Transactional Email", region: c.region, projectId: c.projectId },
186
- allowedRecipients: { addresses: [...c.allowlist.addresses], domains: [...c.allowlist.domains].map((d) => `@${d}`) },
187
- limits: { maxRecipientsPerEmail: c.maxRecipients, maxPerHour: c.maxPerHour, remainingThisHour: ctx.limiter.remaining(), subjectChars: MAX_SUBJECT, textChars: MAX_TEXT, htmlChars: MAX_HTML },
188
- killSwitch: c.killSwitchFile ? { file: c.killSwitchFile, active: ctx.killSwitchActive() } : "not configured",
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
+ },
483
+ allowedRecipients: cl.allowlist ? { server: fmt(c.allowlist), client: fmt(cl.allowlist), rule: "a recipient must be in both" } : fmt(c.allowlist),
484
+ limits: {
485
+ maxRecipientsPerEmail: c.maxRecipients,
486
+ client: { maxPerHour: cl.maxPerHour, maxPerDay: cl.maxPerDay },
487
+ remaining: ctx.quotas.remaining(cl.id, cl),
488
+ subjectChars: MAX_SUBJECT,
489
+ textChars: MAX_TEXT,
490
+ htmlChars: MAX_HTML,
491
+ },
492
+ templates: ctx.templates.configured ? { available: ctx.templates.list(cl.id).length, only: cl.templatesOnly } : "not configured",
493
+ attachments: ctx.attachments.configured
494
+ ? { sources: ctx.attachments.sourcesFor(cl.id), types: ctx.attachments.config.types, maxCount: ctx.attachments.config.maxCount, maxTotalBytes: ctx.attachments.config.maxTotalBytes }
495
+ : "not configured",
496
+ deliveryEvents: ctx.webhookConfigured ? "webhook configured" : "webhook not configured",
497
+ killSwitch: ctx.killSwitchActive() ? "active — every send is refused" : c.killSwitchFile ? "inactive" : "not configured",
189
498
  }));
190
499
  }
191
500
  const address = z.string().min(3).max(254);
501
+ const messageShape = {
502
+ to: z.array(address).min(1).max(50).describe("Recipient addresses (bare addresses, each must be allowed for this client)"),
503
+ cc: z.array(address).max(50).optional().describe("Carbon-copy addresses, same rules as to. There is no bcc."),
504
+ subject: z.string().min(1).max(MAX_SUBJECT).optional().describe("Required unless template is given"),
505
+ text: z.string().min(1).max(MAX_TEXT).optional().describe("Plain-text body, required unless template is given"),
506
+ html: z.string().max(MAX_HTML).optional().describe("Optional HTML body; the plain-text body stays the fallback"),
507
+ template: z
508
+ .object({
509
+ name: z.string().max(64),
510
+ version: z.number().int().min(1),
511
+ variables: z.record(z.string().max(40), z.string().max(HARD_VARIABLE_LENGTH)).optional(),
512
+ })
513
+ .strict()
514
+ .optional()
515
+ .describe("A versioned template from email_templates, instead of subject/text/html"),
516
+ attachments: z
517
+ .array(z
518
+ .object({
519
+ source: z.string().max(40).describe("Attachment source id from email_config"),
520
+ ref: z.string().max(512).describe("Reference of the file inside that source (e.g. a vault file id) — never a local path or a URL"),
521
+ filename: z.string().max(100).optional().describe("Name shown to the recipient; its extension must match the file type"),
522
+ })
523
+ .strict())
524
+ .max(HARD_MAX_ATTACHMENTS)
525
+ .optional()
526
+ .describe("Files resolved by the server from configured sources"),
527
+ idempotency_key: z
528
+ .string()
529
+ .regex(IDEMPOTENCY_KEY_RE)
530
+ .describe("Your unique key for this message (8-128 of letters, digits . _ : -), the same in email_preview and email_send. A key never sends twice."),
531
+ approvalId: z
532
+ .string()
533
+ .regex(/^[\w.:-]{1,128}$/)
534
+ .optional()
535
+ .describe("Pulse Chat approval id, recorded in the audit trail for correlation (not an authorisation)."),
536
+ };
192
537
  export function registerTools(server, ctx) {
193
- server.tool("email_config", "Sender address, allowed recipients, per-email and hourly limits, sender domain status and kill switch. Start here.", {}, async () => emailConfig(ctx));
194
- server.tool("email_send", `[SEND] Send an email from the fixed sender ${ctx.config.from.email} to allowlisted recipients. Two steps: call without confirmToken to preview (nothing is sent), show the preview to the human, then — only after their explicit approval — call again with the same arguments and the returned confirmToken.`, {
195
- to: z.array(address).min(1).max(50).describe("Recipient addresses (bare addresses, each must be allowed by EMAIL_ALLOWED_RECIPIENTS)"),
196
- cc: z.array(address).max(50).optional().describe("Carbon-copy addresses, same rules as to. There is no bcc."),
197
- subject: z.string().min(1).max(MAX_SUBJECT),
198
- text: z.string().min(1).max(MAX_TEXT).describe("Plain-text body (always required)"),
199
- html: z.string().max(MAX_HTML).optional().describe("Optional HTML body; the plain-text body stays the fallback"),
200
- confirmToken: z
201
- .string()
202
- .max(200)
203
- .optional()
204
- .describe("Omit for the preview. Then the token from the preview, with the SAME arguments, to send. Single use, valid 5 minutes."),
205
- approvalId: z
206
- .string()
207
- .regex(/^[\w.:-]{1,128}$/)
208
- .optional()
209
- .describe("Pulse Chat approval id, recorded in the audit trail for correlation (not an authorisation)."),
538
+ server.tool("email_config", "Your client id, the fixed sender, the recipients you may write to, your quotas, templates, attachment sources, sender domain status and kill switch. Start here.", {}, async () => emailConfig(ctx));
539
+ server.tool("email_preview", `Prepare an email from the fixed sender ${ctx.config.from.email} and check every policy WITHOUT sending: effective recipients, sender, bodies, resolved attachments, size, quotas. Returns a confirmToken for email_send, to use only after the human approved this preview.`, messageShape, async (args) => previewEmail(ctx, args));
540
+ server.tool("email_send", `[SEND] Send the email previewed by email_preview — same arguments, same idempotency_key, plus its confirmToken — only after the human's explicit approval. A reused idempotency_key never sends twice.`, {
541
+ ...messageShape,
542
+ confirmToken: z.string().max(200).describe("Token returned by email_preview for exactly these arguments. Single use, valid 5 minutes."),
210
543
  }, async (args) => sendEmail(ctx, args));
211
- server.tool("email_status", "Delivery status of an email sent by this server (TEM email id returned by email_send): new, sending, sent, failed, canceled, with the last delivery attempts.", { emailId: z.string().max(64).describe("TEM email id (UUID)") }, async ({ emailId }) => emailStatus(ctx, emailId));
544
+ server.tool("email_status", "Delivery status of one of your sends, by TEM email id (from email_send) or by idempotency_key: new, sending, sent, failed, canceled, last attempts and webhook events (delivered, bounce, complaint…).", {
545
+ emailId: z.string().max(64).optional().describe("TEM email id (UUID)"),
546
+ idempotency_key: z.string().regex(IDEMPOTENCY_KEY_RE).optional().describe("The key used for the send"),
547
+ }, async (args) => emailStatus(ctx, args));
548
+ server.tool("email_templates", "Versioned templates you may send (name, version, subject, variables).", {}, async () => emailTemplates(ctx));
549
+ server.tool("email_suppressions", `Whether addresses you may write to are on the provider's suppression list (bounce, full mailbox…), at most ${MAX_SUPPRESSION_LOOKUPS} at a time.`, { addresses: z.array(address).min(1).max(MAX_SUPPRESSION_LOOKUPS) }, async ({ addresses }) => emailSuppressions(ctx, addresses));
212
550
  }