简介:这份VC++电子邮件发送示例面向已具备C++基础、希望理解邮件协议与网络编程的开发者,围绕SMTP会话、MIME编码、邮件头与邮件体构造展开,并涉及MAPI、libcurl集成、SSL/TLS加密及认证失败、服务器拒收等错误处理思路。压缩包共58个文件,以18个h头文件和16个cpp源文件为核心,辅以txt说明、ico图标及dsw/dsp工程配置等,整体约141KB,可用于搭建或对照一个较完整的邮件发送工程。已有2669人学习,适合作为课程设计、毕业项目或桌面工具开发的参考。读者可从中梳理从连接SMTP服务器、发送HELO/EHLO与AUTH,到构造带附件邮件、调试网络异常的实践路径,并借鉴现成工程组织方式快速定位实现要点。
1. 点了「发送」卡三秒,然后弹一个「发送失败」
这种让人抓不到线索的体验最难受——邮件发送往往被塞进老工程里一个没人敢动的 .cpp 文件,Socket、Base64、MIME 三段逻辑搅在一起,出问题只能靠猜。这份 VC++ 电子邮件发送(Email) 源码要解决的就是它:纯 Win32/MFC 环境,不引入第三方邮件库,用 Winsock 直连 SMTP,走完 EHLO、AUTH LOGIN、MAIL FROM、RCPT TO、DATA,把一封带中文主题和附件的邮件投出去。
它适合两类人:手里有十几年前的 VC++ 老工程、又必须零依赖补上发信能力的维护者;以及想搞明白 SMTP 到底在干什么、不愿只会调现成 SDK 的开发者。代码总量不大,但边界条件密集,编码、应答码、超时、垃圾邮件判定,每一处都能让人卡上半天。下面按「跑起来、拆开看、改得动」的顺序走一遍,重点放在能直接抄的部分。
2. 工程结构与协议分层:从 Winsock 字节流到 SMTP 会话
拿到一个通信类项目,我第一件事不是看它怎么发信,而是看它怎么分层。分层乱的代码,加一个附件功能都能改出三个 bug。这套源码的目录结构大致是这样,不同版本可能略有增删,但职责划分是清晰的:
SmtpDemo/ ├─ SmtpDemo.sln ├─ SmtpDemo/ │ ├─ SmtpDemo.cpp // MFC 对话框应用入口 │ ├─ SmtpDemoDlg.cpp // UI 层:收发件人、主题、附件选择、发送按钮 │ ├─ SmtpClient.h/.cpp // 协议层:连接、认证、五条 SMTP 命令 │ ├─ MailMessage.h/.cpp // 数据层:头字段、正文、附件列表与序列化 │ ├─ MimeCodec.h/.cpp // 编解码:Base64、RFC2047 头编码、RFC822 日期 │ ├─ SocketEx.h/.cpp // 传输层:Winsock 薄封装、超时、按行读 │ └─ Log.cpp // 会话流水日志,抓包之外的第二只眼 └─ third/ // 空目录:这套实现不依赖 OpenSSL 等外部库2.1 四层职责:谁管字节、谁管协议、谁管报文
传输层SocketEx只认字节,对外暴露Connect / SendAll / ReadLine三个方法,不知道什么叫 EHLO。协议层SmtpClient只认状态,它拿到一行文本、解析出三位应答码、判断能不能走下一步,不关心正文是中文还是二进制。数据层MailMessage是纯数据结构,序列化成一段 std::string;编解码MimeCodec全是无状态纯函数,好测、好替换。
这么切的好处很实际:换邮件服务器只动配置,加 STARTTLS 只动传输层,正文从纯文本改成 HTML 只动数据层。UI 层SmtpDemoDlg只做一件事——把控件内容塞进MailParam结构体,然后调用发送。
有一条约定必须先说清楚,这是一个让人印象深刻的教训:协议文本全部用std::string(ASCII/UTF-8),不要用CString在层与层之间传递。Unicode 字符集下CString是宽字符,直接send出去会变成U\0S\0E\0R\0这种夹了零字节的字节流,服务器收到后只会回一句 500,而你在调试器里看到的字符串完全正常,很难怀疑到类型上。UI 边界做一次CT2A转换,往里一律用std::string。
2.2 应答码就是唯一事实:3xx 继续、4xx 重试、5xx 别碰
SMTP 是一个「一问一答」的文本协议,客户端每发一条命令,服务器回一行三位数字加描述。这套代码里所有流程控制都挂在这个数字上,所以先把常见码记熟,比读十遍代码有用:
| 应答码 | 含义 | 客户端该做什么 |
|---|---|---|
| 220 | 服务就绪 | 连接成功后第一条,必须读掉 |
| 250 | 命令成功 | 继续下一步 |
| 235 | 认证成功 | 进入 MAIL FROM |
| 334 | 等待认证输入 | 继续发 Base64 串 |
| 354 | 允许开始发数据 | 发报文,以\r\n.\r\n收尾 |
| 421 | 服务不可用,即将关闭 | 断开,稍后整条重连 |
| 450/451/452 | 临时失败(限流、磁盘满) | 可退避重试 |
| 500/501/502/503 | 语法错或命令顺序错 | 改代码,重试没用 |
| 535 | 认证失败 | 查账号、授权码、Base64 |
| 550/552/554 | 邮箱不可用、超限、事务失败 | 不要重试,重试只会让 IP 更难看 |
还有一个容易踩的细节:EHLO 的应答是多行的,格式是250-特征开头若干行,最后一行是250加空格。如果只读一行就当成功,后面发 AUTH 时会读到上一轮残留的半截应答,整个会话序列全部错位一格,症状是「认证明明输对了却一直 334」。所以读应答的函数必须循环读到第四位字符是空格为止。
3. 核心模块拆解:一次发送从 25 端口到 250 OK 的完整链路
这一章按真实执行顺序走:连接、握手、认证、拼报文、投递。每段代码后面说清楚参数怎么改、失败时看哪里。
3.1 连接与 EHLO:DNS 解析和 connect 都可能卡死
老代码最常见的问题不是发不出去,是卡住不动。默认的阻塞 socket 在 DNS 不通或对端不回 SYN 时,能卡到用户以为程序崩了。
// SocketEx.cpp —— 连接阶段:逐个地址尝试,失败就换下一个,别一次失败就放弃 bool SocketEx::Connect(const std::string& host, unsigned short port, int timeoutMs) { addrinfo hints{}, *res = nullptr; hints.ai_family = AF_UNSPEC; // 服务器同时有 IPv4/IPv6 记录时都能尝试 hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) != 0) return false; // 域名拼错、本机 DNS 不通,都在这行返回 for (addrinfo* p = res; p != nullptr; p = p->ai_next) { m_sock = socket(p->ai_family, p->ai_socktype, p->ai_protocol); if (m_sock == INVALID_SOCKET) continue; SetIoTimeout(timeoutMs); // 只管收发,管不了 connect,见下面的说明 if (connect(m_sock, p->ai_addr, (int)p->ai_addrlen) == 0) { freeaddrinfo(res); return true; } closesocket(m_sock); // 这个地址不通,释放后试下一个 m_sock = INVALID_SOCKET; } freeaddrinfo(res); return false; } // 收发超时:recv/send 到点返回,避免服务端不吭声时线程永久挂住 void SocketEx::SetIoTimeout(int ms) { DWORD tv = (DWORD)ms; setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); setsockopt(m_sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); }timeoutMs一般给 8000 到 15000。给 30000 以上,UI 上的转圈会让人以为死机;给 2000 以下,跨国链路稍有抖动就误判失败。hints.ai_family用AF_UNSPEC而不是写死AF_INET,是因为不少邮件服务器的 IPv6 记录排在前面,写死 IPv4 会白白多一次失败。
注意:Windows 上SO_SNDTIMEO对connect本身不生效,真正要做连接超时得把 socket 设成非阻塞,connect返回WSAEWOULDBLOCK之后用select等可写,再getsockopt(SO_ERROR)判断结果。如果源码里没做这层,遇到防火墙丢包时仍可能卡十几秒,这是这个实现的一个已知短板,二次开发时值得补上。
连接建立后第一件事是读掉220,然后用EHLO 域名握手。这里的域名随便填一个形如client.local的标识即可,部分老旧服务端要求它是合法域名格式,填localhost有时会被回 501。
// SmtpClient.cpp —— 握手:多行应答必须读到 "250 " 为止 bool SmtpClient::Helo(const std::string& domain) { if (!m_sock.SendAll("EHLO " + domain + "\r\n")) return false; int code = m_sock.ReadReplyEx(m_lastReply); // 内部循环处理 250- 续行 if (code == 250) return true; // 极老的服务器不认 EHLO,退回 HELO,但退回后就没有 AUTH 能力了 if (!m_sock.SendAll("HELO " + domain + "\r\n")) return false; return m_sock.ReadReplyEx(m_lastReply) == 250; }ReadReplyEx的返回值就是三位码,同时把完整应答文本塞进m_lastReply。判定失败时不要只看返回值,把m_lastReply打进日志,很多问题一眼就能看出来——比如503 Bad sequence of commands说明前一步没读完应答,421 Too many connections说明你连太频了。
3.2 AUTH LOGIN:认证失败的九成原因在 Base64 和口令本身
认证这一步出错率最高,但排查路径其实很短。先看代码:
// MimeCodec.cpp —— 标准 Base64,注意每 3 字节转 4 字符,末尾补 '=' std::string Base64Encode(const std::string& in) { static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; int val = 0, bits = -6; for (unsigned char c : in) { val = (val << 8) + c; bits += 8; while (bits >= 0) { out.push_back(tbl[(val >> bits) & 0x3F]); bits -= 6; } } if (bits > -6) out.push_back(tbl[((val << 8) >> (bits + 8)) & 0x3F]); while (out.size() % 4) out.push_back('='); return out; } // SmtpClient.cpp —— 三步认证:命令 -> 用户名 -> 口令 bool SmtpClient::Login(const std::string& user, const std::string& pass) { if (!m_sock.SendAll("AUTH LOGIN\r\n")) return false; if (m_sock.ReadReplyEx(m_lastReply) != 334) return false; if (!m_sock.SendAll(Base64Encode(user) + "\r\n")) return false; if (m_sock.ReadReplyEx(m_lastReply) != 334) return false; if (!m_sock.SendAll(Base64Encode(pass) + "\r\n")) return false; int code = m_sock.ReadReplyEx(m_lastReply); Trace("AUTH", "******"); // 日志里绝不打明文口令 return code == 235; }参数上只有两个要留神的地方。一是user一般要填完整邮箱地址,不是登录名,有些服务端两者都收,有些只认前者。二是pass这一栏,公网邮箱服务商普遍要求填「授权码」而不是网页登录密码,这是新手最容易卡住的地方——现象就是 Base64 编码肉眼核对完全正确、535一直返回,换成授权码立刻通。
Base64 本身也有几个隐蔽的失败点:Windows 记事本复制粘贴会在末尾带上\r\n或者全角空格,编出来的串长度对不上 4 的倍数,服务端直接判非法;口令里含+、/、=这些字符时,如果在中途做过一次 URL 解码或配置项转义,也会被吃掉。我一般在编码后打一行长度日志(不是内容),长度对不上立刻就能定位。
3.3 MIME 报文组装:中文主题、正文与附件的分段
报文组装是这个项目代码量最大的部分,也是最容易「本地测试好看、真实收件箱乱码」的部分。核心就两件事:头字段必须全是 ASCII(中文走 RFC2047 编码),正文和附件用 boundary 切开。
// MimeCodec.cpp —— 中文主题编码:单段不超过 75 字节,超了要折成多段 std::string EncodeHeader(const std::string& utf8) { std::string out; const size_t kChunk = 45; // 45 字节原文 -> 60 字节 Base64,加包裹仍 < 75 size_t i = 0; while (i < utf8.size()) { std::string part = utf8.substr(i, kChunk); // 不要把 UTF-8 多字节字符从中间切断,否则收件端解码出半个汉字 while (!part.empty() && ((unsigned char)part.back() & 0xC0) == 0x80) part.pop_back(); i += part.size(); out += "=?UTF-8?B?" + Base64Encode(part) + "?="; if (i < utf8.size()) out += "\r\n "; // 折行后续接,注意空格 } return out; }kChunk取 45 是算出来的:Base64 后是 60 字符,加上=?UTF-8?B?和?=共 12 字符,总长 72,卡在 RFC2047 规定的 75 字节红线内。取 60 会溢出,取 30 会切出过多分段,部分客户端会把多段中间的换行空格也当成正文。
报文主体用multipart/mixed组织,第一段是正文,后面每段一个附件:
// MailMessage.cpp —— 序列化:头 -> 空行 -> 各段 -> 结束 boundary std::string MailMessage::Serialize() const { const std::string rn = "\r\n"; std::string m; m += "From: " + FormatAddress(m_fromName, m_fromAddr) + rn; m += "To: " + FormatAddress(m_toName, m_toAddr) + rn; m += "Subject: " + EncodeHeader(m_subject) + rn; m += "Date: " + Rfc822Date() + rn; // 缺 Date 容易被判可疑 m += "Message-ID: <" + GenMessageId() + ">" + rn; // 缺 Message-ID 同理 m += "MIME-Version: 1.0" + rn; m += "Content-Type: multipart/mixed; boundary=\"" + m_boundary + "\"" + rn + rn; // 正文段:统一 UTF-8 + base64,避免不同网关对 8bit 的兼容差异 m += "--" + m_boundary + rn; m += "Content-Type: text/plain; charset=UTF-8" + rn; m += "Content-Transfer-Encoding: base64" + rn + rn; m += Wrap76(Base64Encode(m_body)) + rn; // 附件段:文件名同样要走 RFC2047,否则中文文件名到客户端变问号 for (size_t i = 0; i < m_attachments.size(); ++i) { const Attachment& a = m_attachments[i]; m += "--" + m_boundary + rn; m += "Content-Type: " + a.mime + "; name=\"" + EncodeHeader(a.name) + "\"" + rn; m += "Content-Disposition: attachment; filename=\"" + EncodeHeader(a.name) + "\"" + rn; m += "Content-Transfer-Encoding: base64" + rn + rn; m += Wrap76(Base64Encode(a.data)) + rn; // 每 76 字符一行 } m += "--" + m_boundary + "--" + rn; // 结束分隔符,不能省 return m; }几个参数值得单独点名。m_boundary用时间戳加随机数拼,形如----=_Part_1730_8821,绝不能是固定串,否则正文里恰好出现这串字符时会被收件端当成段落分隔,正文被截断。Wrap76是给 Base64 每 76 字符插一个\r\n,这是 RFC2045 的硬性要求,不折行的话某些老客户端会直接解析失败。附件那段的a.mime建议按扩展名映射(.pdf对应application/pdf,.png对应image/png),全填application/octet-stream也能发出去,但客户端不会给预览按钮。
3.4 DATA 阶段:点号转义和终止符
命令顺序是MAIL FROM→RCPT TO(可以有多个)→DATA→ 服务器回 354 → 发报文 → 单独一行.收尾 → 服务器回 250。看代码:
// SmtpClient.cpp —— 投递:报文里的行首点必须补成两个点 std::string DotStuff(const std::string& body) { std::string out; out.reserve(body.size() + 64); if (!body.empty() && body[0] == '.') out += '.'; for (size_t i = 0; i < body.size(); ++i) { out += body[i]; if (body[i] == '\n' && i + 1 < body.size() && body[i + 1] == '.') out += '.'; } return out; } bool SmtpClient::SendData(const std::string& raw) { if (!m_sock.SendAll("DATA\r\n")) return false; if (m_sock.ReadReplyEx(m_lastReply) != 354) return false; if (!m_sock.SendAll(DotStuff(raw))) return false; if (!m_sock.SendAll("\r\n.\r\n")) return false; // 终止行,前面要有换行 return m_sock.ReadReplyEx(m_lastReply) == 250; }点号转义这一条经常被当成玄学。SMTP 用「单独一行一个点」表示报文结束,如果正文里恰好有一行以.开头(比如代码片段、价格里的.99),服务端会认为报文提前结束,剩下的内容被当成下一条命令解析,回一堆 500,而客户端还在傻等 250。DotStuff做的就是把行首的点翻倍,收件端会自动还原。这个功能只在有正文为纯文本、且用户可能输入任意内容时才暴露出来,测试用例里很难覆盖,属于典型的「线上才炸」的坑。
MAIL FROM和RCPT TO的地址要包尖括号:MAIL FROM:<sender@yourdomain.com>。不加括号有些服务端容忍,有些直接 501。整个报文发送建议一次性SendAll或者分块发送但每块都判断返回值,而不是逐字符send,一次系统调用的开销差异在大附件时非常明显。
4. 编译与运行:从 VS 工程配置到第一封邮件落地
代码能看懂是一回事,能编译出来是另一回事。这类老工程最常见的编译卡点不在代码,在工程属性。
4.1 工程属性:链接 ws2_32、统一字符集和运行库
| 配置项 | 建议值 | 不这么设会怎样 |
|---|---|---|
| 平台工具集 | 与源码年代匹配,或升级到当前 VS 版本 | 老工程用 C++98 写法,升级后个别for循环和类型转换要改 |
| 字符集 | Unicode(与源码一致,别来回切) | 混用会在CString与std::string转换处出现乱码或截断 |
| 附加依赖项 | ws2_32.lib | 报LNK2019找不到WSAStartup、socket、send |
| 运行库 | 全工程统一 MD 或 MT | 静态库与主程序不一致时报LNK2005重复定义 |
| C++ 语言标准 | 默认即可,用到std::mutex时选 C++11 以上 | 编译报mutex不是成员 |
| 预处理宏 | _WINSOCK_DEPRECATED_NO_WARNINGS | 只是警告,但会淹没真正的错误信息 |
ws2_32.lib是最容易漏的一步,因为编译阶段完全正常,只在链接阶段报错,而且报错信息里一堆__imp_前缀,新手容易误以为是源码缺函数。WSAStartup一个进程调一次就够,放在InitInstance里或者SmtpClient的静态初始化中都行,重复调用只要配对WSACleanup也不会出错,但千万别每发一封信调一次——高频发送时这会让句柄数缓慢上涨。
4.2 第一次跑通:一份最小的调用代码
配置好工程,先别急着接业务。写一个最简的对话框按钮响应,把参数硬编码进去,跑通一次再谈其他:
// SmtpDemoDlg.cpp —— 最小可运行调用,先把链路打通 void CSmtpDemoDlg::OnBnClickedSend() { MailParam p; p.host = "smtp.yourdomain.com"; // 换成你的服务器地址 p.port = 25; // 内网中继常用 25,公网多数要求加密端口 p.user = "sender@yourdomain.com"; // 认证账号,通常要写完整地址 p.pass = "******"; // 授权码,不是网页登录密码 p.fromAddr = p.user; p.toAddr = "receiver@yourdomain.com"; p.subject = "测试主题:中文编码验证"; p.body = "这是一封用 VC++ 直连 SMTP 发出的测试邮件。\r\n第二行用来验证折行。"; p.timeoutMs = 10000; CSmtpClient client; if (client.Send(p)) AfxMessageBox(_T("发送成功")); else AfxMessageBox(CString(_T("失败:")) + CString(client.LastError().c_str())); }Send内部就是前面几节的串行调用:连接 → 读 220 → EHLO → AUTH LOGIN → MAIL FROM → RCPT TO → DATA → QUIT。把client.LastError()打到界面或日志上,比弹一个「发送失败」有价值得多。
提示:现在公网邮箱服务商绝大多数要求 TLS,明文 25 端口在公网上基本被拒或者被中间设备拦。第一次联调建议用公司内网的邮件中继,或者本地假服务器(第 6 章给了一份)先把协议流程跑通,再考虑补加密这一层。直接拿公网账号硬连,很容易在 connect 或 AUTH 阶段就失败,误以为代码有问题。
日志开关也建议在第一次跑的时候就打开。好的日志长这样:每一行是方向 + 内容,C: EHLO client.local/S: 250-mail.example.com,认证那两行把 Base64 涂成******。有了它,再对比服务器返回的原始文本,八成问题不用抓包就能定位。
5. 避坑与排查:五个真实翻过车的现场
下面五条都是这类项目里反复出现的,按「现象 → 原因 → 解决」写,遇到问题时可以直接对号入座。
5.1 连接与认证阶段的三次翻车
一、connect 成功但读不到 220,ReadReply返回 -1。现象是程序走到连接就停住,日志里只有C: 已连接,没有S:开头的行。原因通常是端口不对或者对端要求先做 TLS:很多服务端的 587 端口在明文下不会主动发 220,而是在等你发 STARTTLS,两边互相等。解决办法是先确认端口语义——25 是传统明文(现在多数只对内网开放),465 是连上就要握手的隐式加密,587 是明文连接后升级加密。这套纯 Winsock 实现只支持明文,所以要么换内网中继,要么按第 6 章的办法补 TLS,不要在明文端口上反复重试。
二、EHLO 成功,AUTH LOGIN 一直回 535。现象很统一:Base64 编码肉眼核对没错,账号密码也没记错,就是过不去。原因有三个层次,按概率排:填的是网页登录密码而不是授权码;用户名少了@域名后缀;口令字符串里混进了复制粘贴带上的\r\n或全角空格。排查方法是把 Base64 串解回明文打印一次长度,和预期逐字节比对。另一个隐蔽原因是有些服务端只接受AUTH PLAIN,AUTH LOGIN会直接回 504,那就得在 EHLO 应答里看它声明了哪几种认证方式,按声明的来。
三、EHLO 之后所有应答都错位一格,出现莫名其妙的 503。现象是明明按顺序发的命令,服务器却一直说「命令顺序错误」,日志里S:的内容总是上一条命令的回复。原因是多行应答没读完——EHLO 返回250-xxx若干行加一行250,只读一行就往下走,剩下几行留在缓冲区里,被下一次ReadReply当成新应答读走。解决就是把读取函数改成循环:读一行,看第四位字符,是-就继续读,是空格就结束。这个 bug 的表现非常像「服务器抽风」,但本质是客户端自己把缓冲区读串了。
5.2 编码与投递阶段的两次翻车
四、收件方看到的标题是=?UTF-8?B?xxxx?=原文,或者一堆问号。前一种现象的根因是头字段只做了 Base64 却没套 RFC2047 的包裹格式,或者包裹格式少了结尾的?=;后一种现象是包裹对了但charset声明和实际字节不一致——比如内容其实是 GBK,头上写的 UTF-8。还有一种更细的:主题特别长,超过 75 字节没折行,部分客户端解析到一半就放弃,把后半截当普通文本显示。解决办法就是第 3.3 节那段按 45 字节切段、每段独立包裹、段间用\r\n续接的写法,切的时候还要避开 UTF-8 多字节边界。
五、服务器回了 250(发送成功),但对方收件箱里找不到,或者躺在垃圾箱。这是最让人抓狂的一类,因为协议层完全正确。原因通常是报文特征被判定为可疑:From用的地址和认证账号不一致(这叫伪造发件人)、缺Date和Message-ID头、主题全大写带三个感叹号、正文只有一张图和一条链接、同一 IP 短时间高频投递。解决顺序是:先把From和认证账号对齐;把头字段补全;正文保证有一段可读的纯文本(别只发 HTML);发送频率做队列节流,别一个 for 循环连着发三百封。这几条做完,进箱率通常会有肉眼可见的改善,剩下的就取决于域名的发信信誉,那已经不是客户端代码能控制的了。
6. 二次开发:把发送模块接进业务系统的三个改造点
这份代码当 Demo 跑通容易,接进真实业务要动三处。第一处是把同步发送改成队列加工作线程,UI 线程里绝不能出现connect和send——一次网络抖动就是三秒白屏,用户会以为程序死了。改造方式是加一个Post(MailParam)把任务塞进std::queue,一个std::condition_variable唤醒常驻 worker,Post立刻返回。第二处是失败重试策略要按应答码分流:4xx 用指数退避重试三次,5xx 直接进失败队列并落盘,绝不重试——对 550 反复重试,只会让你的发信 IP 更快被打上标记。第三处是加密,明文字符串在公网上越来越难走通,比较省事的做法是走 Windows 自带的 SChannel 在 socket 之上做一层 TLS,不引第三方库;如果团队本来就带 OpenSSL,那就直接用它,改造成本主要在握手那一段,协议层几乎不用动。
验证环节我一般不上真实邮箱,先起一个假服务器把会话录下来。下面这段跑在本地 2525 端口,只打印客户端发来的每一条命令,不校验密码:
# fake_smtp.py —— 联调用假服务器,验证命令顺序和报文边界 import asyncio async def handle(reader, writer): writer.write(b"220 fake.local ESMTP\r\n"); await writer.drain() auth_state = 0 while True: line = await reader.readline() if not line: break cmd = line.decode("utf-8", "replace").rstrip("\r\n") print("C:", cmd) # 客户端说了什么,一眼看全 if auth_state == 1: auth_state = 2; writer.write(b"334 UGFzc3dvcmQ6\r\n") elif auth_state == 2: auth_state = 0; writer.write(b"235 Authentication successful\r\n") elif cmd.upper().startswith("EHLO"): writer.write(b"250-fake.local\r\n250-SIZE 10485760\r\n250 AUTH LOGIN\r\n") elif cmd.upper().startswith("AUTH LOGIN"): auth_state = 1; writer.write(b"334 VXNlcm5hbWU6\r\n") elif cmd.upper() == "DATA": writer.write(b"354 End data with <CR><LF>.<CR><LF>\r\n"); await writer.drain() while True: # 一直读到单独一个点,模拟真实边界 l = await reader.readline() if l == b".\r\n": break print(" body:", l.decode("utf-8", "replace").rstrip()) writer.write(b"250 OK: queued as FAKE123\r\n") elif cmd.upper() == "QUIT": writer.write(b"221 Bye\r\n"); await writer.drain(); break else: writer.write(b"250 OK\r\n") await writer.drain() writer.close() asyncio.run(asyncio.start_server(handle, "127.0.0.1", 2525).serve_forever())客户端把host改成127.0.0.1、port改成2525,跑一遍就能看到完整命令流;重点看三件事:EHLO 之后有没有正确读到多行应答、DATA 段的终止行是不是单独的.、正文里带点号的行有没有被正确翻倍。这三条过了,再换真实服务器,剩下的变量就只有网络和认证了。
血泪经验是:这类项目里最容易出问题的,往往就是最不起眼的命令行首那个点、应答码第四位的那个空格,以及头字段结尾少写的那一个回车。从那以后我每次改完发送模块,都会先用假服务器把整段会话打出来对一遍,确认字节级对齐了才敢连真实服务器。希望帮到你。
本文还有配套的精品资源,点击获取