做临时邮箱这类功能,第一个要想清楚的问题是:邮件怎么进来。
很多人第一反应是找个邮箱服务商,用 IMAP 定时去拉。这条路在临时邮箱场景下走不通——临时邮箱要求用户随时能开一个全新地址,你不可能给每个地址都去服务商那儿注册一个真邮箱。
所以只有一条路:自己当这个域名的 MX,自己收信。
也就是说,你得跑一个 SMTP 服务端。
SMTP 服务端比想象中简单
收信方向的 SMTP 比发信方向简单得多——不需要认证、不需要投递重试、不需要处理退信。核心就是接受连接、读取信封和正文、存起来。
Go 生态里用 github.com/emersion/go-smtp,它把协议细节封装好了,你只需要实现两个接口。
Backend 负责为每条连接创建会话:
type Backend struct {
store *MailboxStore
maxEmails int
}
func (b *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
return &Session{
store: b.store,
maxEmails: b.maxEmails,
}, nil
}
Session 对应一次 SMTP 会话,方法名和协议命令一一对应:
type Session struct {
store *MailboxStore
maxEmails int
from string
to string
}
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
s.from = from // MAIL FROM:<...>
return nil
}
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
s.to = to // RCPT TO:<...>
return nil
}
func (s *Session) Data(r io.Reader) error { // DATA
body, err := io.ReadAll(r)
if err != nil {
return err
}
from, fromName, subject, textBody, htmlBody := parseEmail(body)
return s.receiveEmail(s.to, from, fromName, subject, textBody, htmlBody)
}
func (s *Session) Reset() { s.from = ""; s.to = "" } // RSET
func (s *Session) Logout() error { return nil } // QUIT
一次完整的收信就是:NewSession → Mail → Rcpt → Data → Logout。对照 telnet 手敲 SMTP 的流程看,几乎是一一对应的。
服务端参数:三个必须设的
server := smtp.NewServer(backend)
server.Domain = cfg.Domain
server.AllowInsecureAuth = true
server.MaxMessageBytes = int64(maxMessageBytes)
server.ReadTimeout = time.Duration(readTimeout) * time.Second
AllowInsecureAuth = true 看起来吓人,但收信服务端本来就不做认证——全世界任何邮件服务器都得能往你这儿投递,这是 SMTP 的本质。真正的防护不在认证,在后面几条。
MaxMessageBytes 必须设。 不设的话一封超大附件就能把你的内存吃光。这是收信服务最直接的攻击面。
ReadTimeout 必须设。 SMTP 是长连接协议,一个恶意客户端可以连上来之后什么都不发,慢慢耗着。没有超时,几百个这样的连接就能把服务拖死。
我们写错的一个地方
看回 receiveEmail:
func (s *Session) receiveEmail(to string, ...) error {
mailboxData, err := s.store.GetMailbox(context.Background(), to)
if err != nil {
return err
}
if mailboxData == nil {
return fmt.Errorf("mailbox not found or expired") // 收件人不存在
}
// ... 存邮件
}
逻辑没错:收件人不存在或已过期就拒收。
但这个检查放错了阶段。
它在 Data 里执行,也就是说整封邮件(可能几 MB)已经完整传输完了,才发现这个地址根本不存在,然后返回错误。
正确做法是在 Rcpt 阶段就查:
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
mailbox, err := s.store.GetMailbox(context.Background(), to)
if err != nil {
return err
}
if mailbox == nil {
return &smtp.SMTPError{
Code: 550,
EnhancedCode: smtp.EnhancedCode{5, 1, 1},
Message: "No such user here",
}
}
s.to = to
return nil
}
SMTP 协议本来就是为此设计的:RCPT TO 阶段返回 550,对方压根不会进入 DATA 阶段,整封信的传输直接省掉。
对临时邮箱这种场景差别很大——地址是随机生成、很快过期的,垃圾邮件发到已过期地址的比例会很高。放在 Data 阶段拒绝,等于白白收了一遍所有垃圾邮件的正文。
这个问题我们还没改,写出来算是给自己立个 flag。
其他必须知道的限制
25 端口。 绝大多数云厂商默认封禁出站 25,入站一般没问题但要确认。另外家宽基本别想了。
没有 SPF / DKIM / DMARC 校验。 上面这套照单全收,谁都能伪造发件人往里投。临时邮箱场景下影响相对小(本来就是收验证码用的,不涉及信任),但如果你的场景需要判断发件人真实性,这块得自己补。
没有反垃圾。 只要地址还没过期,垃圾邮件也照收。目前靠 maxEmails 限制单个邮箱最多存多少封来兜底,超出就淘汰旧的,属于最粗暴的策略。
单机。 SMTP 服务端和 HTTP 服务跑在同一个进程里,靠 boot 阶段启动、Close 优雅关闭。要多实例的话得考虑 MX 记录多条 + 存储共享,目前规模用不上。
总结:收信方向的 SMTP 服务端,用 go-smtp 大概一百行就能跑起来,真正要花心思的是在哪个阶段拒绝和各种超时/体积上限。前者影响性能,后者决定你会不会被一封信打挂。