From d9ab369c9cd19d9120581765158da39c38f499c2 Mon Sep 17 00:00:00 2001 From: ithewei Date: Sat, 19 Sep 2026 13:45:46 +0800 Subject: [PATCH 1/3] feat(mail): add event-driven SMTP send + IMAP recv client module Add a new optional `mail/` module providing asynchronous email clients built on the hloop event loop, addressing issue #883 (send/recv email like curl). Replaces the synchronous demo in protocol/smtp.c (kept as-is for compatibility). - mail/mime.{h,c}: MIME assembly (multipart/mixed + multipart/alternative, base64 attachments, RFC 2047 encoded-word for non-ASCII headers) and parsing (multipart split, base64 / quoted-printable decode). Shared by both clients. mail_t with multi-recipient To/Cc + attachments. - mail/smtp_client.{h,c}: async SMTP client (C core + SmtpClient C++ wrapper). State machine EHLO -> AUTH LOGIN -> MAIL FROM -> RCPT TO (multi) -> DATA -> QUIT. Direct TLS (SMTPS 465) via hssl. - mail/imap_client.{h,c}: async IMAP client (C core + ImapClient C++ wrapper). LOGIN -> SELECT -> SEARCH -> FETCH (per id, with literal {n} parsing) -> LOGOUT, then MIME-parses each message. Direct TLS (IMAPS 993). - build wiring: --with-mail / WITH_MAIL (default OFF), mirroring the mqtt module across configure / Makefile / CMake / Bazel. - examples/mail/{sendmail,recvmail}_test.cpp + docs/cn/{SmtpClient,ImapClient}.md. - README: mention the mail client. Scope: direct TLS only (no STARTTLS); IMAP read-only minimal set (no STORE/COPY/IDLE); no POP3. Integration is manually verified against real mailboxes; CI covers compilation. Co-authored-by: TRAE CLI --- BUILD.bazel | 21 ++ CMakeLists.txt | 8 +- Makefile | 17 +- Makefile.vars | 4 + README-CN.md | 1 + README.md | 1 + cmake/vars.cmake | 6 + config.ini | 2 + configure | 1 + docs/cn/ImapClient.md | 76 +++++ docs/cn/SmtpClient.md | 91 +++++ examples/mail/recvmail_test.cpp | 68 ++++ examples/mail/sendmail_test.cpp | 64 ++++ mail/imap_client.c | 481 ++++++++++++++++++++++++++ mail/imap_client.h | 116 +++++++ mail/mime.c | 574 ++++++++++++++++++++++++++++++++ mail/mime.h | 79 +++++ mail/smtp_client.c | 377 +++++++++++++++++++++ mail/smtp_client.h | 114 +++++++ 19 files changed, 2099 insertions(+), 2 deletions(-) create mode 100644 docs/cn/ImapClient.md create mode 100644 docs/cn/SmtpClient.md create mode 100644 examples/mail/recvmail_test.cpp create mode 100644 examples/mail/sendmail_test.cpp create mode 100644 mail/imap_client.c create mode 100644 mail/imap_client.h create mode 100644 mail/mime.c create mode 100644 mail/mime.h create mode 100644 mail/smtp_client.c create mode 100644 mail/smtp_client.h diff --git a/BUILD.bazel b/BUILD.bazel index 1a229bde3..429d803e6 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -100,6 +100,12 @@ config_setting( visibility = [":__subpackages__"], ) +config_setting( + name = "with_mail", + define_values = {"WITH_MAIL": "ON"}, + visibility = [":__subpackages__"], +) + config_setting( name = "with_redis", define_values = { @@ -213,6 +219,9 @@ HEADERS_DIRS = ["base", "ssl", "event"] + select({ }) + select({ "with_mqtt": ["mqtt"], "//conditions:default": [], +}) + select({ + "with_mail": ["mail"], + "//conditions:default": [], }) COPTS = select({ @@ -388,6 +397,12 @@ MQTT_HEADERS = [ "mqtt/mqtt_client.h", ] +MAIL_HEADERS = [ + "mail/mime.h", + "mail/smtp_client.h", + "mail/imap_client.h", +] + HEADERS = ["hv.h", ":config", "hexport.h"] + BASE_HEADERS + SSL_HEADERS + EVENT_HEADERS + UTIL_HEADERS + select({ "with_protocol": PROTOCOL_HEADERS, @@ -413,6 +428,9 @@ HEADERS = ["hv.h", ":config", "hexport.h"] + BASE_HEADERS + SSL_HEADERS + EVENT_ }) + select({ "with_mqtt": MQTT_HEADERS, "//conditions:default": [], +}) + select({ + "with_mail": MAIL_HEADERS, + "//conditions:default": [], }) @@ -453,6 +471,9 @@ SRCS = CORE_SRCS + glob(["util/*.h", "util/*.c", "util/*.cpp"], exclude = ["util }) + select({ "with_mqtt": glob(["mqtt/*.h", "mqtt/*.c", "mqtt/*.cpp"], exclude = ["mqtt/*_test.c"]), "//conditions:default": [], +}) + select({ + "with_mail": glob(["mail/*.h", "mail/*.c", "mail/*.cpp"], exclude = ["mail/*_test.c"]), + "//conditions:default": [], }) cc_library( diff --git a/CMakeLists.txt b/CMakeLists.txt index 899b01629..a184fa8e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ option(WITH_HTTP_SERVER "compile http/server" ON) option(WITH_HTTP_CLIENT "compile http/client" ON) option(WITH_MQTT "compile mqtt" OFF) option(WITH_REDIS "compile redis" OFF) +option(WITH_MAIL "compile mail (SMTP/IMAP)" OFF) option(WITH_RPC "compile hrpc (libhrpc, needs protobuf)" OFF) option(ENABLE_UDS "Unix Domain Socket" OFF) @@ -296,7 +297,7 @@ if(APPLE) endif() # see Makefile -set(ALL_SRCDIRS . base ssl event event/kcp util cpputil evpp redis protocol http http/client http/server mqtt js) +set(ALL_SRCDIRS . base ssl event event/kcp util cpputil evpp redis protocol http http/client http/server mqtt mail js) set(CORE_SRCDIRS . base ssl event) if(WIN32 OR MINGW) if(WITH_WEPOLL) @@ -370,6 +371,11 @@ if(WITH_MQTT) set(LIBHV_SRCDIRS ${LIBHV_SRCDIRS} mqtt) endif() +if(WITH_MAIL) + set(LIBHV_HEADERS ${LIBHV_HEADERS} ${MAIL_HEADERS}) + set(LIBHV_SRCDIRS ${LIBHV_SRCDIRS} mail) +endif() + list_source_directories(LIBHV_SRCS ${LIBHV_SRCDIRS}) if(NOT WITH_LUA) list(FILTER LIBHV_SRCS EXCLUDE REGEX "(^|/)HttpLuaHandler\\.cpp$") diff --git a/Makefile b/Makefile index 9e8cd0b9f..b0772ec4a 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ include config.mk include Makefile.vars MAKEF=$(MAKE) -f Makefile.in -ALL_SRCDIRS=. base ssl event event/kcp util cpputil evpp redis protocol http http/client http/server mqtt js +ALL_SRCDIRS=. base ssl event event/kcp util cpputil evpp redis protocol http http/client http/server mqtt mail js CORE_SRCDIRS=. base ssl event ifeq ($(WITH_KCP), yes) CORE_SRCDIRS += event/kcp @@ -80,6 +80,11 @@ LIBHV_HEADERS += $(MQTT_HEADERS) LIBHV_SRCDIRS += mqtt endif +ifeq ($(WITH_MAIL), yes) +LIBHV_HEADERS += $(MAIL_HEADERS) +LIBHV_SRCDIRS += mail +endif + default: all all: libhv examples @@ -123,6 +128,10 @@ ifeq ($(WITH_MQTT), yes) EXAMPLES += mqtt_sub mqtt_pub mqtt_client_test endif +ifeq ($(WITH_MAIL), yes) +EXAMPLES += sendmail_test recvmail_test +endif + ifeq ($(WITH_LUA), yes) ifeq ($(WITH_EVPP), yes) EXAMPLES += hvlua @@ -316,6 +325,12 @@ mqtt_pub: prepare mqtt_client_test: prepare $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS) mqtt" SRCS="examples/mqtt/mqtt_client_test.cpp" +sendmail_test: prepare + $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS) util mail" SRCS="examples/mail/sendmail_test.cpp" + +recvmail_test: prepare + $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS) util mail" SRCS="examples/mail/recvmail_test.cpp" + kcptun: kcptun_client kcptun_server kcptun_client: prepare diff --git a/Makefile.vars b/Makefile.vars index f51418a97..7209211da 100644 --- a/Makefile.vars +++ b/Makefile.vars @@ -130,6 +130,10 @@ HTTP_SERVER_HEADERS = http/server/HttpServer.h\ MQTT_HEADERS = mqtt/mqtt_protocol.h\ mqtt/mqtt_client.h +MAIL_HEADERS = mail/mime.h\ + mail/smtp_client.h\ + mail/imap_client.h + LUA_HEADERS = lua/hvlua.h\ lua/hvlua_util.h\ lua/hvlua_json.h diff --git a/README-CN.md b/README-CN.md index 1dca3a315..bff96ec93 100644 --- a/README-CN.md +++ b/README-CN.md @@ -64,6 +64,7 @@ - WebSocket服务端/客户端 - MQTT客户端 - Redis客户端 +- 邮件客户端(SMTP发送 / IMAP接收) ## ⌛️ 构建 diff --git a/README.md b/README.md index 9970198d5..5e37dc06b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ but simpler api and richer protocols. - WebSocket client/server - MQTT client - Redis client +- Mail client (SMTP send / IMAP recv) ## ⌛️ Build diff --git a/cmake/vars.cmake b/cmake/vars.cmake index 83e7afd7f..17354612c 100644 --- a/cmake/vars.cmake +++ b/cmake/vars.cmake @@ -124,6 +124,12 @@ set(MQTT_HEADERS mqtt/mqtt_client.h ) +set(MAIL_HEADERS + mail/mime.h + mail/smtp_client.h + mail/imap_client.h +) + set(LUA_HEADERS lua/hvlua.h lua/hvlua_util.h diff --git a/config.ini b/config.ini index e4a87fe9e..c84cef1cf 100644 --- a/config.ini +++ b/config.ini @@ -18,6 +18,8 @@ WITH_HTTP_SERVER=yes WITH_HTTP_CLIENT=yes WITH_MQTT=no WITH_REDIS=no +# mail = SMTP send + IMAP recv (requires SSL for direct TLS) +WITH_MAIL=no # hrpc = TLV + protobuf, built as a separate libhrpc (requires protobuf) WITH_RPC=no diff --git a/configure b/configure index aa7a66d4c..32ff8f024 100755 --- a/configure +++ b/configure @@ -31,6 +31,7 @@ modules: --with-http-server compile http server module? (DEFAULT: $WITH_HTTP_SERVER) --with-mqtt compile mqtt module? (DEFAULT: $WITH_MQTT) --with-redis compile redis module? (DEFAULT: $WITH_REDIS) + --with-mail compile mail module (SMTP/IMAP)? (DEFAULT: $WITH_MAIL) --with-rpc compile hrpc (libhrpc, needs protobuf)? (DEFAULT: $WITH_RPC) --with-lua compile lua module? (DEFAULT: $WITH_LUA) --with-js compile js module? (DEFAULT: $WITH_JS) diff --git a/docs/cn/ImapClient.md b/docs/cn/ImapClient.md new file mode 100644 index 000000000..fb3178445 --- /dev/null +++ b/docs/cn/ImapClient.md @@ -0,0 +1,76 @@ +IMAP 邮件接收客户端 + +基于事件循环的异步 IMAP 客户端,支持直连 TLS(IMAPS,如 993 端口),读取邮件(读取最小集:LOGIN / SELECT / SEARCH / FETCH / LOGOUT),并解析 MIME 报文得到发件人、主题、正文与附件。 + +> 注意: +> - 只支持直连 TLS(IMAPS),不支持 STARTTLS。主流邮箱(QQ/163/Gmail 等)均提供 993 直连 SSL 端口。 +> - 只实现读取相关命令,不支持写操作(STORE 改标记 / COPY / 移动 / IDLE 推送等)。 + +编译需开启 mail 模块与 SSL: + +```sh +./configure --with-mail --with-openssl && make libhv +# 或 CMake: cmake .. -DWITH_MAIL=ON -DWITH_OPENSSL=ON +``` + +## C++ 接口 + +```c++ +namespace hv { + +class ImapClient { +public: + ImapClient(hloop_t* loop = NULL); + + // 设置服务器: host、端口(默认993)、是否直连TLS(默认true) + void setHost(const char* host, int port = 993, bool ssl = true); + // 设置认证账号 + void setAuth(const char* username, const char* password); + // 设置连接超时(ms) + void setConnectTimeout(int ms); + // SSL/TLS + int setSslCtx(hssl_ctx_t ssl_ctx); + int newSslCtx(hssl_ctx_opt_t* opt); + + // 每封邮件回调: mail 在回调期间有效 + std::function onMail; + // 完成/失败回调: code==0 成功; code<0 为 libhv ERR_* + std::function onDone; + + // 拉取: LOGIN -> SELECT mailbox -> SEARCH criteria -> 逐封 FETCH -> LOGOUT + // mailbox 如 "INBOX"; criteria 为 IMAP SEARCH 条件,如 "ALL"、"UNSEEN" + int fetch(const char* mailbox = "INBOX", const char* criteria = "ALL"); + + void run(); // 占用当前线程运行事件循环 + void stop(); +}; + +} +``` + +## 示例 + +```c++ +#include "imap_client.h" +using namespace hv; + +int main() { + ImapClient cli; + cli.setHost("imap.qq.com", 993, true); + cli.setAuth("you@qq.com", "yourpassword"); // 多数邮箱这里填授权码 + cli.onMail = [](ImapClient*, mail_t* mail) { + printf("From: %s\n", mail->from.addr ? mail->from.addr : ""); + printf("Subject: %s\n", mail->subject ? mail->subject : ""); + if (mail->text_body) printf("Body: %s\n", mail->text_body); + }; + cli.onDone = [&cli](ImapClient*, int code, const std::string& msg) { + printf("done: %d %s\n", code, msg.c_str()); + cli.stop(); + }; + cli.fetch("INBOX", "UNSEEN"); // 拉取未读邮件 + cli.run(); + return 0; +} +``` + +测试代码见 [examples/mail/recvmail_test.cpp](../../examples/mail/recvmail_test.cpp) diff --git a/docs/cn/SmtpClient.md b/docs/cn/SmtpClient.md new file mode 100644 index 000000000..b3e6a65ff --- /dev/null +++ b/docs/cn/SmtpClient.md @@ -0,0 +1,91 @@ +SMTP 邮件发送客户端 + +基于事件循环的异步 SMTP 客户端,支持直连 TLS(SMTPS,如 465 端口)、多收件人/抄送、附件。 + +> 注意:只支持直连 TLS(SMTPS),不支持 STARTTLS(587/143 明文中途升级)。主流邮箱(QQ/163/Gmail 等)均提供 465 直连 SSL 端口。 + +编译需开启 mail 模块与 SSL: + +```sh +./configure --with-mail --with-openssl && make libhv +# 或 CMake: cmake .. -DWITH_MAIL=ON -DWITH_OPENSSL=ON +``` + +## C++ 接口 + +```c++ +namespace hv { + +class SmtpClient { +public: + SmtpClient(hloop_t* loop = NULL); + + // 设置服务器: host、端口(默认465)、是否直连TLS(默认true) + void setHost(const char* host, int port = 465, bool ssl = true); + // 设置认证账号 + void setAuth(const char* username, const char* password); + // 设置连接超时(ms) + void setConnectTimeout(int ms); + // SSL/TLS + int setSslCtx(hssl_ctx_t ssl_ctx); + int newSslCtx(hssl_ctx_opt_t* opt); + + // 结果回调: code∈[200,300) 表示成功; code<0 为 libhv ERR_* + std::function onResult; + + // 异步发送: 连接 -> EHLO -> AUTH -> MAIL FROM -> RCPT TO -> DATA -> QUIT + int send(mail_t* mail); + + void run(); // 占用当前线程运行事件循环 + void stop(); +}; + +} +``` + +## 邮件结构 mail_t + +见 `mail/mime.h`,提供构造辅助函数(均为拷贝语义,用 `mail_clear` 释放): + +```c +void mail_set_from(mail_t* mail, const char* addr, const char* name); +void mail_add_to (mail_t* mail, const char* addr, const char* name); +void mail_add_cc (mail_t* mail, const char* addr, const char* name); +void mail_set_subject(mail_t* mail, const char* subject); +void mail_set_text(mail_t* mail, const char* text); // text/plain +void mail_set_html(mail_t* mail, const char* html); // text/html +void mail_add_attachment(mail_t* mail, const char* filename, + const char* content_type, // NULL 则按扩展名推断 + const void* data, size_t size); +void mail_clear(mail_t* mail); +``` + +## 示例 + +```c++ +#include "smtp_client.h" +using namespace hv; + +int main() { + mail_t mail; + memset(&mail, 0, sizeof(mail)); + mail_set_from(&mail, "you@qq.com", NULL); + mail_add_to(&mail, "friend@example.com", NULL); + mail_set_subject(&mail, "hello from libhv"); + mail_set_text(&mail, "This is a test mail.\r\n"); + + SmtpClient cli; + cli.setHost("smtp.qq.com", 465, true); + cli.setAuth("you@qq.com", "yourpassword"); // 多数邮箱这里填授权码 + cli.onResult = [&cli](SmtpClient*, int code, const std::string& msg) { + printf("result: %d %s\n", code, msg.c_str()); + cli.stop(); + }; + cli.send(&mail); + cli.run(); + mail_clear(&mail); + return 0; +} +``` + +测试代码见 [examples/mail/sendmail_test.cpp](../../examples/mail/sendmail_test.cpp) diff --git a/examples/mail/recvmail_test.cpp b/examples/mail/recvmail_test.cpp new file mode 100644 index 000000000..0dee5a610 --- /dev/null +++ b/examples/mail/recvmail_test.cpp @@ -0,0 +1,68 @@ +/* + * sample IMAP mail receiver + * + * @build ./configure --with-mail --with-openssl && make libhv && make recvmail_test + * (or) make WITH_MAIL=yes WITH_OPENSSL=yes + * + * @run bin/recvmail_test [mailbox] [criteria] + * + * @example bin/recvmail_test imap.qq.com 993 you@qq.com yourpass INBOX UNSEEN + * + * NOTE: only direct TLS (IMAPS, e.g. port 993) is supported, not STARTTLS. + */ + +#include "imap_client.h" +#include + +using namespace hv; + +int main(int argc, char** argv) { + if (argc < 5) { + printf("Usage: %s imap_host port username password [mailbox] [criteria]\n", argv[0]); + return -1; + } + const char* host = argv[1]; + int port = atoi(argv[2]); + const char* username = argv[3]; + const char* password = argv[4]; + const char* mailbox = argc > 5 ? argv[5] : "INBOX"; + const char* criteria = argc > 6 ? argv[6] : "ALL"; + + ImapClient cli; + cli.setHost(host, port, port == 993 /* ssl for 993 */); + cli.setAuth(username, password); + cli.setConnectTimeout(10000); + + int count = 0; + cli.onMail = [&count](ImapClient*, mail_t* mail) { + ++count; + printf("---- mail #%d ----\n", count); + printf("From: %s\n", mail->from.addr ? mail->from.addr : ""); + printf("Subject: %s\n", mail->subject ? mail->subject : ""); + printf("Date: %s\n", mail->date ? mail->date : ""); + if (mail->text_body) { + printf("Body:\n%.200s%s\n", mail->text_body, + strlen(mail->text_body) > 200 ? "..." : ""); + } + if (mail->attachment_count > 0) { + printf("Attachments: %d\n", mail->attachment_count); + for (int i = 0; i < mail->attachment_count; ++i) { + printf(" - %s (%zu bytes)\n", + mail->attachments[i].filename ? mail->attachments[i].filename : "?", + mail->attachments[i].size); + } + } + }; + cli.onDone = [&cli, &count](ImapClient*, int code, const std::string& msg) { + printf("---- done: %d mail(s), code=%d %s ----\n", count, code, msg.c_str()); + cli.stop(); + }; + + int ret = cli.fetch(mailbox, criteria); + if (ret != 0) { + printf("fetch start failed: %d\n", ret); + return ret; + } + cli.run(); // block until stopped + return 0; +} diff --git a/examples/mail/sendmail_test.cpp b/examples/mail/sendmail_test.cpp new file mode 100644 index 000000000..9818e5e14 --- /dev/null +++ b/examples/mail/sendmail_test.cpp @@ -0,0 +1,64 @@ +/* + * sample SMTP mail sender + * + * @build ./configure --with-mail --with-openssl && make libhv && make sendmail_test + * (or) make WITH_MAIL=yes WITH_OPENSSL=yes + * + * @run bin/sendmail_test + * + * @example bin/sendmail_test smtp.qq.com 465 you@qq.com yourpass you@qq.com friend@example.com + * + * NOTE: only direct TLS (SMTPS, e.g. port 465) is supported, not STARTTLS. + */ + +#include "smtp_client.h" +#include + +using namespace hv; + +int main(int argc, char** argv) { + if (argc < 7) { + printf("Usage: %s smtp_host port username password from to\n", argv[0]); + return -1; + } + const char* host = argv[1]; + int port = atoi(argv[2]); + const char* username = argv[3]; + const char* password = argv[4]; + const char* from = argv[5]; + const char* to = argv[6]; + + // build the mail + mail_t mail; + memset(&mail, 0, sizeof(mail)); + mail_set_from(&mail, from, NULL); + mail_add_to(&mail, to, NULL); + mail_set_subject(&mail, "hello from libhv"); + mail_set_text(&mail, "This is a test mail sent by libhv SmtpClient.\r\n"); + // optional attachment: + // const char* content = "attachment content"; + // mail_add_attachment(&mail, "hello.txt", NULL, content, strlen(content)); + + SmtpClient cli; + cli.setHost(host, port, port == 465 /* ssl for 465 */); + cli.setAuth(username, password); + cli.setConnectTimeout(10000); + cli.onResult = [&cli](SmtpClient*, int code, const std::string& msg) { + if (code >= 200 && code < 300) { + printf("send mail success: %d %s\n", code, msg.c_str()); + } else { + printf("send mail failed: %d %s\n", code, msg.c_str()); + } + cli.stop(); + }; + + int ret = cli.send(&mail); + if (ret != 0) { + printf("send start failed: %d\n", ret); + mail_clear(&mail); + return ret; + } + cli.run(); // block until stopped + mail_clear(&mail); + return 0; +} diff --git a/mail/imap_client.c b/mail/imap_client.c new file mode 100644 index 000000000..2294c1072 --- /dev/null +++ b/mail/imap_client.c @@ -0,0 +1,481 @@ +#include "imap_client.h" + +#include +#include +#include + +#include "hbase.h" +#include "hlog.h" +#include "herr.h" +#include "hsocket.h" + +// IMAP fetch state machine +enum imap_state { + IMAP_ST_INIT = 0, + IMAP_ST_GREETING, // wait untagged * OK + IMAP_ST_LOGIN, // wait tagged OK + IMAP_ST_SELECT, // wait tagged OK + IMAP_ST_SEARCH, // wait * SEARCH ... + tagged OK + IMAP_ST_FETCH, // wait literal message + tagged OK (per id) + IMAP_ST_LOGOUT, // wait tagged OK + IMAP_ST_DONE, +}; + +#define IMAP_MAX_IDS 4096 + +struct imap_client_s { + char host[256]; + int port; + int connect_timeout; + unsigned char ssl: 1; + unsigned char alloced_ssl_ctx: 1; + unsigned char is_loop_owner: 1; + char username[128]; + char password[128]; + char mailbox[128]; + char criteria[128]; + // state + int state; + int tag; // current command tag number + // search result ids + int* ids; + int id_count; + int id_index; // current fetch index + // recv accumulation buffer (plain C growable buffer) + char* recvbuf; + size_t recvcap; + size_t recvlen; + // callbacks + imap_mail_cb mail_cb; + imap_done_cb done_cb; + int done_called; + void* userdata; + // io + hloop_t* loop; + hio_t* io; + htimer_t* timer; + hssl_ctx_t ssl_ctx; + hmutex_t mutex_; +}; + +static void imap_fetch_next(imap_client_t* cli); +static void imap_process(imap_client_t* cli); + +static void imap_done(imap_client_t* cli, int code, const char* msg) { + if (cli->done_cb && !cli->done_called) { + cli->done_called = 1; + cli->done_cb(cli, code, msg); + } +} + +static int imap_write(imap_client_t* cli, const char* buf, int len) { + hmutex_lock(&cli->mutex_); + int nwrite = cli->io ? hio_write(cli->io, buf, len) : -1; + hmutex_unlock(&cli->mutex_); + return nwrite; +} + +// send "aNNN \r\n" +static int imap_send_cmd(imap_client_t* cli, const char* cmd) { + char buf[512]; + int len = snprintf(buf, sizeof(buf), "a%03d %s\r\n", ++cli->tag, cmd); + return imap_write(cli, buf, len); +} + +// build current tag prefix "aNNN " +static int imap_tag_prefix(imap_client_t* cli, char* out, int outlen) { + return snprintf(out, outlen, "a%03d ", cli->tag); +} + +// check whether buffer [data,data+len) contains the tagged final response for +// the current tag. Sets *ok to 1 if "aNNN OK", 0 if NO/BAD. Returns pointer to +// the byte just after that line's CRLF, or NULL if not present yet. +static char* find_tagged_response(imap_client_t* cli, char* data, size_t len, int* ok) { + char prefix[16]; + int plen = imap_tag_prefix(cli, prefix, sizeof(prefix)); + char* p = data; + char* end = data + len; + while (p < end) { + char* nl = (char*)memchr(p, '\n', end - p); + int linelen = nl ? (int)(nl - p) : (int)(end - p); + if (linelen >= plen && strncmp(p, prefix, plen) == 0) { + const char* status = p + plen; + if (strnicmp(status, "OK", 2) == 0) *ok = 1; + else *ok = 0; // NO / BAD + return nl ? nl + 1 : end; + } + if (nl == NULL) break; + p = nl + 1; + } + return NULL; +} + +// parse "* SEARCH 1 2 3\r\n" ids into cli->ids +static void parse_search(imap_client_t* cli, char* data, size_t len) { + char* p = data; + char* end = data + len; + while (p < end) { + char* nl = (char*)memchr(p, '\n', end - p); + int linelen = nl ? (int)(nl - p) : (int)(end - p); + if (linelen >= 8 && strncmp(p, "* SEARCH", 8) == 0) { + const char* q = p + 8; + const char* le = p + linelen; + while (q < le) { + while (q < le && (*q < '0' || *q > '9')) q++; + if (q >= le) break; + int id = 0; + while (q < le && *q >= '0' && *q <= '9') { id = id * 10 + (*q - '0'); q++; } + if (cli->id_count < IMAP_MAX_IDS) { + int* np = (int*)realloc(cli->ids, sizeof(int) * (cli->id_count + 1)); + if (np) { cli->ids = np; cli->ids[cli->id_count++] = id; } + } + } + } + if (nl == NULL) break; + p = nl + 1; + } +} + +// For FETCH: response looks like +// * N FETCH (BODY[] {SIZE}\r\n)\r\n +// aNNN OK ... +// We need the literal {SIZE} then SIZE raw bytes. Returns 1 if a full FETCH +// message + tagged OK is available and was processed; 0 if need more data. +static int try_process_fetch(imap_client_t* cli, char* data, size_t len) { + // find "{SIZE}\r\n" + char* brace = (char*)memchr(data, '{', len); + if (brace == NULL) { + // maybe no literal (e.g. empty) — check for tagged response to finish + int ok = 0; + char* after = find_tagged_response(cli, data, len, &ok); + if (after) { + // consume everything up to tagged line; no mail parsed + size_t consumed = after - data; + memmove(data, after, len - consumed); + cli->recvlen = len - consumed; + return 1; + } + return 0; + } + char* end = data + len; + // parse size + char* q = brace + 1; + long size = 0; + while (q < end && *q >= '0' && *q <= '9') { size = size * 10 + (*q - '0'); q++; } + if (q >= end || *q != '}') return 0; // incomplete literal header + // skip "}\r\n" + q++; + if (q < end && *q == '\r') q++; + if (q >= end || *q != '\n') return 0; // incomplete + q++; + // need `size` bytes of message + if ((long)(end - q) < size) return 0; // wait for more + char* msg = q; + // parse the message via MIME + mail_t mail; + memset(&mail, 0, sizeof(mail)); + if (mime_parse(msg, (size_t)size, &mail) == 0) { + if (cli->mail_cb) cli->mail_cb(cli, &mail); + } + mail_clear(&mail); + + // after literal: expect ")\r\n" then tagged "aNNN OK" + char* rest = msg + size; + int ok = 0; + char* after = find_tagged_response(cli, rest, end - rest, &ok); + if (after == NULL) { + // tagged response not fully received yet; keep the tail from rest + size_t consumed = rest - data; + memmove(data, rest, len - consumed); + cli->recvlen = len - consumed; + return 0; // wait for tagged OK + } + // fully consumed this fetch + size_t consumed = after - data; + memmove(data, after, len - consumed); + cli->recvlen = len - consumed; + return 1; +} + +static void imap_process(imap_client_t* cli) { + char* data = cli->recvbuf; + size_t len = cli->recvlen; + int ok = 0; + + switch (cli->state) { + case IMAP_ST_GREETING: { + // wait for untagged "* OK" + char* nl = (char*)memchr(data, '\n', len); + if (nl == NULL) return; + if (len >= 4 && strncmp(data, "* OK", 4) != 0) { + imap_done(cli, ERR_RESPONSE, "no IMAP greeting"); + hio_close(cli->io); + return; + } + // consume greeting line + size_t consumed = nl - data + 1; + memmove(data, nl + 1, len - consumed); + cli->recvlen = len - consumed; + // LOGIN + cli->state = IMAP_ST_LOGIN; + char cmd[384]; + snprintf(cmd, sizeof(cmd), "LOGIN %s %s", cli->username, cli->password); + imap_send_cmd(cli, cmd); + break; + } + case IMAP_ST_LOGIN: { + char* after = find_tagged_response(cli, data, len, &ok); + if (after == NULL) return; + if (!ok) { imap_done(cli, ERR_RESPONSE, "IMAP LOGIN failed"); hio_close(cli->io); return; } + cli->recvlen = 0; // discard + cli->state = IMAP_ST_SELECT; + char cmd[256]; + snprintf(cmd, sizeof(cmd), "SELECT %s", cli->mailbox); + imap_send_cmd(cli, cmd); + break; + } + case IMAP_ST_SELECT: { + char* after = find_tagged_response(cli, data, len, &ok); + if (after == NULL) return; + if (!ok) { imap_done(cli, ERR_RESPONSE, "IMAP SELECT failed"); hio_close(cli->io); return; } + cli->recvlen = 0; + cli->state = IMAP_ST_SEARCH; + char cmd[256]; + snprintf(cmd, sizeof(cmd), "SEARCH %s", cli->criteria); + imap_send_cmd(cli, cmd); + break; + } + case IMAP_ST_SEARCH: { + char* after = find_tagged_response(cli, data, len, &ok); + if (after == NULL) return; + if (!ok) { imap_done(cli, ERR_RESPONSE, "IMAP SEARCH failed"); hio_close(cli->io); return; } + parse_search(cli, data, after - data); + cli->recvlen = 0; + cli->id_index = 0; + cli->state = IMAP_ST_FETCH; + imap_fetch_next(cli); + break; + } + case IMAP_ST_FETCH: { + if (try_process_fetch(cli, data, len)) { + // this id done; fetch next or logout + cli->id_index++; + imap_fetch_next(cli); + } + break; + } + case IMAP_ST_LOGOUT: { + char* after = find_tagged_response(cli, data, len, &ok); + if (after == NULL) return; + cli->recvlen = 0; + cli->state = IMAP_ST_DONE; + imap_done(cli, 0, "OK"); + hio_close(cli->io); + break; + } + default: + break; + } +} + +static void imap_fetch_next(imap_client_t* cli) { + if (cli->id_index >= cli->id_count) { + // all fetched -> LOGOUT + cli->state = IMAP_ST_LOGOUT; + cli->recvlen = 0; + imap_send_cmd(cli, "LOGOUT"); + return; + } + int id = cli->ids[cli->id_index]; + char cmd[64]; + snprintf(cmd, sizeof(cmd), "FETCH %d BODY[]", id); + imap_send_cmd(cli, cmd); +} + +static void on_recv(hio_t* io, void* buf, int len) { + imap_client_t* cli = (imap_client_t*)hevent_userdata(io); + if (cli == NULL) return; + + // accumulate into recvbuf (IMAP responses/literals may span reads) + size_t need = cli->recvlen + len + 1; + if (need > cli->recvcap) { + size_t newcap = cli->recvcap ? cli->recvcap : 8192; + while (newcap < need) newcap *= 2; + char* np = (char*)realloc(cli->recvbuf, newcap); + if (np == NULL) return; + cli->recvbuf = np; + cli->recvcap = newcap; + } + memcpy(cli->recvbuf + cli->recvlen, buf, len); + cli->recvlen += len; + cli->recvbuf[cli->recvlen] = '\0'; + + // drive the state machine; loop while progress can be made in FETCH + size_t prev; + do { + prev = cli->recvlen; + imap_process(cli); + } while (cli->state == IMAP_ST_FETCH && cli->recvlen != prev && cli->recvlen > 0); +} + +static void connect_timeout_cb(htimer_t* timer) { + imap_client_t* cli = (imap_client_t*)hevent_userdata(timer); + if (cli == NULL) return; + cli->timer = NULL; + if (cli->io == NULL) return; + hlogw("imap connect timeout %s:%d", cli->host, cli->port); + imap_done(cli, ERR_TASK_TIMEOUT, "connect timeout"); + hio_close(cli->io); +} + +static void on_connect(hio_t* io) { + imap_client_t* cli = (imap_client_t*)hevent_userdata(io); + if (cli == NULL) return; + if (cli->timer) { + htimer_del(cli->timer); + cli->timer = NULL; + } + cli->state = IMAP_ST_GREETING; + hio_setcb_read(io, on_recv); + hio_read(io); +} + +static void on_close(hio_t* io) { + imap_client_t* cli = (imap_client_t*)hevent_userdata(io); + if (cli == NULL) return; + if (!cli->done_called) { + imap_done(cli, ERR_CONNECT, "connection closed"); + } + cli->io = NULL; +} + +imap_client_t* imap_client_new(hloop_t* loop) { + int is_loop_owner = (loop == NULL); + if (loop == NULL) { + loop = hloop_new(HLOOP_FLAG_AUTO_FREE); + if (loop == NULL) return NULL; + } + imap_client_t* cli = NULL; + HV_ALLOC_SIZEOF(cli); + if (cli == NULL) return NULL; + cli->loop = loop; + cli->is_loop_owner = is_loop_owner; + cli->port = DEFAULT_IMAPS_PORT; + cli->ssl = 1; + hv_strncpy(cli->mailbox, "INBOX", sizeof(cli->mailbox)); + hv_strncpy(cli->criteria, "ALL", sizeof(cli->criteria)); + hmutex_init(&cli->mutex_); + return cli; +} + +void imap_client_free(imap_client_t* cli) { + if (!cli) return; + if (cli->timer) { + hevent_set_userdata(cli->timer, NULL); + htimer_del(cli->timer); + cli->timer = NULL; + } + if (cli->io) { + hevent_set_userdata(cli->io, NULL); + hio_setcb_close(cli->io, NULL); + hio_close(cli->io); + cli->io = NULL; + } + hmutex_destroy(&cli->mutex_); + if (cli->ssl_ctx && cli->alloced_ssl_ctx) { + hssl_ctx_free(cli->ssl_ctx); + cli->ssl_ctx = NULL; + } + free(cli->ids); + free(cli->recvbuf); + HV_FREE(cli); +} + +void imap_client_run(imap_client_t* cli) { + if (!cli || !cli->loop) return; + if (!cli->is_loop_owner) return; + hloop_run(cli->loop); + cli->loop = NULL; + cli->io = NULL; + cli->timer = NULL; +} + +void imap_client_stop(imap_client_t* cli) { + if (!cli || !cli->loop) return; + if (!cli->is_loop_owner) return; + hloop_stop(cli->loop); +} + +void imap_client_set_auth(imap_client_t* cli, const char* username, const char* password) { + if (!cli) return; + if (username) hv_strncpy(cli->username, username, sizeof(cli->username)); + if (password) hv_strncpy(cli->password, password, sizeof(cli->password)); +} + +void imap_client_set_mail_callback(imap_client_t* cli, imap_mail_cb cb) { + if (cli) cli->mail_cb = cb; +} +void imap_client_set_done_callback(imap_client_t* cli, imap_done_cb cb) { + if (cli) cli->done_cb = cb; +} +void imap_client_set_userdata(imap_client_t* cli, void* userdata) { + if (cli) cli->userdata = userdata; +} +void* imap_client_get_userdata(imap_client_t* cli) { + return cli ? cli->userdata : NULL; +} + +int imap_client_set_ssl_ctx(imap_client_t* cli, hssl_ctx_t ssl_ctx) { + cli->ssl_ctx = ssl_ctx; + return 0; +} +int imap_client_new_ssl_ctx(imap_client_t* cli, hssl_ctx_opt_t* opt) { + opt->endpoint = HSSL_CLIENT; + hssl_ctx_t ssl_ctx = hssl_ctx_new(opt); + if (ssl_ctx == NULL) return ERR_NEW_SSL_CTX; + cli->alloced_ssl_ctx = 1; + return imap_client_set_ssl_ctx(cli, ssl_ctx); +} + +void imap_client_set_connect_timeout(imap_client_t* cli, int ms) { + if (cli) cli->connect_timeout = ms; +} + +void imap_client_set_host(imap_client_t* cli, const char* host, int port, int ssl) { + if (!cli) return; + hv_strncpy(cli->host, host, sizeof(cli->host)); + cli->port = port; + cli->ssl = ssl ? 1 : 0; +} + +int imap_client_fetch(imap_client_t* cli, const char* mailbox, const char* criteria) { + if (!cli) return -1; + if (!cli->host[0] || !cli->username[0]) return ERR_INVALID_PARAM; + if (mailbox) hv_strncpy(cli->mailbox, mailbox, sizeof(cli->mailbox)); + if (criteria) hv_strncpy(cli->criteria, criteria, sizeof(cli->criteria)); + + cli->state = IMAP_ST_INIT; + cli->tag = 0; + cli->done_called = 0; + cli->id_count = 0; + cli->id_index = 0; + free(cli->ids); + cli->ids = NULL; + cli->recvlen = 0; + + hio_t* io = hio_create_socket(cli->loop, cli->host, cli->port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); + if (io == NULL) return ERR_SOCKET; + if (cli->ssl) { + if (cli->ssl_ctx) hio_set_ssl_ctx(io, cli->ssl_ctx); + hio_enable_ssl(io); + } + cli->io = io; + hevent_set_userdata(io, cli); + hio_setcb_connect(io, on_connect); + hio_setcb_close(io, on_close); + if (cli->connect_timeout > 0) { + cli->timer = htimer_add(cli->loop, connect_timeout_cb, cli->connect_timeout, 1); + hevent_set_userdata(cli->timer, cli); + } + return hio_connect(io) < 0 ? ERR_SOCKET : 0; +} diff --git a/mail/imap_client.h b/mail/imap_client.h new file mode 100644 index 000000000..20e73a8bb --- /dev/null +++ b/mail/imap_client.h @@ -0,0 +1,116 @@ +#ifndef HV_IMAP_CLIENT_H_ +#define HV_IMAP_CLIENT_H_ + +#include "hloop.h" +#include "hssl.h" +#include "hmutex.h" +#include "mime.h" + +#define DEFAULT_IMAP_PORT 143 +#define DEFAULT_IMAPS_PORT 993 + +typedef struct imap_client_s imap_client_t; + +// per-mail callback: called once for each fetched message. +// mail is owned by the client and valid only during the callback. +typedef void (*imap_mail_cb)(imap_client_t* cli, mail_t* mail); +// done/result callback: called once when fetch finishes or fails. +// @param code: 0 success; <0 libhv ERR_*; >0 not used +typedef void (*imap_done_cb)(imap_client_t* cli, int code, const char* msg); + +BEGIN_EXTERN_C + +HV_EXPORT imap_client_t* imap_client_new(hloop_t* loop DEFAULT(NULL)); +HV_EXPORT void imap_client_run (imap_client_t* cli); +HV_EXPORT void imap_client_stop(imap_client_t* cli); +HV_EXPORT void imap_client_free(imap_client_t* cli); + +HV_EXPORT void imap_client_set_auth(imap_client_t* cli, + const char* username, const char* password); +HV_EXPORT void imap_client_set_mail_callback(imap_client_t* cli, imap_mail_cb cb); +HV_EXPORT void imap_client_set_done_callback(imap_client_t* cli, imap_done_cb cb); +HV_EXPORT void imap_client_set_userdata(imap_client_t* cli, void* userdata); +HV_EXPORT void* imap_client_get_userdata(imap_client_t* cli); + +HV_EXPORT int imap_client_set_ssl_ctx(imap_client_t* cli, hssl_ctx_t ssl_ctx); +HV_EXPORT int imap_client_new_ssl_ctx(imap_client_t* cli, hssl_ctx_opt_t* opt); + +HV_EXPORT void imap_client_set_connect_timeout(imap_client_t* cli, int ms); +HV_EXPORT void imap_client_set_host(imap_client_t* cli, const char* host, int port, int ssl); + +// fetch: LOGIN -> SELECT mailbox -> SEARCH criteria -> FETCH each -> LOGOUT. +// @param mailbox: e.g. "INBOX" +// @param criteria: IMAP SEARCH criteria, e.g. "ALL", "UNSEEN" +// @retval 0 started ok, <0 error +HV_EXPORT int imap_client_fetch(imap_client_t* cli, const char* mailbox, const char* criteria); + +END_EXTERN_C + +#ifdef __cplusplus + +#include +#include + +namespace hv { + +// @usage examples/mail/recvmail_test.cpp +class ImapClient { +public: + imap_client_t* client; + typedef std::function MailCallback; + typedef std::function DoneCallback; + MailCallback onMail; + DoneCallback onDone; + + ImapClient(hloop_t* loop = NULL) { + client = imap_client_new(loop); + } + ~ImapClient() { + if (client) { + imap_client_free(client); + client = NULL; + } + } + + void setHost(const char* host, int port = DEFAULT_IMAPS_PORT, bool ssl = true) { + imap_client_set_host(client, host, port, ssl ? 1 : 0); + } + void setAuth(const char* username, const char* password) { + imap_client_set_auth(client, username, password); + } + void setConnectTimeout(int ms) { + imap_client_set_connect_timeout(client, ms); + } + int setSslCtx(hssl_ctx_t ssl_ctx) { + return imap_client_set_ssl_ctx(client, ssl_ctx); + } + int newSslCtx(hssl_ctx_opt_t* opt) { + return imap_client_new_ssl_ctx(client, opt); + } + + int fetch(const char* mailbox = "INBOX", const char* criteria = "ALL") { + imap_client_set_mail_callback(client, on_mail); + imap_client_set_done_callback(client, on_done); + imap_client_set_userdata(client, this); + return imap_client_fetch(client, mailbox, criteria); + } + + void run() { imap_client_run(client); } + void stop() { imap_client_stop(client); } + +private: + static void on_mail(imap_client_t* cli, mail_t* mail) { + ImapClient* self = (ImapClient*)imap_client_get_userdata(cli); + if (self && self->onMail) self->onMail(self, mail); + } + static void on_done(imap_client_t* cli, int code, const char* msg) { + ImapClient* self = (ImapClient*)imap_client_get_userdata(cli); + if (self && self->onDone) self->onDone(self, code, msg ? msg : ""); + } +}; + +} + +#endif // __cplusplus + +#endif // HV_IMAP_CLIENT_H_ diff --git a/mail/mime.c b/mail/mime.c new file mode 100644 index 000000000..bd8c6f90b --- /dev/null +++ b/mail/mime.c @@ -0,0 +1,574 @@ +#include "mime.h" + +#include +#include +#include +#include + +#include "hbase.h" +#include "base64.h" + +// ---- small helpers ---- + +static char* mime_strndup(const char* s, size_t n) { + char* p = (char*)malloc(n + 1); + if (p == NULL) return NULL; + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static char* mime_strdup(const char* s) { + return s ? mime_strndup(s, strlen(s)) : NULL; +} + +// growable buffer for message assembly +typedef struct { + char* base; + size_t len; + size_t cap; +} membuf_t; + +static int membuf_ensure(membuf_t* buf, size_t need) { + if (buf->len + need <= buf->cap) return 0; + size_t newcap = buf->cap ? buf->cap * 2 : 4096; + while (newcap < buf->len + need) newcap *= 2; + char* p = (char*)realloc(buf->base, newcap); + if (p == NULL) return -1; + buf->base = p; + buf->cap = newcap; + return 0; +} + +static int membuf_append(membuf_t* buf, const char* data, size_t len) { + if (membuf_ensure(buf, len) != 0) return -1; + memcpy(buf->base + buf->len, data, len); + buf->len += len; + return 0; +} + +static int membuf_puts(membuf_t* buf, const char* s) { + return membuf_append(buf, s, strlen(s)); +} + +// ---- codecs ---- + +int mime_qp_decode(const char* in, int inlen, char* out) { + int o = 0; + for (int i = 0; i < inlen; ++i) { + char c = in[i]; + if (c == '=' && i + 2 < inlen) { + if (in[i + 1] == '\r' && in[i + 2] == '\n') { + // soft line break + i += 2; + } else { + char hex[3] = { in[i + 1], in[i + 2], 0 }; + out[o++] = (char)strtol(hex, NULL, 16); + i += 2; + } + } else if (c == '=' && i + 1 < inlen && in[i + 1] == '\n') { + i += 1; // soft line break (LF only) + } else { + out[o++] = c; + } + } + return o; +} + +int mime_encode_word(const char* in, int inlen, char* out, int outlen) { + // "=?utf-8?B??=" + int need = 12 + BASE64_ENCODE_OUT_SIZE(inlen) + 1; + if (outlen < need) return -1; + int n = snprintf(out, outlen, "=?utf-8?B?"); + n += hv_base64_encode((const unsigned char*)in, inlen, out + n); + n += snprintf(out + n, outlen - n, "?="); + return n; +} + +// Encode a header value: if it contains non-ASCII, use RFC 2047 encoded-word. +static void append_header_value(membuf_t* buf, const char* value) { + int has_non_ascii = 0; + for (const char* p = value; *p; ++p) { + if ((unsigned char)*p >= 0x80) { has_non_ascii = 1; break; } + } + if (!has_non_ascii) { + membuf_puts(buf, value); + return; + } + int inlen = (int)strlen(value); + int outlen = 16 + BASE64_ENCODE_OUT_SIZE(inlen); + char* enc = (char*)malloc(outlen); + if (enc == NULL) { membuf_puts(buf, value); return; } + int n = mime_encode_word(value, inlen, enc, outlen); + if (n > 0) membuf_append(buf, enc, n); + else membuf_puts(buf, value); + free(enc); +} + +// append "name " or "addr" +static void append_addr(membuf_t* buf, const mail_addr_t* addr) { + if (addr->name && addr->name[0]) { + append_header_value(buf, addr->name); + membuf_puts(buf, " <"); + membuf_puts(buf, addr->addr ? addr->addr : ""); + membuf_puts(buf, ">"); + } else { + membuf_puts(buf, addr->addr ? addr->addr : ""); + } +} + +static void append_addr_list(membuf_t* buf, const char* header, + const mail_addr_t* addrs, int count) { + if (count <= 0) return; + membuf_puts(buf, header); + membuf_puts(buf, ": "); + for (int i = 0; i < count; ++i) { + if (i) membuf_puts(buf, ", "); + append_addr(buf, &addrs[i]); + } + membuf_puts(buf, "\r\n"); +} + +// ---- mail_t builders ---- + +void mail_set_from(mail_t* mail, const char* addr, const char* name) { + free(mail->from.addr); + free(mail->from.name); + mail->from.addr = mime_strdup(addr); + mail->from.name = mime_strdup(name); +} + +static void add_addr(mail_addr_t** list, int* count, const char* addr, const char* name) { + mail_addr_t* p = (mail_addr_t*)realloc(*list, sizeof(mail_addr_t) * (*count + 1)); + if (p == NULL) return; + *list = p; + p[*count].addr = mime_strdup(addr); + p[*count].name = mime_strdup(name); + (*count)++; +} + +void mail_add_to(mail_t* mail, const char* addr, const char* name) { + add_addr(&mail->to, &mail->to_count, addr, name); +} + +void mail_add_cc(mail_t* mail, const char* addr, const char* name) { + add_addr(&mail->cc, &mail->cc_count, addr, name); +} + +void mail_set_subject(mail_t* mail, const char* subject) { + free(mail->subject); + mail->subject = mime_strdup(subject); +} + +void mail_set_text(mail_t* mail, const char* text) { + free(mail->text_body); + mail->text_body = mime_strdup(text); +} + +void mail_set_html(mail_t* mail, const char* html) { + free(mail->html_body); + mail->html_body = mime_strdup(html); +} + +void mail_add_attachment(mail_t* mail, const char* filename, + const char* content_type, + const void* data, size_t size) { + mail_attachment_t* p = (mail_attachment_t*)realloc( + mail->attachments, sizeof(mail_attachment_t) * (mail->attachment_count + 1)); + if (p == NULL) return; + mail->attachments = p; + mail_attachment_t* a = &p[mail->attachment_count]; + a->filename = mime_strdup(filename); + a->content_type = mime_strdup(content_type); + a->data = malloc(size); + if (a->data) memcpy(a->data, data, size); + a->size = a->data ? size : 0; + mail->attachment_count++; +} + +void mail_clear(mail_t* mail) { + if (mail == NULL) return; + free(mail->from.addr); + free(mail->from.name); + for (int i = 0; i < mail->to_count; ++i) { + free(mail->to[i].addr); + free(mail->to[i].name); + } + free(mail->to); + for (int i = 0; i < mail->cc_count; ++i) { + free(mail->cc[i].addr); + free(mail->cc[i].name); + } + free(mail->cc); + free(mail->subject); + free(mail->text_body); + free(mail->html_body); + for (int i = 0; i < mail->attachment_count; ++i) { + free(mail->attachments[i].filename); + free(mail->attachments[i].content_type); + free(mail->attachments[i].data); + } + free(mail->attachments); + free(mail->date); + memset(mail, 0, sizeof(mail_t)); +} + +// ---- assembly ---- + +static const char* guess_content_type(const char* filename) { + if (filename == NULL) return "application/octet-stream"; + const char* dot = strrchr(filename, '.'); + if (dot == NULL) return "application/octet-stream"; + dot++; + if (stricmp(dot, "txt") == 0) return "text/plain"; + if (stricmp(dot, "html") == 0) return "text/html"; + if (stricmp(dot, "htm") == 0) return "text/html"; + if (stricmp(dot, "jpg") == 0 || stricmp(dot, "jpeg") == 0) return "image/jpeg"; + if (stricmp(dot, "png") == 0) return "image/png"; + if (stricmp(dot, "gif") == 0) return "image/gif"; + if (stricmp(dot, "pdf") == 0) return "application/pdf"; + if (stricmp(dot, "zip") == 0) return "application/zip"; + if (stricmp(dot, "json") == 0) return "application/json"; + return "application/octet-stream"; +} + +// append base64 of data, wrapped at 76 chars per line +static void append_base64_wrapped(membuf_t* buf, const void* data, size_t size) { + if (size == 0) return; + int encoded_size = BASE64_ENCODE_OUT_SIZE(size); + char* enc = (char*)malloc(encoded_size + 1); + if (enc == NULL) return; + int n = hv_base64_encode((const unsigned char*)data, (unsigned int)size, enc); + for (int i = 0; i < n; i += 76) { + int line = (n - i) < 76 ? (n - i) : 76; + membuf_append(buf, enc + i, line); + membuf_puts(buf, "\r\n"); + } + free(enc); +} + +static void append_common_headers(membuf_t* buf, const mail_t* mail) { + // From / To / Cc + membuf_puts(buf, "From: "); + append_addr(buf, &mail->from); + membuf_puts(buf, "\r\n"); + append_addr_list(buf, "To", mail->to, mail->to_count); + append_addr_list(buf, "Cc", mail->cc, mail->cc_count); + // Subject + membuf_puts(buf, "Subject: "); + append_header_value(buf, mail->subject ? mail->subject : ""); + membuf_puts(buf, "\r\n"); + membuf_puts(buf, "MIME-Version: 1.0\r\n"); +} + +char* mime_build(const mail_t* mail) { + membuf_t buf; + memset(&buf, 0, sizeof(buf)); + + int has_attach = mail->attachment_count > 0; + int has_html = mail->html_body && mail->html_body[0]; + int has_text = mail->text_body && mail->text_body[0]; + + append_common_headers(&buf, mail); + + if (!has_attach && !has_html) { + // simple text/plain + membuf_puts(&buf, "Content-Type: text/plain; charset=utf-8\r\n\r\n"); + membuf_puts(&buf, has_text ? mail->text_body : ""); + membuf_puts(&buf, "\r\n"); + } else { + char boundary[64]; + snprintf(boundary, sizeof(boundary), "----=_libhv_%p_%d", (void*)mail, (int)buf.len); + char altboundary[72]; + snprintf(altboundary, sizeof(altboundary), "%s_alt", boundary); + + membuf_puts(&buf, "Content-Type: multipart/mixed; boundary=\""); + membuf_puts(&buf, boundary); + membuf_puts(&buf, "\"\r\n\r\n"); + + // body part (text + optional html as multipart/alternative) + membuf_puts(&buf, "--"); membuf_puts(&buf, boundary); membuf_puts(&buf, "\r\n"); + if (has_html && has_text) { + membuf_puts(&buf, "Content-Type: multipart/alternative; boundary=\""); + membuf_puts(&buf, altboundary); + membuf_puts(&buf, "\"\r\n\r\n"); + membuf_puts(&buf, "--"); membuf_puts(&buf, altboundary); membuf_puts(&buf, "\r\n"); + membuf_puts(&buf, "Content-Type: text/plain; charset=utf-8\r\n\r\n"); + membuf_puts(&buf, mail->text_body); + membuf_puts(&buf, "\r\n--"); membuf_puts(&buf, altboundary); membuf_puts(&buf, "\r\n"); + membuf_puts(&buf, "Content-Type: text/html; charset=utf-8\r\n\r\n"); + membuf_puts(&buf, mail->html_body); + membuf_puts(&buf, "\r\n--"); membuf_puts(&buf, altboundary); membuf_puts(&buf, "--\r\n"); + } else if (has_html) { + membuf_puts(&buf, "Content-Type: text/html; charset=utf-8\r\n\r\n"); + membuf_puts(&buf, mail->html_body); + membuf_puts(&buf, "\r\n"); + } else { + membuf_puts(&buf, "Content-Type: text/plain; charset=utf-8\r\n\r\n"); + membuf_puts(&buf, has_text ? mail->text_body : ""); + membuf_puts(&buf, "\r\n"); + } + + // attachments + for (int i = 0; i < mail->attachment_count; ++i) { + const mail_attachment_t* a = &mail->attachments[i]; + const char* ct = (a->content_type && a->content_type[0]) + ? a->content_type : guess_content_type(a->filename); + membuf_puts(&buf, "--"); membuf_puts(&buf, boundary); membuf_puts(&buf, "\r\n"); + membuf_puts(&buf, "Content-Type: "); + membuf_puts(&buf, ct); + membuf_puts(&buf, "\r\n"); + membuf_puts(&buf, "Content-Transfer-Encoding: base64\r\n"); + membuf_puts(&buf, "Content-Disposition: attachment; filename=\""); + membuf_puts(&buf, a->filename ? a->filename : "attachment"); + membuf_puts(&buf, "\"\r\n\r\n"); + append_base64_wrapped(&buf, a->data, a->size); + } + + membuf_puts(&buf, "--"); membuf_puts(&buf, boundary); membuf_puts(&buf, "--\r\n"); + } + + // NUL-terminate for convenience (not counted in a length; caller uses strlen) + membuf_append(&buf, "", 1); + return buf.base; +} + +// ---- parsing ---- + +// case-insensitive header field lookup within [start, end); returns value start +// (after "name:") and sets *val_len to value length (trimmed, single line + +// folded continuations collapsed is not done here — value is up to CRLF). +static const char* find_header(const char* start, const char* end, + const char* name, int* val_len) { + size_t namelen = strlen(name); + const char* p = start; + while (p < end) { + const char* line_end = p; + while (line_end < end && *line_end != '\n') line_end++; + if ((size_t)(line_end - p) > namelen && + strnicmp(p, name, namelen) == 0 && p[namelen] == ':') { + const char* v = p + namelen + 1; + while (v < line_end && (*v == ' ' || *v == '\t')) v++; + const char* ve = line_end; + if (ve > v && ve[-1] == '\r') ve--; + *val_len = (int)(ve - v); + return v; + } + // empty line => end of headers + if (p == line_end || (p + 1 == line_end && *p == '\r')) break; + p = line_end + 1; + } + *val_len = 0; + return NULL; +} + +static char* dup_header(const char* start, const char* end, const char* name) { + int len = 0; + const char* v = find_header(start, end, name, &len); + if (v == NULL || len <= 0) return NULL; + return mime_strndup(v, len); +} + +// find end of headers (double CRLF); returns pointer to body start, or end. +static const char* find_body(const char* start, const char* end) { + for (const char* p = start; p + 1 < end; ++p) { + if (p[0] == '\n' && p[1] == '\n') return p + 2; + if (p + 3 < end && p[0] == '\r' && p[1] == '\n' && p[2] == '\r' && p[3] == '\n') + return p + 4; + } + return end; +} + +// extract boundary="xxx" from a Content-Type value +static int extract_boundary(const char* ct, int ctlen, char* out, int outlen) { + const char* p = ct; + const char* end = ct + ctlen; + while (p < end) { + if (strnicmp(p, "boundary", 8) == 0) { + p += 8; + while (p < end && (*p == ' ' || *p == '=')) p++; + int quoted = 0; + if (p < end && *p == '"') { quoted = 1; p++; } + const char* b = p; + while (p < end && (quoted ? *p != '"' : (*p != ';' && *p != ' ' && *p != '\r' && *p != '\n'))) p++; + int len = (int)(p - b); + if (len <= 0 || len >= outlen) return -1; + memcpy(out, b, len); + out[len] = '\0'; + return 0; + } + p++; + } + return -1; +} + +// decode a body part according to Content-Transfer-Encoding into a heap buffer. +static char* decode_part_body(const char* body, int bodylen, + const char* enc, int enclen) { + if (enc && enclen >= 6 && strnicmp(enc, "base64", 6) == 0) { + int out_size = BASE64_DECODE_OUT_SIZE(bodylen) + 1; + char* out = (char*)malloc(out_size); + if (out == NULL) return NULL; + int n = hv_base64_decode(body, bodylen, (unsigned char*)out); + if (n < 0) n = 0; + out[n] = '\0'; + return out; + } + if (enc && enclen >= 16 && strnicmp(enc, "quoted-printable", 16) == 0) { + char* out = (char*)malloc(bodylen + 1); + if (out == NULL) return NULL; + int n = mime_qp_decode(body, bodylen, out); + out[n] = '\0'; + return out; + } + // 7bit / 8bit / none + return mime_strndup(body, bodylen); +} + +// parse a single part [start,end); fill text/html/attachment on mail. +static void parse_part(const char* start, const char* end, mail_t* mail); + +// parse a multipart body given its boundary. +static void parse_multipart(const char* body, const char* end, + const char* boundary, mail_t* mail) { + char delim[80]; + snprintf(delim, sizeof(delim), "--%s", boundary); + size_t delimlen = strlen(delim); + + const char* p = body; + // find first boundary + while (p < end) { + if ((size_t)(end - p) >= delimlen && strncmp(p, delim, delimlen) == 0) break; + while (p < end && *p != '\n') p++; + if (p < end) p++; + } + while (p < end) { + // p at a boundary line + p += delimlen; + if (p + 1 < end && p[0] == '-' && p[1] == '-') break; // closing boundary + while (p < end && *p != '\n') p++; // skip to end of boundary line + if (p < end) p++; + const char* part_start = p; + // find next boundary + const char* q = p; + const char* next = end; + while (q < end) { + if ((size_t)(end - q) >= delimlen && strncmp(q, delim, delimlen) == 0 && + (q == body || q[-1] == '\n')) { + next = q; + break; + } + while (q < end && *q != '\n') q++; + if (q < end) q++; + } + const char* part_end = next; + // trim trailing CRLF before boundary + if (part_end > part_start && part_end[-1] == '\n') part_end--; + if (part_end > part_start && part_end[-1] == '\r') part_end--; + parse_part(part_start, part_end, mail); + p = next; + } +} + +static void parse_part(const char* start, const char* end, mail_t* mail) { + int ctlen = 0; + const char* ct = find_header(start, end, "Content-Type", &ctlen); + const char* body = find_body(start, end); + + // nested multipart + if (ct && ctlen >= 9 && strnicmp(ct, "multipart", 9) == 0) { + char boundary[64]; + if (extract_boundary(ct, ctlen, boundary, sizeof(boundary)) == 0) { + parse_multipart(body, end, boundary, mail); + } + return; + } + + int enclen = 0; + const char* enc = find_header(start, end, "Content-Transfer-Encoding", &enclen); + int dislen = 0; + const char* dis = find_header(start, end, "Content-Disposition", &dislen); + + int is_attachment = (dis && dislen >= 10 && strnicmp(dis, "attachment", 10) == 0); + + if (is_attachment) { + // filename + char* filename = NULL; + if (dis) { + const char* fp = dis; + const char* de = dis + dislen; + while (fp < de) { + if (strnicmp(fp, "filename", 8) == 0) { + fp += 8; + while (fp < de && (*fp == ' ' || *fp == '=')) fp++; + int quoted = 0; + if (fp < de && *fp == '"') { quoted = 1; fp++; } + const char* fb = fp; + while (fp < de && (quoted ? *fp != '"' : (*fp != ';' && *fp != ' '))) fp++; + filename = mime_strndup(fb, fp - fb); + break; + } + fp++; + } + } + int bodylen = (int)(end - body); + char* decoded = decode_part_body(body, bodylen, enc, enclen); + char* ct_dup = ct ? mime_strndup(ct, ctlen) : NULL; + // strip params after ';' in ct + if (ct_dup) { + char* semi = strchr(ct_dup, ';'); + if (semi) *semi = '\0'; + } + mail_add_attachment(mail, filename ? filename : "attachment", + ct_dup, decoded ? decoded : "", + decoded ? strlen(decoded) : 0); + free(filename); + free(ct_dup); + free(decoded); + return; + } + + // text body + int bodylen = (int)(end - body); + char* decoded = decode_part_body(body, bodylen, enc, enclen); + if (decoded == NULL) return; + int is_html = (ct && ctlen >= 9 && strnicmp(ct, "text/html", 9) == 0); + if (is_html) { + if (mail->html_body == NULL) mail->html_body = decoded; + else free(decoded); + } else { + if (mail->text_body == NULL) mail->text_body = decoded; + else free(decoded); + } +} + +int mime_parse(const char* data, size_t size, mail_t* mail) { + if (data == NULL || mail == NULL) return -1; + const char* start = data; + const char* end = data + size; + + // top-level headers + mail->subject = dup_header(start, end, "Subject"); + mail->date = dup_header(start, end, "Date"); + char* from = dup_header(start, end, "From"); + if (from) { + // naive: take whole From as addr (address extraction kept simple) + mail->from.addr = from; + } + + int ctlen = 0; + const char* ct = find_header(start, end, "Content-Type", &ctlen); + const char* body = find_body(start, end); + + if (ct && ctlen >= 9 && strnicmp(ct, "multipart", 9) == 0) { + char boundary[64]; + if (extract_boundary(ct, ctlen, boundary, sizeof(boundary)) == 0) { + parse_multipart(body, end, boundary, mail); + return 0; + } + } + + // single part + parse_part(start, end, mail); + return 0; +} diff --git a/mail/mime.h b/mail/mime.h new file mode 100644 index 000000000..f620d8b8b --- /dev/null +++ b/mail/mime.h @@ -0,0 +1,79 @@ +#ifndef HV_MAIL_MIME_H_ +#define HV_MAIL_MIME_H_ + +#include +#include "hexport.h" + +/* + * MIME message assembly (for sending) and parsing (for receiving), + * shared by the SMTP and IMAP clients. + * + * NOTE: charset is not transcoded here; parsed text is returned as-is with + * the declared charset noted, to avoid an iconv dependency. + */ + +// email address: "name" +typedef struct mail_addr_s { + char* name; // display name, may be NULL + char* addr; // mailbox address user@host +} mail_addr_t; + +// attachment / message part +typedef struct mail_attachment_s { + char* filename; // attachment filename, may be NULL for inline parts + char* content_type; // e.g. "application/octet-stream"; NULL => guessed by filename + void* data; + size_t size; +} mail_attachment_t; + +// a whole mail (used for both sending and received-parse result) +typedef struct mail_s { + mail_addr_t from; + mail_addr_t* to; int to_count; + mail_addr_t* cc; int cc_count; + char* subject; + char* text_body; // text/plain, may be NULL + char* html_body; // text/html, may be NULL + mail_attachment_t* attachments; int attachment_count; + char* date; // Date header (receive side), may be NULL +} mail_t; + +BEGIN_EXTERN_C + +// ---- helpers for building a mail_t (sending side) ---- +// All setters copy the input; call mail_clear to free. +HV_EXPORT void mail_set_from(mail_t* mail, const char* addr, const char* name); +HV_EXPORT void mail_add_to (mail_t* mail, const char* addr, const char* name); +HV_EXPORT void mail_add_cc (mail_t* mail, const char* addr, const char* name); +HV_EXPORT void mail_set_subject(mail_t* mail, const char* subject); +HV_EXPORT void mail_set_text(mail_t* mail, const char* text); +HV_EXPORT void mail_set_html(mail_t* mail, const char* html); +HV_EXPORT void mail_add_attachment(mail_t* mail, const char* filename, + const char* content_type, + const void* data, size_t size); +// Free everything allocated inside mail (does not free mail itself). +HV_EXPORT void mail_clear(mail_t* mail); + +// ---- assembly (sending side) ---- +// Build the full RFC 5322 / MIME message (headers + body) into a heap string. +// Caller frees the returned buffer with free(). Returns NULL on error. +HV_EXPORT char* mime_build(const mail_t* mail); + +// ---- parsing (receiving side) ---- +// Parse a raw RFC 5322 / MIME message into mail (which must be zeroed first). +// The parsed fields are heap-allocated; free with mail_clear. +// @retval 0 on success, <0 on error. +HV_EXPORT int mime_parse(const char* data, size_t size, mail_t* mail); + +// ---- content-transfer-encoding codecs (exposed for reuse/tests) ---- +// quoted-printable decode; returns decoded length, writes into out (>= inlen). +HV_EXPORT int mime_qp_decode(const char* in, int inlen, char* out); + +// RFC 2047 encoded-word for a header value ("=?utf-8?B?...?="). Returns the +// number of bytes written (excluding the terminating '\0'); out must be large +// enough (BASE64 of in plus a small fixed overhead). +HV_EXPORT int mime_encode_word(const char* in, int inlen, char* out, int outlen); + +END_EXTERN_C + +#endif // HV_MAIL_MIME_H_ diff --git a/mail/smtp_client.c b/mail/smtp_client.c new file mode 100644 index 000000000..655eee88c --- /dev/null +++ b/mail/smtp_client.c @@ -0,0 +1,377 @@ +#include "smtp_client.h" + +#include +#include +#include + +#include "hbase.h" +#include "hlog.h" +#include "herr.h" +#include "hsocket.h" +#include "base64.h" + +// SMTP send state machine +enum smtp_state { + SMTP_ST_INIT = 0, + SMTP_ST_GREETING, // wait 220 + SMTP_ST_EHLO, // wait 250 + SMTP_ST_AUTH, // wait 334 (AUTH LOGIN) + SMTP_ST_AUTH_USER, // wait 334 (username sent) + SMTP_ST_AUTH_PASS, // wait 235 (password sent) + SMTP_ST_MAIL_FROM, // wait 250 + SMTP_ST_RCPT_TO, // wait 250 (one per recipient) + SMTP_ST_DATA, // wait 354 + SMTP_ST_BODY, // wait 250 (after EOB) + SMTP_ST_QUIT, // wait 221 + SMTP_ST_DONE, +}; + +struct smtp_client_s { + char host[256]; + int port; + int connect_timeout; // ms + unsigned char ssl: 1; + unsigned char alloced_ssl_ctx: 1; + unsigned char is_loop_owner: 1; + char username[128]; + char password[128]; + // state + int state; + int rcpt_index; // current recipient being sent + mail_t* mail; // borrowed, not owned + char* message; // built MIME message (owned) + char last_msg[512]; // last server response line + // callback + smtp_client_cb cb; + int cb_called; + void* userdata; + // io + hloop_t* loop; + hio_t* io; + htimer_t* timer; + hssl_ctx_t ssl_ctx; + hmutex_t mutex_; +}; + +static void smtp_send_next(smtp_client_t* cli); + +static void smtp_finish(smtp_client_t* cli, int code, const char* msg) { + if (cli->cb && !cli->cb_called) { + cli->cb_called = 1; + cli->cb(cli, code, msg); + } +} + +static int smtp_write(smtp_client_t* cli, const char* buf, int len) { + hmutex_lock(&cli->mutex_); + int nwrite = cli->io ? hio_write(cli->io, buf, len) : -1; + hmutex_unlock(&cli->mutex_); + return nwrite; +} + +static int smtp_writef(smtp_client_t* cli, const char* fmt, ...) { + char buf[1024]; + va_list ap; + va_start(ap, fmt); + int len = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (len <= 0) return -1; + return smtp_write(cli, buf, len); +} + +// parse leading 3-digit status code; returns -1 if not a complete final line. +// SMTP multiline: "250-..." continuation, "250 ..." final line. +static int smtp_parse_status(const char* data, int len, int* is_final) { + if (len < 4) { *is_final = 0; return -1; } + int code = (data[0]-'0')*100 + (data[1]-'0')*10 + (data[2]-'0'); + *is_final = (data[3] == ' '); + return code; +} + +static void on_recv(hio_t* io, void* buf, int len) { + smtp_client_t* cli = (smtp_client_t*)hevent_userdata(io); + if (cli == NULL) return; + + // A server response may contain multiple lines; only act on the final line + // of the current reply. Scan for the last "NNN " (space after code) line. + const char* data = (const char*)buf; + int is_final = 0; + int code = -1; + const char* line = data; + const char* p = data; + const char* end = data + len; + while (p < end) { + const char* nl = memchr(p, '\n', end - p); + int linelen = nl ? (int)(nl - p) : (int)(end - p); + int fin = 0; + int c = smtp_parse_status(p, linelen, &fin); + if (c >= 0) { + code = c; + is_final = fin; + line = p; + // save last line text + int cpy = linelen < (int)sizeof(cli->last_msg) - 1 ? linelen : (int)sizeof(cli->last_msg) - 1; + memcpy(cli->last_msg, p, cpy); + cli->last_msg[cpy] = '\0'; + } + if (nl == NULL) break; + p = nl + 1; + } + (void)line; + if (code < 0 || !is_final) { + // wait for more / not a final line + return; + } + + // dispatch by state; verify expected code, advance, send next command + switch (cli->state) { + case SMTP_ST_GREETING: + if (code != 220) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + cli->state = SMTP_ST_EHLO; + smtp_writef(cli, "EHLO %s\r\n", cli->host); + break; + case SMTP_ST_EHLO: + if (code != 250) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + if (cli->username[0]) { + cli->state = SMTP_ST_AUTH; + smtp_writef(cli, "AUTH LOGIN\r\n"); + } else { + cli->state = SMTP_ST_MAIL_FROM; + smtp_writef(cli, "MAIL FROM:<%s>\r\n", cli->mail->from.addr); + } + break; + case SMTP_ST_AUTH: + if (code != 334) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + { + char b64[256]; + int n = hv_base64_encode((const unsigned char*)cli->username, strlen(cli->username), b64); + cli->state = SMTP_ST_AUTH_USER; + smtp_writef(cli, "%.*s\r\n", n, b64); + } + break; + case SMTP_ST_AUTH_USER: + if (code != 334) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + { + char b64[256]; + int n = hv_base64_encode((const unsigned char*)cli->password, strlen(cli->password), b64); + cli->state = SMTP_ST_AUTH_PASS; + smtp_writef(cli, "%.*s\r\n", n, b64); + } + break; + case SMTP_ST_AUTH_PASS: + if (code != 235) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + cli->state = SMTP_ST_MAIL_FROM; + smtp_writef(cli, "MAIL FROM:<%s>\r\n", cli->mail->from.addr); + break; + case SMTP_ST_MAIL_FROM: + if (code != 250) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + cli->state = SMTP_ST_RCPT_TO; + cli->rcpt_index = 0; + smtp_send_next(cli); // send first RCPT TO + break; + case SMTP_ST_RCPT_TO: + if (code != 250 && code != 251) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + cli->rcpt_index++; + smtp_send_next(cli); // next RCPT or DATA + break; + case SMTP_ST_DATA: + if (code != 354) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + cli->state = SMTP_ST_BODY; + // send message + end-of-body + smtp_write(cli, cli->message, (int)strlen(cli->message)); + smtp_write(cli, "\r\n.\r\n", 5); + break; + case SMTP_ST_BODY: + if (code != 250) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + cli->state = SMTP_ST_QUIT; + smtp_writef(cli, "QUIT\r\n"); + break; + case SMTP_ST_QUIT: + // 221 expected; report success regardless of QUIT ack + cli->state = SMTP_ST_DONE; + smtp_finish(cli, 250, cli->last_msg); + hio_close(io); + break; + default: + break; + } +} + +// send RCPT TO for cli->rcpt_index (to then cc), or advance to DATA. +static void smtp_send_next(smtp_client_t* cli) { + mail_t* mail = cli->mail; + int idx = cli->rcpt_index; + if (idx < mail->to_count) { + smtp_writef(cli, "RCPT TO:<%s>\r\n", mail->to[idx].addr); + return; + } + idx -= mail->to_count; + if (idx < mail->cc_count) { + smtp_writef(cli, "RCPT TO:<%s>\r\n", mail->cc[idx].addr); + return; + } + // all recipients done -> DATA + cli->state = SMTP_ST_DATA; + smtp_writef(cli, "DATA\r\n"); +} + +static void connect_timeout_cb(htimer_t* timer) { + smtp_client_t* cli = (smtp_client_t*)hevent_userdata(timer); + if (cli == NULL) return; + cli->timer = NULL; + hio_t* io = cli->io; + if (io == NULL) return; + hlogw("smtp connect timeout %s:%d", cli->host, cli->port); + smtp_finish(cli, ERR_TASK_TIMEOUT, "connect timeout"); + hio_close(io); +} + +static void on_connect(hio_t* io) { + smtp_client_t* cli = (smtp_client_t*)hevent_userdata(io); + if (cli == NULL) return; + if (cli->timer) { + htimer_del(cli->timer); + cli->timer = NULL; + } + cli->state = SMTP_ST_GREETING; + hio_setcb_read(io, on_recv); + hio_read(io); +} + +static void on_close(hio_t* io) { + smtp_client_t* cli = (smtp_client_t*)hevent_userdata(io); + if (cli == NULL) return; + // if closed before finishing, report failure + if (!cli->cb_called) { + smtp_finish(cli, ERR_CONNECT, cli->last_msg[0] ? cli->last_msg : "connection closed"); + } + cli->io = NULL; +} + +smtp_client_t* smtp_client_new(hloop_t* loop) { + int is_loop_owner = (loop == NULL); + if (loop == NULL) { + loop = hloop_new(HLOOP_FLAG_AUTO_FREE); + if (loop == NULL) return NULL; + } + smtp_client_t* cli = NULL; + HV_ALLOC_SIZEOF(cli); + if (cli == NULL) return NULL; + cli->loop = loop; + cli->is_loop_owner = is_loop_owner; + cli->port = DEFAULT_SMTPS_PORT; + cli->ssl = 1; + hmutex_init(&cli->mutex_); + return cli; +} + +void smtp_client_free(smtp_client_t* cli) { + if (!cli) return; + if (cli->timer) { + hevent_set_userdata(cli->timer, NULL); + htimer_del(cli->timer); + cli->timer = NULL; + } + if (cli->io) { + hevent_set_userdata(cli->io, NULL); + hio_setcb_close(cli->io, NULL); + hio_close(cli->io); + cli->io = NULL; + } + hmutex_destroy(&cli->mutex_); + if (cli->ssl_ctx && cli->alloced_ssl_ctx) { + hssl_ctx_free(cli->ssl_ctx); + cli->ssl_ctx = NULL; + } + HV_FREE(cli->message); + HV_FREE(cli); +} + +void smtp_client_run(smtp_client_t* cli) { + if (!cli || !cli->loop) return; + if (!cli->is_loop_owner) return; + hloop_run(cli->loop); + cli->loop = NULL; + cli->io = NULL; + cli->timer = NULL; +} + +void smtp_client_stop(smtp_client_t* cli) { + if (!cli || !cli->loop) return; + if (!cli->is_loop_owner) return; + hloop_stop(cli->loop); +} + +void smtp_client_set_auth(smtp_client_t* cli, const char* username, const char* password) { + if (!cli) return; + if (username) hv_strncpy(cli->username, username, sizeof(cli->username)); + if (password) hv_strncpy(cli->password, password, sizeof(cli->password)); +} + +void smtp_client_set_callback(smtp_client_t* cli, smtp_client_cb cb) { + if (cli) cli->cb = cb; +} + +void smtp_client_set_userdata(smtp_client_t* cli, void* userdata) { + if (cli) cli->userdata = userdata; +} + +void* smtp_client_get_userdata(smtp_client_t* cli) { + return cli ? cli->userdata : NULL; +} + +int smtp_client_set_ssl_ctx(smtp_client_t* cli, hssl_ctx_t ssl_ctx) { + cli->ssl_ctx = ssl_ctx; + return 0; +} + +int smtp_client_new_ssl_ctx(smtp_client_t* cli, hssl_ctx_opt_t* opt) { + opt->endpoint = HSSL_CLIENT; + hssl_ctx_t ssl_ctx = hssl_ctx_new(opt); + if (ssl_ctx == NULL) return ERR_NEW_SSL_CTX; + cli->alloced_ssl_ctx = 1; + return smtp_client_set_ssl_ctx(cli, ssl_ctx); +} + +void smtp_client_set_connect_timeout(smtp_client_t* cli, int ms) { + if (cli) cli->connect_timeout = ms; +} + +void smtp_client_set_host(smtp_client_t* cli, const char* host, int port, int ssl) { + if (!cli) return; + hv_strncpy(cli->host, host, sizeof(cli->host)); + cli->port = port; + cli->ssl = ssl ? 1 : 0; +} + +int smtp_client_send(smtp_client_t* cli, mail_t* mail) { + if (!cli || !mail) return -1; + if (!cli->host[0]) return ERR_INVALID_PARAM; + if (!mail->from.addr || (mail->to_count == 0 && mail->cc_count == 0)) return ERR_INVALID_PARAM; + + cli->mail = mail; + cli->cb_called = 0; + cli->state = SMTP_ST_INIT; + cli->rcpt_index = 0; + cli->last_msg[0] = '\0'; + + HV_FREE(cli->message); + cli->message = mime_build(mail); + if (cli->message == NULL) return ERR_NULL_POINTER; + + hio_t* io = hio_create_socket(cli->loop, cli->host, cli->port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); + if (io == NULL) return ERR_SOCKET; + if (cli->ssl) { + if (cli->ssl_ctx) hio_set_ssl_ctx(io, cli->ssl_ctx); + hio_enable_ssl(io); + } + cli->io = io; + hevent_set_userdata(io, cli); + hio_setcb_connect(io, on_connect); + hio_setcb_close(io, on_close); + if (cli->connect_timeout > 0) { + cli->timer = htimer_add(cli->loop, connect_timeout_cb, cli->connect_timeout, 1); + hevent_set_userdata(cli->timer, cli); + } + return hio_connect(io) < 0 ? ERR_SOCKET : 0; +} diff --git a/mail/smtp_client.h b/mail/smtp_client.h new file mode 100644 index 000000000..88b81ad21 --- /dev/null +++ b/mail/smtp_client.h @@ -0,0 +1,114 @@ +#ifndef HV_SMTP_CLIENT_H_ +#define HV_SMTP_CLIENT_H_ + +#include "hloop.h" +#include "hssl.h" +#include "hmutex.h" +#include "mime.h" + +#define DEFAULT_SMTP_PORT 25 +#define DEFAULT_SMTPS_PORT 465 + +typedef struct smtp_client_s smtp_client_t; + +// result callback: code>=200 && code<300 means success (SMTP 2xx) +// @param code: SMTP status code, or negative libhv ERR_* on transport error +// @param msg: last server response line (may be NULL) +typedef void (*smtp_client_cb)(smtp_client_t* cli, int code, const char* msg); + +BEGIN_EXTERN_C + +// hloop_new -> malloc(smtp_client_t) +HV_EXPORT smtp_client_t* smtp_client_new(hloop_t* loop DEFAULT(NULL)); +HV_EXPORT void smtp_client_run (smtp_client_t* cli); +HV_EXPORT void smtp_client_stop(smtp_client_t* cli); +HV_EXPORT void smtp_client_free(smtp_client_t* cli); + +// auth +HV_EXPORT void smtp_client_set_auth(smtp_client_t* cli, + const char* username, const char* password); + +// callback (called once when the send finishes or fails) +HV_EXPORT void smtp_client_set_callback(smtp_client_t* cli, smtp_client_cb cb); + +// userdata +HV_EXPORT void smtp_client_set_userdata(smtp_client_t* cli, void* userdata); +HV_EXPORT void* smtp_client_get_userdata(smtp_client_t* cli); + +// SSL/TLS (direct TLS; use port 465) +HV_EXPORT int smtp_client_set_ssl_ctx(smtp_client_t* cli, hssl_ctx_t ssl_ctx); +HV_EXPORT int smtp_client_new_ssl_ctx(smtp_client_t* cli, hssl_ctx_opt_t* opt); + +// connect +HV_EXPORT void smtp_client_set_connect_timeout(smtp_client_t* cli, int ms); +HV_EXPORT void smtp_client_set_host(smtp_client_t* cli, const char* host, int port, int ssl); + +// send: connect (if needed) and send the mail; result via callback. +// @retval 0 started ok, <0 error +HV_EXPORT int smtp_client_send(smtp_client_t* cli, mail_t* mail); + +END_EXTERN_C + +#ifdef __cplusplus + +#include +#include + +namespace hv { + +// @usage examples/mail/sendmail_test.cpp +class SmtpClient { +public: + smtp_client_t* client; + typedef std::function ResultCallback; + ResultCallback onResult; + + SmtpClient(hloop_t* loop = NULL) { + client = smtp_client_new(loop); + } + ~SmtpClient() { + if (client) { + smtp_client_free(client); + client = NULL; + } + } + + void setHost(const char* host, int port = DEFAULT_SMTPS_PORT, bool ssl = true) { + smtp_client_set_host(client, host, port, ssl ? 1 : 0); + } + void setAuth(const char* username, const char* password) { + smtp_client_set_auth(client, username, password); + } + void setConnectTimeout(int ms) { + smtp_client_set_connect_timeout(client, ms); + } + int setSslCtx(hssl_ctx_t ssl_ctx) { + return smtp_client_set_ssl_ctx(client, ssl_ctx); + } + int newSslCtx(hssl_ctx_opt_t* opt) { + return smtp_client_new_ssl_ctx(client, opt); + } + + int send(mail_t* mail) { + smtp_client_set_callback(client, on_smtp); + smtp_client_set_userdata(client, this); + return smtp_client_send(client, mail); + } + + void run() { smtp_client_run(client); } + void stop() { smtp_client_stop(client); } + +private: + static void on_smtp(smtp_client_t* cli, int code, const char* msg) { + SmtpClient* self = (SmtpClient*)smtp_client_get_userdata(cli); + if (self && self->onResult) { + self->onResult(self, code, msg ? msg : ""); + } + } +}; + +} + +#endif // __cplusplus + +#endif // HV_SMTP_CLIENT_H_ From ad4322ce63cacbf7a536dd449379347ea7b1b666 Mon Sep 17 00:00:00 2001 From: ithewei Date: Sat, 19 Sep 2026 13:47:07 +0800 Subject: [PATCH 2/3] ci: enable WITH_MAIL in linux build Co-authored-by: TRAE CLI --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 960129ed7..6a8e0bdce 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -36,7 +36,7 @@ jobs: run: | sudo apt update sudo apt install libssl-dev libnghttp2-dev libprotobuf-dev libprotoc-dev protobuf-compiler liblua5.4-dev - ./configure --with-openssl --with-nghttp2 --with-kcp --with-mqtt --with-redis --with-rpc --with-lua --with-js + ./configure --with-openssl --with-nghttp2 --with-kcp --with-mqtt --with-redis --with-mail --with-rpc --with-lua --with-js make libhv evpp unittest # hrpc = separate libhrpc (needs protobuf); apt installs protobuf under /usr make libhrpc hrpc PROTOBUF_PREFIX=/usr From 0cd21e92f2e64faf58016e63d393972414c985d6 Mon Sep 17 00:00:00 2001 From: ithewei Date: Sat, 19 Sep 2026 14:22:46 +0800 Subject: [PATCH 3/3] fix(mail): address review findings (security + correctness) MIME (mail/mime.c): - header injection: encode header values containing CR/LF/control chars via RFC 2047 (was appended verbatim, allowing Bcc-style injection). - base64 decode now tolerates MIME line breaks/whitespace (hv_base64_decode rejects CR/LF), so wrapped attachments no longer decode to 0 bytes. - decode RFC 2047 encoded-words when parsing headers (Subject etc.), so a build->parse round-trip returns the original text. - attachment decode is binary-safe now (track decoded length instead of strlen, which truncated data at the first embedded NUL). SMTP (mail/smtp_client.c): - clamp vsnprintf return to the actual buffer size to avoid reading past the stack buffer on very long host/address. - dot-stuff the DATA payload so a body line "." cannot terminate DATA early. - accept RCPT TO reply code 252 (accepted, cannot verify recipient). - set TLS SNI hostname (hio_set_hostname) for SMTPS virtual hosts. IMAP (mail/imap_client.c): - quote LOGIN credentials and reject control characters (command injection). - require a complete CRLF-terminated line before treating a tagged response as final, so responses split across reads are not discarded. - set TLS SNI hostname for IMAPS virtual hosts. - use BODY.PEEK[] so fetching does not mark messages as \Seen. Build: - examples/CMakeLists.txt: add WITH_MAIL targets (sendmail_test/recvmail_test). Co-authored-by: TRAE CLI --- examples/CMakeLists.txt | 12 +++++ mail/imap_client.c | 50 ++++++++++++++++--- mail/mime.c | 103 +++++++++++++++++++++++++++++++++++----- mail/smtp_client.c | 33 +++++++++++-- 4 files changed, 175 insertions(+), 23 deletions(-) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 45c02c2a9..6fab95344 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -226,6 +226,18 @@ if(WITH_MQTT) list(APPEND EXAMPLES mqtt_sub mqtt_pub mqtt_client_test) endif() +if(WITH_MAIL) + include_directories(../mail) + + add_executable(sendmail_test mail/sendmail_test.cpp) + target_link_libraries(sendmail_test ${HV_LIBRARIES}) + + add_executable(recvmail_test mail/recvmail_test.cpp) + target_link_libraries(recvmail_test ${HV_LIBRARIES}) + + list(APPEND EXAMPLES sendmail_test recvmail_test) +endif() + # hrpc examples: link the hrpc library (built at top level when WITH_RPC=ON) -- # exactly how a downstream user consumes hrpc: -lhrpc -lhv -lprotobuf. if(WITH_RPC) diff --git a/mail/imap_client.c b/mail/imap_client.c index 2294c1072..04358a214 100644 --- a/mail/imap_client.c +++ b/mail/imap_client.c @@ -61,6 +61,29 @@ struct imap_client_s { static void imap_fetch_next(imap_client_t* cli); static void imap_process(imap_client_t* cli); +// IMAP quoted-string: wrap in double quotes and backslash-escape " and \. +// Caller must ensure the input has no control chars (see imap_has_ctrl). +static void imap_quote(const char* in, char* out, int outlen) { + int o = 0; + if (o < outlen - 1) out[o++] = '"'; + for (const char* p = in; *p && o < outlen - 2; ++p) { + if (*p == '"' || *p == '\\') { + if (o < outlen - 3) out[o++] = '\\'; + } + out[o++] = *p; + } + if (o < outlen - 1) out[o++] = '"'; + out[o] = '\0'; +} + +// reject CR/LF and other control characters (command injection guard) +static int imap_has_ctrl(const char* s) { + for (const char* p = s; *p; ++p) { + if ((unsigned char)*p < 0x20) return 1; + } + return 0; +} + static void imap_done(imap_client_t* cli, int code, const char* msg) { if (cli->done_cb && !cli->done_called) { cli->done_called = 1; @@ -90,6 +113,9 @@ static int imap_tag_prefix(imap_client_t* cli, char* out, int outlen) { // check whether buffer [data,data+len) contains the tagged final response for // the current tag. Sets *ok to 1 if "aNNN OK", 0 if NO/BAD. Returns pointer to // the byte just after that line's CRLF, or NULL if not present yet. +// NOTE: only a complete LF-terminated line is treated as final, so a tagged +// response split across reads is not consumed prematurely (which would discard +// the remainder and hang waiting for the next tag). static char* find_tagged_response(imap_client_t* cli, char* data, size_t len, int* ok) { char prefix[16]; int plen = imap_tag_prefix(cli, prefix, sizeof(prefix)); @@ -97,14 +123,14 @@ static char* find_tagged_response(imap_client_t* cli, char* data, size_t len, in char* end = data + len; while (p < end) { char* nl = (char*)memchr(p, '\n', end - p); - int linelen = nl ? (int)(nl - p) : (int)(end - p); + if (nl == NULL) break; // incomplete line; wait for more data + int linelen = (int)(nl - p); if (linelen >= plen && strncmp(p, prefix, plen) == 0) { const char* status = p + plen; if (strnicmp(status, "OK", 2) == 0) *ok = 1; else *ok = 0; // NO / BAD - return nl ? nl + 1 : end; + return nl + 1; } - if (nl == NULL) break; p = nl + 1; } return NULL; @@ -216,10 +242,14 @@ static void imap_process(imap_client_t* cli) { size_t consumed = nl - data + 1; memmove(data, nl + 1, len - consumed); cli->recvlen = len - consumed; - // LOGIN + // LOGIN with quoted credentials (escape " and \; control chars are + // rejected earlier in imap_client_fetch to prevent command injection) cli->state = IMAP_ST_LOGIN; - char cmd[384]; - snprintf(cmd, sizeof(cmd), "LOGIN %s %s", cli->username, cli->password); + char user_q[256], pass_q[256]; + imap_quote(cli->username, user_q, sizeof(user_q)); + imap_quote(cli->password, pass_q, sizeof(pass_q)); + char cmd[600]; + snprintf(cmd, sizeof(cmd), "LOGIN %s %s", user_q, pass_q); imap_send_cmd(cli, cmd); break; } @@ -288,7 +318,9 @@ static void imap_fetch_next(imap_client_t* cli) { } int id = cli->ids[cli->id_index]; char cmd[64]; - snprintf(cmd, sizeof(cmd), "FETCH %d BODY[]", id); + // BODY.PEEK[] fetches the full message without setting the \Seen flag, + // so reading mail does not mark it as read on the server. + snprintf(cmd, sizeof(cmd), "FETCH %d BODY.PEEK[]", id); imap_send_cmd(cli, cmd); } @@ -451,6 +483,8 @@ void imap_client_set_host(imap_client_t* cli, const char* host, int port, int ss int imap_client_fetch(imap_client_t* cli, const char* mailbox, const char* criteria) { if (!cli) return -1; if (!cli->host[0] || !cli->username[0]) return ERR_INVALID_PARAM; + // reject control characters in credentials (IMAP command injection guard) + if (imap_has_ctrl(cli->username) || imap_has_ctrl(cli->password)) return ERR_INVALID_PARAM; if (mailbox) hv_strncpy(cli->mailbox, mailbox, sizeof(cli->mailbox)); if (criteria) hv_strncpy(cli->criteria, criteria, sizeof(cli->criteria)); @@ -467,6 +501,8 @@ int imap_client_fetch(imap_client_t* cli, const char* mailbox, const char* crite if (io == NULL) return ERR_SOCKET; if (cli->ssl) { if (cli->ssl_ctx) hio_set_ssl_ctx(io, cli->ssl_ctx); + // set SNI hostname (see smtp_client for rationale) + hio_set_hostname(io, cli->host); hio_enable_ssl(io); } cli->io = io; diff --git a/mail/mime.c b/mail/mime.c index bd8c6f90b..784029c31 100644 --- a/mail/mime.c +++ b/mail/mime.c @@ -53,6 +53,23 @@ static int membuf_puts(membuf_t* buf, const char* s) { // ---- codecs ---- +// base64 decode that tolerates MIME line breaks / whitespace: hv_base64_decode +// rejects CR/LF, so strip whitespace into a scratch buffer first. +// Returns decoded length, or -1 on error. +static int mime_base64_decode(const char* in, int inlen, unsigned char* out) { + char* tmp = (char*)malloc(inlen + 1); + if (tmp == NULL) return -1; + int n = 0; + for (int i = 0; i < inlen; ++i) { + char c = in[i]; + if (c == '\r' || c == '\n' || c == ' ' || c == '\t') continue; + tmp[n++] = c; + } + int ret = hv_base64_decode(tmp, n, out); + free(tmp); + return ret; +} + int mime_qp_decode(const char* in, int inlen, char* out) { int o = 0; for (int i = 0; i < inlen; ++i) { @@ -85,23 +102,76 @@ int mime_encode_word(const char* in, int inlen, char* out, int outlen) { return n; } +// Decode an RFC 2047 header value that may contain encoded-words +// "=?charset?B?..?=" (base64) or "=?charset?Q?..?=" (quoted-printable-like). +// Non-encoded text is copied through. Returns a heap string (caller frees), +// charset is not converted (returned bytes are the decoded payload as-is). +static char* mime_decode_word(const char* in, int inlen) { + char* out = (char*)malloc(inlen + 1); + if (out == NULL) return NULL; + int o = 0; + int i = 0; + while (i < inlen) { + // look for "=?" + if (i + 1 < inlen && in[i] == '=' && in[i + 1] == '?') { + // parse =?charset?E?text?= + const char* p = in + i + 2; + const char* end = in + inlen; + const char* q1 = memchr(p, '?', end - p); // after charset + if (q1 && q1 + 2 < end && q1[2] == '?') { + char enc = q1[1]; + const char* text = q1 + 3; + // find closing "?=" + const char* close = text; + while (close + 1 < end && !(close[0] == '?' && close[1] == '=')) close++; + if (close + 1 < end) { + int tlen = (int)(close - text); + if (enc == 'B' || enc == 'b') { + o += hv_base64_decode(text, tlen, (unsigned char*)out + o); + } else if (enc == 'Q' || enc == 'q') { + // Q-encoding: '_' => space, '=XX' => byte + for (int k = 0; k < tlen; ++k) { + if (text[k] == '_') { + out[o++] = ' '; + } else if (text[k] == '=' && k + 2 < tlen) { + char hex[3] = { text[k + 1], text[k + 2], 0 }; + out[o++] = (char)strtol(hex, NULL, 16); + k += 2; + } else { + out[o++] = text[k]; + } + } + } + i = (int)(close + 2 - in); + continue; + } + } + } + out[o++] = in[i++]; + } + out[o] = '\0'; + return out; +} + // Encode a header value: if it contains non-ASCII, use RFC 2047 encoded-word. +// Any CR/LF (or other control chars) force encoding too, to prevent header +// injection (e.g. a subject/name carrying "\r\nBcc: ..."). static void append_header_value(membuf_t* buf, const char* value) { - int has_non_ascii = 0; + int needs_encoding = 0; for (const char* p = value; *p; ++p) { - if ((unsigned char)*p >= 0x80) { has_non_ascii = 1; break; } + unsigned char c = (unsigned char)*p; + if (c >= 0x80 || c == '\r' || c == '\n' || c == '\t') { needs_encoding = 1; break; } } - if (!has_non_ascii) { + if (!needs_encoding) { membuf_puts(buf, value); return; } int inlen = (int)strlen(value); int outlen = 16 + BASE64_ENCODE_OUT_SIZE(inlen); char* enc = (char*)malloc(outlen); - if (enc == NULL) { membuf_puts(buf, value); return; } + if (enc == NULL) return; // drop rather than emit an unsafe raw value int n = mime_encode_word(value, inlen, enc, outlen); if (n > 0) membuf_append(buf, enc, n); - else membuf_puts(buf, value); free(enc); } @@ -366,7 +436,7 @@ static char* dup_header(const char* start, const char* end, const char* name) { int len = 0; const char* v = find_header(start, end, name, &len); if (v == NULL || len <= 0) return NULL; - return mime_strndup(v, len); + return mime_decode_word(v, len); } // find end of headers (double CRLF); returns pointer to body start, or end. @@ -403,25 +473,30 @@ static int extract_boundary(const char* ct, int ctlen, char* out, int outlen) { } // decode a body part according to Content-Transfer-Encoding into a heap buffer. +// Sets *outlen to the decoded byte length (binary-safe; the buffer is also +// NUL-terminated for text convenience but may contain embedded NULs). static char* decode_part_body(const char* body, int bodylen, - const char* enc, int enclen) { + const char* enc, int enclen, int* outlen) { if (enc && enclen >= 6 && strnicmp(enc, "base64", 6) == 0) { int out_size = BASE64_DECODE_OUT_SIZE(bodylen) + 1; char* out = (char*)malloc(out_size); - if (out == NULL) return NULL; - int n = hv_base64_decode(body, bodylen, (unsigned char*)out); + if (out == NULL) { *outlen = 0; return NULL; } + int n = mime_base64_decode(body, bodylen, (unsigned char*)out); if (n < 0) n = 0; out[n] = '\0'; + *outlen = n; return out; } if (enc && enclen >= 16 && strnicmp(enc, "quoted-printable", 16) == 0) { char* out = (char*)malloc(bodylen + 1); - if (out == NULL) return NULL; + if (out == NULL) { *outlen = 0; return NULL; } int n = mime_qp_decode(body, bodylen, out); out[n] = '\0'; + *outlen = n; return out; } // 7bit / 8bit / none + *outlen = bodylen; return mime_strndup(body, bodylen); } @@ -512,7 +587,8 @@ static void parse_part(const char* start, const char* end, mail_t* mail) { } } int bodylen = (int)(end - body); - char* decoded = decode_part_body(body, bodylen, enc, enclen); + int declen = 0; + char* decoded = decode_part_body(body, bodylen, enc, enclen, &declen); char* ct_dup = ct ? mime_strndup(ct, ctlen) : NULL; // strip params after ';' in ct if (ct_dup) { @@ -521,7 +597,7 @@ static void parse_part(const char* start, const char* end, mail_t* mail) { } mail_add_attachment(mail, filename ? filename : "attachment", ct_dup, decoded ? decoded : "", - decoded ? strlen(decoded) : 0); + decoded ? (size_t)declen : 0); free(filename); free(ct_dup); free(decoded); @@ -530,7 +606,8 @@ static void parse_part(const char* start, const char* end, mail_t* mail) { // text body int bodylen = (int)(end - body); - char* decoded = decode_part_body(body, bodylen, enc, enclen); + int declen = 0; + char* decoded = decode_part_body(body, bodylen, enc, enclen, &declen); if (decoded == NULL) return; int is_html = (ct && ctlen >= 9 && strnicmp(ct, "text/html", 9) == 0); if (is_html) { diff --git a/mail/smtp_client.c b/mail/smtp_client.c index 655eee88c..5dbc76ac9 100644 --- a/mail/smtp_client.c +++ b/mail/smtp_client.c @@ -69,6 +69,25 @@ static int smtp_write(smtp_client_t* cli, const char* buf, int len) { return nwrite; } +// Send the DATA payload with dot-stuffing: any line starting with '.' gets an +// extra leading '.', so a body line "." cannot prematurely terminate DATA. +static void smtp_send_data(smtp_client_t* cli, const char* msg) { + const char* p = msg; + while (*p) { + if (*p == '.') { + smtp_write(cli, ".", 1); // stuff an extra leading dot + } + const char* nl = strchr(p, '\n'); + if (nl == NULL) { + smtp_write(cli, p, (int)strlen(p)); + break; + } + int linelen = (int)(nl - p) + 1; + smtp_write(cli, p, linelen); // includes the '\n' + p = nl + 1; + } +} + static int smtp_writef(smtp_client_t* cli, const char* fmt, ...) { char buf[1024]; va_list ap; @@ -76,6 +95,9 @@ static int smtp_writef(smtp_client_t* cli, const char* fmt, ...) { int len = vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); if (len <= 0) return -1; + // vsnprintf returns the length that WOULD be written; clamp to the actual + // bytes stored so smtp_write never reads past the stack buffer. + if (len >= (int)sizeof(buf)) len = (int)sizeof(buf) - 1; return smtp_write(cli, buf, len); } @@ -170,15 +192,16 @@ static void on_recv(hio_t* io, void* buf, int len) { smtp_send_next(cli); // send first RCPT TO break; case SMTP_ST_RCPT_TO: - if (code != 250 && code != 251) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } + // 250 OK, 251 forwarded, 252 accepted (cannot verify recipient) + if (code != 250 && code != 251 && code != 252) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } cli->rcpt_index++; smtp_send_next(cli); // next RCPT or DATA break; case SMTP_ST_DATA: if (code != 354) { smtp_finish(cli, code, cli->last_msg); hio_close(io); return; } cli->state = SMTP_ST_BODY; - // send message + end-of-body - smtp_write(cli, cli->message, (int)strlen(cli->message)); + // send dot-stuffed message + end-of-body + smtp_send_data(cli, cli->message); smtp_write(cli, "\r\n.\r\n", 5); break; case SMTP_ST_BODY: @@ -363,6 +386,10 @@ int smtp_client_send(smtp_client_t* cli, mail_t* mail) { if (io == NULL) return ERR_SOCKET; if (cli->ssl) { if (cli->ssl_ctx) hio_set_ssl_ctx(io, cli->ssl_ctx); + // set SNI hostname: hio_create_socket only stores the peer address, but + // the TLS backend sends SNI from io->hostname. Without this, name-based + // virtual SMTPS hosts may reject the handshake or route incorrectly. + hio_set_hostname(io, cli->host); hio_enable_ssl(io); } cli->io = io;