简介:这份VC++电子邮件发送示例面向具备C++基础、希望理解桌面端邮件收发流程的开发者,围绕Windows环境下SMTP会话建立、邮件内容组织与发送结果处理展开。包内以18个头文件和16个源文件为核心,辅以工程配置、资源脚本、图标及说明文本,共58个文件,压缩包约141KB,规模精简却保留了多工程组织与邮件模块拆分的脉络。代码涉及SMTP命令交互、MIME编码、附件处理、MAPI与libcurl等可选实现路径,也触及SSL/TLS加密连接和认证失败、服务器拒绝等异常处理,便于读者对照理解邮件头与邮件体构造、服务器认证及发送失败排查。已有2669人学习下载,适合需要参考VC++邮件客户端实例、梳理SMTP协议落地方式或为课程设计寻找骨架的读者,可从中获取模块划分、接口组织、邮件队列与定时发送的实现思路以及调试方向。
1. 桌面程序里发一封带附件的邮件:这份 VC++ 源码解决的是什么
某开发者在维护一套工业上位机软件,设备报警时需要把日志和截图打包发到指定地址。工程是十多年前的 MFC 架构,不允许引入第三方库,也没有条件走网页接口,于是选择了一条最朴素的方式:用 Winsock 直接连接 SMTP 服务器,把协议字节流自己拼出来。这份 VC++ 电子邮件发送源码就是这个思路的完整实现——socket 连接与超时、EHLO 与 AUTH LOGIN 应答握手、MIME multipart 构包、Base64 编码与 76 字符折行、附件拼装,全部放在源码里,没有黑匣子。它适合三类人:还在维护 Win32/MFC 老工程的、要给设备做告警邮件推送的、想把 SMTP 从「调库黑盒」变成「看得见字节」的人。后面按工程配置、协议交互、构包编码、排查、进阶五段拆开讲,每一步都能自己编译验证。
2. 工程配置:Winsock2 的包含顺序、链接库与字符集
老工程接邮件功能,失败原因里有一半不在协议本身,而在编译期。头文件顺序、链接库、字符集这三件事,只要有一个不对,错误提示都会指向和邮件毫无关系的地方,排查起来很浪费精力。这一章先把这些准备工作处理干净,再谈功能。
2.1 包含顺序不能反,链接库不能漏
Windows SDK 里同时存在winsock.h(1.1)和winsock2.h(2.x),后者必须在前者被间接包含之前生效。windows.h内部会拉进winsock.h,所以顺序写反就会撞上一堆重复定义。这个坑在 VS2010 之后依然存在,只要工程里有老代码 include 了windows.h就躲不开。
// pch.h / stdafx.h —— 这三行的顺序不能改 #include <winsock2.h> // 必须在 windows.h 之前 #include <ws2tcpip.h> // getaddrinfo / inet_pton #include <windows.h> #include <string> #pragma comment(lib, "ws2_32.lib") // 漏了就是 LNK2019: 无法解析的外部符号 __imp__socket逻辑:winsock2.h必须在windows.h之前,先把 2.x 的声明固定住;ws2tcpip.h提供getaddrinfo,比老的gethostbyname更安全,也不用管返回结构里那些已经过时的字段。参数:pragma comment只对 MSVC 生效,换成别的编译器要在工程里手动补ws2_32.lib。
进程启动时要初始化一次 Winsock,退出时清理:
WSADATA wsa = { 0 }; int ret = WSAStartup(MAKEWORD(2, 2), &wsa); if (ret != 0) { // 常见是 10093(未初始化)或 10091(网络子系统不可用) return false; } // ... 整个生命周期内可以创建多个 socket WSACleanup(); // 放在退出路径上,别在每次发信后调用逻辑:MAKEWORD(2, 2)请求 2.2 版本,绝大多数系统都满足。参数:返回值是 Winsock 错误码不是 errno,别用strerror去解读,用WSAGetLastError()那一套。
2.2 字符集:收件端乱码的源头在发信端
工程属性里字符集分「Unicode」和「多字节」。老 MFC 工程大多还在多字节,新工程默认 Unicode。麻烦在于源文件里的中文注释、界面上的主题字符串、磁盘上读的日志,编码可能都不一样。邮件这一侧统一按 UTF-8 组织,因为头部字段和正文都以它为传输编码,中间少一次来回转换,出现乱码的地方就少一处。
// CString / std::wstring -> UTF-8 的 std::string std::string W2U8(const std::wstring& w) { if (w.empty()) return std::string(); int n = ::WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); std::string s(n, '\0'); ::WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), &s[0], n, nullptr, nullptr); return s; }逻辑:先取长度再一次性转换,避免缓冲区估算错误。参数:第一个CP_UTF8是目标代码页,写成CP_ACP就变成跟随系统区域设置,在中文系统上是 GBK,发到非中文收件端就是一堆问号。源文件本身如果没有保存成带 BOM 的 UTF-8,编译器按本地代码页解析字面量,这一步转出来的内容同样是错的,源文件编码要跟着一起检查。
2.3 把一次会话封装成 CSmtpClient
一份邮件发送源码最容易出问题的地方,是把协议命令散落在界面事件里。写成类之后,每个命令一个方法,返回值只有成功或失败,调用方只看结果,排查时打开日志就知道断在哪一步。
class CSmtpClient { public: CSmtpClient(); ~CSmtpClient(); // 连接 + 读 220 + EHLO,失败返回 false 并记录阶段 bool Connect(const std::string& host, unsigned short port, int timeoutSec); bool AuthLogin(const std::string& user, const std::string& pass); // 一次完整会话:MAIL FROM / RCPT TO / DATA / QUIT bool SendMail(const MailItem& item); void Close(); private: bool ReadLine(std::string& line); bool ReadResponse(int& code, std::string& text); bool SendLine(const std::string& line); // 自动补 \r\n bool SendRaw(const std::string& data); SOCKET m_sock; std::string m_lastError; // 失败阶段 + 服务器原始应答,排查全靠它 };逻辑:m_lastError里不存「发送失败」这种笼统的字符串,存「阶段名 + 服务器返回码 + 原文」,比如DATA: 552 message too large,这才是能定位的信息。参数:MailItem里至少要有发件人地址、发件人显示名、收件人列表、主题、正文、附件路径数组,不然后面加抄送、加附件都会改签名。
2.4 connect 在阻塞模式下能把界面卡二十秒
默认的 socket 是阻塞的,connect到一个不可达的地址,要等系统自己的连接超时,视网络环境可能超过二十秒。放到界面线程里,整个窗口变白,用户以为程序死了。常见做法是切成非阻塞加select,自己决定等多久。
bool CSmtpClient::Connect(const std::string& host, unsigned short port, int timeoutSec) { addrinfo hints = { 0 }, *res = nullptr; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM; char portStr[8]; sprintf_s(portStr, "%u", port); if (getaddrinfo(host.c_str(), portStr, &hints, &res) != 0) return false; // 域名解析失败:先查 DNS,再查 host 拼写 m_sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol); u_long nb = 1; ioctlsocket(m_sock, FIONBIO, &nb); // 非阻塞 int r = connect(m_sock, res->ai_addr, (int)res->ai_addrlen); freeaddrinfo(res); if (r == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { Close(); return false; } fd_set wfds; FD_ZERO(&wfds); FD_SET(m_sock, &wfds); timeval tv = { timeoutSec, 0 }; r = select(0, nullptr, &wfds, nullptr, &tv); if (r <= 0) { Close(); return false; } // 0 = 超时,SOCKET_ERROR = 出错 int err = 0, len = sizeof(err); getsockopt(m_sock, SOL_SOCKET, SO_ERROR, (char*)&err, &len); if (err != 0) { Close(); return false; } // 连接被拒绝、不可达都在这 nb = 0; ioctlsocket(m_sock, FIONBIO, &nb); // 切回阻塞,后面按行读更省事 DWORD tmo = (DWORD)timeoutSec * 1000; setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, (char*)&tmo, sizeof(tmo)); setsockopt(m_sock, SOL_SOCKET, SO_SNDTIMEO, (char*)&tmo, sizeof(tmo)); return true; }逻辑:非阻塞connect立刻返回WSAEWOULDBLOCK是正常现象,真正的结果要从select之后用SO_ERROR取。参数:timeoutSec建议 5 到 8 秒之间,太短会在网络抖动时误判失败,太长又失去控制意义;SO_RCVTIMEO必须设,否则服务器不发应答时recv会永久阻塞。
3. SMTP 会话状态机:220 到 354 每一步都不能少
SMTP 是一条严格按「服务器说、客户端说」轮转的文本协议,每一句都有预期返回码。把顺序搞错、把返回码判断写松,表现就是「有时候能发、有时候发不出去」,而这类问题在日志里往往什么都没留下。这一章把会话顺序和应答解析逐个写清楚。
3.1 按行读取与多行应答的判别
SMTP 应答以\r\n结尾,并且可能是多行:250-XXX\r\n250-YYY\r\n250 OK\r\n,前三行是250加连字符,最后一行是250加空格。只读一行就判断成功,会把扩展能力列表当成应答结束,后面的命令全部错位。
bool CSmtpClient::ReadLine(std::string& line) { line.clear(); char ch = 0; while (true) { int n = recv(m_sock, &ch, 1, 0); if (n == SOCKET_ERROR) return false; // 超时是 WSAETIMEDOUT(10060) if (n == 0) return false; // 对端关闭连接 if (ch == '\n') break; if (ch != '\r') line.push_back(ch); if (line.size() > 8192) return false; // 防止对端灌数据撑爆内存 } return true; } bool CSmtpClient::ReadResponse(int& code, std::string& text) { std::string line; code = 0; text.clear(); while (ReadLine(line)) { if (line.size() < 3) return false; code = atoi(line.substr(0, 3).c_str()); text += line.substr(3) + "\n"; // 第 4 个字符是 '-' 表示还有后续行,是空格或行尾表示应答结束 if (line.size() < 4 || line[3] != '-') return true; } return false; }逻辑:一次一个字节读虽然效率一般,但不会跨应答边界,调试期最省事。参数:line.size() > 8192是个保护阈值,正常应答都在两百字节以内,超了基本是对端异常或者连错了服务。等到性能敏感时再换成一次recv一个大缓冲、在缓冲里找\r\n的写法,但那时要处理好「半个行留在缓冲里」的状态,反而更容易写错。
连接成功后第一件事是读 220:
int code; std::string text; if (!ReadResponse(code, text) || code != 220) { Close(); return false; }跳过这一步直接发EHLO,服务器会回503 5.5.1 Error: send HELO/EHLO first,或者干脆不响应等着你。这是很多人拿到源码后第一处改动就踩空的地方。
3.2 EHLO 与 AUTH LOGIN 的字节流
现在的服务器几乎都要求认证。AUTH LOGIN是最广泛支持的一种:服务器回334加一段 Base64 提示,客户端发 Base64 后的用户名,再收334,发 Base64 后的密码,最后收235。
bool CSmtpClient::AuthLogin(const std::string& user, const std::string& pass) { int code; std::string text; SendLine("EHLO " + GetLocalHostName()); // 本机名,别乱编一个不存在的域名 if (!ReadResponse(code, text) || code != 250) return false; SendLine("AUTH LOGIN"); if (!ReadResponse(code, text) || code != 334) return false; SendLine(Base64Encode((const unsigned char*)user.data(), user.size())); if (!ReadResponse(code, text) || code != 334) return false; SendLine(Base64Encode((const unsigned char*)pass.data(), pass.size())); if (!ReadResponse(code, text) || code != 235) return false; // 235 = 认证成功 return true; }逻辑:每一步都要求ReadResponse成功再判断返回码,两个条件用||连起来短路,失败就立刻退出,不要继续往下发。参数:GetLocalHostName()用gethostname取本机名即可;很多服务器并不校验这个值,但写成明显的伪造域名会被反垃圾规则扣分。认证失败常见返回535 5.7.8,这时先确认账号是否需要单独开启 SMTP 权限,而不是改代码。
如果服务器只回250却没有任何AUTH扩展,说明它不支持认证,通常意味着你要用的端口不对,比如连到了只接受内网中继的 25 端口。
3.3 信封命令、DATA 与点号转义
认证过了就该发信封。MAIL FROM和RCPT TO只是信封信息,和邮件头里的From、To是两回事——前者决定投递路径,后者决定收件人看到什么。两者不一致是被判垃圾邮件的常见原因之一。
bool CSmtpClient::SendMail(const MailItem& item) { int code; std::string text; SendLine("MAIL FROM:<" + item.fromAddr + ">"); if (!ReadResponse(code, text) || code != 250) return false; for (size_t i = 0; i < item.toList.size(); ++i) { SendLine("RCPT TO:<" + item.toList[i] + ">"); // 250 是接受,251 是转发;550 通常是收件人不存在 if (!ReadResponse(code, text) || (code != 250 && code != 251)) return false; } SendLine("DATA"); if (!ReadResponse(code, text) || code != 354) return false; SendRaw(BuildMime(item)); // 整封邮件,含头部与正文 SendLine("."); // 单独一行点号,表示数据结束 if (!ReadResponse(code, text) || code != 250) return false; SendLine("QUIT"); // 不读 221 也行,但读了才能确认投递被受理 ReadResponse(code, text); return true; }逻辑:DATA阶段最容易出问题的是点号。协议规定「单独一行.」表示结束,所以正文里任何以.开头的行都必须再补一个点号变成..,服务器收到后会自动还原一个。这个转换写漏了,以点开头的日志行会被吞掉,而且是那种「看着发出去了、内容缺一段」的静默错误。
bool CSmtpClient::SendRaw(const std::string& data) { std::string out; out.reserve(data.size() + data.size() / 32); bool atLineStart = true; for (size_t i = 0; i < data.size(); ++i) { char c = data[i]; if (atLineStart && c == '.') out.push_back('.'); // 行首点号补一个 out.push_back(c); atLineStart = (c == '\n'); } return send(m_sock, out.data(), (int)out.size(), 0) == (int)out.size(); }参数:send的返回值可能小于请求长度,一次没发完要循环,正式代码里要写成 while 累加,上面为了可读性省略了。另外每行结尾必须是\r\n,只写\n的服务器有的接受、有的直接把整段数据当一行处理,构包阶段统一用\r\n最稳。
4. MIME 构包:中文主题、正文与附件的拼法
协议层通了之后,剩下的全是「怎么让收件端正确显示」。一封邮件在服务器眼里就是一串文本,头部声明什么编码、正文怎么分段、附件边界怎么标,全由这段拼接代码决定。写得不对,邮件能发出去,但显示出来是乱码或者少东西。
4.1 头部字段与 RFC 2047 编码
头部字段原则上是 ASCII。中文主题、中文发件人名必须按 RFC 2047 转成=?UTF-8?B?<base64>?=的形式,纯 ASCII 的保持原样,否则有的客户端会按原字节解析、有的会拒绝。
std::string EncodeHeader(const std::string& utf8) { for (size_t i = 0; i < utf8.size(); ++i) if ((unsigned char)utf8[i] > 0x7F) { return "=?UTF-8?B?" + Base64Encode((const unsigned char*)utf8.data(), utf8.size()) + "?="; } return utf8; // 全 ASCII 不加编码标记 }逻辑:只在含非 ASCII 字节时才编码,避免把英文主题也包一层,某些老客户端对多余的编码标记处理不好。参数:UTF-8是字符集名,用GB2312也能写,但跨语言环境下会出错,统一 UTF-8 更省心。
Date字段也要补,缺了它,部分服务器会直接降权:
std::string Rfc2822Date() { SYSTEMTIME st; ::GetLocalTime(&st); static const char* wd[] = { "Sun","Mon","Tue","Wed","Thu","Fri","Sat" }; static const char* mon[] = { "Jan","Feb","Mar","Apr","May","Jun", "Jul","Aug","Sep","Oct","Nov","Dec" }; char buf[64]; sprintf_s(buf, "%s, %02d %s %04d %02d:%02d:%02d +0800", wd[st.wDayOfWeek], st.wDay, mon[st.wMonth - 1], st.wYear, st.wHour, st.wMinute, st.wSecond); return buf; }逻辑:格式固定为「星期, 日 月 年 时:分:秒 时区」。参数:+0800这里是写死的,如果程序要跑在不同时区的机器上,得换成按GetTimeZoneInformation算出来的偏移,否则收件端看到的时间会偏几个小时。这个细节看着小,排查时却很容易被忽略。
4.2 multipart/mixed 与附件边界
一封带附件的邮件用multipart/mixed包起来,每个部分之间用--boundary分隔,最后一个分隔行后面再加两个连字符表示结束。边界串必须足够随机,且不能出现在正文或附件内容里。
std::string BuildMime(const MailItem& item) { // 边界串带上时间戳,避免多封邮件撞车;只用字母数字和 -=_ 等安全字符 std::string b = "----=_Part_" + CurrentTimeStamp() + "_0001"; std::string s; s += "From: " + EncodeHeader(item.fromName) + " <" + item.fromAddr + ">\r\n"; s += "To: " + JoinAddr(item.toList) + "\r\n"; s += "Subject: " + EncodeHeader(item.subject) + "\r\n"; s += "Date: " + Rfc2822Date() + "\r\n"; s += "Message-ID: <" + CurrentTimeStamp() + "." + RandHex(8) + "@local>\r\n"; s += "MIME-Version: 1.0\r\n"; s += "Content-Type: multipart/mixed; boundary=\"" + b + "\"\r\n\r\n"; // 正文部分 s += "--" + b + "\r\n"; s += "Content-Type: text/plain; charset=UTF-8\r\n"; s += "Content-Transfer-Encoding: base64\r\n\r\n"; s += Wrap76(Base64Encode((const unsigned char*)item.bodyU8.data(), item.bodyU8.size())); s += "\r\n"; // 附件部分 for (size_t i = 0; i < item.attachments.size(); ++i) { std::string bytes = ReadAllBytes(item.attachments[i]); std::string name = PathFileName(item.attachments[i]); s += "--" + b + "\r\n"; s += "Content-Type: application/octet-stream; name=\"" + EncodeHeader(name) + "\"\r\n"; s += "Content-Disposition: attachment; filename=\"" + EncodeHeader(name) + "\"\r\n"; s += "Content-Transfer-Encoding: base64\r\n\r\n"; s += Wrap76(Base64Encode((const unsigned char*)bytes.data(), bytes.size())); s += "\r\n"; } s += "--" + b + "--\r\n"; // 结束边界,后面两个连字符 return s; }逻辑:任何一段的Content-Type里声明的 charset 必须和实际编码一致,声明 UTF-8 却塞 GBK 字节是最典型的乱码来源。参数:Message-ID不是必填,但补上能明显降低被判垃圾邮件的概率;附件的Content-Type统一写application/octet-stream最省事,收件端靠文件名判断类型,想让对方直接预览就按扩展名给image/png、application/pdf这类具体值。
4.3 Base64 与 76 字符折行
Base64 本身很简单,但 MIME 要求每行不超过 76 个字符,超出必须折行,否则某些服务器在转发时会截断或者报格式错误。
static const char kTab[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string Base64Encode(const unsigned char* d, size_t len) { std::string out; out.reserve((len + 2) / 3 * 4); size_t i = 0; for (; i + 2 < len; i += 3) { unsigned int v = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2]; out.push_back(kTab[(v >> 18) & 0x3F]); out.push_back(kTab[(v >> 12) & 0x3F]); out.push_back(kTab[(v >> 6) & 0x3F]); out.push_back(kTab[ v & 0x3F]); } if (i < len) { // 尾部剩 1 或 2 字节,补 '=' unsigned int v = d[i] << 16; if (i + 1 < len) v |= d[i + 1] << 8; out.push_back(kTab[(v >> 18) & 0x3F]); out.push_back(kTab[(v >> 12) & 0x3F]); out.push_back((i + 1 < len) ? kTab[(v >> 6) & 0x3F] : '='); out.push_back('='); } return out; } std::string Wrap76(const std::string& s) { std::string out; for (size_t i = 0; i < s.size(); i += 76) { out.append(s, i, 76); out.append("\r\n"); // 每行必须以 CRLF 结束 } if (out.empty()) out = "\r\n"; return out; }逻辑:编码按 3 字节一组处理,尾部不足 3 字节用=补齐,这是 Base64 的标准行为,不需要额外判断文件是否被整除。参数:76是 MIME 规定的行宽上限,写 64 或者 80 都能被多数服务器接受,但 76 最安全;折行必须用 CRLF,只写 LF 在部分中转服务器上会被当成一行处理,进而触发长度限制。
5. 现场排查:五个坑的现象、原因与解决
代码跑通一次不算完,真正花时间的是「昨天还能发,今天不行了」。下面五条都是实际排过的,现象描述尽量保持原始状态,方便对上号。
5.1 连接类:25 端口不通与超时无反馈
现象:程序卡在这一步十几秒后返回失败,日志里只有「连接失败」,服务器那边没有任何记录。
原因:一是网络侧对出站 25 端口有限制,很多云主机和办公网络默认不允许直接连外部 25 端口;二是服务器只开放了加密端口,明文端口即使是通的也会在发出EHLO后直接断开。这两种情况在客户端看起来都是「连不上」或「连上了没反应」。
解决:先用系统自带的 telnet 类工具确认端口可达,再确认服务器对明文会话是否支持。如果不支持,要么换一个允许明文的端口,要么在客户端补上加密层——这一点必须在动手前确认清楚,不要等代码写完才发现方向不对。同时把Connect的返回值细分成「解析失败 / 连接超时 / 被拒绝」三种,日志里写清楚,省掉一半猜测。
5.2 编码类:主题显示成=?UTF-8?B?...?=或问号
现象:主题在某个收件端显示成一串=?UTF-8?B?xxx?=,在另一个收件端显示成问号。
原因:前者是该字段被错误地编码了两次,或者编码标记里字符集名写错导致客户端不识别、直接原样输出;后者是声明了 UTF-8 但实际发出去的是 GBK 字节,客户端按声明解码就得到乱码。
解决:抓一次实际发出的原文,逐字节核对。头部只在含非 ASCII 时才编码,编码一次;正文的Content-Type里声明的 charset 与实际字节编码保持一致。团队里统一约定「源文件 UTF-8 + 传输 UTF-8」,中间不做任何本地代码页转换,能消掉绝大多数乱码。
5.3 投递类:收件端判垃圾邮件或 550 退信
现象:自己给自己发能收到,发给外部地址就进垃圾箱,或者直接收到550退信。
原因:From头和MAIL FROM里的地址不一致、缺Date和Message-ID、发件域名没有对应的解析记录,这几种都会让反垃圾规则扣分。550则通常是收件人地址不存在或对方拒收。
解决:先把信封发件人和头部发件人对齐到同一个地址,补上Date、Message-ID,主题和正文别全是图片或超短链接。测试阶段固定用一个自控的收件地址,收到后再逐项加内容,这样能快速定位是哪一项触发的。
5.4 数据类:正文里以点开头的行消失
现象:日志正文里有几行以.开头,收件端打开发现那几行没了,其余内容完好。
原因:DATA阶段没有做行首点号转义,服务器把第一个「行首点号」当成了数据结束标记,后面的内容被丢弃或者当成新命令解析。
解决:在写数据之前统一扫一遍,行首的.补成..。这个处理必须放在SendRaw这一层,不能放在构包阶段,因为 MIME 部件之间的边界行本身就以--开头,混淆了会更难查。
5.5 稳定性类:大附件发送中途断开
现象:小附件正常,几 MB 的附件发到一半连接被重置或者超时。
原因:一次性把整个 Base64 后的字符串塞给send,服务器侧有单封邮件大小限制,超过之后直接回552并断连;另外发送超时设置过短,大体积数据在慢速链路上还没传完就被判定失败。
解决:发送前先检查附件总大小,超过阈值就分批或者改用压缩包;SO_SNDTIMEO按数据量动态放大而不是固定几秒;把send改成循环累加并记录已发送字节,界面上做进度反馈,至少能看出卡在哪个百分比。
6. 进阶:异步发送、日志与自测手法
把发送逻辑从界面线程里挪出去,是这份源码投入实际使用前必须做的一步。原来SendMail是同步的,附件一大窗口就假死,用户点两下又触发第二次发送,两路会话抢同一个 socket,问题就变得很隐蔽。常见做法是开一个工作线程,会话对象在线程里构造,进度通过PostMessage回到界面:
struct SendTask { MailItem item; HWND hwndNotify; // 进度回调目标窗口 }; UINT AFX_CDECL SendThread(LPVOID p) { SendTask* t = (SendTask*)p; CSmtpClient cli; bool ok = cli.Connect(t->host, t->port, 8) && cli.AuthLogin(t->user, t->pass) && cli.SendMail(t->item); ::PostMessage(t->hwndNotify, WM_SEND_DONE, ok ? 1 : 0, 0); // 只在结束时回主线程 cli.Close(); delete t; return 0; }逻辑:会话对象在线程栈上,析构时自动释放 socket,避免线程退出后句柄泄漏。参数:WM_SEND_DONE用自定义消息,wParam传结果,失败详情仍留在m_lastError里由界面读取,不要塞进消息参数里传指针。
日志这块我的习惯是每次会话落一个文本文件,按阶段记录:连上之后的 220、EHLO后的 250、认证后的 235、每个RCPT TO的返回码、DATA的 354 和最后的 250。排查时不用抓包,打开文件就知道断在第几步。代价是日志里会出现账号和部分应答内容,密码那一行只记「已发送 Base64 凭据,长度 N」,不记内容。
自测手法固定两步:第一步自己给自己发一封纯文本,确认主题、正文、时间都对;第二步加一个几 MB 的压缩包,确认附件能完整打开、大小一致。这两步过了,再往真实收件人推。跨域投递的问题基本不会出现在第一步,但会集中在第二步暴露,所以两步都要走。
有一个细节我吃过亏:Date字段里时区偏移写死成+0800,程序部署到别的时区后,收件端显示的时间差了整整几个小时,还以为是服务器延迟。从那以后我每次加时间相关字段都强制走一遍「在目标时区机器上发一封给自己看头部原文」的流程,不再凭经验直接写常量。希望帮到你。
本文还有配套的精品资源,点击获取