POP3 的模型是「把信搬到本机」:收完就删,服务器只当临时中转柜。 三个阶段:授权、事务、更新,删掉的标记要到 QUIT 才生效。
这一节会起一台假 POP3 服务,用真的 poplib 收一次信,把双方对话全打出来。 然后说清它的两个结构性缺陷。
一、三个阶段,一条命令一条应答
POP3 只有三个阶段:授权(USER 和 PASS)、事务(STAT、LIST、RETR、DELE)、更新(QUIT)。
这三个阶段的划分不是为了整齐,是为了「权限和状态分离」——
授权之前一条邮件都碰不到,更新之后才算真删。
命令只有十来条,而且严格一问一答。
所以 POP3 的客户端实现极其简单,这就是它当年能铺开的原因之一。
注意 STAT 和 LIST 的区别:一个只报「几封、共多少字节」,一个逐条报大小。
客户端先用 STAT 决定「要不要收」,再用 LIST 决定「先收哪封」。
二、DELE 只是打个标记
这是 POP3 设计里少见的「安全默认值」。 DELE 之后邮件并不会立刻消失,真正删除要等 QUIT 进入更新阶段。 所以中途断线,那些标记全部作废,信一封都不会丢。 这个设计的意义在于:「收信」这个动作可能持续几分钟,中间断线的概率不低。 如果 DELE 立刻生效,一次网络抖动就可能永久丢信。 把「意图」和「生效」分开,是分布式系统里反复出现的模式。 有意思的是:同样的问题在 IMAP 里用「标记删除 + EXPUNGE」解决, 机制更复杂,但目的完全一样。 「两阶段删除」几乎是所有邮件协议的标配。
三、两个结构性缺陷
第一个是「收完就删」:第二台设备看不到第一台收过的信。 这在单机时代无所谓,在「手机加电脑加平板」的时代就是灾难。 很多客户端默认「服务器保留副本」,就是为了绕过它——代价是服务器空间一直被占着。 第二个是「状态在本地」:「已读」「标记」「在哪个文件夹」这些概念在 POP3 里根本不存在。 它们只存在于客户端的本地数据库里,换一台机器就全没了。 所以 POP3 不是「功能少」,是「它的数据模型里没有这些字段」。 这两条加起来,决定了 POP3 的定位:「一台机器收一个邮箱」。 一旦「多设备」成为常态,就必须换协议——这就是 IMAP 存在的全部理由。
四、动手 Demo:POP3 的「下载并删除」,为什么两台电脑会打架
起一台假 POP3 服务器(两封信),用真的 poplib 走一遍全流程。 重点是「删除发生在 QUIT 那一刻」。
import socketserver, threading, poplib
box = [('1', '来自张三', 512), ('2', '来自李四', 768)]
log = []
class FakePOP(socketserver.StreamRequestHandler):
def w(self, line):
self.wfile.write((line + '\r\n').encode('utf-8'))
log.append(('S', line))
def handle(self):
self.w('+OK fake pop3 ready')
while True:
line = self.rfile.readline()
if not line:
return
s = line.decode().rstrip('\r\n')
log.append(('C', s))
up = s.upper()
if up.startswith('USER'):
self.w('+OK user accepted')
elif up.startswith('PASS'):
self.w('+OK maildrop has %d messages' % len(box))
elif up == 'STAT':
self.w('+OK %d %d' % (len(box), sum(b[2] for b in box)))
elif up == 'LIST':
self.w('+OK %d messages' % len(box))
for no, subj, n in box:
self.w('%s %d' % (no, n))
self.w('.')
elif up.startswith('RETR'):
no = s.split()[1]
hit = [b for b in box if b[0] == no][0]
self.w('+OK %d octets' % hit[2])
self.w('Subject: ' + hit[1])
self.w('')
self.w('this is the body of message ' + no)
self.w('.')
elif up.startswith('DELE'):
self.w('+OK message deleted')
elif up == 'QUIT':
self.w('+OK bye')
return
c = poplib.POP3('127.0.0.1', port, timeout=5)
c.user('zhangsan')
c.pass_('secret')
print(c.stat(), c.list())
print(c.retr(1)[1])
c.dele(1)
c.quit()
实跑输出:
--------------------------------------------------------------------
POP3 的「下载并删除」,为什么两台电脑会打架
--------------------------------------------------------------------
**POP3 是「把我邮箱里的信全搬到本机」**:**收完就删,服务器只当一个中转柜。**
**下面起一台假 POP3 服务,用真的客户端库收一次信。**
**完整对话**:
S +OK fake pop3 ready
C USER zhangsan
S +OK user accepted
C PASS secret
S +OK maildrop has 2 messages
C STAT
S +OK 2 1280
C LIST
S +OK 2 messages
S 1 512
S 2 768
S .
C RETR 1
S +OK 512 octets
S Subject: 来自张三
S
S this is the body of message 1
S .
C DELE 1
S +OK message deleted
C QUIT
S +OK bye
**C 是我发的命令,S 是服务器答的**,**一问一答严格配对,这就是 POP3 的全部节奏。**
**服务器说有 2 封、共 1280 字节。**
**`RETR 1` 收回来 3 行正文。**
**注意 `STAT` 和 `LIST` 的区别:一个报总数,一个逐条报大小。**
**`LIST` 的结尾是一个单独的点,和 SMTP 一样是「带内信令」。**
**POP3 只有三个阶段**:
阶段 命令 注意什么
-------------------------------------------------------------
**授权** USER 与 PASS **这时还不能动邮件**
**事务** STAT、LIST、RETR、DELE **收信和标记删除**
**更新** QUIT **删掉的这时才真删**
**第三行才是关键**:**DELE 只是「打个删除标记」,真正删要等 QUIT。**
**所以中途断线,那些标记全部作废**,信一封都不会丢。**这是 POP3 设计里少见的「安全默认值」。**
**但它有两个结构性缺陷**:**一是「收完就删」意味着第二台设备看不到第一台收过的信;**
**二是「状态在本地」意味着「已读」「标记」「文件夹」这些概念根本不存在。**
**很多客户端默认「服务器保留副本」,就是为了绕过第一个缺陷**——**代价是服务器空间一直被占着。**
整段对话二十二行,一问一答严格配对。
服务器说有 2 封、共 1280 字节,RETR 1 收回三行正文。
LIST 的结尾是一个单独的点,和 SMTP 里「正文以点结束」是同一个套路。
这是 1980 年代文本协议的共同习惯:需要一个「后面没有了」的信号,
于是拿一个不可能出现在数据里的短符号来当它。
再看 DELE 1 之后:服务器只回了一句 +OK message deleted,
然后 QUIT 回 +OK bye。
真正删除发生在那句 bye 之前的那一瞬间。
如果客户端在 DELE 之后直接拔网线,那封信还在。
30 秒实验
- 想一下:
STAT和LIST分别适合什么场景? - 想一下:为什么 DELE 要延迟到 QUIT 才生效?
- 想一下:POP3 里「已读」这个状态存在哪儿?
常见误区
- 以为「POP3 删了就没了」。 DELE 只打标记,QUIT 才真删。
- 以为「POP3 只是 IMAP 的老版本」。 它们是两种不同的模型,一个搬走、一个遥控。
- 以为「保留服务器副本是协议特性」。 那是客户端的设置,不是协议规定的。
自测三问(点开对答案)
1. POP3 的三个阶段是什么? —— **授权、事务、更新。** 2. DELE 之后邮件立刻消失吗? —— **不会**,要到 QUIT 进入更新阶段才真删。 3. POP3 的两个结构性缺陷是什么? —— **收完就删导致多设备不同步**;**状态在本地导致换机全丢。**下一节看 IMAP 怎么把状态搬回服务器。
- ☐ (本节完,下一节 8.4.6 IMAP)