@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.
- package/dist/__tests__/email-tools.test.d.ts +2 -0
- package/dist/__tests__/email-tools.test.d.ts.map +1 -0
- package/dist/__tests__/email-tools.test.js +705 -0
- package/dist/__tests__/email-tools.test.js.map +1 -0
- package/dist/__tests__/extended-tools.test.d.ts +2 -0
- package/dist/__tests__/extended-tools.test.d.ts.map +1 -0
- package/dist/__tests__/extended-tools.test.js +743 -0
- package/dist/__tests__/extended-tools.test.js.map +1 -0
- package/dist/__tests__/external-api-tools.test.d.ts +2 -0
- package/dist/__tests__/external-api-tools.test.d.ts.map +1 -0
- package/dist/__tests__/external-api-tools.test.js +1731 -0
- package/dist/__tests__/external-api-tools.test.js.map +1 -0
- package/dist/__tests__/memory-tools.test.d.ts +2 -0
- package/dist/__tests__/memory-tools.test.d.ts.map +1 -0
- package/dist/__tests__/memory-tools.test.js +0 -0
- package/dist/__tests__/memory-tools.test.js.map +1 -0
- package/dist/audio-tools.d.ts +25 -27
- package/dist/audio-tools.d.ts.map +1 -1
- package/dist/audio-tools.js +156 -438
- package/dist/audio-tools.js.map +1 -1
- package/dist/browser-tools.d.ts.map +1 -1
- package/dist/browser-tools.js +5 -1
- package/dist/browser-tools.js.map +1 -1
- package/dist/email-tools.d.ts.map +1 -1
- package/dist/email-tools.js +11 -3
- package/dist/email-tools.js.map +1 -1
- package/dist/image-tools.d.ts +27 -25
- package/dist/image-tools.d.ts.map +1 -1
- package/dist/image-tools.js +151 -332
- package/dist/image-tools.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/edge-speech-model.d.ts +61 -0
- package/dist/lib/edge-speech-model.d.ts.map +1 -0
- package/dist/lib/edge-speech-model.js +144 -0
- package/dist/lib/edge-speech-model.js.map +1 -0
- package/dist/lib/exa-search-provider.d.ts +27 -0
- package/dist/lib/exa-search-provider.d.ts.map +1 -0
- package/dist/lib/exa-search-provider.js +109 -0
- package/dist/lib/exa-search-provider.js.map +1 -0
- package/dist/lib/provider-resolver.d.ts +54 -0
- package/dist/lib/provider-resolver.d.ts.map +1 -0
- package/dist/lib/provider-resolver.js +115 -0
- package/dist/lib/provider-resolver.js.map +1 -0
- package/dist/search-tools.d.ts +10 -13
- package/dist/search-tools.d.ts.map +1 -1
- package/dist/search-tools.js +63 -140
- package/dist/search-tools.js.map +1 -1
- package/dist/system-tools.d.ts +19 -5
- package/dist/system-tools.d.ts.map +1 -1
- package/dist/system-tools.js +16 -10
- package/dist/system-tools.js.map +1 -1
- package/package.json +12 -2
- package/dist/phone-tools.d.ts +0 -27
- package/dist/phone-tools.d.ts.map +0 -1
- package/dist/phone-tools.js +0 -577
- 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
|