@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/README.md +148 -65
- package/deploy/clients.example.json +4 -0
- package/deploy/compose.yaml +50 -0
- package/deploy/templates/rapport-hebdo@1.json +13 -0
- package/dist/attachments.js +207 -0
- package/dist/audit.js +4 -31
- package/dist/clients.js +144 -0
- package/dist/config.js +42 -20
- package/dist/errors.js +3 -1
- package/dist/http.js +131 -0
- package/dist/index.js +65 -14
- package/dist/ledger.js +138 -0
- package/dist/quota.js +77 -0
- package/dist/redact.js +23 -0
- package/dist/smtp-client.js +70 -0
- package/dist/tem-client.js +14 -0
- package/dist/templates.js +118 -0
- package/dist/tools.js +440 -102
- package/dist/webhook.js +67 -0
- package/docs/exploitation.md +140 -0
- package/package.json +7 -3
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
|
|
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
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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) => !
|
|
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
|
|
83
|
+
throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${denied.join(", ")} not allowed for this client — nothing was sent`);
|
|
55
84
|
}
|
|
56
|
-
|
|
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(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
81
|
-
throw new EmailError("
|
|
82
|
-
|
|
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
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
95
|
-
|
|
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
|
|
111
|
-
const
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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.
|
|
133
|
-
|
|
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:
|
|
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" :
|
|
140
|
-
ctx.audit.log({ ...
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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: {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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", "
|
|
194
|
-
server.tool("
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
}
|