diff --git a/.gitignore b/.gitignore index bf82062..b9d1994 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +bun.lock *.log .env .env.local diff --git a/manifest.json b/manifest.json index 4e8a009..e5a7d51 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "fastmail-mcp", - "version": "1.9.4", + "version": "1.11.0", "description": "MCP server for Fastmail API integration", "author": { "name": "Jeremy Gill" diff --git a/package.json b/package.json index 2bc944e..b27acb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fastmail-mcp", - "version": "1.9.4", + "version": "1.11.0", "description": "MCP server for Fastmail API integration", "main": "dist/index.js", "type": "module", diff --git a/src/index.ts b/src/index.ts index 7572973..72cc980 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { coerceStringArray, coerceBool, redactBearerTokens } from './coerce.js'; const server = new Server( { name: 'fastmail-mcp', - version: '1.9.4', + version: '1.11.0', }, { capabilities: { @@ -133,10 +133,52 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { tools: [ { name: 'list_mailboxes', - description: 'List all mailboxes in the Fastmail account', + description: 'List mailboxes in the Fastmail account. By default returns all mailboxes with full metadata; on accounts with hundreds of mailboxes the full result can exceed the MCP tool result window. Use `properties: ["id","name","parentId"]` for a slim view, and/or `parentId` to filter to one level of children.', inputSchema: { type: 'object', - properties: {}, + properties: { + properties: { + type: 'array', + items: { type: 'string' }, + description: 'JMAP Mailbox properties to return (e.g. ["id","name","parentId"]). Default: all properties. The slim form roughly halves payload size on large accounts.', + }, + parentId: { + type: ['string', 'null'], + description: 'Filter to direct children of this mailbox ID. Pass null for top-level mailboxes. Filter is applied client-side after Mailbox/get.', + }, + }, + }, + }, + { + name: 'get_mailbox_by_name', + description: 'Look up a single mailbox by its full path from root (e.g. "Folder/Subfolder/Leaf"). Returns the mailbox ID and minimal metadata, or throws "Mailbox not found" if no exact match. The path separator is "/"; folder names containing a literal "/" are not supported.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Full path from root, separated by "/" (e.g. "Inbox" or "Archive/2026/Suppliers/ExampleCo").', + }, + }, + required: ['path'], + }, + }, + { + name: 'create_mailbox', + description: 'Create a new mailbox (folder). Returns the new mailbox ID. The caller is responsible for validating the name is appropriate (length, character set, parent-folder allow-list) before calling — JMAP itself only enforces uniqueness within a parent.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Leaf name of the new mailbox (not a full path). Must not contain "/".', + }, + parentId: { + type: ['string', 'null'], + description: 'Parent mailbox ID. Pass null (or omit) to create at top level.', + }, + }, + required: ['name'], }, }, { @@ -161,6 +203,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, }, }, + { + name: 'list_emails_metadata', + description: 'Same as list_emails (lists emails from a mailbox, optionally filtered by mailboxId, with paging and sort) but returns ONLY metadata fields on each result — id, threadId, subject, from, to, replyTo, receivedAt, hasAttachment, keywords. Does NOT return preview or any body-derived content. Use in privacy-sensitive flows where the workflow needs only the envelope (e.g. customer-mail least-privilege scans, or any caller forbidden from ingesting message bodies). Pair with get_email_metadata for follow-up lookups that should also stay header-only.', + inputSchema: { + type: 'object', + properties: { + mailboxId: { + type: 'string', + description: 'ID of the mailbox to list emails from (optional, defaults to all)', + }, + limit: { + type: ['number', 'string'], + description: 'Maximum number of emails to return (default: 20)', + default: 20, + }, + ascending: { + type: 'boolean', + description: 'Sort oldest first instead of newest first (default: false)', + }, + }, + }, + }, { name: 'get_email', description: 'Get a specific email by ID', @@ -174,6 +238,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, required: ['emailId'], }, + _meta: { + // Raise the per-tool result size limit honoured by Claude Code + // (v2.1.91+) and other MCP clients that respect this annotation. + // get_email returns the full Email object including textBody/ + // htmlBody/bodyValues/attachments — promotional newsletters and + // policy-update emails routinely exceed the default ~25KB inline + // budget and get spilled to a temp file by the harness, which + // then forces the caller to do its own file-read recovery. + // 500000 chars (~500KB) covers virtually all real-world email + // payloads while remaining well under the MCP hard ceiling. + 'anthropic/maxResultSizeChars': 500000, + }, + }, + { + name: 'get_email_metadata', + description: 'Get headers/metadata for an email — sender, recipients, subject, date, threading, mailbox membership, keywords (read/flagged/etc.), size, and whether an attachment is present — but NOT the body, preview, or any rendered text. Useful when a workflow needs to classify or route an email without ingesting its content (e.g. customer-mail least-privilege flows where reading bodies is forbidden, or skills that only need to verify post-archive folder placement). The return shape is the standard JMAP Email object restricted to a strict header-only allowlist.', + inputSchema: { + type: 'object', + properties: { + emailId: { + type: 'string', + description: 'ID of the email to retrieve metadata for', + }, + }, + required: ['emailId'], + }, }, { name: 'send_email', @@ -433,6 +523,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ['query'], }, }, + { + name: 'search_emails_metadata', + description: 'Same as search_emails (free-text search across subject and body) but returns ONLY metadata on each match — id, threadId, subject, from, to, replyTo, receivedAt, hasAttachment, keywords. The query still searches body text on the server side; only the result envelopes come back, never preview or body excerpts. Use when a content match is required (e.g. "find all messages mentioning X") but the matches must not surface body fragments to the caller.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query string', + }, + limit: { + type: ['number', 'string'], + description: 'Maximum number of results (default: 20)', + default: 20, + }, + ascending: { + type: 'boolean', + description: 'Sort oldest first instead of newest first (default: false)', + }, + }, + required: ['query'], + }, + }, { name: 'list_contacts', description: 'List contacts from the address book', @@ -674,6 +787,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ['emailId', 'targetMailboxId'], }, }, + { + name: 'archive_email', + description: 'Archive an email — move it to the target mailbox AND mark it as read in a single atomic JMAP operation. Equivalent to calling move_email followed by mark_email_read, but in one MCP call and one Email/set patch (the move and the read flag land together or not at all). For trashing an email, use delete_email instead — that follows a different convention and does not auto-mark-read.', + inputSchema: { + type: 'object', + properties: { + emailId: { + type: 'string', + description: 'ID of the email to archive', + }, + targetMailboxId: { + type: 'string', + description: 'ID of the destination mailbox', + }, + }, + required: ['emailId', 'targetMailboxId'], + }, + }, { name: 'add_labels', description: 'Add labels (mailboxes) to an email without removing existing ones', @@ -750,7 +881,75 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, { name: 'advanced_search', - description: 'Advanced email search with multiple criteria', + description: 'Advanced email search with multiple criteria. Mailbox scoping supports a single mailbox (mailboxId), an intersection of multiple mailboxes (requiredMailboxIds — must be a member of ALL listed mailboxes), and exclusion (excludeMailboxIds — member of NONE of the listed mailboxes), alongside the standard sender / recipient / subject / free-text / date / attachment / unread / pinned filters.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Text to search for in subject/body', + }, + from: { + type: 'string', + description: 'Filter by sender email', + }, + to: { + type: 'string', + description: 'Filter by recipient email', + }, + subject: { + type: 'string', + description: 'Filter by subject', + }, + hasAttachment: { + type: 'boolean', + description: 'Filter emails with attachments', + }, + isUnread: { + type: 'boolean', + description: 'Filter unread emails', + }, + isPinned: { + type: 'boolean', + description: 'Filter pinned emails', + }, + mailboxId: { + type: 'string', + description: 'Search within a single mailbox. For an intersection across multiple mailboxes (e.g. Inbox AND a label folder), use requiredMailboxIds instead.', + }, + requiredMailboxIds: { + type: 'array', + items: { type: 'string' }, + description: 'Require membership in ALL of these mailbox IDs (intersection / AND semantic). Use this for queries like "in Inbox AND a label folder" — pass both mailbox IDs in the array. If mailboxId is also passed, it is folded into the intersection (de-duplicated). JMAP cannot express multi-mailbox membership in a single FilterCondition, so this builds a FilterOperator AND over multiple inMailbox conditions on the server.', + }, + excludeMailboxIds: { + type: 'array', + items: { type: 'string' }, + description: 'Exclude emails that are members of ANY of these mailbox IDs (maps to JMAP inMailboxOtherThan). Useful for queries like "in a parent label but not its archive sub-folder". Combines cleanly with mailboxId / requiredMailboxIds.', + }, + after: { + type: 'string', + description: 'Emails after this date (ISO 8601)', + }, + before: { + type: 'string', + description: 'Emails before this date (ISO 8601)', + }, + limit: { + type: ['number', 'string'], + description: 'Maximum results (default: 50)', + default: 50, + }, + ascending: { + type: 'boolean', + description: 'Sort oldest first instead of newest first (default: false)', + }, + }, + }, + }, + { + name: 'advanced_search_metadata', + description: 'Same filter capabilities as advanced_search (single-mailbox scoping via mailboxId, multi-mailbox intersection via requiredMailboxIds, exclusion via excludeMailboxIds, plus sender / recipient / subject / free text / date / attachment / unread / pinned) but returns ONLY metadata on each match — id, threadId, subject, from, to, cc, replyTo, receivedAt, hasAttachment, keywords. Does NOT return preview or any body-derived content. Use in privacy-sensitive flows where the routing decision is made from headers alone — for example, when classifying customer mail by sender / recipient / subject / thread state without ingesting body content. The free-text query still searches body content on the server side; only the result envelope comes back without body excerpts.', inputSchema: { type: 'object', properties: { @@ -784,7 +983,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, mailboxId: { type: 'string', - description: 'Search within specific mailbox', + description: 'Search within a single mailbox. For an intersection across multiple mailboxes (e.g. Inbox AND a label folder), use requiredMailboxIds instead.', + }, + requiredMailboxIds: { + type: 'array', + items: { type: 'string' }, + description: 'Require membership in ALL of these mailbox IDs (intersection / AND semantic). Use this for queries like "in Inbox AND a label folder" — pass both mailbox IDs in the array. If mailboxId is also passed, it is folded into the intersection (de-duplicated). JMAP cannot express multi-mailbox membership in a single FilterCondition, so this builds a FilterOperator AND over multiple inMailbox conditions on the server.', + }, + excludeMailboxIds: { + type: 'array', + items: { type: 'string' }, + description: 'Exclude emails that are members of ANY of these mailbox IDs (maps to JMAP inMailboxOtherThan). Useful for queries like "in a parent label but not its archive sub-folder". Combines cleanly with mailboxId / requiredMailboxIds.', }, after: { type: 'string', @@ -820,6 +1029,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ['threadId'], }, }, + { + name: 'get_thread_metadata', + description: 'Same as get_thread (enumerate every message in a conversation thread) but returns ONLY metadata on each thread message — id, threadId, subject, from, to, cc, replyTo, receivedAt, hasAttachment, keywords. Does NOT return preview or any body-derived content. Use for thread-state checks (reply-presence detection, sender enumeration, date comparison, read/flagged status) without ingesting message bodies — particularly in customer-mail least-privilege flows where the skill needs to know "did we reply, when, and from which alias" but is forbidden from reading what was said. Accepts either a thread ID or an email ID and resolves to the parent thread, mirroring get_thread.', + inputSchema: { + type: 'object', + properties: { + threadId: { + type: 'string', + description: 'ID of the thread/conversation (an email ID is also accepted and will be resolved to its parent thread)', + }, + }, + required: ['threadId'], + }, + }, { name: 'get_mailbox_stats', description: 'Get statistics for a mailbox (unread count, total emails, etc.)', @@ -995,7 +1218,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (name) { case 'list_mailboxes': { - const mailboxes = await client.getMailboxes(); + const { properties, parentId } = (args ?? {}) as any; + const options: { properties?: string[]; parentId?: string | null } = {}; + if (Array.isArray(properties) && properties.length > 0) { + options.properties = properties; + } + if (args && Object.prototype.hasOwnProperty.call(args, 'parentId')) { + options.parentId = parentId ?? null; + } + const mailboxes = await client.getMailboxes(options); return { content: [ { @@ -1006,6 +1237,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'get_mailbox_by_name': { + const { path } = (args ?? {}) as any; + if (!path || typeof path !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'path is required and must be a non-empty string'); + } + const mailbox = await client.getMailboxByName(path); + return { + content: [ + { + type: 'text', + text: JSON.stringify(mailbox, null, 2), + }, + ], + }; + } + + case 'create_mailbox': { + const { name: mailboxName, parentId } = (args ?? {}) as any; + if (!mailboxName || typeof mailboxName !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'name is required and must be a non-empty string'); + } + if (mailboxName.includes('/')) { + throw new McpError(ErrorCode.InvalidParams, 'name must not contain "/" — pass a leaf name and use parentId to nest'); + } + const newId = await client.createMailbox(mailboxName, parentId ?? null); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ id: newId, name: mailboxName, parentId: parentId ?? null }, null, 2), + }, + ], + }; + } + case 'list_emails': { const { mailboxId, limit, ascending } = args as any; const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); @@ -1020,6 +1286,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'list_emails_metadata': { + const { mailboxId, limit, ascending } = (args ?? {}) as any; + const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + const emails = await client.getEmailsMetadata(mailboxId, validLimit, !!ascending); + return { + content: [ + { + type: 'text', + text: JSON.stringify(emails, null, 2), + }, + ], + }; + } + case 'get_email': { const { emailId } = args as any; if (!emailId) { @@ -1036,6 +1316,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'get_email_metadata': { + const { emailId } = (args ?? {}) as any; + if (!emailId) { + throw new McpError(ErrorCode.InvalidParams, 'emailId is required'); + } + const email = await client.getEmailMetadata(emailId); + return { + content: [ + { + type: 'text', + text: JSON.stringify(email, null, 2), + }, + ], + }; + } + case 'send_email': { const { to, cc, bcc, from, mailboxId, subject, textBody, htmlBody, inReplyTo, references, replyTo } = args as any; const toArray = coerceStringArray(to); @@ -1251,6 +1547,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'search_emails_metadata': { + const { query, limit, ascending } = (args ?? {}) as any; + if (!query) { + throw new McpError(ErrorCode.InvalidParams, 'query is required'); + } + const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + const emails = await client.searchEmailsMetadata(query, validLimit, !!ascending); + return { + content: [ + { + type: 'text', + text: JSON.stringify(emails, null, 2), + }, + ], + }; + } + case 'list_contacts': { const { limit = 50 } = args as any; const contactsClient = initializeContactsCalendarClient(); @@ -1469,6 +1782,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'archive_email': { + const { emailId, targetMailboxId } = (args ?? {}) as any; + if (!emailId || !targetMailboxId) { + throw new McpError(ErrorCode.InvalidParams, 'emailId and targetMailboxId are required'); + } + const client = initializeClient(); + await client.archiveEmail(emailId, targetMailboxId); + return { + content: [ + { + type: 'text', + text: 'Email archived successfully (moved to target mailbox and marked as read)', + }, + ], + }; + } + case 'add_labels': { const { emailId, mailboxIds } = args as any; if (!emailId) { @@ -1568,11 +1898,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case 'advanced_search': { - const { query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit, ascending } = args as any; + const { query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, requiredMailboxIds, excludeMailboxIds, after, before, limit, ascending } = args as any; const client = initializeClient(); const validLimit = Math.min(Math.max(Number(limit) || 50, 1), 100); const emails = await client.advancedSearch({ - query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit: validLimit, ascending + query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, requiredMailboxIds, excludeMailboxIds, after, before, limit: validLimit, ascending + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(emails, null, 2), + }, + ], + }; + } + + case 'advanced_search_metadata': { + const { query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, requiredMailboxIds, excludeMailboxIds, after, before, limit, ascending } = (args ?? {}) as any; + const client = initializeClient(); + const validLimit = Math.min(Math.max(Number(limit) || 50, 1), 100); + const emails = await client.advancedSearchMetadata({ + query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, requiredMailboxIds, excludeMailboxIds, after, before, limit: validLimit, ascending }); return { content: [ @@ -1606,6 +1953,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } + case 'get_thread_metadata': { + const { threadId } = (args ?? {}) as any; + if (!threadId) { + throw new McpError(ErrorCode.InvalidParams, 'threadId is required'); + } + const client = initializeClient(); + try { + const thread = await client.getThreadMetadata(threadId); + return { + content: [ + { + type: 'text', + text: JSON.stringify(thread, null, 2), + }, + ], + }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Thread access failed: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`); + } + } + case 'get_mailbox_stats': { const { mailboxId } = args as any; const client = initializeClient(); @@ -1752,9 +2120,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { email: { available: true, functions: [ - 'list_mailboxes', 'list_emails', 'get_email', 'send_email', 'create_draft', 'edit_draft', 'send_draft', 'search_emails', - 'get_recent_emails', 'mark_email_read', 'pin_email', 'delete_email', 'move_email', - 'get_email_attachments', 'download_attachment', 'advanced_search', 'get_thread', + 'list_mailboxes', 'get_mailbox_by_name', 'create_mailbox', 'list_emails', 'list_emails_metadata', 'get_email', 'get_email_metadata', 'send_email', + 'create_draft', 'edit_draft', 'send_draft', 'search_emails', 'search_emails_metadata', + 'get_recent_emails', 'mark_email_read', 'pin_email', 'delete_email', 'move_email', 'archive_email', + 'get_email_attachments', 'download_attachment', 'advanced_search', 'advanced_search_metadata', 'get_thread', 'get_thread_metadata', 'get_mailbox_stats', 'get_account_summary', 'bulk_mark_read', 'bulk_pin', 'bulk_move', 'bulk_delete', 'add_labels', 'remove_labels', 'bulk_add_labels', 'bulk_remove_labels' ] diff --git a/src/jmap-client-extra.test.ts b/src/jmap-client-extra.test.ts index ed65152..aab5d14 100644 --- a/src/jmap-client-extra.test.ts +++ b/src/jmap-client-extra.test.ts @@ -1,6 +1,6 @@ import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { JmapClient } from './jmap-client.js'; +import { JmapClient, buildEmailQueryFilter } from './jmap-client.js'; import { FastmailAuth } from './auth.js'; // ---------- helpers ---------- @@ -626,3 +626,319 @@ describe('ascending sort parameter', () => { }); }); }); + +// ---------- *_metadata variants: privacy invariant ---------- +// +// These tests pin the load-bearing privacy invariant of the four metadata +// variants: their JMAP `Email/get` properties allowlist must never contain +// `preview` (or any body-derived field). A future refactor that accidentally +// re-introduces preview will fail here loudly, rather than silently leaking +// body excerpts to callers operating under least-privilege constraints. + +describe('metadata variants — JMAP properties allowlist', () => { + let client: JmapClient; + + beforeEach(() => { + client = makeClient(); + }); + + const QUERY_GET_RESPONSE = { + methodResponses: [ + ['Email/query', { ids: ['e1'] }, 'query'], + ['Email/get', { list: [{ id: 'e1', subject: 'Test' }] }, 'emails'], + ], + }; + + const FORBIDDEN_PROPERTIES = ['preview', 'textBody', 'htmlBody', 'bodyValues', 'body', 'bodyStructure']; + + function assertNoBodyProperties(props: any) { + assert.ok(Array.isArray(props), 'properties must be an array'); + for (const forbidden of FORBIDDEN_PROPERTIES) { + assert.ok( + !props.includes(forbidden), + `properties allowlist must not contain '${forbidden}' (got: ${JSON.stringify(props)})`, + ); + } + } + + it('getEmailsMetadata excludes preview and body fields from Email/get properties', async () => { + const makeReq = mock.method(client, 'makeRequest', async () => QUERY_GET_RESPONSE); + + await client.getEmailsMetadata('mb-inbox', 5); + + const props = makeReq.mock.calls[0].arguments[0].methodCalls[1][1].properties; + assertNoBodyProperties(props); + }); + + it('searchEmailsMetadata excludes preview and body fields from Email/get properties', async () => { + const makeReq = mock.method(client, 'makeRequest', async () => QUERY_GET_RESPONSE); + + await client.searchEmailsMetadata('test', 10); + + const props = makeReq.mock.calls[0].arguments[0].methodCalls[1][1].properties; + assertNoBodyProperties(props); + }); + + it('advancedSearchMetadata excludes preview and body fields from Email/get properties', async () => { + const makeReq = mock.method(client, 'makeRequest', async () => QUERY_GET_RESPONSE); + + await client.advancedSearchMetadata({ query: 'test', mailboxId: 'mb-inbox' }); + + const props = makeReq.mock.calls[0].arguments[0].methodCalls[1][1].properties; + assertNoBodyProperties(props); + }); + + it('advancedSearchMetadata preserves the full filter logic of advancedSearch', async () => { + // The metadata variant is structurally identical to advancedSearch except + // for the property list — confirm filter handling is intact (mailbox, + // recipient, attachment, isUnread/isPinned conjunction). + const makeReq = mock.method(client, 'makeRequest', async () => QUERY_GET_RESPONSE); + + await client.advancedSearchMetadata({ + mailboxId: 'mb-inbox', + to: 'someone@example.com', + hasAttachment: true, + isUnread: true, + isPinned: true, + }); + + const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter; + // When both isUnread and isPinned are set, advancedSearch wraps the + // conditions in an AND operator — the metadata variant must do the same. + assert.equal(filter.operator, 'AND'); + assert.ok(Array.isArray(filter.conditions)); + }); + + it('getThreadMetadata excludes preview and body fields when fetching thread emails', async () => { + // Two-step: the threadId-resolution probe also calls Email/get, but with + // a `properties: ['threadId']` minimum payload — that's fine. The + // privacy-critical call is the second `makeRequest` (the Thread/get + + // Email/get composite). We only inspect that one's properties list. + let callIndex = 0; + const makeReq = mock.method(client, 'makeRequest', async () => { + callIndex += 1; + if (callIndex === 1) { + // First call: threadId resolution probe. + return { methodResponses: [['Email/get', { list: [{ id: 'e1', threadId: 't1' }] }, 'checkEmail']] }; + } + // Second call: Thread/get + Email/get composite — this is the one that + // would leak preview if the allowlist were wrong. + return { + methodResponses: [ + ['Thread/get', { list: [{ id: 't1', emailIds: ['e1'] }] }, 'getThread'], + ['Email/get', { list: [{ id: 'e1', subject: 'Test' }] }, 'emails'], + ], + }; + }); + + await client.getThreadMetadata('e1'); + + // Inspect the second call's Email/get properties (the composite). + const compositeProps = makeReq.mock.calls[1].arguments[0].methodCalls[1][1].properties; + assertNoBodyProperties(compositeProps); + }); +}); + +// ---------- buildEmailQueryFilter ---------- +// +// Direct unit tests for the JMAP filter assembler. These pin the wire-level +// shapes that advancedSearch / advancedSearchMetadata depend on. The helper is +// pure, so testing it directly is much cheaper than round-tripping through a +// mocked makeRequest for every shape. + +describe('buildEmailQueryFilter', () => { + it('returns an empty filter when no fields are set', () => { + assert.deepEqual(buildEmailQueryFilter({}), {}); + }); + + it('passes through standard FilterCondition fields on a flat condition', () => { + const filter = buildEmailQueryFilter({ + query: 'invoice', + from: 'a@example.com', + to: 'b@example.com', + subject: 'Q1 2025', + hasAttachment: true, + after: '2025-01-01T00:00:00Z', + before: '2025-04-01T00:00:00Z', + }); + assert.deepEqual(filter, { + text: 'invoice', + from: 'a@example.com', + to: 'b@example.com', + subject: 'Q1 2025', + hasAttachment: true, + after: '2025-01-01T00:00:00Z', + before: '2025-04-01T00:00:00Z', + }); + }); + + it('flattens a single mailboxId into a flat FilterCondition', () => { + assert.deepEqual( + buildEmailQueryFilter({ mailboxId: 'mb-1' }), + { inMailbox: 'mb-1' }, + ); + }); + + it('flattens a single requiredMailboxIds entry to the same shape as mailboxId', () => { + const viaArray = buildEmailQueryFilter({ requiredMailboxIds: ['mb-1'] }); + const viaScalar = buildEmailQueryFilter({ mailboxId: 'mb-1' }); + assert.deepEqual(viaArray, viaScalar); + }); + + it('builds FilterOperator AND when requiredMailboxIds has multiple entries', () => { + const filter = buildEmailQueryFilter({ requiredMailboxIds: ['mb-1', 'mb-2'] }); + assert.equal(filter.operator, 'AND'); + assert.equal(filter.conditions.length, 2); + assert.deepEqual(filter.conditions[0], { inMailbox: 'mb-1' }); + assert.deepEqual(filter.conditions[1], { inMailbox: 'mb-2' }); + }); + + it('combines mailboxId with requiredMailboxIds and de-duplicates overlap', () => { + const filter = buildEmailQueryFilter({ + mailboxId: 'mb-1', + requiredMailboxIds: ['mb-1', 'mb-2'], // 'mb-1' duplicates the scalar; should drop + }); + assert.equal(filter.operator, 'AND'); + assert.equal(filter.conditions.length, 2); + assert.deepEqual(filter.conditions[0], { inMailbox: 'mb-1' }); + assert.deepEqual(filter.conditions[1], { inMailbox: 'mb-2' }); + }); + + it('emits inMailboxOtherThan on a single-condition filter', () => { + const filter = buildEmailQueryFilter({ + mailboxId: 'mb-parent', + excludeMailboxIds: ['mb-archive'], + }); + assert.deepEqual(filter, { + inMailbox: 'mb-parent', + inMailboxOtherThan: ['mb-archive'], + }); + }); + + it('emits inMailboxOtherThan inside the AND-base when multi-required is needed', () => { + const filter = buildEmailQueryFilter({ + requiredMailboxIds: ['mb-inbox', 'mb-label'], + excludeMailboxIds: ['mb-archive'], + isPinned: true, + }); + assert.equal(filter.operator, 'AND'); + assert.deepEqual(filter.conditions[0], { + inMailboxOtherThan: ['mb-archive'], + hasKeyword: '$flagged', + }); + assert.deepEqual(filter.conditions[1], { inMailbox: 'mb-inbox' }); + assert.deepEqual(filter.conditions[2], { inMailbox: 'mb-label' }); + }); + + it('keeps a single keyword condition flat when only one of isUnread/isPinned is set', () => { + assert.deepEqual( + buildEmailQueryFilter({ mailboxId: 'mb-1', isPinned: true }), + { inMailbox: 'mb-1', hasKeyword: '$flagged' }, + ); + }); + + it('preserves the legacy isUnread+isPinned AND-split when both are set', () => { + // Both keyword conditions must be split out of base to avoid + // hasKeyword/notKeyword collisions on a single FilterCondition. + const filter = buildEmailQueryFilter({ + mailboxId: 'mb-inbox', + isUnread: true, + isPinned: true, + }); + assert.equal(filter.operator, 'AND'); + assert.equal(filter.conditions.length, 3); + assert.deepEqual(filter.conditions[0], { inMailbox: 'mb-inbox' }); + assert.ok(filter.conditions.some((c: any) => c.notKeyword === '$seen')); + assert.ok(filter.conditions.some((c: any) => c.hasKeyword === '$flagged')); + }); + + it('combines every primitive in one shot', () => { + const filter = buildEmailQueryFilter({ + query: 'invoice', + hasAttachment: true, + requiredMailboxIds: ['mb-inbox', 'mb-receipts'], + excludeMailboxIds: ['mb-archive'], + isPinned: true, + after: '2025-01-01T00:00:00Z', + }); + assert.equal(filter.operator, 'AND'); + assert.deepEqual(filter.conditions[0], { + text: 'invoice', + hasAttachment: true, + after: '2025-01-01T00:00:00Z', + inMailboxOtherThan: ['mb-archive'], + hasKeyword: '$flagged', + }); + assert.deepEqual(filter.conditions[1], { inMailbox: 'mb-inbox' }); + assert.deepEqual(filter.conditions[2], { inMailbox: 'mb-receipts' }); + }); + + it('treats empty requiredMailboxIds and empty excludeMailboxIds as absent', () => { + assert.deepEqual( + buildEmailQueryFilter({ + mailboxId: 'mb-1', + requiredMailboxIds: [], + excludeMailboxIds: [], + }), + { inMailbox: 'mb-1' }, + ); + }); +}); + +// ---------- advancedSearch / advancedSearchMetadata wiring ---------- + +describe('advancedSearch and advancedSearchMetadata wire requiredMailboxIds correctly', () => { + let client: JmapClient; + const QUERY_GET_RESPONSE = { + methodResponses: [ + ['Email/query', { ids: [] }, 'query'], + ['Email/get', { list: [] }, 'emails'], + ], + }; + + beforeEach(() => { + client = makeClient(); + }); + + it('advancedSearch sends a FilterOperator AND when requiredMailboxIds has 2 entries', async () => { + const makeReq = mock.method(client, 'makeRequest', async () => QUERY_GET_RESPONSE); + + await client.advancedSearch({ + requiredMailboxIds: ['mb-inbox', 'mb-label'], + isPinned: true, + }); + + const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter; + assert.equal(filter.operator, 'AND'); + assert.equal(filter.conditions.length, 3); + assert.ok(filter.conditions.some((c: any) => c.inMailbox === 'mb-inbox')); + assert.ok(filter.conditions.some((c: any) => c.inMailbox === 'mb-label')); + assert.ok(filter.conditions.some((c: any) => c.hasKeyword === '$flagged')); + }); + + it('advancedSearchMetadata sends inMailboxOtherThan when excludeMailboxIds is set', async () => { + const makeReq = mock.method(client, 'makeRequest', async () => QUERY_GET_RESPONSE); + + await client.advancedSearchMetadata({ + mailboxId: 'mb-parent', + excludeMailboxIds: ['mb-child-archive'], + }); + + const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter; + assert.deepEqual(filter, { + inMailbox: 'mb-parent', + inMailboxOtherThan: ['mb-child-archive'], + }); + }); + + it('advancedSearch backwards-compat: legacy single-mailbox calls still produce flat filter', async () => { + // Pre-1.11 callers passing only mailboxId must continue to produce a flat + // FilterCondition shape. + const makeReq = mock.method(client, 'makeRequest', async () => QUERY_GET_RESPONSE); + + await client.advancedSearch({ mailboxId: 'mb-only' }); + + const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter; + assert.deepEqual(filter, { inMailbox: 'mb-only' }); + }); +}); diff --git a/src/jmap-client.ts b/src/jmap-client.ts index 77873d7..f6453ff 100644 --- a/src/jmap-client.ts +++ b/src/jmap-client.ts @@ -33,6 +33,99 @@ function matchesIdentity(identityEmail: string, address: string): boolean { } return false; } + +export interface EmailQueryFilters { + query?: string; + from?: string; + to?: string; + subject?: string; + hasAttachment?: boolean; + isUnread?: boolean; + isPinned?: boolean; + mailboxId?: string; + requiredMailboxIds?: string[]; + excludeMailboxIds?: string[]; + after?: string; + before?: string; + limit?: number; + ascending?: boolean; +} + +/** + * Build a JMAP Email/query filter from the high-level filter parameters used by + * advancedSearch / advancedSearchMetadata. + * + * Output is one of three shapes: + * - {} when no fields are set (matches all messages) + * - a single FilterCondition object when every field can coexist on one condition + * - { operator: 'AND', conditions: [...] } when JMAP's per-FilterCondition limits + * require splitting across multiple conditions (multi-mailbox AND, or + * hasKeyword/notKeyword conflicts between isUnread and isPinned) + * + * Exported (rather than file-private) so the test suite can verify the filter + * shape directly without round-tripping through a mocked makeRequest. + */ +export function buildEmailQueryFilter(filters: EmailQueryFilters): any { + // Base FilterCondition: fields that always coexist cleanly on a single condition. + const base: any = {}; + if (filters.query) base.text = filters.query; + if (filters.from) base.from = filters.from; + if (filters.to) base.to = filters.to; + if (filters.subject) base.subject = filters.subject; + if (filters.hasAttachment !== undefined) base.hasAttachment = filters.hasAttachment; + if (filters.after) base.after = filters.after; + if (filters.before) base.before = filters.before; + if (filters.excludeMailboxIds && filters.excludeMailboxIds.length > 0) { + base.inMailboxOtherThan = filters.excludeMailboxIds; + } + + // Combine mailboxId + requiredMailboxIds, de-dup, preserve insertion order. + const seenIds = new Set(); + const requiredMailboxes: string[] = []; + const candidateIds = [filters.mailboxId, ...(filters.requiredMailboxIds ?? [])]; + for (const id of candidateIds) { + if (id && !seenIds.has(id)) { + seenIds.add(id); + requiredMailboxes.push(id); + } + } + + // A single required mailbox folds into the base condition. Multiple + // memberships have to live in separate FilterConditions because JMAP's + // inMailbox is singular per condition. + if (requiredMailboxes.length === 1) { + base.inMailbox = requiredMailboxes[0]; + } + + // When both isUnread and isPinned are defined, their keyword conditions + // can collide on hasKeyword or notKeyword (each is singular per + // FilterCondition), so split them across separate conditions. + const splitKeywords = filters.isUnread !== undefined && filters.isPinned !== undefined; + if (!splitKeywords) { + if (filters.isUnread === true) base.notKeyword = '$seen'; + else if (filters.isUnread === false) base.hasKeyword = '$seen'; + if (filters.isPinned === true) base.hasKeyword = '$flagged'; + else if (filters.isPinned === false) base.notKeyword = '$flagged'; + } + + // Assemble the final shape. + const conditions: any[] = []; + if (Object.keys(base).length > 0) conditions.push(base); + + if (requiredMailboxes.length > 1) { + for (const id of requiredMailboxes) conditions.push({ inMailbox: id }); + } + + if (splitKeywords) { + conditions.push(filters.isUnread ? { notKeyword: '$seen' } : { hasKeyword: '$seen' }); + conditions.push(filters.isPinned ? { hasKeyword: '$flagged' } : { notKeyword: '$flagged' }); + } + + if (conditions.length === 0) return {}; + if (conditions.length === 1) return conditions[0]; + return { operator: 'AND', conditions }; +} + export class JmapClient { private auth: FastmailAuth; private session: JmapSession | null = null; @@ -139,18 +232,94 @@ export class JmapClient { (nameFallback ? mailboxes.find(mb => mb.name.toLowerCase().includes(nameFallback)) : undefined); } - async getMailboxes(): Promise { + async getMailboxes(options?: { properties?: string[]; parentId?: string | null }): Promise { const session = await this.getSession(); - + + const args: Record = { accountId: session.accountId }; + if (options?.properties && options.properties.length > 0) { + args.properties = options.properties; + } + const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ - ['Mailbox/get', { accountId: session.accountId }, 'mailboxes'] + ['Mailbox/get', args, 'mailboxes'] ] }; const response = await this.makeRequest(request); - return this.getListResult(response, 0); + let list = this.getListResult(response, 0); + if (options && Object.prototype.hasOwnProperty.call(options, 'parentId')) { + const filterParent = options.parentId ?? null; + list = list.filter((mb: any) => (mb.parentId ?? null) === filterParent); + } + return list; + } + + async getMailboxByName(path: string): Promise<{ id: string; name: string; parentId: string | null; path: string }> { + if (!path || typeof path !== 'string') { + throw new Error('path is required and must be a non-empty string'); + } + const mailboxes = await this.getMailboxes({ properties: ['id', 'name', 'parentId'] }); + const byId = new Map(); + for (const mb of mailboxes) byId.set(mb.id, mb); + + const buildPath = (mb: any): string => { + const segments: string[] = []; + let cursor: any = mb; + let depth = 0; + while (cursor && depth < 100) { + segments.unshift(cursor.name); + cursor = cursor.parentId ? byId.get(cursor.parentId) : null; + depth++; + } + return segments.join('/'); + }; + + for (const mb of mailboxes) { + if (buildPath(mb) === path) { + return { id: mb.id, name: mb.name, parentId: mb.parentId ?? null, path }; + } + } + throw new Error(`Mailbox not found: ${path}`); + } + + async createMailbox(name: string, parentId?: string | null): Promise { + if (!name || typeof name !== 'string') { + throw new Error('name is required and must be a non-empty string'); + } + const session = await this.getSession(); + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Mailbox/set', { + accountId: session.accountId, + create: { + new1: { + name, + parentId: parentId ?? null + } + } + }, 'createMailbox'] + ] + }; + + const response = await this.makeRequest(request); + const result = this.getMethodResult(response, 0); + + if (result.notCreated && result.notCreated.new1) { + const err = result.notCreated.new1; + const detail = err.description ? ` - ${err.description}` : ''; + const props = err.properties ? ` (properties: ${err.properties.join(', ')})` : ''; + throw new Error(`Failed to create mailbox: ${err.type}${detail}${props}`); + } + + const created = result.created?.new1; + if (!created?.id) { + throw new Error('Mailbox creation reported success but server did not return an ID'); + } + return created.id; } async getEmails(mailboxId?: string, limit: number = 20, ascending: boolean = false): Promise { @@ -179,6 +348,32 @@ export class JmapClient { return this.getListResult(response, 1); } + async getEmailsMetadata(mailboxId?: string, limit: number = 20, ascending: boolean = false): Promise { + const session = await this.getSession(); + + const filter = mailboxId ? { inMailbox: mailboxId } : {}; + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Email/query', { + accountId: session.accountId, + filter, + sort: [{ property: 'receivedAt', isAscending: ascending }], + limit + }, 'query'], + ['Email/get', { + accountId: session.accountId, + '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, + properties: ['id', 'threadId', 'subject', 'from', 'to', 'replyTo', 'receivedAt', 'hasAttachment', 'keywords'] + }, 'emails'] + ] + }; + + const response = await this.makeRequest(request); + return this.getListResult(response, 1); + } + async getEmailById(id: string): Promise { const session = await this.getSession(); @@ -207,7 +402,54 @@ export class JmapClient { if (!email) { throw new Error(`Email with ID '${id}' not found or not accessible`); } - + + return email; + } + + async getEmailMetadata(id: string): Promise { + const session = await this.getSession(); + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Email/get', { + accountId: session.accountId, + ids: [id], + properties: [ + 'id', + 'threadId', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'sentAt', + 'subject', + 'from', + 'to', + 'cc', + 'bcc', + 'replyTo', + 'messageId', + 'inReplyTo', + 'references', + 'size', + 'hasAttachment', + ], + }, 'emailMetadata'] + ] + }; + + const response = await this.makeRequest(request); + const result = this.getMethodResult(response, 0); + + if (result.notFound && result.notFound.includes(id)) { + throw new Error(`Email with ID '${id}' not found`); + } + + const email = result.list?.[0]; + if (!email) { + throw new Error(`Email with ID '${id}' not found or not accessible`); + } + return email; } @@ -881,6 +1123,57 @@ export class JmapClient { } } + async archiveEmail(emailId: string, targetMailboxId: string): Promise { + const session = await this.getSession(); + + const getRequest: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Email/get', { + accountId: session.accountId, + ids: [emailId], + properties: ['mailboxIds'] + }, 'getEmail'] + ] + }; + const getResponse = await this.makeRequest(getRequest); + const email = this.getListResult(getResponse, 0)[0]; + + if (!email) { + throw new Error(`Email not found: ${emailId}`); + } + + const patch: Record = {}; + if (email.mailboxIds) { + for (const mbId of Object.keys(email.mailboxIds)) { + patch[`mailboxIds/${mbId}`] = null; + } + } + patch[`mailboxIds/${targetMailboxId}`] = true; + patch['keywords/$seen'] = true; + + const setRequest: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Email/set', { + accountId: session.accountId, + update: { + [emailId]: patch + } + }, 'archiveEmail'] + ] + }; + + const response = await this.makeRequest(setRequest); + const result = this.getMethodResult(response, 0); + + if (result.notUpdated && result.notUpdated[emailId]) { + const err = result.notUpdated[emailId]; + const detail = err.description ? ` - ${err.description}` : ''; + throw new Error(`Failed to archive email: ${err.type}${detail}`); + } + } + async addLabels(emailId: string, mailboxIds: string[]): Promise { const session = await this.getSession(); @@ -1178,49 +1471,9 @@ export class JmapClient { return { url, bytesWritten: buffer.length }; } - async advancedSearch(filters: { - query?: string; - from?: string; - to?: string; - subject?: string; - hasAttachment?: boolean; - isUnread?: boolean; - isPinned?: boolean; - mailboxId?: string; - after?: string; - before?: string; - limit?: number; - ascending?: boolean; - }): Promise { + async advancedSearch(filters: EmailQueryFilters): Promise { const session = await this.getSession(); - - // Build JMAP filter object - const filter: any = {}; - - if (filters.query) filter.text = filters.query; - if (filters.from) filter.from = filters.from; - if (filters.to) filter.to = filters.to; - if (filters.subject) filter.subject = filters.subject; - if (filters.hasAttachment !== undefined) filter.hasAttachment = filters.hasAttachment; - if (filters.isUnread === true) filter.notKeyword = '$seen'; - else if (filters.isUnread === false) filter.hasKeyword = '$seen'; - if (filters.isPinned === true) filter.hasKeyword = '$flagged'; - if (filters.isPinned === false) filter.notKeyword = '$flagged'; - if (filters.mailboxId) filter.inMailbox = filters.mailboxId; - if (filters.after) filter.after = filters.after; - if (filters.before) filter.before = filters.before; - - // When both isUnread and isPinned are set, hasKeyword/notKeyword may conflict. - // JMAP FilterCondition only supports one hasKeyword, so wrap in an AND operator. - let finalFilter: any = filter; - if (filters.isUnread !== undefined && filters.isPinned !== undefined) { - delete filter.hasKeyword; - delete filter.notKeyword; - const conditions: any[] = [filter]; - conditions.push(filters.isUnread ? { notKeyword: '$seen' } : { hasKeyword: '$seen' }); - conditions.push(filters.isPinned ? { hasKeyword: '$flagged' } : { notKeyword: '$flagged' }); - finalFilter = { operator: 'AND', conditions }; - } + const finalFilter = buildEmailQueryFilter(filters); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], @@ -1243,6 +1496,31 @@ export class JmapClient { return this.getListResult(response, 1); } + async advancedSearchMetadata(filters: EmailQueryFilters): Promise { + const session = await this.getSession(); + const finalFilter = buildEmailQueryFilter(filters); + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Email/query', { + accountId: session.accountId, + filter: finalFilter, + sort: [{ property: 'receivedAt', isAscending: filters.ascending ?? false }], + limit: Math.min(filters.limit || 50, 100) + }, 'query'], + ['Email/get', { + accountId: session.accountId, + '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, + properties: ['id', 'threadId', 'subject', 'from', 'to', 'cc', 'replyTo', 'receivedAt', 'hasAttachment', 'keywords'] + }, 'emails'] + ] + }; + + const response = await this.makeRequest(request); + return this.getListResult(response, 1); + } + async searchEmails(query: string, limit: number = 20, ascending: boolean = false): Promise { const session = await this.getSession(); @@ -1267,6 +1545,30 @@ export class JmapClient { return this.getListResult(response, 1); } + async searchEmailsMetadata(query: string, limit: number = 20, ascending: boolean = false): Promise { + const session = await this.getSession(); + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Email/query', { + accountId: session.accountId, + filter: { text: query }, + sort: [{ property: 'receivedAt', isAscending: ascending }], + limit + }, 'query'], + ['Email/get', { + accountId: session.accountId, + '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, + properties: ['id', 'threadId', 'subject', 'from', 'to', 'replyTo', 'receivedAt', 'hasAttachment', 'keywords'] + }, 'emails'] + ] + }; + + const response = await this.makeRequest(request); + return this.getListResult(response, 1); + } + async getThread(threadId: string): Promise { const session = await this.getSession(); @@ -1323,6 +1625,59 @@ export class JmapClient { return this.getListResult(response, 1); } + async getThreadMetadata(threadId: string): Promise { + const session = await this.getSession(); + + // Resolve threadId — accept either an email ID or a thread ID, mirroring getThread. + let actualThreadId = threadId; + + try { + const emailRequest: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Email/get', { + accountId: session.accountId, + ids: [threadId], + properties: ['threadId'] + }, 'checkEmail'] + ] + }; + + const emailResponse = await this.makeRequest(emailRequest); + const email = this.getListResult(emailResponse, 0)[0]; + + if (email && email.threadId) { + actualThreadId = email.threadId; + } + } catch (error) { + // If email lookup fails, assume threadId is correct + } + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + ['Thread/get', { + accountId: session.accountId, + ids: [actualThreadId] + }, 'getThread'], + ['Email/get', { + accountId: session.accountId, + '#ids': { resultOf: 'getThread', name: 'Thread/get', path: '/list/*/emailIds' }, + properties: ['id', 'threadId', 'subject', 'from', 'to', 'cc', 'replyTo', 'receivedAt', 'hasAttachment', 'keywords'] + }, 'emails'] + ] + }; + + const response = await this.makeRequest(request); + const threadResult = this.getMethodResult(response, 0); + + if (threadResult.notFound && threadResult.notFound.includes(actualThreadId)) { + throw new Error(`Thread with ID '${actualThreadId}' not found`); + } + + return this.getListResult(response, 1); + } + async getMailboxStats(mailboxId?: string): Promise { const session = await this.getSession();