diff --git a/CMakeLists.txt b/CMakeLists.txt index d75cbe9..014be80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(oatpp-authkit VERSION 0.3.1 LANGUAGES CXX) +project(oatpp-authkit VERSION 0.3.2 LANGUAGES CXX) # Header-only interface library — no compilation, just an include path and # a CMake config package so consumers do: diff --git a/include/oatpp-authkit/mail/SmtpTransport.hpp b/include/oatpp-authkit/mail/SmtpTransport.hpp new file mode 100644 index 0000000..129e25b --- /dev/null +++ b/include/oatpp-authkit/mail/SmtpTransport.hpp @@ -0,0 +1,150 @@ +#ifndef oatpp_authkit_mail_SmtpTransport_hpp +#define oatpp_authkit_mail_SmtpTransport_hpp + +/** + * @file SmtpTransport.hpp + * @brief Pure libcurl SMTP+MIME transport — lifted from fewo-webapp #454. + * + * Handles MAIL FROM / RCPT TO / STARTTLS / optional SMTP AUTH / MIME multipart + * body + attachments / RFC 2047 encoded Subject. Knows nothing about templates, + * DTOs or databases — callers hand over the fully-rendered HTML body, the + * subject line, any attachment blobs and an `SmtpConfig` struct. Use a tiny + * adapter in the caller to map from whatever DTO/settings row you have to + * `SmtpConfig` so this header stays free of project-specific types. + * + * Consumers: add `#include `; link + * against libcurl (authkit itself is header-only so the consumer's CMake + * owns the curl dependency). + */ + +#include + +#include +#include +#include + +namespace oatpp_authkit::mail { + +/** @brief Plain-struct SMTP config; projects adapt from their own DTO/settings row. */ +struct SmtpConfig { + std::string host; + int port = 587; + std::string fromAddress; + std::string username; // empty = no SMTP AUTH + std::string password; +}; + +/** @brief RFC 4648 Base64 encode — used for RFC 2047 Subject headers. */ +inline std::string base64Encode(const std::string& data) { + static const char* table = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((data.size() + 2) / 3) * 4); + for (size_t i = 0; i < data.size(); i += 3) { + unsigned int b = (unsigned char)data[i] << 16; + if (i + 1 < data.size()) b |= (unsigned char)data[i + 1] << 8; + if (i + 2 < data.size()) b |= (unsigned char)data[i + 2]; + out += table[(b >> 18) & 0x3f]; + out += table[(b >> 12) & 0x3f]; + out += (i + 1 < data.size()) ? table[(b >> 6) & 0x3f] : '='; + out += (i + 2 < data.size()) ? table[b & 0x3f] : '='; + } + return out; +} + +/** + * @brief Send a single email via libcurl SMTP. + * + * @param to Recipient address. + * @param subject Plain UTF-8 subject; wrapped as an RFC 2047 encoded-word so + * non-ASCII characters (umlauts etc.) survive. + * @param htmlBody text/html body (quoted-printable on the wire). + * @param attachments (filename, blob) pairs; `.pdf`/`.ics` extensions get + * recognised Content-Type, everything else goes as + * application/octet-stream. + * @param cfg SMTP configuration. + * @return Empty string on success; error message otherwise. Callers typically + * log a non-empty result and treat it as a soft failure. + */ +inline std::string send( + const std::string& to, + const std::string& subject, + const std::string& htmlBody, + const std::vector>& attachments, + const SmtpConfig& cfg) +{ + if (cfg.host.empty()) return "SMTP not configured (no host)"; + if (cfg.fromAddress.empty()) return "SMTP not configured (no from_address)"; + + CURL* curl = curl_easy_init(); + if (!curl) return "curl_easy_init failed"; + + std::string url = "smtp://" + cfg.host + ":" + std::to_string(cfg.port); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_MAIL_FROM, ("<" + cfg.fromAddress + ">").c_str()); + + struct curl_slist* rcpt = nullptr; + rcpt = curl_slist_append(rcpt, to.c_str()); + curl_easy_setopt(curl, CURLOPT_MAIL_RCPT, rcpt); + + if (!cfg.username.empty()) { + curl_easy_setopt(curl, CURLOPT_USERNAME, cfg.username.c_str()); + curl_easy_setopt(curl, CURLOPT_PASSWORD, cfg.password.c_str()); + } + curl_easy_setopt(curl, CURLOPT_USE_SSL, CURLUSESSL_TRY); + // Allow self-signed certs on localhost relay — a common dev / pipe-transport setup. + if (cfg.host == "localhost" || cfg.host == "127.0.0.1") { + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + } + + // Keep worker threads from blocking on a dead mail server indefinitely. + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + // Build MIME body: text/html body first, then attachments. + curl_mime* mime = curl_mime_init(curl); + + curl_mimepart* bodyPart = curl_mime_addpart(mime); + curl_mime_data(bodyPart, htmlBody.c_str(), (curl_off_t)htmlBody.size()); + curl_mime_type(bodyPart, "text/html; charset=utf-8"); + curl_mime_encoder(bodyPart, "quoted-printable"); + + for (const auto& [fname, fcontent] : attachments) { + curl_mimepart* apart = curl_mime_addpart(mime); + curl_mime_data(apart, fcontent.c_str(), (curl_off_t)fcontent.size()); + curl_mime_filename(apart, fname.c_str()); + curl_mime_encoder(apart, "base64"); + std::string mtype = "application/octet-stream"; + if (fname.size() > 4) { + std::string ext = fname.substr(fname.size() - 4); + if (ext == ".pdf") mtype = "application/pdf"; + else if (ext == ".ics") mtype = "text/calendar; charset=utf-8"; + } + curl_mime_type(apart, mtype.c_str()); + } + + curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime); + + // RFC 2047 encoded-word Subject so non-ASCII survives. + std::string encodedSubject = "=?UTF-8?B?" + base64Encode(subject) + "?="; + struct curl_slist* hdrs = nullptr; + hdrs = curl_slist_append(hdrs, ("From: " + cfg.fromAddress).c_str()); + hdrs = curl_slist_append(hdrs, ("To: " + to).c_str()); + hdrs = curl_slist_append(hdrs, ("Subject: " + encodedSubject).c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + + CURLcode res = curl_easy_perform(curl); + std::string err; + if (res != CURLE_OK) err = std::string(curl_easy_strerror(res)); + + curl_mime_free(mime); + curl_slist_free_all(rcpt); + curl_slist_free_all(hdrs); + curl_easy_cleanup(curl); + return err; +} + +} // namespace oatpp_authkit::mail + +#endif // oatpp_authkit_mail_SmtpTransport_hpp