做临时邮箱这类功能,第一个要想清楚的问题是:邮件怎么进来。

很多人第一反应是找个邮箱服务商,用 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

一次完整的收信就是:NewSessionMailRcptDataLogout。对照 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 大概一百行就能跑起来,真正要花心思的是在哪个阶段拒绝各种超时/体积上限。前者影响性能,后者决定你会不会被一封信打挂。