做临时邮箱这类功能,绕不开一个问题:信从哪来。
最省事的是买个邮件服务商的入站 Webhook,对方收信后 POST 给你。但如果你想完全自己掌控(不想让第三方经手用户的验证码邮件,或者单纯想省钱),就得自己起一个 SMTP 服务,挂在 25 端口上,等全世界的邮件服务器来投递。
听起来吓人,其实"只收不发"的 SMTP 服务端是整套邮件协议里最简单的一块。这篇从协议讲起,用 Go 标准库写一个能跑的最小实现,再讲几个上线后才会遇到的坑。代码是为本文写的示例,不依赖任何第三方库。
一、收信这件事,协议上发生了什么
别人给 abc@mail.example.com 发信时,对方的邮件服务器会:
- 查
mail.example.com的 MX 记录,得到接收服务器的主机名; - 连这台主机的 25 端口;
- 用纯文本命令一问一答,把信交过来。
一次完整的会话大概长这样(C: 是对方,S: 是我们):
vbnet
S: 220 mail.example.com ESMTP ready
C: EHLO mx.sender.com
S: 250-mail.example.com
S: 250-8BITMIME
S: 250 SIZE 10485760
C: MAIL FROM:<noreply@sender.com>
S: 250 OK
C: RCPT TO:<abc@mail.example.com>
S: 250 OK
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: Subject: 你的验证码
C: ...正文...
C: .
S: 250 OK: queued
C: QUIT
S: 221 Bye
几个要点:
- 每行以
\r\n结尾,不是\n。 - 响应码的第一位决定语义:2xx 成功,3xx 需要继续,4xx 临时失败(对方会稍后重试),5xx 永久失败(对方会退信)。
- 多行响应用
250-表示"还有下一行",最后一行用250(空格)。 DATA之后直到单独一行的.为止都是信件原文。如果原文里某行本身以.开头,发送方会多加一个.(叫 dot-stuffing),接收方要去掉。
net/textproto 刚好把这些都处理好了:它的 ReadLine 按 \r\n 切行,DotReader 负责 . 结尾和 dot-stuffing 的还原。
二、最小可运行实现
go
package main
import (
"bufio"
"fmt"
"io"
"log"
"net"
"net/textproto"
"strings"
"time"
)
const (
hostname = "mail.example.com"
maxMsgBytes = 10 << 20 // 本文示例用 10MB
)
type envelope struct {
started bool // 收到过 MAIL FROM(发件人可以为空,退信就是这样)
from string
rcpt []string
data []byte
}
func main() {
ln, err := net.Listen("tcp", ":2525") // 本地调试用 2525,上线换 25
if err != nil {
log.Fatal(err)
}
log.Println("listening on", ln.Addr())
for {
conn, err := ln.Accept()
if err != nil {
log.Println("accept:", err)
continue
}
go handle(conn)
}
}
func handle(c net.Conn) {
defer c.Close()
tp := textproto.NewConn(c)
reply := func(code int, msg string) {
c.SetWriteDeadline(time.Now().Add(30 * time.Second))
tp.PrintfLine("%d %s", code, msg)
}
reply(220, hostname+" ESMTP ready")
var env envelope
for {
c.SetReadDeadline(time.Now().Add(5 * time.Minute))
line, err := tp.ReadLine()
if err != nil {
return
}
verb, arg, _ := strings.Cut(line, " ")
switch strings.ToUpper(verb) {
case "HELO":
reply(250, hostname)
case "EHLO":
c.SetWriteDeadline(time.Now().Add(30 * time.Second))
tp.PrintfLine("250-%s", hostname)
tp.PrintfLine("250-8BITMIME")
tp.PrintfLine("250 SIZE %d", maxMsgBytes)
case "MAIL":
addr, ok := parsePath(arg, "FROM:")
if !ok {
reply(501, "Syntax: MAIL FROM:<address>")
continue
}
env = envelope{started: true, from: addr}
reply(250, "OK")
case "RCPT":
if !env.started {
reply(503, "Need MAIL first")
continue
}
addr, ok := parsePath(arg, "TO:")
if !ok {
reply(501, "Syntax: RCPT TO:<address>")
continue
}
if !acceptRecipient(addr) {
reply(550, "No such user")
continue
}
env.rcpt = append(env.rcpt, addr)
reply(250, "OK")
case "DATA":
if len(env.rcpt) == 0 {
reply(503, "Need RCPT first")
continue
}
reply(354, "End data with <CR><LF>.<CR><LF>")
data, err := io.ReadAll(io.LimitReader(tp.DotReader(), maxMsgBytes+1))
if err != nil {
return
}
if len(data) > maxMsgBytes {
reply(552, "Message too large")
env = envelope{}
continue
}
env.data = data
deliver(env)
reply(250, "OK: queued")
env = envelope{}
case "RSET":
env = envelope{}
reply(250, "OK")
case "NOOP":
reply(250, "OK")
case "QUIT":
reply(221, "Bye")
return
default:
reply(502, "Command not implemented")
}
}
}
// parsePath 从 "FROM:<a@b.com> SIZE=123" 里取出 a@b.com
func parsePath(arg, prefix string) (string, bool) {
if !strings.HasPrefix(strings.ToUpper(arg), prefix) {
return "", false
}
rest := strings.TrimSpace(arg[len(prefix):])
start, end := strings.Index(rest, "<"), strings.Index(rest, ">")
if start != 0 || end < 0 {
return "", false
}
return strings.ToLower(rest[1:end]), true
}
func acceptRecipient(addr string) bool {
return strings.HasSuffix(addr, "@"+hostname)
}
func deliver(env envelope) {
r := bufio.NewReader(strings.NewReader(string(env.data)))
msg, err := textproto.NewReader(r).ReadMIMEHeader()
if err != nil {
log.Println("bad header:", err)
return
}
fmt.Printf("from=%s to=%v subject=%q size=%d\n",
env.from, env.rcpt, msg.Get("Subject"), len(env.data))
}
本地跑起来之后,可以用 Python 自带的 smtplib 测:
python
import smtplib
from email.mime.text import MIMEText
msg = MIMEText("验证码 482913", "plain", "utf-8")
msg["Subject"] = "测试"
msg["From"] = "a@test.org"
msg["To"] = "hello@mail.example.com"
with smtplib.SMTP("127.0.0.1", 2525) as s:
s.sendmail("a@test.org", ["hello@mail.example.com"], msg.as_string())
终端打印出 subject="=?utf-8?b?5rWL6K+V?=",说明收到了。标题是一串乱码,这就是下一节要说的第一个坑。
三、坑一:标题和发件人名字是编码过的
邮件头只允许 ASCII,所以中文标题会被编码成 RFC 2047 的"编码字":
ruby
Subject: =?utf-8?b?5rWL6K+V?=
From: =?gbk?q?=D5=C5=C8=FD?= <zhang@example.com>
格式是 =?字符集?编码?内容?=,编码有 b(base64)和 q(类似 quoted-printable)两种。标准库的 mime.WordDecoder 能解,但默认只认 utf-8 和 us-ascii,国内不少老系统发的是 gbk/gb2312,直接解会报错。需要自己挂一个字符集转换:
go
import (
"mime"
"golang.org/x/text/encoding/htmlindex"
)
var dec = &mime.WordDecoder{
CharsetReader: func(charset string, input io.Reader) (io.Reader, error) {
enc, err := htmlindex.Get(charset) // 认识 gbk、gb2312、big5 等
if err != nil {
return nil, err
}
return enc.NewDecoder().Reader(input), nil
},
}
subject, err := dec.DecodeHeader(msg.Get("Subject"))
注意用 DecodeHeader 而不是 Decode:一个头里可能混着普通文本和好几段编码字,DecodeHeader 会把它们拼起来,还会处理"两个相邻编码字之间的空白要去掉"这条规则。
四、坑二:正文不是你以为的那样
验证码邮件的正文,十封里有八封是 multipart/alternative:同一份内容给一个纯文本版、一个 HTML 版。还有的带附件,是 multipart/mixed 套 multipart/alternative。每一段又可能是 base64 或 quoted-printable 编码的。
用 net/mail + mime/multipart 递归拆:
go
import (
"bytes"
"encoding/base64"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
)
type part struct {
contentType string
body []byte
}
func walk(header map[string][]string, body io.Reader, out *[]part) error {
h := textproto.MIMEHeader(header)
mediaType, params, err := mime.ParseMediaType(h.Get("Content-Type"))
if err != nil {
mediaType = "text/plain" // 没写 Content-Type 就按纯文本
}
if strings.HasPrefix(mediaType, "multipart/") {
mr := multipart.NewReader(body, params["boundary"])
for {
p, err := mr.NextRawPart() // 注意是 Raw,见下文
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if err := walk(p.Header, p, out); err != nil {
return err
}
}
}
var r io.Reader = body
switch strings.ToLower(h.Get("Content-Transfer-Encoding")) {
case "base64":
r = base64.NewDecoder(base64.StdEncoding, body)
case "quoted-printable":
r = quotedprintable.NewReader(body)
}
b, err := io.ReadAll(io.LimitReader(r, 5<<20))
if err != nil {
return err
}
*out = append(*out, part{mediaType, b})
return nil
}
func parse(raw []byte) ([]part, error) {
m, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, err
}
var parts []part
err = walk(m.Header, m.Body, &parts)
return parts, err
}
这里有个很隐蔽的点:multipart.Reader.NextPart() 会自动解码 quoted-printable 并删掉那个头 ,而对 base64 不做处理。如果你用 NextPart 又自己按 Content-Transfer-Encoding 解一遍,QP 的段落不会重复解码(头已经被删了),看起来没问题;但行为不对称,哪天换了处理逻辑就容易出错。用 NextRawPart 拿原始数据,所有解码自己做,行为可预期。
解完之后,正文段落本身还有字符集问题(Content-Type: text/html; charset=gb2312),同样要按 params["charset"] 转一次码,思路和上一节一样。
还有一个现实问题:HTML 正文不能直接塞进你的页面 。邮件 HTML 里什么都有,外链图片(会泄露"用户打开了邮件")、内联样式(会把你的页面样式搞乱)、甚至脚本。展示时至少要放进 sandbox 的 iframe,或者先过一遍 HTML 净化。
五、坑三:25 端口和 DNS
代码写完只是第一步,想让别人真的投得进来:
1. 云服务器的 25 端口。 国内主流云厂商默认封 25 端口的出站,入站一般是通的,但也有例外,部署前在安全组里确认入站 25 放行。只收不发的服务不受出站封禁影响,这也是"只收信"比"收发都做"简单得多的原因之一。
2. MX 记录。 给收信域名加一条 MX 记录指向你的主机名,主机名再有 A 记录指向服务器 IP。直接用裸 IP 当 MX 是不行的。
3. 用子域名。 建议用 mail.example.com 或 inbox.example.com 这种子域名收信,别动主域名的 MX,否则会影响你主域名上正常的企业邮箱。
4. TLS 没做好之前,别宣告 STARTTLS。 很多发送方会在对方支持时尝试 STARTTLS,你在 EHLO 里宣告了又做不好,比不宣告更糟。本文的实现没有宣告,发送方会退回明文投递。要支持的话,用 tls.Server 包装连接后重新创建 textproto.Conn 即可,注意握手之后要丢弃之前的所有会话状态(RFC 3207 要求)。
六、坑四:它是一个裸露在公网上的 TCP 服务
上线之后,你会看到大量并不是来投信的连接:扫描器、尝试把你当开放中继的、连上就不说话的。几条基本的防护:
- 每个阶段都有超时。 上面代码里每读一行都重置读超时,防止连上不说话一直占着 goroutine。
- 大小有上限。
SIZE宣告、io.LimitReader双重保护,超了回 552。 - 只收自己域名的收件人。
acceptRecipient里不是自己域名的一律 550,这一条保证你永远不会成为开放中继。 - 限制并发连接数。 用一个带缓冲的 channel 当信号量,满了直接回
421并断开。 - 不存在的收件人尽早拒绝。 在
RCPT阶段就回 550,而不是收完整封信再丢掉,省带宽也让发送方及时退信。
这些是最低限度的。垃圾邮件过滤(SPF/DKIM 校验、黑名单)是另一个大话题,对"收验证码"这个场景来说,大多数时候不做也能用,但要清楚自己没做。
七、局限
我在福兮(forxi.cn)做临时邮箱时走的就是"自己起 SMTP 收信"这条路,上面说的几个坑基本都是实际踩出来的。但这篇的示例代码离生产还差不少:
- 没有 STARTTLS,发送方只能明文投递;
- 没有 SPF/DKIM 校验,伪造发件人的邮件照单全收;
deliver只是打印,真实场景要落库并处理失败(落库失败时应该回 451 让对方重试,而不是回 250 然后把信丢了);- 单进程单机,没考虑多实例时同一个收件地址的信落到不同机器上的问题。
但如果你只是想弄明白"一封信是怎么进到服务器里的",这两百来行已经把主干都走了一遍。协议本身比想象中友好得多,难的都在协议之外。