📚 第八章 应用协议
📄 一句话概括

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 秒实验

  1. 想一下:如果 250 后面少一个空格,客户端会怎么理解?
  2. 想一下:正文里如果真有一行只有一个点,发送方要怎么写?
  3. 想一下:为什么灰名单用 4xx 而不是 5xx?

常见误区

  1. 以为「SMTP 需要认证才能发信」。 服务器之间投递不要认证,只有提交(587 端口)才要。
  2. 以为「5xx 和 4xx 只是措辞不同」。 第一位决定重试与否,判错会退掉本该送达的信。
  3. 以为「点填充是历史细节」。 它到现在还是必须实现,漏了会截断正文。
自测三问(点开对答案) 1. SMTP 的六条命令是什么? —— **EHLO、MAIL FROM、RCPT TO、DATA、点、QUIT。** 2. 4xx 和 5xx 的区别是什么? —— **4xx 临时失败要重试,5xx 永久失败直接退信。** 3. `250-` 和 `250 ` 有什么区别? —— **连字符表示后面还有续行,空格表示这是最后一行。**

下一节看收信的两个协议:POP 和 IMAP。

相关笔记