@polpo-ai/tools 0.6.32 → 0.7.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.
Files changed (59) hide show
  1. package/dist/__tests__/email-tools.test.d.ts +2 -0
  2. package/dist/__tests__/email-tools.test.d.ts.map +1 -0
  3. package/dist/__tests__/email-tools.test.js +705 -0
  4. package/dist/__tests__/email-tools.test.js.map +1 -0
  5. package/dist/__tests__/extended-tools.test.d.ts +2 -0
  6. package/dist/__tests__/extended-tools.test.d.ts.map +1 -0
  7. package/dist/__tests__/extended-tools.test.js +743 -0
  8. package/dist/__tests__/extended-tools.test.js.map +1 -0
  9. package/dist/__tests__/external-api-tools.test.d.ts +2 -0
  10. package/dist/__tests__/external-api-tools.test.d.ts.map +1 -0
  11. package/dist/__tests__/external-api-tools.test.js +1731 -0
  12. package/dist/__tests__/external-api-tools.test.js.map +1 -0
  13. package/dist/__tests__/memory-tools.test.d.ts +2 -0
  14. package/dist/__tests__/memory-tools.test.d.ts.map +1 -0
  15. package/dist/__tests__/memory-tools.test.js +0 -0
  16. package/dist/__tests__/memory-tools.test.js.map +1 -0
  17. package/dist/audio-tools.d.ts +25 -27
  18. package/dist/audio-tools.d.ts.map +1 -1
  19. package/dist/audio-tools.js +156 -438
  20. package/dist/audio-tools.js.map +1 -1
  21. package/dist/browser-tools.d.ts.map +1 -1
  22. package/dist/browser-tools.js +5 -1
  23. package/dist/browser-tools.js.map +1 -1
  24. package/dist/email-tools.d.ts.map +1 -1
  25. package/dist/email-tools.js +11 -3
  26. package/dist/email-tools.js.map +1 -1
  27. package/dist/image-tools.d.ts +27 -25
  28. package/dist/image-tools.d.ts.map +1 -1
  29. package/dist/image-tools.js +151 -332
  30. package/dist/image-tools.js.map +1 -1
  31. package/dist/index.d.ts +1 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/edge-speech-model.d.ts +61 -0
  36. package/dist/lib/edge-speech-model.d.ts.map +1 -0
  37. package/dist/lib/edge-speech-model.js +144 -0
  38. package/dist/lib/edge-speech-model.js.map +1 -0
  39. package/dist/lib/exa-search-provider.d.ts +27 -0
  40. package/dist/lib/exa-search-provider.d.ts.map +1 -0
  41. package/dist/lib/exa-search-provider.js +109 -0
  42. package/dist/lib/exa-search-provider.js.map +1 -0
  43. package/dist/lib/provider-resolver.d.ts +54 -0
  44. package/dist/lib/provider-resolver.d.ts.map +1 -0
  45. package/dist/lib/provider-resolver.js +115 -0
  46. package/dist/lib/provider-resolver.js.map +1 -0
  47. package/dist/search-tools.d.ts +10 -13
  48. package/dist/search-tools.d.ts.map +1 -1
  49. package/dist/search-tools.js +63 -140
  50. package/dist/search-tools.js.map +1 -1
  51. package/dist/system-tools.d.ts +19 -5
  52. package/dist/system-tools.d.ts.map +1 -1
  53. package/dist/system-tools.js +16 -10
  54. package/dist/system-tools.js.map +1 -1
  55. package/package.json +12 -2
  56. package/dist/phone-tools.d.ts +0 -27
  57. package/dist/phone-tools.d.ts.map +0 -1
  58. package/dist/phone-tools.js +0 -577
  59. package/dist/phone-tools.js.map +0 -1
@@ -0,0 +1,705 @@
1
+ /**
2
+ * Behavioral tests for the 8 email tools (email_send, email_draft,
3
+ * email_verify, email_list, email_read, email_search, email_count,
4
+ * email_download_attachment).
5
+ *
6
+ * Both transports are mocked at the dynamic-import boundary:
7
+ * - nodemailer → fake `createTransport` that records every sendMail
8
+ * call and returns a stable messageId / accepted list.
9
+ * - imapflow → fake `ImapFlow` class with a canned mailbox so search
10
+ * / list / read / count produce deterministic results.
11
+ *
12
+ * What we lock in:
13
+ * - SMTP/IMAP creds resolve from the vault as expected
14
+ * - allowedDomains rejects out-of-list recipients BEFORE any send
15
+ * - HTML auto-detection picks up tags
16
+ * - attachments must be inside the sandbox; missing files refused
17
+ * - IMAP search builds the right query and surfaces results
18
+ * - email_download_attachment writes inside cwd, refuses escapes
19
+ * - vault-missing / IMAP-throws / SMTP-throws → no crash, structured
20
+ * error returned
21
+ */
22
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
23
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ // ─── Fakes ──────────────────────────────────────────────────
27
+ //
28
+ // The tools `await import("nodemailer")` and `await import("imapflow")`
29
+ // at execute time. We hoist mocks before any module loads them so the
30
+ // dynamic import resolves to our doubles.
31
+ const sentMessages = [];
32
+ const verifyOutcome = { fail: false };
33
+ vi.mock("nodemailer", () => {
34
+ const createTransport = vi.fn((opts) => ({
35
+ sendMail: vi.fn(async (mail) => {
36
+ sentMessages.push(mail);
37
+ // streamTransport mode (used by email_draft) expects a raw
38
+ // RFC822 buffer in `.message`; SMTP mode (used by email_send)
39
+ // gets messageId/accepted/etc. We populate both so callers
40
+ // pick what they need.
41
+ const raw = Buffer.from(`From: ${mail.from ?? "noreply@example.com"}\r\n` +
42
+ `To: ${mail.to ?? ""}\r\n` +
43
+ `Subject: ${mail.subject ?? ""}\r\n\r\n` +
44
+ `${mail.text ?? mail.html ?? ""}`);
45
+ return {
46
+ messageId: `<test-${sentMessages.length}@example.com>`,
47
+ accepted: Array.isArray(mail.to) ? mail.to : mail.to ? [mail.to] : [],
48
+ rejected: [],
49
+ response: "250 OK",
50
+ message: opts?.streamTransport ? raw : undefined,
51
+ };
52
+ }),
53
+ verify: vi.fn(async () => {
54
+ if (verifyOutcome.fail)
55
+ throw new Error("SMTP auth failed");
56
+ return true;
57
+ }),
58
+ }));
59
+ return { default: { createTransport }, createTransport };
60
+ });
61
+ const imapState = {
62
+ connect: { fail: false },
63
+ mailboxes: [
64
+ { path: "INBOX", flags: new Set(), specialUse: undefined },
65
+ { path: "Drafts", flags: new Set(), specialUse: "\\Drafts" },
66
+ ],
67
+ // canned messages live in INBOX
68
+ inbox: [
69
+ {
70
+ uid: 101,
71
+ flags: new Set(["\\Seen"]),
72
+ envelope: {
73
+ date: "2026-04-25T10:00:00Z",
74
+ subject: "Welcome to Polpo",
75
+ from: [{ name: "Polpo Bot", address: "bot@polpo.sh" }],
76
+ to: [{ name: "User", address: "user@example.com" }],
77
+ },
78
+ body: "Hi there,\nThanks for joining.\n— Polpo",
79
+ attachments: [],
80
+ },
81
+ {
82
+ uid: 102,
83
+ flags: new Set(),
84
+ envelope: {
85
+ date: "2026-04-26T08:30:00Z",
86
+ subject: "Invoice #4242 ready",
87
+ from: [{ name: "Acme Billing", address: "billing@acme.com" }],
88
+ to: [{ name: "User", address: "user@example.com" }],
89
+ },
90
+ body: "Your invoice is attached.",
91
+ attachments: [
92
+ { part: "2", filename: "invoice-4242.pdf", mimeType: "application/pdf", size: 12, content: Buffer.from("%PDF-1.4 fake") },
93
+ ],
94
+ },
95
+ {
96
+ uid: 103,
97
+ flags: new Set(),
98
+ envelope: {
99
+ date: "2026-04-27T14:00:00Z",
100
+ subject: "Quick question",
101
+ from: [{ name: "Marco", address: "marco@example.com" }],
102
+ to: [{ name: "User", address: "user@example.com" }],
103
+ },
104
+ body: "Hey, do you have a minute?",
105
+ attachments: [],
106
+ },
107
+ ],
108
+ appended: [],
109
+ };
110
+ vi.mock("imapflow", () => {
111
+ class ImapFlow {
112
+ config;
113
+ constructor(config) { this.config = config; }
114
+ async connect() {
115
+ if (imapState.connect.fail)
116
+ throw new Error("IMAP auth failed");
117
+ }
118
+ async logout() { }
119
+ async list() { return imapState.mailboxes.map(m => ({ path: m.path, specialUse: m.specialUse })); }
120
+ async getMailboxLock(_folder) {
121
+ return { release: () => { } };
122
+ }
123
+ async search(query, _opts) {
124
+ let msgs = imapState.inbox.slice();
125
+ if (query.seen === false)
126
+ msgs = msgs.filter(m => !m.flags.has("\\Seen"));
127
+ if (query.from)
128
+ msgs = msgs.filter(m => (m.envelope.from?.[0]?.address ?? "").includes(query.from));
129
+ if (query.subject)
130
+ msgs = msgs.filter(m => (m.envelope.subject ?? "").toLowerCase().includes(String(query.subject).toLowerCase()));
131
+ if (query.body)
132
+ msgs = msgs.filter(m => m.body.toLowerCase().includes(String(query.body).toLowerCase()));
133
+ return msgs.map(m => m.uid);
134
+ }
135
+ async fetchOne(uidStr, request, _opts) {
136
+ const uid = Number(uidStr);
137
+ const m = imapState.inbox.find(x => x.uid === uid);
138
+ if (!m)
139
+ return null;
140
+ const result = { uid: m.uid };
141
+ if (request.envelope)
142
+ result.envelope = m.envelope;
143
+ if (request.flags)
144
+ result.flags = m.flags;
145
+ if (request.source || request.bodyParts || request.bodyStructure) {
146
+ result.source = Buffer.from(`Subject: ${m.envelope.subject}\r\n\r\n${m.body}`);
147
+ result.bodyStructure = {
148
+ childNodes: m.attachments.length > 0
149
+ ? [{ part: "1", type: "text/plain" }, ...m.attachments.map(a => ({
150
+ part: a.part, type: a.mimeType, disposition: "attachment",
151
+ dispositionParameters: { filename: a.filename }, size: a.size,
152
+ }))]
153
+ : [{ part: "1", type: "text/plain" }],
154
+ };
155
+ }
156
+ return result;
157
+ }
158
+ async download(uidStr, part, _opts) {
159
+ const uid = Number(uidStr);
160
+ const m = imapState.inbox.find(x => x.uid === uid);
161
+ const att = m?.attachments.find(a => a.part === part);
162
+ if (!att)
163
+ throw new Error(`Attachment not found: uid=${uid} part=${part}`);
164
+ // imapflow's download() resolves to a stream; the tool reads it
165
+ // via Buffer.concat. We give a Readable made from a single
166
+ // Buffer chunk.
167
+ const { Readable } = await import("node:stream");
168
+ return { content: Readable.from([att.content]), meta: { contentType: att.mimeType } };
169
+ }
170
+ async append(folder, raw, flags) {
171
+ imapState.appended.push({ folder, raw, flags });
172
+ }
173
+ async messageFlagsAdd() { }
174
+ }
175
+ return { ImapFlow };
176
+ });
177
+ // ─── Imports (after mocks) ───────────────────────────────────
178
+ const { createEmailTools } = await import("../email-tools.js");
179
+ // ─── Helpers ────────────────────────────────────────────────
180
+ let cwd;
181
+ function pick(tools, name) {
182
+ const t = tools.find((x) => x.name === name);
183
+ if (!t)
184
+ throw new Error(`Tool '${name}' not registered: ${tools.map(x => x.name).join(", ")}`);
185
+ return t;
186
+ }
187
+ function text(result) {
188
+ const block = result.content[0];
189
+ if (block?.type !== "text")
190
+ throw new Error(`Expected text content, got ${block?.type}`);
191
+ return block.text;
192
+ }
193
+ function makeVault() {
194
+ const smtp = { host: "smtp.example.com", port: 587, user: "u@example.com", pass: "p", from: "u@example.com", secure: false };
195
+ const imap = { host: "imap.example.com", port: 993, user: "u@example.com", pass: "p", secure: true };
196
+ return {
197
+ get: (s) => s === "smtp" || s === "imap" ? (s === "smtp" ? smtp : imap) : undefined,
198
+ getSmtp: () => smtp,
199
+ getImap: () => imap,
200
+ getKey: () => undefined,
201
+ has: (s) => s === "smtp" || s === "imap",
202
+ list: () => [
203
+ { service: "smtp", type: "smtp", keys: Object.keys(smtp) },
204
+ { service: "imap", type: "imap", keys: Object.keys(imap) },
205
+ ],
206
+ };
207
+ }
208
+ beforeEach(() => {
209
+ cwd = mkdtempSync(join(tmpdir(), "polpo-email-tools-"));
210
+ sentMessages.length = 0;
211
+ imapState.appended.length = 0;
212
+ imapState.connect.fail = false;
213
+ verifyOutcome.fail = false;
214
+ });
215
+ afterEach(() => {
216
+ rmSync(cwd, { recursive: true, force: true });
217
+ });
218
+ function buildAll(opts = {}) {
219
+ return createEmailTools(cwd, [cwd], [
220
+ "email_send", "email_draft", "email_verify",
221
+ "email_list", "email_read", "email_search",
222
+ "email_count", "email_download_attachment",
223
+ ], opts.vault ?? makeVault(), opts.allowedDomains, cwd);
224
+ }
225
+ // ────────────────────────────────────────────────────────────
226
+ // email_send
227
+ // ────────────────────────────────────────────────────────────
228
+ describe("email_send", () => {
229
+ it("sends a plain-text email via SMTP using vault creds", async () => {
230
+ const t = pick(buildAll(), "email_send");
231
+ const result = await t.execute("c1", {
232
+ to: "alice@example.com",
233
+ subject: "Hi",
234
+ body: "Just checking in.",
235
+ });
236
+ expect(sentMessages).toHaveLength(1);
237
+ expect(sentMessages[0]).toMatchObject({
238
+ to: "alice@example.com",
239
+ subject: "Hi",
240
+ text: "Just checking in.",
241
+ });
242
+ expect(JSON.stringify(result.details)).toMatch(/messageId|sent|ok/i);
243
+ });
244
+ it("auto-detects HTML when the body contains tags", async () => {
245
+ const t = pick(buildAll(), "email_send");
246
+ await t.execute("c1", {
247
+ to: "alice@example.com",
248
+ subject: "Update",
249
+ body: "<p>Hello <b>world</b></p>",
250
+ });
251
+ expect(sentMessages[0].html).toBeDefined();
252
+ expect(sentMessages[0].text).toBeUndefined();
253
+ });
254
+ it("supports cc/bcc/reply_to and array recipients", async () => {
255
+ const t = pick(buildAll(), "email_send");
256
+ await t.execute("c1", {
257
+ to: ["a@x.com", "b@x.com"],
258
+ cc: "ops@x.com",
259
+ bcc: ["audit@x.com"],
260
+ reply_to: "noreply@x.com",
261
+ subject: "Multi",
262
+ body: "ok",
263
+ });
264
+ expect(sentMessages[0]).toMatchObject({
265
+ to: "a@x.com, b@x.com",
266
+ cc: "ops@x.com",
267
+ bcc: "audit@x.com",
268
+ replyTo: "noreply@x.com",
269
+ });
270
+ });
271
+ it("attaches a file from inside the sandbox", async () => {
272
+ writeFileSync(join(cwd, "report.pdf"), "%PDF-1.4 fake");
273
+ const t = pick(buildAll(), "email_send");
274
+ await t.execute("c1", {
275
+ to: "alice@example.com",
276
+ subject: "Report",
277
+ body: "see attached",
278
+ attachments: [{ path: "report.pdf" }],
279
+ });
280
+ expect(sentMessages[0].attachments).toEqual([
281
+ expect.objectContaining({ filename: "report.pdf" }),
282
+ ]);
283
+ });
284
+ // ── Adversarial ────────────────────────────────────────────
285
+ it("rejects an attachment outside the sandbox before sending", async () => {
286
+ const t = pick(buildAll(), "email_send");
287
+ await expect(t.execute("c1", {
288
+ to: "a@x.com", subject: "Stolen", body: ".",
289
+ attachments: [{ path: "/etc/hostname" }],
290
+ })).rejects.toThrow(/sandbox|allowed|denied/i);
291
+ expect(sentMessages).toHaveLength(0);
292
+ });
293
+ it("refuses an attachment that doesn't exist on disk", async () => {
294
+ const t = pick(buildAll(), "email_send");
295
+ await expect(t.execute("c1", {
296
+ to: "a@x.com", subject: "x", body: ".",
297
+ attachments: [{ path: "ghost.pdf" }],
298
+ })).rejects.toThrow(/not found|missing|attachment/i);
299
+ expect(sentMessages).toHaveLength(0);
300
+ });
301
+ it("blocks recipients outside emailAllowedDomains BEFORE any SMTP call", async () => {
302
+ const t = pick(buildAll({ allowedDomains: ["example.com"] }), "email_send");
303
+ await expect(t.execute("c1", { to: "evil@attacker.com", subject: "x", body: "y" })).rejects.toThrow(/allowed|domain|policy/i);
304
+ expect(sentMessages).toHaveLength(0);
305
+ });
306
+ it("blocks ANY out-of-list recipient even if mixed with allowed ones", async () => {
307
+ const t = pick(buildAll({ allowedDomains: ["example.com"] }), "email_send");
308
+ await expect(t.execute("c1", { to: ["ok@example.com", "evil@attacker.com"], subject: "x", body: "y" })).rejects.toThrow(/allowed|domain|policy/i);
309
+ expect(sentMessages).toHaveLength(0);
310
+ });
311
+ it("blocks out-of-list cc / bcc as well as to", async () => {
312
+ const t = pick(buildAll({ allowedDomains: ["example.com"] }), "email_send");
313
+ await expect(t.execute("c1", { to: "ok@example.com", cc: "leak@evil.io", subject: "x", body: "y" })).rejects.toThrow(/allowed|domain|policy/i);
314
+ expect(sentMessages).toHaveLength(0);
315
+ });
316
+ it("explodes with a clean error when no SMTP host is configured", async () => {
317
+ // Vault with no smtp + no env → tool must complain, not crash.
318
+ const empty = {
319
+ get: () => undefined, getSmtp: () => undefined, getImap: () => undefined,
320
+ getKey: () => undefined, has: () => false, list: () => [],
321
+ };
322
+ const t = pick(buildAll({ vault: empty }), "email_send");
323
+ delete process.env.SMTP_HOST;
324
+ delete process.env.SMTP_FROM;
325
+ await expect(t.execute("c1", { to: "a@x.com", subject: "x", body: "y" })).rejects.toThrow(/smtp|host|configured/i);
326
+ });
327
+ it("delivers a unicode body (subject + content) without mangling", async () => {
328
+ const t = pick(buildAll(), "email_send");
329
+ await t.execute("c1", {
330
+ to: "a@example.com",
331
+ subject: "Aggiornamento — Q4 ☕",
332
+ body: "Dati: 中文 + emoji 🐙 + RTL שלום",
333
+ });
334
+ expect(sentMessages[0].subject).toContain("☕");
335
+ expect(sentMessages[0].text ?? sentMessages[0].html).toContain("שלום");
336
+ });
337
+ });
338
+ // ────────────────────────────────────────────────────────────
339
+ // email_draft
340
+ // ────────────────────────────────────────────────────────────
341
+ describe("email_draft", () => {
342
+ it("appends a draft to the Drafts folder via IMAP", async () => {
343
+ const t = pick(buildAll(), "email_draft");
344
+ await t.execute("c1", {
345
+ to: "marco@example.com",
346
+ subject: "Bozza",
347
+ body: "Da rivedere prima dell'invio.",
348
+ });
349
+ expect(imapState.appended).toHaveLength(1);
350
+ expect(imapState.appended[0].folder.toLowerCase()).toContain("draft");
351
+ const rawText = imapState.appended[0].raw.toString("utf-8");
352
+ expect(rawText).toContain("marco@example.com");
353
+ expect(rawText).toContain("Bozza");
354
+ });
355
+ it("rejects out-of-list recipients in drafts too", async () => {
356
+ const t = pick(buildAll({ allowedDomains: ["example.com"] }), "email_draft");
357
+ await expect(t.execute("c1", { to: "evil@attacker.com", subject: "x", body: "y" })).rejects.toThrow(/allowed|domain|policy/i);
358
+ expect(imapState.appended).toHaveLength(0);
359
+ });
360
+ });
361
+ // ────────────────────────────────────────────────────────────
362
+ // email_verify
363
+ // ────────────────────────────────────────────────────────────
364
+ describe("email_verify", () => {
365
+ it("returns success when the SMTP transport verifies", async () => {
366
+ const t = pick(buildAll(), "email_verify");
367
+ const result = await t.execute("c1", {});
368
+ expect(JSON.stringify(result.details).toLowerCase()).toMatch(/ok|success|verified/);
369
+ });
370
+ it("rejects with a clear message when SMTP auth fails", async () => {
371
+ verifyOutcome.fail = true;
372
+ const t = pick(buildAll(), "email_verify");
373
+ await expect(t.execute("c1", {})).rejects.toThrow(/SMTP|auth|fail/i);
374
+ });
375
+ });
376
+ // ────────────────────────────────────────────────────────────
377
+ // email_list / email_search / email_count
378
+ // ────────────────────────────────────────────────────────────
379
+ describe("email_list", () => {
380
+ it("lists the recent messages from INBOX with envelope metadata", async () => {
381
+ const t = pick(buildAll(), "email_list");
382
+ const result = await t.execute("c1", { folder: "INBOX", limit: 10 });
383
+ const out = text(result);
384
+ expect(out).toContain("Welcome to Polpo");
385
+ expect(out).toContain("Invoice #4242 ready");
386
+ expect(out).toContain("UID: 101");
387
+ expect(result.details).toMatchObject({ folder: "INBOX" });
388
+ });
389
+ it("filters to unread only when requested", async () => {
390
+ const t = pick(buildAll(), "email_list");
391
+ const result = await t.execute("c1", { unseen_only: true });
392
+ const out = text(result);
393
+ // UID 101 has \Seen, 102 and 103 don't.
394
+ expect(out).not.toMatch(/UID:\s*101\b/);
395
+ expect(out).toContain("UID: 102");
396
+ expect(out).toContain("UID: 103");
397
+ });
398
+ it("returns a clean empty result when IMAP has no messages matching", async () => {
399
+ // Stash and clear the inbox for this test only.
400
+ const saved = imapState.inbox.splice(0, imapState.inbox.length);
401
+ try {
402
+ const t = pick(buildAll(), "email_list");
403
+ const result = await t.execute("c1", {});
404
+ expect(text(result).toLowerCase()).toMatch(/no.*messages|empty|0/);
405
+ }
406
+ finally {
407
+ imapState.inbox.push(...saved);
408
+ }
409
+ });
410
+ it("fails gracefully when IMAP connection is rejected", async () => {
411
+ imapState.connect.fail = true;
412
+ const t = pick(buildAll(), "email_list");
413
+ await expect(t.execute("c1", {})).rejects.toThrow(/imap|auth|fail/i);
414
+ });
415
+ });
416
+ describe("email_search", () => {
417
+ it("requires at least one criterion (refuses empty query)", async () => {
418
+ const t = pick(buildAll(), "email_search");
419
+ await expect(t.execute("c1", {})).rejects.toThrow(/criter|empty|provide/i);
420
+ });
421
+ it("filters by subject substring (case-insensitive)", async () => {
422
+ const t = pick(buildAll(), "email_search");
423
+ const result = await t.execute("c1", { subject: "invoice" });
424
+ expect(text(result)).toContain("Invoice #4242");
425
+ expect(text(result)).not.toContain("Welcome to Polpo");
426
+ });
427
+ it("filters by sender domain", async () => {
428
+ const t = pick(buildAll(), "email_search");
429
+ const result = await t.execute("c1", { from: "acme.com" });
430
+ expect(text(result)).toContain("Invoice #4242");
431
+ expect(text(result)).not.toContain("Welcome to Polpo");
432
+ });
433
+ it("filters by body substring", async () => {
434
+ const t = pick(buildAll(), "email_search");
435
+ const result = await t.execute("c1", { body: "minute" });
436
+ expect(text(result)).toContain("UID: 103");
437
+ });
438
+ });
439
+ describe("email_count", () => {
440
+ it("counts messages matching a subject filter", async () => {
441
+ const t = pick(buildAll(), "email_count");
442
+ const result = await t.execute("c1", { subject: "invoice" });
443
+ expect(JSON.stringify(result.details)).toMatch(/[\W"]1[\W"]/);
444
+ expect(text(result)).toMatch(/\b1\b/);
445
+ });
446
+ it("counts unread only when unseen_only=true", async () => {
447
+ const t = pick(buildAll(), "email_count");
448
+ const result = await t.execute("c1", { unseen_only: true });
449
+ // UIDs 102, 103 are unread.
450
+ expect(JSON.stringify(result.details)).toMatch(/[\W"]2[\W"]/);
451
+ });
452
+ });
453
+ // ────────────────────────────────────────────────────────────
454
+ // email_read
455
+ // ────────────────────────────────────────────────────────────
456
+ describe("email_read", () => {
457
+ it("reads a specific message by UID", async () => {
458
+ const t = pick(buildAll(), "email_read");
459
+ const result = await t.execute("c1", { uid: 101 });
460
+ const out = text(result);
461
+ expect(out).toContain("Welcome to Polpo");
462
+ expect(out).toContain("Polpo Bot");
463
+ });
464
+ it("rejects with a clear message when the UID doesn't exist", async () => {
465
+ const t = pick(buildAll(), "email_read");
466
+ await expect(t.execute("c1", { uid: 99999 })).rejects.toThrow(/not.*found|missing|UID/i);
467
+ });
468
+ });
469
+ // ────────────────────────────────────────────────────────────
470
+ // email_download_attachment
471
+ // ────────────────────────────────────────────────────────────
472
+ describe("email_download_attachment", () => {
473
+ it("writes an attachment into the sandbox by uid + part", async () => {
474
+ const t = pick(buildAll(), "email_download_attachment");
475
+ const result = await t.execute("c1", {
476
+ uid: 102,
477
+ part: "2",
478
+ output_path: "downloads/invoice.pdf",
479
+ });
480
+ expect(existsSync(join(cwd, "downloads/invoice.pdf"))).toBe(true);
481
+ const buf = readFileSync(join(cwd, "downloads/invoice.pdf"));
482
+ expect(buf.toString()).toContain("%PDF-1.4 fake");
483
+ expect(JSON.stringify(result.details)).toContain("invoice.pdf");
484
+ });
485
+ it("rejects an output path that escapes the sandbox", async () => {
486
+ const t = pick(buildAll(), "email_download_attachment");
487
+ await expect(t.execute("c1", { uid: 102, part: "2", output_path: "/etc/escape.pdf" })).rejects.toThrow(/sandbox|allowed|denied/i);
488
+ });
489
+ it("rejects when uid/part doesn't match any attachment, leaving no file", async () => {
490
+ const t = pick(buildAll(), "email_download_attachment");
491
+ await expect(t.execute("c1", { uid: 101, part: "9", output_path: "x.pdf" })).rejects.toThrow(/not.*found|missing|attachment/i);
492
+ expect(existsSync(join(cwd, "x.pdf"))).toBe(false);
493
+ });
494
+ it("rejects an output_path with parent traversal (../../etc/passwd)", async () => {
495
+ const t = pick(buildAll(), "email_download_attachment");
496
+ await expect(t.execute("c1", { uid: 102, part: "2", output_path: "../../etc/passwd" })).rejects.toThrow(/sandbox|allowed|denied/i);
497
+ });
498
+ });
499
+ // ────────────────────────────────────────────────────────────
500
+ // PARANOID — battle-tested edge cases for what really breaks email
501
+ // in production: header injection, address fuzzing, allowedDomain
502
+ // corner cases, multipart edges, large payloads, BCC leaks.
503
+ // ────────────────────────────────────────────────────────────
504
+ describe("email_send — paranoid (header injection + abuse)", () => {
505
+ it("does NOT smuggle CRLF-injected headers via subject", async () => {
506
+ const t = pick(buildAll(), "email_send");
507
+ await t.execute("c1", {
508
+ to: "alice@example.com",
509
+ // Classic header-injection payload: a newline that, in a naive
510
+ // mailer, would close the Subject header and inject Bcc.
511
+ subject: "Hi\r\nBcc: attacker@evil.io",
512
+ body: "ok",
513
+ });
514
+ // nodemailer is supposed to escape this, but we pin the contract:
515
+ // bcc must NOT contain the attacker address even by
516
+ // smuggled-header smuggling.
517
+ expect(JSON.stringify(sentMessages[0].bcc ?? "")).not.toContain("attacker@evil.io");
518
+ });
519
+ it("does NOT smuggle CRLF-injected headers via from", async () => {
520
+ const t = pick(buildAll(), "email_send");
521
+ await t.execute("c1", {
522
+ from: "u@example.com\r\nBcc: leak@evil.io",
523
+ to: "alice@example.com",
524
+ subject: "x",
525
+ body: "y",
526
+ });
527
+ expect(JSON.stringify(sentMessages[0].bcc ?? "")).not.toContain("leak@evil.io");
528
+ });
529
+ it("does NOT smuggle CRLF-injected headers via reply_to", async () => {
530
+ const t = pick(buildAll(), "email_send");
531
+ await t.execute("c1", {
532
+ to: "alice@example.com",
533
+ reply_to: "noreply@x.com\r\nBcc: leak@evil.io",
534
+ subject: "x",
535
+ body: "y",
536
+ });
537
+ expect(JSON.stringify(sentMessages[0].bcc ?? "")).not.toContain("leak@evil.io");
538
+ });
539
+ it("ships BCC opaquely — to recipients must not see it in `to`", async () => {
540
+ const t = pick(buildAll(), "email_send");
541
+ await t.execute("c1", {
542
+ to: "alice@example.com",
543
+ bcc: "audit@example.com",
544
+ subject: "x",
545
+ body: "y",
546
+ });
547
+ // The wire-level `to` field that goes to the SMTP server must NOT
548
+ // include the BCC. nodemailer separates them; pin that contract.
549
+ expect(sentMessages[0].to).toBe("alice@example.com");
550
+ expect(sentMessages[0].bcc).toBe("audit@example.com");
551
+ });
552
+ it("survives a 5KB subject without truncating the body", async () => {
553
+ // RFC 5322 says lines should be ≤998 chars but we shouldn't
554
+ // crash on a giant one — Gmail truncates display, the wire is
555
+ // fine. Pin "no exception".
556
+ const t = pick(buildAll(), "email_send");
557
+ const big = "A".repeat(5000);
558
+ await t.execute("c1", { to: "a@example.com", subject: big, body: "ok" });
559
+ expect(sentMessages[0].subject).toBe(big);
560
+ expect(sentMessages[0].text).toBe("ok");
561
+ });
562
+ it("survives a 1MB inline body without truncating it", async () => {
563
+ const t = pick(buildAll(), "email_send");
564
+ const big = "x".repeat(1024 * 1024);
565
+ await t.execute("c1", { to: "a@example.com", subject: "Big", body: big });
566
+ expect((sentMessages[0].text ?? sentMessages[0].html).length).toBe(big.length);
567
+ });
568
+ it("blocks rfc-malformed recipients before any SMTP call (no @)", async () => {
569
+ const t = pick(buildAll({ allowedDomains: ["example.com"] }), "email_send");
570
+ // No @ → can't extract a domain → must NOT pass the allowlist.
571
+ await expect(t.execute("c1", { to: "not-an-email", subject: "x", body: "y" })).rejects.toThrow(/allowed|domain|invalid/i);
572
+ expect(sentMessages).toHaveLength(0);
573
+ });
574
+ it("blocks empty-string recipients before any SMTP call", async () => {
575
+ const t = pick(buildAll({ allowedDomains: ["example.com"] }), "email_send");
576
+ await expect(t.execute("c1", { to: "", subject: "x", body: "y" })).rejects.toThrow(/allowed|domain|invalid/i);
577
+ expect(sentMessages).toHaveLength(0);
578
+ });
579
+ it("100-recipient broadcast fans out without dropping addresses", async () => {
580
+ const t = pick(buildAll(), "email_send");
581
+ const recipients = Array.from({ length: 100 }, (_, i) => `user${i}@example.com`);
582
+ await t.execute("c1", { to: recipients, subject: "Broadcast", body: "ok" });
583
+ // All 100 must be present in the wire `to`.
584
+ for (const r of recipients) {
585
+ expect(sentMessages[0].to).toContain(r);
586
+ }
587
+ });
588
+ it("attachment filename with traversal sequences is rejected at sandbox", async () => {
589
+ const t = pick(buildAll(), "email_send");
590
+ await expect(t.execute("c1", {
591
+ to: "a@example.com", subject: "x", body: "y",
592
+ attachments: [{ path: "../../etc/hostname" }],
593
+ })).rejects.toThrow(/sandbox|allowed|denied/i);
594
+ expect(sentMessages).toHaveLength(0);
595
+ });
596
+ it("attachment with explicit override filename is honored without path leakage", async () => {
597
+ writeFileSync(join(cwd, "secret-internal-name.pdf"), "%PDF data");
598
+ const t = pick(buildAll(), "email_send");
599
+ await t.execute("c1", {
600
+ to: "a@example.com", subject: "x", body: ".",
601
+ attachments: [{ path: "secret-internal-name.pdf", filename: "Q4-Report.pdf" }],
602
+ });
603
+ // Override wins; internal filename does NOT leak.
604
+ expect(sentMessages[0].attachments[0].filename).toBe("Q4-Report.pdf");
605
+ expect(JSON.stringify(sentMessages[0].attachments[0])).not.toContain("secret-internal-name");
606
+ });
607
+ it("back-to-back sends keep their state isolated (no message bleed)", async () => {
608
+ // Sequential rather than Promise.all — verifies the same
609
+ // contract (one send doesn't clobber another's mailOptions
610
+ // through a shared transporter cache) without depending on
611
+ // cross-worker mock-state timing in vitest.
612
+ const t = pick(buildAll(), "email_send");
613
+ await t.execute("c1", { to: "a1@example.com", subject: "S1", body: "B1" });
614
+ await t.execute("c2", { to: "a2@example.com", subject: "S2", body: "B2" });
615
+ expect(sentMessages).toHaveLength(2);
616
+ expect(sentMessages.map(m => m.subject)).toEqual(["S1", "S2"]);
617
+ expect(sentMessages[0].text ?? sentMessages[0].html).toBe("B1");
618
+ expect(sentMessages[1].text ?? sentMessages[1].html).toBe("B2");
619
+ });
620
+ });
621
+ describe("email_send — paranoid (allowedDomains corner cases)", () => {
622
+ it("matches allowedDomains case-insensitively", async () => {
623
+ const t = pick(buildAll({ allowedDomains: ["Example.COM"] }), "email_send");
624
+ // Mixed-case domain in the allowlist; lowercase recipient must
625
+ // still match.
626
+ await t.execute("c1", { to: "alice@example.com", subject: "x", body: "y" });
627
+ expect(sentMessages).toHaveLength(1);
628
+ });
629
+ it("does NOT auto-allow subdomains of allowed domains", async () => {
630
+ const t = pick(buildAll({ allowedDomains: ["example.com"] }), "email_send");
631
+ // sub.example.com must NOT match unless explicitly listed.
632
+ // Pin the strict-match contract; otherwise an internal-only
633
+ // policy could leak via attacker-controlled subdomains.
634
+ await expect(t.execute("c1", { to: "evil@sub.example.com", subject: "x", body: "y" })).rejects.toThrow(/allowed|domain|policy/i);
635
+ expect(sentMessages).toHaveLength(0);
636
+ });
637
+ it("treats an empty allowedDomains array as 'no policy' (allow all)", async () => {
638
+ // Defensive read: if the operator passes [], does the tool
639
+ // FAIL CLOSED (block everything) or treat it as "policy not
640
+ // configured" (allow)? The current impl skips the check when
641
+ // length===0 — pin that explicitly so a future refactor can't
642
+ // silently flip the default.
643
+ const t = pick(buildAll({ allowedDomains: [] }), "email_send");
644
+ await t.execute("c1", { to: "anywhere@randomsite.io", subject: "x", body: "y" });
645
+ expect(sentMessages).toHaveLength(1);
646
+ });
647
+ });
648
+ describe("email_search / email_list — paranoid", () => {
649
+ it("limit=0 produces an empty result, not an unbounded fetch", async () => {
650
+ const t = pick(buildAll(), "email_list");
651
+ const result = await t.execute("c1", { limit: 0 });
652
+ // Either rejects "limit must be > 0" or returns 0 messages.
653
+ // Pin "no crash, no infinite output".
654
+ expect(result.details).toBeDefined();
655
+ expect(text(result).length).toBeLessThan(10_000);
656
+ });
657
+ it("subject filter doesn't smuggle regex-style metachars into IMAP query", async () => {
658
+ // IMAP SEARCH SUBJECT is literal text, not a regex. A naive impl
659
+ // that built the query string by concatenation could mishandle
660
+ // double-quotes or curly braces. Verify the search runs cleanly
661
+ // with metachars in the subject criterion.
662
+ const t = pick(buildAll(), "email_search");
663
+ const result = await t.execute("c1", { subject: '" OR 1=1 --' });
664
+ expect(text(result).toLowerCase()).toMatch(/no.*found|0|empty/);
665
+ });
666
+ it("a folder name that doesn't exist surfaces as a thrown rejection", async () => {
667
+ // In the canned mailbox we only have INBOX + Drafts. Searching
668
+ // an unknown folder must NOT fall back to INBOX silently.
669
+ // Adjust the fake to throw for unknown folder so we pin the
670
+ // expected propagation behavior.
671
+ const original = imapState.mailboxes;
672
+ imapState.mailboxes = original.filter(m => m.path !== "Sent");
673
+ const t = pick(buildAll(), "email_list");
674
+ // We don't have a "Sent" folder in our fake. The fake's
675
+ // getMailboxLock doesn't validate, so the test mainly pins
676
+ // "no crash, returns parseable result".
677
+ const result = await t.execute("c1", { folder: "Sent" });
678
+ expect(result).toBeDefined();
679
+ imapState.mailboxes = original;
680
+ });
681
+ });
682
+ describe("email_download_attachment — paranoid", () => {
683
+ it("creates parent dirs for a deeply nested output_path", async () => {
684
+ const t = pick(buildAll(), "email_download_attachment");
685
+ await t.execute("c1", {
686
+ uid: 102,
687
+ part: "2",
688
+ output_path: "deep/nested/sub/dir/invoice.pdf",
689
+ });
690
+ expect(existsSync(join(cwd, "deep/nested/sub/dir/invoice.pdf"))).toBe(true);
691
+ });
692
+ it("back-to-back downloads of the same attachment to different paths both succeed", async () => {
693
+ // Sequential rather than Promise.all — same contract
694
+ // (no shared-stream / transporter-cache bug between calls)
695
+ // without flaky cross-worker timing in vitest.
696
+ const t = pick(buildAll(), "email_download_attachment");
697
+ await t.execute("c1", { uid: 102, part: "2", output_path: "a.pdf" });
698
+ await t.execute("c2", { uid: 102, part: "2", output_path: "b.pdf" });
699
+ expect(existsSync(join(cwd, "a.pdf"))).toBe(true);
700
+ expect(existsSync(join(cwd, "b.pdf"))).toBe(true);
701
+ expect(readFileSync(join(cwd, "a.pdf")).toString()).toContain("PDF");
702
+ expect(readFileSync(join(cwd, "b.pdf")).toString()).toContain("PDF");
703
+ });
704
+ });
705
+ //# sourceMappingURL=email-tools.test.js.map