SMTP 是纯文本对话:客户端说一句,服务端答一句,靠三位数字的状态码分岔。
4xx 和 5xx 这一位决定「要不要重试」,这是整个协议最关键的分岔。
这一节会起一台假 SMTP 服务器,用真的 smtplib 投一封信,把整段对话打出来。 顺便说清那个「正文里单独一行的点」的坑。
一、命令只有六条
SMTP 的命令少得出奇:
EHLO(打招呼)、MAIL FROM(谁发的)、RCPT TO(发给谁)、DATA(下面开始是正文)、
点(正文结束)、QUIT(再见)。
就这六条,撑起了整个互联网的邮件投递。
EHLO 和 HELO 的区别值得记一下:
HELO 是老写法,服务端只回一行「好」;EHLO 是扩展写法,服务端会回一串「我支持哪些扩展」。
所以 EHLO 的响应必须用「连字符续行」的格式——
250- 表示后面还有,250(带空格)表示最后一行。
这个「用连字符和空格区分续行」的做法很省,但很容易写错:
少一个空格,客户端就把后面几行当成新响应了。
这是「文本协议靠格式细节承载语义」的典型代价。
二、4 和 5 这一位决定重试
三位状态码的第一位是关键:2xx 成功、3xx 需要更多输入、4xx 临时失败、5xx 永久失败。
发信服务器靠这一位决定「进重试队列」还是「直接退信」。
4xx 是「我现在忙,你五分钟后再来」,5xx 是「这个用户不存在,别来了」。
判断错了会出两种事故:把 4xx 当 5xx,会白退掉本来能送达的信;
把 5xx 当 4xx,会无限重试一封永远送不到的信,最后被对方拉黑。
灰名单就是利用这个机制:第一次来一律回 4xx,让你过几分钟再来。
正常服务器会重试,垃圾群发器通常不会。
这个设计的巧妙之处在于「不需要识别谁是垃圾」——它只利用「垃圾发不长久」这个特征。
三、点填充:带内信令的老毛病
正文结束的标志是「单独一行的点」。所以正文里如果真有一行只有一个点,必须写成两个点。 这个规则是 SMTP 最容易出 bug 的地方, 因为它要求发送方「扫描全文并改写」——漏一处,整封信就截断了。 而且接收方还要反过来「把两个点还原成一个点」。 这是「带内信令」的典型代价,和 TELNET 的 IAC 转义是同一个毛病。 现代协议基本都改成「带外」了:先给长度,再给内容(HTTP 的 Content-Length 就是这样)。 代价是多几个字节,换来的是「内容里什么都不用转义」。
四、动手 Demo:亲手跟一台 SMTP 服务器对话
起一台假 SMTP 服务器(回 220/250/354/221),用真的 smtplib 投一封信。 把双方的每一句话都记下来。
import socketserver, threading, smtplib
log = []
class FakeSMTP(socketserver.StreamRequestHandler):
def handle(self):
self.wfile.write(b'220 fake.example.com ESMTP ready\r\n')
data_mode = False
while True:
line = self.rfile.readline()
if not line:
return
s = line.decode('utf-8', 'replace').rstrip('\r\n')
log.append(('C', s))
if data_mode:
if s == '.':
data_mode = False
self.wfile.write(b'250 2.0.0 queued as 1A2B3C\r\n')
continue
up = s.upper()
if up.startswith('EHLO'):
self.wfile.write(b'250-fake.example.com\r\n'
b'250-SIZE 35882577\r\n'
b'250-8BITMIME\r\n'
b'250 AUTH PLAIN LOGIN\r\n')
elif up.startswith('MAIL FROM'):
self.wfile.write(b'250 2.1.0 Ok\r\n')
elif up.startswith('RCPT TO'):
self.wfile.write(b'250 2.1.5 Ok\r\n')
elif up.startswith('DATA'):
data_mode = True
self.wfile.write(b'354 End data with <CR><LF>.<CR><LF>\r\n')
elif up.startswith('QUIT'):
self.wfile.write(b'221 2.0.0 Bye\r\n')
return
srv = socketserver.ThreadingTCPServer(('127.0.0.1', 0), FakeSMTP)
threading.Thread(target=srv.serve_forever, daemon=True).start()
c = smtplib.SMTP('127.0.0.1', srv.server_address[1], timeout=5)
c.ehlo('client.local')
c.sendmail('[email protected]', ['[email protected]'], msg)
c.quit()
for who, s in log:
print(who, s)
实跑输出:
--------------------------------------------------------------------
亲手跟一台 SMTP 服务器对话
--------------------------------------------------------------------
**SMTP 是一个「纯文本对话」协议**:**客户端说一句,服务端答一句,全靠三位数字的状态码分岔。**
**下面起一台假服务器,用真的客户端库投一封信。**
**完整对话**(C 是我发的,S 是服务器答的):
C ehlo client.local
S 250-fake.example.com ... 250 AUTH PLAIN LOGIN
C mail from:<[email protected]> size=86
S 250 2.1.0 Ok
C rcpt to:<[email protected]>
S 250 2.1.5 Ok
C data
S 354 End data with <CR><LF>.<CR><LF>
C From: [email protected]
C To: [email protected]
C Subject: hi
C
C hello from demo 227
C .
S 250 2.0.0 queued as 1A2B3C
C quit
S 221 2.0.0 Bye
**逐行解读**:
状态码 含义 为什么这么分
---------------------------------------------------
220 服务就绪 **连接一建就主动打招呼**
250 命令成功 **最常收到的码**
354 开始收数据 **DATA 之后才可能出现**
221 我关闭连接 **QUIT 的回执**
4xx 临时失败 **等会儿重试**
5xx 永久失败 **别重试了,退信吧**
**4 和 5 开头这一位是关键**:**它决定了发信方「要不要重试」。**
**4xx 是「我现在忙,你五分钟后再来」,5xx 是「这个用户不存在,别来了」。**
**发信服务器靠这一位决定「进重试队列」还是「直接退信」。**
**还有一处必须注意**:**正文结束的标志是「单独一行的点」。**
**所以正文里如果真有一行只有点,必须写成两个点。**
**这个「点填充」规则是 SMTP 最容易出 bug 的地方**——**它是「带内信令」的典型代价。**
整段对话十九行,六条命令。
注意 mail from: 和 rcpt to: 是小写的——这是 smtplib 原样发出去的,
而 SMTP 命令不区分大小写,所以服务器照样认。
MAIL FROM 后面还带了一个 size=86:这是 EHLO 里声明 SIZE 扩展之后才能用的参数。
服务器由此就能在收正文之前判断「这封信会不会撑爆我的硬盘」。
这是「先报尺寸、再收内容」的典型做法。
正文里的空行(C 后面什么都没有)是头与体的分隔,和 HTTP 一样。
最后那个 C . 就是「正文结束」的信号——它不是内容,是信令。
30 秒实验
- 想一下:如果 250 后面少一个空格,客户端会怎么理解?
- 想一下:正文里如果真有一行只有一个点,发送方要怎么写?
- 想一下:为什么灰名单用 4xx 而不是 5xx?
常见误区
- 以为「SMTP 需要认证才能发信」。 服务器之间投递不要认证,只有提交(587 端口)才要。
- 以为「5xx 和 4xx 只是措辞不同」。 第一位决定重试与否,判错会退掉本该送达的信。
- 以为「点填充是历史细节」。 它到现在还是必须实现,漏了会截断正文。
自测三问(点开对答案)
1. SMTP 的六条命令是什么? —— **EHLO、MAIL FROM、RCPT TO、DATA、点、QUIT。** 2. 4xx 和 5xx 的区别是什么? —— **4xx 临时失败要重试,5xx 永久失败直接退信。** 3. `250-` 和 `250 ` 有什么区别? —— **连字符表示后面还有续行,空格表示这是最后一行。**下一节看收信的两个协议:POP 和 IMAP。
- ☐ (本节完,下一节 8.4.5 POP)