ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python实现SMTP+POP3协议栈:从状态机到可抓包的课设级邮件系统

Python实现SMTP+POP3协议栈:从状态机到可抓包的课设级邮件系统 简介这是一份面向计算机网络课程设计与Java Web开发初学者的SMTPPOP3邮件系统模拟项目聚焦协议原理实践与MVC架构落地。资源完整实现用户注册登录、信息管理、邮件收发与回复等核心功能采用ServletJSPMyBatisJavaMail技术栈基于Maven构建适配MySQL数据库开箱即用。压缩包共58个文件含12个Java源码业务逻辑与协议交互、12个Class字节码、10个XML配置MyBatis映射与Web部署、6个JSP页面前端交互及CSS/JS/Properties等辅助文件总大小仅119KB结构精简、模块清晰便于理解MVC分层与邮件协议封装细节。已有3493人学习下载提供可直接运行的完整工程包含标准Eclipse项目结构.project/.classpath/.settings等、pom.xml依赖配置及数据库初始化说明是掌握网络应用层协议编程与Web全栈开发流程的典型教学案例。1. 为什么一个“模拟邮件系统”能扛住计算机网络课设的全部压力它不是玩具是协议级黑匣子的拆解现场你手里的课设题目写着“模拟邮件系统SMTPPOP3协议”但老师没说清楚的是这根本不是让你做个带UI的发信网页而是逼你亲手把谢希仁《计算机网络》第七章、第八章里那些抽象框图——SMTP会话状态机、POP3事务模型、TCP连接生命周期、命令/响应码语义、报文边界处理——全变成可调试、可抓包、可断点的活体代码。我带过三届网络实验课90%的学生卡在“连上端口但收不到OK”“发了MAIL FROM却收到503错误”“POP3 LIST返回空列表但服务器日志显示有邮件”——这些不是bug是协议理解断层的具象化。它适合两类人一类是想真正搞懂“应用层协议怎么靠纯文本指令驱动状态迁移”的硬核学习者另一类是急需一份能过答辩、能现场演示、能被助教用Wireshark抓到完整三次握手协议交互的交付物的务实派。本文不讲RFC文档翻译只讲怎么用Python标准库少量socket原语在300行内跑通一个可交互、可验证、可扩展的双协议模拟器——从telnet localhost 25敲出第一行HELO开始。2. 从零搭起SMTP服务端状态机不是伪代码是必须手动维护的变量SMTP协议本质是基于TCP连接的有限状态机FSM。RFC 5321定义了HELLO/EHLO → MAIL FROM → RCPT TO → DATA → QUIT的严格流转任何一步跳转错误都会导致5xx响应码。很多学生用Flask或Django套壳结果发现HTTP框架根本无法控制底层TCP流控和命令解析节奏——这是方向性错误。我们必须回归socket自己管理连接、缓冲区、状态标识。2.1 用socketserver.ThreadingTCPServer构建可并发SMTP基础框架import socketserver import threading import time from email.utils import formatdate class SMTPHandler(socketserver.BaseRequestHandler): def handle(self): self.client_address self.client_address[0] self.state HELO # 初始状态等待HELO/EHLO self.mail_from None self.rcpt_to [] self.data_buffer b # 发送欢迎消息注意必须以CRLF结尾 self.request.sendall(b220 localhost SMTP Service Ready\r\n) while True: try: # 读取一行命令最大1024字节防溢出 data self.request.recv(1024).strip() if not data: break # 解析命令忽略大小写但保留原始格式用于日志 cmd_line data.decode(utf-8, errorsignore) parts cmd_line.split() if not parts: self._send_response(500 Error: Empty command) continue cmd parts[0].upper() args .join(parts[1:]) if len(parts) 1 else # 根据当前state分发处理 if cmd HELO or cmd EHLO: self._handle_helo(cmd, args) elif cmd MAIL: self._handle_mail(args) elif cmd RCPT: self._handle_rcpt(args) elif cmd DATA: self._handle_data() elif cmd QUIT: self._send_response(221 Bye) break else: self._send_response(500 Error: Command not recognized) except ConnectionResetError: break except Exception as e: self._send_response(451 Request failed) break def _send_response(self, msg): 统一响应发送强制CRLF self.request.sendall((msg \r\n).encode(utf-8)) # 启动服务端口25需root权限开发时建议用1025 if __name__ __main__: server socketserver.ThreadingTCPServer((localhost, 1025), SMTPHandler) print(SMTP server running on port 1025...) try: server.serve_forever() except KeyboardInterrupt: server.shutdown()逻辑说明ThreadingTCPServer为每个连接创建独立线程避免阻塞self.state是核心状态变量后续所有命令校验都依赖它_send_response()强制添加\r\n——这是SMTP协议生死线少一个字符Wireshark里就显示“Malformed packet”。参数说明recv(1024)SMTP单行命令理论最大长度为512字符RFC 5321但留余量防扩展decode(utf-8, errorsignore)邮件地址可能含UTF-8但协议层要求ASCII此处容错避免崩溃端口1025绕过Linux对1024以下端口的root限制测试无感知。2.2 实现MAIL FROM与RCPT TO的状态跃迁为什么503错误总在第二步出现SMTP要求MAIL FROM必须在RCPT TO之前且只能执行一次。学生常犯的错误是把MAIL FROM当成“设置发件人”却没意识到它触发了会话状态从HELO→SENDER的跃迁。一旦状态未更新后续RCPT TO就会收到503 Bad sequence of commands。def _handle_helo(self, cmd, args): if not args: self._send_response(501 Syntax: {} domain.format(cmd)) return # 简单域名校验实际需DNS查询课设可省略 if . not in args or in args: self._send_response(501 Domain syntax error) return self.domain args self.state SENDER # 关键状态变更 if cmd EHLO: # 返回扩展功能列表课设只需基础项 self._send_response(250-localhost) self._send_response(250-SIZE 10485760) # 最大10MB self._send_response(250 HELP) else: self._send_response(250 Hello {}.format(args)) def _handle_mail(self, args): if self.state ! SENDER: self._send_response(503 Bad sequence of commands) # 这就是503根源 return if not args.startswith(FROM:): self._send_response(501 Syntax: MAIL FROM:address) return addr args[5:].strip( ) if not in addr or not addr.count() 1: self._send_response(501 Invalid address) return self.mail_from addr self.state RECIPIENT # 跃迁到可接收RCPT状态 self._send_response(250 OK) def _handle_rcpt(self, args): if self.state ! RECIPIENT: self._send_response(503 Bad sequence of commands) return if not args.startswith(TO:): self._send_response(501 Syntax: RCPT TO:address) return addr args[3:].strip( ) if not in addr or not addr.count() 1: self._send_response(501 Invalid address) return self.rcpt_to.append(addr) self._send_response(250 OK)关键点self.state不是装饰是协议引擎的油门。MAIL FROM后必须置为RECIPIENT否则RCPT TO永远503同理DATA命令只允许在RECIPIENT状态后执行。这个状态机必须手写没有捷径。3. POP3服务端事务模型比SMTP更苛刻LIST/RETR/DELE必须成对出现POP3协议RFC 1939是典型的事务驱动模型客户端先USER/PASS认证再LIST获取邮件索引RETR n下载第n封DELE n标记删除最后QUIT提交。它的严苛在于所有操作必须在认证后进行且DELE只是标记QUIT才真正删除。学生常把POP3当成“文件服务器”直接RETR 1却忘了先USER结果卡在-ERR No mail for user——其实是因为根本没进入事务上下文。3.1 构建内存邮箱用字典模拟用户隔离与邮件存储import os import json from datetime import datetime # 全局邮箱存储生产环境应换Redis/SQLite MAILBOXES {} def get_mailbox(username): 获取用户邮箱不存在则初始化 if username not in MAILBOXES: MAILBOXES[username] { messages: [], # 存储邮件字典列表 deleted: set() # 标记已删除索引set去重 } return MAILBOXES[username] def add_message(username, from_addr, to_addr, subject, body): 向用户邮箱添加新邮件 mbox get_mailbox(username) msg { id: len(mbox[messages]) 1, from: from_addr, to: to_addr, subject: subject, body: body, date: datetime.now().strftime(%a, %d %b %Y %H:%M:%S %z), size: len(body.encode(utf-8)) 200 # 估算头部大小 } mbox[messages].append(msg) return msg[id]设计理由课设无需持久化内存字典足够deleted用set而非list避免重复DELE 1; DELE 1导致误删size字段必须精确因为LIST响应要返回字节数OK 1 1234表示第1封1234字节。3.2 实现POP3核心命令从USER认证到RETR内容流式传输class POP3Handler(socketserver.BaseRequestHandler): def handle(self): self.username None self.authenticated False self.state AUTH # 初始为认证态 self.request.sendall(bOK POP3 server ready\r\n) while True: try: data self.request.recv(1024).strip() if not data: break line data.decode(utf-8, errorsignore) parts line.split() if not parts: self._send_response(-ERR Empty command) continue cmd parts[0].upper() args .join(parts[1:]) if len(parts) 1 else if self.state AUTH: if cmd USER: self._handle_user(args) elif cmd PASS: self._handle_pass(args) else: self._send_response(-ERR Command only allowed in AUTH state) elif self.state TRANS: if cmd STAT: self._handle_stat() elif cmd LIST: self._handle_list(args) elif cmd RETR: self._handle_retr(args) elif cmd DELE: self._handle_dele(args) elif cmd QUIT: self._handle_quit() break else: self._send_response(-ERR Command only allowed in TRANS state) else: self._send_response(-ERR Unknown state) except Exception as e: self._send_response(-ERR Server error) break def _handle_user(self, username): if not username: self._send_response(-ERR No username given) return self.username username self._send_response(OK User accepted) def _handle_pass(self, password): # 课设简化密码固定为123456实际应校验hash if password ! 123456: self._send_response(-ERR Invalid password) return # 认证成功进入事务态 self.authenticated True self.state TRANS self._send_response(OK Password accepted) def _handle_stat(self): if not self.authenticated: self._send_response(-ERR Not authenticated) return mbox get_mailbox(self.username) # 统计未删除邮件数和总大小 count len(mbox[messages]) - len(mbox[deleted]) size sum(msg[size] for i, msg in enumerate(mbox[messages]) if i1 not in mbox[deleted]) self._send_response(OK {} {}.format(count, size)) def _handle_list(self, args): if not self.authenticated: self._send_response(-ERR Not authenticated) return mbox get_mailbox(self.username) if not args: # 无参数列出所有邮件ID和大小 response [OK {} messages.format(len(mbox[messages]))] for i, msg in enumerate(mbox[messages], 1): if i not in mbox[deleted]: response.append({} {}.format(i, msg[size])) response.append(.) self._send_response(\r\n.join(response)) else: # 有参数返回指定邮件大小 try: msg_id int(args) if msg_id 1 or msg_id len(mbox[messages]): self._send_response(-ERR No such message) return msg mbox[messages][msg_id-1] if msg_id in mbox[deleted]: self._send_response(-ERR Message marked as deleted) return self._send_response(OK {} {}.format(msg_id, msg[size])) except ValueError: self._send_response(-ERR Invalid message number)重点说明STAT命令必须返回未删除邮件数和未删除邮件总大小这是客户端计算下载进度的依据LIST无参时响应以.结尾这是POP3协议规定的消息体结束符漏掉会导致客户端一直等待。3.3 RETR实现为什么邮件内容必须按CRLF分隔且以.独占一行结束POP3规定RETR响应格式为OK 1234 octets From: ab.com To: cd.com Subject: test Date: Mon, 01 Jan 2024 00:00:00 0000 Hello world! .其中最后一行单独的.是消息体结束标志。如果邮件正文含.开头的行必须转义为..RFC 1939 3.5节。这是学生抓包时最常困惑的点——Wireshark显示RETR响应卡住其实是客户端在等那个.。def _handle_retr(self, args): if not self.authenticated: self._send_response(-ERR Not authenticated) return try: msg_id int(args) if msg_id 1: self._send_response(-ERR Invalid message number) return except ValueError: self._send_response(-ERR Invalid message number) return mbox get_mailbox(self.username) if msg_id len(mbox[messages]): self._send_response(-ERR No such message) return msg mbox[messages][msg_id-1] if msg_id in mbox[deleted]: self._send_response(-ERR Message marked as deleted) return # 构建邮件头必须包含From/To/Subject/Date headers [ From: {}.format(msg[from]), To: {}.format(msg[to]), Subject: {}.format(msg[subject]), Date: {}.format(msg[date]), , # 空行分隔头与体 ] # 处理正文将每行以.开头的转义为.. body_lines msg[body].split(\n) escaped_body [] for line in body_lines: if line.startswith(.): escaped_body.append(. line) else: escaped_body.append(line) full_msg \r\n.join(headers escaped_body) self._send_response(OK {} octets.format(len(full_msg.encode(utf-8)))) self._send_response(full_msg) self._send_response(.) # 关键结束符血泪经验.encode(utf-8)必须用否则中文乱码full_msg中所有换行必须是\r\n不能是\nself._send_response(.)必须单独调用不能拼进full_msg——这是协议强制要求。4. SMTP与POP3联动让发出去的邮件自动出现在收件箱光有独立SMTP和POP3服务还不够。课设验收时助教一定会做这件事用telnet localhost 1025发一封邮件给testlocalhost再用telnet localhost 110登录test账户执行LIST看是否出现。这就要求SMTP服务端在收到DATA后解析收件人地址将邮件存入对应POP3邮箱。这是整个系统闭环的关键一环。4.1 在SMTP DATA处理中解析收件人并投递def _handle_data(self): if self.state ! RECIPIENT: self._send_response(503 Bad sequence of commands) return if not self.rcpt_to: self._send_response(503 No recipients specified) return self._send_response(354 Start mail input; end with CRLF.CRLF) # 接收邮件数据直到遇到单独的.行 data_lines [] while True: try: line self.request.recv(1024) if not line: break # 检查是否为结束符.\r\n if line b.\r\n: break # 反转义将..还原为.发送方转义的 if line.startswith(b..): line line[1:] # 去掉一个. data_lines.append(line.decode(utf-8, errorsignore)) except Exception as e: break raw_data \n.join(data_lines) # 解析邮件头简化版找To:字段 to_addr None subject No Subject for line in raw_data.split(\n): if line.lower().startswith(to:): to_addr line[3:].strip() break elif line.lower().startswith(subject:): subject line[8:].strip() if not to_addr: self._send_response(550 No To header found) return # 提取用户名testlocalhost - test if in to_addr: username to_addr.split()[0] else: username to_addr # 存入POP3邮箱 add_message( usernameusername, from_addrself.mail_from or unknownlocalhost, to_addrto_addr, subjectsubject, bodyraw_data ) self._send_response(250 OK: Message accepted for delivery) self.state HELO # 重置状态准备下一轮关键逻辑add_message()调用后邮件就进入了MAILBOXES[username]后续POP3客户端登录该用户即可看到self.state HELO重置SMTP状态机符合协议要求to_addr提取必须健壮课设中可接受testlocalhost格式但代码要防To: testlocalhost这种带尖括号的变体。4.2 验证联动用telnet手动走一遍全流程打开三个终端终端1启动SMTP端口1025python smtp_server.py终端2启动POP3端口110python pop3_server.py终端3发邮件telnet localhost 1025 # 输入 HELO localhost MAIL FROM:senderlocal RCPT TO:testlocalhost DATA To: testlocalhost Subject: Test from SMTP Hello, this is a test email! . QUIT终端4收邮件telnet localhost 110 # 输入 USER test PASS 123456 STAT LIST RETR 1 QUIT现象验证STAT应返回OK 1 2001封约200字节LIST应显示1 200RETR 1应完整输出邮件头和正文末尾有单独的.。Wireshark里能看到完整的TCP流三次握手→SMTP交互→POP3交互→四次挥手。5. 避坑指南那些让课设答辩前夜崩溃的5个真实问题协议实现不是写完就能跑大量问题藏在细节里。以下是我在实验室帮学生debug时记录的真实踩坑案例按发生频率排序5.1 现象telnet localhost 1025连接后立即断开原因socketserver.ThreadingTCPServer默认启用allow_reuse_addressTrue但某些系统如WSL2需要显式设置否则端口处于TIME_WAIT状态导致拒绝连接。解决在server实例化后加server.allow_reuse_address True server.server_bind() server.server_activate()5.2 现象MAIL FROM返回250 OK但RCPT TO始终503 Bad sequence原因self.state变量在多线程环境下被其他连接覆盖。ThreadingTCPServer为每个连接新建Handler实例但若在__init__外定义state为类变量所有实例共享同一份。解决确保self.state在handle()方法内初始化或在__init__中赋值def __init__(self, request, client_address, server): super().__init__(request, client_address, server) self.state HELO # 必须在此处初始化5.3 现象POP3RETR 1返回内容缺最后一行或Wireshark显示“Line-based text data”红色警告原因邮件正文末尾缺少\r\n或self._send_response()内部用了\n而非\r\n。SMTP/POP3协议强制CRLFLF会被视为非法换行。解决所有响应和邮件内容换行符必须为\r\n。检查_send_response()和RETR构造的full_msg# 错误 self.request.sendall((msg \n).encode()) # ❌ # 正确 self.request.sendall((msg \r\n).encode()) # ✅5.4 现象LIST命令返回OK 0 messages但STAT显示OK 1 1234原因STAT统计的是len(messages) - len(deleted)而LIST遍历时未过滤deleted集合导致索引错位。例如messages[m1,m2]deleted{1}LIST遍历i0→m1id1被删i1→m2id2但LIST只输出2 1234客户端认为只有第2封。解决LIST遍历时必须用真实ID即i1判断是否在deleted中for i, msg in enumerate(mbox[messages], 1): # i从1开始 if i not in mbox[deleted]: response.append({} {}.format(i, msg[size]))5.5 现象用Outlook或Thunderbird连接时报Authentication failed但telnet手动USER/PASS成功原因现代邮件客户端默认使用APOP或加密认证而我们的PASS是明文。课设只需支持明文但客户端可能因安全策略拒绝。解决在客户端设置中强制关闭“加密连接”选择“不加密的密码”Plain Password或修改POP3响应声明不支持APOP# 在USER响应后加 self._send_response(OK Password required) # 显式提示需要密码6. 进阶技巧用Wireshark精准定位协议问题以及如何让助教一眼看懂你的设计课设答辩时助教最想确认两件事一是你真懂协议流程二是你能调试真实问题。光靠print()日志太弱必须用Wireshark抓包——这不是炫技是证明你站在协议栈底层思考。6.1 Wireshark过滤规则三步锁定SMTP/POP3交互流启动Wireshark选择lo回环接口因为所有通信都在localhost不抓eth0。输入显示过滤器SMTPtcp.port 1025 tcp.len 0POP3tcp.port 110 tcp.len 0提示tcp.len 0排除ACK包只看有效载荷若用默认25/110端口改数字即可。右键某条TCP流 → Follow → TCP Stream立刻看到完整会话颜色区分客户端/服务端比telnet回显更清晰。6.2 抓包解读从乱码到协议真相的翻译表Wireshark显示实际含义为什么重要220 localhost SMTP Service Ready\r\n服务端欢迎消息验证服务已启动且\r\n存在MAIL FROM:ab.com\r\n客户端发送MAIL命令确认命令格式正确无多余空格354 Start mail input...\r\n服务端准许发送数据表明MAIL/RCPT已通过进入DATA态From: ab.com\r\nTo: cd.com\r\n\r\nHello\r\n.\r\n邮件内容结束符.\\r\\n必须存在否则客户端挂起玄学技巧如果某条响应在Wireshark里显示为[TCP segment of a reassembled PDU]说明TCP分片了——立刻检查recv()缓冲区大小调大到4096。6.3 答辩材料准备一页纸讲清你的协议理解深度不要交50页报告助教只看3分钟。准备一张A4纸分三栏协议层你实现的关键点未实现但知其意的点SMTP手写状态机HELO→SENDER→RECIPIENT→TRANSACTIONDATA后解析To:投递503错误由状态校验触发ESMTP扩展STARTTLS、邮件队列持久化、DNS MX查询POP3内存邮箱隔离、DELE仅标记、QUIT才提交、RETR内容.转义APOP认证、UIDL唯一ID、TOP命令部分获取联动SMTPDATA解析收件人→调用add_message()→POP3LIST可见SMTP AUTH登录、IMAP协议对比、Webmail前端我的习惯答辩前夜我会用Wireshark抓三组包一组正常发信收信一组故意输错MAIL FROM触发501一组在DATA里发带.的正文测试转义。把截图打印出来贴在A4纸背面——助教问“怎么验证的”我就翻过去指给他看。这比讲一百句“我理解了状态机”都有力。希望帮到你。本文还有配套的精品资源点击获取
返回列表