用 Go 从零写一个只收信的 SMTP 服务:协议、坑和上线前要想清楚的事

做临时邮箱这类功能,绕不开一个问题:信从哪来。

最省事的是买个邮件服务商的入站 Webhook,对方收信后 POST 给你。但如果你想完全自己掌控(不想让第三方经手用户的验证码邮件,或者单纯想省钱),就得自己起一个 SMTP 服务,挂在 25 端口上,等全世界的邮件服务器来投递。

听起来吓人,其实"只收不发"的 SMTP 服务端是整套邮件协议里最简单的一块。这篇从协议讲起,用 Go 标准库写一个能跑的最小实现,再讲几个上线后才会遇到的坑。代码是为本文写的示例,不依赖任何第三方库。

一、收信这件事,协议上发生了什么

别人给 abc@mail.example.com 发信时,对方的邮件服务器会:

  1. 查 mail.example.com 的 MX 记录,得到接收服务器的主机名;
  2. 连这台主机的 25 端口;
  3. 用纯文本命令一问一答,把信交过来。

一次完整的会话大概长这样(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 然后把信丢了);
  • 单进程单机,没考虑多实例时同一个收件地址的信落到不同机器上的问题。

但如果你只是想弄明白"一封信是怎么进到服务器里的",这两百来行已经把主干都走了一遍。协议本身比想象中友好得多,难的都在协议之外。

相关推荐
newerp43 分钟前
GC 触发时机与调优
后端·程序员·go
探索云原生1 小时前
一个 Deployment 就能跑 vLLM,为什么还需要 KServe?
docker·ai·云原生·kubernetes·go
alsmile5 天前
Node-RED 之外,国产规则引擎的新方案:基于标准语法,Go 先行实现
后端·开源·go
茉莉玫瑰花茶6 天前
GO [ 变量 ]
go
lpfasd1236 天前
展厅中控总体架构
go·串口·中控
茉莉玫瑰花茶6 天前
GO [ 条件/循环控制 ]
go
茉莉玫瑰花茶6 天前
GO [ 数据类型 ]
go
云浪7 天前
Go Context 到底是什么?
后端·go
newerp7 天前
系统调用与 M/P 解绑
后端·程序员·go