read-email-mcp 1.0.0 → 2.0.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/tools.js CHANGED
@@ -1,24 +1,83 @@
1
1
  import { z } from "zod";
2
+ import { simpleParser } from "mailparser";
2
3
  import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { encodeCursor } from "./store.js";
6
+ const accountSelectorFields = {
7
+ account: z
8
+ .string()
9
+ .min(1)
10
+ .optional()
11
+ .describe("Account name or email. Omit to use all configured accounts (where supported)."),
12
+ accounts: z
13
+ .array(z.string().min(1))
14
+ .optional()
15
+ .describe("Restrict to specific accounts by name/email ('*' wildcard supported), e.g. [\"work\", \"*-gmail\"]"),
16
+ accountGroup: z
17
+ .string()
18
+ .min(1)
19
+ .optional()
20
+ .describe("Restrict to accounts in this configured group (accounts file \"groups\" field)")
21
+ };
22
+ const durationParam = z
23
+ .string()
24
+ .regex(/^\d+[smhdw]$/)
25
+ .optional()
26
+ .describe("Relative lookback window like \"24h\", \"7d\", \"4w\" — sets startDate to now minus the duration");
27
+ const isoDateTime = () => z.iso.datetime({ offset: true });
3
28
  export const toolSchemas = {
4
- sync_email: z.object({
29
+ sync_email: z
30
+ .object({
31
+ ...accountSelectorFields,
5
32
  mailbox: z.string().min(1).optional(),
33
+ mailboxes: z.array(z.string().min(1)).optional(),
34
+ excludeMailboxes: z.array(z.string().min(1)).optional(),
35
+ excludeFlags: z.array(z.string().min(1)).optional(),
6
36
  limit: z.number().int().positive().max(1000).optional(),
7
- since: z.string().datetime().optional()
37
+ since: isoDateTime().optional(),
38
+ uids: z
39
+ .string()
40
+ .regex(/^\d+([:,\-]\d+)*$/)
41
+ .optional(),
42
+ search: z
43
+ .object({
44
+ from: z.string().min(1).optional(),
45
+ to: z.string().min(1).optional(),
46
+ cc: z.string().min(1).optional(),
47
+ subject: z.string().min(1).optional(),
48
+ since: isoDateTime().optional(),
49
+ before: isoDateTime().optional(),
50
+ unseen: z.boolean().optional(),
51
+ flagged: z.boolean().optional()
52
+ })
53
+ .optional(),
54
+ refreshFlags: z.boolean().optional(),
55
+ pruneExpunged: z.boolean().optional(),
56
+ resetMailbox: z.boolean().optional()
57
+ })
58
+ .refine((value) => !value.uids || value.mailbox || value.mailboxes?.length === 1, {
59
+ message: "uids requires exactly one mailbox (pass mailbox, or mailboxes with a single entry)"
8
60
  }),
9
- get_sync_status: z.object({}),
10
- list_mailboxes: z.object({}),
61
+ list_accounts: z.object({}),
62
+ get_sync_status: z.object({ ...accountSelectorFields }),
63
+ list_mailboxes: z.object({ ...accountSelectorFields }),
11
64
  get_recent_emails: z.object({
65
+ ...accountSelectorFields,
12
66
  mailbox: z.string().min(1).optional(),
67
+ mailboxes: z.array(z.string().min(1)).optional(),
13
68
  unreadOnly: z.boolean().optional(),
14
69
  flaggedOnly: z.boolean().optional(),
70
+ last: durationParam,
15
71
  limit: z.number().int().positive().max(100).optional()
16
72
  }),
17
73
  browse_email_dates: z.object({
74
+ ...accountSelectorFields,
18
75
  granularity: z.enum(["day", "week", "month"]).default("day"),
19
76
  mailbox: z.string().min(1).optional(),
20
- startDate: z.string().datetime().optional(),
21
- endDate: z.string().datetime().optional()
77
+ mailboxes: z.array(z.string().min(1)).optional(),
78
+ startDate: isoDateTime().optional(),
79
+ endDate: isoDateTime().optional(),
80
+ last: durationParam
22
81
  }),
23
82
  search_emails: messageFilterSchema().extend({
24
83
  query: z.string().min(1).optional()
@@ -26,114 +85,392 @@ export const toolSchemas = {
26
85
  list_emails: messageFilterSchema(),
27
86
  get_email: z
28
87
  .object({
88
+ ...accountSelectorFields,
29
89
  id: z.number().int().positive().optional(),
30
90
  mailbox: z.string().min(1).optional(),
31
91
  uid: z.number().int().positive().optional(),
32
- messageId: z.string().min(1).optional()
92
+ messageId: z.string().min(1).optional(),
93
+ format: z.enum(["full", "text", "headers", "summary"]).optional(),
94
+ maxBodyChars: z.number().int().positive().max(500_000).optional()
33
95
  })
34
96
  .refine((value) => value.id || value.messageId || (value.mailbox && value.uid), {
35
97
  message: "Provide id, messageId, or mailbox plus uid"
36
98
  }),
99
+ get_emails: z.object({
100
+ ...accountSelectorFields,
101
+ ids: z.array(z.number().int().positive()).min(1).max(25),
102
+ format: z.enum(["full", "text", "headers", "summary"]).optional(),
103
+ maxBodyChars: z.number().int().positive().max(500_000).optional()
104
+ }),
37
105
  get_thread: z
38
106
  .object({
107
+ ...accountSelectorFields,
39
108
  id: z.number().int().positive().optional(),
40
- messageId: z.string().min(1).optional()
109
+ messageId: z.string().min(1).optional(),
110
+ format: z.enum(["full", "text", "headers", "summary"]).optional(),
111
+ maxBodyChars: z.number().int().positive().max(500_000).optional()
41
112
  })
42
113
  .refine((value) => value.id || value.messageId, {
43
114
  message: "Provide id or messageId"
44
115
  }),
116
+ list_attachments: emailLocatorBaseSchema()
117
+ .extend({ ...accountSelectorFields })
118
+ .refine((value) => value.id || value.messageId || (value.mailbox && value.uid), {
119
+ message: "Provide id, messageId, or mailbox plus uid"
120
+ }),
121
+ get_attachment: emailLocatorBaseSchema()
122
+ .extend({
123
+ ...accountSelectorFields,
124
+ index: z.number().int().min(0).optional(),
125
+ filename: z.string().min(1).optional(),
126
+ saveTo: z.string().min(1).optional(),
127
+ maxBytes: z.number().int().positive().max(5_000_000).default(262_144)
128
+ })
129
+ .refine((value) => value.id || value.messageId || (value.mailbox && value.uid), {
130
+ message: "Provide id, messageId, or mailbox plus uid"
131
+ })
132
+ .refine((value) => value.index !== undefined || value.filename !== undefined, {
133
+ message: "Provide index or filename to select the attachment"
134
+ }),
135
+ get_inbox_summary: z.object({
136
+ ...accountSelectorFields,
137
+ unreadLimit: z.number().int().positive().max(50).optional()
138
+ }),
139
+ list_senders: z.object({
140
+ ...accountSelectorFields,
141
+ unreadOnly: z.boolean().optional(),
142
+ limit: z.number().int().positive().max(100).optional()
143
+ }),
144
+ check_accounts: z.object({ ...accountSelectorFields }),
45
145
  get_raw_email: emailLocatorBaseSchema()
46
146
  .extend({
147
+ ...accountSelectorFields,
47
148
  maxBytes: z.number().int().positive().max(1_000_000).default(65_536)
48
149
  })
49
150
  .refine((value) => value.id || value.messageId || (value.mailbox && value.uid), {
50
151
  message: "Provide id, messageId, or mailbox plus uid"
51
152
  }),
52
- get_storage_stats: z.object({}),
53
- optimize_email_store: z.object({})
153
+ get_storage_stats: z.object({ ...accountSelectorFields }),
154
+ optimize_email_store: z.object({ ...accountSelectorFields })
54
155
  };
55
156
  export const toolDescriptions = {
56
- sync_email: "Pull new messages from the IMAP server into the local mirror. Call this first (or after a gap) before browsing/searching, since every other tool reads the local copy. Tip: start with a small limit on one mailbox (e.g. { mailbox: 'INBOX', limit: 10 }) to verify credentials, then sync more. Without a mailbox, syncs all selectable mailboxes. Uses read-only IMAP access and never mutates the server mailbox.",
57
- get_sync_status: "Check how fresh the local mirror is: total messages, latest message date, per-mailbox counts, and the last sync result. Good first call to see whether a sync is needed and whether the previous sync succeeded.",
58
- list_mailboxes: "List the mailboxes known locally (e.g. INBOX, Sent, Drafts) with message counts and last-sync time. Use this to discover mailbox names to pass to sync_email, get_recent_emails, or list_emails.",
59
- get_recent_emails: "Show the latest emails, newest first. This is the everyday 'what's new in my inbox' tool. Set unreadOnly: true to see only unread messages, or flaggedOnly: true for starred messages. Returns summaries (subject, from, date, preview, attachments, read/flagged state); pass the returned id to get_email for the full body, or get_thread to see the whole conversation.",
60
- browse_email_dates: "Summarize message volume over time as counts per day, week, or month. Useful for 'how much email did I get this month' or finding a busy period before drilling in with list_emails/search_emails over that date range.",
61
- search_emails: "Full-text search across subject, sender/recipient addresses, preview, and body text, with the same filters as list_emails (mailbox, from, to, cc, subject, date range, attachments, unread, flagged). Use this when looking for specific content or a topic. Returns paginated summaries; use get_email on a hit for the full message.",
62
- list_emails: "List email summaries in chronological order with filters (mailbox, from, to, cc, subject, date range, attachments, unread, flagged) and keyset pagination, but without full-text search. Prefer this over search_emails when browsing by sender, date, or mailbox rather than searching body text. Defaults to newest first with a page size of 25.",
63
- get_email: "Return the full metadata and body (text and HTML) for a single message. Resolve it by the id returned from get_recent_emails/list_emails/search_emails, by Message-ID, or by mailbox plus UID. Use this after finding a summary you want to read in full.",
64
- get_thread: "Return all locally known messages in one conversation, matched via Message-ID, In-Reply-To, and References headers. Give the id or messageId of any message in the thread. Use this to follow a discussion after get_email or when a search hit looks like part of a longer thread.",
157
+ sync_email: "Pull messages from IMAP into the local mirror. Call this first (or after a gap) before browsing/searching, since every other tool reads the local copy. Default mode is incremental: only UIDs above the per-mailbox high-water mark are fetched. Targeted mode (pass uids, search, or since) fetches exactly the matching messages without moving the incremental cursor — use it to backfill or fetch specific mail, e.g. { mailbox: 'INBOX', uids: '12,15,20-30' } or { mailbox: 'INBOX', search: { from: 'alice@x.com', unseen: true } }. Targeted fetches upsert messages, so they also refresh stored flags/content for those UIDs. Options: mailboxes/excludeMailboxes select folders ('*' wildcard supported), excludeFlags skips special-use folders (default excludes \\All to avoid double-mirroring Gmail-style All Mail), refreshFlags re-syncs \\Seen/\\Flagged state on already-synced messages, pruneExpunged (default on with refreshFlags) removes local copies of server-deleted mail, resetMailbox restarts the incremental scan from UID 1. With multiple accounts, pass account (or accounts/accountGroup for a subset) to scope the sync; omit to sync every account in parallel. Uses read-only IMAP access and never mutates the server mailbox.",
158
+ list_accounts: "List the configured email accounts (name, address, provider, groups, storage path, message counts). Use the returned name as the account parameter on other tools, or a group name as accountGroup, to scope reads and syncs.",
159
+ get_sync_status: "Check how fresh the local mirror is: total messages, latest message date, per-mailbox counts, and the last sync result. Good first call to see whether a sync is needed and whether the previous sync succeeded. Pass account to check one account, or omit for a per-account summary.",
160
+ list_mailboxes: "List the mailboxes known locally (e.g. INBOX, Sent, Drafts) with message counts and last-sync time. Use this to discover mailbox names to pass to sync_email, get_recent_emails, or list_emails. Pass account to list one account's folders.",
161
+ get_recent_emails: "Show the latest emails, newest first. This is the everyday 'what's new in my inbox' tool. Set unreadOnly: true to see only unread messages, or flaggedOnly: true for starred messages; last: '24h' limits to a lookback window. Omit account for a unified inbox across all configured accounts (each item is tagged with its account). Returns summaries; pass the returned id plus its account to get_email for the full body, or get_thread for the whole conversation.",
162
+ browse_email_dates: "Summarize message volume over time as counts per day, week, or month. Useful for 'how much email did I get this month' or finding a busy period before drilling in with list_emails/search_emails over that date range. Omit account for a merged view across accounts.",
163
+ search_emails: "Full-text search across subject, sender/recipient addresses, preview, and body text, with the same filters as list_emails (mailbox/mailboxes, from, to, cc, subject, date range or last: '7d', attachments, unread, flagged). sort: 'relevance' ranks hits by FTS match quality and interleaves each account's best results first. Use this when looking for specific content or a topic. Scope with account, accounts (list/'*' wildcard), or accountGroup; omit all to search every configured account and merge hits. Returns paginated summaries; use get_email on a hit for the full message.",
164
+ list_emails: "List email summaries in chronological order with filters (mailbox/mailboxes, from, to, cc, subject, date range or last: '7d', attachments, unread, flagged) and keyset pagination, but without full-text search. Prefer this over search_emails when browsing by sender, date, or mailbox rather than searching body text. Scope with account, accounts, or accountGroup; omit to merge all accounts into one timeline. Defaults to newest first with a page size of 25.",
165
+ get_email: "Return the full metadata and body for a single message. Resolve it by the id returned from get_recent_emails/list_emails/search_emails, by Message-ID, or by mailbox plus UID. With multiple accounts, pass the account shown on the summary when resolving by id or mailbox+uid (Message-ID lookups search all accounts automatically). Use format 'text' (drop HTML), 'headers' (metadata + headers only), or 'summary' (metadata only), and maxBodyChars to truncate long bodies and save context.",
166
+ get_emails: "Read a group of messages in one call. Pass up to 25 ids returned by get_recent_emails/list_emails/search_emails plus their account (required with multiple accounts). Prefer this over looping get_email when you need several full bodies. Supports the same format/maxBodyChars options as get_email.",
167
+ get_thread: "Return all locally known messages in one conversation, matched via Message-ID, In-Reply-To, and References headers. Give the id or messageId of any message in the thread (plus account when multiple accounts are configured and you resolve by id). Threads are assembled within a single account's mirror. Supports format/maxBodyChars like get_email.",
168
+ list_attachments: "List the attachments on a message (index, filename, content type, size) without downloading them. Resolve the message the same way as get_email. Follow up with get_attachment to extract one.",
169
+ get_attachment: "Extract one attachment from a stored message by index or filename (find indexes with list_attachments). Returns base64 content up to maxBytes (default 256 KiB), or saves to a local path with saveTo for larger files.",
170
+ get_inbox_summary: "One-call triage overview per account: total/unread/flagged counts, mailbox count, latest message date, and the newest unread summaries (unreadLimit, default 10). Omit account for a per-account briefing across everything — the 'what needs attention' tool.",
171
+ list_senders: "Rank senders by message count with unread counts per sender, merged across the selected accounts (each sender lists which accounts they hit). Use unreadOnly for 'who's emailing me that I haven't read'. Great for finding a person before a targeted search.",
172
+ check_accounts: "Test IMAP connectivity for each account: connects, lists mailboxes, disconnects. Reports ok/mailbox count or the error per account — use it to diagnose auth or network problems before a sync.",
65
173
  get_raw_email: "Return the raw RFC822/MIME source for one message, byte-limited (default 64 KiB). Use this only when you need original headers or MIME structure not exposed by get_email, for example to inspect a specific header or attachment encoding.",
66
- get_storage_stats: "Report SQLite database, WAL, and shared-memory file sizes plus page counts for the local mirror. Use this to understand disk usage after large syncs.",
67
- optimize_email_store: "Run lightweight SQLite and FTS maintenance (ANALYZE/optimize) to keep queries fast. Safe to call occasionally after large syncs or before packaging a local archive."
174
+ get_storage_stats: "Report SQLite database, WAL, and shared-memory file sizes plus page counts for the local mirror. Pass account for one account's database or omit for all. Use this to understand disk usage after large syncs.",
175
+ optimize_email_store: "Run lightweight SQLite and FTS maintenance (ANALYZE/optimize) to keep queries fast. Safe to call occasionally after large syncs or before packaging a local archive. Omit account to optimize every account's database."
68
176
  };
69
177
  export function createToolHandlers(context) {
70
178
  return {
71
179
  sync_email: async (input) => {
72
- const args = toolSchemas.sync_email.parse(input);
73
- const result = await context.sync.sync(args);
74
- return json(result);
180
+ const { account, accounts: accountList, accountGroup, ...syncArgs } = toolSchemas.sync_email.parse(input);
181
+ const accounts = resolveAccounts(context, { account, accounts: accountList, accountGroup });
182
+ const settled = await Promise.allSettled(accounts.map((runtime) => runtime.sync.sync(syncArgs)));
183
+ const results = settled.map((outcome, index) => {
184
+ const name = accounts[index].name;
185
+ if (outcome.status === "fulfilled") {
186
+ return { ...outcome.value, account: name };
187
+ }
188
+ const message = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
189
+ return {
190
+ ok: false,
191
+ mode: "incremental",
192
+ mailbox: null,
193
+ startedAt: "",
194
+ finishedAt: new Date().toISOString(),
195
+ message,
196
+ syncedMessages: 0,
197
+ mailboxes: [],
198
+ refreshedFlags: 0,
199
+ prunedMessages: 0,
200
+ errors: [{ mailbox: "*", message }],
201
+ account: name
202
+ };
203
+ });
204
+ if (results.length === 1) {
205
+ return json(results[0]);
206
+ }
207
+ return json({ ok: results.every((result) => result.ok), results });
208
+ },
209
+ list_accounts: async (input) => {
210
+ toolSchemas.list_accounts.parse(input);
211
+ const accounts = context.accounts.map((runtime) => {
212
+ const status = runtime.store.getSyncStatus();
213
+ return {
214
+ name: runtime.name,
215
+ email: runtime.email,
216
+ provider: runtime.provider,
217
+ groups: runtime.groups,
218
+ storagePath: runtime.storagePath,
219
+ totalMessages: status.totalMessages,
220
+ latestMessageDate: status.latestMessageDate
221
+ };
222
+ });
223
+ return json({ accounts });
75
224
  },
76
225
  get_sync_status: async (input) => {
77
- toolSchemas.get_sync_status.parse(input);
226
+ const args = toolSchemas.get_sync_status.parse(input);
227
+ const accounts = resolveAccounts(context, args);
228
+ if (accounts.length === 1) {
229
+ const runtime = accounts[0];
230
+ return json({
231
+ account: runtime.name,
232
+ email: runtime.email,
233
+ storagePath: runtime.storagePath,
234
+ ...runtime.store.getSyncStatus()
235
+ });
236
+ }
78
237
  return json({
79
- account: context.accountEmail,
80
- storagePath: context.storagePath,
81
- ...context.store.getSyncStatus()
238
+ accounts: accounts.map((runtime) => ({
239
+ account: runtime.name,
240
+ email: runtime.email,
241
+ storagePath: runtime.storagePath,
242
+ ...runtime.store.getSyncStatus()
243
+ }))
82
244
  });
83
245
  },
84
246
  list_mailboxes: async (input) => {
85
- toolSchemas.list_mailboxes.parse(input);
86
- return json({ mailboxes: context.store.listMailboxes() });
247
+ const args = toolSchemas.list_mailboxes.parse(input);
248
+ const accounts = resolveAccounts(context, args);
249
+ if (accounts.length === 1) {
250
+ return json({ account: accounts[0].name, mailboxes: accounts[0].store.listMailboxes() });
251
+ }
252
+ return json({
253
+ accounts: accounts.map((runtime) => ({ account: runtime.name, mailboxes: runtime.store.listMailboxes() }))
254
+ });
87
255
  },
88
256
  get_recent_emails: async (input) => {
89
257
  const args = toolSchemas.get_recent_emails.parse(input);
90
- return json(context.store.listMessages({
258
+ const accounts = resolveAccounts(context, args);
259
+ return json(queryAccounts(accounts, {
91
260
  mailbox: args.mailbox,
261
+ mailboxes: args.mailboxes,
92
262
  isUnread: args.unreadOnly,
93
263
  isFlagged: args.flaggedOnly,
264
+ startDate: durationToSince(args.last),
94
265
  limit: args.limit,
95
266
  sort: "date_desc"
96
267
  }));
97
268
  },
98
269
  browse_email_dates: async (input) => {
99
270
  const args = toolSchemas.browse_email_dates.parse(input);
100
- return json({ buckets: context.store.getDateBuckets(args) });
271
+ const accounts = resolveAccounts(context, args);
272
+ const options = {
273
+ granularity: args.granularity,
274
+ mailbox: args.mailbox,
275
+ mailboxes: args.mailboxes,
276
+ startDate: args.startDate ?? durationToSince(args.last),
277
+ endDate: args.endDate
278
+ };
279
+ const perAccount = accounts.map((runtime) => ({
280
+ account: runtime.name,
281
+ buckets: runtime.store.getDateBuckets(options)
282
+ }));
283
+ if (perAccount.length === 1) {
284
+ return json({ account: perAccount[0].account, buckets: perAccount[0].buckets });
285
+ }
286
+ const merged = new Map();
287
+ for (const { buckets } of perAccount) {
288
+ for (const bucket of buckets) {
289
+ merged.set(bucket.bucket, (merged.get(bucket.bucket) ?? 0) + bucket.count);
290
+ }
291
+ }
292
+ const mergedBuckets = [...merged.entries()]
293
+ .map(([bucket, count]) => ({ bucket, count }))
294
+ .sort((a, b) => a.bucket.localeCompare(b.bucket));
295
+ return json({ buckets: mergedBuckets, accounts: perAccount });
101
296
  },
102
297
  search_emails: async (input) => {
103
298
  const args = toolSchemas.search_emails.parse(input);
104
- return json(context.store.searchMessages(args));
299
+ const accounts = resolveAccounts(context, args);
300
+ return json(queryAccounts(accounts, withDuration(args), "search"));
105
301
  },
106
302
  list_emails: async (input) => {
107
303
  const args = toolSchemas.list_emails.parse(input);
108
- return json(context.store.listMessages(args));
304
+ const accounts = resolveAccounts(context, args);
305
+ return json(queryAccounts(accounts, withDuration(args)));
109
306
  },
110
307
  get_email: async (input) => {
111
308
  const args = toolSchemas.get_email.parse(input);
112
- const message = findMessage(context.store, args);
113
- if (!message) {
309
+ const found = findMessage(context, args);
310
+ if (!found) {
114
311
  throw new Error("Email message not found");
115
312
  }
116
- return json({ message });
313
+ return json({
314
+ account: found.account.name,
315
+ message: annotateMessage(shapeMessage(found.message, args.format, args.maxBodyChars), found.account)
316
+ });
317
+ },
318
+ get_emails: async (input) => {
319
+ const args = toolSchemas.get_emails.parse(input);
320
+ const runtime = requireSingleAccount(context, args);
321
+ const found = runtime.store.getMessagesByIds(args.ids);
322
+ const foundIds = new Set(found.map((message) => message.id));
323
+ return json({
324
+ account: runtime.name,
325
+ items: found.map((message) => annotateMessage(shapeMessage(message, args.format, args.maxBodyChars), runtime)),
326
+ missing: args.ids.filter((id) => !foundIds.has(id))
327
+ });
117
328
  },
118
329
  get_thread: async (input) => {
119
330
  const args = toolSchemas.get_thread.parse(input);
120
- return json({ items: context.store.getThread(args.id ?? args.messageId) });
331
+ const found = findMessage(context, args);
332
+ if (!found) {
333
+ return json({ items: [] });
334
+ }
335
+ const thread = found.account.store.getThread(found.message.id);
336
+ return json({
337
+ account: found.account.name,
338
+ items: thread.map((message) => annotateMessage(shapeMessage(message, args.format, args.maxBodyChars), found.account))
339
+ });
340
+ },
341
+ list_attachments: async (input) => {
342
+ const args = toolSchemas.list_attachments.parse(input);
343
+ const found = findMessage(context, args);
344
+ if (!found) {
345
+ throw new Error("Email message not found");
346
+ }
347
+ return json({
348
+ account: found.account.name,
349
+ id: found.message.id,
350
+ count: found.message.attachments.length,
351
+ attachments: found.message.attachments.map((attachment, index) => ({ index, ...attachment }))
352
+ });
353
+ },
354
+ get_attachment: async (input) => {
355
+ const args = toolSchemas.get_attachment.parse(input);
356
+ const found = findMessage(context, args);
357
+ if (!found) {
358
+ throw new Error("Email message not found");
359
+ }
360
+ const resolvedPath = resolveRawPath(found.account, found.message);
361
+ const raw = await fs.readFile(resolvedPath);
362
+ const parsed = await simpleParser(raw);
363
+ const attachment = selectAttachment(parsed.attachments, args);
364
+ const meta = {
365
+ account: found.account.name,
366
+ id: found.message.id,
367
+ index: parsed.attachments.indexOf(attachment),
368
+ filename: attachment.filename ?? null,
369
+ contentType: attachment.contentType,
370
+ size: attachment.size
371
+ };
372
+ if (args.saveTo) {
373
+ const target = path.resolve(args.saveTo);
374
+ await fs.mkdir(path.dirname(target), { recursive: true });
375
+ await fs.writeFile(target, attachment.content);
376
+ return json({ ...meta, savedTo: target });
377
+ }
378
+ if (attachment.size > args.maxBytes) {
379
+ throw new Error(`Attachment is ${attachment.size} bytes, above maxBytes ${args.maxBytes}. Raise maxBytes (max 5000000) or pass saveTo to write it to disk.`);
380
+ }
381
+ return json({ ...meta, encoding: "base64", content: attachment.content.toString("base64") });
382
+ },
383
+ get_inbox_summary: async (input) => {
384
+ const args = toolSchemas.get_inbox_summary.parse(input);
385
+ const accounts = resolveAccounts(context, args);
386
+ const unreadLimit = args.unreadLimit ?? 10;
387
+ const summaries = accounts.map((runtime) => {
388
+ const counts = runtime.store.getMessageCounts();
389
+ const status = runtime.store.getSyncStatus();
390
+ const summary = {
391
+ account: runtime.name,
392
+ email: runtime.email,
393
+ totalMessages: counts.total,
394
+ unreadMessages: counts.unread,
395
+ flaggedMessages: counts.flagged,
396
+ mailboxCount: status.mailboxes.length,
397
+ latestMessageDate: status.latestMessageDate,
398
+ recentUnread: runtime.store
399
+ .listMessages({ isUnread: true, limit: unreadLimit })
400
+ .items.map((item) => annotateMessage(item, runtime))
401
+ };
402
+ return summary;
403
+ });
404
+ return summaries.length === 1 ? json(summaries[0]) : json({ accounts: summaries });
405
+ },
406
+ list_senders: async (input) => {
407
+ const args = toolSchemas.list_senders.parse(input);
408
+ const accounts = resolveAccounts(context, args);
409
+ const limit = Math.min(Math.max(Math.trunc(args.limit ?? 25), 1), 100);
410
+ const merged = new Map();
411
+ for (const runtime of accounts) {
412
+ for (const sender of runtime.store.getTopSenders({ limit, unreadOnly: args.unreadOnly })) {
413
+ const existing = merged.get(sender.address);
414
+ if (existing) {
415
+ existing.count += sender.count;
416
+ existing.unreadCount += sender.unreadCount;
417
+ if (!existing.name && sender.name) {
418
+ existing.name = sender.name;
419
+ }
420
+ if (!existing.accounts?.includes(runtime.name)) {
421
+ existing.accounts = [...(existing.accounts ?? []), runtime.name];
422
+ }
423
+ }
424
+ else {
425
+ merged.set(sender.address, { ...sender, accounts: [runtime.name] });
426
+ }
427
+ }
428
+ }
429
+ const senders = [...merged.values()]
430
+ .sort((a, b) => b.count - a.count || a.address.localeCompare(b.address))
431
+ .slice(0, limit);
432
+ return json({ senders });
433
+ },
434
+ check_accounts: async (input) => {
435
+ const args = toolSchemas.check_accounts.parse(input);
436
+ const accounts = resolveAccounts(context, args);
437
+ const settled = await Promise.allSettled(accounts.map((runtime) => runtime.sync.check()));
438
+ const results = settled.map((outcome, index) => {
439
+ const name = accounts[index].name;
440
+ if (outcome.status === "fulfilled") {
441
+ return { account: name, ...outcome.value };
442
+ }
443
+ return {
444
+ account: name,
445
+ ok: false,
446
+ host: "",
447
+ mailboxCount: 0,
448
+ mailboxes: [],
449
+ error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
450
+ };
451
+ });
452
+ return results.length === 1
453
+ ? json(results[0])
454
+ : json({ ok: results.every((result) => result.ok), accounts: results });
121
455
  },
122
456
  get_raw_email: async (input) => {
123
457
  const args = toolSchemas.get_raw_email.parse(input);
124
- const message = findMessage(context.store, args);
125
- if (!message) {
458
+ const found = findMessage(context, args);
459
+ if (!found) {
126
460
  throw new Error("Email message not found");
127
461
  }
128
- const stats = await fs.stat(message.rawPath);
129
- const handle = await fs.open(message.rawPath, "r");
462
+ const { account, message } = found;
463
+ const resolvedPath = resolveRawPath(account, message);
464
+ const stats = await fs.stat(resolvedPath);
465
+ const handle = await fs.open(resolvedPath, "r");
130
466
  try {
131
467
  const bytesToRead = Math.min(args.maxBytes, stats.size);
132
468
  const buffer = Buffer.alloc(bytesToRead);
133
469
  const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
134
470
  return json({
471
+ account: account.name,
135
472
  id: message.id,
136
- rawPath: message.rawPath,
473
+ rawPath: resolvedPath,
137
474
  sizeBytes: stats.size,
138
475
  returnedBytes: bytesRead,
139
476
  truncated: bytesRead < stats.size,
@@ -145,19 +482,340 @@ export function createToolHandlers(context) {
145
482
  }
146
483
  },
147
484
  get_storage_stats: async (input) => {
148
- toolSchemas.get_storage_stats.parse(input);
149
- return json(context.store.getStorageStats());
485
+ const args = toolSchemas.get_storage_stats.parse(input);
486
+ const accounts = resolveAccounts(context, args);
487
+ if (accounts.length === 1) {
488
+ return json({ account: accounts[0].name, ...accounts[0].store.getStorageStats() });
489
+ }
490
+ return json({
491
+ accounts: accounts.map((runtime) => ({ account: runtime.name, ...runtime.store.getStorageStats() }))
492
+ });
150
493
  },
151
494
  optimize_email_store: async (input) => {
152
- toolSchemas.optimize_email_store.parse(input);
153
- return json(context.store.optimize());
495
+ const args = toolSchemas.optimize_email_store.parse(input);
496
+ const accounts = resolveAccounts(context, args);
497
+ const results = accounts.map((runtime) => ({ account: runtime.name, ...runtime.store.optimize() }));
498
+ return results.length === 1 ? json(results[0]) : json({ results });
154
499
  }
155
500
  };
156
501
  }
157
- function emailLocatorSchema() {
158
- return emailLocatorBaseSchema().refine((value) => value.id || value.messageId || (value.mailbox && value.uid), {
159
- message: "Provide id, messageId, or mailbox plus uid"
502
+ function resolveAccounts(context, selector = {}) {
503
+ const matched = new Set();
504
+ const patterns = [selector.account, ...(selector.accounts ?? [])].filter((value) => Boolean(value));
505
+ for (const pattern of patterns) {
506
+ const matches = matchAccounts(context.accounts, pattern);
507
+ if (matches.length === 0) {
508
+ const available = context.accounts.map((runtime) => runtime.name).join(", ");
509
+ throw new Error(`Unknown account "${pattern}". Configured accounts: ${available}`);
510
+ }
511
+ for (const runtime of matches) {
512
+ matched.add(runtime);
513
+ }
514
+ }
515
+ if (selector.accountGroup) {
516
+ const group = selector.accountGroup.trim().toLowerCase();
517
+ const members = context.accounts.filter((runtime) => runtime.groups.includes(group));
518
+ if (members.length === 0) {
519
+ const groups = [...new Set(context.accounts.flatMap((runtime) => runtime.groups))].join(", ");
520
+ throw new Error(`Unknown account group "${selector.accountGroup}".` +
521
+ (groups ? ` Configured groups: ${groups}` : " No groups are configured."));
522
+ }
523
+ for (const runtime of members) {
524
+ matched.add(runtime);
525
+ }
526
+ }
527
+ if (matched.size === 0) {
528
+ return context.accounts;
529
+ }
530
+ return context.accounts.filter((runtime) => matched.has(runtime));
531
+ }
532
+ function matchAccounts(accounts, pattern) {
533
+ const needle = pattern.trim().toLowerCase();
534
+ if (needle.includes("*")) {
535
+ const regex = new RegExp(`^${needle.split("*").map(escapeRegex).join(".*")}$`);
536
+ return accounts.filter((runtime) => regex.test(runtime.name.toLowerCase()) || regex.test(runtime.email.toLowerCase()));
537
+ }
538
+ const exact = accounts.find((runtime) => runtime.name.toLowerCase() === needle || runtime.email.toLowerCase() === needle);
539
+ return exact ? [exact] : [];
540
+ }
541
+ function escapeRegex(value) {
542
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
543
+ }
544
+ function requireSingleAccount(context, selector = {}) {
545
+ const accounts = resolveAccounts(context, selector);
546
+ if (accounts.length === 1) {
547
+ return accounts[0];
548
+ }
549
+ const available = context.accounts.map((runtime) => runtime.name).join(", ");
550
+ throw new Error(`Multiple accounts matched — narrow to one account (one of: ${available})`);
551
+ }
552
+ function annotateMessage(message, account) {
553
+ return { ...message, account: account.name };
554
+ }
555
+ const DEFAULT_MAX_BODY_CHARS = 50_000;
556
+ function shapeMessage(message, format, maxBodyChars) {
557
+ const mode = format ?? "full";
558
+ const cap = maxBodyChars ?? DEFAULT_MAX_BODY_CHARS;
559
+ const keepText = mode === "full" || mode === "text";
560
+ const keepHtml = mode === "full";
561
+ const keepHeaders = mode !== "summary";
562
+ const truncated = {};
563
+ const text = keepText ? truncateBody(message.text, cap, truncated, "text") : null;
564
+ const html = keepHtml ? truncateBody(message.html, cap, truncated, "html") : null;
565
+ const shaped = { ...message, text, html, headers: keepHeaders ? message.headers : {} };
566
+ if (truncated.text || truncated.html) {
567
+ return { ...shaped, truncated };
568
+ }
569
+ return shaped;
570
+ }
571
+ function truncateBody(value, cap, truncated, key) {
572
+ if (value === null || value.length <= cap) {
573
+ return value;
574
+ }
575
+ truncated[key] = true;
576
+ return value.slice(0, cap);
577
+ }
578
+ function selectAttachment(attachments, args) {
579
+ if (args.index !== undefined) {
580
+ const attachment = attachments[args.index];
581
+ if (!attachment) {
582
+ throw new Error(`No attachment at index ${args.index} — message has ${attachments.length} attachment(s)`);
583
+ }
584
+ return attachment;
585
+ }
586
+ const needle = args.filename.toLowerCase();
587
+ const attachment = attachments.find((candidate) => candidate.filename?.toLowerCase() === needle);
588
+ if (!attachment) {
589
+ const names = attachments.map((candidate) => candidate.filename ?? "(unnamed)").join(", ");
590
+ throw new Error(`No attachment named "${args.filename}". Available: ${names || "none"}`);
591
+ }
592
+ return attachment;
593
+ }
594
+ function resolveRawPath(account, message) {
595
+ const rawRoot = path.resolve(account.rawDir);
596
+ const resolvedPath = path.resolve(message.rawPath);
597
+ if (resolvedPath !== rawRoot && !resolvedPath.startsWith(rawRoot + path.sep)) {
598
+ throw new Error("Stored raw path for this message is outside the account storage directory");
599
+ }
600
+ return resolvedPath;
601
+ }
602
+ const DURATION_UNITS_MS = {
603
+ s: 1_000,
604
+ m: 60_000,
605
+ h: 3_600_000,
606
+ d: 86_400_000,
607
+ w: 604_800_000
608
+ };
609
+ function durationToSince(last) {
610
+ if (!last) {
611
+ return undefined;
612
+ }
613
+ const amount = Number(last.slice(0, -1));
614
+ const unit = last.slice(-1);
615
+ const ms = DURATION_UNITS_MS[unit];
616
+ if (!Number.isFinite(amount) || !ms) {
617
+ return undefined;
618
+ }
619
+ return new Date(Date.now() - amount * ms).toISOString();
620
+ }
621
+ function withDuration(args) {
622
+ const { last, ...rest } = args;
623
+ if (last && rest.startDate) {
624
+ throw new Error("Pass either last or startDate, not both");
625
+ }
626
+ return { ...rest, startDate: rest.startDate ?? durationToSince(last) };
627
+ }
628
+ function findMessage(context, locator) {
629
+ const needsAccount = Boolean(locator.id || (locator.mailbox && locator.uid));
630
+ const hasSelector = Boolean(locator.account || locator.accounts?.length || locator.accountGroup);
631
+ if (hasSelector || needsAccount) {
632
+ const runtime = requireSingleAccount(context, locator);
633
+ const message = locator.id
634
+ ? runtime.store.getMessageById(locator.id)
635
+ : locator.messageId
636
+ ? runtime.store.getMessageByMessageId(locator.messageId)
637
+ : runtime.store.getMessageByMailboxUid(locator.mailbox, locator.uid);
638
+ return message ? { account: runtime, message } : null;
639
+ }
640
+ for (const runtime of context.accounts) {
641
+ const message = runtime.store.getMessageByMessageId(locator.messageId);
642
+ if (message) {
643
+ return { account: runtime, message };
644
+ }
645
+ }
646
+ return null;
647
+ }
648
+ function encodeMergedCursor(cursor) {
649
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
650
+ }
651
+ function decodeMergedCursor(cursor) {
652
+ if (!cursor) {
653
+ return null;
654
+ }
655
+ try {
656
+ const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
657
+ if ((decoded.sort === "date_asc" || decoded.sort === "date_desc") &&
658
+ decoded.per &&
659
+ typeof decoded.per === "object" &&
660
+ Array.isArray(decoded.done)) {
661
+ return {
662
+ sort: decoded.sort,
663
+ per: decoded.per,
664
+ done: decoded.done.filter((name) => typeof name === "string")
665
+ };
666
+ }
667
+ return null;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ }
673
+ function queryAccounts(accounts, filters, mode = "list") {
674
+ if (mode === "search" && filters.sort === "relevance" && filters.query?.trim()) {
675
+ return queryAccountsRanked(accounts, filters);
676
+ }
677
+ if (filters.sort === "relevance") {
678
+ filters = { ...filters, sort: "date_desc" };
679
+ }
680
+ if (accounts.length === 1) {
681
+ const runtime = accounts[0];
682
+ const result = queryStore(runtime, filters, mode);
683
+ return { items: result.items.map((item) => annotateMessage(item, runtime)), nextCursor: result.nextCursor };
684
+ }
685
+ const sort = filters.sort ?? "date_desc";
686
+ const limit = Math.min(Math.max(Math.trunc(filters.limit ?? 25), 1), 100);
687
+ const decoded = decodeMergedCursor(filters.cursor);
688
+ const prior = decoded && decoded.sort === sort ? decoded.per : {};
689
+ const doneAccounts = new Set(decoded && decoded.sort === sort ? decoded.done : []);
690
+ const direction = sort === "date_asc" ? 1 : -1;
691
+ const perAccountResults = accounts.map((runtime) => {
692
+ const priorEntry = prior[runtime.name];
693
+ const storeCursor = priorEntry ? encodeCursor(priorEntry, sort) : undefined;
694
+ const result = doneAccounts.has(runtime.name)
695
+ ? { items: [], nextCursor: undefined }
696
+ : queryStore(runtime, { ...filters, limit, cursor: storeCursor }, mode);
697
+ return { runtime, result, priorEntry: priorEntry ?? null };
698
+ });
699
+ const candidates = perAccountResults.flatMap(({ runtime, result }) => result.items.map((item) => annotateMessage(item, runtime)));
700
+ candidates.sort((a, b) => {
701
+ if (a.date !== b.date) {
702
+ return a.date < b.date ? -direction : direction;
703
+ }
704
+ if (a.id !== b.id) {
705
+ return (a.id - b.id) * direction;
706
+ }
707
+ return (a.account ?? "").localeCompare(b.account ?? "");
708
+ });
709
+ const page = candidates.slice(0, limit);
710
+ const includedByAccount = new Map();
711
+ for (const item of page) {
712
+ const list = includedByAccount.get(item.account ?? "") ?? [];
713
+ list.push(item);
714
+ includedByAccount.set(item.account ?? "", list);
715
+ }
716
+ const nextPer = {};
717
+ const nextDone = [];
718
+ for (const { runtime, result, priorEntry } of perAccountResults) {
719
+ if (doneAccounts.has(runtime.name)) {
720
+ nextDone.push(runtime.name);
721
+ continue;
722
+ }
723
+ const included = includedByAccount.get(runtime.name) ?? [];
724
+ const hasMore = result.nextCursor !== undefined || result.items.length > included.length;
725
+ if (!hasMore) {
726
+ nextDone.push(runtime.name);
727
+ continue;
728
+ }
729
+ if (included.length > 0) {
730
+ const last = included[included.length - 1];
731
+ nextPer[runtime.name] = { date: last.date, id: last.id };
732
+ }
733
+ else {
734
+ nextPer[runtime.name] = priorEntry;
735
+ }
736
+ }
737
+ return {
738
+ items: page,
739
+ nextCursor: Object.keys(nextPer).length > 0 ? encodeMergedCursor({ sort, per: nextPer, done: nextDone }) : undefined
740
+ };
741
+ }
742
+ function encodeRelevanceCursor(cursor) {
743
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
744
+ }
745
+ function decodeRelevanceCursor(cursor) {
746
+ if (!cursor) {
747
+ return null;
748
+ }
749
+ try {
750
+ const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
751
+ if (decoded.sort === "relevance" &&
752
+ decoded.per &&
753
+ typeof decoded.per === "object" &&
754
+ Array.isArray(decoded.done)) {
755
+ const per = Object.fromEntries(Object.entries(decoded.per).filter((entry) => typeof entry[1] === "number" && entry[1] >= 0));
756
+ return {
757
+ sort: "relevance",
758
+ per,
759
+ done: decoded.done.filter((name) => typeof name === "string")
760
+ };
761
+ }
762
+ return null;
763
+ }
764
+ catch {
765
+ return null;
766
+ }
767
+ }
768
+ function queryAccountsRanked(accounts, filters) {
769
+ const limit = Math.min(Math.max(Math.trunc(filters.limit ?? 25), 1), 100);
770
+ const decoded = decodeRelevanceCursor(filters.cursor);
771
+ const prior = decoded?.per ?? {};
772
+ const doneAccounts = new Set(decoded?.done ?? []);
773
+ const perAccountResults = accounts.map((runtime) => {
774
+ const offset = prior[runtime.name] ?? 0;
775
+ if (doneAccounts.has(runtime.name)) {
776
+ return { runtime, items: [], hasMore: false, offset };
777
+ }
778
+ const result = runtime.store.searchRanked(filters, { limit, offset });
779
+ return {
780
+ runtime,
781
+ items: result.items.map((item) => annotateMessage(item, runtime)),
782
+ hasMore: result.hasMore,
783
+ offset
784
+ };
160
785
  });
786
+ const candidates = perAccountResults.flatMap(({ runtime, items }) => items.map((item, position) => ({ item, position, runtime })));
787
+ candidates.sort((a, b) => a.position - b.position ||
788
+ (a.item.rank ?? 0) - (b.item.rank ?? 0) ||
789
+ a.runtime.name.localeCompare(b.runtime.name));
790
+ const page = candidates.slice(0, limit).map((candidate) => candidate.item);
791
+ const includedByAccount = new Map();
792
+ for (const candidate of candidates.slice(0, limit)) {
793
+ includedByAccount.set(candidate.runtime.name, (includedByAccount.get(candidate.runtime.name) ?? 0) + 1);
794
+ }
795
+ const nextPer = {};
796
+ const nextDone = [];
797
+ for (const { runtime, items, hasMore, offset } of perAccountResults) {
798
+ if (doneAccounts.has(runtime.name)) {
799
+ nextDone.push(runtime.name);
800
+ continue;
801
+ }
802
+ const included = includedByAccount.get(runtime.name) ?? 0;
803
+ const isDone = !hasMore && included === items.length;
804
+ if (isDone) {
805
+ nextDone.push(runtime.name);
806
+ continue;
807
+ }
808
+ nextPer[runtime.name] = offset + included;
809
+ }
810
+ return {
811
+ items: page,
812
+ nextCursor: Object.keys(nextPer).length > 0
813
+ ? encodeRelevanceCursor({ sort: "relevance", per: nextPer, done: nextDone })
814
+ : undefined
815
+ };
816
+ }
817
+ function queryStore(runtime, filters, mode) {
818
+ return mode === "search" ? runtime.store.searchMessages(filters) : runtime.store.listMessages(filters);
161
819
  }
162
820
  function emailLocatorBaseSchema() {
163
821
  return z.object({
@@ -169,28 +827,24 @@ function emailLocatorBaseSchema() {
169
827
  }
170
828
  function messageFilterSchema() {
171
829
  return z.object({
830
+ ...accountSelectorFields,
172
831
  mailbox: z.string().min(1).optional(),
832
+ mailboxes: z.array(z.string().min(1)).optional(),
173
833
  from: z.string().min(1).optional(),
174
834
  to: z.string().min(1).optional(),
175
835
  cc: z.string().min(1).optional(),
176
836
  subject: z.string().min(1).optional(),
177
- startDate: z.string().datetime().optional(),
178
- endDate: z.string().datetime().optional(),
837
+ startDate: isoDateTime().optional(),
838
+ endDate: isoDateTime().optional(),
839
+ last: durationParam,
179
840
  hasAttachments: z.boolean().optional(),
180
841
  isUnread: z.boolean().optional(),
181
842
  isFlagged: z.boolean().optional(),
182
843
  limit: z.number().int().positive().max(100).optional(),
183
844
  cursor: z.string().min(1).optional(),
184
- sort: z.enum(["date_asc", "date_desc"]).optional()
845
+ sort: z.enum(["date_asc", "date_desc", "relevance"]).optional()
185
846
  });
186
847
  }
187
- function findMessage(store, locator) {
188
- return locator.id
189
- ? store.getMessageById(locator.id)
190
- : locator.messageId
191
- ? store.getMessageByMessageId(locator.messageId)
192
- : store.getMessageByMailboxUid(locator.mailbox, locator.uid);
193
- }
194
848
  function json(value) {
195
849
  return {
196
850
  content: [