download_attachment
Download email attachments using email and attachment IDs. Optionally save to a restricted directory, or receive a download URL.
Instructions
Download an email attachment. If savePath is provided, saves the file to disk and returns the file path and size. Otherwise returns a download URL.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| emailId | Yes | ID of the email | |
| attachmentId | Yes | ID of the attachment | |
| savePath | No | File path to save the attachment to. Paths are restricted to ~/Downloads/fastmail-mcp/ (configurable via FASTMAIL_DOWNLOAD_DIR). Path traversal outside this directory is rejected for security. Parent directories will be created automatically. |
Implementation Reference
- src/index.ts:729-750 (schema)Input schema definition for the download_attachment tool. Defines required emailId, attachmentId parameters and optional savePath parameter with directory restrictions.
{ name: 'download_attachment', description: 'Download an email attachment. If savePath is provided, saves the file to disk and returns the file path and size. Otherwise returns a download URL.', inputSchema: { type: 'object', properties: { emailId: { type: 'string', description: 'ID of the email', }, attachmentId: { type: 'string', description: 'ID of the attachment', }, savePath: { type: 'string', description: `File path to save the attachment to. Paths are restricted to ${getDownloadDir() || '~/Downloads/fastmail-mcp/'} (configurable via FASTMAIL_DOWNLOAD_DIR). Path traversal outside this directory is rejected for security. Parent directories will be created automatically.`, }, }, required: ['emailId', 'attachmentId'], }, }, - src/index.ts:1529-1568 (handler)Handler for the download_attachment tool in the CallToolRequestSchema switch-case. Calls client.downloadAttachmentToFile() if savePath is provided, otherwise calls client.downloadAttachment() to return a download URL. Handles error cases including path validation errors and attachment download failures.
case 'download_attachment': { const { emailId, attachmentId, savePath } = args as any; if (!emailId || !attachmentId) { throw new McpError(ErrorCode.InvalidParams, 'emailId and attachmentId are required'); } const client = initializeClient(); try { if (savePath) { const result = await client.downloadAttachmentToFile(emailId, attachmentId, savePath, getDownloadDir()); return { content: [ { type: 'text', text: `Saved to: ${savePath} (${result.bytesWritten} bytes)`, }, ], }; } else { const downloadUrl = await client.downloadAttachment(emailId, attachmentId); return { content: [ { type: 'text', text: `Download URL: ${downloadUrl}`, }, ], }; } } catch (error) { // Let path validation errors through so users see why their savePath was rejected if (error instanceof Error && (error.message.includes('Save path') || error.message.includes('null bytes'))) { throw new McpError(ErrorCode.InvalidParams, error.message); } // Sanitize other errors to avoid leaking attachment metadata throw new McpError( ErrorCode.InternalError, 'Attachment download failed. Verify emailId and attachmentId and try again.' ); } } - src/jmap-client.ts:1025-1079 (helper)Client method downloadAttachment() that fetches attachment details from JMAP, resolves the blobId, and builds a download URL from the session's downloadUrl template.
async downloadAttachment(emailId: string, attachmentId: string): Promise<string> { const session = await this.getSession(); // Get the email with full attachment details const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/get', { accountId: session.accountId, ids: [emailId], properties: ['attachments', 'bodyValues'], bodyProperties: ['partId', 'blobId', 'size', 'name', 'type'] }, 'getEmail'] ] }; const response = await this.makeRequest(request); const email = this.getListResult(response, 0)[0]; if (!email) { throw new Error('Email not found'); } // Find attachment by partId or by index let attachment = email.attachments?.find((att: any) => att.partId === attachmentId || att.blobId === attachmentId ); // If not found, try by array index if (!attachment) { const index = parseInt(attachmentId, 10); if (!isNaN(index)) { attachment = email.attachments?.[index]; } } if (!attachment) { throw new Error('Attachment not found.'); } // Get the download URL from session const downloadUrl = session.downloadUrl; if (!downloadUrl) { throw new Error('Download capability not available in session'); } // Build download URL const url = downloadUrl .replace('{accountId}', session.accountId) .replace('{blobId}', attachment.blobId) .replace('{type}', encodeURIComponent(attachment.type || 'application/octet-stream')) .replace('{name}', encodeURIComponent(attachment.name || 'attachment')); return url; } - src/jmap-client.ts:1161-1179 (helper)Client method downloadAttachmentToFile() that validates the save path via safeWritePath(), fetches the download URL, downloads the binary data, writes it to disk, and returns URL and byte count.
async downloadAttachmentToFile(emailId: string, attachmentId: string, savePath: string, downloadDir?: string): Promise<{ url: string; bytesWritten: number }> { const safePath = await JmapClient.safeWritePath(savePath, downloadDir); const url = await this.downloadAttachment(emailId, attachmentId); const response = await fetch(url, { headers: { 'Authorization': this.auth.getAuthHeaders()['Authorization'] } }); if (!response.ok) { throw new Error(`Download failed: ${response.status} ${response.statusText}`); } const buffer = Buffer.from(await response.arrayBuffer()); await mkdir(dirname(safePath), { recursive: true }); await writeFile(safePath, buffer); return { url, bytesWritten: buffer.length }; } - src/jmap-client.ts:1081-1159 (helper)Static helpers validateSavePath() and safeWritePath() for path traversal protection. Restricts saves to a configurable directory (defaults to ~/Downloads/fastmail-mcp/) and rejects symlink escapes.
static readonly DEFAULT_DOWNLOADS_DIR = resolve(homedir(), 'Downloads', 'fastmail-mcp'); static validateSavePath(savePath: string, downloadDir?: string): string { const allowedDir = downloadDir ? resolve(normalize(downloadDir)) : JmapClient.DEFAULT_DOWNLOADS_DIR; const resolved = resolve(normalize(savePath)); if (resolved.includes('\0')) { throw new Error('Save path contains null bytes'); } if (!resolved.startsWith(allowedDir + sep) && resolved !== allowedDir) { throw new Error( `Save path must be within ${allowedDir}. ` + `Received: ${savePath}` ); } return resolved; } /** * Symlink-safe canonicalization of a save path. Walks up to the longest * existing ancestor, realpaths it, and verifies it lives under the canonical * allowed directory. Refuses to overwrite an existing symlink at the target. * * Returns the canonical path that is safe to write to. Throws on escape. */ static async safeWritePath(savePath: string, downloadDir?: string): Promise<string> { // Lexical pre-check first (cheap and gives nice errors) const lexical = JmapClient.validateSavePath(savePath, downloadDir); const allowedDir = downloadDir ? resolve(normalize(downloadDir)) : JmapClient.DEFAULT_DOWNLOADS_DIR; // Ensure allowed dir exists so realpath can resolve it. await mkdir(allowedDir, { recursive: true }); const canonicalAllowed = await realpath(allowedDir); // Walk up from the target until we find an existing ancestor. let ancestor = dirname(lexical); const missingSegments: string[] = []; while (true) { try { await stat(ancestor); break; } catch (e: any) { if (e.code !== 'ENOENT') throw e; missingSegments.unshift(basename(ancestor)); const parent = dirname(ancestor); if (parent === ancestor) { throw new Error(`Could not find existing ancestor for save path: ${lexical}`); } ancestor = parent; } } // Canonicalize the existing ancestor — this is what catches symlink escapes. const canonicalAncestor = await realpath(ancestor); if (canonicalAncestor !== canonicalAllowed && !canonicalAncestor.startsWith(canonicalAllowed + sep)) { throw new Error( `Save path resolves to '${canonicalAncestor}' which is outside the allowed directory '${canonicalAllowed}'. ` + `Refusing to follow symlink escape.`, ); } // Reconstruct the safe canonical path under the canonical ancestor. const safePath = join(canonicalAncestor, ...missingSegments, basename(lexical)); // If a symlink already exists at the target, refuse — writing through it // would still escape the allowed directory. try { const lst = await lstat(safePath); if (lst.isSymbolicLink()) { throw new Error(`Refusing to overwrite an existing symlink at the target: ${safePath}`); } } catch (e: any) { if (e.code !== 'ENOENT') throw e; } return safePath; }