☰
VC++ SMTP邮件发送实战:MIME编码、附件与异步队列
2026/10/26 11:38:19 网站建设 项目流程

简介:这是一份面向具备C++与MFC基础的开发者、用于学习电子邮件发送原理与工程实现的VC++示例源码,围绕SMTP协议交互、MIME编码、邮件头与邮件体构造、附件处理及SSL/TLS安全连接等关键环节给出可参考的代码组织。压缩包共58个文件、约141KB,以18个头文件和16个cpp实现文件为核心,配合rc资源脚本、ico图标、dsw/dsp工程文件及txt说明文档,构成较完整的MFC工程结构,便于按模块阅读和调试。已有2669人学习下载,说明其在邮件客户端开发入门与课程实践中有一定参考价值。读者可从中了解AUTH认证、MAIL FROM/RCPT TO/DATA等SMTP命令流程,以及Base64或Quoted-Printable编码、邮件视图与发送对话框等实现思路,并据此排查网络连接、认证失败和服务器拒收等问题,适合作为课程设计、二次开发或协议学习的对照材料。

1. 一个按钮转三秒:VC++ 里实现的电子邮件发送到底在做什么

点一下对话框上的“发送”,进度条转三秒,对面收件箱里多出一封带附件的邮件。这个用 VC++ 做的 Email 电子邮件发送项目,做的事说穿了不复杂:把界面上填的几个文本框,翻译成一条完整的 SMTP 会话,然后一个字节一个字节地喂给服务器。但真正动手写的时候,麻烦全藏在细节里——中文主题变成一串问号、附件下回来打不开、明明返回 250 却进了垃圾箱。

这份工程适合两类人:一是在自己的桌面工具、内部管理软件里需要内嵌发信能力,又不想为了发一封邮件挂一个第三方 SDK 的开发者;二是想搞明白 SMTP 和 MIME 报文长什么样,拿一个能跑的最小工程当参照的人。它是 MFC 对话框壳子加纯 WinSock 传输,配套一个命令行 Demo 做验证,收发链路自己走一遍,协议层没有黑匣子,出了问题能一路跟到 recv 返回的原始字节。

2. 工程骨架与分层:从对话框到 Socket 的四层分工

拿到源码先别急着翻 SmtpClient.cpp,先看目录。这类项目的价值不在于某个函数写得多巧,而在于每一层职责切得干净不干净。切得干净,你换 UI、加队列、换 TLS 实现都只是换一层,不用把整条链路重读一遍。

2.1 目录结构与模块职责

MailSender/ ├─ MailSender.sln ├─ MailSender/ MFC 对话框工程 │ ├─ MailSenderDlg.cpp/.h 界面层:收集表单、起线程、回显日志 │ ├─ SmtpClient.cpp/.h 会话层:EHLO / AUTH / MAIL / RCPT / DATA 状态机 │ ├─ SmtpSocket.cpp/.h 传输层:WSAStartup、connect、按行拆应答 │ ├─ MimeBuilder.cpp/.h 报文层:头部字段、multipart 边界、附件拼装 │ ├─ Base64.cpp/.h 编码工具:Base64、RFC 2047 头部编码 │ ├─ Config.cpp/.h ini 配置读写 │ └─ res/ └─ ConsoleDemo/ └─ main.cpp 不带界面的最小调用示例

四层的调用方向是单向的:MailSenderDlg只认CSmtpClient::Send(MailContext),CSmtpClient只认CSmtpSocket::SendLine/ReadReply,CMimeBuilder是个纯函数式模块,输入MailContext,输出一整段 payload 字符串。这种切法最大的好处是CMimeBuilder可以脱离网络单测——把生成的报文fwrite到 .eml 文件,双击用邮件客户端打开,格式对不对立刻见分晓,不用真的发一封出去试。

MailContext是贯穿全链路的数据结构,我一般会定义成下面这样,后面所有代码都围绕它转:

// MailContext.h —— 一次发送任务的全部输入 struct MailContext { std::string host; // SMTP 服务器,例如 smtp.example.com int port = 25; // 25 明文 / 465 隐式 TLS / 587 STARTTLS bool useTls = false; // 是否走加密通道 std::string user; // 登录账号,通常就是完整邮箱地址 std::string password; // 授权码,不是登录密码 std::string from; // 发件人地址 std::string fromName; // 发件人显示名,中文需要编码 std::vector<std::string> to; // 收件人列表 std::string subject; // 主题,UTF-8 std::string bodyUtf8; // 正文,UTF-8 纯文本 std::vector<std::string> attachments; // 本地文件绝对路径 int timeoutMs = 15000; // 单条命令超时 };

字段不多,但每一项都有坑。user和from长得像但不是一回事,有些服务器要求MAIL FROM必须等于登录账号,否则直接 553 拒绝;password指的是邮箱服务商后台单独生成的授权码,拿登录密码去连,认证阶段十有八九返回 535。这两点写在代码注释里比写在文档里管用,因为改代码的人一定会看到注释。

2.2 编译环境与依赖准备

工程本身不引第三方库,只依赖 Windows 自带的东西:WinSock2 做 TCP,Base64 自己实现,TLS 部分走系统自带的安全通道组件。这样做的代价是 TLS 那层要写点平台代码,好处是拷到任何一台装了 VS 的机器上都能直接编,不用先折腾一遍编译依赖——这一步翻车的人比想象中多。

编译配置上有几个必须对齐的点:

配置项推荐值选错会怎样
平台工具集v143 或 v142老工具集缺std::string_view之类,会报一堆语法错
字符集Unicode用多字节字符集时CString与std::string互转到处要手写
C++ 标准C++17 及以上用到std::filesystem读附件大小
附加依赖项ws2_32.lib不链接就报WSAStartup无法解析的外部符号

ws2_32.lib其实不用手动加到项目属性里,在SmtpSocket.cpp顶部写一行#pragma comment(lib, "ws2_32.lib")更省事,别人 clone 下来直接能编。MFC 工程记得在项目属性里把“使用 MFC”设成“在共享 DLL 中使用 MFC”,否则换台机器跑就提示缺 DLL。

命令行 Demo 是我强烈建议保留的部分。它把 MFC 那层壳子剥掉,main里就十几行:加载配置、填MailContext、调Send、打印返回码。排查问题时先跑 Demo,能发出去说明问题在界面层,发不出去就直接看网络日志,排查范围一下缩掉一半。

2.3 配置项与一个能跑起来的最小调用

配置走 ini,Config.cpp里读一个mail.ini,字段和MailContext一一对应。不要把账号密码硬编进代码里再提交,这种事发生过不止一次,最后处理起来很难看。ini 里至少要有这几项:

[smtp] host=smtp.example.com port=465 tls=1 user=someone@example.com password=这里的授权码 timeout=15000 [sender] from=someone@example.com name=某系统通知

真正跑第一封邮件的入口代码,ConsoleDemo/main.cpp里大概长这样:

// main.cpp —— 命令行验证入口,先跑通这条链路再上界面 int main(int argc, char** argv) { CConfig cfg; if (!cfg.Load("mail.ini")) { // 配置读不到就直接退出,别带着空值往下跑 printf("mail.ini 读取失败\n"); return -1; } MailContext ctx; ctx.host = cfg.Get("smtp", "host"); ctx.port = atoi(cfg.Get("smtp", "port").c_str()); ctx.useTls = cfg.Get("smtp", "tls") == "1"; ctx.user = cfg.Get("smtp", "user"); ctx.password = cfg.Get("smtp", "password"); ctx.from = cfg.Get("sender", "from"); ctx.fromName = cfg.Get("sender", "name"); ctx.timeoutMs = atoi(cfg.Get("smtp", "timeout").c_str()); ctx.to.push_back("receiver@example.com"); // 先发给自己,方便对着收件箱看格式 ctx.subject = "测试主题:中文与附件"; ctx.bodyUtf8 = "这是一封由 VC++ 工程发出的测试邮件。"; ctx.attachments.push_back("D:\\temp\\report.pdf"); CSmtpClient client; std::string err; bool ok = client.Send(ctx, err); printf("%s %s\n", ok ? "[OK]" : "[FAIL]", ok ? "已投递" : err.c_str()); return ok ? 0 : 1; }

这段代码没什么技巧,但它把变量隔离出来了:服务器、端口、账号、收件人、附件路径全在外部,排查时可以逐个替换。比如怀疑是附件导致的 502,就把attachments清空再发一次;怀疑是中文主题,就把subject改成纯英文。改完重新编一次只要两秒,比在对话框里点半天快得多。

第一次运行前有个动作别忘:CSmtpSocket里WSAStartup只在Connect开头调一次,Disconnect里必须配对WSACleanup。漏掉不一定会立刻出问题,但反复发几十次之后某些系统上 socket 句柄会泄漏,进程越跑越重。

3. SMTP 会话层实现:连接、握手、认证与投递命令

SMTP 本质是一个“一问一答”的文本协议:客户端发一行命令,服务器回一行状态码加描述。把这个模式吃透,会话层就是个状态机,没多少玄学。真正的难点在于应答可能是多行的、编码可能是 base64 的、网络可能在任何一步断掉。

3.1 WinSock 建连与按行拆应答

建连这段代码所有项目都差不多,但参数选择上我吃过亏。域名解析用getaddrinfo而不是老的gethostbyname,前者能一次拿到 IPv4 和 IPv6 的全部候选地址,后者只有 A 记录。现在很多服务器优先走 IPv6,只用老接口解出来的是那批可能已经不通的地址。

// SmtpSocket.cpp —— 建立 TCP 连接并设置收发超时 bool CSmtpSocket::Connect(const std::string& host, int port, int timeoutMs) { WSADATA wsa = { 0 }; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { m_lastError = "WSAStartup 失败"; return false; } addrinfo hints = { 0 }; hints.ai_family = AF_UNSPEC; // IPv4 / IPv6 都接受 hints.ai_socktype = SOCK_STREAM; // TCP hints.ai_protocol = IPPROTO_TCP; addrinfo* res = nullptr; if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) != 0) { m_lastError = "域名解析失败: " + host; return false; } // 解析出来可能有多个地址,逐个尝试,任意一个连上就停 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; if (connect(m_sock, p->ai_addr, (int)p->ai_addrlen) == 0) break; closesocket(m_sock); m_sock = INVALID_SOCKET; } freeaddrinfo(res); if (m_sock == INVALID_SOCKET) { m_lastError = "TCP 连接失败,检查端口是否被拦截"; return false; } // 收发都设超时,否则网络抖动时界面直接假死,用户以为程序崩了 DWORD tv = (DWORD)timeoutMs; setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); setsockopt(m_sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); return true; }

参数里timeoutMs建议 15 秒上下。设太短,发大附件时中途 recv 超时被误判成失败;设太长,服务器不回包时界面卡着不动,体验很差。附件特别大的项目我一般把发送超时单独拆一个更大的值,接收仍然保持 15 秒。

对应答的拆行逻辑,是这个类里最值得看的一段。SMTP 的多行应答长这样:前三字符是状态码,第四字符是-表示后面还有,是空格表示这一条结束。

250-smtp.example.com 250-SIZE 52428800 250-AUTH LOGIN PLAIN 250 STARTTLS

所以不能收到一次 recv 就当成完整应答。得维护一个接收缓冲,按\r\n切行,一直读到“第四字符是空格”的那一行为止:

// SmtpSocket.cpp —— 读一条完整应答(可能由多行组成) bool CSmtpSocket::ReadReply(int& code, std::string& text) { text.clear(); code = 0; while (true) { size_t pos; // 缓冲区里已经有完整行,先拆,不急着再 recv while ((pos = m_recvBuf.find("\r\n")) != std::string::npos) { std::string line = m_recvBuf.substr(0, pos); m_recvBuf.erase(0, pos + 2); if (line.size() < 3) continue; if (code == 0) code = atoi(line.substr(0, 3).c_str()); text += (line.size() > 4 ? line.substr(4) : "") + std::string("\n"); // 第 4 个字符为空格(或整行只有状态码)代表应答结束 if (line.size() <= 3 || line[3] == ' ') return code != 0; } char buf[2048]; int n = recv(m_sock, buf, sizeof(buf), 0); if (n <= 0) { m_lastError = "连接被关闭或接收超时"; return false; } m_recvBuf.append(buf, n); } }

那几个line.size()判断看着啰嗦,但没有它们,服务器返回一条只有三个字符、不带描述的怪异应答时就会越界。这类边界我在联调某家不太规范的服务器时真碰上过,后来所有按协议格式取子串的地方都加了长度校验。

3.2 EHLO 与 AUTH LOGIN 认证

连上之后的第一个动作不是发信,是打招呼。老协议用HELO,现在一律用EHLO,因为只有EHLO的返回里才带扩展能力列表——支持哪些认证方式、最大邮件尺寸、是否支持 STARTTLS,这些信息全在那一坨多行应答里。有些实现图省事发HELO,结果AUTH命令直接返回 502 不认识,卡在这的人不少。

认证主流是AUTH LOGIN,流程三步:客户端发AUTH LOGIN,服务器回 334 要用户名,客户端发 base64 编码的用户名,服务器再回 334 要密码,客户端发 base64 密码,服务器回 235 表示成功。

// SmtpClient.cpp —— AUTH LOGIN 三步握手 bool CSmtpClient::AuthLogin() { int code = 0; std::string text; if (!SendCmd("AUTH LOGIN", code, text) || code != 334) { m_lastError = "服务器不接受 AUTH LOGIN: " + text; return false; } if (!SendCmd(Base64Encode((const unsigned char*)m_ctx.user.data(), m_ctx.user.size()), code, text) || code != 334) { m_lastError = "用户名被拒绝: " + text; return false; } if (!SendCmd(Base64Encode((const unsigned char*)m_ctx.password.data(), m_ctx.password.size()), code, text) || code != 235) { m_lastError = "认证失败,确认是否使用授权码: " + text; return false; } return true; }

SendCmd内部做两件事:给命令末尾补\r\n再send,然后调ReadReply拿回状态码。这两个动作必须绑在一起,不能发完一批再统一收,SMTP 不允许流水线,服务器会按你发的顺序逐个回,错位之后就全乱了。

密码那一步的 base64 编码要格外小心:直接对std::string的c_str()编码会在遇到内嵌\0时截断,虽然密码里极少出现这种情况,但养成传指针加长度的习惯没坏处。编码结果里不要带换行,有些实现调用了会加\r\n的通用编码函数,导致服务器把换行也算进密码,返回 535 却怎么查都查不出原因。

3.3 投递三连:MAIL FROM、RCPT TO、DATA

认证过了就开始投递,顺序固定:先声明发件人,再逐个声明收件人,最后DATA进入正文模式。三个命令的返回码含义要分清:

命令成功码常见失败码与含义
MAIL FROM:<addr>250553 发件人不被允许,通常是和登录账号不一致
RCPT TO:<addr>250 / 251550 收件人不存在,550 也可能是不允许中继
DATA354503 顺序错了,554 被判定为垃圾邮件拒绝
正文结束.250552 超出容量限制,451 临时故障可重试

注意RCPT TO的成功码有两个:250 表示收件人本地存在,251 表示会转发到别的地址。只判断 250 会把 251 当成失败,白白丢掉一个合法收件人。多个收件人时更不能一遇到失败就整体中止——某个地址不存在不该影响其他人收信,正确做法是收集失败列表,其余继续投。

// SmtpClient.cpp —— 投递命令序列,收件人失败不中断整体 bool CSmtpClient::Deliver(const std::string& payload, std::string& err) { int code = 0; std::string text; if (!SendCmd("MAIL FROM:<" + m_ctx.from + ">", code, text) || code != 250) { err = "MAIL FROM 被拒: " + text; return false; } std::vector<std::string> accepted; for (const auto& rcpt : m_ctx.to) { if (SendCmd("RCPT TO:<" + rcpt + ">", code, text) && (code == 250 || code == 251)) accepted.push_back(rcpt); else m_failedRcpt.push_back(rcpt); // 记下来,最后一并反馈给界面 } if (accepted.empty()) { err = "所有收件人都被拒绝"; return false; } if (!SendCmd("DATA", code, text) || code != 354) { err = "DATA 被拒: " + text; return false; } // payload 必须以 \r\n.\r\n 收尾,这一条单独发,不经过 SendCmd 补行尾 if (!m_sock.SendAll(payload + "\r\n.\r\n")) { err = "正文发送中断"; return false; } if (!m_sock.ReadReply(code, text) || code != 250) { err = "服务器未接收正文: " + text; return false; } SendCmd("QUIT", code, text); // 礼貌退出,失败也不影响已投递结果 return true; }

结束符那个点必须单独占一行,也就是\r\n.\r\n。如果你在 payload 末尾已经带了\r\n,那就只补.\r\n,别补成两个空行——多出来的空行会变成邮件正文的一部分,收件人看到底部莫名多一块空白。

3.4 超时、断线与重试的边界

发送失败分两类:可重试和不可重试。4xx 开头是临时故障(服务器忙、限流、灰名单),5xx 是永久失败(账号错、地址不存在、被拒收)。会话层要做的是把这两类区分开往上报,而不是一律返回 false 让界面弹个“发送失败”。

我一般给Send加一个返回值枚举,而不是 bool:Ok、AuthFailed、Rejected、TempFail、Network。界面按枚举给不同提示,任务队列按枚举决定要不要重投。重投次数控制在 3 次以内、间隔递增,超过就直接进失败列表——无脑循环重试会被服务器当成攻击行为,反而把 IP 拉进临时黑名单,这个亏我吃过。

还有一个容易被忽略的点:连接建立后服务器会先发一条220欢迎语,必须读完再发EHLO。有些实现建连后直接发命令,服务器还在吐欢迎语,两边就错位了,后面每条应答都对不上号,表现是“所有命令都返回奇怪的码”。调试时把每次recv的原始内容打到日志里,这种错位一眼就能看出来。

4. MIME 报文组装:Base64、中文编码与附件边界

会话层负责把字节送到服务器,报文层负责这些字节长什么样。邮件格式的规范细节非常多,但真正影响“能不能正常显示”的只有三件事:头部字段的编码、正文与附件的分隔、base64 的换行位置。这三件事做对了,剩下的都是锦上添花。

4.1 手写一个 Base64 编码器

不引第三方库就得自己写,好在逻辑很短。核心是把每 3 个字节拼成 24 位,再切成 4 个 6 位查表。

// Base64.cpp —— 标准 base64 编码,不插入任何换行 static const char kTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string Base64Encode(const unsigned char* data, size_t len) { std::string out; out.reserve(((len + 2) / 3) * 4); size_t i = 0; for (; i + 2 < len; i += 3) { unsigned int n = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]; out.push_back(kTable[(n >> 18) & 0x3F]); out.push_back(kTable[(n >> 12) & 0x3F]); out.push_back(kTable[(n >> 6) & 0x3F]); out.push_back(kTable[ n & 0x3F]); } // 尾部剩余 1 或 2 字节,用 '=' 补齐到 4 的倍数 size_t rest = len - i; if (rest == 1) { unsigned int n = data[i] << 16; out.push_back(kTable[(n >> 18) & 0x3F]); out.push_back(kTable[(n >> 12) & 0x3F]); out.append("=="); } else if (rest == 2) { unsigned int n = (data[i] << 16) | (data[i + 1] << 8); out.push_back(kTable[(n >> 18) & 0x3F]); out.push_back(kTable[(n >> 12) & 0x3F]); out.push_back(kTable[(n >> 6) & 0x3F]); out.push_back('='); } return out; }

函数签名用(指针, 长度)而不是std::string,是为了能直接对二进制文件缓冲区编码,附件里出现\0也不会被截断。返回的字符串里不带任何换行——这一点很关键:这个函数同时被头部编码和正文编码复用,而两者的换行规则完全不同,混在一起写就是 bug 温床。

4.2 中文主题的 RFC 2047 编码与切片

邮件头部只认 ASCII。中文主题直接写进去,收件端大概率显示成一串乱码或问号。规范做法是编码成一个“编码词”:=?UTF-8?B?<base64>?=,其中B表示 base64。

麻烦在于长度限制:一个编码词连同前后缀不能超过 75 个字符,超了就得切成多个编码词,中间用空格隔开。前缀=?UTF-8?B?是 10 个字符,后缀?=是 2 个,留给 base64 的只剩 63 字符,按 4 的倍数向下取整是 60,反推原始字节就是 45 字节。

// Base64.cpp —— 把 UTF-8 头部文本编成符合 RFC 2047 的编码词 std::string EncodeHeader(const std::string& utf8Text) { const size_t kChunk = 45; // 每个编码词最多携带 45 字节原文 std::string out; size_t pos = 0; while (pos < utf8Text.size()) { size_t take = kChunk; if (pos + take > utf8Text.size()) take = utf8Text.size() - pos; // 关键:不能从多字节 UTF-8 字符中间切断 while (take > 0 && pos + take < utf8Text.size() && ((unsigned char)utf8Text[pos + take] & 0xC0) == 0x80) --take; out += "=?UTF-8?B?"; out += Base64Encode((const unsigned char*)utf8Text.data() + pos, take); out += "?="; if (pos + take < utf8Text.size()) out += " "; pos += take; } return out; }

那段while是整个函数里最要紧的三行。UTF-8 的汉字占 3 字节,如果按 45 字节硬切,很可能把一个汉字的第 1 个字节留在上一片、后 2 个字节扔到下一片,收件端解码出来就是两个乱码方块。判断方法很简单:UTF-8 里非首字节的高两位固定是10,看到这种字节说明切在了字符中间,往前走一格。

对于发件人显示名这类可能含中文的字段,编码完之后还要套一层地址格式:=?UTF-8?B?xxx?= <someone@example.com>。尖括号不能少,少了某些客户端解析不出地址。

4.3 multipart/mixed 边界拼装

带附件的邮件必须用multipart/mixed,每个部分之间靠一个 boundary 分隔。boundary 是一串随机字符,要求是不能在正文或附件内容里出现过——所以别用----boundary----这种一看就重复的,用时间戳加随机数。

// MimeBuilder.cpp —— 组装完整报文 std::string CMimeBuilder::Build(const MailContext& ctx) { std::string m; m += "Date: " + Rfc5322Date() + "\r\n"; // 必须带时区 m += "From: " + EncodeAddress(ctx.fromName, ctx.from) + "\r\n"; m += "To: " + JoinAddress(ctx.to) + "\r\n"; m += "Subject: " + EncodeHeader(ctx.subject) + "\r\n"; m += "Message-ID: " + MakeMessageId(ctx.from) + "\r\n"; // 唯一性,利于反垃圾判定 m += "MIME-Version: 1.0\r\n"; m += "Content-Type: multipart/mixed; boundary=\"" + m_boundary + "\"\r\n"; m += "\r\n"; // 第一部分:正文 m += "--" + m_boundary + "\r\n"; m += "Content-Type: text/plain; charset=\"utf-8\"\r\n"; m += "Content-Transfer-Encoding: base64\r\n\r\n"; m += Wrap76(Base64Encode((const unsigned char*)ctx.bodyUtf8.data(), ctx.bodyUtf8.size())) + "\r\n"; // 后续部分:每个附件一段 for (const auto& path : ctx.attachments) { std::string bytes, fileName; if (!ReadFileBytes(path, bytes, fileName)) continue; // 单个附件读失败不影响整封 m += "--" + m_boundary + "\r\n"; m += "Content-Type: application/octet-stream; name=\"" + EncodeHeader(fileName) + "\"\r\n"; m += "Content-Transfer-Encoding: base64\r\n"; m += "Content-Disposition: attachment; filename=\"" + EncodeHeader(fileName) + "\"\r\n\r\n"; m += Wrap76(Base64Encode((const unsigned char*)bytes.data(), bytes.size())) + "\r\n"; } m += "--" + m_boundary + "--\r\n"; // 结束边界,后面必须跟两个短横 return m; }

Wrap76的作用是把 base64 字符串每 76 个字符插一个\r\n。MIME 规范要求 base64 行不超过 76 字符,不换行虽然很多客户端也能容错解析,但某些严格的服务器会在 DATA 阶段直接判定格式非法。这是一个典型的“不改也能跑、换了环境就崩”的地方。

ReadFileBytes里读附件要注意两点:一是必须用二进制模式打开,文本模式会把\r\n悄悄转换,二进制文件必然损坏;二是大附件不要一次全读进内存再编码,20M 的附件编码后接近 27M,加上拼接过程中的字符串拷贝,峰值内存能翻好几倍。我一般按 3 的倍数分块读,边读边编码边往 socket 写,内存占用是常数级。

4.4 正文结束符与点转义

如果正文用 base64 传输,天然不会出现行首单独一个点的情况,DATA阶段的点转义(dot-stuffing)可以不管。但如果正文是 7bit 明文(纯英文短邮件常用这种省流量的方式),就必须处理:任何以.开头的行,发送前要再加一个.,变成..,服务器收到后会还原。不处理的话,正文里恰好有一行是.,服务器就认为正文结束了,后面的内容全被当成下一条命令,报一堆 500 语法错误。

现在这套工程正文统一走 base64,省掉了这个分支,但MimeBuilder里我仍留着注释提醒:哪天为了省体积改成 7bit,这一步必须补上。代码里留个坑位说明,比事后去查 RFC 快得多。

5. 避坑与排查:五个把邮件送进垃圾箱的现场

代码能编译、能返回 250,不等于邮件能正常收到。下面这几条是调试过程中真实撞上的,每条都按“看到什么、为什么、怎么改”记下来,省得下次重走一遍。

5.1 现象:中文主题在收件端显示成一串问号

原因:头部字段直接写了 UTF-8 字节,没有做 RFC 2047 编码。有些客户端会猜编码显示,有些干脆原样输出。

解决:所有可能含非 ASCII 的头部字段(Subject、From的显示名、附件名)统一过一遍EncodeHeader。改完先存成 .eml 文件本地打开验证,别急着发出去试——本地验证一轮只要几秒,发出去等收件端刷新要几十秒,来回几次时间全耗在等上。

5.2 现象:附件收到后打不开,或者比原文件大了一圈

原因:八成是读取时用了文本模式,\r\n被转换,二进制内容被破坏;另一可能是 base64 没做 76 字符换行,服务器按行解析时把超长行截断了。

解决:fopen必须带"rb",用std::ifstream的话要加std::ios::binary。发完之后比一下源文件和收件端附件的大小,正常情况下解码后应该字节级一致。如果大小对但打不开,多半是Content-Disposition里的文件名编码有问题,客户端按错误的扩展名存了盘。

5.3 现象:25 端口连不上,换 465 之后卡在认证前不动

原因:25 端口在很多网络环境下被封是常态,换 465 是对的,但 465 是“隐式 TLS”——TCP 一连上就必须先做 TLS 握手,之后才能发EHLO。直接把明文连接改成 465 端口,服务器在等 TLS 握手,客户端在等220欢迎语,两边互相干等,直到超时。

解决:端口和加密方式必须配对。465 走隐式 TLS,连上即加密;587 走 STARTTLS,先明文发EHLO,看到返回里有STARTTLS再发STARTTLS命令,收到 220 之后升级加密,然后重新发一次EHLO。这个“升级后必须重发 EHLO”的细节漏掉,认证就会失败,因为加密前拿到的能力列表不适用于加密后。

5.4 现象:返回码全是 250,邮件却进了垃圾箱

原因:内容本身合规但缺少几个“信誉信号”。Message-ID缺失或每次都是同一个、Date没有时区、From的域名和实际发信服务器不匹配、正文只有一张图没有文字,这些都是常见的减分项。

解决:Message-ID用“时间戳 + 随机数 + 发件域”生成,保证每封唯一;Date严格按 RFC 5322 带+0800这样的时区偏移;From用真实可回信的地址,别写noreply@却又不配置;正文保证有足够的纯文本内容。这些改动不保证一定不进垃圾箱,但缺了它们,投递成功率会明显下滑。

5.5 现象:发大附件时 DATA 阶段超时或返回 552

原因:服务器的单封邮件大小限制一般在 20M 到 50M 之间,base64 编码会让体积膨胀约 33%,一个 40M 的附件编码后超过 50M,直接顶到上限。另一个原因是整个 payload 一次性send,中间没有任何进度反馈,超时设置又是按单次 recv 算的,大邮件很容易踩线。

解决:发送前先算编码后体积,超限就拆成多封或改用下载链接。SendAll里循环send并累计已发字节数,把进度回调出去,界面上能看见进度条在动,用户就不会以为卡死。同时把发送超时单独提到 60 秒以上,接收超时仍保持 15 秒。

6. 二次开发:把同步发送改成异步任务队列

原工程的Send是同步的,界面上点一下,UI 线程就跟着阻塞到发完为止。小邮件几秒还行,带附件的时候窗口直接白屏,用户点第二次就重复发送了。改造思路不复杂:把MailContext塞进一个队列,起一个工作线程消费,界面只负责入队和刷日志。

队列用std::deque加std::mutex加条件变量就够了,不必上线程池。工作线程循环取任务、调Send、按返回枚举决定重投或落失败列表,重投次数记在任务结构里而不是全局,避免不同任务互相干扰。

// MailQueue.cpp —— 单工作线程消费,失败按枚举分流 void CMailQueue::Worker() { while (!m_stop) { MailTask task; { std::unique_lock<std::mutex> lk(m_mtx); m_cv.wait(lk, [this] { return m_stop || !m_tasks.empty(); }); if (m_stop) break; task = m_tasks.front(); m_tasks.pop_front(); } std::string err; CSmtpClient client; // 每个任务独立连接,避免状态串味 SendResult r = client.Send(task.ctx, err); if (r == SendResult::TempFail && task.retry < 3) { task.retry++; std::this_thread::sleep_for(std::chrono::seconds(5 * task.retry)); std::lock_guard<std::mutex> lk(m_mtx); m_tasks.push_front(task); // 放回队首,优先重试 } else { PostMessage(m_hwndNotify, WM_MAIL_DONE, (WPARAM)r, (LPARAM)new std::string(err)); } } }

每个任务新建一个CSmtpClient是有意为之。复用连接虽然省一次握手,但 SMTP 会话是有状态的,上一封的失败状态很容易带到下一封,排查时根本分不清是谁的问题。多发场景下单封多花一两秒建连,换来的是干净的日志。

验证改造是否成功,我的习惯是造三组数据跑一遍:正常邮件、故意写错的收件人、20M 附件。三组分别应该表现为成功回调、进失败列表且不阻塞后续任务、进度条走完且不卡界面。三组都过,基本可以交付。

从那以后我每次改这类发送模块,都强制先跑一遍“三封连发”的冒烟测试再提交——单封能发出去太容易了,真正暴露问题的是连续任务下的状态残留和资源泄漏。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询