Go go-message 怎样同时写入纯文本正文和附件
来源:17golang原创
时间:2026-10-30 23:45:29 434浏览 收藏
用 go-message 同时写纯文本正文与附件,先用 mail.CreateWriter 创建 multipart/mixed 邮件,再用 CreateSingleInline 写一个 text/plain 正文部件,用 CreateAttachment 写附件。每个部件写完都检查 Close,最后关闭外层邮件 Writer,不能把附件字节直接追加到正文末尾。
官方项目地址:https://github.com/emersion/go-message
假设报表服务要生成一封“周报与明细”:收件人先看到一句说明,再下载 weekly-detail.csv。关键在于让收件端区分正文、附件和各自的编码。下面的原创示例只生成邮件字节,不连接 SMTP、不发送邮件。
先保护正文、附件和完整邮件三份资产
正文应保持可阅读,附件应保持原始内容,最终交付的邮件不能只是写到一半的缓冲区。常见风险包括部件归属错误、重复编码、忽略关闭错误,以及把不可信文件路径或头部直接带入交付流程。
示例固定发件地址、收件地址、主题和附件名,不让外部输入控制邮件头与本地文件路径。正文限64KiB、附件限8MiB,这是示例自行选择的上限,不是库或邮箱服务的通用限制。实际业务仍需限制并发及总量。
| 问题 | 影响 | 处理方式 |
|---|---|---|
| 附件直接追加正文 | 不能识别独立附件 | 创建Attachment部件 |
| 先Base64再写默认编码器 | 附件被编码两次 | 写原始字节 |
| 忽略Close | 编码末尾或邮件边界不完整 | 逐层处理关闭错误 |
| 失败后仍输出缓冲区 | 半成品进入交付 | 错误时返回nil数据 |
选对容器:mixed 下放正文和附件
根容器 multipart/mixed 包含 text/plain 和 text/csv。前者的 Content-Disposition 是 inline,后者是 attachment,附件显示名称为 weekly-detail.csv。它们是兄弟部件,不是正文字符串里的两个段落。
下图分为“邮件容器”和“内容部件”:根关联两个部件,纯文本关联inline,CSV关联attachment,attachment关联文件名。关系表示静态组成,不是发送步骤。

仅一个纯文本版本时,使用外层 Writer 的 CreateSingleInline 即可。若以后需要纯文本与 HTML 两种等价正文,再考虑 CreateInline 提供的 alternative 容器。独立附件与同一正文的不同表现形式,属于不同层次。
构建示例:部件关闭后再写下一个部件
在独立 Go 模块中加入 github.com/emersion/go-message 依赖。以下代码是 maildemo 包中的构建函数,使用 errors.Join,需要 Go 1.20 或更高版本;它不是单独运行的 main 程序。
package maildemo
import (
"bytes"
"errors"
"fmt"
"io"
"time"
"unicode/utf8"
"github.com/emersion/go-message/mail"
)
// 同时保留写入与编码器收尾错误;需Go 1.20或更高版本。
func writePart(w io.WriteCloser, data []byte) error {
_, err := io.Copy(w, bytes.NewReader(data))
return errors.Join(err, w.Close())
}
// BuildMail 仅构建EML,不连接SMTP;地址与附件名为固定演示值。
func BuildMail(text string, csv []byte) ([]byte, error) {
if len(text) > 64*1024 || len(csv) > 8*1024*1024 {
return nil, fmt.Errorf("示例限制:正文64KiB,附件8MiB")
}
if !utf8.ValidString(text) {
return nil, fmt.Errorf("正文必须为有效UTF-8")
}
var h mail.Header
h.SetAddressList("From", []*mail.Address{
{Name: "报表服务", Address: "reports@example.com"},
})
h.SetAddressList("To", []*mail.Address{
{Name: "收件示例", Address: "reader@example.net"},
})
h.SetSubject("周报与明细")
h.SetDate(time.Now())
if err := h.GenerateMessageID(); err != nil {
return nil, fmt.Errorf("消息编号: %w", err)
}
// 先在私有缓冲区构建,全部收尾成功后才对外交付。
var buf bytes.Buffer
mw, err := mail.CreateWriter(&buf, h)
if err != nil { return nil, fmt.Errorf("邮件头: %w", err) }
var bh mail.InlineHeader
bh.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
body, err := mw.CreateSingleInline(bh)
if err != nil { return nil, fmt.Errorf("正文部件: %w", err) }
if err := writePart(body, []byte(text)); err != nil {
return nil, fmt.Errorf("正文写入与关闭: %w", err)
}
var ah mail.AttachmentHeader
ah.SetContentType("text/csv", map[string]string{"charset": "utf-8"})
ah.SetFilename("weekly-detail.csv")
attachment, err := mw.CreateAttachment(ah)
if err != nil { return nil, fmt.Errorf("附件部件: %w", err) }
// 原始CSV字节直接交给部件Writer,不预先Base64。
if err := writePart(attachment, csv); err != nil {
return nil, fmt.Errorf("附件写入与关闭: %w", err)
}
if err := mw.Close(); err != nil {
return nil, fmt.Errorf("邮件收尾: %w", err)
}
return buf.Bytes(), nil
}
在同包调用函数中使用:
// 在同包调用函数中构建邮件,错误时不得保存或发送结果。
raw, err := BuildMail("本周统计已完成,明细见附件。\r\n",
[]byte("item,count\r\nreviewed,12\r\n"))
if err != nil { return err }
// raw可交给独立的存储或SMTP模块;此处不执行投递。
_ = raw
writePart 的 io.Copy 负责写入,Close 负责结束当前编码器,errors.Join 保留两处错误。正文结束后才创建附件,附件结束后才关闭 mw。不能把全部部件的 defer Close 都放在同一个长函数中,导致跨过下一个部件才收尾。
外层 mw.Close 完成 multipart 最终边界。bytes.Buffer 没有文件描述符要关闭,需要结束的是上层 MIME Writer。示例失败时丢弃私有内存缓冲;如果改为文件或网络输出,调用方还必须管理底层资源及失败清理,不能认为 MIME Writer 会代为关闭文件或连接。
编码与输入边界:附件写原始字节即可
mail 包默认为文本 inline 部件选择 quoted-printable,为附件选择 base64,除非调用方已经显式指定传输编码。底层 Writer 根据声明编码,因此示例不手写boundary或Base64。charset=utf-8 是字符集说明,并不是把任意旧编码自动转成UTF-8的开关。
示例验证text的UTF-8有效性,csv则假定由可信导出模块生成。给CSV声明UTF-8前,要保证其字节确实如此。PDF或其他二进制附件需要相应MIME类型和文件名,仍应写原始字节。SetFilename设置邮件里的显示名称,不负责读取本地文件。
内存构建适合小附件,优点是完整构建前不对外暴露结果;代价是原始数据与编码邮件可能同时驻留,base64也会增加体积。8MiB原始附件不等于8MiB最终邮件。大型邮件可考虑受控流式读取或临时文件暂存,但需保留失败清理和最终交付条件。
第二张图分为“构建数据”和“交付边界”:正文输入、CSV输入、私有缓冲区属于前者;部件Writer、外层Writer、完整EML属于后者。两份输入关联部件Writer,部件Writer关联外层Writer,外层Writer使用私有缓冲区,完整EML由成功结束的缓冲区提供。这是对象归属说明,不是执行证据。

记录失败阶段,明确交付条件
日志记录任务ID、阶段、字节数和错误类别即可,避免打印正文、附件或收件人隐私信息。示例错误前缀区分邮件头、正文部件、附件部件和收尾,便于定位放弃产物的位置。Message-ID提供邮件标识,不自动完成业务任务去重。
按代码逻辑,成功返回的内容应是一封mixed邮件,解码后包含一句中文正文和weekly-detail.csv,附件原始内容中有reviewed,12。boundary与Message-ID会生成,不应要求每次整封EML逐字相同。此处为结构推导,本文制作过程中未实际运行示例。
- 正文的text/plain与UTF-8字节约定明确。
- 附件是独立attachment部件,名称与内容一致。
- 部件写入、编码关闭和外层收尾错误均被处理。
- 只在成功结束后交付结果,后续另记保存与投递状态。
拿到raw就说明邮件发出了吗?没有,raw只是格式化字节。信封地址、SMTP认证、传输失败和最终投递由另一模块管理。
零字节附件是否一定失败?不是,MIME可以表达空部件,是否允许空附件由业务合同决定。
是否需要手动设置传输编码?普通场景先用默认值。若确有特殊需求,应确保声明与写入方式一致,避免双重编码。
技术依据:官方mail API、mail Writer实现、底层Writer实现。周报场景、限额与构建函数为本文原创设计。
-
860 收藏
-
843 收藏
-
826 收藏
-
809 收藏
-
792 收藏
-
495 收藏
-
246 收藏
-
495 收藏
-
201 收藏
-
190 收藏
-
437 收藏
-
154 收藏
-
330 收藏
-
466 收藏
-
178 收藏
-
240 收藏
-
263 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习