@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/README.md +135 -65
- package/deploy/clients.example.json +4 -0
- package/deploy/compose.yaml +47 -0
- package/deploy/templates/rapport-hebdo@1.json +13 -0
- package/dist/attachments.js +203 -0
- package/dist/audit.js +4 -31
- package/dist/clients.js +144 -0
- package/dist/config.js +30 -20
- package/dist/errors.js +3 -1
- package/dist/http.js +131 -0
- package/dist/index.js +63 -14
- package/dist/ledger.js +128 -0
- package/dist/quota.js +77 -0
- package/dist/redact.js +23 -0
- package/dist/tem-client.js +5 -0
- package/dist/templates.js +118 -0
- package/dist/tools.js +349 -90
- package/dist/webhook.js +67 -0
- package/docs/exploitation.md +135 -0
- package/package.json +5 -3
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
|
|
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
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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
|
|
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
|
-
|
|
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(
|
|
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) => !
|
|
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
|
|
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 (!
|
|
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(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
81
|
-
throw new EmailError("
|
|
82
|
-
|
|
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
|
|
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`);
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
111
|
-
const
|
|
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
|
|
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.
|
|
133
|
-
|
|
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:
|
|
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" :
|
|
140
|
-
ctx.audit.log({ ...
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
186
|
-
allowedRecipients: {
|
|
187
|
-
limits: {
|
|
188
|
-
|
|
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", "
|
|
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)."),
|
|
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
|
|
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
|
}
|
package/dist/webhook.js
ADDED
|
@@ -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
|
+
}
|