@aidalinfo/email-mcp 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,37 +1,62 @@
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";
7
12
  export const MAX_SUBJECT = 200;
8
13
  export const MAX_TEXT = 100_000;
9
14
  export const MAX_HTML = 200_000;
15
+ export const MAX_SUPPRESSION_LOOKUPS = 20;
10
16
  /** Above this, the preview shows the start of the HTML only (the digest still covers all of it). */
11
17
  const PREVIEW_HTML = 20_000;
12
18
  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. " +
19
+ "Call email_config first to see the sender, the recipients you may write to, your quotas, templates and attachment sources. " +
20
+ "Sequence: email_preview (nothing is sent, returns a confirmToken) → show the preview to the human and wait for their explicit approval → " +
21
+ "email_send with the SAME arguments, the SAME idempotency_key and the confirmToken → email_status to follow delivery. " +
22
+ "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
23
  "Recipients outside the allowlist are refused — do not try to work around it.";
17
- export function createContext(config, tem) {
24
+ export const NO_ATTACHMENTS = { sources: [], maxCount: DEFAULT_MAX_ATTACHMENTS, maxTotalBytes: DEFAULT_MAX_ATTACHMENT_BYTES, types: [] };
25
+ export function localClient(config) {
26
+ return { id: "local", maxPerHour: config.maxPerHour, maxPerDay: config.maxPerDay, templatesOnly: false };
27
+ }
28
+ export function createService(config, tem, opts = {}) {
18
29
  return {
19
30
  config,
20
31
  tem,
21
32
  confirmations: new Confirmations(),
22
33
  audit: new AuditLogger(config.auditFile),
23
- limiter: new RateLimiter(config.maxPerHour),
34
+ quotas: new Quotas(config),
35
+ ledger: new SendLedger(config.stateDir ? join(config.stateDir, "ledger.json") : undefined),
36
+ templates: new TemplateCatalog(config.templatesDir),
37
+ attachments: new AttachmentResolver(opts.attachments ?? NO_ATTACHMENTS, opts.fetchImpl),
38
+ multiClient: !!opts.multiClient,
39
+ webhookConfigured: !!opts.webhookConfigured,
24
40
  killSwitchActive: () => !!config.killSwitchFile && existsSync(config.killSwitchFile),
25
41
  };
26
42
  }
43
+ /** Stdio: one implicit client, `local`. */
44
+ export function createContext(config, tem, opts = {}) {
45
+ return { ...createService(config, tem, opts), client: localClient(config) };
46
+ }
27
47
  const text = (t) => ({ content: [{ type: "text", text: t }] });
28
48
  const json = (v) => "```json\n" + JSON.stringify(v, null, 2) + "\n```";
29
49
  const fence = (s) => {
30
50
  const ticks = "`".repeat(Math.max(3, ...[...s.matchAll(/`+/g)].map((m) => m[0].length + 1)));
31
51
  return `${ticks}\n${s}\n${ticks}`;
32
52
  };
53
+ const allowedFor = (ctx, address) => isAllowed(ctx.config.allowlist, address) && (!ctx.client.allowlist || isAllowed(ctx.client.allowlist, address));
33
54
  /** Validates the arguments into the exact message that will be sent. Throws on any guard. */
34
- export function buildMessage(config, a) {
55
+ export async function buildMessage(ctx, a) {
56
+ const { config } = ctx;
57
+ if (!IDEMPOTENCY_KEY_RE.test(a.idempotency_key ?? "")) {
58
+ throw new EmailError("invalid_request", "invalid_request: idempotency_key is required (8 to 128 characters among letters, digits and . _ : -)");
59
+ }
35
60
  const seen = new Set();
36
61
  const norm = (list, field) => list.map((raw) => {
37
62
  const addr = normalizeAddress(raw);
@@ -49,20 +74,55 @@ export function buildMessage(config, a) {
49
74
  if (seen.size > config.maxRecipients) {
50
75
  throw new EmailError("invalid_request", `invalid_request: ${seen.size} recipients, the maximum is ${config.maxRecipients} (EMAIL_MAX_RECIPIENTS)`);
51
76
  }
52
- const denied = [...seen].filter((r) => !isAllowed(config.allowlist, r));
77
+ const denied = [...seen].filter((r) => !allowedFor(ctx, r));
53
78
  if (denied.length) {
54
- throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${denied.join(", ")} not in EMAIL_ALLOWED_RECIPIENTS — nothing was sent`);
79
+ throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${denied.join(", ")} not allowed for this client — nothing was sent`);
80
+ }
81
+ let subject, body, html, template;
82
+ if (a.template) {
83
+ if (a.subject !== undefined || a.text !== undefined || a.html !== undefined) {
84
+ throw new EmailError("invalid_request", "invalid_request: give either a template or subject/text/html, not both");
85
+ }
86
+ ({ subject, text: body, html } = ctx.templates.render(ctx.client.id, a.template.name, a.template.version, a.template.variables ?? {}));
87
+ template = `${a.template.name}@${a.template.version}`;
88
+ }
89
+ else {
90
+ if (ctx.client.templatesOnly)
91
+ throw new EmailError("refused", `refused: client "${ctx.client.id}" may only send templates — see email_templates`);
92
+ subject = a.subject ?? "";
93
+ body = a.text ?? "";
94
+ html = a.html;
55
95
  }
56
- if (!a.subject.trim())
96
+ if (!subject.trim())
57
97
  throw new EmailError("invalid_request", "invalid_request: empty subject");
58
98
  // a line break in a header value is how header injection starts
59
- if (/[\u0000-\u001f\u007f]/.test(a.subject))
99
+ if (/[\u0000-\u001f\u007f]/.test(subject))
60
100
  throw new EmailError("invalid_request", "invalid_request: the subject must not contain line breaks or control characters");
61
- if (!a.text.trim())
101
+ if (subject.length > MAX_SUBJECT)
102
+ throw new EmailError("invalid_request", `invalid_request: subject longer than ${MAX_SUBJECT} characters`);
103
+ if (!body.trim())
62
104
  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())
105
+ if (body.length > MAX_TEXT)
106
+ throw new EmailError("invalid_request", `invalid_request: text body longer than ${MAX_TEXT} characters`);
107
+ if (html !== undefined && !html.trim())
64
108
  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 };
109
+ if (html !== undefined && html.length > MAX_HTML)
110
+ throw new EmailError("invalid_request", `invalid_request: HTML body longer than ${MAX_HTML} characters`);
111
+ 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,
125
+ };
66
126
  }
67
127
  function header(m) {
68
128
  const from = m.from.name ? `${m.from.name} <${m.from.email}>` : m.from.email;
@@ -70,45 +130,126 @@ function header(m) {
70
130
  .filter(Boolean)
71
131
  .join("\n");
72
132
  }
133
+ /** Bytes on the wire, roughly: bodies plus base64-encoded attachments. */
134
+ 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);
135
+ const killSwitchError = (ctx) => new EmailError("refused", `refused: sending is disabled by the kill switch${ctx.config.killSwitchFile ? ` (${ctx.config.killSwitchFile} exists)` : ""}`);
136
+ /** Kill switch, sender domain, quotas: the server-side state a confirmation is bound to. */
137
+ async function checkSendable(ctx) {
138
+ const { config } = ctx;
139
+ if (ctx.killSwitchActive())
140
+ throw killSwitchError(ctx);
141
+ const domain = await ctx.tem.findDomain(domainOf(config.from.email));
142
+ if (!domain) {
143
+ throw new EmailError("domain_not_ready", `domain_not_ready: ${domainOf(config.from.email)} is not a TEM domain of project ${config.projectId} (${config.region})`);
144
+ }
145
+ if (domain.status !== "checked") {
146
+ 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`);
147
+ }
148
+ ctx.quotas.check(ctx.client.id, ctx.client);
149
+ return domain;
150
+ }
151
+ const bindingOf = (ctx, domain, key, msg) => ({
152
+ tool: "email_send",
153
+ state: { domainId: domain.id, status: domain.status },
154
+ plan: { client: ctx.client.id, idempotencyKey: key, msg },
155
+ });
156
+ /** A key already in the ledger: same message and sent → the earlier result; anything else → refused. */
157
+ function priorSend(prior, messageDigest) {
158
+ if (prior.digest !== messageDigest) {
159
+ 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");
160
+ }
161
+ if (prior.state === "in_flight")
162
+ throw new EmailError("idempotency_conflict", "idempotency_conflict: a send with this idempotency_key is in progress");
163
+ if (prior.state === "unknown_outcome") {
164
+ 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");
165
+ }
166
+ 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` +
167
+ json(prior.emailIds) +
168
+ "\n\nFollow delivery with email_status.");
169
+ }
170
+ const auditBase = (ctx, tool, a) => ({
171
+ tool,
172
+ client: ctx.client.id,
173
+ from: ctx.config.from.email,
174
+ approvalId: a.approvalId,
175
+ idempotencyKey: IDEMPOTENCY_KEY_RE.test(a.idempotency_key ?? "") ? a.idempotency_key : undefined,
176
+ });
177
+ const auditMsg = (m, messageDigest) => m
178
+ ? {
179
+ to: m.to,
180
+ cc: m.cc.length ? m.cc : undefined,
181
+ subject: m.subject,
182
+ template: m.template,
183
+ attachments: m.attachments.length ? m.attachments.map(({ type, size, sha256 }) => ({ type, size, sha256 })) : undefined,
184
+ messageDigest,
185
+ }
186
+ : {};
187
+ const REFUSALS = ["invalid_request", "recipient_not_allowed", "domain_not_ready", "rate_limited", "refused", "confirmation_invalid", "idempotency_conflict", "attachment_refused", "capability_unavailable", "not_found"];
188
+ export async function previewEmail(ctx, a) {
189
+ const tool = "email_preview";
190
+ const { config } = ctx;
191
+ let msg;
192
+ let messageDigest;
193
+ try {
194
+ msg = (await buildMessage(ctx, a)).msg;
195
+ messageDigest = digest(msg);
196
+ const prior = ctx.ledger.find(ctx.client.id, a.idempotency_key);
197
+ if (prior)
198
+ return priorSend(prior, messageDigest);
199
+ const domain = await checkSendable(ctx);
200
+ const { token, expiresAt } = ctx.confirmations.issue(bindingOf(ctx, domain, a.idempotency_key, msg));
201
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: "preview" });
202
+ const q = ctx.quotas.remaining(ctx.client.id, ctx.client);
203
+ const html = msg.html === undefined
204
+ ? ""
205
+ : `\n\nHTML body (${msg.html.length} characters${msg.html.length > PREVIEW_HTML ? `, first ${PREVIEW_HTML} shown` : ""}):\n${fence(msg.html.slice(0, PREVIEW_HTML))}`;
206
+ const files = msg.attachments.length
207
+ ? `\n\nAttachments (resolved by the server):\n${json(msg.attachments.map((f) => ({ name: f.name, type: f.type, bytes: f.size, sha256: f.sha256 })))}`
208
+ : "";
209
+ 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` +
210
+ json({
211
+ effectiveSender: config.from.email,
212
+ recipients: msg.to.length + msg.cc.length,
213
+ format: msg.html === undefined ? "text" : "text + html",
214
+ estimatedBytes: estimatedSize(msg),
215
+ messageDigest: `sha256:${messageDigest}`,
216
+ policy: { recipientsAllowed: true, senderDomain: domain.status, killSwitch: "inactive" },
217
+ quotaLeft: { client: q.client, server: q.global },
218
+ idempotencyKey: a.idempotency_key,
219
+ }) +
220
+ `\n\nShow this preview to the human. Only after their explicit approval, call email_send with the same arguments, ` +
221
+ `the same idempotency_key and confirmToken: "${token}" (valid until ${expiresAt}, single use).`);
222
+ }
223
+ catch (err) {
224
+ const e = toEmailError(err);
225
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: REFUSALS.includes(e.code) ? "refused" : "failed", errorCode: e.code });
226
+ return errorResult(e);
227
+ }
228
+ }
73
229
  export async function sendEmail(ctx, a) {
74
230
  const tool = "email_send";
75
231
  const { config } = ctx;
76
- const base = { tool, from: config.from.email, approvalId: a.approvalId };
77
232
  let msg;
78
233
  let messageDigest;
79
234
  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);
235
+ if (!a.confirmToken)
236
+ throw new EmailError("confirmation_invalid", "confirmation_invalid: confirmToken is required — call email_preview first and get the human's approval");
237
+ const built = await buildMessage(ctx, a);
238
+ msg = built.msg;
83
239
  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`);
240
+ const prior = ctx.ledger.find(ctx.client.id, a.idempotency_key);
241
+ if (prior) {
242
+ const r = priorSend(prior, messageDigest);
243
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result: "replayed", temEmailIds: prior.emailIds });
244
+ return r;
90
245
  }
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)`);
93
- }
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);
246
+ const domain = await checkSendable(ctx);
247
+ ctx.confirmations.verify(a.confirmToken, bindingOf(ctx, domain, a.idempotency_key, msg));
108
248
  // the switch may have been set while the human was reading the preview
109
249
  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();
250
+ throw killSwitchError(ctx);
251
+ const record = ctx.ledger.begin(ctx.client.id, a.idempotency_key, messageDigest);
252
+ const release = ctx.quotas.reserve(ctx.client.id);
112
253
  let emails;
113
254
  try {
114
255
  emails = await ctx.tem.createEmail({
@@ -120,49 +261,123 @@ export async function sendEmail(ctx, a) {
120
261
  html: msg.html,
121
262
  project_id: config.projectId,
122
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,
123
265
  });
124
266
  }
125
267
  catch (err) {
126
- // a send that may have left keeps its quota slot
127
- if (toEmailError(err).code !== "unknown_outcome")
268
+ // a send that may have left keeps its quota slot and burns its key
269
+ if (toEmailError(err).code === "unknown_outcome")
270
+ ctx.ledger.settle(record, "unknown_outcome");
271
+ else {
128
272
  release();
273
+ ctx.ledger.drop(record);
274
+ }
129
275
  throw err;
130
276
  }
131
277
  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` +
278
+ ctx.ledger.settle(record, "sent", ids);
279
+ 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
+ return text(`# email_send — accepted by Scaleway TEM\n\n${fence(header(msg))}\n\n` +
134
282
  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}.`);
283
+ `\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
284
  }
137
285
  catch (err) {
138
286
  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 });
287
+ const result = e.code === "unknown_outcome" ? "unknown_outcome" : REFUSALS.includes(e.code) ? "refused" : "failed";
288
+ ctx.audit.log({ ...auditBase(ctx, tool, a), ...auditMsg(msg, messageDigest), result, errorCode: e.code });
141
289
  return errorResult(e);
142
290
  }
143
291
  }
144
292
  const addressOf = (s) => (/<([^>]+)>/.exec(s)?.[1] ?? s).trim().toLowerCase();
145
- export async function emailStatus(ctx, emailId) {
293
+ async function statusOf(ctx, id) {
294
+ const e = await ctx.tem.getEmail(id);
295
+ // only this server's own sends: the project may also carry other applications' mail
296
+ if (e.project_id !== ctx.config.projectId || addressOf(e.mail_from ?? "") !== ctx.config.from.email) {
297
+ throw new EmailError("not_found", "not_found: no email with this id was sent by this sender");
298
+ }
299
+ return {
300
+ id: e.id,
301
+ status: e.status,
302
+ statusDetails: e.status_details || undefined,
303
+ rcptTo: e.rcpt_to,
304
+ rcptType: e.rcpt_type,
305
+ subject: e.subject,
306
+ createdAt: e.created_at,
307
+ updatedAt: e.updated_at,
308
+ tryCount: e.try_count,
309
+ lastTries: e.last_tries?.slice(-5).map((t) => ({ triedAt: t.tried_at, code: t.code, message: t.message?.slice(0, 300) })),
310
+ deliveryEvents: ctx.webhookConfigured ? (ctx.ledger.ownerOf(e.id)?.events[e.id] ?? []) : "webhook not configured",
311
+ };
312
+ }
313
+ export async function emailStatus(ctx, a) {
146
314
  try {
147
- if (!UUID_RE.test(emailId))
315
+ if (!!a.emailId === !!a.idempotency_key)
316
+ throw new EmailError("invalid_request", "invalid_request: give either emailId or idempotency_key");
317
+ if (a.idempotency_key) {
318
+ const r = ctx.ledger.find(ctx.client.id, a.idempotency_key);
319
+ if (!r)
320
+ throw new EmailError("not_found", "not_found: no send with this idempotency_key for this client (kept 30 days)");
321
+ const emails = [];
322
+ for (const id of r.emailIds.slice(0, 10))
323
+ emails.push(await statusOf(ctx, id).catch((err) => ({ id, error: toEmailError(err).message })));
324
+ 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 }));
325
+ }
326
+ const id = a.emailId;
327
+ if (!UUID_RE.test(id))
148
328
  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");
329
+ if (ctx.multiClient && ctx.ledger.ownerOf(id.toLowerCase())?.client !== ctx.client.id) {
330
+ throw new EmailError("not_found", "not_found: no email with this id was sent by this client");
331
+ }
332
+ return text(json(await statusOf(ctx, id.toLowerCase())));
333
+ }
334
+ catch (err) {
335
+ return errorResult(err);
336
+ }
337
+ }
338
+ export async function emailSuppressions(ctx, addresses) {
339
+ try {
340
+ if (!addresses.length || addresses.length > MAX_SUPPRESSION_LOOKUPS)
341
+ throw new EmailError("invalid_request", `invalid_request: 1 to ${MAX_SUPPRESSION_LOOKUPS} addresses`);
342
+ const list = addresses.map((raw) => {
343
+ const a = normalizeAddress(raw);
344
+ if (!a)
345
+ throw new EmailError("invalid_request", `invalid_request: invalid address "${raw.slice(0, 100)}"`);
346
+ // no probing: only addresses this client may write to
347
+ if (!allowedFor(ctx, a))
348
+ throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${a} is not a recipient this client may write to`);
349
+ return a;
350
+ });
351
+ const domain = await ctx.tem.findDomain(domainOf(ctx.config.from.email));
352
+ if (!domain)
353
+ throw new EmailError("domain_not_ready", `domain_not_ready: ${domainOf(ctx.config.from.email)} is not a TEM domain of project ${ctx.config.projectId}`);
354
+ const out = [];
355
+ for (const address of [...new Set(list)]) {
356
+ const entries = await ctx.tem.listBlocklists(domain.id, address);
357
+ out.push({
358
+ address,
359
+ suppressed: entries.some((b) => !b.ends_at || Date.parse(b.ends_at) > Date.now()),
360
+ 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 })),
361
+ });
153
362
  }
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
- }));
363
+ return text(json(out));
364
+ }
365
+ catch (err) {
366
+ return errorResult(err);
367
+ }
368
+ }
369
+ export function emailTemplates(ctx) {
370
+ try {
371
+ if (!ctx.templates.configured)
372
+ throw new EmailError("capability_unavailable", "capability_unavailable: no template directory is configured (EMAIL_TEMPLATES_DIR)");
373
+ return text(json(ctx.templates.list(ctx.client.id).map((t) => ({
374
+ name: t.name,
375
+ version: t.version,
376
+ description: t.description,
377
+ subject: t.subject,
378
+ html: t.html !== undefined,
379
+ variables: t.variables,
380
+ }))));
166
381
  }
167
382
  catch (err) {
168
383
  return errorResult(err);
@@ -178,35 +393,79 @@ export async function emailConfig(ctx) {
178
393
  catch (err) {
179
394
  domain = { name: domainOf(c.from.email), error: toEmailError(err).message };
180
395
  }
396
+ const cl = ctx.client;
397
+ const fmt = (l) => ({ addresses: [...l.addresses], domains: [...l.domains].map((d) => `@${d}`) });
181
398
  return text(json({
399
+ client: cl.id,
182
400
  sender: c.from.name ? `${c.from.name} <${c.from.email}>` : c.from.email,
183
401
  replyTo: c.replyTo,
184
402
  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",
403
+ provider: { name: "Scaleway Transactional Email", region: c.region },
404
+ allowedRecipients: cl.allowlist ? { server: fmt(c.allowlist), client: fmt(cl.allowlist), rule: "a recipient must be in both" } : fmt(c.allowlist),
405
+ limits: {
406
+ maxRecipientsPerEmail: c.maxRecipients,
407
+ client: { maxPerHour: cl.maxPerHour, maxPerDay: cl.maxPerDay },
408
+ remaining: ctx.quotas.remaining(cl.id, cl),
409
+ subjectChars: MAX_SUBJECT,
410
+ textChars: MAX_TEXT,
411
+ htmlChars: MAX_HTML,
412
+ },
413
+ templates: ctx.templates.configured ? { available: ctx.templates.list(cl.id).length, only: cl.templatesOnly } : "not configured",
414
+ attachments: ctx.attachments.configured
415
+ ? { sources: ctx.attachments.sourcesFor(cl.id), types: ctx.attachments.config.types, maxCount: ctx.attachments.config.maxCount, maxTotalBytes: ctx.attachments.config.maxTotalBytes }
416
+ : "not configured",
417
+ deliveryEvents: ctx.webhookConfigured ? "webhook configured" : "webhook not configured",
418
+ killSwitch: ctx.killSwitchActive() ? "active — every send is refused" : c.killSwitchFile ? "inactive" : "not configured",
189
419
  }));
190
420
  }
191
421
  const address = z.string().min(3).max(254);
422
+ const messageShape = {
423
+ to: z.array(address).min(1).max(50).describe("Recipient addresses (bare addresses, each must be allowed for this client)"),
424
+ cc: z.array(address).max(50).optional().describe("Carbon-copy addresses, same rules as to. There is no bcc."),
425
+ subject: z.string().min(1).max(MAX_SUBJECT).optional().describe("Required unless template is given"),
426
+ text: z.string().min(1).max(MAX_TEXT).optional().describe("Plain-text body, required unless template is given"),
427
+ html: z.string().max(MAX_HTML).optional().describe("Optional HTML body; the plain-text body stays the fallback"),
428
+ template: z
429
+ .object({
430
+ name: z.string().max(64),
431
+ version: z.number().int().min(1),
432
+ variables: z.record(z.string().max(40), z.string().max(HARD_VARIABLE_LENGTH)).optional(),
433
+ })
434
+ .strict()
435
+ .optional()
436
+ .describe("A versioned template from email_templates, instead of subject/text/html"),
437
+ attachments: z
438
+ .array(z
439
+ .object({
440
+ source: z.string().max(40).describe("Attachment source id from email_config"),
441
+ 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"),
442
+ filename: z.string().max(100).optional().describe("Name shown to the recipient; its extension must match the file type"),
443
+ })
444
+ .strict())
445
+ .max(HARD_MAX_ATTACHMENTS)
446
+ .optional()
447
+ .describe("Files resolved by the server from configured sources"),
448
+ idempotency_key: z
449
+ .string()
450
+ .regex(IDEMPOTENCY_KEY_RE)
451
+ .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."),
452
+ approvalId: z
453
+ .string()
454
+ .regex(/^[\w.:-]{1,128}$/)
455
+ .optional()
456
+ .describe("Pulse Chat approval id, recorded in the audit trail for correlation (not an authorisation)."),
457
+ };
192
458
  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)."),
459
+ 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));
460
+ 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));
461
+ 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.`, {
462
+ ...messageShape,
463
+ confirmToken: z.string().max(200).describe("Token returned by email_preview for exactly these arguments. Single use, valid 5 minutes."),
210
464
  }, 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));
465
+ 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…).", {
466
+ emailId: z.string().max(64).optional().describe("TEM email id (UUID)"),
467
+ idempotency_key: z.string().regex(IDEMPOTENCY_KEY_RE).optional().describe("The key used for the send"),
468
+ }, async (args) => emailStatus(ctx, args));
469
+ server.tool("email_templates", "Versioned templates you may send (name, version, subject, variables).", {}, async () => emailTemplates(ctx));
470
+ 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
471
  }
@@ -0,0 +1,67 @@
1
+ import { UUID_RE } from "./config.js";
2
+ const EVENT_TYPE_RE = /^[a-z_]{1,40}$/;
3
+ export const CONFIRM_TIMEOUT_MS = 10_000;
4
+ /**
5
+ * Scaleway TEM webhook, delivered through a Topics and Events (SNS) HTTPS subscription.
6
+ * The subscription URL carries the shared secret; the caller checked it before. Only events of
7
+ * emails this server sent (known to the ledger) are kept, and only their type and date.
8
+ */
9
+ export async function handleTemWebhook(body, opts) {
10
+ let payload;
11
+ try {
12
+ payload = JSON.parse(body);
13
+ }
14
+ catch {
15
+ return { status: 400, log: "tem webhook: body is not JSON" };
16
+ }
17
+ if (!payload || typeof payload !== "object")
18
+ return { status: 400, log: "tem webhook: body is not a JSON object" };
19
+ if (payload.Type === "SubscriptionConfirmation") {
20
+ // confirm only towards the Scaleway SNS endpoint of the configured region: never an arbitrary URL
21
+ let url;
22
+ try {
23
+ url = new URL(String(payload.SubscribeURL));
24
+ }
25
+ catch {
26
+ return { status: 400, log: "tem webhook: SubscriptionConfirmation without a valid SubscribeURL" };
27
+ }
28
+ const expected = `sns.mnq.${opts.region}.scaleway.com`;
29
+ if (url.protocol !== "https:" || url.hostname !== expected || url.port) {
30
+ return { status: 400, log: `tem webhook: SubscribeURL host ${url.hostname} refused (expected ${expected})` };
31
+ }
32
+ try {
33
+ const res = await (opts.fetchImpl ?? fetch)(url.href, { method: "GET", redirect: "error", signal: AbortSignal.timeout(CONFIRM_TIMEOUT_MS) });
34
+ if (!res.ok)
35
+ return { status: 502, log: `tem webhook: subscription confirmation answered ${res.status}` };
36
+ }
37
+ catch (err) {
38
+ return { status: 502, log: `tem webhook: subscription confirmation failed (${err instanceof Error ? err.name : "error"})` };
39
+ }
40
+ return { status: 200, log: "tem webhook: SNS subscription confirmed" };
41
+ }
42
+ let event = payload;
43
+ if (payload.Type === "Notification") {
44
+ try {
45
+ event = JSON.parse(String(payload.Message));
46
+ }
47
+ catch {
48
+ return { status: 400, log: "tem webhook: Notification whose Message is not JSON" };
49
+ }
50
+ }
51
+ else if (payload.Type !== undefined) {
52
+ return { status: 202, log: `tem webhook: SNS message type ignored` };
53
+ }
54
+ const type = event?.type;
55
+ const emailId = typeof event?.email_id === "string" ? event.email_id.toLowerCase() : undefined;
56
+ if (typeof type !== "string" || !EVENT_TYPE_RE.test(type))
57
+ return { status: 400, log: "tem webhook: event without a valid type" };
58
+ if (event.project_id !== undefined && String(event.project_id).toLowerCase() !== opts.projectId) {
59
+ return { status: 202, log: "tem webhook: event of another project ignored" };
60
+ }
61
+ if (!emailId || !UUID_RE.test(emailId))
62
+ return { status: 202, log: `tem webhook: ${type} without email id ignored` };
63
+ const created = typeof event.created_at === "string" ? Date.parse(event.created_at) : NaN;
64
+ const at = new Date(Number.isFinite(created) ? created : (opts.now ?? Date.now)()).toISOString();
65
+ const kept = opts.ledger.addEvent(emailId, { type, at });
66
+ return kept ? { status: 204, log: `tem webhook: ${type} recorded for ${emailId}` } : { status: 202, log: `tem webhook: ${type} for an email not sent by this server ignored` };
67
+ }