公司动态

用MCP构建只读邮件服务:多邮箱统一接入手机端

📅 2026/8/31 9:47:03
用MCP构建只读邮件服务:多邮箱统一接入手机端
管理多个邮箱账户时最烦人的不是收件箱数量多而是每次都要打开不同客户端、输入不同授权信息页面和搜索逻辑还不一致。我按工程化方式搭建了这样一套服务用 Model Context ProtocolMCP把所有邮件账户统一暴露成只读接口做成一个可监听的 MCP Server然后从手机上的 MCP 客户端直接读取。这个方案既绕开了重复登录又把写操作的误触风险降到最低。这篇文章会从设计思路、依赖准备、工具实现、本地验证到手机接入完整走一遍适合已经在用 MCP 或准备把个人数据接入 MCP 的开发者。1. 先想清楚只读 MCP Server 到底解决什么问题1.1 多邮箱管理的真实痛点多数人都有两个以上邮箱工作邮箱、个人邮箱、服务监控邮箱、临时注册邮箱。每到月底想统一看看“最近有哪些重要邮件”就要在几个网页或客户端之间切换。更麻烦的是不同邮箱的文件夹命名不同已读未读规则不同搜索语法也不同。这类问题适合用统一接口抽象掉底层差异而不是靠记密码和手工刷新。还有一个细节容易被忽略很多邮件客户端默认会把邮件标记为已读或者把服务端文件夹状态改掉。对个人工具来说这不是大问题但如果你把邮件接口提供给一个 Agent 或手机端的 AI 应用使用未授权写入就意味着风险。AI 可能基于错误理解触发发信、删除、移动等操作。只读约束能从入口杜绝这类事故。1.2 MCP 在其中的作用以及只读约束为什么合理Model Context Protocol 本质上是给“模型 / 客户端”和“工具 / 数据源”之间定义一个标准协议。MCP Server 负责暴露可用工具、定义输入输出格式客户端通过协议调用这些工具。邮件服务的场景正好合适邮箱账户属于数据源手机上的 MCP 客户端是调用方我们需要把数据源能力转成几个语义明确、参数稳定的工具。只读约束在这里不是功能缺陷而是安全边界。读取邮件本身已经涉及敏感数据如果把任意写入能力都暴露出来一旦客户端或中间层被注入恶意指令就可能产生不可逆后果。保持只读意味着工具列表里只有list_mailboxes、list_messages、get_message、search_messages没有send_mail、delete_mail、set_seen。即使调用方乱传参数也无法造成数据变更。1.3 与传统邮件客户端方案的区别传统方案是装一个手机邮件客户端配置一个或多个邮箱然后在客户端内浏览。优点是交互完整缺点是企业级认证、别名、多账号间同步经常做得不好。MCP Server 的思路是把邮件读取能力变成接口客户端只需要实现 MCP 协议不需要为每个邮箱厂商写适配器。区别可以归结为三层维度传统邮件客户端只读 MCP Server数据访问方式客户端私有协议标准 MCP 协议多邮箱适配每个客户端适配每个邮箱服务端统一封装客户端一次接入写入能力默认支持按需暴露只读优先可编程性低高可被 Agent 调用安全边界依赖客户端权限管理由服务端工具定义强制约束如果只是日常手动读信传统客户端足够。但如果想把邮件变成另一个系统可调用的数据源MCP Server 是更合适的存在。2. 前置知识、运行环境和依赖准备2.1 前置知识IMAP、MCP 与应用专用密码邮件远程读取基本都走 IMAP它比 POP3 更适合多端同步因为邮件保留在服务器上文件夹结构也能同步。IMAP 对只读操作也有原生支持打开邮箱时可以请求只读模式避免修改\Seen等标志。这是实现只读 MCP 工具的重要依赖。MCP 方面至少要理解三个概念McpServer定义工具和处理逻辑的服务对象。tool一个可被客户端调用的具名函数包含入参 schema 和执行函数。transport服务与客户端之间的通信方式本地可用 stdio远程手机访问可考虑 HTTP streamable transport。邮箱侧必须做两件事开启 IMAP 服务并生成一个不依赖浏览器登录的应用专用密码。不少邮箱默认只允许网页端登录IMAP 登录密码不是登录密码而是单独生成的授权码。2.2 运行环境Node.js 或 Python 的选择下面的示例用 Node.js 实现因为 MCP 生态中 Node.js SDK 使用率较高而且imapflow这类 IMAP 库 API 清晰。Python 也有imaplib和官方 MCP SDK流程类似。建议版本组件建议Node.js18 或 20 以上尽量使用 20 LTSnpm 包modelcontextprotocol/sdkIMAP 客户端imapflow邮件解析mailparser参数 schemazod需要说明的是MCP SDK 仍在迭代不同版本的方法名和 transport 路径可能有差异。下面的代码是思路模板落地前先运行npm view modelcontextprotocol/sdk version确认你拿到的版本再参考对应文档微调。2.3 依赖安装与项目初始化先创建项目目录并初始化mkdir mail-mcp-server cd mail-mcp-server npm init -y npm install modelcontextprotocol/sdk imapflow mailparser zod为了不让密码硬编码创建.env文件之前先安装环境变量加载库npm install dotenv.env内容示例ACCOUNTS_JSON[{id:work,host:imap.example.com,port:993,user:userexample.com,pass:app-password,label:工作邮箱}] PORT8787 API_TOKENchange-me-token生产环境不要用明文 token应该从密钥管理服务或系统环境变量读取。这里先放在.env是为了本地验证方便。3. 设计工具集一个只读邮件服务应该暴露哪些能力在设计工具前先明确一个问题客户端到底需要哪些操作。如果只做“收件箱聚合阅读”工具数量不必很多。工具太多会增加模型误用概率工具太少则无法完成任务。建议初始只暴露四个只读工具。3.1 工具一列出邮件账户这个工具没有入参或只允许一个可选id作用是返回当前已配置的账户列表包括账户 id 和标签。手机端接进来后可以先问“现在有哪些邮箱”再用账户 id 去查邮件。server.tool( list_accounts, 返回已配置的邮件账户列表不包含敏感信息, async () { const accounts config.accounts.map(({ id, label }) ({ id, label })); return { content: [{ type: text, text: JSON.stringify(accounts) }] }; } );3.2 工具二读取邮件列表这是最核心的工具。入参应该包含账户 id、文件夹名、起始位置、数量和未读过滤。IMAP 中文件夹名通常是INBOX但也要允许传Sent、Archive等自定义文件夹。server.tool( list_messages, 按文件夹读取邮件列表只返回元数据不读取正文不修改已读状态, { accountId: z.string().describe(账户 id通过 list_accounts 获取), mailbox: z.string().default(INBOX).describe(邮箱文件夹名), limit: z.number().int().min(1).max(50).default(20), unreadOnly: z.boolean().default(false), }, async ({ accountId, mailbox, limit, unreadOnly }) { const result await readMessages(accountId, mailbox, limit, unreadOnly); return { content: [{ type: text, text: JSON.stringify(result) }] }; } );3.3 工具三读取邮件正文与附件元信息列表接口只返回uid、主题、发件人、日期和seen状态。正文需要单独工具因为正文可能很大而且包含 HTML 需要清洗。该工具入参是账户 id、文件夹、uid以及includeHtml布尔值。默认只返回纯文本避免丢给客户端一堆带外链的 HTML。3.4 工具四搜索邮件IMAP 服务端具备基础搜索能力但不同邮箱支持不全。包装成 MCP 工具时建议只暴露关键词、文件夹、未读过滤三个参数并限制结果数量。搜索逻辑先看主题和发件人再判断是否包含关键词必要时退回全文检索。3.5 用参数表约束读写边界下面这个表可以直接放进项目 README也方便后续审查工具是否越界工具入参是否修改服务端状态说明list_accounts无否返回账户元数据list_messagesaccountId, mailbox, limit, unreadOnly否只读邮件列表get_messageaccountId, mailbox, uid, includeHtml否读正文不修改已读search_messagesaccountId, mailbox, query, limit否基于 IMAP 搜索如果后续出现一个工具需要写操作那就必须单独评估并把它放在另一个“写协议”的 server 中而不是混进只读 server。4. 逐步实现只读 MCP Server4.1 初始化 MCP Server 和 IMAP 客户端封装先写一个配置加载模块读取所有账户。这里的Accounts结构保存连接信息但不参与工具返回。import dotenv/config; import { ImapFlow } from imapflow; const rawAccounts JSON.parse(process.env.ACCOUNTS_JSON || []); const config { port: Number(process.env.PORT || 8787), token: process.env.API_TOKEN || dev-token, accounts: rawAccounts.map((acc) ({ id: acc.id, host: acc.host, port: acc.port, user: acc.user, pass: acc.pass, label: acc.label, secure: true, })), };secure: true表示使用imaps端口通常是 993。IMAP 连接是一次性还是长连接取决于调用频率后面再讨论。4.2 实现读取逻辑readMessages的作用是建立连接、打开文件夹、搜索邮件、关闭连接。重点在于打开文件夹时一定要传readOnly: true这样 IMAP 层不会修改任何标志。async function readMessages(accountId, mailbox, limit, unreadOnly) { const account config.accounts.find((a) a.id accountId); if (!account) throw new Error(unknown account: ${accountId}); const client new ImapFlow({ host: account.host, port: account.port, secure: account.secure, auth: { user: account.user, pass: account.pass }, logger: false, }); try { await client.connect(); const lock await client.getMailboxLock(mailbox, { readOnly: true }); try { const searchQuery unreadOnly ? { seen: false } : { all: true }; const uids await client.search(searchQuery, { uid: true }); const recentUids uids.slice(-limit); const messages []; for (const uid of recentUids) { const msg await client.fetchOne(uid, { envelope: true, flags: true, internalDate: true, source: false, }); messages.push({ uid, date: msg.internalDate, subject: msg.envelope.subject, from: msg.envelope.from?.map((a) a.address).join(, ), seen: msg.flags.includes(\\Seen), }); } return messages; } finally { lock.release(); } } finally { await client.logout(); } }这段代码的重要点是即使在readOnly下读取\Seen状态也不会把它标记为已读。fetchOne只抓取信封和标志不下载完整报文所以速度比较快。4.3 通过 Streamable HTTP Transport 暴露服务手机端无法方便地使用 stdio需要用 HTTP transport。MCP SDK 现在支持 streamable HTTP transport可以把它接入 Node 原生http模块。下面是一个简化版import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import http from node:http; const server new McpServer({ name: mail-mcp, version: 0.1.0 }); // 注册工具... registerTools(server); const httpServer http.createServer(async (req, res) { if (req.headers.authorization ! Bearer ${config.token}) { res.writeHead(401); res.end(Unauthorized); return; } const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); await transport.handleRequest(req, res); }); httpServer.listen(config.port, () { console.log(MCP mail server listening on ${config.port}); });实际运行时streamable HTTP 需要处理 GET、POST、DELETE 三种请求并且要保存已建立的会话。生产代码不应每次请求都创建新 transport。这里只是演示连接方式正式实现要参考官方示例按会话 ID 管理 transport 实例。4.4 错误处理与日志邮件系统天然不稳定连接超时、IMAP 服务器断连、邮件格式异常。错误处理至少要区分两类客户端传参错误和下游 IMAP 异常。对 MCP 工具来说推荐抛出带明确信息的Error让客户端能看到可读错误而不是堆栈。function requireAccount(id) { const account config.accounts.find((a) a.id id); if (!account) throw new Error(账户不存在: ${id}请先调用 list_accounts); return account; }日志只记录账户 id、工具名、耗时不要记录邮件主题和正文。主题可能本身就是敏感信息。可以把日志输出到 stdout由运行环境统一采集。5. 本地验证用 MCP Inspector 和命令行客户端测试5.1 启动服务在项目目录下执行node index.js看到端口监听日志后首先测试健康面板。如果服务只是暴露 MCP 接口没有健康路由可以用curl发一个空的 MCP 请求看是否能识别端点。curl -X POST http://localhost:8787/ \ -H Authorization: Bearer dev-token \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}正常情况下返回 JSON-RPC 响应里面包含已注册工具列表。这一步确认服务、鉴权和工具注册都正常。5.2 用 MCP Inspector 查看工具输入输出SDK 自带 Inspector通常在 npm 中可以直接启动npx modelcontextprotocol/inspector node index.js当然如果连接方式是 HTTP也可以在浏览器里打开 Inspector 并填写服务地址。Inspector 能按工具 schema 自动生成表单方便测试list_messages返回结构是否符合预期。建议重点查看list_accounts是否返回账户 id。list_messages是否只返回元数据。get_message在includeHtmlfalse时是否只输出纯文本。传入不存在的账户 id 是否返回业务错误。5.3 异常分支验证只验证正常流程不够还要验证异常分支。在测试阶段建议构造以下场景场景预期行为不传 Authorization 头返回 401传错误 token返回 401list_messages的limit传 1000参数校验失败账户 id 不存在返回可读错误邮箱服务器密码错误MCP 工具内部抛出 IMAP 认证错误这些验证通过后服务才算基本可用。6. 让手机能用网络暴露、认证和安全边界6.1 局域网内访问的最小方案如果只是自己手机和电脑在同一 Wi-Fi 下使用可以不暴露公网。启动服务时监听局域网 IP手机上的 MCP 客户端配置http://192.168.x.x:8787即可。这种方式的好处是延迟低、不依赖公网域名坏处是离开局域网就无法使用。为了降低风险建议在靠外一侧再包一层反向代理并只允许特定 IP 访问。手机访问时使用代理提供的 HTTPS 地址而不是直接连接 MCP 端口。6.2 公网访问必须补上的四件事使用 HTTPS绝对不能用裸 HTTP 传邮件内容。可以用 Caddy、Nginx 或云厂商的负载均衡终止 TLS。使用强鉴权在 MCP 层之上再加一层 API Token定期轮换。限制请求体大小邮件正文可能很大直接在 Web 服务器限制 body 大小为 5MB 左右防止内存被撑爆。记录访问日志至少记录来源 IP、工具名、调用结果但不记录邮件内容。如果本来就有公网服务器可以把 MCP Server 部署在服务器上手机通过 HTTPS 地址访问。不要为了省事把服务直接绑定0.0.0.0:8787且不做鉴权。6.3 手机端 MCP 客户端的接入方式手机端接入取决于具体 MCP 客户端。一般来说接入流程可以归纳为几步获取服务地址例如https://mail.example.com/mcp。填写 API Token。客户端自动拉取工具列表。对话中输入“查看工作邮箱最近 10 封未读邮件”客户端会调用list_messages并渲染结果。如果手机端客户端不支持自定义 HTTP transport就需要在局域网内跑一个 MCP 网关或者改用支持 SSH 转发的方案。MCP 的 transport 层仍在演进接入前先确认客户端支持哪种 transport。7. 常见问题排查IMAP、只读限制与连接异常7.1 IMAP 登录失败但网页端正常现象MCP 工具调用时报登录失败代码为AUTHENTICATIONFAILED或类似提示但浏览器网页邮箱可以正常登录。原因网页端登录使用主密码IMAP 登录通常要求已开启 IMAP 服务并且使用应用专用密码。如果服务已经把主密码填进去就会失败。部分邮箱还有“允许不安全应用”开关需要关闭或开启视服务安全策略而定。检查方式先在本地用curl或openssl验证 IMAP 端口可达openssl s_client -connect imap.example.com:993 -quiet很多邮箱会返回* OK欢迎信息此时再测试登录。建议直接在邮箱设置里生成应用密码并重新配置环境变量。预防建议配置密码后不要把.env提交到 Git。用chmod 600 .env限制文件权限。7.2 出现类似 write operations are not allowed in read-only mode 的错误现象MCP 服务调用底层数据层时报错提示写操作在只读模式下不允许可能来自 IMAP 客户端、数据库连接或 ORM。这类报错不一定由 MCP 的工具逻辑抛出更多是底层依赖检测到“当前连接处于只读模式但代码尝试执行写操作”。原因有两种常见情况。一种是 IMAP 客户端未按只读模式打开文件夹代码里误用了会修改标志的 API另一种是你在同一个服务里接入了数据库或缓存事务配置为只读但某个查询或更新逻辑意外触发了写入。检查方式看服务端日志的堆栈定位到具体是哪一行触发。如果是imapflow相关检查getMailboxLock是否传了readOnly: true。如果是其它 ORM需要检查连接配置和事务注解。解决方案在只读 MCP Server 中所有邮件操作都应通过只读 IMAP 会话执行。对数据库使用只读账号连接禁止使用具有写权限的管理员账号。预防建议把工具分成只读和可写两组分别注册到不同 server。或者给每个 MCP Server 命名加上readonly避免后续开发者误加写工具。7.3 手机客户端连不上服务现象电脑本地能访问手机 MCP 客户端提示连接失败或超时。原因最常见是监听地址绑定了127.0.0.1只接受本机回环请求。第二个常见原因是手机和服务器不在同一网段或防火墙拦截了端口。第三个原因是 MCP 客户端不支持你使用的 transport需要换用 HTTP 兼容模式。检查方式# 查看服务监听地址 lsof -i :8787如果监听的是127.0.0.1需要改成0.0.0.0。然后在手机上用浏览器访问http://服务器IP:8787/看是否有响应。注意这只是一个探活操作真正调用还要带 MCP JSON-RPC 请求。解决方案监听0.0.0.0后重启服务。公网访问时配置域名和 TLS。检查防火墙是否允许对应端口进入。7.4 搜索邮件过慢或超时现象search_messages执行时间很长手机端等待太久最终超时。原因IMAP 服务端搜索性能差异很大尤其是老旧的邮件服务器在大目录下执行全文搜索时非常慢。另一个原因是一次性拉取太多邮件导致网络和解析压力过大。检查方式在工具入口打印调用耗时。搜索uid并限制数量后看你搜索整个邮箱需要多久。不同邮箱文件夹的邮件数量差异很大。解决方案把搜索范围限制在当前文件夹并增加limit上限。对超大文件夹可以先用since或before收窄时间范围再在内存中过滤关键词。搜索工具最好加上超时比如 10 秒未返回就主动断开 IMAP 连接返回错误。8. 最佳实践与后续扩展8.1 安全最佳实践邮件是高度敏感数据这条线不能放松每个邮箱使用最小权限。部分邮箱可以只开放 IMAP 权限不开 SMTP。密钥只保存在环境变量或密钥管理服务中不要进入代码仓库。API Token 至少 32 位随机字符串并支持轮换。日志清理规则访问日志保留 30 天不保留邮件正文。如果服务直接穿透到公网必须启用 TLS禁止明文传输。8.2 性能与稳定性最佳实践IMAP 连接建立成本较高。如果工具调用频繁每次建立和断开连接都会产生明显延迟。可以在内存中维护连接池每个账户保留一份长期 IMAP 连接并做空闲断开。建议参数参数建议值说明连接空闲超时5 分钟左右超过后释放连接工具默认 limit20防止一次返回太多结果单封邮件大小最大 5MB超过该大小只返回附件元信息工具执行超时10 秒超出后返回超时错误同时要给邮件列表加缓存。例如 60 秒内对同一账户同一文件夹的list_messages请求直接使用缓存避免反复点击时频繁压 IMAP 服务。8.3 扩展方向从只读到可写服务只读 server 先落地后续可以再开发一个“写操作 MCP Server”单独暴露send_email、move_message、set_seen等工具。两个 server 使用相同账户基础设施但写 server 必须走额外的确认机制。比如调用send_email前要求客户端传一个确认 token或要求模型先调用preview_email预览内容再执行发送。这种拆分可以避免把写能力放在只读服务中让安全策略更清晰。如果以后要接入更多数据源比如日历、通讯录、网盘也可以沿用同样模式一个只读 MCP Server 对多个数据源统一返回元数据写操作单独一个服务。对想从零实践的人建议先不要接太多账户用一个测试邮箱跑通全部流程再逐步加入第二个、第三个账户。把readOnly打开、把 token 鉴权配置好、把异常分支验证完这套只读 MCP Server 才能真正稳定地放在手机端使用。