MailFlare 附件全链路解析:从 MIME 拆包到 R2 存储,再到带鉴权的下载与 inline 图片
【免费下载链接】mailflareEmail client with custom domain based on Cloudflare项目地址: https://gitcode.com/gh_mirrors/mai/mailflare
MailFlare 是一款基于 Cloudflare 构建的自托管邮箱客户端,而附件处理正是其中最复杂的一环:一封带图邮件从收到、拆开、存到 R2 对象存储,再到用户下载或正文中内联显示图片,中间横跨解析、存储、鉴权、渲染四层链路。本文带你完整走一遍这条链路,看懂每一步在做什么、为什么这么做。
一、附件全链路的四个环节
📎 把一条附件的生命周期拆开看,就是四步:
| 环节 | 做什么 | 核心文件 |
|---|---|---|
| 收信拆包 | 原始邮件落 R2,MIME 解析出附件 | src/lib/email/inbound.ts、src/lib/email/parse.ts |
| 存储归档 | 附件逐个写入 R2,元数据入库 | src/lib/email/attachments.ts |
| 带鉴权下载 | Cookie 登录态 + 邮箱访问权限校验 | src/app/api/messages/[messageId]/attachments/[attachmentId]/route.ts |
| inline 渲染 | 正文cid:引用替换为鉴权预览地址 | src/app/(dashboard)/inbox/[messageId]/utils.ts |
二、收信:原始邮件先落 R2,再 MIME 拆包
一封外部来信到达后,worker.ts不会直接解析正文,而是先调用storeRawToR2把完整的 EML 原始字节以inbound/{时间戳}-{id}.eml为键存入 R2(见src/lib/email/inbound.ts),随后丢进队列异步处理。
processInboundMessage从 R2 取回原始邮件,交给parseRawMime(见src/lib/email/parse.ts)。它基于 postal-mime 库完成真正的 MIME 拆包:
- 提取主题、纯文本/HTML 正文、发件人等头部信息;
- 把
email.attachments逐个规整成统一的AttachmentContent结构:文件名、MIME 类型、内容字节(自动处理 base64 解码)、inline/attachmentdisposition,以及关键的contentId(即 CID,正文内嵌图片靠它定位); - 无名附件自动命名为
attachment-1、attachment-2,未知类型兜底为application/octet-stream。
原始邮件保留在 R2 还有一个用途:邮件详情页据此生成退订链接。
三、存储:附件写 R2,元数据进 D1,失败自动回滚
拆出的附件由storeMessageAttachments(见src/lib/email/attachments.ts)归档:
- 键名结构:
attachments/{messageId}/{attId}/{filename},按消息隔离,便于整封邮件清理; - 文件名消毒:路径分隔符、反斜杠、空字节统一替换为下划线,防止越权路径;
- 元数据入库:D1 的
message_attachments表只存文件名、类型、大小、disposition、CID、R2 键名等轻量字段,二进制本体始终在 R2,数据库不膨胀; - 失败回滚:批量写入中途出错时,已上传的 R2 对象会被逐一删除,避免产生"孤儿文件"。
发信走同一套存储函数,但先过一道validateAttachments硬限制:单个附件 ≤ 10 MB、一封合计 ≤ 20 MB、最多 10 个附件,超限时直接报错拒绝,而不是截断发送。
四、发信:inline 图片在这里第一次"上岗"
sendEmail(见src/lib/email/send.ts)把消息、正文、附件落库后,通过 Cloudflare Email 服务真正发出去。发信时对附件做了精细区分:
- 带
contentId且 disposition 为inline的附件(比如正文里的一张配图),会以inline + Content-ID方式发出——收件方客户端能用cid:引用在正文位置直接显示图片; - 其余一律按普通附件发出。
也就是说,CID 这套"正文内嵌附件"机制在发信端就已就位,为收信端的 inline 渲染埋下伏笔。
五、下载与预览:一个接口,三种模式 🔒
附件访问入口是GET /api/messages/{messageId}/attachments/{attachmentId}。这个接口的设计值得细看,它同时解决了安全和体验两个问题。
先鉴权,再谈文件(见src/app/api/messages/[messageId]/attachments/[attachmentId]/route.ts与src/lib/email/attachments.ts中的getAttachmentForUser):
- 未登录(无有效 Cookie)直接 401;
- 查询消息归属,若是共享邮箱,调用
getMailboxAccessLevel校验当前用户是否有读权限,没有则 404; - 附件必须同时匹配附件 ID 与消息 ID,杜绝跨消息越权取件。
再用查询参数切换模式:
?download=1→ 强制下载;?preview=1且类型可预览 → 内联展示;- 附件本身 disposition 为
inline→ 内联展示(这正是 inline 图片的通道)。
"可预览"由isPreviewableAttachmentType判定(见src/app/api/messages/[messageId]/attachments/[attachmentId]/utils.ts):PDF、音频、视频、图片(刻意排除 SVG 以防脚本注入)、纯文本、JSON、XML、CSV 都算;其余一律走下载。
响应头里还有两道安全保险:
X-Content-Type-Options: nosniff+ 严格的Content-Security-Policy(含sandbox),浏览器不敢"自作聪明"把文件当脚本执行;Cache-Control: private, max-age=3600——只允许当前登录用户缓存一小时,多用户共享部署下不会串号。
六、inline 图片:把cid:翻译成带 Cookie 的 URL 🖼️
收件时正文里的内嵌图片长得像<img src="cid:abc123@img">,浏览器根本打不开这种地址。MailFlare 在页面渲染前用resolveInlineAttachmentUrls(见src/app/(dashboard)/inbox/[messageId]/utils.ts)做一次替换:
- 从附件元数据里取出
contentId(去掉尖括号); - 把 HTML 正文中的
cid:{contentId}全部替换为/api/messages/{messageId}/attachments/{attId}?preview=1。
由于该接口认的是登录 Cookie,而<img>标签默认就携带同源 Cookie,图片于是"无感"地在正文原位置渲染出来——这就是上一节 disposition 为inline的通道发挥作用的场景。
前端则由两个组件完成最后一步体验:
src/components/message-attachment-card.tsx:渲染附件卡片(图标、文件名、大小、下载按钮);src/components/message-attachment-viewer.tsx:点击卡片弹出预览器,图片/PDF/音视频直接内嵌播放,文本类附件通过authFetch拉取内容展示,不支持的类型优雅降级为下载。
七、小结
MailFlare 的附件模块做对了三件关键的事:
- ✅二进制与元数据分离:文件本体进 R2、描述进 D1,存储成本与查询性能兼得;
- ✅全链路鉴权:下载、预览、inline 图片全部走同一个带登录态校验的 API,共享邮箱场景下按读权限逐人把关;
- ✅CID 机制闭环:发信端以 inline+Content-ID 发出,收信端把
cid:翻译成鉴权预览 URL,正文图片收发都能"原位"显示。
从worker.ts收到原始邮件那一刻,到你在浏览器里点开附件卡片,这条链路只用了四个核心模块。如果你想动手看源码,建议按本文第二节到第六节的文件路径顺序阅读,十分钟即可跑通整个心智模型。
【免费下载链接】mailflareEmail client with custom domain based on Cloudflare项目地址: https://gitcode.com/gh_mirrors/mai/mailflare
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考