oatpp-authkit/include/oatpp-authkit/mail/SmtpTransport.hpp
Uwe Schuster 448cd9ef8c v0.3.2: Add mail::SmtpTransport — lifted from fewo-webapp
Pure libcurl SMTP + MIME transport, DTO-free so it drops into any
consumer that can cough up host/port/from/user/pass. Callers adapt
their own settings row/DTO to `oatpp_authkit::mail::SmtpConfig`.

Closes the email-service half of #447 (tracked under fewo-webapp #454).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:06:35 +02:00

150 lines
5.9 KiB
C++

#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 <oatpp-authkit/mail/SmtpTransport.hpp>`; link
* against libcurl (authkit itself is header-only so the consumer's CMake
* owns the curl dependency).
*/
#include <curl/curl.h>
#include <string>
#include <utility>
#include <vector>
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<std::pair<std::string, std::string>>& 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