Skip to main content
Glama
MadLlama25

Fastmail MCP Server

by MadLlama25

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

TableJSON Schema
NameRequiredDescriptionDefault
emailIdYesID of the email
attachmentIdYesID of the attachment
savePathNoFile 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

  • 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'],
      },
    },
  • 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.'
        );
      }
    }
  • 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;
    }
  • 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 };
    }
  • 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;
    }
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description discloses key behaviors: file saving vs URL return, path restrictions (directory allowed, traversal rejected, auto-creation of parent dirs). It lacks details on overwrite behavior or error handling but covers the essential behavioral aspects.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two sentences, zero wasted words. Each sentence provides distinct information: first states the overall purpose, second elaborates on the conditional behavior. Perfectly front-loaded and efficient.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness3/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

The description covers the main functionality and security restrictions, but lacks details on return format specifics (e.g., structure of the file path/size object or URL) and error scenarios. Given the lack of output schema and annotations, more completeness would be beneficial, but it is adequate for basic usage.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so baseline is 3. The description adds value by explaining the conditional semantics of savePath (disk save vs URL) and the security constraints. This goes beyond the schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: download an email attachment. It distinguishes two distinct behaviors based on whether savePath is provided, which is specific and unique among sibling tools (no other download tool exists).

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explains when to provide savePath (to save to disk) versus when to omit it (to get a download URL). It does not explicitly mention alternatives because none exist among siblings, so the guidance is clear for the two usage modes.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/MadLlama25/fastmail-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server