@polpo-ai/tools 0.2.4

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 (89) hide show
  1. package/dist/adapters/node-filesystem.d.ts +12 -0
  2. package/dist/adapters/node-filesystem.d.ts.map +1 -0
  3. package/dist/adapters/node-filesystem.js +46 -0
  4. package/dist/adapters/node-filesystem.js.map +1 -0
  5. package/dist/adapters/node-shell.d.ts +5 -0
  6. package/dist/adapters/node-shell.d.ts.map +1 -0
  7. package/dist/adapters/node-shell.js +34 -0
  8. package/dist/adapters/node-shell.js.map +1 -0
  9. package/dist/audio-tools.d.ts +42 -0
  10. package/dist/audio-tools.d.ts.map +1 -0
  11. package/dist/audio-tools.js +552 -0
  12. package/dist/audio-tools.js.map +1 -0
  13. package/dist/browser-tools.d.ts +36 -0
  14. package/dist/browser-tools.d.ts.map +1 -0
  15. package/dist/browser-tools.js +525 -0
  16. package/dist/browser-tools.js.map +1 -0
  17. package/dist/coding-tools.d.ts +99 -0
  18. package/dist/coding-tools.d.ts.map +1 -0
  19. package/dist/coding-tools.js +434 -0
  20. package/dist/coding-tools.js.map +1 -0
  21. package/dist/docx-tools.d.ts +22 -0
  22. package/dist/docx-tools.d.ts.map +1 -0
  23. package/dist/docx-tools.js +236 -0
  24. package/dist/docx-tools.js.map +1 -0
  25. package/dist/email-tools.d.ts +34 -0
  26. package/dist/email-tools.d.ts.map +1 -0
  27. package/dist/email-tools.js +787 -0
  28. package/dist/email-tools.js.map +1 -0
  29. package/dist/excel-tools.d.ts +25 -0
  30. package/dist/excel-tools.d.ts.map +1 -0
  31. package/dist/excel-tools.js +409 -0
  32. package/dist/excel-tools.js.map +1 -0
  33. package/dist/http-tools.d.ts +23 -0
  34. package/dist/http-tools.d.ts.map +1 -0
  35. package/dist/http-tools.js +214 -0
  36. package/dist/http-tools.js.map +1 -0
  37. package/dist/image-tools.d.ts +40 -0
  38. package/dist/image-tools.d.ts.map +1 -0
  39. package/dist/image-tools.js +522 -0
  40. package/dist/image-tools.js.map +1 -0
  41. package/dist/index.d.ts +33 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +37 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/memory-tools.d.ts +19 -0
  46. package/dist/memory-tools.d.ts.map +1 -0
  47. package/dist/memory-tools.js +104 -0
  48. package/dist/memory-tools.js.map +1 -0
  49. package/dist/outcome-tools.d.ts +25 -0
  50. package/dist/outcome-tools.d.ts.map +1 -0
  51. package/dist/outcome-tools.js +191 -0
  52. package/dist/outcome-tools.js.map +1 -0
  53. package/dist/path-sandbox.d.ts +28 -0
  54. package/dist/path-sandbox.d.ts.map +1 -0
  55. package/dist/path-sandbox.js +58 -0
  56. package/dist/path-sandbox.js.map +1 -0
  57. package/dist/pdf-tools.d.ts +25 -0
  58. package/dist/pdf-tools.d.ts.map +1 -0
  59. package/dist/pdf-tools.js +363 -0
  60. package/dist/pdf-tools.js.map +1 -0
  61. package/dist/phone-tools.d.ts +27 -0
  62. package/dist/phone-tools.d.ts.map +1 -0
  63. package/dist/phone-tools.js +577 -0
  64. package/dist/phone-tools.js.map +1 -0
  65. package/dist/safe-env.d.ts +26 -0
  66. package/dist/safe-env.d.ts.map +1 -0
  67. package/dist/safe-env.js +76 -0
  68. package/dist/safe-env.js.map +1 -0
  69. package/dist/search-tools.d.ts +22 -0
  70. package/dist/search-tools.d.ts.map +1 -0
  71. package/dist/search-tools.js +205 -0
  72. package/dist/search-tools.js.map +1 -0
  73. package/dist/ssrf-guard.d.ts +17 -0
  74. package/dist/ssrf-guard.d.ts.map +1 -0
  75. package/dist/ssrf-guard.js +95 -0
  76. package/dist/ssrf-guard.js.map +1 -0
  77. package/dist/types.d.ts +21 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +5 -0
  80. package/dist/types.js.map +1 -0
  81. package/dist/vault-tools.d.ts +26 -0
  82. package/dist/vault-tools.d.ts.map +1 -0
  83. package/dist/vault-tools.js +86 -0
  84. package/dist/vault-tools.js.map +1 -0
  85. package/dist/whatsapp-tools.d.ts +18 -0
  86. package/dist/whatsapp-tools.d.ts.map +1 -0
  87. package/dist/whatsapp-tools.js +206 -0
  88. package/dist/whatsapp-tools.js.map +1 -0
  89. package/package.json +56 -0
@@ -0,0 +1,787 @@
1
+ /**
2
+ * Email tools for sending and reading messages via SMTP/IMAP.
3
+ *
4
+ * Provides tools for agents to:
5
+ * - Send emails with HTML or plain text (SMTP)
6
+ * - Save draft emails to IMAP Drafts folder
7
+ * - Add attachments from local files
8
+ * - Send to multiple recipients (to, cc, bcc)
9
+ * - List, read, and search emails (IMAP)
10
+ *
11
+ * Credential resolution order:
12
+ * 1. Tool parameters (explicit overrides)
13
+ * 2. Agent vault (per-agent credentials from polpo.json)
14
+ * 3. Environment variables (global fallback)
15
+ *
16
+ * SMTP env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
17
+ * IMAP env vars: IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASS
18
+ */
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
20
+ import { resolve, basename, join, dirname } from "node:path";
21
+ import { Type } from "@sinclair/typebox";
22
+ import { resolveAllowedPaths, assertPathAllowed } from "./path-sandbox.js";
23
+ // ─── Tool: email_send ───
24
+ const EmailSendSchema = Type.Object({
25
+ to: Type.Union([
26
+ Type.String(),
27
+ Type.Array(Type.String()),
28
+ ], { description: "Recipient email address(es)" }),
29
+ subject: Type.String({ description: "Email subject line" }),
30
+ body: Type.String({ description: "Email body content (plain text or HTML)" }),
31
+ html: Type.Optional(Type.Boolean({ description: "Treat body as HTML (default: auto-detect)" })),
32
+ cc: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "CC recipients" })),
33
+ bcc: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "BCC recipients" })),
34
+ from: Type.Optional(Type.String({ description: "Sender address (overrides vault/env)" })),
35
+ reply_to: Type.Optional(Type.String({ description: "Reply-to address" })),
36
+ attachments: Type.Optional(Type.Array(Type.Object({
37
+ path: Type.String({ description: "File path of the attachment" }),
38
+ filename: Type.Optional(Type.String({ description: "Override filename in the email" })),
39
+ }), { description: "File attachments" })),
40
+ // SMTP config overrides (optional - defaults to vault then env vars)
41
+ smtp_host: Type.Optional(Type.String({ description: "SMTP host (overrides vault/env)" })),
42
+ smtp_port: Type.Optional(Type.Number({ description: "SMTP port (overrides vault/env)" })),
43
+ smtp_user: Type.Optional(Type.String({ description: "SMTP user (overrides vault/env)" })),
44
+ smtp_pass: Type.Optional(Type.String({ description: "SMTP password (overrides vault/env)" })),
45
+ smtp_secure: Type.Optional(Type.Boolean({ description: "Use TLS (default: true for port 465, STARTTLS for others)" })),
46
+ });
47
+ const EmailDraftSchema = Type.Object({
48
+ to: Type.Optional(Type.Union([
49
+ Type.String(),
50
+ Type.Array(Type.String()),
51
+ ], { description: "Recipient email address(es)" })),
52
+ subject: Type.Optional(Type.String({ description: "Email subject line" })),
53
+ body: Type.Optional(Type.String({ description: "Draft body content (plain text or HTML)" })),
54
+ html: Type.Optional(Type.Boolean({ description: "Treat body as HTML (default: auto-detect)" })),
55
+ cc: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "CC recipients" })),
56
+ bcc: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "BCC recipients" })),
57
+ from: Type.Optional(Type.String({ description: "Sender address (defaults to SMTP_FROM or IMAP user)" })),
58
+ reply_to: Type.Optional(Type.String({ description: "Reply-to address" })),
59
+ attachments: Type.Optional(Type.Array(Type.Object({
60
+ path: Type.String({ description: "File path of the attachment" }),
61
+ filename: Type.Optional(Type.String({ description: "Override filename in the draft" })),
62
+ }), { description: "File attachments" })),
63
+ folder: Type.Optional(Type.String({ description: "Drafts folder path (default: auto-detect from IMAP special-use, then 'Drafts')" })),
64
+ imap_host: Type.Optional(Type.String({ description: "IMAP host (overrides vault/env)" })),
65
+ imap_port: Type.Optional(Type.Number({ description: "IMAP port (overrides vault/env)" })),
66
+ imap_user: Type.Optional(Type.String({ description: "IMAP user (overrides vault/env)" })),
67
+ imap_pass: Type.Optional(Type.String({ description: "IMAP password (overrides vault/env)" })),
68
+ imap_tls: Type.Optional(Type.Boolean({ description: "Use TLS for IMAP (overrides vault/env)" })),
69
+ });
70
+ /**
71
+ * Validate that all recipient addresses are in allowed domains.
72
+ * Throws if any address has a domain not in the allowlist.
73
+ */
74
+ function validateRecipientDomains(addresses, allowedDomains) {
75
+ const addrs = Array.isArray(addresses) ? addresses : [addresses];
76
+ for (const addr of addrs) {
77
+ const atIdx = addr.lastIndexOf("@");
78
+ if (atIdx < 0)
79
+ continue; // malformed — let SMTP reject it
80
+ const domain = addr.slice(atIdx + 1).toLowerCase().trim();
81
+ if (!allowedDomains.some(d => d.toLowerCase() === domain)) {
82
+ throw new Error(`Recipient domain "${domain}" is not in the allowed domains: ${allowedDomains.join(", ")}`);
83
+ }
84
+ }
85
+ }
86
+ function createEmailSendTool(cwd, sandbox, vault, emailAllowedDomains) {
87
+ return {
88
+ name: "email_send",
89
+ label: "Send Email",
90
+ description: "Send an email via SMTP. Supports HTML content, multiple recipients (to/cc/bcc), " +
91
+ "file attachments, and reply-to. Credentials are resolved from: tool params > agent vault > env vars.",
92
+ parameters: EmailSendSchema,
93
+ async execute(_id, params) {
94
+ // Resolve SMTP config: tool params > vault > env vars
95
+ const vaultSmtp = vault?.getSmtp();
96
+ const host = params.smtp_host ?? vaultSmtp?.host ?? process.env.SMTP_HOST;
97
+ const port = params.smtp_port ?? vaultSmtp?.port ?? Number(process.env.SMTP_PORT ?? "587");
98
+ const user = params.smtp_user ?? vaultSmtp?.user ?? process.env.SMTP_USER;
99
+ const pass = params.smtp_pass ?? vaultSmtp?.pass ?? process.env.SMTP_PASS;
100
+ const from = params.from ?? vaultSmtp?.from ?? process.env.SMTP_FROM;
101
+ if (!host)
102
+ throw new Error("SMTP host not configured. Set SMTP_HOST env var, configure vault, or pass smtp_host parameter.");
103
+ if (!from)
104
+ throw new Error("Sender address not configured. Set SMTP_FROM env var, configure vault, or pass 'from' parameter.");
105
+ // Validate recipient domains against allowlist
106
+ if (emailAllowedDomains && emailAllowedDomains.length > 0) {
107
+ validateRecipientDomains(params.to, emailAllowedDomains);
108
+ if (params.cc)
109
+ validateRecipientDomains(params.cc, emailAllowedDomains);
110
+ if (params.bcc)
111
+ validateRecipientDomains(params.bcc, emailAllowedDomains);
112
+ }
113
+ const nodemailer = await import("nodemailer");
114
+ const secure = params.smtp_secure ?? vaultSmtp?.secure ?? (port === 465);
115
+ const transporter = nodemailer.default.createTransport({
116
+ host,
117
+ port,
118
+ secure,
119
+ auth: user ? { user, pass } : undefined,
120
+ });
121
+ // Process attachments
122
+ const attachments = [];
123
+ if (params.attachments) {
124
+ for (const att of params.attachments) {
125
+ const attPath = resolve(cwd, att.path);
126
+ assertPathAllowed(attPath, sandbox, "email_send");
127
+ if (!existsSync(attPath))
128
+ throw new Error(`Attachment not found: ${att.path}`);
129
+ attachments.push({
130
+ filename: att.filename ?? basename(attPath),
131
+ content: readFileSync(attPath),
132
+ });
133
+ }
134
+ }
135
+ // Detect HTML
136
+ const isHtml = params.html ?? (params.body.includes("<") && params.body.includes(">"));
137
+ const mailOptions = {
138
+ from,
139
+ to: Array.isArray(params.to) ? params.to.join(", ") : params.to,
140
+ subject: params.subject,
141
+ ...(isHtml ? { html: params.body } : { text: params.body }),
142
+ ...(params.cc && { cc: Array.isArray(params.cc) ? params.cc.join(", ") : params.cc }),
143
+ ...(params.bcc && { bcc: Array.isArray(params.bcc) ? params.bcc.join(", ") : params.bcc }),
144
+ ...(params.reply_to && { replyTo: params.reply_to }),
145
+ ...(attachments.length > 0 && { attachments }),
146
+ };
147
+ const info = await transporter.sendMail(mailOptions);
148
+ // Check for rejected recipients
149
+ if (info.rejected?.length) {
150
+ throw new Error(`Email rejected by server for: ${info.rejected.join(", ")}`);
151
+ }
152
+ const recipientCount = (Array.isArray(params.to) ? params.to.length : 1) +
153
+ (params.cc ? (Array.isArray(params.cc) ? params.cc.length : 1) : 0) +
154
+ (params.bcc ? (Array.isArray(params.bcc) ? params.bcc.length : 1) : 0);
155
+ return {
156
+ content: [{ type: "text", text: `Email sent successfully!\nTo: ${params.to}\nSubject: ${params.subject}\nMessage ID: ${info.messageId}\nRecipients: ${recipientCount}${attachments.length ? `\nAttachments: ${attachments.length}` : ""}` }],
157
+ details: {
158
+ messageId: info.messageId,
159
+ accepted: info.accepted,
160
+ rejected: info.rejected,
161
+ recipients: recipientCount,
162
+ attachments: attachments.length,
163
+ },
164
+ };
165
+ },
166
+ };
167
+ }
168
+ function createEmailDraftTool(cwd, sandbox, vault, emailAllowedDomains) {
169
+ return {
170
+ name: "email_draft",
171
+ label: "Save Draft Email",
172
+ description: "Create a draft email by appending a composed message to the IMAP Drafts folder. " +
173
+ "Supports HTML content, multiple recipients (to/cc/bcc), and file attachments.",
174
+ parameters: EmailDraftSchema,
175
+ async execute(_id, params) {
176
+ if (emailAllowedDomains && emailAllowedDomains.length > 0) {
177
+ if (params.to)
178
+ validateRecipientDomains(params.to, emailAllowedDomains);
179
+ if (params.cc)
180
+ validateRecipientDomains(params.cc, emailAllowedDomains);
181
+ if (params.bcc)
182
+ validateRecipientDomains(params.bcc, emailAllowedDomains);
183
+ }
184
+ const vaultSmtp = vault?.getSmtp();
185
+ const vaultImap = vault?.getImap();
186
+ const from = params.from ?? vaultSmtp?.from ?? process.env.SMTP_FROM ?? vaultImap?.user ?? process.env.IMAP_USER;
187
+ const attachments = [];
188
+ if (params.attachments) {
189
+ for (const att of params.attachments) {
190
+ const attPath = resolve(cwd, att.path);
191
+ assertPathAllowed(attPath, sandbox, "email_draft");
192
+ if (!existsSync(attPath))
193
+ throw new Error(`Attachment not found: ${att.path}`);
194
+ attachments.push({
195
+ filename: att.filename ?? basename(attPath),
196
+ content: readFileSync(attPath),
197
+ });
198
+ }
199
+ }
200
+ const body = params.body ?? "";
201
+ const isHtml = params.html ?? (body.includes("<") && body.includes(">"));
202
+ const nodemailer = await import("nodemailer");
203
+ const streamTransport = nodemailer.default.createTransport({
204
+ streamTransport: true,
205
+ buffer: true,
206
+ newline: "windows",
207
+ });
208
+ const mailOptions = {
209
+ ...(from && { from }),
210
+ ...(params.to && { to: Array.isArray(params.to) ? params.to.join(", ") : params.to }),
211
+ ...(params.cc && { cc: Array.isArray(params.cc) ? params.cc.join(", ") : params.cc }),
212
+ ...(params.bcc && { bcc: Array.isArray(params.bcc) ? params.bcc.join(", ") : params.bcc }),
213
+ subject: params.subject ?? "",
214
+ ...(isHtml ? { html: body } : { text: body }),
215
+ ...(params.reply_to && { replyTo: params.reply_to }),
216
+ ...(attachments.length > 0 && { attachments }),
217
+ };
218
+ const composed = await streamTransport.sendMail(mailOptions);
219
+ const rawMessage = composed.message;
220
+ if (!rawMessage || !Buffer.isBuffer(rawMessage)) {
221
+ throw new Error("Failed to compose draft message as RFC822 buffer");
222
+ }
223
+ const client = await connectImap(vault, {
224
+ host: params.imap_host,
225
+ port: params.imap_port,
226
+ user: params.imap_user,
227
+ pass: params.imap_pass,
228
+ tls: params.imap_tls,
229
+ });
230
+ try {
231
+ const folder = params.folder ?? await detectDraftFolder(client);
232
+ const appendResult = await client.append(folder, rawMessage, ["\\Draft"]);
233
+ return {
234
+ content: [{ type: "text", text: `Draft saved successfully!\nFolder: ${folder}\nSubject: ${params.subject ?? "(no subject)"}${params.to ? `\nTo: ${params.to}` : ""}` }],
235
+ details: {
236
+ folder,
237
+ subject: params.subject ?? "",
238
+ to: params.to,
239
+ attachments: attachments.length,
240
+ append: appendResult,
241
+ },
242
+ };
243
+ }
244
+ finally {
245
+ await client.logout();
246
+ }
247
+ },
248
+ };
249
+ }
250
+ // ─── Tool: email_verify ───
251
+ const EmailVerifySchema = Type.Object({
252
+ smtp_host: Type.Optional(Type.String({ description: "SMTP host (overrides vault/env)" })),
253
+ smtp_port: Type.Optional(Type.Number({ description: "SMTP port" })),
254
+ smtp_user: Type.Optional(Type.String({ description: "SMTP user" })),
255
+ smtp_pass: Type.Optional(Type.String({ description: "SMTP password" })),
256
+ });
257
+ function createEmailVerifyTool(vault) {
258
+ return {
259
+ name: "email_verify",
260
+ label: "Verify SMTP",
261
+ description: "Verify SMTP connection and credentials. Use to check that email is properly configured before sending.",
262
+ parameters: EmailVerifySchema,
263
+ async execute(_id, params) {
264
+ const vaultSmtp = vault?.getSmtp();
265
+ const host = params.smtp_host ?? vaultSmtp?.host ?? process.env.SMTP_HOST;
266
+ const port = params.smtp_port ?? vaultSmtp?.port ?? Number(process.env.SMTP_PORT ?? "587");
267
+ const user = params.smtp_user ?? vaultSmtp?.user ?? process.env.SMTP_USER;
268
+ const pass = params.smtp_pass ?? vaultSmtp?.pass ?? process.env.SMTP_PASS;
269
+ if (!host)
270
+ throw new Error("SMTP host not configured. Set SMTP_HOST env var, configure vault, or pass smtp_host parameter.");
271
+ const nodemailer = await import("nodemailer");
272
+ const secure = vaultSmtp?.secure ?? (port === 465);
273
+ const transporter = nodemailer.default.createTransport({
274
+ host,
275
+ port,
276
+ secure,
277
+ auth: user ? { user, pass } : undefined,
278
+ });
279
+ await transporter.verify();
280
+ return {
281
+ content: [{ type: "text", text: `SMTP connection verified: ${host}:${port} (user: ${user ?? "none"})` }],
282
+ details: { verified: true, host, port },
283
+ };
284
+ },
285
+ };
286
+ }
287
+ async function connectImap(vault, overrides) {
288
+ const vaultImap = vault?.getImap();
289
+ const host = overrides?.host ?? vaultImap?.host ?? process.env.IMAP_HOST;
290
+ const port = overrides?.port ?? vaultImap?.port ?? Number(process.env.IMAP_PORT ?? "993");
291
+ const user = overrides?.user ?? vaultImap?.user ?? process.env.IMAP_USER;
292
+ const pass = overrides?.pass ?? vaultImap?.pass ?? process.env.IMAP_PASS;
293
+ const tls = overrides?.tls ?? vaultImap?.tls ?? true;
294
+ if (!host)
295
+ throw new Error("IMAP host not configured. Set IMAP_HOST env var or configure vault.");
296
+ if (!user)
297
+ throw new Error("IMAP user not configured. Set IMAP_USER env var or configure vault.");
298
+ const { ImapFlow } = await import("imapflow");
299
+ const client = new ImapFlow({
300
+ host,
301
+ port,
302
+ secure: tls,
303
+ auth: { user, pass: pass ?? "" },
304
+ logger: false,
305
+ });
306
+ await client.connect();
307
+ return client;
308
+ }
309
+ async function detectDraftFolder(client) {
310
+ try {
311
+ for await (const mailbox of client.list()) {
312
+ if (mailbox?.specialUse === "\\Drafts") {
313
+ return mailbox.path;
314
+ }
315
+ const flags = mailbox?.flags;
316
+ if (flags instanceof Set && flags.has("\\Drafts")) {
317
+ return mailbox.path;
318
+ }
319
+ if (Array.isArray(flags) && flags.includes("\\Drafts")) {
320
+ return mailbox.path;
321
+ }
322
+ }
323
+ }
324
+ catch {
325
+ // Fall back to conventional Drafts folder name.
326
+ }
327
+ return "Drafts";
328
+ }
329
+ // ─── Tool: email_list ───
330
+ const EmailListSchema = Type.Object({
331
+ folder: Type.Optional(Type.String({ description: "Mail folder (default: INBOX)" })),
332
+ limit: Type.Optional(Type.Number({ description: "Max emails to return (default: 20)" })),
333
+ unseen_only: Type.Optional(Type.Boolean({ description: "Only show unread emails (default: false)" })),
334
+ });
335
+ function createEmailListTool(vault) {
336
+ return {
337
+ name: "email_list",
338
+ label: "List Emails",
339
+ description: "List recent emails from the inbox (or specified folder). Returns subject, from, date, and UID for each message. Use email_read to get full content.",
340
+ parameters: EmailListSchema,
341
+ async execute(_id, params) {
342
+ const client = await connectImap(vault);
343
+ const folder = params.folder ?? "INBOX";
344
+ const limit = params.limit ?? 20;
345
+ const lock = await client.getMailboxLock(folder);
346
+ try {
347
+ const messages = [];
348
+ let count = 0;
349
+ // Fetch recent messages (newest first)
350
+ const searchCriteria = params.unseen_only ? { seen: false } : { all: true };
351
+ const searchResult = await client.search(searchCriteria, { uid: true });
352
+ const uids = Array.isArray(searchResult) ? searchResult : [];
353
+ // Get the last N UIDs
354
+ const targetUids = uids.slice(-limit).reverse();
355
+ for (const uid of targetUids) {
356
+ const msg = await client.fetchOne(String(uid), { envelope: true, uid: true, flags: true }, { uid: true });
357
+ if (!msg?.envelope)
358
+ continue;
359
+ const env = msg.envelope;
360
+ const fromAddr = env.from?.[0] ? `${env.from[0].name ?? ""} <${env.from[0].address ?? ""}>`.trim() : "unknown";
361
+ const date = env.date ? new Date(env.date).toISOString() : "unknown";
362
+ const unread = !msg.flags?.has("\\Seen") ? " [UNREAD]" : "";
363
+ messages.push(`UID: ${msg.uid} | ${date} | From: ${fromAddr} | Subject: ${env.subject ?? "(no subject)"}${unread}`);
364
+ count++;
365
+ }
366
+ return {
367
+ content: [{ type: "text", text: messages.length > 0 ? `${folder} — ${count} message(s):\n\n${messages.join("\n")}` : `${folder} — no messages found` }],
368
+ details: { folder, count },
369
+ };
370
+ }
371
+ finally {
372
+ lock.release();
373
+ await client.logout();
374
+ }
375
+ },
376
+ };
377
+ }
378
+ /**
379
+ * Walk a bodyStructure tree and collect attachment metadata.
380
+ * Attachments are parts with disposition=attachment, or non-text/non-multipart
381
+ * inline parts that have a filename.
382
+ */
383
+ function findAttachments(node, path = []) {
384
+ const attachments = [];
385
+ if (!node)
386
+ return attachments;
387
+ const isAttachment = node.disposition === "attachment" ||
388
+ (node.disposition === "inline" && (node.dispositionParameters?.filename || node.parameters?.name)) ||
389
+ (node.type && node.type !== "text" && node.type !== "multipart" && !node.disposition &&
390
+ (node.dispositionParameters?.filename || node.parameters?.name));
391
+ if (isAttachment) {
392
+ const filename = node.dispositionParameters?.filename ??
393
+ node.parameters?.name ??
394
+ `attachment-${path.join(".")}`;
395
+ attachments.push({
396
+ part: path.length ? path.join(".") : "1",
397
+ filename,
398
+ mimeType: `${node.type ?? "application"}/${node.subtype ?? "octet-stream"}`,
399
+ size: node.size,
400
+ encoding: node.encoding,
401
+ });
402
+ }
403
+ if (node.childNodes && Array.isArray(node.childNodes)) {
404
+ for (let i = 0; i < node.childNodes.length; i++) {
405
+ attachments.push(...findAttachments(node.childNodes[i], [...path, i + 1]));
406
+ }
407
+ }
408
+ return attachments;
409
+ }
410
+ // ─── Tool: email_read ───
411
+ const EmailReadSchema = Type.Object({
412
+ uid: Type.Number({ description: "Email UID (from email_list)" }),
413
+ folder: Type.Optional(Type.String({ description: "Mail folder (default: INBOX)" })),
414
+ mark_read: Type.Optional(Type.Boolean({ description: "Mark as read after fetching (default: true)" })),
415
+ download_attachments: Type.Optional(Type.Boolean({ description: "Download all attachments to the output directory (default: false). Use email_download_attachment for selective download." })),
416
+ });
417
+ function createEmailReadTool(vault, outputDir, sandbox) {
418
+ return {
419
+ name: "email_read",
420
+ label: "Read Email",
421
+ description: "Read the full content of an email by UID. Returns headers, body text, and attachment metadata (filename, size, part ID). " +
422
+ "Set download_attachments=true to save all attachments to the output directory, or use email_download_attachment for selective download.",
423
+ parameters: EmailReadSchema,
424
+ async execute(_id, params) {
425
+ const client = await connectImap(vault);
426
+ const folder = params.folder ?? "INBOX";
427
+ const markRead = params.mark_read ?? true;
428
+ const lock = await client.getMailboxLock(folder);
429
+ try {
430
+ const msg = await client.fetchOne(String(params.uid), {
431
+ envelope: true,
432
+ source: true,
433
+ bodyStructure: true,
434
+ uid: true,
435
+ flags: true,
436
+ }, { uid: true });
437
+ if (!msg)
438
+ throw new Error(`Email UID ${params.uid} not found in ${folder}`);
439
+ const env = msg.envelope;
440
+ const fromAddr = env?.from?.[0] ? `${env.from[0].name ?? ""} <${env.from[0].address ?? ""}>`.trim() : "unknown";
441
+ const toAddr = env?.to?.map((a) => `${a.name ?? ""} <${a.address ?? ""}>`.trim()).join(", ") ?? "unknown";
442
+ const date = env?.date ? new Date(env.date).toISOString() : "unknown";
443
+ // Extract text body from source
444
+ let bodyText = "";
445
+ if (msg.source) {
446
+ const source = msg.source.toString("utf-8");
447
+ const headerEnd = source.indexOf("\r\n\r\n");
448
+ if (headerEnd >= 0) {
449
+ bodyText = source.slice(headerEnd + 4);
450
+ if (bodyText.length > 10000) {
451
+ bodyText = bodyText.slice(0, 10000) + "\n... (truncated)";
452
+ }
453
+ }
454
+ }
455
+ // Extract attachment metadata from bodyStructure
456
+ const attachments = findAttachments(msg.bodyStructure);
457
+ // Optionally download all attachments
458
+ const downloadedFiles = [];
459
+ if (params.download_attachments && attachments.length > 0) {
460
+ const downloadDir = outputDir ?? process.cwd();
461
+ for (const att of attachments) {
462
+ const { content } = await client.download(String(params.uid), att.part, { uid: true });
463
+ const chunks = [];
464
+ for await (const chunk of content) {
465
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
466
+ }
467
+ const buffer = Buffer.concat(chunks);
468
+ const filePath = join(downloadDir, att.filename);
469
+ mkdirSync(dirname(filePath), { recursive: true });
470
+ writeFileSync(filePath, buffer);
471
+ downloadedFiles.push(filePath);
472
+ }
473
+ }
474
+ // Mark as read if requested
475
+ if (markRead) {
476
+ try {
477
+ await client.messageFlagsAdd(String(params.uid), ["\\Seen"], { uid: true });
478
+ }
479
+ catch { /* best-effort */ }
480
+ }
481
+ const parts = [
482
+ `From: ${fromAddr}`,
483
+ `To: ${toAddr}`,
484
+ `Date: ${date}`,
485
+ `Subject: ${env?.subject ?? "(no subject)"}`,
486
+ `UID: ${msg.uid}`,
487
+ ];
488
+ if (attachments.length > 0) {
489
+ parts.push(``, `Attachments (${attachments.length}):`);
490
+ for (const att of attachments) {
491
+ const sizeStr = att.size ? ` (${(att.size / 1024).toFixed(1)} KB)` : "";
492
+ parts.push(` - [part ${att.part}] ${att.filename} — ${att.mimeType}${sizeStr}`);
493
+ }
494
+ if (downloadedFiles.length > 0) {
495
+ parts.push(``, `Downloaded to:`);
496
+ for (const f of downloadedFiles)
497
+ parts.push(` - ${f}`);
498
+ }
499
+ }
500
+ parts.push(``, bodyText || "(empty body)");
501
+ return {
502
+ content: [{ type: "text", text: parts.join("\n") }],
503
+ details: {
504
+ uid: params.uid,
505
+ subject: env?.subject,
506
+ attachments: attachments.map(a => ({ part: a.part, filename: a.filename, mimeType: a.mimeType, size: a.size })),
507
+ downloadedFiles: downloadedFiles.length > 0 ? downloadedFiles : undefined,
508
+ },
509
+ };
510
+ }
511
+ finally {
512
+ lock.release();
513
+ await client.logout();
514
+ }
515
+ },
516
+ };
517
+ }
518
+ // ─── Tool: email_download_attachment ───
519
+ const EmailDownloadAttachmentSchema = Type.Object({
520
+ uid: Type.Number({ description: "Email UID (from email_list or email_read)" }),
521
+ part: Type.String({ description: "MIME part number of the attachment (from email_read attachment list, e.g. '2', '1.2')" }),
522
+ folder: Type.Optional(Type.String({ description: "Mail folder (default: INBOX)" })),
523
+ filename: Type.Optional(Type.String({ description: "Override the output filename (default: uses the attachment's original filename)" })),
524
+ output_path: Type.Optional(Type.String({ description: "Custom output path relative to working directory (default: output directory)" })),
525
+ });
526
+ function createEmailDownloadAttachmentTool(vault, cwd, outputDir, sandbox) {
527
+ return {
528
+ name: "email_download_attachment",
529
+ label: "Download Email Attachment",
530
+ description: "Download a specific attachment from an email by UID and MIME part number. " +
531
+ "Use email_read first to see the list of attachments with their part numbers.",
532
+ parameters: EmailDownloadAttachmentSchema,
533
+ async execute(_id, params) {
534
+ const client = await connectImap(vault);
535
+ const folder = params.folder ?? "INBOX";
536
+ const lock = await client.getMailboxLock(folder);
537
+ try {
538
+ // If no filename override, fetch bodyStructure to get the original filename
539
+ let filename = params.filename;
540
+ if (!filename) {
541
+ const msg = await client.fetchOne(String(params.uid), { bodyStructure: true }, { uid: true });
542
+ if (msg?.bodyStructure) {
543
+ const attachments = findAttachments(msg.bodyStructure);
544
+ const match = attachments.find(a => a.part === params.part);
545
+ filename = match?.filename ?? `attachment-${params.part}`;
546
+ }
547
+ else {
548
+ filename = `attachment-${params.part}`;
549
+ }
550
+ }
551
+ // Download the part
552
+ const { meta, content } = await client.download(String(params.uid), params.part, { uid: true });
553
+ const chunks = [];
554
+ for await (const chunk of content) {
555
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
556
+ }
557
+ const buffer = Buffer.concat(chunks);
558
+ if (buffer.length === 0) {
559
+ throw new Error(`Attachment part ${params.part} is empty or not found in UID ${params.uid}`);
560
+ }
561
+ // Determine output path
562
+ let filePath;
563
+ if (params.output_path) {
564
+ filePath = resolve(cwd ?? process.cwd(), params.output_path);
565
+ }
566
+ else {
567
+ filePath = join(outputDir ?? cwd ?? process.cwd(), filename);
568
+ }
569
+ // Sandbox check
570
+ if (sandbox) {
571
+ assertPathAllowed(filePath, sandbox, "email_download_attachment");
572
+ }
573
+ mkdirSync(dirname(filePath), { recursive: true });
574
+ writeFileSync(filePath, buffer);
575
+ const contentType = meta?.contentType ?? "application/octet-stream";
576
+ return {
577
+ content: [{ type: "text", text: `Attachment downloaded:\n File: ${filePath}\n Size: ${(buffer.length / 1024).toFixed(1)} KB\n Type: ${contentType}\n Part: ${params.part}` }],
578
+ details: {
579
+ path: filePath,
580
+ size: buffer.length,
581
+ contentType,
582
+ part: params.part,
583
+ uid: params.uid,
584
+ filename,
585
+ },
586
+ };
587
+ }
588
+ finally {
589
+ lock.release();
590
+ await client.logout();
591
+ }
592
+ },
593
+ };
594
+ }
595
+ // ─── Tool: email_search ───
596
+ const EmailSearchSchema = Type.Object({
597
+ from: Type.Optional(Type.String({ description: "Search by sender address" })),
598
+ to: Type.Optional(Type.String({ description: "Search by recipient address" })),
599
+ subject: Type.Optional(Type.String({ description: "Search by subject (substring)" })),
600
+ since: Type.Optional(Type.String({ description: "Search emails since date (YYYY-MM-DD)" })),
601
+ before: Type.Optional(Type.String({ description: "Search emails before date (YYYY-MM-DD)" })),
602
+ body: Type.Optional(Type.String({ description: "Search by body text (substring)" })),
603
+ answered: Type.Optional(Type.Boolean({ description: "Filter by answered flag (true = answered, false = unanswered)" })),
604
+ folder: Type.Optional(Type.String({ description: "Mail folder (default: INBOX)" })),
605
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
606
+ });
607
+ function createEmailSearchTool(vault) {
608
+ return {
609
+ name: "email_search",
610
+ label: "Search Emails",
611
+ description: "Search emails by sender, recipient, subject, date range, body text, or answered status. Returns matching messages with UID, subject, from, and date.",
612
+ parameters: EmailSearchSchema,
613
+ async execute(_id, params) {
614
+ if (!params.from && !params.to && !params.subject && !params.since && !params.before && !params.body && params.answered === undefined) {
615
+ throw new Error("Provide at least one search criterion (from, to, subject, since, before, body, or answered)");
616
+ }
617
+ const client = await connectImap(vault);
618
+ const folder = params.folder ?? "INBOX";
619
+ const limit = params.limit ?? 20;
620
+ const lock = await client.getMailboxLock(folder);
621
+ try {
622
+ // Build search query
623
+ const query = {};
624
+ if (params.from)
625
+ query.from = params.from;
626
+ if (params.to)
627
+ query.to = params.to;
628
+ if (params.subject)
629
+ query.subject = params.subject;
630
+ if (params.since)
631
+ query.since = params.since;
632
+ if (params.before)
633
+ query.before = params.before;
634
+ if (params.body)
635
+ query.body = params.body;
636
+ if (params.answered === true)
637
+ query.answered = true;
638
+ if (params.answered === false)
639
+ query.unanswered = true;
640
+ const searchResult = await client.search(query, { uid: true });
641
+ const uids = Array.isArray(searchResult) ? searchResult : [];
642
+ const targetUids = uids.slice(-limit).reverse();
643
+ const messages = [];
644
+ for (const uid of targetUids) {
645
+ const msg = await client.fetchOne(String(uid), { envelope: true, uid: true, flags: true }, { uid: true });
646
+ if (!msg?.envelope)
647
+ continue;
648
+ const env = msg.envelope;
649
+ const fromAddr = env.from?.[0] ? `${env.from[0].name ?? ""} <${env.from[0].address ?? ""}>`.trim() : "unknown";
650
+ const date = env.date ? new Date(env.date).toISOString() : "unknown";
651
+ const unread = !msg.flags?.has("\\Seen") ? " [UNREAD]" : "";
652
+ messages.push(`UID: ${msg.uid} | ${date} | From: ${fromAddr} | Subject: ${env.subject ?? "(no subject)"}${unread}`);
653
+ }
654
+ return {
655
+ content: [{ type: "text", text: messages.length > 0 ? `Search results (${messages.length}):\n\n${messages.join("\n")}` : "No emails found matching your criteria" }],
656
+ details: { folder, count: messages.length, query: params },
657
+ };
658
+ }
659
+ finally {
660
+ lock.release();
661
+ await client.logout();
662
+ }
663
+ },
664
+ };
665
+ }
666
+ // ─── Tool: email_count ───
667
+ const EmailCountSchema = Type.Object({
668
+ from: Type.Optional(Type.String({ description: "Count emails from sender" })),
669
+ to: Type.Optional(Type.String({ description: "Count emails to recipient" })),
670
+ subject: Type.Optional(Type.String({ description: "Count emails matching subject (substring)" })),
671
+ since: Type.Optional(Type.String({ description: "Count emails since date (YYYY-MM-DD)" })),
672
+ before: Type.Optional(Type.String({ description: "Count emails before date (YYYY-MM-DD)" })),
673
+ body: Type.Optional(Type.String({ description: "Count emails matching body text (substring)" })),
674
+ answered: Type.Optional(Type.Boolean({ description: "Filter by answered flag (true = answered, false = unanswered)" })),
675
+ unseen_only: Type.Optional(Type.Boolean({ description: "Count only unread emails (default: false)" })),
676
+ folder: Type.Optional(Type.String({ description: "Mail folder (default: INBOX)" })),
677
+ });
678
+ function createEmailCountTool(vault) {
679
+ return {
680
+ name: "email_count",
681
+ label: "Count Emails",
682
+ description: "Count emails matching the given filters without downloading message contents. " +
683
+ "Returns total and unread counts. With no filters, counts all emails in the folder.",
684
+ parameters: EmailCountSchema,
685
+ async execute(_id, params) {
686
+ const client = await connectImap(vault);
687
+ const folder = params.folder ?? "INBOX";
688
+ const lock = await client.getMailboxLock(folder);
689
+ try {
690
+ // Build search query for the filtered count
691
+ const query = {};
692
+ let hasFilter = false;
693
+ if (params.from) {
694
+ query.from = params.from;
695
+ hasFilter = true;
696
+ }
697
+ if (params.to) {
698
+ query.to = params.to;
699
+ hasFilter = true;
700
+ }
701
+ if (params.subject) {
702
+ query.subject = params.subject;
703
+ hasFilter = true;
704
+ }
705
+ if (params.since) {
706
+ query.since = params.since;
707
+ hasFilter = true;
708
+ }
709
+ if (params.before) {
710
+ query.before = params.before;
711
+ hasFilter = true;
712
+ }
713
+ if (params.body) {
714
+ query.body = params.body;
715
+ hasFilter = true;
716
+ }
717
+ if (params.answered === true) {
718
+ query.answered = true;
719
+ hasFilter = true;
720
+ }
721
+ if (params.answered === false) {
722
+ query.unanswered = true;
723
+ hasFilter = true;
724
+ }
725
+ if (params.unseen_only) {
726
+ query.seen = false;
727
+ hasFilter = true;
728
+ }
729
+ if (!hasFilter)
730
+ query.all = true;
731
+ const searchResult = await client.search(query, { uid: true });
732
+ const total = Array.isArray(searchResult) ? searchResult.length : 0;
733
+ // Also get unread count within the same filter
734
+ let unread = 0;
735
+ if (!params.unseen_only) {
736
+ const unreadQuery = { ...query, seen: false };
737
+ delete unreadQuery.all;
738
+ const unreadResult = await client.search(unreadQuery, { uid: true });
739
+ unread = Array.isArray(unreadResult) ? unreadResult.length : 0;
740
+ }
741
+ else {
742
+ unread = total; // already filtered to unseen
743
+ }
744
+ const filterDesc = hasFilter
745
+ ? Object.entries(params).filter(([k, v]) => v !== undefined && k !== "folder").map(([k, v]) => `${k}=${v}`).join(", ")
746
+ : "all";
747
+ return {
748
+ content: [{ type: "text", text: `${folder} — ${total} email(s) matching [${filterDesc}] (${unread} unread)` }],
749
+ details: { folder, total, unread, filters: params },
750
+ };
751
+ }
752
+ finally {
753
+ lock.release();
754
+ await client.logout();
755
+ }
756
+ },
757
+ };
758
+ }
759
+ export const ALL_EMAIL_TOOL_NAMES = ["email_send", "email_draft", "email_verify", "email_list", "email_read", "email_search", "email_count", "email_download_attachment"];
760
+ /**
761
+ * Create email tools.
762
+ *
763
+ * @param cwd - Working directory (for resolving attachment paths)
764
+ * @param allowedPaths - Sandbox paths
765
+ * @param allowedTools - Optional filter
766
+ * @param vault - Resolved vault credentials (per-agent SMTP/IMAP)
767
+ * @param emailAllowedDomains - Allowed recipient email domains (omit for unrestricted)
768
+ * @param outputDir - Per-task output directory for downloaded attachments
769
+ */
770
+ export function createEmailTools(cwd, allowedPaths, allowedTools, vault, emailAllowedDomains, outputDir) {
771
+ const sandbox = resolveAllowedPaths(cwd, allowedPaths);
772
+ const factories = {
773
+ email_send: () => createEmailSendTool(cwd, sandbox, vault, emailAllowedDomains),
774
+ email_draft: () => createEmailDraftTool(cwd, sandbox, vault, emailAllowedDomains),
775
+ email_verify: () => createEmailVerifyTool(vault),
776
+ email_list: () => createEmailListTool(vault),
777
+ email_read: () => createEmailReadTool(vault, outputDir, sandbox),
778
+ email_search: () => createEmailSearchTool(vault),
779
+ email_count: () => createEmailCountTool(vault),
780
+ email_download_attachment: () => createEmailDownloadAttachmentTool(vault, cwd, outputDir, sandbox),
781
+ };
782
+ const names = allowedTools
783
+ ? ALL_EMAIL_TOOL_NAMES.filter(n => allowedTools.some(a => a.toLowerCase() === n))
784
+ : ALL_EMAIL_TOOL_NAMES;
785
+ return names.map(n => factories[n]());
786
+ }
787
+ //# sourceMappingURL=email-tools.js.map