简介:这是一份基于MFC框架实现的SMTP/POP3邮件收发器完整源码工程,面向C++初学者与高校课程设计实践者,解决Windows平台下轻量级邮件客户端开发的学习与验证需求。资源共34个文件,包含5个头文件(.h)定义类接口、4个C++源文件(.cpp)实现核心协议逻辑、1个可执行程序(.exe)及配套资源文件(.rc、.res)、调试支持文件(.pdb、.ilk)和VC6工程配置(.dsw、.dsp),整体压缩包仅3.53MB,结构典型,便于理解MFC项目组织方式与Win32网络编程基础流程。已有480人学习下载,适合作为《VC++程序设计》《网络编程》等课程课设参考,提供可直接运行的带附件发送(实测支持5MB)、邮件列表展示、点击查看内容及简易黑名单管理功能,虽未完成Base64解码与报文头/正文分离,但代码清晰、模块划分明确,为后续扩展POP3附件解析与UI优化提供了良好起点。
1. MFC邮件收发器 SMTP POP3:一个被低估的本地化通信基建能力,为什么现在还要手写 Win32 邮件客户端?
你可能觉得“邮件客户端”是 Outlook、Foxmail 或网页邮箱的专属领域,MFC 写邮件工具?过时、冗余、吃力不讨好。但真实产线场景里,我见过某工业监控系统在无外网、仅内网隔离的产线机上,靠一个精简版 MFC 邮件收发器自动上报设备异常——它不依赖 Outlook 进程、不弹 COM 安全警告、不触发杀软拦截 DLL 注入,连注册表都只读不写。核心就两点:用原生 Windows Sockets + OpenSSL 封装 SMTP 发信(带附件/HTML 正文/多收件人),用 POP3 协议轮询拉取指令邮件(含 Base64 编码的 JSON 控制参数)。这不是怀旧,是可控性刚需:当你的设备不能装第三方运行时、不能开浏览器、甚至不能连 DNS 服务器时,“MFC邮件收发器 SMTP POP3”就成了唯一能落地的轻量级双向信道。它适合嵌入式网关开发者、工控软件维护工程师、以及所有需要绕过 GUI 依赖、在 Win32 层直接握紧网络协议控制权的人。本文不讲 C++ 基础,只聚焦:怎么用最小 MFC 框架+标准 Winsock+开源 SSL 库,在 VS2019/2022 下从零编译出可静默运行、支持 TLS1.2、带完整错误码反馈的命令行友好型邮件模块。
2. 从零搭建 MFC 邮件收发器工程:VS2022 环境配置与 OpenSSL 静态链接实操
2.1 创建最小化 MFC 工程并关闭无关组件
新建项目 → 选择「MFC 应用程序」→ 项目名设为MailClientCore→ 在“应用程序类型”中勾选「基于对话框」→关键操作:取消勾选「使用 Unicode 库」(避免后续 OpenSSL 字符串处理乱码)、取消勾选「使用 Windows Sockets」(我们手动初始化,更可控)→ 在“高级功能”中全部取消勾选(禁用 ActiveX、数据库、打印等,减小体积和依赖)。生成后,删除默认对话框资源(IDD_MAILCLIENTCORE_DIALOG),保留CMainFrame类骨架,将主窗口改为无界面模式:在InitInstance()中注释掉m_pMainWnd->ShowWindow(m_nCmdShow)和m_pMainWnd->UpdateWindow(),改用AfxGetApp()->m_nCmdShow = SW_HIDE;实现后台静默运行。
提示:MFC 默认启用
_AFXDLL动态链接,但邮件模块需部署到老旧工控机(Win7 SP1 无 VC++2015-2022 运行库),因此必须切换为静态链接。右键项目 → 属性 → 配置属性 → 常规 → “使用 MFC” → 改为「在静态库中使用 MFC」;C/C++ → 代码生成 → “运行库” → 改为「多线程 (/MT)」。此步若遗漏,运行时会报错0xc000007b。
2.2 集成 OpenSSL 1.1.1w 静态库(非 DLL!)
下载 OpenSSL 1.1.1w 源码(官网 tar.gz 包),用 Perl + NASM + Visual Studio 工具链编译(不推荐直接下预编译二进制,版本兼容性极差)。编译命令如下(在 x64 Native Tools Command Prompt for VS2022 中执行):
perl Configure VC-WIN64A no-shared no-asm --prefix=C:\openssl-static --openssldir=C:\openssl-static nmake nmake install编译成功后,C:\openssl-static\lib下得到libcrypto.lib和libssl.lib。在 VS 项目属性中配置:
- 配置属性 → 常规 → “附加包含目录” → 添加
C:\openssl-static\include - 链接器 → 常规 → “附加库目录” → 添加
C:\openssl-static\lib - 链接器 → 输入 → “附加依赖项” → 填入
libssl.lib;libcrypto.lib;ws2_32.lib;gdi32.lib;user32.lib
注意:OpenSSL 1.1.1 系列已废弃
SSLv2_method()等函数,必须用TLS_client_method()。且OPENSSL_init_ssl()必须在WSAStartup()之后、任何 SSL 调用之前调用,否则SSL_CTX_new()返回 NULL —— 这是新手最常翻车的第一步。
2.3 手动初始化 Winsock 并封装基础 Socket 类
MFC 的CSocket类在非 GUI 模式下易出问题(如Create()失败返回 0),故我们绕过 MFC 封装,直接用socket()/connect()/send()/recv()。新建CNetSocket类,关键成员函数如下:
// CNetSocket.h class CNetSocket { public: SOCKET m_hSocket; bool Connect(const char* host, int port); int Send(const char* buf, int len); int Recv(char* buf, int len, int timeout_ms = 5000); void Close(); private: bool SetNonBlocking(bool bNonBlock); };// CNetSocket.cpp bool CNetSocket::Connect(const char* host, int port) { WSADATA wsa; if (WSAStartup(MAKEWORD(2,2), &wsa) != 0) return false; struct hostent* hp = gethostbyname(host); if (!hp) { WSACleanup(); return false; } sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = *(u_long*)hp->h_addr_list[0]; m_hSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hSocket == INVALID_SOCKET) { WSACleanup(); return false; } // 设置 10 秒连接超时(阻塞模式下) int timeout = 10000; setsockopt(m_hSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout)); setsockopt(m_hSocket, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout, sizeof(timeout)); if (connect(m_hSocket, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(m_hSocket); WSACleanup(); return false; } return true; }逻辑说明:
gethostbyname()在无 DNS 环境下会失败,因此生产环境必须支持 IP 直连(如192.168.1.100)和域名双模式。SO_RCVTIMEO/SO_SNDTIMEO是关键——POP3/SMTP 交互中,服务端响应延迟不可控,硬等会导致整个线程卡死。此处设为 5~10 秒,配合后续Recv()中的select()轮询,实现可中断的 IO 等待。
3. SMTP 发信模块实现:支持 HTML 正文、多附件、TLS1.2 加密握手
3.1 构建 MIME 多部分邮件体(RFC 2045 兼容)
MFC 自带CMimeMessage类?不存在。我们必须手写 MIME 封装。核心是生成唯一 boundary 字符串,并按顺序拼接:
Content-Type: multipart/mixed; boundary="----=_NextPart_000_XXXX"- 第一部分:HTML 正文(
Content-Type: text/html; charset="utf-8") - 后续部分:每个附件(
Content-Type: application/octet-stream+Content-Disposition: attachment; filename="xxx.pdf") - 每部分以
--boundary开头,结尾以--boundary--结束
// BuildMimeEmail() 伪代码逻辑(实际为 CString 拼接) CString boundary = _T("----=_NextPart_000_") + GenerateRandomString(8); CString email; email += _T("From: ") + from + _T("\r\n"); email += _T("To: ") + to + _T("\r\n"); email += _T("Subject: =?UTF-8?B?") + Base64Encode(subject) + _T("?=\r\n"); email += _T("Date: ") + GetRFC2822Date() + _T("\r\n"); email += _T("MIME-Version: 1.0\r\n"); email += _T("Content-Type: multipart/mixed; boundary=\"") + boundary + _T("\"\r\n\r\n"); // HTML 正文部分 email += _T("--") + boundary + _T("\r\n"); email += _T("Content-Type: text/html; charset=\"utf-8\"\r\n"); email += _T("Content-Transfer-Encoding: base64\r\n\r\n"); email += Base64Encode(html_body) + _T("\r\n"); // 附件部分(循环) for (int i = 0; i < m_attachments.GetSize(); i++) { email += _T("--") + boundary + _T("\r\n"); email += _T("Content-Type: application/octet-stream\r\n"); email += _T("Content-Transfer-Encoding: base64\r\n"); email += _T("Content-Disposition: attachment; filename=\"") + PathFindFileName(m_attachments[i]) + _T("\"\r\n\r\n"); email += Base64EncodeFile(m_attachments[i]) + _T("\r\n"); } email += _T("--") + boundary + _T("--\r\n"); // 结束标记参数说明:
Base64EncodeFile()必须按 57 字节/行切分(RFC 2045 要求),否则 Gmail 会拒收;PathFindFileName()来自shlwapi.h,比_tcsrchr()更安全;GetRFC2822Date()必须严格按EEE, DD MMM YYYY HH:MM:SS +ZZZZ格式(如Mon, 01 Jan 2024 12:00:00 +0800),否则部分企业邮件网关校验失败。
3.2 SMTP TLS 握手与认证流程(含 OAuth2 可选路径)
SMTP 流程分三阶段:明文连接 → STARTTLS 升级 → 登录发送。关键点在于:
EHLO后必须检查响应是否含STARTTLS字样,否则降级为非加密(不推荐)STARTTLS命令后,必须关闭原有 socket 并新建 SSL 连接(OpenSSL 不支持 on-the-fly upgrade)- 认证方式优先级:OAuth2 Bearer Token > PLAIN > LOGIN(现代邮箱基本禁用明文密码)
// SMTPSession.cpp 关键片段 bool SMTPSession::SendMail(const CString& mime_data) { // 1. 明文连接 if (!m_socket.Connect(m_smtp_host, m_smtp_port)) return false; // 2. EHLO + 检查 STARTTLS if (!SendCommand(_T("EHLO localhost")) || !m_response.Find(_T("STARTTLS")) >= 0) { return false; // 不支持 STARTTLS 则终止 } // 3. 发送 STARTTLS 并关闭原 socket if (!SendCommand(_T("STARTTLS"))) return false; m_socket.Close(); // 4. 新建 SSL 连接 SSL_library_init(); OpenSSL_add_all_algorithms(); SSL_load_error_strings(); const SSL_METHOD* method = TLS_client_method(); // OpenSSL 1.1.1+ SSL_CTX* ctx = SSL_CTX_new(method); if (!ctx) return false; SSL* ssl = SSL_new(ctx); if (!ssl) { SSL_CTX_free(ctx); return false; } // 绑定 socket 到 SSL SSL_set_fd(ssl, (int)m_socket.m_hSocket); if (SSL_connect(ssl) <= 0) { SSL_free(ssl); SSL_CTX_free(ctx); return false; } // 5. AUTH LOGIN(或 OAuth2) CString auth_str = _T("AUTH LOGIN\r\n"); if (!SSL_Send(ssl, auth_str)) { /* error */ } // 发送 Base64 编码的用户名/密码(PLAIN 方式更简洁,但 LOGIN 兼容性更好) CString user_b64 = Base64Encode(m_username); CString pass_b64 = Base64Encode(m_password); SSL_Send(ssl, user_b64 + _T("\r\n")); SSL_Send(ssl, pass_b64 + _T("\r\n")); // 6. MAIL FROM / RCPT TO / DATA / mime_data / QUIT // ...(标准 SMTP 流程,此处省略细节) }逻辑说明:
SSL_set_fd()必须在SSL_connect()之前调用,否则握手失败;SSL_send()/SSL_recv()替代send()/recv();SSL_shutdown()必须在退出前调用,否则连接未优雅关闭,服务端可能限流。
4. POP3 收信模块实现:带 UIDL 缓存的增量拉取与 Base64/Quoted-Printable 解码
4.1 POP3 协议状态机与 UIDL 增量同步机制
POP3 是单向拉取协议,无推送。为避免重复处理同一封邮件,必须用UIDL命令获取每封邮件唯一 ID,并本地缓存(推荐用CMapStringToString存内存,或写入mail_cache.dat文件)。流程如下:
USER/PASS认证(或AUTH PLAIN)UIDL获取所有邮件 ID 列表- 对比本地缓存,找出新增 ID
- 对每个新增 ID,用
RETR下载全文 - 解析
Content-Type和Content-Transfer-Encoding,调用对应解码器
// POP3Session.cpp bool POP3Session::FetchNewMails(CArray<CString>& new_emails) { if (!Connect()) return false; if (!Auth()) return false; // 获取 UIDL 列表 CString uidl_list = SendCommand(_T("UIDL")); if (uidl_list.IsEmpty()) return false; // 解析 UIDL(格式:1 abcdef123456...) CAtlArray<CString> lines; SplitString(uidl_list, _T("\r\n"), lines); CMapStringToString uidl_map; for (int i = 0; i < lines.GetSize(); i++) { if (lines[i].Left(1) == _T(".")) continue; // 忽略结束符 int pos = lines[i].Find(_T(" ")); if (pos > 0) { CString id = lines[i].Mid(pos + 1).Trim(); uidl_map[lines[i].Left(pos).Trim()] = id; } } // 加载本地缓存 CMapStringToString local_cache; LoadCacheFromFile(_T("mail_cache.dat"), local_cache); // 找出新增邮件编号 POSITION pos = uidl_map.GetStartPosition(); while (pos) { CString num, uid; uidl_map.GetNextAssoc(pos, num, uid); if (!local_cache.Lookup(uid)) { // UID 未在缓存中 CString mail_full = RetrieveMail(num); if (!mail_full.IsEmpty()) { new_emails.Add(mail_full); // 解析邮件头,提取 Subject/From/Date ParseMailHeader(mail_full); } // 缓存该 UID local_cache[uid] = _T("1"); } } SaveCacheToFile(_T("mail_cache.dat"), local_cache); return true; }参数说明:
SplitString()是自定义辅助函数,用CAtlString的Tokenize()实现;LoadCacheFromFile()将key=value行写入文本,每行一个 UID;RetrieveMail()内部调用RETR+SSL_recv(),并做CRLF→LF归一化(Windows 行尾兼容)。
4.2 HTML 邮件正文与附件的智能解码(Quoted-Printable / Base64 / 7bit)
POP3 下载的是原始 RFC822 邮件,含多层 MIME 封装。我们不引入libmime,而是用正则 + 状态机轻量解析:
- 先用
RegExp提取Content-Type: text/html和Content-Transfer-Encoding: quoted-printable的段落 - 对 Quoted-Printable:将
=XX替换为对应字节,=0D=0A→\r\n,=_→ (空格) - 对 Base64:调用
CryptStringToBinary()(Windows API)解码 - 对附件:识别
Content-Disposition: attachment,提取filename=后的值,Base64 解码后写入本地文件
// DecodeQuotedPrintable() 示例 CString DecodeQuotedPrintable(const CString& input) { CString output; for (int i = 0; i < input.GetLength(); i++) { if (input[i] == _T('=') && i + 2 < input.GetLength()) { if (input[i+1] == _T('\r') && input[i+2] == _T('\n')) { // 软换行,跳过 i += 2; continue; } TCHAR hex[3] = { input[i+1], input[i+2], 0 }; DWORD byte; if (_stscanf_s(hex, _T("%02X"), &byte) == 1) { output += (char)byte; i += 2; continue; } } output += input[i]; } return output; }逻辑说明:Quoted-Printable 中
=0D=0A是软换行(soft line break),必须丢弃;=_是行尾空格,要转为空格字符;CryptStringToBinary()比 OpenSSL 的EVP_DecodeBlock()更轻量,且无需初始化 OpenSSL。
5. 避坑指南:MFC邮件收发器 SMTP POP3 开发中 4 个血泪经验
5.1 现象:SSL_connect()返回 -1,SSL_get_error()得到SSL_ERROR_SSL,日志显示wrong version number
原因:服务端要求 TLS1.2,但 OpenSSL 编译时未启用enable-tls1_2,或SSL_CTX_set_options(ctx, SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1)误关了 TLS1.2。
解决:编译 OpenSSL 时加enable-tls1_2参数;代码中移除所有SSL_OP_NO_TLSv1_2相关设置;用SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION)显式指定最低版本。
5.2 现象:Gmail/Outlook 邮箱拒绝登录,返回535-5.7.8 Username and Password not accepted
原因:现代邮箱已全面禁用「用户名+密码」的 SMTP 认证,强制要求 OAuth2 或 App Password。
解决:
- 对个人 Gmail:开启两步验证 → 生成 App Password(16位)→ 用该密码替代账户密码
- 对企业 Outlook:申请 OAuth2 Client ID/Secret → 用
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token获取 Access Token → SMTP 认证时用AUTH XOAUTH2 base64("user={user}\x01auth=Bearer {token}\x01\x01")
5.3 现象:POP3RETR下载的邮件中,中文 Subject 显示为=?GBK?B?...?=,但解码后仍是乱码
原因:Content-Transfer-Encoding是 Base64,但字符集声明是GBK,而 Windows APICryptStringToBinary()默认按 UTF-16 解码,未按 GBK 重编码。
解决:先 Base64 解码得字节数组,再用MultiByteToWideChar(CP_ACP, 0, bytes, -1, ...)转宽字符;或统一强制用 UTF-8 存储所有邮件头(服务端可配置)。
5.4 现象:MFC 程序在 Win7 上启动即崩溃,事件查看器报Application Error: faulting module ntdll.dll
原因:OpenSSL 1.1.1w 静态库链接了ucrtbase.dll(Universal CRT),而 Win7 默认无此库。
解决:
- 方案 A(推荐):编译 OpenSSL 时加
-D_WIN32_WINNT=0x0601(Win7 SDK),并链接legacy_stdio_definitions.lib - 方案 B:在项目属性 → 配置属性 → 常规 → “Windows SDK 版本” → 改为
10.0,但“平台工具集”保持v142(VS2019)或v143(VS2022),并勾选「使用 Microsoft C/C++ 旧版库」
提示:第 5.4 条是工控现场最高频问题。某导师曾因忽略此条,导致 200 台设备批量蓝屏,最终回退到 OpenSSL 1.0.2u(已停止维护)才临时救火。教训是:永远在目标 OS 上编译,而非开发机。
6. 生产就绪技巧:命令行参数驱动、日志分级与静默模式下的错误诊断
6.1 用GetCommandLine()解析命令行,实现免配置运行
MFC 默认不暴露main(),但我们可在InitInstance()中调用GetCommandLine(),用CommandLineToArgvW()解析参数,支持:
/send from=xx@yy.com to=zz@aa.com subject="test" body="hello" attach=c:\1.pdf/recv server=pop3.xxx.com user=abc pwd=123 cache=c:\cache.dat/loglevel 3(0=off, 1=error, 2=warn, 3=info, 4=debug)
// 在 CMailClientCoreApp::InitInstance() 中 LPWSTR* argv; int argc = CommandLineToArgvW(GetCommandLine(), &argv); if (argc > 1) { for (int i = 1; i < argc; i++) { if (_wcsnicmp(argv[i], L"/send", 5) == 0) { m_mode = MODE_SEND; } else if (_wcsnicmp(argv[i], L"/recv", 5) == 0) { m_mode = MODE_RECV; } else if (_wcsnicmp(argv[i], L"/loglevel", 9) == 0 && i + 1 < argc) { m_log_level = _wtoi(argv[i + 1]); } } } LocalFree(argv);逻辑说明:
CommandLineToArgvW()是 Windows 原生 API,比_tmain()更底层,完美兼容 MFC;参数解析不依赖第三方库,避免 DLL 依赖;/send模式下,程序执行完立即ExitProcess(0),不进入消息循环,真正静默。
6.2 分级日志写入文件(非OutputDebugString)
OutputDebugString()在无调试器时丢失日志,必须写文件。设计环形日志:mailclient.log最大 1MB,超限则重命名为mailclient.log.bak。日志格式含时间戳、线程ID、级别、模块名:
| 时间戳 | 线程ID | 级别 | 模块 | 消息 |
|---|---|---|---|---|
| 2024-01-01 12:00:00.123 | 0x1A2B | INFO | SMTP | Connected to smtp.gmail.com:587 |
| 2024-01-01 12:00:01.456 | 0x1A2B | ERROR | POP3 | UIDL parse failed at line 3 |
void WriteLog(int level, LPCTSTR module, LPCTSTR fmt, ...) { if (level > g_log_level) return; TCHAR time_buf[64]; GetLocalTimeStr(time_buf); // 自定义函数,输出精确到毫秒 DWORD tid = GetCurrentThreadId(); va_list args; va_start(args, fmt); int len = _vsctprintf(fmt, args) + 100; LPTSTR buf = new TCHAR[len]; _vsntprintf_s(buf, len, _TRUNCATE, fmt, args); va_end(args); CString log_line; log_line.Format(_T("%s | 0x%04X | %s | %s | %s\r\n"), time_buf, tid, level == 1 ? _T("ERROR") : level == 2 ? _T("WARN") : level == 3 ? _T("INFO") : _T("DEBUG"), module, buf); HANDLE h = CreateFile(_T("mailclient.log"), GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (h != INVALID_HANDLE_VALUE) { SetFilePointer(h, 0, NULL, FILE_END); DWORD written; WriteFile(h, log_line, log_line.GetLength() * sizeof(TCHAR), &written, NULL); CloseHandle(h); } delete[] buf; }6.3 静默模式下的错误诊断:用SetUnhandledExceptionFilter捕获崩溃堆栈
当程序在客户机上无声退出,必须留下线索。注册全局异常处理器,捕获EXCEPTION_ACCESS_VIOLATION等致命错误,生成crash.dmp:
LONG WINAPI CrashHandler(EXCEPTION_POINTERS* pExceptionInfo) { HANDLE hDump = CreateFile(_T("crash.dmp"), GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hDump != INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION info; info.ClientPointers = TRUE; info.ExceptionPointers = pExceptionInfo; info.ThreadId = GetCurrentThreadId(); MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hDump, MiniDumpNormal, &info, NULL, NULL); CloseHandle(hDump); } return EXCEPTION_EXECUTE_HANDLER; } // 在 InitInstance() 开头调用 SetUnhandledExceptionFilter(CrashHandler);我的习惯是:每次交付前,用
procdump -e -ma -x .\MailClientCore.exe在测试机上挂起进程,模拟内存泄漏和句柄耗尽,看日志是否记录ERROR: Out of socket handles;再用netsh interface portproxy add v4tov4 listenport=25 connectaddress=127.0.0.1 connectport=2525搭建本地 SMTP 代理,抓包验证 TLS 握手是否真走 587 端口。这些不是玄学,是让客户说“这东西真能跑”的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取