diff --git a/BARANTMailConfig.cpp b/BARANTMailConfig.cpp new file mode 100644 index 0000000..735f7a5 --- /dev/null +++ b/BARANTMailConfig.cpp @@ -0,0 +1,95 @@ +#include "BARANTMailConfig.h" +#include "Exception.h" +#include "JFile.h" + +namespace mail { + + BARANTMailConfig::BARANTMailConfig(const std::string &path) { + file = new coreutils::JFile(coreutils::ZString(path.c_str(), path.length())); + + if (!file->exists("hostName")) + throw coreutils::Exception("BARANTMailConfig: \"hostName\" is missing from " + path); + if (!file->exists("mailPath")) + throw coreutils::Exception("BARANTMailConfig: \"mailPath\" is missing from " + path); + if (!file->exists("smtp.port")) + throw coreutils::Exception("BARANTMailConfig: \"smtp.port\" is missing from " + path); + + bool needsTls = file->exists("smtpTLS.port") || file->exists("imap.port") || file->exists("pop3.port"); + if (needsTls) { + if (!file->exists("tls.certificate") || !file->exists("tls.key")) + throw coreutils::Exception( + "BARANTMailConfig: smtpTLS/imap/pop3 is configured but \"tls.certificate\"/\"tls.key\" " + "is missing from " + path); + certPath = file->getString("tls.certificate"); + keyPath = file->getString("tls.key"); + tlsInfo.certificate = certPath.c_str(); + tlsInfo.key = keyPath.c_str(); + if (file->exists("tls.caCertificate")) { + caPath = file->getString("tls.caCertificate"); + tlsInfo.cACertificate = caPath.c_str(); + } + hasTls = true; + } + } + + BARANTMailConfig::~BARANTMailConfig() { + delete file; + } + + std::string BARANTMailConfig::getHostName() { + return file->getString("hostName"); + } + + std::string BARANTMailConfig::getMailPath() { + return file->getString("mailPath"); + } + + int BARANTMailConfig::getSmtpPort() { + return (int)file->getInt("smtp.port"); + } + + bool BARANTMailConfig::hasSmtpTLS() { + return file->exists("smtpTLS.port"); + } + + int BARANTMailConfig::getSmtpTLSPort() { + return (int)file->getInt("smtpTLS.port"); + } + + bool BARANTMailConfig::hasImap() { + return file->exists("imap.port"); + } + + int BARANTMailConfig::getImapPort() { + return (int)file->getInt("imap.port"); + } + + bool BARANTMailConfig::hasPop3() { + return file->exists("pop3.port"); + } + + int BARANTMailConfig::getPop3Port() { + return (int)file->getInt("pop3.port"); + } + + core::TLSInfo *BARANTMailConfig::getTlsInfo() { + return hasTls ? &tlsInfo : NULL; + } + + bool BARANTMailConfig::hasDkim() { + return file->exists("dkim.domain") && file->exists("dkim.selector") && file->exists("dkim.privateKey"); + } + + std::string BARANTMailConfig::getDkimDomain() { + return file->getString("dkim.domain"); + } + + std::string BARANTMailConfig::getDkimSelector() { + return file->getString("dkim.selector"); + } + + std::string BARANTMailConfig::getDkimPrivateKeyPath() { + return file->getString("dkim.privateKey"); + } + +} diff --git a/BARANTMailConfig.h b/BARANTMailConfig.h new file mode 100644 index 0000000..0996a21 --- /dev/null +++ b/BARANTMailConfig.h @@ -0,0 +1,104 @@ +#ifndef __BARANTMailConfig_h__ +#define __BARANTMailConfig_h__ + +#include "TLSInfo.h" +#include + +namespace coreutils { class JFile; } + +namespace mail { + + /// + /// Server startup configuration, loaded from a JSON file via + /// CoreUtils' JFile - mirrors APIServer's own ApiServerConfig (see + /// that class's doc comment for JFile's create-on-missing behavior). + /// + /// Schema: + /// { + /// "hostName": "mail.example.com", + /// "mailPath": "/var/mail", + /// "smtp": { "port": 25 }, + /// "smtpTLS": { "port": 465 }, + /// "imap": { "port": 993 }, + /// "pop3": { "port": 995 }, + /// "tls": { + /// "certificate": "/path/to/fullchain.pem", + /// "key": "/path/to/privkey.pem", + /// "caCertificate": "/path/to/ca.pem" + /// }, + /// "dkim": { + /// "domain": "mail.example.com", + /// "selector": "default", + /// "privateKey": "/path/to/dkim_private.pem" + /// } + /// } + /// + /// "hostName", "mailPath", and "smtp.port" are required - plain port + /// 25 is the one listener every deployment needs (an inbound peer + /// MTA relaying mail in has no TLS-first negotiation to fall back + /// on). "smtpTLS"/"imap"/"pop3" are each independently optional - + /// leaving one out just means that listener isn't started, so this + /// same file covers a plaintext-only relay just as well as a full + /// mail server. The moment any one of those three IS present, + /// "tls.certificate" and "tls.key" become required - there is no + /// path through this class that hands back a submission/IMAPS/POP3S + /// listener request without a real certificate configured for it. + /// "dkim" is entirely optional - outbound mail goes out unsigned + /// unless all three of its fields are present. + /// + + class BARANTMailConfig { + + public: + + BARANTMailConfig(const std::string &path); + ~BARANTMailConfig(); + + std::string getHostName(); + std::string getMailPath(); + + int getSmtpPort(); + + bool hasSmtpTLS(); + int getSmtpTLSPort(); + + bool hasImap(); + int getImapPort(); + + bool hasPop3(); + int getPop3Port(); + + /// + /// NULL if none of smtpTLS/imap/pop3 are configured. Owned by + /// this BARANTMailConfig, valid for as long as it is - pass + /// straight through to a server's tlsInfo parameter, do not + /// delete, and don't let this config object go out of scope + /// while a server built from this pointer is still running. + /// + + core::TLSInfo *getTlsInfo(); + + bool hasDkim(); + std::string getDkimDomain(); + std::string getDkimSelector(); + std::string getDkimPrivateKeyPath(); + + private: + + coreutils::JFile *file; + + // Owned, persistent storage for the TLSInfo fields below - + // TLSInfo's own ZString members are non-owning views, so they + // have to point at something that outlives them; pointing them at + // temporary std::strings decoded during construction would leave + // them dangling the moment this constructor returned. + std::string certPath, keyPath, caPath; + + core::TLSInfo tlsInfo; + bool hasTls = false; + + }; + +} + +#endif diff --git a/BMAMail b/BMAMail index d108037..91766f6 100755 Binary files a/BMAMail and b/BMAMail differ diff --git a/DKIMSigner.cpp b/DKIMSigner.cpp new file mode 100644 index 0000000..52d420f --- /dev/null +++ b/DKIMSigner.cpp @@ -0,0 +1,265 @@ +#include "DKIMSigner.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mail { + + DKIMSigner::DKIMSigner(std::string domain, std::string selector, std::string privateKeyPath) + : domain(domain), selector(selector), privateKey(nullptr) { + + FILE *fp = fopen(privateKeyPath.c_str(), "r"); + if (!fp) + throw std::runtime_error("DKIMSigner: could not open private key " + privateKeyPath); + + privateKey = PEM_read_PrivateKey(fp, nullptr, nullptr, nullptr); + fclose(fp); + + if (!privateKey) + throw std::runtime_error("DKIMSigner: could not parse private key " + privateKeyPath); + } + + DKIMSigner::~DKIMSigner() { + if (privateKey) + EVP_PKEY_free(privateKey); + } + + // Reduces every run of SP/TAB to a single SP, then trims leading and + // trailing SP entirely - the relaxed canonicalization rule for + // whitespace *around* a header field's name/value (RFC 6376 3.4.2), + // distinct from the body's own version of this rule (canonicalizeBody), + // which reduces internal runs the same way but does NOT strip a + // leading run down to nothing, only to one space. + std::string DKIMSigner::collapseWSP(const std::string &s) { + std::string result; + bool lastWasSpace = false; + for (char c : s) { + if (c == ' ' || c == '\t') { + if (!lastWasSpace) + result += ' '; + lastWasSpace = true; + } else { + result += c; + lastWasSpace = false; + } + } + size_t start = 0; + while (start < result.size() && result[start] == ' ') + ++start; + size_t end = result.size(); + while (end > start && result[end - 1] == ' ') + --end; + return result.substr(start, end - start); + } + + // RFC 6376 3.4.2 (relaxed header canonicalization), applied to one + // already-unfolded (name, value) pair: lowercase the name, delete + // WSP around the colon, collapse+trim WSP within the value, one + // trailing CRLF. + std::string DKIMSigner::canonicalizeHeaderField(const std::string &name, const std::string &value) { + std::string lowerName = name; + for (char &c : lowerName) + c = std::tolower((unsigned char)c); + return lowerName + ":" + collapseWSP(value) + "\r\n"; + } + + // RFC 6376 3.4.4 (relaxed body canonicalization): collapse WSP runs + // within each line to a single SP (a leading run becomes one SP, not + // zero - unlike header canonicalization above), strip trailing WSP + // from each line, then drop trailing empty lines so the body ends in + // exactly one CRLF (or, if it was empty/all-whitespace to begin + // with, canonicalizes to a single CRLF - RFC 6376 3.4.3's explicit + // "null body" case). + std::string DKIMSigner::canonicalizeBody(const std::string &body) { + std::vector lines; + size_t pos = 0; + while (pos < body.size()) { + size_t next = body.find("\r\n", pos); + if (next == std::string::npos) { + lines.push_back(body.substr(pos)); + break; + } + lines.push_back(body.substr(pos, next - pos)); + pos = next + 2; + } + + for (auto &line : lines) { + std::string collapsed; + bool lastWasSpace = false; + for (char c : line) { + if (c == ' ' || c == '\t') { + if (!lastWasSpace) + collapsed += ' '; + lastWasSpace = true; + } else { + collapsed += c; + lastWasSpace = false; + } + } + while (!collapsed.empty() && collapsed.back() == ' ') + collapsed.pop_back(); + line = collapsed; + } + + while (!lines.empty() && lines.back().empty()) + lines.pop_back(); + + std::string result; + for (auto &line : lines) { + result += line; + result += "\r\n"; + } + if (result.empty()) + result = "\r\n"; + return result; + } + + std::vector DKIMSigner::parseHeaders(const std::string &message) { + std::vector fields; + size_t headerEnd = message.find("\r\n\r\n"); + std::string headerBlock = (headerEnd == std::string::npos) ? message : message.substr(0, headerEnd); + + std::vector rawLines; + size_t pos = 0; + while (pos < headerBlock.size()) { + size_t next = headerBlock.find("\r\n", pos); + std::string line = (next == std::string::npos) ? headerBlock.substr(pos) : headerBlock.substr(pos, next - pos); + rawLines.push_back(line); + if (next == std::string::npos) + break; + pos = next + 2; + } + + // RFC 5322 3.2.2 folding: a continuation line starts with SP/TAB + // and belongs to the previous header field - unfolded here by + // joining with a single space, matching what canonicalizeHeaderField's + // own WSP collapsing expects to receive. + for (auto &line : rawLines) { + if (line.empty()) + continue; + if ((line[0] == ' ' || line[0] == '\t') && !fields.empty()) { + fields.back().value += " " + line; + continue; + } + size_t colon = line.find(':'); + if (colon == std::string::npos) + continue; + HeaderField f; + f.name = line.substr(0, colon); + f.value = line.substr(colon + 1); + fields.push_back(f); + } + + return fields; + } + + std::string DKIMSigner::bodyOf(const std::string &message) { + size_t headerEnd = message.find("\r\n\r\n"); + if (headerEnd == std::string::npos) + return ""; + return message.substr(headerEnd + 4); + } + + std::string DKIMSigner::base64(const std::string &raw) { + // BIO-based rather than reusing coreutils::Base64 here deliberately + // - this file only needs OpenSSL's own headers (already required + // for EVP signing), so encoding through the same library avoids + // pulling CoreUtils' ZString/MString machinery into a class whose + // only job is talking to libcrypto. + BIO *b64 = BIO_new(BIO_f_base64()); + BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL); + BIO *mem = BIO_new(BIO_s_mem()); + BIO_push(b64, mem); + BIO_write(b64, raw.data(), (int)raw.size()); + BIO_flush(b64); + char *encodedData = nullptr; + long encodedLen = BIO_get_mem_data(mem, &encodedData); + std::string result(encodedData, encodedLen); + BIO_free_all(b64); + return result; + } + + std::string DKIMSigner::sha256Base64(const std::string &data) { + unsigned char hash[EVP_MAX_MD_SIZE]; + unsigned int hashLen = 0; + EVP_Digest(data.data(), data.size(), hash, &hashLen, EVP_sha256(), nullptr); + return base64(std::string((char *)hash, hashLen)); + } + + std::string DKIMSigner::rsaSha256SignBase64(const std::string &data) { + EVP_MD_CTX *mdctx = EVP_MD_CTX_new(); + std::string result; + if (EVP_DigestSignInit(mdctx, nullptr, EVP_sha256(), nullptr, privateKey) == 1 && + EVP_DigestSignUpdate(mdctx, data.data(), data.size()) == 1) { + size_t sigLen = 0; + EVP_DigestSignFinal(mdctx, nullptr, &sigLen); + std::vector sig(sigLen); + if (EVP_DigestSignFinal(mdctx, sig.data(), &sigLen) == 1) + result = base64(std::string((char *)sig.data(), sigLen)); + } + EVP_MD_CTX_free(mdctx); + if (result.empty()) + throw std::runtime_error("DKIMSigner: RSA-SHA256 signing failed"); + return result; + } + + std::string DKIMSigner::sign(const std::string &message) { + + std::vector headers = parseHeaders(message); + + // Sign whichever of these standard headers the message actually + // has - From is the one DMARC alignment actually depends on, the + // rest are along for integrity (a receiver seeing a signed + // Subject/Date/Message-ID that doesn't match the visible one is + // exactly the tampering DKIM exists to catch). Deliberately not + // signing headers this relay itself can still legitimately need + // to touch or add downstream of this point. + static const char *toSign[] = {"From", "To", "Subject", "Date", "Message-ID"}; + + std::string signedHeaderNames; + std::string headerCanon; + bool first = true; + for (const char *wanted : toSign) { + for (auto &f : headers) { + bool matches = f.name.size() == strlen(wanted); + if (matches) + for (size_t i = 0; i < f.name.size() && matches; ++i) + matches = std::tolower((unsigned char)f.name[i]) == std::tolower((unsigned char)wanted[i]); + if (matches) { + headerCanon += canonicalizeHeaderField(f.name, f.value); + if (!first) + signedHeaderNames += ":"; + signedHeaderNames += wanted; + first = false; + break; + } + } + } + + std::string bodyHash = sha256Base64(canonicalizeBody(bodyOf(message))); + + std::stringstream tagsNoSig; + tagsNoSig << "v=1; a=rsa-sha256; c=relaxed/relaxed; d=" << domain << "; s=" << selector + << "; t=" << (long)time(nullptr) << "; h=" << signedHeaderNames + << "; bh=" << bodyHash << "; b="; + + // The signature covers the selected headers (in the order signed + // above) followed by this DKIM-Signature field itself, with an + // empty b= - unlike every other signed header, this last one is + // included WITHOUT a trailing CRLF (RFC 6376 3.7): the field + // being created doesn't have one yet, since nothing follows it + // until sign() returns. + std::string signingInput = headerCanon + canonicalizeHeaderField("DKIM-Signature", tagsNoSig.str()); + signingInput.erase(signingInput.size() - 2); // drop the trailing CRLF just for this last field + + std::string signature = rsaSha256SignBase64(signingInput); + + return "DKIM-Signature: " + tagsNoSig.str() + signature + "\r\n"; + } + +} diff --git a/DKIMSigner.h b/DKIMSigner.h new file mode 100644 index 0000000..1c6edfb --- /dev/null +++ b/DKIMSigner.h @@ -0,0 +1,64 @@ +#ifndef __DKIMSigner_h__ +#define __DKIMSigner_h__ + +#include +#include +#include + +namespace mail { + + /// + /// DKIMSigner produces a DKIM-Signature header (RFC 6376) for an + /// outbound message, using the relaxed/relaxed canonicalization and + /// rsa-sha256, the combination essentially every real deployment + /// uses (relaxed tolerates the whitespace/line-ending massaging a + /// message can pick up in transit; simple does not, and breaks + /// easily as a result). + /// + /// One DKIMSigner is built for one (domain, selector, private key) + /// triple and can sign any number of messages with it - construction + /// loads and parses the private key once; sign() itself does no I/O. + /// + + class DKIMSigner { + + public: + DKIMSigner(std::string domain, std::string selector, std::string privateKeyPath); + ~DKIMSigner(); + + /// + /// message is the complete outbound message exactly as it will be + /// sent in the DATA phase - CRLF-terminated header lines, a blank + /// line, then the body. Returns a complete "DKIM-Signature: + /// ...\r\n" line, ready to prepend to `message` as its new first + /// header. + /// + + std::string sign(const std::string &message); + + private: + struct HeaderField { + std::string name; // original case, as it appeared + std::string value; // unfolded (continuation lines joined), not yet whitespace-collapsed + }; + + std::vector parseHeaders(const std::string &message); + std::string bodyOf(const std::string &message); + + std::string collapseWSP(const std::string &s); + std::string canonicalizeHeaderField(const std::string &name, const std::string &value); + std::string canonicalizeBody(const std::string &body); + + std::string base64(const std::string &raw); + std::string sha256Base64(const std::string &data); + std::string rsaSha256SignBase64(const std::string &data); + + std::string domain; + std::string selector; + EVP_PKEY *privateKey; + + }; + +} + +#endif diff --git a/DKIMVerifier.cpp b/DKIMVerifier.cpp new file mode 100644 index 0000000..9cc6ee3 --- /dev/null +++ b/DKIMVerifier.cpp @@ -0,0 +1,405 @@ +#include "DKIMVerifier.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mail { + + namespace { + + struct RawHeader { + std::string name; // original case + std::string rawText; // exact bytes as they appeared, folding included, ending in CRLF + std::string unfoldedValue; // continuation lines joined with a single space, not yet WSP-collapsed + }; + + std::vector parseRawHeaders(const std::string &message) { + std::vector headers; + size_t headerEnd = message.find("\r\n\r\n"); + std::string block = (headerEnd == std::string::npos) ? message : message.substr(0, headerEnd); + + std::vector rawLines; + size_t pos = 0; + while (pos < block.size()) { + size_t next = block.find("\r\n", pos); + std::string line = (next == std::string::npos) ? block.substr(pos) : block.substr(pos, next - pos); + rawLines.push_back(line); + if (next == std::string::npos) + break; + pos = next + 2; + } + + for (auto &line : rawLines) { + if (line.empty()) + continue; + if ((line[0] == ' ' || line[0] == '\t') && !headers.empty()) { + headers.back().rawText += line + "\r\n"; + headers.back().unfoldedValue += " " + line; + continue; + } + size_t colon = line.find(':'); + if (colon == std::string::npos) + continue; + RawHeader h; + h.name = line.substr(0, colon); + h.rawText = line + "\r\n"; + h.unfoldedValue = line.substr(colon + 1); + headers.push_back(h); + } + return headers; + } + + std::string bodyOf(const std::string &message) { + size_t headerEnd = message.find("\r\n\r\n"); + if (headerEnd == std::string::npos) + return ""; + return message.substr(headerEnd + 4); + } + + std::string collapseWSP(const std::string &s) { + std::string result; + bool lastWasSpace = false; + for (char c : s) { + if (c == ' ' || c == '\t') { + if (!lastWasSpace) + result += ' '; + lastWasSpace = true; + } else { + result += c; + lastWasSpace = false; + } + } + size_t start = 0; + while (start < result.size() && result[start] == ' ') + ++start; + size_t end = result.size(); + while (end > start && result[end - 1] == ' ') + --end; + return result.substr(start, end - start); + } + + // RFC 6376 3.4.2 (relaxed) - as in DKIMSigner. Returns rawText + // unmodified for 3.4.1 (simple), which requires the header field + // exactly as it appeared, folding included. + std::string canonicalizeHeaderField(bool simple, const RawHeader &h) { + if (simple) + return h.rawText; + std::string lowerName = h.name; + for (char &c : lowerName) + c = std::tolower((unsigned char)c); + return lowerName + ":" + collapseWSP(h.unfoldedValue) + "\r\n"; + } + + // For synthesizing the DKIM-Signature field itself (with b= + // emptied) under simple canonicalization, which needs the same + // "exactly as it appeared" rule applied to a header this code + // built rather than one that was actually received on the wire - + // so "exactly as it appeared" here means exactly the tag text, + // with the SAME folding (none) it already has. + std::string canonicalizeSyntheticHeader(bool simple, const std::string &name, const std::string &value) { + if (simple) + return name + ":" + value + "\r\n"; + std::string lowerName = name; + for (char &c : lowerName) + c = std::tolower((unsigned char)c); + return lowerName + ":" + collapseWSP(value) + "\r\n"; + } + + std::string canonicalizeBody(bool simple, const std::string &body) { + std::vector lines; + size_t pos = 0; + while (pos < body.size()) { + size_t next = body.find("\r\n", pos); + if (next == std::string::npos) { + lines.push_back(body.substr(pos)); + break; + } + lines.push_back(body.substr(pos, next - pos)); + pos = next + 2; + } + + if (!simple) { + for (auto &line : lines) { + std::string collapsed; + bool lastWasSpace = false; + for (char c : line) { + if (c == ' ' || c == '\t') { + if (!lastWasSpace) + collapsed += ' '; + lastWasSpace = true; + } else { + collapsed += c; + lastWasSpace = false; + } + } + while (!collapsed.empty() && collapsed.back() == ' ') + collapsed.pop_back(); + line = collapsed; + } + } + + while (!lines.empty() && lines.back().empty()) + lines.pop_back(); + + std::string result; + for (auto &line : lines) { + result += line; + result += "\r\n"; + } + if (result.empty()) + result = "\r\n"; + return result; + } + + std::string base64Decode(const std::string &encoded) { + BIO *b64 = BIO_new(BIO_f_base64()); + BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL); + BIO *mem = BIO_new_mem_buf(encoded.data(), (int)encoded.size()); + BIO_push(b64, mem); + std::string out(encoded.size(), '\0'); + int len = BIO_read(b64, out.data(), (int)out.size()); + BIO_free_all(b64); + if (len <= 0) + return ""; + out.resize(len); + return out; + } + + std::string sha1or256Base64(bool useSha1, const std::string &data) { + unsigned char hash[EVP_MAX_MD_SIZE]; + unsigned int hashLen = 0; + EVP_Digest(data.data(), data.size(), hash, &hashLen, useSha1 ? EVP_sha1() : EVP_sha256(), nullptr); + BIO *b64 = BIO_new(BIO_f_base64()); + BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL); + BIO *mem = BIO_new(BIO_s_mem()); + BIO_push(b64, mem); + BIO_write(b64, hash, (int)hashLen); + BIO_flush(b64); + char *encodedData = nullptr; + long encodedLen = BIO_get_mem_data(mem, &encodedData); + std::string result(encodedData, encodedLen); + BIO_free_all(b64); + return result; + } + + // Fetches the signer's public key: TXT record at + // ._domainkey. (RFC 6376 3.6.1/3.6.2.2), tag + // format "v=DKIM1; k=rsa; p=" - v= + // and k= are optional (default to exactly this), only p= is + // actually required. An empty p= is a published, deliberate key + // revocation (6376 3.6.1) and correctly fails to load as a key. + EVP_PKEY *fetchPublicKey(const std::string &selector, const std::string &domain) { + std::string queryName = selector + "._domainkey." + domain; + unsigned char answer[4096]; + int len = res_query(queryName.c_str(), C_IN, T_TXT, answer, sizeof(answer)); + if (len <= 0) + return nullptr; + + ns_msg handle; + if (ns_initparse(answer, len, &handle) != 0) + return nullptr; + + std::string record; + int count = ns_msg_count(handle, ns_s_an); + for (int i = 0; i < count && record.empty(); ++i) { + ns_rr rr; + if (ns_parserr(&handle, ns_s_an, i, &rr) != 0 || ns_rr_type(rr) != ns_t_txt) + continue; + const unsigned char *rdata = ns_rr_rdata(rr); + int rdlen = ns_rr_rdlen(rr); + std::string full; + int pos = 0; + while (pos < rdlen) { + int segLen = rdata[pos]; + ++pos; + if (pos + segLen > rdlen) + break; + full.append((const char *)(rdata + pos), segLen); + pos += segLen; + } + if (full.find("p=") != std::string::npos) + record = full; + } + if (record.empty()) + return nullptr; + + std::string p; + std::istringstream stream(record); + std::string tag; + while (std::getline(stream, tag, ';')) { + size_t start = tag.find_first_not_of(" \t"); + if (start == std::string::npos) + continue; + tag = tag.substr(start); + if (tag.rfind("p=", 0) == 0) { + p = tag.substr(2); + // Public key TXT records are often split across several + // quoted strings by the zone editor for readability - + // res_query()/ns_parserr() already concatenated the RR's + // own multiple character-strings, but stray whitespace + // between them (or trailing junk after the base64) still + // needs stripping before base64 decoding. + std::string cleaned; + for (char c : p) + if (!std::isspace((unsigned char)c)) + cleaned += c; + p = cleaned; + } + } + if (p.empty()) + return nullptr; // revoked key (6376 3.6.1) or malformed record + + std::string der = base64Decode(p); + if (der.empty()) + return nullptr; + const unsigned char *derPtr = (const unsigned char *)der.data(); + return d2i_PUBKEY(nullptr, &derPtr, (long)der.size()); + } + + } + + std::vector DKIMVerifier::verify(const std::string &message) { + + std::vector results; + std::vector headers = parseRawHeaders(message); + std::string body = bodyOf(message); + + for (size_t sigIndex = 0; sigIndex < headers.size(); ++sigIndex) { + if (headers[sigIndex].name.size() != 14) continue; + bool isDkimSig = true; + { + static const char *want = "DKIM-Signature"; + for (size_t i = 0; i < 14 && isDkimSig; ++i) + isDkimSig = std::tolower((unsigned char)headers[sigIndex].name[i]) == std::tolower((unsigned char)want[i]); + } + if (!isDkimSig) + continue; + + SignatureResult result; + + // Parse this DKIM-Signature's own tags. + std::string tagStr = headers[sigIndex].unfoldedValue; + std::string a = "rsa-sha256", c = "simple/simple", d, s, h, bh, b; + std::istringstream stream(tagStr); + std::string part; + while (std::getline(stream, part, ';')) { + size_t start = part.find_first_not_of(" \t"); + if (start == std::string::npos) + continue; + part = part.substr(start); + size_t eq = part.find('='); + if (eq == std::string::npos) + continue; + std::string key = part.substr(0, eq); + std::string value = part.substr(eq + 1); + while (!value.empty() && std::isspace((unsigned char)value.back())) + value.pop_back(); + if (key == "a") a = value; + else if (key == "c") c = value; + else if (key == "d") d = value; + else if (key == "s") s = value; + else if (key == "h") h = value; + else if (key == "bh") bh = value; + else if (key == "b") { std::string cleaned; for (char ch : value) if (!std::isspace((unsigned char)ch)) cleaned += ch; b = cleaned; } + } + + result.domain = d; + result.selector = s; + + if (d.empty() || s.empty() || bh.empty() || b.empty()) { + results.push_back(result); // valid=false: malformed signature + continue; + } + + bool simpleHeader = (c.rfind("simple", 0) == 0); + bool simpleBody = (c.find('/') != std::string::npos) ? (c.substr(c.find('/') + 1).rfind("simple", 0) == 0) + : simpleHeader; // "c=simple" alone means simple/simple + + bool useSha1 = (a == "rsa-sha1"); + + std::string computedBH = sha1or256Base64(useSha1, canonicalizeBody(simpleBody, body)); + if (computedBH != bh) { + results.push_back(result); // body was altered in transit - valid=false + continue; + } + + // Build the signing input the same way DKIMSigner does: every + // header named in h=, in that order, canonicalized per c='s + // header algorithm, followed by this DKIM-Signature field + // itself (with b= emptied) - also canonicalized, and, for + // simple canonicalization specifically, using the ORIGINAL + // header exactly as received up through "b=" (nothing after + // it), not a re-synthesized one, since simple canonicalization + // forbids changing header text at all. + std::string signingInput; + std::istringstream hstream(h); + std::string wanted; + while (std::getline(hstream, wanted, ':')) { + for (auto &hdr : headers) { + if (hdr.name.size() == wanted.size()) { + bool match = true; + for (size_t i = 0; i < wanted.size() && match; ++i) + match = std::tolower((unsigned char)hdr.name[i]) == std::tolower((unsigned char)wanted[i]); + if (match) { + signingInput += canonicalizeHeaderField(simpleHeader, hdr); + break; + } + } + } + } + + size_t bPos = tagStr.rfind("b="); + std::string tagsUpToB = tagStr.substr(0, bPos + 2); + std::string dkimFieldCanon; + if (simpleHeader) { + // rawText already reads "DKIM-Signature: v=1; ...; b=..." in + // full (name, colon, and value all together, folding + // preserved) - simple canonicalization requires using that + // exact original text unmodified, just truncated right after + // the literal "b=" so the actual signature value isn't + // included (RFC 6376 3.7). + size_t rawBPos = headers[sigIndex].rawText.find("b="); + dkimFieldCanon = (rawBPos == std::string::npos) + ? canonicalizeSyntheticHeader(true, headers[sigIndex].name, tagsUpToB) + : headers[sigIndex].rawText.substr(0, rawBPos + 2); + } else { + dkimFieldCanon = canonicalizeSyntheticHeader(false, headers[sigIndex].name, tagsUpToB); + } + // The CRLF that would terminate this header field MUST NOT be + // included (RFC 6376 3.7) - it doesn't exist yet from the + // signer's perspective. canonicalizeSyntheticHeader's relaxed + // path always appends one; the simple path (a rawText prefix + // ending right at "b=") never had one to begin with. + if (dkimFieldCanon.size() >= 2 && dkimFieldCanon.substr(dkimFieldCanon.size() - 2) == "\r\n") + dkimFieldCanon.erase(dkimFieldCanon.size() - 2); + + signingInput += dkimFieldCanon; + + EVP_PKEY *pubkey = fetchPublicKey(s, d); + if (!pubkey) { + results.push_back(result); // no key published, or revoked - valid=false + continue; + } + + std::string sigBytes = base64Decode(b); + EVP_MD_CTX *mdctx = EVP_MD_CTX_new(); + int rc = 0; + if (EVP_DigestVerifyInit(mdctx, nullptr, useSha1 ? EVP_sha1() : EVP_sha256(), nullptr, pubkey) == 1) + rc = EVP_DigestVerify(mdctx, (const unsigned char *)sigBytes.data(), sigBytes.size(), + (const unsigned char *)signingInput.data(), signingInput.size()); + EVP_MD_CTX_free(mdctx); + EVP_PKEY_free(pubkey); + + result.valid = (rc == 1); + results.push_back(result); + } + + return results; + } + +} diff --git a/DKIMVerifier.h b/DKIMVerifier.h new file mode 100644 index 0000000..faef7df --- /dev/null +++ b/DKIMVerifier.h @@ -0,0 +1,50 @@ +#ifndef __DKIMVerifier_h__ +#define __DKIMVerifier_h__ + +#include +#include + +namespace mail { + + /// + /// DKIMVerifier checks the DKIM-Signature header(s) (RFC 6376) on an + /// already-fully-received message: for each one present, fetches the + /// claimed signer's public key from DNS and verifies the signature + /// against it. Exists specifically to support DMARC's DKIM-alignment + /// check (DMARCChecker) - a signature that verifies but whose d= + /// domain does not align with the message's From: header doesn't + /// make the message DMARC-authenticated via DKIM, so both the + /// cryptographic result AND the signing domain are reported back for + /// DMARC to judge alignment itself. + /// + /// A message may carry more than one DKIM-Signature (e.g. one from + /// the original sender, one added by a mailing list) - every one + /// present is verified independently. + /// + /// Unlike DKIMSigner (which only ever produces relaxed/relaxed, + /// rsa-sha256 signatures of its own), this has to interoperate with + /// whatever a real remote sender actually used, so it supports all + /// four canonicalization combinations (simple/relaxed for header/body) + /// a real DKIM-Signature's c= tag can specify. Does not implement + /// signature expiry (x=) checking or Ed25519 (RFC 8463) - both real + /// gaps, but the overwhelming majority of real-world DKIM signatures + /// today are plain rsa-sha256 with no expiry set. + /// + + class DKIMVerifier { + + public: + + struct SignatureResult { + bool valid = false; + std::string domain; // d= tag - the identity DMARC needs to align against + std::string selector; // s= tag + }; + + static std::vector verify(const std::string &message); + + }; + +} + +#endif diff --git a/DMARCChecker.cpp b/DMARCChecker.cpp new file mode 100644 index 0000000..5a3c345 --- /dev/null +++ b/DMARCChecker.cpp @@ -0,0 +1,171 @@ +#include "DMARCChecker.h" +#include +#include +#include +#include + +namespace mail { + + namespace { + + std::vector lookupDMARCRecords(const std::string &domain) { + std::vector records; + std::string queryName = "_dmarc." + domain; + unsigned char answer[4096]; + int len = res_query(queryName.c_str(), C_IN, T_TXT, answer, sizeof(answer)); + if (len <= 0) + return records; + ns_msg handle; + if (ns_initparse(answer, len, &handle) != 0) + return records; + int count = ns_msg_count(handle, ns_s_an); + for (int i = 0; i < count; ++i) { + ns_rr rr; + if (ns_parserr(&handle, ns_s_an, i, &rr) != 0 || ns_rr_type(rr) != ns_t_txt) + continue; + const unsigned char *rdata = ns_rr_rdata(rr); + int rdlen = ns_rr_rdlen(rr); + std::string full; + int pos = 0; + while (pos < rdlen) { + int segLen = rdata[pos]; + ++pos; + if (pos + segLen > rdlen) + break; + full.append((const char *)(rdata + pos), segLen); + pos += segLen; + } + std::string lower = full; + for (char &c : lower) + c = std::tolower((unsigned char)c); + if (lower.rfind("v=dmarc1", 0) == 0) + records.push_back(full); + } + return records; + } + + void parseTags(const std::string &record, DMARCChecker::Policy &p) { + std::istringstream stream(record); + std::string part; + while (std::getline(stream, part, ';')) { + size_t start = part.find_first_not_of(" \t"); + if (start == std::string::npos) + continue; + part = part.substr(start); + size_t eq = part.find('='); + if (eq == std::string::npos) + continue; + std::string key = part.substr(0, eq); + std::string value = part.substr(eq + 1); + while (!value.empty() && std::isspace((unsigned char)value.back())) + value.pop_back(); + for (char &c : key) + c = std::tolower((unsigned char)c); + if (key == "p") + p.policy = value; + else if (key == "sp") + p.subdomainPolicy = value; + else if (key == "adkim") + p.strictDKIM = (value == "s"); + else if (key == "aspf") + p.strictSPF = (value == "s"); + else if (key == "pct") + p.percent = atoi(value.c_str()); + } + if (p.subdomainPolicy.empty()) + p.subdomainPolicy = p.policy; + } + + } + + std::string DMARCChecker::organizationalDomain(const std::string &domain) { + std::vector labels; + std::istringstream stream(domain); + std::string label; + while (std::getline(stream, label, '.')) + if (!label.empty()) + labels.push_back(label); + if (labels.size() <= 2) + return domain; + return labels[labels.size() - 2] + "." + labels[labels.size() - 1]; + } + + bool DMARCChecker::aligned(const std::string &identityDomain, const std::string &fromDomain, bool strict) { + if (identityDomain.empty() || fromDomain.empty()) + return false; + auto lower = [](std::string s) { for (char &c : s) c = std::tolower((unsigned char)c); return s; }; + if (strict) + return lower(identityDomain) == lower(fromDomain); + return lower(organizationalDomain(identityDomain)) == lower(organizationalDomain(fromDomain)); + } + + bool DMARCChecker::lookupPolicy(const std::string &domain, Policy &policyOut) { + std::vector records = lookupDMARCRecords(domain); + if (records.size() == 1) { + parseTags(records[0], policyOut); + policyOut.found = true; + return true; + } + if (records.size() > 1) + return false; // RFC 7489 6.6.3: more than one record is treated as none published + + // RFC 7489 6.6.3: no record at the exact domain falls back to the + // organizational domain's own record (using ITS sp=, or p= if sp= + // is absent, as the effective policy for this subdomain) - but + // only when domain actually IS a subdomain of something else, or + // this would just re-query the same name again. + std::string org = organizationalDomain(domain); + if (org == domain) + return false; + std::vector orgRecords = lookupDMARCRecords(org); + if (orgRecords.size() != 1) + return false; + Policy orgPolicy; + parseTags(orgRecords[0], orgPolicy); + policyOut = orgPolicy; + policyOut.policy = orgPolicy.subdomainPolicy; // sp= (or p=, via parseTags' own fallback) governs here + policyOut.found = true; + return true; + } + + DMARCChecker::Result DMARCChecker::evaluate(const std::string &fromDomain, const std::string &spfDomain, + SPFChecker::Result spfResult, + const std::vector &dkimResults, + Policy &appliedPolicy) { + if (fromDomain.empty() || !lookupPolicy(fromDomain, appliedPolicy)) + return Result::None; + + bool spfAligned = (spfResult == SPFChecker::Result::Pass) && aligned(spfDomain, fromDomain, appliedPolicy.strictSPF); + + bool dkimAligned = false; + for (auto &d : dkimResults) + if (d.valid && aligned(d.domain, fromDomain, appliedPolicy.strictDKIM)) { + dkimAligned = true; + break; + } + + return (spfAligned || dkimAligned) ? Result::Pass : Result::Fail; + } + + std::string DMARCChecker::resultName(Result result) { + switch (result) { + case Result::Pass: return "pass"; + case Result::Fail: return "fail"; + case Result::None: return "none"; + } + return "none"; + } + + std::string DMARCChecker::authenticationResultsHeader(SPFChecker::Result spfResult, const std::string &spfDomain, + const std::vector &dkimResults, + Result dmarcResult, const std::string &fromDomain, + const std::string &receivingHost) { + std::string h = "Authentication-Results: " + receivingHost + ";\r\n"; + h += "\tspf=" + SPFChecker::resultName(spfResult) + " smtp.mailfrom=" + spfDomain + ";\r\n"; + for (auto &d : dkimResults) + h += "\tdkim=" + std::string(d.valid ? "pass" : "fail") + " header.d=" + d.domain + " header.s=" + d.selector + ";\r\n"; + h += "\tdmarc=" + resultName(dmarcResult) + " header.from=" + fromDomain + ";\r\n"; + return h; + } + +} diff --git a/DMARCChecker.h b/DMARCChecker.h new file mode 100644 index 0000000..951f9ab --- /dev/null +++ b/DMARCChecker.h @@ -0,0 +1,93 @@ +#ifndef __DMARCChecker_h__ +#define __DMARCChecker_h__ + +#include "DKIMVerifier.h" +#include "SPFChecker.h" +#include +#include + +namespace mail { + + /// + /// DMARCChecker implements RFC 7489: a message is DMARC-authenticated + /// if EITHER SPF or DKIM passed AND is "aligned" with the RFC 5322 + /// From: header's domain (the identity a human actually sees) - SPF + /// alone checks the envelope sender, DKIM alone checks whatever + /// domain signed it, and neither by itself stops a message whose + /// From: header claims a domain that had nothing to do with either + /// check. Requires both SPFChecker's result and DKIMVerifier's + /// results as input, since DMARC is defined entirely in terms of + /// those two other mechanisms' outcomes plus the alignment check. + /// + /// Not implemented: the RFC 7489 Public Suffix List-based + /// organizational-domain derivation for relaxed alignment - the + /// PSL is a several-thousand-entry external data file this codebase + /// has no copy of and no fetch mechanism for. Approximated instead + /// by comparing the last two dot-separated labels (e.g. treating + /// "mail.example.com" and "example.com" as aligned via + /// "example.com"), which is correct for ordinary domains but wrong + /// for registrations directly under a multi-label public suffix + /// (e.g. "example.co.uk" and "other.co.uk" would incorrectly be + /// treated as aligned via "co.uk"). Exact (strict) alignment, + /// requested via adkim=s/aspf=s, is unaffected by this and is exact + /// either way. + /// + + class DMARCChecker { + + public: + + enum class Result { Pass, Fail, None }; + + struct Policy { + bool found = false; + std::string policy = "none"; // p= + std::string subdomainPolicy; // sp= (defaults to policy if absent) + bool strictDKIM = false; // adkim=s (default r, relaxed) + bool strictSPF = false; // aspf=s (default r, relaxed) + int percent = 100; // pct= + }; + + /// + /// fromDomain: the RFC 5322 From: header's domain (what a human + /// reading the message actually sees as the sender). + /// spfDomain/spfResult: the identity SPF was actually evaluated + /// against (RFC 7208 2.4 - the MAIL FROM domain, or HELO's for a + /// null reverse-path) and its result. + /// dkimResults: every DKIM-Signature DKIMVerifier found on this + /// message, verified or not. + /// appliedPolicy is filled in with the policy that was actually + /// looked up (found==false if none was published at all - the + /// domain isn't participating in DMARC, so this always resolves + /// to Result::None regardless of alignment). + /// + + static Result evaluate(const std::string &fromDomain, const std::string &spfDomain, SPFChecker::Result spfResult, + const std::vector &dkimResults, Policy &appliedPolicy); + + static std::string resultName(Result result); + + /// + /// A DMARC-aware Authentication-Results header (RFC 8601) - + /// summarizes spf=/dkim=/dmarc= together, the modern convention + /// downstream filtering actually looks for (Received-SPF alone, + /// which __SMTP_MAIL.cpp already adds, predates DMARC and doesn't + /// record dkim/dmarc results at all). + /// + + static std::string authenticationResultsHeader(SPFChecker::Result spfResult, const std::string &spfDomain, + const std::vector &dkimResults, + Result dmarcResult, const std::string &fromDomain, + const std::string &receivingHost); + + private: + + static bool lookupPolicy(const std::string &domain, Policy &policyOut); + static std::string organizationalDomain(const std::string &domain); + static bool aligned(const std::string &identityDomain, const std::string &fromDomain, bool strict); + + }; + +} + +#endif diff --git a/IMAPArgs.cpp b/IMAPArgs.cpp new file mode 100644 index 0000000..7c15594 --- /dev/null +++ b/IMAPArgs.cpp @@ -0,0 +1,154 @@ +#include "IMAPArgs.h" +#include "MailFolder.h" +#include +#include +#include +#include +#include + +namespace mail { + + namespace IMAPArgs { + + coreutils::ZString nextArgument(coreutils::ZString &request) { + request.skipWhitespace(); + if (request.eod()) + return coreutils::ZString(); + char c = request.getChar(); + if (c == '"' || c == '(' || c == '[' || c == '{') + return request.getContainer(); + return request.getTokenExclude(" "); + } + + bool isKeyword(coreutils::ZString &argument, const char *keyword) { + std::string a = argument.str(); + std::string b = keyword; + if (a.size() != b.size()) + return false; + for (size_t i = 0; i < a.size(); ++i) + if (std::toupper((unsigned char)a[i]) != std::toupper((unsigned char)b[i])) + return false; + return true; + } + + std::string unwrap(coreutils::ZString &argument) { + std::string value = argument.str(); + if (value.size() >= 2) { + char first = value.front(); + char last = value.back(); + if ((first == '"' && last == '"') || + (first == '(' && last == ')') || + (first == '[' && last == ']') || + (first == '{' && last == '}')) + return value.substr(1, value.size() - 2); + } + return value; + } + + std::vector parseSequenceSet(coreutils::ZString &request, MailFolder &folder, bool uidMode) { + std::vector result; + + // Always resolves to a concrete UID, regardless of uidMode - + // callers below (including the range-walking code, which + // re-derives sequence numbers from these UIDs via folder.seq()) + // depend on that. + auto resolveNumber = [&](const std::string &token) -> uint32_t { + if (token == "*") + return folder.size() == 0 ? 0 : folder.messages().back().uid; + uint32_t value = (uint32_t)std::strtoul(token.c_str(), nullptr, 10); + if (uidMode) + return folder.find(value) ? value : 0; + return folder.uidForSeq(value); + }; + + coreutils::ZString set = nextArgument(request); + std::string setStr = set.str(); + std::istringstream stream(setStr); + std::string part; + while (std::getline(stream, part, ',')) { + if (part.empty()) + continue; + size_t colon = part.find(':'); + if (colon == std::string::npos) { + uint32_t uid = resolveNumber(part); + if (uid != 0) + result.push_back(uid); + } else { + std::string fromStr = part.substr(0, colon); + std::string toStr = part.substr(colon + 1); + uint32_t fromUid = resolveNumber(fromStr); + uint32_t toUid = resolveNumber(toStr); + if (fromUid == 0 || toUid == 0) + continue; + if (fromUid > toUid) + std::swap(fromUid, toUid); + // resolveNumber() already turned both ends into concrete + // UIDs regardless of uidMode - walking sequence-number- + // by-sequence-number (not uid-by-uid) from here is + // deliberate: a UID range like "5:9" must include every + // message whose UID falls in that range, not just + // messages whose UID happens to equal one of the + // integers 5-9 (UIDs aren't dense/contiguous once + // anything's been expunged). + uint32_t fromSeq = folder.seq(fromUid); + uint32_t toSeq = folder.seq(toUid); + if (fromSeq == 0 || toSeq == 0) + continue; + for (uint32_t s = fromSeq; s <= toSeq; ++s) + result.push_back(folder.uidForSeq(s)); + } + } + + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; + } + + std::vector parseFlagList(coreutils::ZString &request) { + coreutils::ZString arg = nextArgument(request); + std::string text = unwrap(arg); + std::vector flags; + std::istringstream stream(text); + std::string token; + while (stream >> token) + flags.push_back(token); + return flags; + } + + time_t parseDate(coreutils::ZString &argument) { + std::string text = unwrap(argument); + struct tm tmValue = {}; + // Try the full APPEND date-time form first, then fall back to + // SEARCH's bare date form. + if (strptime(text.c_str(), "%d-%b-%Y %H:%M:%S %z", &tmValue) != nullptr) + return timegm(&tmValue) - tmValue.tm_gmtoff; + tmValue = {}; + if (strptime(text.c_str(), "%d-%b-%Y", &tmValue) != nullptr) + return timegm(&tmValue); + return (time_t)-1; + } + + bool isValidFolderName(const std::string &name) { + if (name.empty()) + return false; + if (name.find('/') != std::string::npos) + return false; + if (name[0] == '.') + return false; + return true; + } + + std::string normalizeFolderName(const std::string &name) { + if (name.size() == 5) { + std::string upper = name; + for (char &c : upper) + c = std::toupper((unsigned char)c); + if (upper == "INBOX") + return "Inbox"; + } + return name; + } + + } + +} diff --git a/IMAPArgs.h b/IMAPArgs.h new file mode 100644 index 0000000..4a9ceea --- /dev/null +++ b/IMAPArgs.h @@ -0,0 +1,125 @@ +#ifndef __IMAPArgs_h__ +#define __IMAPArgs_h__ + +#include "ZString.h" +#include +#include +#include +#include + +namespace mail { + + class MailFolder; + + /// + /// Shared argument parsing for every IMAP command handler. + /// + /// CommandList::processRequest() (ServerCore) pre-splits a command + /// line on literal spaces before any handler sees it - fine for SMTP, + /// wrong for IMAP, whose arguments routinely contain quoted strings + /// and parenthesized lists with embedded spaces + /// (LOGIN "user name" pw, STORE 1 +FLAGS (\Seen \Flagged)). Handlers + /// must never read request[N]; instead they call nextArgument() + /// repeatedly against the same ZString&, which parses off the RAW, + /// unsplit cursor (request.reset(), already done by + /// CommandList::processRequest() before dispatch, put the cursor back + /// at the real start). + /// + + namespace IMAPArgs { + + /// + /// Skips leading whitespace, then returns the next IMAP argument: + /// an atom (run up to the next space), a quoted "..." string, or a + /// (...)/[...]/{...} container - all via ZString::getContainer(), + /// which already understands these four delimiter shapes, + /// INCLUDING their delimiters. Callers that want a quoted string's + /// interior, or a container's interior, strip the surrounding + /// characters themselves (different callers want different + /// things: FETCH wants to know it got a "[...]" section, STORE just + /// wants a plain flag list). Returns an empty ZString at end of + /// input. + /// + + coreutils::ZString nextArgument(coreutils::ZString &request); + + /// + /// Case-insensitive compare of an already-extracted argument + /// against a bare keyword (FLAGS, SEEN, BEFORE, ...). + /// + + bool isKeyword(coreutils::ZString &argument, const char *keyword); + + /// + /// Strips a single layer of "..." or (...) or [...] delimiters + /// from an argument previously returned by nextArgument(), if + /// present. Leaves an atom (no delimiters) unchanged. + /// + + std::string unwrap(coreutils::ZString &argument); + + /// + /// Parses an IMAP sequence-set ("1:5,7,9:*") against folder's + /// CURRENT message ordering. uidMode=true: the numbers in the set + /// are UIDs and "*" is the highest UID present; uidMode=false: + /// numbers are 1-based sequence numbers and "*" is the last + /// message. Numbers outside the folder's current bounds are + /// silently skipped (matches real server behavior - a client can + /// race a concurrent EXPUNGE). Returns the resolved UIDs, ascending + /// and de-duplicated. + /// + + std::vector parseSequenceSet(coreutils::ZString &request, MailFolder &folder, bool uidMode); + + /// + /// "(\Seen \Flagged)" -> {"\Seen", "\Flagged"}. Also accepts a + /// bare unparenthesized single flag, for STORE clients that omit + /// the parens around a single-flag list. + /// + + std::vector parseFlagList(coreutils::ZString &request); + + /// + /// RFC 3501 date-time ("DD-Mon-YYYY HH:MM:SS +ZZZZ", always + /// quoted - APPEND's optional date-time argument) or bare date + /// ("DD-Mon-YYYY", optionally quoted - SEARCH's BEFORE/ON/SINCE) -> + /// Unix timestamp. Returns -1 on unparseable input. + /// + + time_t parseDate(coreutils::ZString &argument); + + /// + /// RFC 3501 5.1: the mailbox name INBOX is reserved and MUST be + /// interpreted case-insensitively, referring to the user's + /// primary mailbox - every other mailbox name is server-defined + /// and compared literally. On-disk folders are named "Inbox", + /// "Spam", "Trash" (capitalized) - this maps any-case "inbox" to + /// that literal directory name and passes every other name + /// through unchanged. + /// + + std::string normalizeFolderName(const std::string &name); + + /// + /// Every folder-taking command turns a client-supplied name + /// straight into "/" (MailFileSystem:: + /// getFolderPath()). A name containing '/' escapes into a + /// subdirectory; a bare ".." - no slash, so a check for '/' alone + /// does NOT catch it - resolves to the mailbox's own PARENT + /// directory, which really exists and really is a directory, so + /// ifFolderExists() would return true for it: SELECT/DELETE/ + /// APPEND/RENAME would then operate on the directory holding + /// every OTHER user's mailbox on the same domain. Rejects empty, + /// anything containing '/', and anything starting with '.' + /// (which also reserves the leading-dot namespace for this + /// server's own index files - .password, .uidvalidity, .uidnext, + /// .imapindex, .subscriptions). + /// + + bool isValidFolderName(const std::string &name); + + } + +} + +#endif diff --git a/IMAPCommand.cpp b/IMAPCommand.cpp index e12497b..e4f6316 100644 --- a/IMAPCommand.cpp +++ b/IMAPCommand.cpp @@ -6,8 +6,39 @@ namespace mail { int IMAPCommand::processCommand(coreutils::ZString &request, core::TCPSession &session) { - return processCommand(request, (IMAPSession &)session, (IMAPServer &)session.server); + // session.server is a TCPServer* (pointer) - C-style-casting the + // POINTER VALUE directly to a reference type does not dereference + // it. Per C++'s reference-cast rules this falls back to + // reinterpret_cast, which for an expression that is not + // itself an object is *reinterpret_cast(&expr) - i.e. it took + // the address of the `session.server` POINTER FIELD ITSELF (8 + // bytes, wherever that field happens to live inside the + // TCPSession/IMAPSession object) and read an entire IMAPServer's + // worth of memory starting there, not the real IMAPServer object + // the pointer actually points to. Confirmed live via gdb: fields + // read off the "IMAPServer" this produced were complete garbage + // (a null EPoll reference, a socket descriptor of -136794095). + // The compiler's own "does not dereference pointer" warning on + // this exact line, present on every build all session, was + // reporting precisely this. Dereferencing the pointer first + // (*session.server, a real TCPServer&) makes the cast to + // IMAPServer& a normal, correct base-to-derived downcast. + // CommandList::processRequest() (ServerCore) calls request.reset() + // right before dispatch - the cursor lands at the true start of + // the whole command line, which STILL includes the command word + // itself ("LOGIN", "FETCH", ...), not just past it. SMTP handlers + // never noticed because they read arguments via request[N] (the + // pre-split list, built independently of cursor position) rather + // than the cursor - every IMAP handler here parses off the raw + // cursor instead (see IMAPArgs.h's own doc comment for why), so + // without this, every handler's first nextArgument() call was + // reading the command word itself as if it were the first real + // argument (confirmed live: LOGIN "user" "pass" produced + // userArg=="LOGIN", passArg=="user", with "pass" never read at + // all). Discarding it here, once, centrally, is simpler and less + // error-prone than every individual handler remembering to do it. + request.getTokenExclude(" "); + return processCommand(request, (IMAPSession &)session, (IMAPServer &)(*session.server)); } } - diff --git a/IMAPServer.h b/IMAPServer.h index e80cc85..b791ca1 100644 --- a/IMAPServer.h +++ b/IMAPServer.h @@ -2,10 +2,13 @@ # define __IMAPServer_h__ # include "TCPServer.h" +# include "TLSInfo.h" # include "Exception.h" +# include "IMAPSession.h" # include "MailFileSystem.h" # include "__IMAP_APPEND.h" # include "__IMAP_AUTHENTICATE.h" +# include "__IMAP_CAPABILITY.h" # include "__IMAP_CHECK.h" # include "__IMAP_CLOSE.h" # include "__IMAP_COPY.h" @@ -16,7 +19,9 @@ # include "__IMAP_FETCH.h" # include "__IMAP_LIST.h" # include "__IMAP_LOGIN.h" +# include "__IMAP_LOGOUT.h" # include "__IMAP_LSUB.h" +# include "__IMAP_NOOP.h" # include "__IMAP_RENAME.h" # include "__IMAP_SEARCH.h" # include "__IMAP_SELECT.h" @@ -32,21 +37,31 @@ namespace mail { class IMAPServer : public core::TCPServer { public: - IMAPServer(core::EPoll &epoll, std::string hostName, MailFileSystem &mailFileSystem, core::IPAddress ipAddress) - : TCPServer(epoll, ipAddress, " ", 10, "IMAP Server"), mailFileSystem(mailFileSystem), hostName(hostName) { + IMAPServer(core::EPoll &epoll, std::string hostName, MailFileSystem &mailFileSystem, + core::IPAddress ipAddress, core::TLSInfo *tlsInfo) + : TCPServer(epoll, ipAddress, tlsInfo, " ", 10, "IMAP Server"), mailFileSystem(mailFileSystem), hostName(hostName) { commands.add(_imap_append, "APPEND"); - commands.add(_imap_authenticate, "AUTHENTCATE"); + // Was registered as "AUTHENTCATE" (missing the I) - no + // correctly-spelled client request could ever match it. + commands.add(_imap_authenticate, "AUTHENTICATE"); + // RFC 3501 6.1.1/6.1.2/6.1.3: CAPABILITY, NOOP, and LOGOUT MUST + // be supported in every connection state - none of the three + // were registered at all before. + commands.add(_imap_capability, "CAPABILITY"); commands.add(_imap_check, "CHECK"); commands.add(_imap_close, "CLOSE"); commands.add(_imap_copy, "COPY"); commands.add(_imap_create, "CREATE"); commands.add(_imap_delete, "DELETE"); - commands.add(_imap_examine, "EXAAMINE"); + // Was registered as "EXAAMINE" (double A) - same problem. + commands.add(_imap_examine, "EXAMINE"); commands.add(_imap_expunge, "EXPUNGE"); commands.add(_imap_fetch, "FETCH"); commands.add(_imap_list, "LIST"); commands.add(_imap_login, "LOGIN"); + commands.add(_imap_logout, "LOGOUT"); commands.add(_imap_lsub, "LSUB"); + commands.add(_imap_noop, "NOOP"); commands.add(_imap_rename, "RENAME"); commands.add(_imap_search, "SEARCH"); commands.add(_imap_select, "SELECT"); @@ -60,10 +75,23 @@ namespace mail { MailFileSystem &mailFileSystem; std::string hostName; - + + // Without this override, TCPServer's default getSocketAccept() + // returns a plain TCPSession for every accepted connection, not + // an IMAPSession - IMAPSession::onConnected() (the greeting + // banner) then never fires, and IMAPCommand's session casts + // ((IMAPSession &)session) reinterpret a TCPSession's memory as + // an IMAPSession, undefined behavior the moment any handler + // touches a member IMAPSession actually has and TCPSession + // doesn't. + IMAPSession *getSocketAccept(core::EPoll &ePoll) override { + return new IMAPSession(ePoll, *this); + } + private: __IMAP_APPEND _imap_append; __IMAP_AUTHENTICATE _imap_authenticate; + __IMAP_CAPABILITY _imap_capability; __IMAP_CHECK _imap_check; __IMAP_CLOSE _imap_close; __IMAP_COPY _imap_copy; @@ -74,7 +102,9 @@ namespace mail { __IMAP_FETCH _imap_fetch; __IMAP_LIST _imap_list; __IMAP_LOGIN _imap_login; + __IMAP_LOGOUT _imap_logout; __IMAP_LSUB _imap_lsub; + __IMAP_NOOP _imap_noop; __IMAP_RENAME _imap_rename; __IMAP_SEARCH _imap_search; __IMAP_SELECT _imap_select; diff --git a/IMAPSession.h b/IMAPSession.h index e137125..a7de268 100644 --- a/IMAPSession.h +++ b/IMAPSession.h @@ -1,26 +1,88 @@ #ifndef __IMAPSession_h__ # define __IMAPSession_h__ +# include "MailFolder.h" +# include + # define CRLF "\r\n" namespace mail { - + class IMAPSession : public core::TCPSession { - public: + public: IMAPSession(core::EPoll &ePoll, core::TCPServer &server) - : TCPSession(ePoll, server, "IMAP Client Session") {} + : TCPSession(ePoll, &server, "IMAP Client Session") {} enum State { NOT_AUTHENTICATED, AUTHENTICATED, SELECTED }; std::string clientDomainName; std::string userName; std::string password; - State state = NOT_AUTHENTICATED; + std::string tag; + State state = NOT_AUTHENTICATED; + // Valid only while state == SELECTED - the folder SELECT/EXAMINE + // last loaded. readOnly is true for EXAMINE (STORE/EXPUNGE must + // be refused), false for SELECT. + MailFolder mailbox; + std::string selectedFolderName; + bool readOnly = false; + + // APPEND's message-body literal is the one place raw bytes (not + // command lines) arrive - see TCPSocket::setBlockSize()/ + // onBlockReceived(), unused anywhere else in this codebase before + // now. __IMAP_APPEND sets this to a closure that does the actual + // mailbox write (kept out of IMAPSession itself so this class + // doesn't need to know about MailFileSystem/MailFolder specifics - + // it only knows "a block finished, hand it off"), then calls + // setBlockSize(n) and sends the "+ OK" continuation. Cleared + // before invoking, so a handler that itself needs another literal + // (not currently used by anything) can safely set a new one. + std::function pendingBlockHandler; + + void onBlockReceived(coreutils::ZString &block) override { + if (pendingBlockHandler) { + auto handler = pendingBlockHandler; + pendingBlockHandler = nullptr; + handler(block); + } + } + + // Used to falsely claim to be Dovecot and advertise a capability + // list (LITERAL+/SASL-IR/LOGIN-REFERRALS/ID/ENABLE/IDLE/STARTTLS/ + // AUTH=PLAIN/AUTH=LOGIN) none of which had a real handler behind + // it - see __IMAP_CAPABILITY.cpp's own doc comment for the same + // reasoning applied to the CAPABILITY command's response. void onConnected() override { - out << "* OK [CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE STARTTLS AUTH=PLAIN AUTH=LOGIN] Dovecot (Ubuntu) ready." << CRLF; - } + out << "* OK [CAPABILITY IMAP4rev1] BARANTMail ready." << CRLF; + } + + protected: + + // Every IMAP command line is client-tag-prefixed ("a001 LOGIN ...", + // RFC 3501 2.2.1) - the tag is an arbitrary string the client + // chooses, not a fixed command name, so it can never appear in the + // command table CommandList::processRequest() dispatches on + // (ServerCore/CommandList.cpp looks up request[0], and for every + // real client request[0] is this tag, not the command word). + // Confirmed: with no tag-stripping anywhere, request[0] never + // matched any registered command, the lookup threw, and the + // catch-all in processRequest() silently swallowed it - no IMAP + // command from any spec-compliant client ever reached a handler. + // Extracting the tag here, before the generic space-delimited + // dispatch runs, leaves the command word where the base + // TCPSession::protocol()/CommandList expects to find it. + void protocol(coreutils::ZString &data) override { + if (data.getLength() == 0) { + TCPSession::protocol(data); + return; + } + tag = data.getTokenExclude(" ").str(); + data.skipWhitespace(); + coreutils::ZString command = data.unparsed(); + TCPSession::protocol(command); + } }; diff --git a/MailFileSystem.cpp b/MailFileSystem.cpp index c0ba553..2ab968c 100644 --- a/MailFileSystem.cpp +++ b/MailFileSystem.cpp @@ -2,11 +2,19 @@ #include "MailFileSystem.h" # include "INotify.h" # include "Log.h" +# include "File.h" +# include namespace mail { MailFileSystem::MailFileSystem(coreutils::ZString &mailPath) : mailPath(mailPath) { queuePath << mailPath << "/.queue/"; + sendLogPath << mailPath << "/.sendlog/"; + // Harmless if it already exists (EEXIST is ignored, same as + // every other error here - a failed mkdir just means the later + // File open in SendMail::deliver() throws instead, which does + // not affect whether the message itself is delivered). + mkdir(sendLogPath.c_str(), 0770); } bool MailFileSystem::ifMailBoxExists(coreutils::ZString &mailbox) { @@ -27,6 +35,16 @@ namespace mail { coreutils::MString MailFileSystem::getMailBoxPath(coreutils::ZString &mailbox) { mailbox.split("@", 1); + // A mailbox string with no "@" splits to a single-element list - + // mailbox[1] would then be an out-of-bounds std::vector access + // (ZString::operator[] doesn't bounds-check), not even a + // catchable exception. Previously unreachable with a safe input + // in practice; now reachable directly from a client-supplied IMAP + // LOGIN/POP3 USER username, so a malformed one (a bare "barant" + // instead of "barant@barant.com", an easy real typo) needs to + // fail cleanly here rather than read adjacent memory. + if (mailbox.getList().size() < 2) + return coreutils::MString(); coreutils::MString path; path << mailPath << "/" << mailbox[1] << "/" << mailbox[0]; return path; @@ -45,4 +63,54 @@ namespace mail { return queuePath; } + coreutils::ZString& MailFileSystem::getSendLogPath() { + return sendLogPath; + } + + bool MailFileSystem::checkPassword(coreutils::ZString &userName, coreutils::ZString &password) { + // File's constructor throws if the mailbox or its .password file + // doesn't exist - an unknown username needs to come back as "not + // authenticated", not an uncaught exception. Previously (as + // __SMTP_AUTH::authLogin(), before this logic moved here) this + // was only saved from crashing the whole process by + // CommandList::processRequest()'s own unrelated catch-all, which + // treated the exception as "could not be parsed" and silently + // dropped the reply entirely - a client attempting AUTH LOGIN (or + // now IMAP LOGIN, POP3 USER/PASS) against a nonexistent user got + // no response at all rather than a clean authentication failure. + try { + coreutils::MString secretPath; + secretPath << getMailBoxPath(userName) << "/.password"; + coreutils::File secret(secretPath); + secret.read(); + return password == secret.asZString(); + } catch (...) { + return false; + } + } + + coreutils::MString MailFileSystem::getFolderPath(coreutils::ZString &mailbox, std::string folder) { + coreutils::MString path; + path << getMailBoxPath(mailbox) << "/" << folder; + return path; + } + + bool MailFileSystem::ifFolderExists(coreutils::ZString &mailbox, std::string folder) { + struct stat folderStat; + return stat(getFolderPath(mailbox, folder).c_str(), &folderStat) != -1 && S_ISDIR(folderStat.st_mode); + } + + std::vector MailFileSystem::listFolders(coreutils::ZString &mailbox) { + std::vector folders; + std::string root = getMailBoxPath(mailbox).str(); + if (!std::filesystem::is_directory(root)) + return folders; + for (const auto &entry : std::filesystem::directory_iterator(root)) { + std::string name = entry.path().filename().string(); + if (!name.empty() && name[0] != '.' && entry.is_directory()) + folders.push_back(coreutils::MString(name)); + } + return folders; + } + } diff --git a/MailFileSystem.h b/MailFileSystem.h index c4c3792..369af1c 100644 --- a/MailFileSystem.h +++ b/MailFileSystem.h @@ -5,6 +5,7 @@ # include # include # include +# include namespace mail { @@ -18,10 +19,31 @@ namespace mail { std::vector getAliasMailboxes(coreutils::ZString &alias); coreutils::ZString& getMailPath(); coreutils::ZString& getQueuePath(); + coreutils::ZString& getSendLogPath(); + + // Shared by every protocol's login (SMTP AUTH LOGIN, IMAP LOGIN, + // POP3 USER/PASS) so there is exactly one place that reads + // /.password and compares it - previously + // __SMTP_AUTH::authLogin() carried this logic alone, and IMAP/POP3 + // login would otherwise have needed their own copies. Still a + // plaintext file comparison (a separately tracked, pre-existing + // gap) - not something this change fixes, just no longer + // triplicated. + bool checkPassword(coreutils::ZString &userName, coreutils::ZString &password); + + // Folder names (Inbox/Spam/Trash/anything CREATEd) as immediate + // subdirectories of a mailbox's own directory - used by IMAP + // LIST/LSUB/SELECT/CREATE/DELETE/RENAME to enumerate and validate + // folders without each command re-implementing the same + // directory scan and dotfile-skipping rule. + std::vector listFolders(coreutils::ZString &mailbox); + coreutils::MString getFolderPath(coreutils::ZString &mailbox, std::string folder); + bool ifFolderExists(coreutils::ZString &mailbox, std::string folder); private: coreutils::ZString &mailPath; coreutils::MString queuePath; + coreutils::MString sendLogPath; struct stat statbuf; }; diff --git a/MailFolder.cpp b/MailFolder.cpp new file mode 100644 index 0000000..c206ce8 --- /dev/null +++ b/MailFolder.cpp @@ -0,0 +1,299 @@ +#include "MailFolder.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mail { + + namespace { + + std::string joinFlags(const std::vector &flags) { + std::string out; + for (size_t i = 0; i < flags.size(); ++i) { + if (i > 0) + out += ","; + out += flags[i]; + } + return out; + } + + std::vector splitFlags(const std::string &blob) { + std::vector flags; + std::istringstream stream(blob); + std::string part; + while (std::getline(stream, part, ',')) + if (!part.empty()) + flags.push_back(part); + return flags; + } + + bool hasFlag(const std::vector &flags, const std::string &flag) { + return std::find(flags.begin(), flags.end(), flag) != flags.end(); + } + + uint32_t readCounterFile(const std::string &path, uint32_t defaultValue) { + std::ifstream in(path); + uint32_t value = 0; + if (in >> value) + return value; + return defaultValue; + } + + void writeCounterFile(const std::string &path, uint32_t value) { + std::ofstream out(path, std::ios::trunc); + out << value; + } + + } + + bool MailFolder::load(std::string folderPath) { + path = folderPath; + struct stat folderStat; + if (stat(path.c_str(), &folderStat) != 0 || !S_ISDIR(folderStat.st_mode)) + return false; + + // UIDVALIDITY is generated once and never changes for the life of + // this folder directory - reusing the folder's own creation time + // is a fine source of a value that's stable, unique enough in + // practice, and never zero. + mUidValidity = readCounterFile(path + "/.uidvalidity", 0); + if (mUidValidity == 0) { + mUidValidity = (uint32_t)time(nullptr); + writeCounterFile(path + "/.uidvalidity", mUidValidity); + } + mUidNext = readCounterFile(path + "/.uidnext", 1); + if (mUidNext < 1) + mUidNext = 1; + + mMessages.clear(); + loadIndexFile(); + reconcileWithDisk(); + save(); + return true; + } + + void MailFolder::loadIndexFile() { + std::ifstream in(path + "/.imapindex"); + if (!in) + return; + std::string line; + while (std::getline(in, line)) { + if (line.empty()) + continue; + std::istringstream stream(line); + Message message; + std::string uidStr, dateStr, flagsBlob; + stream >> message.fileName >> uidStr >> dateStr; + std::getline(stream, flagsBlob); + // The flags blob (if any) is whatever's left after the third + // field, minus the single separating space istream>> already + // consumed up to. + if (!flagsBlob.empty() && flagsBlob[0] == ' ') + flagsBlob.erase(0, 1); + message.uid = (uint32_t)std::stoul(uidStr); + message.internalDate = (time_t)std::stoll(dateStr); + message.flags = splitFlags(flagsBlob); + mMessages.push_back(message); + } + } + + void MailFolder::reconcileWithDisk() { + std::vector onDisk; + if (std::filesystem::is_directory(path)) { + for (const auto &entry : std::filesystem::directory_iterator(path)) { + if (!entry.is_regular_file()) + continue; + std::string name = entry.path().filename().string(); + if (!name.empty() && name[0] != '.') + onDisk.push_back(name); + } + } + + // Drop indexed messages no longer present on disk (expunged some + // other way than MailFolder::expunge(), or the file simply never + // made it - either way, nothing to show for it). + mMessages.erase(std::remove_if(mMessages.begin(), mMessages.end(), + [&](const Message &m) { + return std::find(onDisk.begin(), onDisk.end(), m.fileName) == onDisk.end(); + }), mMessages.end()); + + // Anything on disk not yet indexed is newly delivered mail (or a + // folder being indexed for the very first time) - assign it a UID + // now and mark it \Recent for this one load() only. + for (const std::string &name : onDisk) { + bool indexed = std::any_of(mMessages.begin(), mMessages.end(), + [&](const Message &m) { return m.fileName == name; }); + if (indexed) + continue; + Message message; + message.fileName = name; + message.uid = mUidNext++; + message.recent = true; + struct stat fileStat; + if (stat((path + "/" + name).c_str(), &fileStat) == 0) { + message.size = (size_t)fileStat.st_size; + message.internalDate = fileStat.st_mtime; + } else { + message.internalDate = time(nullptr); + } + mMessages.push_back(message); + } + + // Fill in size for every message (including ones that were + // already indexed - size isn't persisted, it's cheap to stat). + for (Message &message : mMessages) { + struct stat fileStat; + if (stat((path + "/" + message.fileName).c_str(), &fileStat) == 0) + message.size = (size_t)fileStat.st_size; + } + + std::sort(mMessages.begin(), mMessages.end(), + [](const Message &a, const Message &b) { return a.uid < b.uid; }); + } + + void MailFolder::save() { + writeCounterFile(path + "/.uidvalidity", mUidValidity); + writeCounterFile(path + "/.uidnext", mUidNext); + std::ofstream out(path + "/.imapindex", std::ios::trunc); + for (const Message &message : mMessages) + out << message.fileName << " " << message.uid << " " << (long long)message.internalDate + << " " << joinFlags(message.flags) << "\n"; + } + + uint32_t MailFolder::seq(uint32_t uid) const { + for (size_t i = 0; i < mMessages.size(); ++i) + if (mMessages[i].uid == uid) + return (uint32_t)(i + 1); + return 0; + } + + uint32_t MailFolder::uidForSeq(uint32_t seqNum) const { + if (seqNum < 1 || seqNum > mMessages.size()) + return 0; + return mMessages[seqNum - 1].uid; + } + + MailFolder::Message *MailFolder::find(uint32_t uid) { + for (Message &message : mMessages) + if (message.uid == uid) + return &message; + return nullptr; + } + + size_t MailFolder::recentCount() const { + return (size_t)std::count_if(mMessages.begin(), mMessages.end(), + [](const Message &m) { return m.recent; }); + } + + size_t MailFolder::unseenCount() const { + return (size_t)std::count_if(mMessages.begin(), mMessages.end(), + [](const Message &m) { return !hasFlag(m.flags, "\\Seen"); }); + } + + uint32_t MailFolder::firstUnseenSeq() const { + for (size_t i = 0; i < mMessages.size(); ++i) + if (!hasFlag(mMessages[i].flags, "\\Seen")) + return (uint32_t)(i + 1); + return 0; + } + + std::string MailFolder::readMessage(uint32_t uid) { + Message *message = find(uid); + if (!message) + return ""; + std::ifstream in(path + "/" + message->fileName, std::ios::binary); + std::ostringstream out; + out << in.rdbuf(); + return out.str(); + } + + void MailFolder::setFlags(uint32_t uid, const std::vector &flags) { + Message *message = find(uid); + if (!message) + return; + message->flags = flags; + save(); + } + + void MailFolder::addFlags(uint32_t uid, const std::vector &flags) { + Message *message = find(uid); + if (!message) + return; + for (const std::string &flag : flags) + if (!hasFlag(message->flags, flag)) + message->flags.push_back(flag); + save(); + } + + void MailFolder::removeFlags(uint32_t uid, const std::vector &flags) { + Message *message = find(uid); + if (!message) + return; + message->flags.erase(std::remove_if(message->flags.begin(), message->flags.end(), + [&](const std::string &f) { return hasFlag(flags, f); }), message->flags.end()); + save(); + } + + void MailFolder::deleteMessage(uint32_t uid) { + Message *message = find(uid); + if (!message) + return; + unlink((path + "/" + message->fileName).c_str()); + mMessages.erase(std::remove_if(mMessages.begin(), mMessages.end(), + [&](const Message &m) { return m.uid == uid; }), mMessages.end()); + save(); + } + + std::string MailFolder::generateFileName() { + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + std::stringstream ss; + ss << ts.tv_sec << "." << std::setfill('0') << std::setw(9) << ts.tv_nsec; + return ss.str(); + } + + uint32_t MailFolder::append(const std::string &data, const std::vector &flags, time_t internalDate) { + std::string fileName = generateFileName(); + std::ofstream out(path + "/" + fileName, std::ios::binary | std::ios::trunc); + out << data; + out.close(); + + Message message; + message.fileName = fileName; + message.uid = mUidNext++; + message.flags = flags; + message.size = data.size(); + message.internalDate = internalDate != 0 ? internalDate : time(nullptr); + mMessages.push_back(message); + std::sort(mMessages.begin(), mMessages.end(), + [](const Message &a, const Message &b) { return a.uid < b.uid; }); + save(); + return message.uid; + } + + std::vector MailFolder::expunge() { + std::vector toDelete; + for (const Message &message : mMessages) + if (hasFlag(message.flags, "\\Deleted")) + toDelete.push_back(message); + + std::vector adjustedSeqs; + for (size_t i = 0; i < toDelete.size(); ++i) { + uint32_t originalSeq = seq(toDelete[i].uid); + adjustedSeqs.push_back(originalSeq - (uint32_t)i); + unlink((path + "/" + toDelete[i].fileName).c_str()); + } + + mMessages.erase(std::remove_if(mMessages.begin(), mMessages.end(), + [&](const Message &m) { return hasFlag(m.flags, "\\Deleted"); }), mMessages.end()); + + save(); + return adjustedSeqs; + } + +} diff --git a/MailFolder.h b/MailFolder.h new file mode 100644 index 0000000..57e95d3 --- /dev/null +++ b/MailFolder.h @@ -0,0 +1,109 @@ +#ifndef __MailFolder_h__ +#define __MailFolder_h__ + +#include +#include +#include +#include + +namespace mail { + + /// + /// MailFolder is the IMAP mailbox index for one folder directory + /// (Inbox, Spam, Trash, or any client-CREATEd folder). It does NOT + /// change how mail is delivered - SMTPServer::inCreate() still just + /// hardlinks/writes plain files straight into a folder directory, the + /// same as before IMAP existed. What MailFolder adds, alongside those + /// message files, is three small index files: + /// + /// .uidvalidity - generated once, read thereafter. + /// .uidnext - monotonic counter, never reused even across + /// EXPUNGE. + /// .imapindex - one line per message: " + /// " (last field + /// may be empty). + /// + /// load() reconciles the index against an actual directory scan: any + /// message file present on disk that isn't yet in the index (freshly + /// delivered SMTP mail, most commonly) gets a UID assigned and is + /// marked \Recent for this one load() only; any indexed file no + /// longer present (expunged some other way) is dropped. This is the + /// ONLY integration point with delivery - delivery code never needs + /// to know a UID scheme exists. + /// + + class MailFolder { + + public: + + struct Message { + uint32_t uid = 0; + std::string fileName; + std::vector flags; + bool recent = false; + size_t size = 0; + time_t internalDate = 0; + }; + + bool load(std::string folderPath); + void save(); + + uint32_t uidValidity() const { return mUidValidity; } + uint32_t uidNext() const { return mUidNext; } + size_t size() const { return mMessages.size(); } + const std::vector &messages() const { return mMessages; } + + // 1-based sequence number <-> UID (ascending UID order defines + // sequence order). Returns 0 for "not present"/"out of range". + uint32_t seq(uint32_t uid) const; + uint32_t uidForSeq(uint32_t seqNum) const; + + Message *find(uint32_t uid); + + size_t recentCount() const; + size_t unseenCount() const; + uint32_t firstUnseenSeq() const; + + std::string readMessage(uint32_t uid); + + void setFlags(uint32_t uid, const std::vector &flags); + void addFlags(uint32_t uid, const std::vector &flags); + void removeFlags(uint32_t uid, const std::vector &flags); + + // internalDate == 0 means "use now". Not marked \Recent - an + // APPEND/COPY destination message is never \Recent (RFC 3501 + // 2.3.2: \Recent means "delivered since the last time any session + // selected this mailbox", which by definition isn't true of a + // message THIS session just created). + uint32_t append(const std::string &data, const std::vector &flags, time_t internalDate = 0); + + // Unlinks every \Deleted message and removes it from the index. + // Returns the sequence numbers to report via "* n EXPUNGE", each + // ALREADY ADJUSTED for the responses sent before it (see + // __IMAP_EXPUNGE.cpp's doc comment for why that adjustment is + // required, not optional). + std::vector expunge(); + + // Unlinks one specific message and removes it from the index - + // POP3's QUIT (RFC 1939 6) needs this instead of expunge(): POP3 + // deletion marks are tracked by POP3Session as a set of message + // numbers, entirely separate from the \Deleted flag expunge() + // acts on, since POP3 has no flags at all. + void deleteMessage(uint32_t uid); + + std::string path; + + private: + uint32_t mUidValidity = 0; + uint32_t mUidNext = 1; + std::vector mMessages; + + void loadIndexFile(); + void reconcileWithDisk(); + static std::string generateFileName(); + + }; + +} + +#endif diff --git a/MessageHeaders.cpp b/MessageHeaders.cpp new file mode 100644 index 0000000..1a210f4 --- /dev/null +++ b/MessageHeaders.cpp @@ -0,0 +1,208 @@ +#include "MessageHeaders.h" +#include +#include + +namespace mail { + + void splitMessage(const std::string &message, std::string &headerBlock, std::string &bodyBlock) { + size_t split = message.find("\r\n\r\n"); + size_t skip = 4; + if (split == std::string::npos) { + split = message.find("\n\n"); + skip = 2; + } + if (split == std::string::npos) { + headerBlock = message; + bodyBlock = ""; + } else { + headerBlock = message.substr(0, split); + bodyBlock = message.substr(split + skip); + } + } + + std::vector> parseHeaderLines(const std::string &headerBlock) { + std::vector lines; + size_t pos = 0; + while (pos <= headerBlock.size()) { + size_t next = headerBlock.find("\r\n", pos); + size_t lineEnd = (next == std::string::npos) ? headerBlock.size() : next; + lines.push_back(headerBlock.substr(pos, lineEnd - pos)); + if (next == std::string::npos) + break; + pos = next + 2; + } + + std::vector> headers; + for (const std::string &line : lines) { + if (line.empty()) + continue; + if ((line[0] == ' ' || line[0] == '\t') && !headers.empty()) { + std::string continuation = line; + size_t start = continuation.find_first_not_of(" \t"); + headers.back().second += " "; + headers.back().second += (start == std::string::npos) ? "" : continuation.substr(start); + continue; + } + size_t colon = line.find(':'); + if (colon == std::string::npos) + continue; + std::string name = line.substr(0, colon); + std::string value = line.substr(colon + 1); + size_t start = value.find_first_not_of(" \t"); + value = (start == std::string::npos) ? "" : value.substr(start); + headers.push_back({name, value}); + } + return headers; + } + + std::string getHeader(const std::vector> &headers, const std::string &name) { + for (const auto &header : headers) { + if (header.first.size() != name.size()) + continue; + bool match = true; + for (size_t i = 0; i < name.size(); ++i) + if (std::toupper((unsigned char)header.first[i]) != std::toupper((unsigned char)name[i])) { + match = false; + break; + } + if (match) + return header.second; + } + return ""; + } + + namespace { + + std::string trim(const std::string &s) { + size_t start = s.find_first_not_of(" \t\r\n"); + size_t end = s.find_last_not_of(" \t\r\n"); + if (start == std::string::npos) + return ""; + return s.substr(start, end - start + 1); + } + + std::string stripQuotes(const std::string &s) { + std::string value = trim(s); + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') + return value.substr(1, value.size() - 2); + return value; + } + + Address parseOneAddress(const std::string &text) { + Address address; + size_t lt = text.find('<'); + size_t gt = text.find('>'); + std::string addrPart; + if (lt != std::string::npos && gt != std::string::npos && gt > lt) { + address.name = stripQuotes(trim(text.substr(0, lt))); + addrPart = text.substr(lt + 1, gt - lt - 1); + } else { + addrPart = text; + } + addrPart = trim(addrPart); + size_t at = addrPart.find_last_of('@'); + if (at == std::string::npos) { + address.mailbox = addrPart; + } else { + address.mailbox = addrPart.substr(0, at); + address.host = addrPart.substr(at + 1); + } + return address; + } + + } + + std::vector
parseAddressList(const std::string &headerValue) { + std::vector
addresses; + std::string current; + bool inQuotes = false; + int angleDepth = 0; + for (char c : headerValue) { + if (c == '"') + inQuotes = !inQuotes; + else if (!inQuotes && c == '<') + ++angleDepth; + else if (!inQuotes && c == '>' && angleDepth > 0) + --angleDepth; + if (c == ',' && !inQuotes && angleDepth == 0) { + std::string piece = trim(current); + if (!piece.empty()) + addresses.push_back(parseOneAddress(piece)); + current.clear(); + continue; + } + current += c; + } + std::string piece = trim(current); + if (!piece.empty()) + addresses.push_back(parseOneAddress(piece)); + return addresses; + } + + std::string imapQuoteOrNil(const std::string &value) { + if (value.empty()) + return "NIL"; + std::string escaped; + escaped.reserve(value.size() + 2); + escaped += '"'; + for (char c : value) { + if (c == '"' || c == '\\') + escaped += '\\'; + if (c == '\r' || c == '\n') + continue; + escaped += c; + } + escaped += '"'; + return escaped; + } + + namespace { + + std::string addressListToEnvelope(const std::vector
&addresses) { + if (addresses.empty()) + return "NIL"; + std::string result = "("; + for (const Address &address : addresses) { + result += "(" + imapQuoteOrNil(address.name) + " NIL " + + imapQuoteOrNil(address.mailbox) + " " + imapQuoteOrNil(address.host) + ")"; + } + result += ")"; + return result; + } + + } + + std::string buildEnvelope(const std::string &message) { + std::string headerBlock, bodyBlock; + splitMessage(message, headerBlock, bodyBlock); + auto headers = parseHeaderLines(headerBlock); + + std::string date = getHeader(headers, "Date"); + std::string subject = getHeader(headers, "Subject"); + std::vector
from = parseAddressList(getHeader(headers, "From")); + std::string senderHeader = getHeader(headers, "Sender"); + std::vector
sender = senderHeader.empty() ? from : parseAddressList(senderHeader); + std::string replyToHeader = getHeader(headers, "Reply-To"); + std::vector
replyTo = replyToHeader.empty() ? from : parseAddressList(replyToHeader); + std::vector
to = parseAddressList(getHeader(headers, "To")); + std::vector
cc = parseAddressList(getHeader(headers, "Cc")); + std::vector
bcc = parseAddressList(getHeader(headers, "Bcc")); + std::string inReplyTo = getHeader(headers, "In-Reply-To"); + std::string messageId = getHeader(headers, "Message-ID"); + + std::string envelope = "("; + envelope += imapQuoteOrNil(date) + " "; + envelope += imapQuoteOrNil(subject) + " "; + envelope += addressListToEnvelope(from) + " "; + envelope += addressListToEnvelope(sender) + " "; + envelope += addressListToEnvelope(replyTo) + " "; + envelope += addressListToEnvelope(to) + " "; + envelope += addressListToEnvelope(cc) + " "; + envelope += addressListToEnvelope(bcc) + " "; + envelope += imapQuoteOrNil(inReplyTo) + " "; + envelope += imapQuoteOrNil(messageId); + envelope += ")"; + return envelope; + } + +} diff --git a/MessageHeaders.h b/MessageHeaders.h new file mode 100644 index 0000000..8e7bcec --- /dev/null +++ b/MessageHeaders.h @@ -0,0 +1,54 @@ +#ifndef __MessageHeaders_h__ +# define __MessageHeaders_h__ + +#include +#include + +namespace mail { + + /// + /// Text-only header parsing shared by FETCH's ENVELOPE/BODY[HEADER...] + /// data items and SEARCH's FROM/TO/CC/SUBJECT criteria. Deliberately + /// does NOT parse MIME structure (no multipart, no Content-Type + /// tree) - see MailFolder.h's own doc comment on the same scoping + /// decision (BODYSTRUCTURE is out of scope; ENVELOPE only needs + /// header text). + /// + + struct Address { + std::string name; + std::string mailbox; + std::string host; + }; + + // Splits a raw message into its header block and body, at the first + // blank line. header does NOT include the trailing blank line. + void splitMessage(const std::string &message, std::string &headerBlock, std::string &bodyBlock); + + // Unfolds continuation lines (RFC 5322 3.2.2 - a line starting with + // SP/TAB continues the previous header) and returns every header as + // (name, value) in on-the-wire order. Value has the folding + // whitespace collapsed to a single space and is otherwise verbatim. + std::vector> parseHeaderLines(const std::string &headerBlock); + + // First header matching name (case-insensitive), or "" if absent. + std::string getHeader(const std::vector> &headers, const std::string &name); + + // "Name , user2@host2" -> parsed addresses. Splits on + // top-level commas only (respecting "..." and <...>) - a simplified, + // not fully RFC 5322-compliant parser (no comments, no obsolete + // routing syntax), sufficient for real-world From/To/Cc header text. + std::vector
parseAddressList(const std::string &headerValue); + + // RFC 3501 7.4.2 ENVELOPE structure, built entirely from parsed + // headers - "sender"/"reply-to" default to "from" when their own + // header is absent, per the same RFC section's own convention. + std::string buildEnvelope(const std::string &message); + + // nstring per RFC 3501 4.3 - a quoted string with backslash/quote + // escaped, or the literal NIL for an empty value. + std::string imapQuoteOrNil(const std::string &value); + +} + +#endif diff --git a/POP3Command.cpp b/POP3Command.cpp index 3da174d..956adf6 100644 --- a/POP3Command.cpp +++ b/POP3Command.cpp @@ -1,9 +1,70 @@ #include "POP3Command.h" +#include namespace mail { - + + MailFolder::Message *pop3FindMessage(POP3Session &session, int msgNum) { + const std::vector &messages = session.mailbox.messages(); + if (msgNum < 1 || (size_t)msgNum > messages.size()) + return nullptr; + if (session.deletedMarks.count(msgNum) > 0) + return nullptr; + return session.mailbox.find(messages[msgNum - 1].uid); + } + + void pop3SendMessage(POP3Session &session, const std::string &content, int maxBodyLines) { + size_t pos = 0; + bool inBody = false; + int bodyLinesSent = 0; + while (pos <= content.size()) { + size_t next = content.find("\r\n", pos); + std::string line = (next == std::string::npos) ? content.substr(pos) : content.substr(pos, next - pos); + bool isBlank = line.empty(); + + if (inBody && maxBodyLines >= 0 && bodyLinesSent >= maxBodyLines) + break; + + if (!line.empty() && line[0] == '.') + session.out << "." << line << CRLF; + else + session.out << line << CRLF; + + if (inBody) + ++bodyLinesSent; + if (isBlank && !inBody) + inBody = true; + + if (next == std::string::npos) + break; + pos = next + 2; + } + session.out << "." << CRLF; + } + + int POP3Command::processCommand(coreutils::ZString &request, core::TCPSession &session) { - return processCommand(request, (POP3Session &)session, (POP3Server &)session.server); + // Same bug, same fix as IMAPCommand::processCommand's own doc + // comment: session.server is a TCPServer* pointer, and casting + // the pointer VALUE directly to a reference type reinterprets the + // pointer FIELD'S OWN memory location as the object, not what it + // points to. Dereference first. + // ZString's copy constructor (ZString.cpp) copies data/length/ + // cursor but NOT `list` - and the specific-handler overload just + // below takes request BY VALUE, so every POP3 handler's `request` + // is such a copy. request[N] (backed by `list`, populated by + // CommandList::processRequest()'s own request.split() call before + // dispatch) would therefore index into an empty vector - out of + // bounds, the same class of bug as MailFileSystem::getMailBoxPath() + // had for mailbox[1] before this session's IMAP work fixed it. + // POP3 handlers must read arguments off the (correctly-copied) + // cursor instead, via getTokenExclude()/skipWhitespace() - which + // is also why the command word needs stripping here first, same + // reasoning as IMAPCommand::processCommand's own doc comment: + // request.reset() (CommandList::processRequest) leaves the cursor + // at the true start of the line, still including the command + // word itself. + request.getTokenExclude(" "); + return processCommand(request, (POP3Session &)session, (POP3Server &)(*session.server)); } int POP3Command::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { diff --git a/POP3Command.h b/POP3Command.h index d30a299..64ae94f 100644 --- a/POP3Command.h +++ b/POP3Command.h @@ -7,15 +7,34 @@ namespace mail { class POP3Server; - + + // RFC 1939 3: POP3 message numbers are 1-based positions in the + // maildrop's fixed-for-the-session ordering - session.mailbox's own + // ascending-by-UID message list, loaded once at PASS and never + // reloaded mid-session, already IS that fixed ordering, so message + // number N is simply session.mailbox.messages()[N-1]. Returns + // nullptr for an out-of-range number OR a number already marked + // \Deleted this session (RFC 1939 3: "the POP3 server MUST NOT" let + // a deleted message be referenced by any command). + MailFolder::Message *pop3FindMessage(POP3Session &session, int msgNum); + + // Shared by RETR and TOP - RFC 1939 3's byte-stuffing rule (any line + // starting with "." gets an extra "." prepended, same transparency + // rule as SMTP DATA, RFC 5321 4.5.2) applies to both, and TOP is + // otherwise identical to RETR except it stops after maxBodyLines + // lines of body (headers and the header/body blank line always go + // out in full either way). maxBodyLines < 0 means unlimited (RETR's + // own call site). + void pop3SendMessage(POP3Session &session, const std::string &content, int maxBodyLines); + class POP3Command : public core::Command { - + public: virtual int processCommand(coreutils::ZString &request, core::TCPSession &session) override; virtual int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server); - + }; - + } #endif diff --git a/POP3Server.h b/POP3Server.h index 3864a6b..c2de160 100644 --- a/POP3Server.h +++ b/POP3Server.h @@ -2,7 +2,11 @@ #define __POP3Server_h__ #include "TCPServer.h" +#include "TLSInfo.h" #include "Exception.h" +#include "MailFileSystem.h" +#include "POP3Session.h" +#include "__POP3_CAPA.h" #include "__POP3_DELE.h" #include "__POP3_LAST.h" #include "__POP3_LIST.h" @@ -14,6 +18,7 @@ #include "__POP3_RSET.h" #include "__POP3_STAT.h" #include "__POP3_TOP.h" +#include "__POP3_UIDL.h" #include "__POP3_USER.h" namespace mail { @@ -21,8 +26,11 @@ namespace mail { class POP3Server : public core::TCPServer { public: - POP3Server(core::EPoll &epoll, std::string hostName, MailFileSystem &mailFileSystem, core::IPAddress ipAddress) - : TCPServer(epoll, ipAddress, " ", 10, "POP3 Server"), mailFileSystem(mailFileSystem), hostName(hostName) { + POP3Server(core::EPoll &epoll, std::string hostName, MailFileSystem &mailFileSystem, + core::IPAddress ipAddress, core::TLSInfo *tlsInfo) + : TCPServer(epoll, ipAddress, tlsInfo, " ", 10, "POP3 Server"), mailFileSystem(mailFileSystem), hostName(hostName) { + // RFC 2449: was not registered at all before. + commands.add(_pop3_capa, "CAPA"); commands.add(_pop3_dele, "DELE"); commands.add(_pop3_last, "LAST"); commands.add(_pop3_list, "LIST"); @@ -34,13 +42,24 @@ namespace mail { commands.add(_pop3_rset, "RSET"); commands.add(_pop3_stat, "STAT"); commands.add(_pop3_top, "TOP"); + commands.add(_pop3_uidl, "UIDL"); commands.add(_pop3_user, "USER"); } MailFileSystem &mailFileSystem; std::string hostName; - + + // Same reasoning as IMAPServer's own override - without it every + // accepted connection gets a plain TCPSession, POP3Session's + // greeting never fires, and POP3Command's (POP3Session &)session + // casts are undefined behavior against an object that was never + // actually a POP3Session. + POP3Session *getSocketAccept(core::EPoll &ePoll) override { + return new POP3Session(ePoll, *this); + } + private: + __POP3_CAPA _pop3_capa; __POP3_DELE _pop3_dele; __POP3_LAST _pop3_last; __POP3_LIST _pop3_list; @@ -52,6 +71,7 @@ namespace mail { __POP3_RSET _pop3_rset; __POP3_STAT _pop3_stat; __POP3_TOP _pop3_top; + __POP3_UIDL _pop3_uidl; __POP3_USER _pop3_user; }; diff --git a/POP3Session.h b/POP3Session.h index 9de0b80..11d4019 100644 --- a/POP3Session.h +++ b/POP3Session.h @@ -1,22 +1,56 @@ #ifndef __POP3Session_h__ #define __POP3Session_h__ +#include "TCPServer.h" +#include "TCPSession.h" +#include "MailFolder.h" +#include + +#define CRLF "\r\n" + namespace mail { class POP3Session : public core::TCPSession { - enum AuthState { - USER_UNKNOWN, USER_QUERY, USER_SECRET_QUERY, USER_KNOWN - }; - - public: + + public: + POP3Session(core::EPoll &ePoll, core::TCPServer &server) + : TCPSession(ePoll, &server, "POP3 Client Session") {} + std::string clientDomainName; + + // RFC 1939 4/7: USER sets the pending username and replies +OK + // without checking anything - only PASS actually authenticates + // (against MailFileSystem::checkPassword(), shared with SMTP AUTH + // and IMAP LOGIN). userName is meaningless until PASS succeeds; + // authenticated gates every other command per RFC 1939 3's state + // diagram (AUTHORIZATION vs TRANSACTION state). std::string userName; - std::string password; - AuthState authstate = USER_UNKNOWN; - bool relay = false; + bool authenticated = false; + + // Loaded once, right after a successful PASS - POP3 has no + // folder concept (RFC 1939 has no SELECT-equivalent), it only + // ever operates on Inbox. RFC 1939 3's "maildrop" message + // numbering is fixed for the whole session (assigned once here, + // not re-derived per command) even if new mail is delivered + // mid-session - deletedMarks/QUIT below follow the same + // session-frozen-snapshot model. + MailFolder mailbox; + + // RFC 1939 3: DELE only MARKS a message deleted - the mark (and + // any RSET undoing it) is session-local and has no effect until + // QUIT actually commits it by unlinking the marked messages. + // Keyed by 1-based POP3 message number (this session's frozen + // ordering), not UID. + std::set deletedMarks; + + // RFC 1939 4: the server must send a positive greeting before the + // client sends anything. + void onConnected() override { + out << "+OK POP3 server ready" << CRLF; + } }; - + } #endif diff --git a/SMTPCommand.cpp b/SMTPCommand.cpp index aa545a5..34ccf1f 100644 --- a/SMTPCommand.cpp +++ b/SMTPCommand.cpp @@ -3,7 +3,16 @@ namespace mail { int SMTPCommand::processCommand(coreutils::ZString &request, core::TCPSession &session) { - return processCommand(request, (SMTPSession &)session, (SMTPServer &)session.server); + // Same bug, same fix as IMAPCommand::processCommand's own doc + // comment: session.server is a TCPServer* pointer, and casting + // the pointer VALUE directly to a reference type reinterprets the + // pointer FIELD'S OWN memory location as the object, not what it + // points to. Confirmed live via gdb - this is the actual cause of + // a real SIGSEGV in __SMTP_EHLO's very first use of + // server.hostName (and every other handler reading + // server.mailFileSystem was silently reading the same garbage, + // just without happening to crash on it yet). Dereference first. + return processCommand(request, (SMTPSession &)session, (SMTPServer &)(*session.server)); } int SMTPCommand::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { @@ -11,11 +20,11 @@ namespace mail { } void SMTPCommand::grabInput(SMTPSession &session) { - session.server.commands.grabInput(session, *this); + session.server->commands.grabInput(session, *this); } void SMTPCommand::clearGrab(SMTPSession &session) { - session.server.commands.clearGrab(session); + session.server->commands.clearGrab(session); } } diff --git a/SMTPServer.cpp b/SMTPServer.cpp index 6b8db29..d2f8138 100644 --- a/SMTPServer.cpp +++ b/SMTPServer.cpp @@ -1,23 +1,32 @@ #include "SMTPServer.h" #include "Log.h" #include "SendMail.h" +#include "SpamAssassinFilter.h" +#include "File.h" +#include namespace mail { - SMTPServer::SMTPServer(core::EPoll &ePoll, std::string hostName, MailFileSystem &mailFileSystem, core::IPAddress ipAddress) - : core::TCPServer(ePoll, ipAddress, " ", 1, "SMTP Server"), core::INotify(ePoll), ePoll(ePoll), hostName(hostName), mailFileSystem(mailFileSystem) { + SMTPServer::SMTPServer(core::EPoll &ePoll, std::string hostName, MailFileSystem &mailFileSystem, + core::IPAddress ipAddress, core::TLSInfo *tlsInfo, bool watchQueue, + DKIMSigner *dkimSigner) + : core::TCPServer(ePoll, ipAddress, tlsInfo, " ", 1, "SMTP Server"), core::INotify(ePoll), ePoll(ePoll), hostName(hostName), mailFileSystem(mailFileSystem), dkimSigner(dkimSigner) { commands.add(_smtp_auth, "AUTH"); commands.add(_smtp_data, "DATA"); commands.add(_smtp_ehlo, "EHLO"); + commands.add(_smtp_expn, "EXPN"); commands.add(_smtp_helo, "HELO"); + commands.add(_smtp_help, "HELP"); commands.add(_smtp_mail, "MAIL"); commands.add(_smtp_noop, "NOOP"); commands.add(_smtp_quit, "QUIT"); commands.add(_smtp_rcpt, "RCPT"); commands.add(_smtp_rset, "RSET"); commands.add(_smtp_vrfy, "VRFY"); - processExisting(); - wd = addWatch(mailFileSystem.getQueuePath()); + if (watchQueue) { + processExisting(); + wd = addWatch(mailFileSystem.getQueuePath()); + } } SMTPSession * SMTPServer::getSocketAccept(core::EPoll &ePoll) { @@ -31,18 +40,52 @@ namespace mail { void SMTPServer::inCreate(coreutils::ZString &name) { coreutils::Log(coreutils::LOG_DEBUG_2) << "inCreate; [" << name << "]"; name.split("|"); - if(name.getList().size() > 1) { + // Three parts now: messageid|recipient|sender - see + // __SMTP_DATA::queueMail()'s own doc comment for why the sender + // had to be added alongside the recipient. + if(name.getList().size() > 2) { bool exists = mailFileSystem.ifMailBoxExists(name[1]); coreutils::MString path; path << mailFileSystem.getMailBoxPath(name[1]) << "/Inbox/" << name[0]; coreutils::MString from; from << mailFileSystem.getQueuePath() << "/" << name; if(exists) { - int rc = link(from.c_str(), path.c_str()); - rc = unlink(from.c_str()); - coreutils::Log(coreutils::LOG_INFO) << "Message " << name[0] << " delivered to recipient " << name[1] << "."; + // Scanned on the way into the recipient's own Inbox, not the + // outbound relay path below - spamd's added X-Spam-* headers + // are for whatever reads this recipient's mailbox, and a + // message this server relays onward gets scored by ITS + // destination's own filtering, same as any other MTA. Reading + // the whole file in (rather than the plain link() this branch + // used to do) is unavoidable now that spamd may have rewritten + // the content the recipient actually needs to receive. + coreutils::File queuedFile(from, O_RDONLY); + queuedFile.read(); + SpamAssassinFilter::Result scan = SpamAssassinFilter::scan(queuedFile.asString()); + + coreutils::File mailFile(path, O_CREAT | O_WRONLY, 0660); + mailFile.write(scan.scanned); + int rc = unlink(from.c_str()); + + coreutils::Log(coreutils::LOG_INFO) << "Message " << name[0] << " delivered to recipient " << name[1] + << (scan.ok && scan.isSpam ? " (flagged as spam)" : "") << "."; } else { - mailQueue.emplace_back(ePoll, name[1].str(), name[1].str(), from.str()); + // Not a local mailbox - relay it out over SMTP. SendMail's + // own send() is a plain blocking call (see its doc comment + // for why) - queued delivery wants fire-and-forget, so this + // owns the heap-allocated SendMail itself and runs it on its + // own detached thread rather than blocking this EPoll + // worker thread for however long the remote host takes. + coreutils::MString sender(name[2]); + coreutils::MString recipient(name[1]); + coreutils::File queuedFile(from, O_RDONLY); + queuedFile.read(); + coreutils::MString mailData(queuedFile.asString()); + SendMail *outbound = new SendMail(hostName, mailFileSystem.getSendLogPath().str(), + name[0].str(), sender, recipient, mailData, dkimSigner); + std::thread([outbound]() { + outbound->send(); + delete outbound; + }).detach(); } } } diff --git a/SMTPServer.h b/SMTPServer.h index 2a2e5d7..b0ab5f7 100644 --- a/SMTPServer.h +++ b/SMTPServer.h @@ -6,12 +6,15 @@ # include # include "EPoll.h" # include "TCPServer.h" +# include "TLSInfo.h" # include "SMTPSession.h" # include "MailFileSystem.h" # include "__SMTP_AUTH.h" # include "__SMTP_DATA.h" # include "__SMTP_EHLO.h" +# include "__SMTP_EXPN.h" # include "__SMTP_HELO.h" +# include "__SMTP_HELP.h" # include "__SMTP_MAIL.h" # include "__SMTP_NOOP.h" # include "__SMTP_QUIT.h" @@ -20,16 +23,37 @@ # include "__SMTP_VRFY.h" # include "INotify.h" # include "SendMail.h" +# include "DKIMSigner.h" namespace mail { class SMTPServer : public core::TCPServer, public core::INotify { public: - SMTPServer(core::EPoll &ePoll, std::string hostName, MailFileSystem &mailFileSystem, core::IPAddress ipAddress); + // watchQueue controls whether this instance watches the mail + // queue directory and relays/delivers what lands in it - true by + // default for the ordinary single-listener case. Running a + // second SMTPServer for a submission/TLS port alongside the + // plaintext one needs watchQueue=false on exactly one of the two: + // each SMTPServer's own INotify watch is independent, so two + // watchers on the same directory would each fire inCreate() for + // every queued message, delivering it twice. + // dkimSigner is optional (nullptr = outbound mail goes out + // unsigned) and not owned - passed straight through to every + // SendMail this instance's inCreate() constructs for outbound + // relay. + SMTPServer(core::EPoll &ePoll, std::string hostName, MailFileSystem &mailFileSystem, + core::IPAddress ipAddress, core::TLSInfo *tlsInfo, bool watchQueue = true, + DKIMSigner *dkimSigner = nullptr); MailFileSystem &mailFileSystem; std::string hostName; core::EPoll &ePoll; + DKIMSigner *dkimSigner; + + // Advertised via EHLO's "250-SIZE " and enforced by __SMTP_DATA + // while accumulating a message body - RFC 1870. 25MB, matching + // the limit most real providers (e.g. Gmail) use. + static const size_t maxMessageSize = 25 * 1024 * 1024; SMTPSession * getSocketAccept(core::EPoll &ePoll) override; void sessionErrorHandler(std::string errorString, std::stringstream &out); @@ -41,7 +65,9 @@ namespace mail { __SMTP_AUTH _smtp_auth; __SMTP_DATA _smtp_data; __SMTP_EHLO _smtp_ehlo; + __SMTP_EXPN _smtp_expn; __SMTP_HELO _smtp_helo; + __SMTP_HELP _smtp_help; __SMTP_MAIL _smtp_mail; __SMTP_NOOP _smtp_noop; __SMTP_QUIT _smtp_quit; @@ -49,7 +75,6 @@ namespace mail { __SMTP_RSET _smtp_rset; __SMTP_VRFY _smtp_vrfy; int wd; - std::vector mailQueue; void processExisting(); diff --git a/SMTPSession.cpp b/SMTPSession.cpp index e73a148..f6620c1 100644 --- a/SMTPSession.cpp +++ b/SMTPSession.cpp @@ -1,14 +1,18 @@ # include "SMTPSession.h" +# include "SMTPServer.h" # include "Log.h" namespace mail { SMTPSession::SMTPSession(core::EPoll &ePoll, core::TCPServer &server) - : TCPSession(ePoll, server, "SMTP Client Session") {} + : TCPSession(ePoll, &server, "SMTP Client Session") {} void SMTPSession::onConnected() { coreutils::Log(coreutils::LOG_DEBUG_2) << "onConnected -> sending 220"; - out << "220 localhost BMAMail" << CRLF; + // RFC 5321 4.2 requires the greeting to name the server's own + // domain - the literal "localhost" was hardcoded here regardless + // of the hostName this server was actually configured with. + out << "220 " << ((SMTPServer *)server)->hostName << " BMAMail" << CRLF; } } diff --git a/SMTPSession.h b/SMTPSession.h index 93e0aa6..613d3ab 100644 --- a/SMTPSession.h +++ b/SMTPSession.h @@ -4,6 +4,7 @@ # include "TCPSession.h" # include "TCPServer.h" # include "MString.h" +# include "SPFChecker.h" # define CRLF "\r\n" @@ -27,10 +28,27 @@ namespace mail { State state = CONNECT; AuthState authState = USER_UNKNOWN; Mode mode = WAIT_FOR_DATA; - bool relay = true; + // Defaults to false: relaying to a non-local recipient is only + // permitted after a real, successful AUTH (see __SMTP_AUTH.cpp, + // which sets this true on success) - defaulting to true made every + // brand-new, unauthenticated connection an open relay for anyone + // (__SMTP_RCPT.cpp checks exactly this flag before accepting a + // recipient whose mailbox does not exist locally). + bool relay = false; coreutils::MString from; std::vector recipientList; + // Computed once at MAIL FROM time (see __SMTP_MAIL.cpp) and + // reused at DATA time (__SMTP_DATA.cpp), rather than re-running + // the SPF DNS lookups a second time for the same transaction. The + // header is ready to prepend to the message as-is; domain/result + // are also kept separately since DMARC's own alignment check + // (also run at DATA time) needs the raw identity/result, not the + // pre-formatted header text. + std::string spfHeader; + std::string spfDomain; + SPFChecker::Result spfResult = SPFChecker::Result::None; + }; } diff --git a/SPFChecker.cpp b/SPFChecker.cpp new file mode 100644 index 0000000..f87326c --- /dev/null +++ b/SPFChecker.cpp @@ -0,0 +1,357 @@ +#include "SPFChecker.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mail { + + namespace { + + struct Term { + char qualifier; // '+', '-', '~', '?' + std::string name; // "all", "ip4", "a", "mx", "include", "exists", "redirect", "exp", or an unknown modifier's key + std::string value; // everything after ':'/'=' (domain-spec, ip/cidr, etc.) - empty if none + bool isModifier; // "redirect=" / "exp=" / unknown "key=value" vs. an actual mechanism + }; + + std::vector parseTerms(const std::string &record) { + std::vector terms; + std::istringstream stream(record); + std::string token; + bool first = true; + while (stream >> token) { + if (first) { + first = false; + continue; // "v=spf1" itself + } + Term t; + t.qualifier = '+'; + std::string body = token; + if (!body.empty() && (body[0] == '+' || body[0] == '-' || body[0] == '~' || body[0] == '?')) { + t.qualifier = body[0]; + body = body.substr(1); + } + size_t eq = body.find('='); + size_t colon = body.find(':'); + if (eq != std::string::npos && (colon == std::string::npos || eq < colon)) { + t.isModifier = true; + t.name = body.substr(0, eq); + t.value = body.substr(eq + 1); + } else { + t.isModifier = false; + size_t sep = body.find_first_of(":/"); + if (sep == std::string::npos) { + t.name = body; + t.value = ""; + } else { + t.name = body.substr(0, sep); + t.value = body.substr(body[sep] == ':' ? sep + 1 : sep); // keep leading '/' for cidr-only forms + } + } + for (char &c : t.name) + c = std::tolower((unsigned char)c); + terms.push_back(t); + } + return terms; + } + + // Splits "domain[/v4prefix[/v6prefix]]" (the tail of a/mx after an + // optional ":domain") into its domain part (possibly empty, meaning + // "use the checked domain") and the two optional prefix lengths + // (-1 = "not given", meaning "match the full address"). + void splitDomainAndCidr(const std::string &value, std::string &domain, int &v4prefix, int &v6prefix) { + domain = value; + v4prefix = -1; + v6prefix = -1; + size_t slash = domain.find('/'); + if (slash == std::string::npos) + return; + std::string cidrPart = domain.substr(slash + 1); + domain = domain.substr(0, slash); + size_t slash2 = cidrPart.find('/'); + std::string v4 = (slash2 == std::string::npos) ? cidrPart : cidrPart.substr(0, slash2); + std::string v6 = (slash2 == std::string::npos) ? "" : cidrPart.substr(slash2 + 1); + if (!v4.empty()) + v4prefix = atoi(v4.c_str()); + if (!v6.empty()) + v6prefix = atoi(v6.c_str()); + } + + SPFChecker::Result qualifierToResult(char q) { + switch (q) { + case '-': return SPFChecker::Result::Fail; + case '~': return SPFChecker::Result::SoftFail; + case '?': return SPFChecker::Result::Neutral; + default: return SPFChecker::Result::Pass; + } + } + + } + + std::vector SPFChecker::lookupSPFRecords(const std::string &domain) { + std::vector records; + unsigned char answer[4096]; + int len = res_query(domain.c_str(), C_IN, T_TXT, answer, sizeof(answer)); + if (len <= 0) + return records; + + ns_msg handle; + if (ns_initparse(answer, len, &handle) != 0) + return records; + + int count = ns_msg_count(handle, ns_s_an); + for (int i = 0; i < count; ++i) { + ns_rr rr; + if (ns_parserr(&handle, ns_s_an, i, &rr) != 0 || ns_rr_type(rr) != ns_t_txt) + continue; + // RFC 7208 3.3: a TXT record's RDATA is one or more + // length-prefixed character-strings, concatenated here into + // one logical string before checking for the "v=spf1 " prefix. + const unsigned char *rdata = ns_rr_rdata(rr); + int rdlen = ns_rr_rdlen(rr); + std::string full; + int pos = 0; + while (pos < rdlen) { + int segLen = rdata[pos]; + ++pos; + if (pos + segLen > rdlen) + break; + full.append((const char *)(rdata + pos), segLen); + pos += segLen; + } + std::string lower = full; + for (char &c : lower) + c = std::tolower((unsigned char)c); + if (lower.rfind("v=spf1", 0) == 0 && (lower.size() == 6 || lower[6] == ' ')) + records.push_back(full); + } + return records; + } + + std::vector SPFChecker::lookupA(const std::string &domain) { + std::vector ips; + struct addrinfo hints {}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo *result = nullptr; + if (getaddrinfo(domain.c_str(), nullptr, &hints, &result) != 0 || !result) + return ips; + for (struct addrinfo *rp = result; rp != nullptr; rp = rp->ai_next) { + char buf[INET6_ADDRSTRLEN]; + void *addr = &((struct sockaddr_in *)rp->ai_addr)->sin_addr; + if (inet_ntop(AF_INET, addr, buf, sizeof(buf))) + ips.push_back(buf); + } + freeaddrinfo(result); + return ips; + } + + std::vector SPFChecker::lookupMX(const std::string &domain) { + std::vector hosts; + unsigned char answer[4096]; + int len = res_query(domain.c_str(), C_IN, T_MX, answer, sizeof(answer)); + if (len <= 0) + return hosts; + ns_msg handle; + if (ns_initparse(answer, len, &handle) != 0) + return hosts; + int count = ns_msg_count(handle, ns_s_an); + // RFC 7208 4.6.4: processing more than 10 MX names per mx + // mechanism is itself a PermError condition - capped here rather + // than trusting a (possibly hostile) record to stay reasonable. + for (int i = 0; i < count && i < 10; ++i) { + ns_rr rr; + if (ns_parserr(&handle, ns_s_an, i, &rr) != 0 || ns_rr_type(rr) != ns_t_mx) + continue; + char host[NS_MAXDNAME]; + if (dn_expand(ns_msg_base(handle), ns_msg_end(handle), ns_rr_rdata(rr) + 2, host, sizeof(host)) >= 0) + hosts.push_back(host); + } + return hosts; + } + + bool SPFChecker::ipInCIDR(const std::string &ip, const std::string &cidrSpec, int defaultPrefixV4, int defaultPrefixV6) { + bool isV6 = ip.find(':') != std::string::npos; + + if (isV6) { + struct in6_addr a{}, n{}; + if (inet_pton(AF_INET6, ip.c_str(), &a) != 1 || inet_pton(AF_INET6, cidrSpec.c_str(), &n) != 1) + return false; + int prefix = (defaultPrefixV6 >= 0) ? defaultPrefixV6 : 128; + if (prefix < 0 || prefix > 128) + return false; + int fullBytes = prefix / 8; + int remBits = prefix % 8; + if (memcmp(a.s6_addr, n.s6_addr, fullBytes) != 0) + return false; + if (remBits > 0) { + unsigned char mask = (unsigned char)(0xFF << (8 - remBits)); + if ((a.s6_addr[fullBytes] & mask) != (n.s6_addr[fullBytes] & mask)) + return false; + } + return true; + } else { + struct in_addr a{}, n{}; + if (inet_pton(AF_INET, ip.c_str(), &a) != 1 || inet_pton(AF_INET, cidrSpec.c_str(), &n) != 1) + return false; + int prefix = (defaultPrefixV4 >= 0) ? defaultPrefixV4 : 32; + if (prefix < 0 || prefix > 32) + return false; + uint32_t mask = (prefix == 0) ? 0 : htonl(0xFFFFFFFFu << (32 - prefix)); + return (a.s_addr & mask) == (n.s_addr & mask); + } + } + + SPFChecker::Result SPFChecker::checkRecursive(const std::string &domain, const std::string &clientIP, int &mechanismCount) { + + std::vector records = lookupSPFRecords(domain); + if (records.empty()) + return Result::None; + if (records.size() > 1) + return Result::PermError; // RFC 7208 4.5: more than one v=spf1 record is a PermError + + std::vector terms = parseTerms(records[0]); + std::string redirectTarget; + + for (auto &t : terms) { + + if (t.isModifier) { + if (t.name == "redirect") + redirectTarget = t.value; + continue; // "exp" and unrecognized modifiers are not evaluated + } + + bool matched = false; + + if (t.name == "all") { + matched = true; + + } else if (t.name == "ip4" || t.name == "ip6") { + std::string cidr; + int v4p = -1, v6p = -1; + splitDomainAndCidr(t.value, cidr, v4p, v6p); + // ip4/ip6 use a single trailing /prefix, not the dual form - + // splitDomainAndCidr's "second slash" case does not apply + // here, so whichever of v4p/v6p got set is the one that + // matters for this term's own family. + int prefix = (t.name == "ip4") ? v4p : v6p; + matched = ipInCIDR(clientIP, cidr, t.name == "ip4" ? prefix : -1, t.name == "ip6" ? prefix : -1); + + } else if (t.name == "a" || t.name == "mx") { + if (t.value.find('%') != std::string::npos) + continue; // macro - not supported, skip (see class doc comment) + std::string targetDomain; + int v4p = -1, v6p = -1; + splitDomainAndCidr(t.value, targetDomain, v4p, v6p); + if (targetDomain.empty()) + targetDomain = domain; + + std::vector candidateDomains; + if (t.name == "a") { + candidateDomains.push_back(targetDomain); + } else { + if (++mechanismCount > 10) + return Result::PermError; + candidateDomains = lookupMX(targetDomain); + } + + for (auto &cd : candidateDomains) { + if (++mechanismCount > 10) + return Result::PermError; + for (auto &ip : lookupA(cd)) { + if (ipInCIDR(clientIP, ip, v4p, v6p)) { + matched = true; + break; + } + } + if (matched) + break; + } + + } else if (t.name == "exists") { + if (t.value.find('%') != std::string::npos) + continue; // macro - not supported, skip + if (++mechanismCount > 10) + return Result::PermError; + matched = !lookupA(t.value).empty(); + + } else if (t.name == "include") { + if (++mechanismCount > 10) + return Result::PermError; + Result sub = checkRecursive(t.value, clientIP, mechanismCount); + // RFC 7208 5.2: only a Pass from the included domain makes + // "include" itself match; Fail/SoftFail/Neutral there just + // mean "this mechanism didn't match", not that the whole + // record fails - but an error in the sub-check is a real + // error here too, not silently swallowed. + if (sub == Result::PermError || sub == Result::TempError) + return sub; + matched = (sub == Result::Pass); + + } else if (t.name == "ptr") { + continue; // deprecated, unsupported - see class doc comment + + } else { + // Unrecognized mechanism name - RFC 7208 5: PermError. + return Result::PermError; + } + + if (matched) + return qualifierToResult(t.qualifier); + } + + if (!redirectTarget.empty()) { + if (++mechanismCount > 10) + return Result::PermError; + return checkRecursive(redirectTarget, clientIP, mechanismCount); + } + + // RFC 7208 4.7: no mechanism matched and no redirect - Neutral, + // as if "?all" had been appended. + return Result::Neutral; + } + + SPFChecker::Result SPFChecker::check(const std::string &domain, const std::string &clientIP) { + if (domain.empty()) + return Result::None; + int mechanismCount = 0; + return checkRecursive(domain, clientIP, mechanismCount); + } + + std::string SPFChecker::resultName(Result result) { + switch (result) { + case Result::Pass: return "pass"; + case Result::Fail: return "fail"; + case Result::SoftFail: return "softfail"; + case Result::Neutral: return "neutral"; + case Result::None: return "none"; + case Result::PermError: return "permerror"; + case Result::TempError: return "temperror"; + } + return "none"; + } + + std::string SPFChecker::receivedSPFHeader(Result result, const std::string &domain, + const std::string &clientIP, const std::string &helo, + const std::string &receivingHost) { + std::string name = resultName(result); + std::string explanation; + switch (result) { + case Result::Pass: explanation = "domain of " + domain + " designates " + clientIP + " as permitted sender"; break; + case Result::Fail: explanation = "domain of " + domain + " does not designate " + clientIP + " as permitted sender"; break; + case Result::SoftFail: explanation = "domain of " + domain + " does not designate " + clientIP + " as permitted sender (soft fail)"; break; + case Result::Neutral: explanation = "domain of " + domain + " has no definitive policy for " + clientIP; break; + case Result::None: explanation = "domain of " + domain + " does not designate permitted sender hosts"; break; + case Result::PermError: explanation = "permanent error in processing domain of " + domain; break; + case Result::TempError: explanation = "temporary error in processing domain of " + domain; break; + } + return "Received-SPF: " + name + " (" + receivingHost + ": " + explanation + ") client-ip=" + clientIP + + "; envelope-from=" + domain + "; helo=" + helo + ";\r\n"; + } + +} diff --git a/SPFChecker.h b/SPFChecker.h new file mode 100644 index 0000000..b672d0d --- /dev/null +++ b/SPFChecker.h @@ -0,0 +1,71 @@ +#ifndef __SPFChecker_h__ +#define __SPFChecker_h__ + +#include +#include + +namespace mail { + + /// + /// SPFChecker evaluates RFC 7208 Sender Policy Framework: whether + /// the IP address a connection actually came from is authorized to + /// send mail claiming to be from a given domain. + /// + /// Deliberately does not implement two parts of the RFC: macro + /// expansion (7208 7) and the ptr mechanism (7208 5.5) - macros are + /// rare outside large/complex senders, and the RFC itself says ptr + /// "SHOULD NOT be published" and "SHOULD be used as a last resort" + /// by checkers, precisely because it is unreliable and expensive (a + /// reverse-then-forward DNS round trip per candidate name). A + /// mechanism using either is simply skipped (treated as + /// non-matching) rather than failing the whole check. + /// + + class SPFChecker { + + public: + + enum class Result { Pass, Fail, SoftFail, Neutral, None, PermError, TempError }; + + /// + /// domain is the identity being checked (the envelope MAIL FROM's + /// domain, or the HELO/EHLO domain when MAIL FROM is the null + /// reverse-path <> - RFC 7208 2.3/2.4). clientIP is the IP + /// address of the SMTP client that actually connected. + /// + + static Result check(const std::string &domain, const std::string &clientIP); + + static std::string resultName(Result result); + + /// + /// A standard Received-SPF: header (RFC 7208 9.1, the convention + /// essentially every inbound MTA follows) recording the result - + /// downstream processing (a user's mail client, a later + /// spam-scoring pass) can key off this without redoing the DNS + /// work itself. + /// + + static std::string receivedSPFHeader(Result result, const std::string &domain, + const std::string &clientIP, const std::string &helo, + const std::string &receivingHost); + + private: + + // mechanismCount is threaded by reference through every + // recursive/redirected call so the RFC 7208 4.6.4 limit (10 + // DNS-querying terms total across the whole evaluation, not per + // record) is enforced globally, not reset at each level. + static Result checkRecursive(const std::string &domain, const std::string &clientIP, int &mechanismCount); + + static std::vector lookupSPFRecords(const std::string &domain); + static std::vector lookupA(const std::string &domain); + static std::vector lookupMX(const std::string &domain); + + static bool ipInCIDR(const std::string &ip, const std::string &cidrSpec, int defaultPrefixV4, int defaultPrefixV6); + + }; + +} + +#endif diff --git a/SendMail.cpp b/SendMail.cpp index 50b5a93..ef47b49 100644 --- a/SendMail.cpp +++ b/SendMail.cpp @@ -1,130 +1,250 @@ #include "SendMail.h" -#include -#include -#include +#include "Log.h" +#include +#include #include +#include +#include +#include #include +#include +#include +#include +#include namespace mail { - - SendMail::SendMail(core::EPoll &ePoll, std::string from, std::string recipient, std::string mailFileName) - : core::TCPSession2(ePoll, "Send Mail Agent"), from(from), recipient(recipient), mailFileName(mailFileName), - mailData(mailFileName), Timer(ePoll, 0.00f), ePoll(ePoll) { - - coreutils::Log(coreutils::LOG_DEBUG_1) << this->recipient; - this->recipient.split("@"); - coreutils::Log(coreutils::LOG_DEBUG_1) << this->recipient[1]; - - u_char nsbuff[4096]; - char *domain = this->recipient[1].c_str(); - int len = res_query(domain, ns_c_any, ns_t_mx, nsbuff, sizeof(nsbuff)); - coreutils::Log(coreutils::LOG_DEBUG_1) << ">" << len; - ns_msg msg; - - ns_initparse(nsbuff, len, &msg); - len = ns_msg_count(msg, ns_s_an); - - char dispbuf[4096]; - ns_rr rr; - for (int i = 0; i < 1; i++) { - ns_parserr(&msg, ns_s_an, i, &rr); - ns_sprintrr(&msg, &rr, NULL, NULL, dispbuf, sizeof(dispbuf)); - printf ("%s\n", dispbuf); - } - - coreutils::ZString mxArray(dispbuf); - mxArray.split("\t"); - mxArray[3].split(" "); - coreutils::Log(coreutils::LOG_DEBUG_1) << "MX: " << mxArray[3][1]; - - core::IPAddress mxAddress(mxArray[3][1].c_str(), 25); - connect(mxAddress); - - coreutils::Log(coreutils::LOG_DEBUG_1) << "SendMail initiated..."; - - } - - SendMail::SendMail(const SendMail ©) : core::TCPSession2(ePoll, "Send Mail Agent"), from(copy.from), recipient(copy.recipient), mailFileName(copy.mailFileName), - mailData(mailFileName), Timer(ePoll, 0.00f), ePoll(copy.ePoll) {} - - SendMail::~SendMail() { - coreutils::Log(coreutils::LOG_DEBUG_1) << "SendMail destructing..."; + + SendMail::SendMail(std::string hostName, std::string sendLogPath, std::string messageId, + coreutils::MString from, coreutils::MString recipient, coreutils::MString mailData, + DKIMSigner *dkimSigner) + : hostName(hostName), sendLogPath(sendLogPath), messageId(messageId), + from(from), recipient(recipient), mailData(mailData), dkimSigner(dkimSigner) {} + + std::string SendMail::domainOf(coreutils::MString &address) { + std::string addr = address.str(); + size_t at = addr.find_last_of('@'); + return (at == std::string::npos) ? addr : addr.substr(at + 1); } - - void SendMail::onTimeout() { - state = stateOnTimeout; - coreutils::Log(coreutils::LOG_DEBUG_1) << "onTimeout..."; - protocol(buffer); - TCPSession2::send(); - } - - void SendMail::protocol(coreutils::ZString &data) { - - coreutils::Log(coreutils::LOG_DEBUG_1) << "[" << data << "..." << state; - - switch (state) { - - case CONNECT: - // read recipient - // parse email for name and domain name - // open tcpsocket with connect - // wait for greeting - if(data.asInteger() == 220) { - out << "EHLO " << "mail.barant.com" << CRLF; - state = READY; - } - break; - - case READY: - buffer = data; - setTimer(.05); - stateOnTimeout = READYX; - break; - - case READYX: - if(buffer.asInteger() == 250) { - out << "MAIL FROM:<" << from << ">" << CRLF; - state = MAIL; - } - break; - - case MAIL: - if(data.asInteger() == 250) { - out << "RCPT TO:<" << recipient << ">" << CRLF; - state = RCPT; - } - break; - - case RCPT: - if(data.asInteger() == 250) { - out << "DATA" << CRLF; - state = DATA; - } - break; - - case DATA: - if(data.asInteger() == 354) { - while(!mailData.eof()) { - mailData.readLine(); - out << mailData.asZString() << CRLF; - } - out << "." << CRLF; - state = SENT; - } - break; - - case SENT: - if(data.asInteger() == 250) { - out << "QUIT" << CRLF; - } - break; - + + std::vector SendMail::lookupMX(const std::string &domain) { + std::vector> records; + unsigned char answer[4096]; + int len = res_query(domain.c_str(), C_IN, T_MX, answer, sizeof(answer)); + if (len > 0) { + ns_msg handle; + if (ns_initparse(answer, len, &handle) == 0) { + int count = ns_msg_count(handle, ns_s_an); + for (int i = 0; i < count; ++i) { + ns_rr rr; + if (ns_parserr(&handle, ns_s_an, i, &rr) == 0 && ns_rr_type(rr) == ns_t_mx) { + const unsigned char *rdata = ns_rr_rdata(rr); + int preference = (rdata[0] << 8) | rdata[1]; + char host[NS_MAXDNAME]; + if (dn_expand(ns_msg_base(handle), ns_msg_end(handle), rdata + 2, host, sizeof(host)) >= 0) + records.emplace_back(preference, std::string(host)); + } + } + } } - + std::sort(records.begin(), records.end(), + [](const std::pair &a, const std::pair &b) { + return a.first < b.first; + }); + std::vector hosts; + for (auto &r : records) + hosts.push_back(r.second); + // RFC 5321 5.1: a domain with no MX records is treated as a + // single implicit MX naming that domain itself. + if (hosts.empty()) + hosts.push_back(domain); + return hosts; } - - std::vector& SendMail::extractRecipientList() { - return recipientList; + + std::string SendMail::readLine(int fd) { + std::string line; + char ch; + while (::read(fd, &ch, 1) == 1) { + if (ch == '\n') + break; + if (ch != '\r') + line += ch; + } + return line; } - + + void SendMail::sendLine(int fd, const std::string &line, coreutils::File &log) { + std::string wire = line + "\r\n"; + ::write(fd, wire.c_str(), wire.length()); + log.write(">> " + line + "\n"); + } + + int SendMail::readResponse(int fd, coreutils::File &log, std::string &fullText) { + // A multi-line SMTP reply repeats the same 3-digit code on every + // line, with "-" (not a space) after the code on every line but + // the last (RFC 5321 4.2.1) - keep reading until a line uses the + // space form. + int code = -1; + fullText.clear(); + while (true) { + std::string line = readLine(fd); + log.write("<< " + line + "\n"); + if (line.length() < 4) + return code; + code = atoi(line.substr(0, 3).c_str()); + fullText += line + "\n"; + if (line[3] == ' ') + break; + } + return code; + } + + bool SendMail::deliverToHost(const std::string &host, coreutils::File &log) { + + struct addrinfo hints {}; + struct addrinfo *result = nullptr; + // AF_UNSPEC let this pick a destination MX host's IPv6 address + // whenever one existed, which - since the OS picks the LOCAL + // source address to match the destination's address family - has + // this server originate the connection from its own IPv6 address + // instead of its IPv4 one. Confirmed live: every one of Gmail's 5 + // MX hosts rejected delivery with "550 5.7.25 ... does not have a + // PTR record" for that IPv6 address, which really doesn't have + // one (nor is it covered by this domain's own SPF record, which + // only lists the IPv4 address - a second, independent reason + // IPv6 delivery from here can never pass real-world receiver + // policy). The IPv4 address DOES have a correct PTR (resolves + // back to barant.com) and IS the address barant.com's SPF record + // authorizes - AF_INET forces every outbound relay connection to + // originate from it, matching what this domain's actual DNS + // records promise. Revisit if IPv6 PTR + an ip6: SPF entry are + // ever added for this host. + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(host.c_str(), "25", &hints, &result) != 0 || !result) { + log.write("connect: could not resolve " + host + "\n"); + return false; + } + + int fd = -1; + for (struct addrinfo *rp = result; rp != nullptr; rp = rp->ai_next) { + fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (fd < 0) + continue; + if (::connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) + break; + ::close(fd); + fd = -1; + } + freeaddrinfo(result); + + if (fd < 0) { + log.write("connect: unable to reach " + host + " on port 25\n"); + return false; + } + + struct timeval timeout {60, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + + std::string text; + + // Everything but the final QUIT lives in this lambda so a failure + // partway through the conversation can bail out with a plain + // `return false` while the caller still always sends QUIT and + // closes the socket afterward, regardless of how this ended. + auto converse = [&]() -> bool { + + if (readResponse(fd, log, text) / 100 != 2) + return false; + + sendLine(fd, "EHLO " + hostName, log); + if (readResponse(fd, log, text) / 100 != 2) { + // Some older servers only speak HELO. + sendLine(fd, "HELO " + hostName, log); + if (readResponse(fd, log, text) / 100 != 2) + return false; + } + + sendLine(fd, "MAIL FROM:<" + from.str() + ">", log); + if (readResponse(fd, log, text) / 100 != 2) + return false; + + sendLine(fd, "RCPT TO:<" + recipient.str() + ">", log); + if (readResponse(fd, log, text) / 100 != 2) + return false; + + sendLine(fd, "DATA", log); + if (readResponse(fd, log, text) != 354) + return false; + + std::string body = mailData.str(); + std::istringstream stream(body); + std::string bodyLine, wire; + while (std::getline(stream, bodyLine)) { + if (!bodyLine.empty() && bodyLine.back() == '\r') + bodyLine.pop_back(); + // RFC 5321 4.5.2 transparency: a body line that legitimately + // starts with '.' must be stuffed with an extra leading '.' + // so the receiver never mistakes it for the terminating line. + if (!bodyLine.empty() && bodyLine[0] == '.') + wire += "."; + wire += bodyLine; + wire += "\r\n"; + } + wire += ".\r\n"; + ::write(fd, wire.c_str(), wire.length()); + log.write(">> [message body, " + std::to_string(body.length()) + " bytes]\n"); + + return readResponse(fd, log, text) / 100 == 2; + }; + + bool ok = converse(); + + sendLine(fd, "QUIT", log); + readResponse(fd, log, text); + ::close(fd); + + return ok; + } + + bool SendMail::send() { + + // ZString's std::string constructor takes a non-const reference, + // which can't bind to a temporary - a named local is required + // here rather than passing the concatenation expression directly. + std::string logFileName = sendLogPath + "/" + messageId + ".log"; + coreutils::File log(logFileName, O_CREAT | O_WRONLY | O_APPEND, 0660); + log.write("=== delivery attempt: message " + messageId + " -> " + recipient.str() + " ===\n"); + + if (dkimSigner) { + // Signed once here, not inside deliverToHost() - deliverToHost() + // can run again against a second MX host if the first fails, and + // the signature (over this exact message content) is the same + // either way, so signing once upfront avoids redundant RSA ops + // and keeps every delivery attempt for this message consistent. + std::string signature = dkimSigner->sign(mailData.str()); + coreutils::MString signedData; + signedData << signature.c_str() << mailData; + mailData = signedData; + log.write("DKIM-signed.\n"); + } + + std::string domain = domainOf(recipient); + std::vector hosts = lookupMX(domain); + + bool delivered = false; + for (auto &host : hosts) { + log.write("--- trying " + host + " ---\n"); + if (deliverToHost(host, log)) { + log.write("delivered successfully via " + host + "\n"); + delivered = true; + break; + } + } + if (!delivered) + log.write("all hosts for " + domain + " failed\n"); + + return delivered; + } + } diff --git a/SendMail.h b/SendMail.h index e28803d..a8b4943 100644 --- a/SendMail.h +++ b/SendMail.h @@ -1,45 +1,74 @@ #ifndef __SendMail_h__ #define __SendMail_h__ -#include "EPoll.h" -#include "TCPSession2.h" +#include "DKIMSigner.h" +#include "File.h" #include "MString.h" -#include "Timer.h" - -#define CRLF "\r\n" -#define CRLF "\r\n" -#ifndef ___state_defined___ -#define ___state_defined___ -typedef enum {CONNECT, READY, READYX, MAIL, RCPT, DATA, SENT} State; -#endif +#include +#include namespace mail { - class SendMail : public core::TCPSession2, private core::Timer { + /// + /// SendMail delivers one message to one remote recipient over + /// outbound SMTP (RFC 5321): resolves the recipient domain's MX + /// records - falling back to the domain's own address when none + /// exist, per RFC 5321 5.1 - tries each candidate host in preference + /// order until one accepts the message, and runs the client side of + /// the SMTP conversation (EHLO/HELO, MAIL FROM, RCPT TO, DATA with + /// dot-stuffing) over a plain blocking socket. + /// + /// send() is a plain, synchronous, blocking call - it does not spawn + /// a thread or manage this object's lifetime itself, so the exact + /// same class serves both of this server's real callers even though + /// they want opposite things: SMTPServer::inCreate() (queued mail + /// found via inotify) wants fire-and-forget, so it wraps its own + /// heap-allocated SendMail in a detached thread and deletes it when + /// send() returns; main.cpp's "sendmail2" CLI-injector mode wants to + /// block and report the result back to its caller (matching the + /// classic local sendmail(1) contract other programs shell out to), + /// so it just calls send() directly on the stack. Baking a threading + /// policy into this class would have made one of those two wrong. + /// + /// Every line sent and every line the remote server responds with is + /// appended, as it happens (not buffered to be written only at the + /// end - a hung or very slow remote host would otherwise leave no + /// trace at all until it finally times out), to one log file per + /// message under sendLogPath, shared across every recipient of that + /// message so its full delivery history reads as one transcript. + /// + + class SendMail { public: - SendMail(core::EPoll &ePoll, std::string from, std::string recipient, std::string mailFileName); - SendMail(const SendMail ©); - ~SendMail(); - coreutils::MString& send(); + // dkimSigner is optional (nullptr = don't sign) and not owned - + // the caller keeps it alive for as long as any SendMail built + // from it might still be sending (a shared signer is fine to + // reuse across many messages; it holds no per-message state). + SendMail(std::string hostName, std::string sendLogPath, std::string messageId, + coreutils::MString from, coreutils::MString recipient, coreutils::MString mailData, + DKIMSigner *dkimSigner = nullptr); - void protocol(coreutils::ZString &data) override; + /// Blocking. Returns true if some MX host for the recipient's + /// domain accepted the message, false if DNS resolution or every + /// host it tried failed. + bool send(); private: - std::vector& extractRecipientList(); - void onTimeout() override; + bool deliverToHost(const std::string &host, coreutils::File &log); + std::vector lookupMX(const std::string &domain); + std::string domainOf(coreutils::MString &address); + std::string readLine(int fd); + int readResponse(int fd, coreutils::File &log, std::string &fullText); + void sendLine(int fd, const std::string &line, coreutils::File &log); - core::EPoll &ePoll; - - coreutils::File mailData; + std::string hostName; + std::string sendLogPath; + std::string messageId; coreutils::MString from; coreutils::MString recipient; - coreutils::MString mailFileName; - coreutils::MString buffer; - std::vector recipientList; - - State state = CONNECT; - State stateOnTimeout = CONNECT; + coreutils::MString mailData; + DKIMSigner *dkimSigner; }; diff --git a/SendMail2.cpp b/SendMail2.cpp deleted file mode 100644 index 6409e85..0000000 --- a/SendMail2.cpp +++ /dev/null @@ -1,184 +0,0 @@ -#include "SendMail2.h" -#include "ZString.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "Log.h" - -#define BUFFER_SIZE 1048576 - -namespace mail { - - SendMail2::SendMail2(core::EPoll &ePoll, coreutils::MString from, coreutils::MString recipient, coreutils::MString message) - : core::TCPSession2(ePoll, "Send Mail Injector"), from(from), recipient(recipient), - Timer(ePoll, 30.00f), ePoll(ePoll), message(message) { - coreutils::Log(coreutils::LOG_DEBUG_1) << "SendMail2 constructed."; - } - - int SendMail2::send() { - - waiter.lock(); - coreutils::Log(coreutils::LOG_DEBUG_1) << this->recipient; - this->recipient.split("@"); - coreutils::Log(coreutils::LOG_DEBUG_1) << this->recipient[1]; - - u_char nsbuff[4096]; - char *domain = this->recipient[1].c_str(); - int len = res_search(domain, C_IN, T_MX, nsbuff, sizeof(nsbuff)); - ns_msg msg; - - ns_initparse(nsbuff, len, &msg); - len = ns_msg_count(msg, ns_s_an); - - char dispbuf[4096]; - ns_rr rr; - for (int i = 0; i < 1; i++) { - ns_parserr(&msg, ns_s_an, i, &rr); - ns_sprintrr(&msg, &rr, NULL, NULL, dispbuf, sizeof(dispbuf)); - } - coreutils::Log(coreutils::LOG_DEBUG_1) << "dispbuf: " << dispbuf; - - coreutils::ZString mxArray(dispbuf); - mxArray.find("MX"); - mxArray.ifNext("MX"); - mxArray.skipWhitespace(); - mxArray.getTokenInclude("0123456789"); - mxArray.skipWhitespace(); - coreutils::Log(coreutils::LOG_DEBUG_1) << "MX: " << mxArray.unparsed(); - core::IPAddress mxAddress(mxArray.unparsed().c_str(), 25); - coreutils::Log(coreutils::LOG_DEBUG_1) << "IP: " << mxAddress.getClientAddressAndPort(); - connect(mxAddress); - - coreutils::Log(coreutils::LOG_DEBUG_1) << "SendMail2 injector initiated..."; - return 0; - } - - SendMail2::SendMail2(const SendMail2 ©) : core::TCPSession2(ePoll, "Send Mail Injector"), from(copy.from), recipient(copy.recipient), - Timer(ePoll, 30.00f), ePoll(copy.ePoll), message(copy.message) {} - - SendMail2::~SendMail2() { - coreutils::Log(coreutils::LOG_DEBUG_1) << "SendMail destructing..."; - } - - void SendMail2::onTimeout() { - coreutils::Log(coreutils::LOG_DEBUG_1) << "onTimeout..." << state; - returnValue << "timeout in " << state; - state = SENT; - waiter.unlock(); - } - - void SendMail2::protocol(coreutils::ZString &data) { - - coreutils::Log(coreutils::LOG_DEBUG_1) << "[" << data << "..." << state; - - switch (state) { - - case CONNECT: - if(data.asInteger() == 220) { - out << "EHLO " << "drinkwise.barant.com" << CRLF; - coreutils::Log(coreutils::LOG_DEBUG_1) << ">EHLO"; - state = READY; - } - break; - - case READY: - buffer = data; - setTimer(30); - stateOnTimeout = READYX; - state = READYX; - break; - - case READYX: - if(buffer.asInteger() == 250) { - coreutils::Log(coreutils::LOG_DEBUG_1) << ">MAIL FROM:<" << from << ">"; - out << "MAIL FROM:<" << from << ">" << CRLF; - state = MAIL; - } - break; - - case MAIL: - if(data.asInteger() == 250) { - coreutils::Log(coreutils::LOG_DEBUG_1) << ">RCPT TO:<" << recipient << ">"; - out << "RCPT TO:<" << recipient << ">" << CRLF; - state = RCPT; - } - break; - - case RCPT: - if(data.asInteger() == 250) { - out << "DATA" << CRLF; - state = DATA; - } - break; - - case DATA: - if(data.asInteger() == 354) { - out << message << CRLF; - out << "." << CRLF; - state = SENT; - } else if (data.asInteger() == 421) { - coreutils::Log(coreutils::LOG_DEBUG_1) << "Error 421"; - data.split(" ", 2); - returnValue = data; - state = SENT; - waiter.unlock(); - } else if (data.asInteger() == 450) { - coreutils::Log(coreutils::LOG_DEBUG_1) << "Error 450"; - data.split(" ", 2); - returnValue = data; - state = SENT; - waiter.unlock(); - } else if (data.asInteger() == 451) { - coreutils::Log(coreutils::LOG_DEBUG_1) << "Error 451"; - data.split(" ", 2); - returnValue = data; - state = SENT; - waiter.unlock(); - } else if (data.asInteger() == 452) { - coreutils::Log(coreutils::LOG_DEBUG_1) << "Error 452"; - data.split(" ", 2); - returnValue = data; - state = SENT; - waiter.unlock(); - } else if (data.asInteger() == 550) { - data.split(" ", 2); - returnValue = data; - state = SENT; - waiter.unlock(); - } else if (data.asInteger() == 552) { - coreutils::Log(coreutils::LOG_DEBUG_1) << "Error 552"; - data.split(" ", 2); - returnValue = data; - state = SENT; - waiter.unlock(); - } - break; - - case SENT: - returnValue = data; - out << "QUIT" << CRLF; - waiter.unlock(); - break; - - } - - } - - coreutils::MString SendMail2::wait() { - waiter.lock(); - waiter.unlock(); - return returnValue; - } - - - std::vector& SendMail2::extractRecipientList() { - return recipientList; - } - -} diff --git a/SendMail2.h b/SendMail2.h deleted file mode 100644 index b86b0ba..0000000 --- a/SendMail2.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef __SendMail2_h__ -#define __SendMail2_h__ - -#include "EPoll.h" -#include "TCPSession2.h" -#include "MString.h" -#include "Timer.h" -#include - -#define CRLF "\r\n" -#ifndef ___state_defined___ -#define ___state_defined___ -typedef enum {CONNECT, READY, READYX, MAIL, RCPT, DATA, SENT} State; -#endif - -namespace mail { - - class SendMail2 : public core::TCPSession2, private core::Timer { - - public: - SendMail2(core::EPoll &ePoll, coreutils::MString from, coreutils::MString recipient, coreutils::MString message); - SendMail2(const SendMail2 ©); - ~SendMail2(); - - void protocol(coreutils::ZString &data) override; - - int send(); - coreutils::MString wait(); - coreutils::MString returnValue; - - private: - std::vector& extractRecipientList(); - void onTimeout() override; - - core::EPoll &ePoll; - - coreutils::MString from; - coreutils::MString recipient; - coreutils::MString buffer; - coreutils::MString message; - std::vector recipientList; - - State state = CONNECT; - State stateOnTimeout = CONNECT; - - std::mutex waiter; - }; - -} - -#endif diff --git a/SpamAssassinFilter.cpp b/SpamAssassinFilter.cpp new file mode 100644 index 0000000..3be7957 --- /dev/null +++ b/SpamAssassinFilter.cpp @@ -0,0 +1,112 @@ +#include "SpamAssassinFilter.h" +#include "Log.h" +#include +#include +#include +#include + +namespace mail { + + namespace { + const char *SPAMD_HOST = "127.0.0.1"; + const int SPAMD_PORT = 783; + } + + // Speaks the spamc/spamd wire protocol (documented in spamd(1)'s + // PROTOCOL file) directly over a plain blocking socket, rather than + // shelling out to the spamc binary - one connect/send/recv is + // simpler than managing a child process's stdin/stdout pipes + // without risking a full-pipe deadlock. This blocks whichever + // thread calls it for however long spamd takes to score the + // message - same tradeoff SPFChecker::check() already makes for its + // own synchronous DNS lookups (see __SMTP_MAIL.cpp's doc comment). + // Called only from SMTPServer::inCreate(), on the queue-watcher's + // EPoll worker thread, never inline in a client's DATA command - so + // it's this thread's other queued deliveries that wait behind a + // slow scan, not a connected client. + bool SpamAssassinFilter::talkToSpamd(const std::string &message, std::string &response) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) + return false; + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(SPAMD_PORT); + if (inet_pton(AF_INET, SPAMD_HOST, &addr.sin_addr) != 1) { + close(fd); + return false; + } + + if (connect(fd, (sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return false; + } + + std::string request = "PROCESS SPAMC/1.5\r\n"; + request += "Content-length: " + std::to_string(message.size()) + "\r\n"; + request += "\r\n"; + request += message; + + size_t sent = 0; + while (sent < request.size()) { + ssize_t n = send(fd, request.data() + sent, request.size() - sent, 0); + if (n <= 0) { + close(fd); + return false; + } + sent += n; + } + shutdown(fd, SHUT_WR); // tell spamd it has the whole message, without closing our read side + + response.clear(); + char buf[8192]; + ssize_t n; + while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) + response.append(buf, n); + + close(fd); + return !response.empty(); + } + + SpamAssassinFilter::Result SpamAssassinFilter::scan(const std::string &message) { + Result result; + result.scanned = message; // fail-open default: deliver the original if anything below bails out + + std::string response; + if (!talkToSpamd(message, response)) { + coreutils::Log(coreutils::LOG_WARN) << "SpamAssassin scan failed (spamd unreachable at " + << SPAMD_HOST << ":" << SPAMD_PORT << ") - delivering unscanned."; + return result; + } + + // spamd's own response is itself a small header block + // ("SPAMD/1.5 0 EX_OK\r\nContent-length: N\r\n\r\n") wrapped + // around the scanned message - strip that off before treating + // what follows as the message to actually deliver. + size_t headerEnd = response.find("\r\n\r\n"); + if (headerEnd == std::string::npos) { + coreutils::Log(coreutils::LOG_WARN) << "SpamAssassin scan returned an unparseable response - delivering unscanned."; + return result; + } + + std::string statusLine = response.substr(0, response.find("\r\n")); + if (statusLine.find("EX_OK") == std::string::npos) { + coreutils::Log(coreutils::LOG_WARN) << "SpamAssassin scan reported an error (" << statusLine << ") - delivering unscanned."; + return result; + } + + result.scanned = response.substr(headerEnd + 4); + result.ok = true; + + // spamd's required_score (local.cf) is the actual threshold; + // this just reads the Yes/No verdict spamd already reached back + // out of the header it wrote, rather than re-deciding anything. + static const std::string marker = "\r\nX-Spam-Status: "; + size_t pos = result.scanned.find(marker); + if (pos != std::string::npos) + result.isSpam = result.scanned.compare(pos + marker.size(), 3, "Yes") == 0; + + return result; + } + +} diff --git a/SpamAssassinFilter.h b/SpamAssassinFilter.h new file mode 100644 index 0000000..e221f49 --- /dev/null +++ b/SpamAssassinFilter.h @@ -0,0 +1,47 @@ +#ifndef __SpamAssassinFilter_h__ +#define __SpamAssassinFilter_h__ + +#include + +namespace mail { + + /// + /// Pipes a message through the local spamd (via the spamc protocol, + /// spoken directly over a socket rather than shelling out to the + /// spamc binary) on the local-delivery path in + /// SMTPServer::inCreate() - spamd hands back the message with its + /// usual X-Spam-Status/X-Spam-Level/X-Spam-Report headers added (and, + /// once a message is over its own report_safe threshold, rewrapped + /// into a text/plain report). This class applies no threshold of its + /// own - spamd's required_score (/etc/spamassassin/local.cf, 5.0 on + /// this host) is what actually decides Yes/No; isSpam below just + /// reads that verdict back out of the X-Spam-Status header spamd + /// already wrote, the same way any other MTA's spam tag would be + /// read by a downstream filter. + /// + /// Fails open: if spamd can't be reached at all, delivery proceeds + /// with the original, unscanned message rather than blocking mail + /// on a scanner outage. + /// + + class SpamAssassinFilter { + + public: + + struct Result { + bool ok = false; // true if spamd actually scanned the message + bool isSpam = false; // parsed from X-Spam-Status: Yes/No - meaningless if !ok + std::string scanned; // the (possibly tagged/rewritten) message; == input verbatim if !ok + }; + + static Result scan(const std::string &message); + + private: + + static bool talkToSpamd(const std::string &message, std::string &response); + + }; + +} + +#endif diff --git a/__IMAP_APPEND.cpp b/__IMAP_APPEND.cpp index 7ec6614..1d7791e 100644 --- a/__IMAP_APPEND.cpp +++ b/__IMAP_APPEND.cpp @@ -1,10 +1,86 @@ #include "__IMAP_APPEND.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include "MailFolder.h" +#include +#include namespace mail { - - int __IMAP_APPEND::processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; + + int __IMAP_APPEND::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString mailboxArg = IMAPArgs::nextArgument(request); + std::string folderName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(mailboxArg)); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(folderName)) { + session.out << session.tag << " BAD Invalid mailbox name" << CRLF; + return 1; + } + if (!server.mailFileSystem.ifFolderExists(userNameZ, folderName)) { + session.out << session.tag << " NO [TRYCREATE] Mailbox does not exist" << CRLF; + return 1; + } + + std::vector flags; + time_t internalDate = 0; + + coreutils::ZString nextArg = IMAPArgs::nextArgument(request); + std::string nextText = nextArg.str(); + if (!nextText.empty() && nextText[0] == '(') { + std::string flagText = IMAPArgs::unwrap(nextArg); + std::istringstream flagStream(flagText); + std::string flag; + while (flagStream >> flag) + flags.push_back(flag); + nextArg = IMAPArgs::nextArgument(request); + nextText = nextArg.str(); + } + if (!nextText.empty() && nextText[0] == '"') { + internalDate = IMAPArgs::parseDate(nextArg); + if (internalDate == (time_t)-1) + internalDate = 0; + nextArg = IMAPArgs::nextArgument(request); + nextText = nextArg.str(); + } + + if (nextText.empty() || nextText[0] != '{') { + session.out << session.tag << " BAD APPEND requires a message literal" << CRLF; + return 1; + } + long literalSize = std::strtol(IMAPArgs::unwrap(nextArg).c_str(), nullptr, 10); + if (literalSize < 0) { + session.out << session.tag << " BAD Invalid literal size" << CRLF; + return 1; + } + + std::string folderPath = server.mailFileSystem.getFolderPath(userNameZ, folderName).str(); + std::string tag = session.tag; + bool wasSelectedFolder = (session.state == IMAPSession::SELECTED && session.selectedFolderName == folderName); + + session.pendingBlockHandler = [&session, folderPath, flags, internalDate, tag, wasSelectedFolder](coreutils::ZString &block) { + session.setBlockSize(0); + MailFolder folder; + folder.load(folderPath); + uint32_t uid = folder.append(block.str(), flags, internalDate); + // A client that APPENDs into the mailbox it currently has + // SELECTed needs its own view refreshed, or a subsequent FETCH/ + // STORE against the sequence numbers it already knows would be + // working off a stale EXISTS count. + if (wasSelectedFolder) + session.mailbox = folder; + session.out << tag << " OK [APPENDUID " << (int)folder.uidValidity() << " " << (int)uid << "] APPEND completed" << CRLF; + session.send(); + }; + + session.out << "+ OK" << CRLF; + session.send(); + session.setBlockSize((int)literalSize); return 1; } - + } diff --git a/__IMAP_APPEND.h b/__IMAP_APPEND.h index 83a3528..87a3c1e 100644 --- a/__IMAP_APPEND.h +++ b/__IMAP_APPEND.h @@ -4,13 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_APPEND : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session); - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_AUTHENTICATE.h b/__IMAP_AUTHENTICATE.h index 983b471..81b0feb 100644 --- a/__IMAP_AUTHENTICATE.h +++ b/__IMAP_AUTHENTICATE.h @@ -7,8 +7,11 @@ namespace mail { class __IMAP_AUTHENTICATE : public IMAPCommand { - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override { + // Still a placeholder: this command dispatches and replies with a + // syntactically valid IMAP tagged response now, but does not yet do + // any real mailbox work. + session.out << session.tag << " OK Completed" << CRLF; return 1; } diff --git a/__IMAP_CAPABILITY.cpp b/__IMAP_CAPABILITY.cpp new file mode 100644 index 0000000..0a9f763 --- /dev/null +++ b/__IMAP_CAPABILITY.cpp @@ -0,0 +1,23 @@ +#include "__IMAP_CAPABILITY.h" +#include "IMAPServer.h" + +namespace mail { + + // RFC 3501 6.1.1: CAPABILITY MUST be supported in every connection + // state - it was previously not registered at all. Deliberately + // advertises nothing beyond bare IMAP4rev1: the greeting used to + // claim LITERAL+/SASL-IR/LOGIN-REFERRALS/ID/ENABLE/IDLE/STARTTLS/ + // AUTH=PLAIN/AUTH=LOGIN, none of which had (or have) a working + // handler behind them - a client that believed any of those and + // tried to use it would get nothing back. Every other IMAP command + // this server registers still only returns a placeholder response + // (see each handler's own file) - claiming a capability whose + // handler doesn't yet do anything real would just move the same + // dishonesty here instead of fixing it. + int __IMAP_CAPABILITY::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + session.out << "* CAPABILITY IMAP4rev1" << CRLF; + session.out << session.tag << " OK CAPABILITY completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_CAPABILITY.h b/__IMAP_CAPABILITY.h new file mode 100644 index 0000000..b5ecc9a --- /dev/null +++ b/__IMAP_CAPABILITY.h @@ -0,0 +1,16 @@ +#ifndef ____IMAP_CAPABILITY_h__ +# define ____IMAP_CAPABILITY_h__ + +# include "IMAPCommand.h" + +namespace mail { + + class __IMAP_CAPABILITY : public IMAPCommand { + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + + }; + +} + +#endif diff --git a/__IMAP_CHECK.cpp b/__IMAP_CHECK.cpp new file mode 100644 index 0000000..7224fba --- /dev/null +++ b/__IMAP_CHECK.cpp @@ -0,0 +1,23 @@ +#include "__IMAP_CHECK.h" +#include "IMAPServer.h" + +namespace mail { + + void refreshSelectedMailbox(IMAPSession &session) { + if (session.state != IMAPSession::SELECTED) + return; + size_t before = session.mailbox.size(); + session.mailbox.load(session.mailbox.path); + if (session.mailbox.size() != before || session.mailbox.recentCount() > 0) { + session.out << "* " << (int)session.mailbox.size() << " EXISTS" << CRLF; + session.out << "* " << (int)session.mailbox.recentCount() << " RECENT" << CRLF; + } + } + + int __IMAP_CHECK::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + refreshSelectedMailbox(session); + session.out << session.tag << " OK CHECK completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_CHECK.h b/__IMAP_CHECK.h index 7215600..8067daf 100644 --- a/__IMAP_CHECK.h +++ b/__IMAP_CHECK.h @@ -4,16 +4,20 @@ # include "IMAPCommand.h" namespace mail { - + + // Re-loads the selected mailbox (if any) and emits fresh untagged + // EXISTS/RECENT if the message count changed - shared by CHECK and + // NOOP, RFC 3501 6.1.2/6.4.1 both call for exactly this ("perform + // housekeeping"/reflect newly delivered mail), and there's nothing + // NOOP does that CHECK doesn't already need to as its main job. + void refreshSelectedMailbox(IMAPSession &session); + class __IMAP_CHECK : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_CLOSE.cpp b/__IMAP_CLOSE.cpp new file mode 100644 index 0000000..1f7ce0a --- /dev/null +++ b/__IMAP_CLOSE.cpp @@ -0,0 +1,25 @@ +#include "__IMAP_CLOSE.h" +#include "IMAPServer.h" + +namespace mail { + + // RFC 3501 6.4.2: CLOSE expunges (silently - no untagged EXPUNGE) + // unless the mailbox was opened via EXAMINE, then always deselects. + int __IMAP_CLOSE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state != IMAPSession::SELECTED) { + session.out << session.tag << " NO No mailbox selected" << CRLF; + return 1; + } + + if (!session.readOnly) + session.mailbox.expunge(); + + session.state = IMAPSession::AUTHENTICATED; + session.selectedFolderName.clear(); + session.readOnly = false; + + session.out << session.tag << " OK CLOSE completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_CLOSE.h b/__IMAP_CLOSE.h index 1089284..9cb6239 100644 --- a/__IMAP_CLOSE.h +++ b/__IMAP_CLOSE.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_CLOSE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_COPY.cpp b/__IMAP_COPY.cpp new file mode 100644 index 0000000..6b502f8 --- /dev/null +++ b/__IMAP_COPY.cpp @@ -0,0 +1,45 @@ +#include "__IMAP_COPY.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" + +namespace mail { + + int copyImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode) { + if (session.state != IMAPSession::SELECTED) { + session.out << session.tag << " NO No mailbox selected" << CRLF; + return 1; + } + + std::vector uids = IMAPArgs::parseSequenceSet(request, session.mailbox, uidMode); + + coreutils::ZString nameArg = IMAPArgs::nextArgument(request); + std::string destName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(nameArg)); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(destName) || !server.mailFileSystem.ifFolderExists(userNameZ, destName)) { + session.out << session.tag << " NO [TRYCREATE] Destination mailbox does not exist" << CRLF; + return 1; + } + + MailFolder destination; + destination.load(server.mailFileSystem.getFolderPath(userNameZ, destName).str()); + + for (uint32_t uid : uids) { + MailFolder::Message *message = session.mailbox.find(uid); + if (!message) + continue; + destination.append(session.mailbox.readMessage(uid), message->flags, message->internalDate); + } + + if (session.state == IMAPSession::SELECTED && session.selectedFolderName == destName) + session.mailbox = destination; + + session.out << session.tag << " OK " << (uidMode ? "UID COPY" : "COPY") << " completed" << CRLF; + return 1; + } + + int __IMAP_COPY::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + return copyImpl(request, session, server, false); + } + +} diff --git a/__IMAP_COPY.h b/__IMAP_COPY.h index 7c4e26d..11f15a7 100644 --- a/__IMAP_COPY.h +++ b/__IMAP_COPY.h @@ -4,16 +4,15 @@ # include "IMAPCommand.h" namespace mail { - + + int copyImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode); + class __IMAP_COPY : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_CREATE.cpp b/__IMAP_CREATE.cpp new file mode 100644 index 0000000..adddb21 --- /dev/null +++ b/__IMAP_CREATE.cpp @@ -0,0 +1,37 @@ +#include "__IMAP_CREATE.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include + +namespace mail { + + int __IMAP_CREATE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString nameArg = IMAPArgs::nextArgument(request); + std::string folderName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(nameArg)); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(folderName)) { + session.out << session.tag << " BAD Invalid mailbox name" << CRLF; + return 1; + } + if (server.mailFileSystem.ifFolderExists(userNameZ, folderName)) { + session.out << session.tag << " NO Mailbox already exists" << CRLF; + return 1; + } + + std::string path = server.mailFileSystem.getFolderPath(userNameZ, folderName).str(); + if (mkdir(path.c_str(), 0770) != 0) { + session.out << session.tag << " NO CREATE failed" << CRLF; + return 1; + } + + session.out << session.tag << " OK CREATE completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_CREATE.h b/__IMAP_CREATE.h index 73d5649..c6961e7 100644 --- a/__IMAP_CREATE.h +++ b/__IMAP_CREATE.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_CREATE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_DELETE.cpp b/__IMAP_DELETE.cpp new file mode 100644 index 0000000..a24a31e --- /dev/null +++ b/__IMAP_DELETE.cpp @@ -0,0 +1,44 @@ +#include "__IMAP_DELETE.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include + +namespace mail { + + int __IMAP_DELETE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString nameArg = IMAPArgs::nextArgument(request); + std::string folderName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(nameArg)); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(folderName) || !server.mailFileSystem.ifFolderExists(userNameZ, folderName)) { + session.out << session.tag << " NO Mailbox does not exist" << CRLF; + return 1; + } + // RFC 3501 6.3.4 permits refusing to delete INBOX outright rather + // than implementing its special "move messages out, keep INBOX + // itself" semantics. + if (folderName == "Inbox") { + session.out << session.tag << " NO INBOX cannot be deleted" << CRLF; + return 1; + } + + std::error_code errorCode; + std::filesystem::remove_all(server.mailFileSystem.getFolderPath(userNameZ, folderName).str(), errorCode); + if (errorCode) { + session.out << session.tag << " NO DELETE failed" << CRLF; + return 1; + } + + if (session.state == IMAPSession::SELECTED && session.selectedFolderName == folderName) + session.state = IMAPSession::AUTHENTICATED; + + session.out << session.tag << " OK DELETE completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_DELETE.h b/__IMAP_DELETE.h index f3b200f..3c0f417 100644 --- a/__IMAP_DELETE.h +++ b/__IMAP_DELETE.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_DELETE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_EXAMINE.cpp b/__IMAP_EXAMINE.cpp new file mode 100644 index 0000000..13cfcaf --- /dev/null +++ b/__IMAP_EXAMINE.cpp @@ -0,0 +1,10 @@ +#include "__IMAP_EXAMINE.h" +#include "__IMAP_SELECT.h" + +namespace mail { + + int __IMAP_EXAMINE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + return selectOrExamine(request, session, server, true); + } + +} diff --git a/__IMAP_EXAMINE.h b/__IMAP_EXAMINE.h index f7af1bc..2f8c44c 100644 --- a/__IMAP_EXAMINE.h +++ b/__IMAP_EXAMINE.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_EXAMINE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_EXPUNGE.cpp b/__IMAP_EXPUNGE.cpp new file mode 100644 index 0000000..b059c97 --- /dev/null +++ b/__IMAP_EXPUNGE.cpp @@ -0,0 +1,27 @@ +#include "__IMAP_EXPUNGE.h" +#include "IMAPServer.h" + +namespace mail { + + int __IMAP_EXPUNGE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state != IMAPSession::SELECTED) { + session.out << session.tag << " NO No mailbox selected" << CRLF; + return 1; + } + if (session.readOnly) { + session.out << session.tag << " NO Mailbox is read-only" << CRLF; + return 1; + } + + // MailFolder::expunge() already returns each sequence number + // pre-adjusted for the ones reported before it - see its own doc + // comment for why that adjustment has to happen before, not + // after, these get sent. + for (uint32_t seqNum : session.mailbox.expunge()) + session.out << "* " << (int)seqNum << " EXPUNGE" << CRLF; + + session.out << session.tag << " OK EXPUNGE completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_EXPUNGE.h b/__IMAP_EXPUNGE.h index ec49ce4..dbac78b 100644 --- a/__IMAP_EXPUNGE.h +++ b/__IMAP_EXPUNGE.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_EXPUNGE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_FETCH.cpp b/__IMAP_FETCH.cpp new file mode 100644 index 0000000..8593cc8 --- /dev/null +++ b/__IMAP_FETCH.cpp @@ -0,0 +1,302 @@ +#include "__IMAP_FETCH.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include "MessageHeaders.h" +#include +#include +#include +#include +#include + +namespace mail { + + namespace { + + // Reads one FETCH data-item token, which - unlike an ordinary IMAP + // atom - may carry a "[section]" and/or "" suffix + // with no space before them (BODY[HEADER.FIELDS (SUBJECT)]), and + // may itself contain embedded spaces INSIDE that bracketed + // section. IMAPArgs::nextArgument() can't handle this shape (it + // only recognizes a container when the very first character is + // one of "([{<" - "BODY[...]" starts with 'B'). + std::string nextFetchItem(coreutils::ZString &items) { + items.skipWhitespace(); + if (items.eod()) + return ""; + std::string token; + while (!items.eod() && items.getChar() != ' ' && items.getChar() != '[' && items.getChar() != '<') + token += items.nextChar(); + if (!items.eod() && items.getChar() == '[') { + coreutils::ZString section = items.getContainer(); + token += section.str(); + } + if (!items.eod() && items.getChar() == '<') { + token += items.nextChar(); + while (!items.eod() && items.getChar() != '>') + token += items.nextChar(); + if (!items.eod()) + token += items.nextChar(); + } + return token; + } + + std::string upper(const std::string &s) { + std::string result = s; + for (char &c : result) + c = std::toupper((unsigned char)c); + return result; + } + + std::string formatInternalDate(time_t when) { + struct tm tmValue; + gmtime_r(&when, &tmValue); + char buf[64]; + strftime(buf, sizeof(buf), "%d-%b-%Y %H:%M:%S +0000", &tmValue); + return buf; + } + + std::string joinFlags(const std::vector &flags) { + std::string out; + for (size_t i = 0; i < flags.size(); ++i) { + if (i > 0) + out += " "; + out += flags[i]; + } + return out; + } + + // Formats a section's content as an IMAP literal ({n}\r\n), + // always - simpler and always correct, vs. quoted-string escaping + // for content that can contain arbitrary bytes/CRLFs. + std::string asLiteral(const std::string &content) { + std::ostringstream out; + out << "{" << content.size() << "}\r\n" << content; + return out.str(); + } + + // Extracts the section name out of a token like + // "BODY[HEADER.FIELDS (SUBJECT TO)]<0.100>" -> "HEADER.FIELDS (SUBJECT TO)", + // and separately reports any / range. + struct FetchItem { + std::string name; // e.g. "FLAGS", "BODY.PEEK", "RFC822.SIZE" + std::string section; // interior of [...], if any + bool hasSection = false; + bool hasRange = false; + long rangeStart = 0; + long rangeLength = -1; // -1 == to end + }; + + FetchItem parseFetchItem(const std::string &token) { + FetchItem item; + size_t bracket = token.find('['); + size_t angle = token.find('<'); + size_t nameEnd = std::min(bracket, angle); + item.name = (nameEnd == std::string::npos) ? token : token.substr(0, nameEnd); + if (bracket != std::string::npos) { + size_t closeBracket = token.find(']', bracket); + if (closeBracket != std::string::npos) { + item.section = token.substr(bracket + 1, closeBracket - bracket - 1); + item.hasSection = true; + } + } + if (angle != std::string::npos) { + size_t closeAngle = token.find('>', angle); + if (closeAngle != std::string::npos) { + std::string rangeText = token.substr(angle + 1, closeAngle - angle - 1); + size_t dot = rangeText.find('.'); + item.hasRange = true; + item.rangeStart = std::strtol(rangeText.substr(0, dot).c_str(), nullptr, 10); + if (dot != std::string::npos) + item.rangeLength = std::strtol(rangeText.substr(dot + 1).c_str(), nullptr, 10); + } + } + return item; + } + + std::string applyRange(const std::string &content, const FetchItem &item) { + if (!item.hasRange) + return content; + if (item.rangeStart < 0 || (size_t)item.rangeStart >= content.size()) + return ""; + size_t length = item.rangeLength < 0 ? std::string::npos : (size_t)item.rangeLength; + return content.substr(item.rangeStart, length); + } + + std::string sectionContent(const std::string &fullMessage, const std::string §ionSpec) { + std::string headerBlock, bodyBlock; + splitMessage(fullMessage, headerBlock, bodyBlock); + std::string spec = upper(sectionSpec); + // Strip trailing whitespace introduced by our own tokenizing. + while (!spec.empty() && spec.back() == ' ') + spec.pop_back(); + + if (spec.empty()) + return fullMessage; + if (spec == "TEXT") + return bodyBlock; + if (spec == "HEADER") + return headerBlock + "\r\n\r\n"; + if (spec.rfind("HEADER.FIELDS", 0) == 0) { + size_t paren = sectionSpec.find('('); + size_t closeParen = sectionSpec.find(')', paren); + std::string fieldsText = (paren != std::string::npos && closeParen != std::string::npos) + ? sectionSpec.substr(paren + 1, closeParen - paren - 1) : ""; + std::istringstream fieldStream(fieldsText); + std::string fieldName; + auto headers = parseHeaderLines(headerBlock); + std::string result; + // Only fields actually present in the message are emitted - + // matching common server behavior for a requested field the + // message simply lacks. + while (fieldStream >> fieldName) { + for (auto &h : headers) { + if (h.first.size() != fieldName.size()) + continue; + bool match = true; + for (size_t i = 0; i < fieldName.size(); ++i) + if (std::toupper((unsigned char)h.first[i]) != std::toupper((unsigned char)fieldName[i])) { + match = false; + break; + } + if (match) + result += h.first + ": " + h.second + "\r\n"; + } + } + result += "\r\n"; + return result; + } + // Unrecognized section shape (MIME part numbers, HEADER.FIELDS.NOT, + // etc. - out of scope, see plan's deferred-items list) - fall + // back to the whole message rather than fail outright. + return fullMessage; + } + + } + + int fetchImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode) { + if (session.state != IMAPSession::SELECTED) { + session.out << session.tag << " NO No mailbox selected" << CRLF; + return 1; + } + + std::vector uids = IMAPArgs::parseSequenceSet(request, session.mailbox, uidMode); + + request.skipWhitespace(); + std::vector itemTokens; + if (!request.eod() && request.getChar() == '(') { + coreutils::ZString listArg = request.getContainer(); + std::string interior = IMAPArgs::unwrap(listArg); + coreutils::ZString interiorZ(interior); + while (true) { + std::string item = nextFetchItem(interiorZ); + if (item.empty()) + break; + itemTokens.push_back(item); + } + } else { + std::string item = nextFetchItem(request); + if (!item.empty()) + itemTokens.push_back(item); + } + + if (itemTokens.size() == 1) { + std::string macro = upper(itemTokens[0]); + if (macro == "ALL") + itemTokens = {"FLAGS", "INTERNALDATE", "RFC822.SIZE", "ENVELOPE"}; + else if (macro == "FAST") + itemTokens = {"FLAGS", "INTERNALDATE", "RFC822.SIZE"}; + else if (macro == "FULL") + itemTokens = {"FLAGS", "INTERNALDATE", "RFC822.SIZE", "ENVELOPE"}; + } + + for (uint32_t uid : uids) { + MailFolder::Message *message = session.mailbox.find(uid); + if (!message) + continue; + uint32_t seqNum = session.mailbox.seq(uid); + + bool needsContent = false; + for (const std::string &token : itemTokens) { + std::string name = upper(parseFetchItem(token).name); + if (name == "ENVELOPE" || name == "RFC822" || name == "RFC822.HEADER" || + name == "RFC822.TEXT" || name.rfind("BODY", 0) == 0) + needsContent = true; + } + std::string content = needsContent ? session.mailbox.readMessage(uid) : ""; + + bool markSeen = false; + std::ostringstream line; + bool first = true; + bool sawFlags = false; + auto emit = [&](const std::string &text) { + if (!first) + line << " "; + first = false; + line << text; + }; + + for (const std::string &token : itemTokens) { + FetchItem item = parseFetchItem(token); + std::string name = upper(item.name); + + if (name == "FLAGS") { + emit("FLAGS (" + joinFlags(message->flags) + ")"); + sawFlags = true; + } else if (name == "UID") { + emit("UID " + std::to_string(uid)); + } else if (name == "INTERNALDATE") { + emit("INTERNALDATE " + imapQuoteOrNil(formatInternalDate(message->internalDate))); + } else if (name == "RFC822.SIZE") { + emit("RFC822.SIZE " + std::to_string(message->size)); + } else if (name == "ENVELOPE") { + emit("ENVELOPE " + buildEnvelope(content)); + } else if (name == "RFC822") { + emit("RFC822 " + asLiteral(applyRange(content, item))); + if (!session.readOnly) markSeen = true; + } else if (name == "RFC822.HEADER") { + std::string headerBlock, bodyBlock; + splitMessage(content, headerBlock, bodyBlock); + emit("RFC822.HEADER " + asLiteral(applyRange(headerBlock + "\r\n\r\n", item))); + } else if (name == "RFC822.TEXT") { + std::string headerBlock, bodyBlock; + splitMessage(content, headerBlock, bodyBlock); + emit("RFC822.TEXT " + asLiteral(applyRange(bodyBlock, item))); + if (!session.readOnly) markSeen = true; + } else if (name == "BODY" || name == "BODY.PEEK") { + std::string sectionText = item.hasSection ? sectionContent(content, item.section) : content; + std::string responseKey = "BODY[" + item.section + "]"; + if (item.hasRange) + responseKey += "<" + std::to_string(item.rangeStart) + ">"; + emit(responseKey + " " + asLiteral(applyRange(sectionText, item))); + bool isPeek = (name == "BODY.PEEK"); + std::string upperSection = upper(item.section); + bool headerOnly = (upperSection == "HEADER" || upperSection.rfind("HEADER.FIELDS", 0) == 0); + if (!isPeek && !headerOnly && !session.readOnly) + markSeen = true; + } else { + // Unrecognized item (BODYSTRUCTURE, bare BODY macro, etc. - + // see the plan's explicitly-deferred list) - acknowledge + // with NIL rather than silently drop the field. + emit(upper(token) + " NIL"); + } + } + + if (markSeen && std::find(message->flags.begin(), message->flags.end(), "\\Seen") == message->flags.end()) { + message->flags.push_back("\\Seen"); + session.mailbox.setFlags(uid, message->flags); + if (!sawFlags) + emit("FLAGS (" + joinFlags(message->flags) + ")"); + } + + session.out << "* " << (int)seqNum << " FETCH (" << line.str() << ")" << CRLF; + } + + session.out << session.tag << " OK " << (uidMode ? "UID FETCH" : "FETCH") << " completed" << CRLF; + return 1; + } + + int __IMAP_FETCH::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + return fetchImpl(request, session, server, false); + } + +} diff --git a/__IMAP_FETCH.h b/__IMAP_FETCH.h index b97a49a..32a1cbb 100644 --- a/__IMAP_FETCH.h +++ b/__IMAP_FETCH.h @@ -4,16 +4,20 @@ # include "IMAPCommand.h" namespace mail { - + + // Shared by FETCH and "UID FETCH" (see __IMAP_UID.cpp) - uidMode + // controls both how the sequence-set argument is interpreted and + // whether UID is force-included in every response line (RFC 3501 + // 6.4.8: UID FETCH always returns UID whether or not it was asked + // for, so a client can tell which message is which). + int fetchImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode); + class __IMAP_FETCH : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_LIST.cpp b/__IMAP_LIST.cpp new file mode 100644 index 0000000..d6ca3b3 --- /dev/null +++ b/__IMAP_LIST.cpp @@ -0,0 +1,57 @@ +#include "__IMAP_LIST.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" + +namespace mail { + + bool imapListPatternMatch(const std::string &pattern, const std::string &name) { + size_t p = 0, n = 0, star = std::string::npos, matchPos = 0; + while (n < name.size()) { + if (p < pattern.size() && pattern[p] == name[n]) { + ++p; ++n; + } else if (p < pattern.size() && (pattern[p] == '*' || pattern[p] == '%')) { + star = p; + matchPos = n; + ++p; + } else if (star != std::string::npos) { + p = star + 1; + ++matchPos; + n = matchPos; + } else { + return false; + } + } + while (p < pattern.size() && (pattern[p] == '*' || pattern[p] == '%')) + ++p; + return p == pattern.size(); + } + + int __IMAP_LIST::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + IMAPArgs::nextArgument(request); // reference name - unused, no folder hierarchy to resolve against + coreutils::ZString patArg = IMAPArgs::nextArgument(request); + std::string pattern = IMAPArgs::unwrap(patArg); + + if (pattern.empty()) { + // RFC 3501 6.3.8: an empty mailbox name means "return the + // hierarchy delimiter and the root name only". + session.out << "* LIST (\\Noselect) \"/\" \"\"" << CRLF; + session.out << session.tag << " OK LIST completed" << CRLF; + return 1; + } + + coreutils::ZString userNameZ(session.userName); + for (coreutils::MString &folder : server.mailFileSystem.listFolders(userNameZ)) { + std::string name = folder.str(); + if (imapListPatternMatch(pattern, name)) + session.out << "* LIST (\\HasNoChildren) \"/\" \"" << name << "\"" << CRLF; + } + session.out << session.tag << " OK LIST completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_LIST.h b/__IMAP_LIST.h index 068b048..1e50b4b 100644 --- a/__IMAP_LIST.h +++ b/__IMAP_LIST.h @@ -2,18 +2,23 @@ # define ____IMAP_LIST_h__ # include "IMAPCommand.h" +# include namespace mail { - + + // Minimal glob match for LIST/LSUB patterns: '*' matches any run of + // characters, '%' does too (there's no folder hierarchy in this + // server, so the usual '%' "don't cross the hierarchy delimiter" + // distinction from '*' has nothing to differ over), anything else + // matches literally. + bool imapListPatternMatch(const std::string &pattern, const std::string &name); + class __IMAP_LIST : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_LOGIN.cpp b/__IMAP_LOGIN.cpp new file mode 100644 index 0000000..7436c19 --- /dev/null +++ b/__IMAP_LOGIN.cpp @@ -0,0 +1,33 @@ +#include "__IMAP_LOGIN.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" + +namespace mail { + + int __IMAP_LOGIN::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + coreutils::ZString userArg = IMAPArgs::nextArgument(request); + coreutils::ZString passArg = IMAPArgs::nextArgument(request); + if (userArg.getLength() == 0 || passArg.getLength() == 0) { + session.out << session.tag << " BAD LOGIN requires a userid and password" << CRLF; + return 1; + } + + std::string userName = IMAPArgs::unwrap(userArg); + std::string password = IMAPArgs::unwrap(passArg); + coreutils::ZString userNameZ(userName); + coreutils::ZString passwordZ(password); + + // checkPassword()/getMailBoxPath() (MailFileSystem) expect a full + // "user@domain" address, the same convention SMTP AUTH LOGIN + // already uses for session.userName. + if (server.mailFileSystem.checkPassword(userNameZ, passwordZ)) { + session.userName = userName; + session.state = IMAPSession::AUTHENTICATED; + session.out << session.tag << " OK LOGIN completed" << CRLF; + } else { + session.out << session.tag << " NO LOGIN failed" << CRLF; + } + return 1; + } + +} diff --git a/__IMAP_LOGIN.h b/__IMAP_LOGIN.h index 4663ec9..41daa5b 100644 --- a/__IMAP_LOGIN.h +++ b/__IMAP_LOGIN.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_LOGIN : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_LOGOUT.cpp b/__IMAP_LOGOUT.cpp new file mode 100644 index 0000000..d678c24 --- /dev/null +++ b/__IMAP_LOGOUT.cpp @@ -0,0 +1,20 @@ +#include "__IMAP_LOGOUT.h" +#include "IMAPServer.h" + +namespace mail { + + // RFC 3501 6.1.3: LOGOUT MUST be supported in every connection + // state, replies with an untagged BYE then a tagged OK, and closes + // the connection - previously not registered at all, so a client + // had no clean way to end a session. terminate() (inherited from + // TCPSession) defers the actual close until after this response has + // gone out - TCPSession::onLineReceived() calls send() before + // checking the term flag. + int __IMAP_LOGOUT::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + session.out << "* BYE IMAP4rev1 Server logging out" << CRLF; + session.out << session.tag << " OK LOGOUT completed" << CRLF; + session.terminate(); + return 1; + } + +} diff --git a/__IMAP_LOGOUT.h b/__IMAP_LOGOUT.h new file mode 100644 index 0000000..5f3eb12 --- /dev/null +++ b/__IMAP_LOGOUT.h @@ -0,0 +1,16 @@ +#ifndef ____IMAP_LOGOUT_h__ +# define ____IMAP_LOGOUT_h__ + +# include "IMAPCommand.h" + +namespace mail { + + class __IMAP_LOGOUT : public IMAPCommand { + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + + }; + +} + +#endif diff --git a/__IMAP_LSUB.cpp b/__IMAP_LSUB.cpp new file mode 100644 index 0000000..87814cb --- /dev/null +++ b/__IMAP_LSUB.cpp @@ -0,0 +1,40 @@ +#include "__IMAP_LSUB.h" +#include "__IMAP_LIST.h" +#include "__IMAP_SUBSCRIBE.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" + +namespace mail { + + int __IMAP_LSUB::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + IMAPArgs::nextArgument(request); // reference name - unused + coreutils::ZString patArg = IMAPArgs::nextArgument(request); + std::string pattern = IMAPArgs::unwrap(patArg); + + if (pattern.empty()) { + session.out << "* LSUB (\\Noselect) \"/\" \"\"" << CRLF; + session.out << session.tag << " OK LSUB completed" << CRLF; + return 1; + } + + coreutils::ZString userNameZ(session.userName); + std::string mailboxPath = server.mailFileSystem.getMailBoxPath(userNameZ).str(); + std::vector allFolders; + for (coreutils::MString &folder : server.mailFileSystem.listFolders(userNameZ)) + allFolders.push_back(folder.str()); + std::vector subscriptions = readSubscriptions(mailboxPath, allFolders); + + for (const std::string &name : subscriptions) + if (imapListPatternMatch(pattern, name)) + session.out << "* LSUB (\\HasNoChildren) \"/\" \"" << name << "\"" << CRLF; + + session.out << session.tag << " OK LSUB completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_LSUB.h b/__IMAP_LSUB.h index 25b915c..44996a9 100644 --- a/__IMAP_LSUB.h +++ b/__IMAP_LSUB.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_LSUB : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_NOOP.h b/__IMAP_NOOP.h new file mode 100644 index 0000000..5935af5 --- /dev/null +++ b/__IMAP_NOOP.h @@ -0,0 +1,26 @@ +#ifndef ____IMAP_NOOP_h__ +# define ____IMAP_NOOP_h__ + +# include "IMAPCommand.h" +# include "__IMAP_CHECK.h" + +namespace mail { + + class __IMAP_NOOP : public IMAPCommand { + + // RFC 3501 6.1.2: NOOP MUST be supported in every connection + // state. Beyond replying OK, it's also the standard way an idle + // client polls for new mail without a full re-SELECT - shares + // refreshSelectedMailbox() with CHECK (see __IMAP_CHECK.h) so both + // report fresh EXISTS/RECENT when the mailbox has changed. + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override { + refreshSelectedMailbox(session); + session.out << session.tag << " OK NOOP completed" << CRLF; + return 1; + } + + }; + +} + +#endif diff --git a/__IMAP_RENAME.cpp b/__IMAP_RENAME.cpp new file mode 100644 index 0000000..98facf5 --- /dev/null +++ b/__IMAP_RENAME.cpp @@ -0,0 +1,52 @@ +#include "__IMAP_RENAME.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include + +namespace mail { + + int __IMAP_RENAME::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString fromArg = IMAPArgs::nextArgument(request); + coreutils::ZString toArg = IMAPArgs::nextArgument(request); + std::string fromName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(fromArg)); + std::string toName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(toArg)); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(fromName) || !IMAPArgs::isValidFolderName(toName)) { + session.out << session.tag << " BAD Invalid mailbox name" << CRLF; + return 1; + } + if (fromName == "Inbox") { + session.out << session.tag << " NO INBOX cannot be renamed" << CRLF; + return 1; + } + if (!server.mailFileSystem.ifFolderExists(userNameZ, fromName)) { + session.out << session.tag << " NO Mailbox does not exist" << CRLF; + return 1; + } + if (server.mailFileSystem.ifFolderExists(userNameZ, toName)) { + session.out << session.tag << " NO Destination mailbox already exists" << CRLF; + return 1; + } + + std::error_code errorCode; + std::filesystem::rename(server.mailFileSystem.getFolderPath(userNameZ, fromName).str(), + server.mailFileSystem.getFolderPath(userNameZ, toName).str(), errorCode); + if (errorCode) { + session.out << session.tag << " NO RENAME failed" << CRLF; + return 1; + } + + if (session.state == IMAPSession::SELECTED && session.selectedFolderName == fromName) + session.selectedFolderName = toName; + + session.out << session.tag << " OK RENAME completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_RENAME.h b/__IMAP_RENAME.h index a391c1f..6c5e22a 100644 --- a/__IMAP_RENAME.h +++ b/__IMAP_RENAME.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_RENAME : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_SEARCH.cpp b/__IMAP_SEARCH.cpp new file mode 100644 index 0000000..9c2ff27 --- /dev/null +++ b/__IMAP_SEARCH.cpp @@ -0,0 +1,214 @@ +#include "__IMAP_SEARCH.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include "MessageHeaders.h" +#include +#include +#include +#include +#include +#include + +namespace mail { + + namespace { + + using Predicate = std::function; + + bool hasFlag(const MailFolder::Message &m, const char *flag) { + return std::find(m.flags.begin(), m.flags.end(), flag) != m.flags.end(); + } + + std::string upperOf(const std::string &s) { + std::string r = s; + for (char &c : r) + c = std::toupper((unsigned char)c); + return r; + } + + bool containsCI(const std::string &haystack, const std::string &needle) { + if (needle.empty()) + return true; + return upperOf(haystack).find(upperOf(needle)) != std::string::npos; + } + + time_t midnightUTC(time_t t) { + struct tm tmValue; + gmtime_r(&t, &tmValue); + tmValue.tm_hour = 0; + tmValue.tm_min = 0; + tmValue.tm_sec = 0; + return timegm(&tmValue); + } + + Predicate headerContains(const std::string &headerName, const std::string &needle) { + return [headerName, needle](MailFolder &folder, const MailFolder::Message &m) { + std::string content = folder.readMessage(m.uid); + std::string headerBlock, bodyBlock; + splitMessage(content, headerBlock, bodyBlock); + auto headers = parseHeaderLines(headerBlock); + return containsCI(getHeader(headers, headerName), needle); + }; + } + + Predicate parseOneKey(coreutils::ZString &request, MailFolder &folder) { + coreutils::ZString keyArg = IMAPArgs::nextArgument(request); + std::string keyText = keyArg.str(); + if (keyText.empty()) + return [](MailFolder &, const MailFolder::Message &) { return true; }; + + if (std::isdigit((unsigned char)keyText[0]) || keyText[0] == '*') { + coreutils::ZString setOnly(keyText); + auto uids = std::make_shared>(IMAPArgs::parseSequenceSet(setOnly, folder, false)); + return [uids](MailFolder &, const MailFolder::Message &m) { + return std::find(uids->begin(), uids->end(), m.uid) != uids->end(); + }; + } + + std::string key = upperOf(keyText); + + if (key == "ALL") + return [](MailFolder &, const MailFolder::Message &) { return true; }; + if (key == "ANSWERED") + return [](MailFolder &, const MailFolder::Message &m) { return hasFlag(m, "\\Answered"); }; + if (key == "UNANSWERED") + return [](MailFolder &, const MailFolder::Message &m) { return !hasFlag(m, "\\Answered"); }; + if (key == "DELETED") + return [](MailFolder &, const MailFolder::Message &m) { return hasFlag(m, "\\Deleted"); }; + if (key == "UNDELETED") + return [](MailFolder &, const MailFolder::Message &m) { return !hasFlag(m, "\\Deleted"); }; + if (key == "SEEN") + return [](MailFolder &, const MailFolder::Message &m) { return hasFlag(m, "\\Seen"); }; + if (key == "UNSEEN") + return [](MailFolder &, const MailFolder::Message &m) { return !hasFlag(m, "\\Seen"); }; + if (key == "FLAGGED") + return [](MailFolder &, const MailFolder::Message &m) { return hasFlag(m, "\\Flagged"); }; + if (key == "UNFLAGGED") + return [](MailFolder &, const MailFolder::Message &m) { return !hasFlag(m, "\\Flagged"); }; + if (key == "DRAFT") + return [](MailFolder &, const MailFolder::Message &m) { return hasFlag(m, "\\Draft"); }; + if (key == "UNDRAFT") + return [](MailFolder &, const MailFolder::Message &m) { return !hasFlag(m, "\\Draft"); }; + if (key == "NEW") + return [](MailFolder &, const MailFolder::Message &m) { return m.recent && !hasFlag(m, "\\Seen"); }; + if (key == "OLD") + return [](MailFolder &, const MailFolder::Message &m) { return !m.recent; }; + if (key == "RECENT") + return [](MailFolder &, const MailFolder::Message &m) { return m.recent; }; + + if (key == "UID") { + coreutils::ZString setArg = IMAPArgs::nextArgument(request); + std::string setText = setArg.str(); + coreutils::ZString setOnly(setText); + auto uids = std::make_shared>(IMAPArgs::parseSequenceSet(setOnly, folder, true)); + return [uids](MailFolder &, const MailFolder::Message &m) { + return std::find(uids->begin(), uids->end(), m.uid) != uids->end(); + }; + } + + if (key == "FROM" || key == "TO" || key == "CC" || key == "BCC" || key == "SUBJECT") { + coreutils::ZString valueArg = IMAPArgs::nextArgument(request); + std::string needle = IMAPArgs::unwrap(valueArg); + std::string headerName = key == "FROM" ? "From" : key == "TO" ? "To" : + key == "CC" ? "Cc" : key == "BCC" ? "Bcc" : "Subject"; + return headerContains(headerName, needle); + } + + if (key == "BODY") { + coreutils::ZString valueArg = IMAPArgs::nextArgument(request); + std::string needle = IMAPArgs::unwrap(valueArg); + return [needle](MailFolder &folder, const MailFolder::Message &m) { + std::string content = folder.readMessage(m.uid); + std::string headerBlock, bodyBlock; + splitMessage(content, headerBlock, bodyBlock); + return containsCI(bodyBlock, needle); + }; + } + + if (key == "TEXT") { + coreutils::ZString valueArg = IMAPArgs::nextArgument(request); + std::string needle = IMAPArgs::unwrap(valueArg); + return [needle](MailFolder &folder, const MailFolder::Message &m) { + return containsCI(folder.readMessage(m.uid), needle); + }; + } + + if (key == "BEFORE" || key == "ON" || key == "SINCE") { + coreutils::ZString dateArg = IMAPArgs::nextArgument(request); + time_t target = IMAPArgs::parseDate(dateArg); + if (target == (time_t)-1) + return [](MailFolder &, const MailFolder::Message &) { return true; }; + target = midnightUTC(target); + if (key == "BEFORE") + return [target](MailFolder &, const MailFolder::Message &m) { return midnightUTC(m.internalDate) < target; }; + if (key == "SINCE") + return [target](MailFolder &, const MailFolder::Message &m) { return midnightUTC(m.internalDate) >= target; }; + return [target](MailFolder &, const MailFolder::Message &m) { return midnightUTC(m.internalDate) == target; }; + } + + if (key == "LARGER" || key == "SMALLER") { + coreutils::ZString numArg = IMAPArgs::nextArgument(request); + long value = std::strtol(numArg.str().c_str(), nullptr, 10); + if (key == "LARGER") + return [value](MailFolder &, const MailFolder::Message &m) { return (long)m.size > value; }; + return [value](MailFolder &, const MailFolder::Message &m) { return (long)m.size < value; }; + } + + if (key == "NOT") { + Predicate inner = parseOneKey(request, folder); + return [inner](MailFolder &folder, const MailFolder::Message &m) { return !inner(folder, m); }; + } + + if (key == "OR") { + Predicate left = parseOneKey(request, folder); + Predicate right = parseOneKey(request, folder); + return [left, right](MailFolder &folder, const MailFolder::Message &m) { + return left(folder, m) || right(folder, m); + }; + } + + // Unrecognized keyword (CHARSET, HEADER, KEYWORD, SENT*, + // SEQUENCE numbers already handled above - see the plan's + // deferred-items list) - treated as always-true rather than + // aborting the whole SEARCH. + return [](MailFolder &, const MailFolder::Message &) { return true; }; + } + + } + + int searchImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode) { + if (session.state != IMAPSession::SELECTED) { + session.out << session.tag << " NO No mailbox selected" << CRLF; + return 1; + } + + std::vector predicates; + while (true) { + request.skipWhitespace(); + if (request.eod()) + break; + predicates.push_back(parseOneKey(request, session.mailbox)); + } + + session.out << "* SEARCH"; + for (const MailFolder::Message &message : session.mailbox.messages()) { + bool matched = true; + for (const Predicate &predicate : predicates) { + if (!predicate(session.mailbox, message)) { + matched = false; + break; + } + } + if (matched) + session.out << " " << (int)(uidMode ? message.uid : session.mailbox.seq(message.uid)); + } + session.out << CRLF; + session.out << session.tag << " OK " << (uidMode ? "UID SEARCH" : "SEARCH") << " completed" << CRLF; + return 1; + } + + int __IMAP_SEARCH::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + return searchImpl(request, session, server, false); + } + +} diff --git a/__IMAP_SEARCH.h b/__IMAP_SEARCH.h index 9ef8373..1dc2ca8 100644 --- a/__IMAP_SEARCH.h +++ b/__IMAP_SEARCH.h @@ -4,16 +4,15 @@ # include "IMAPCommand.h" namespace mail { - + + int searchImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode); + class __IMAP_SEARCH : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_SELECT.cpp b/__IMAP_SELECT.cpp new file mode 100644 index 0000000..7c77eb6 --- /dev/null +++ b/__IMAP_SELECT.cpp @@ -0,0 +1,52 @@ +#include "__IMAP_SELECT.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" + +namespace mail { + + int selectOrExamine(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool readOnly) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString nameArg = IMAPArgs::nextArgument(request); + std::string folderName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(nameArg)); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(folderName)) { + session.out << session.tag << " NO Mailbox does not exist" << CRLF; + return 1; + } + if (!server.mailFileSystem.ifFolderExists(userNameZ, folderName)) { + session.out << session.tag << " NO Mailbox does not exist" << CRLF; + return 1; + } + + MailFolder folder; + folder.load(server.mailFileSystem.getFolderPath(userNameZ, folderName).str()); + + session.mailbox = folder; + session.selectedFolderName = folderName; + session.readOnly = readOnly; + session.state = IMAPSession::SELECTED; + + session.out << "* " << (int)session.mailbox.size() << " EXISTS" << CRLF; + session.out << "* " << (int)session.mailbox.recentCount() << " RECENT" << CRLF; + uint32_t firstUnseen = session.mailbox.firstUnseenSeq(); + if (firstUnseen != 0) + session.out << "* OK [UNSEEN " << (int)firstUnseen << "] Message " << (int)firstUnseen << " is first unseen" << CRLF; + session.out << "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)" << CRLF; + session.out << "* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)] Limited" << CRLF; + session.out << "* OK [UIDVALIDITY " << (int)session.mailbox.uidValidity() << "] UIDs valid" << CRLF; + session.out << "* OK [UIDNEXT " << (int)session.mailbox.uidNext() << "] Predicted next UID" << CRLF; + session.out << session.tag << " OK [" << (readOnly ? "READ-ONLY" : "READ-WRITE") << "] " + << (readOnly ? "EXAMINE" : "SELECT") << " completed" << CRLF; + return 1; + } + + int __IMAP_SELECT::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + return selectOrExamine(request, session, server, false); + } + +} diff --git a/__IMAP_SELECT.h b/__IMAP_SELECT.h index 6e294f4..aad9eff 100644 --- a/__IMAP_SELECT.h +++ b/__IMAP_SELECT.h @@ -4,16 +4,20 @@ # include "IMAPCommand.h" namespace mail { - + + // Shared by SELECT and EXAMINE (RFC 3501 6.3.1/6.3.2 - identical + // except EXAMINE opens read-only and doesn't leave the mailbox + // vulnerable to STORE/EXPUNGE). Also used directly by __IMAP_UID and + // __IMAP_STATUS-adjacent code that needs a freshly loaded MailFolder + // without going through the full command dispatch. + int selectOrExamine(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool readOnly); + class __IMAP_SELECT : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_STARTTLS.h b/__IMAP_STARTTLS.h index f133316..c61cd60 100644 --- a/__IMAP_STARTTLS.h +++ b/__IMAP_STARTTLS.h @@ -7,8 +7,11 @@ namespace mail { class __IMAP_STARTTLS : public IMAPCommand { - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override { + // Still a placeholder: this command dispatches and replies with a + // syntactically valid IMAP tagged response now, but does not yet do + // any real mailbox work. + session.out << session.tag << " OK Completed" << CRLF; return 1; } diff --git a/__IMAP_STATUS.cpp b/__IMAP_STATUS.cpp new file mode 100644 index 0000000..a25c0d0 --- /dev/null +++ b/__IMAP_STATUS.cpp @@ -0,0 +1,59 @@ +#include "__IMAP_STATUS.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include "MailFolder.h" +#include + +namespace mail { + + int __IMAP_STATUS::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString nameArg = IMAPArgs::nextArgument(request); + coreutils::ZString itemsArg = IMAPArgs::nextArgument(request); + std::string folderName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(nameArg)); + std::string itemsText = IMAPArgs::unwrap(itemsArg); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(folderName) || !server.mailFileSystem.ifFolderExists(userNameZ, folderName)) { + session.out << session.tag << " NO Mailbox does not exist" << CRLF; + return 1; + } + + MailFolder folder; + folder.load(server.mailFileSystem.getFolderPath(userNameZ, folderName).str()); + + std::istringstream itemStream(itemsText); + std::string item; + std::ostringstream reply; + bool first = true; + while (itemStream >> item) { + if (!first) + reply << " "; + first = false; + std::string upper = item; + for (char &c : upper) + c = std::toupper((unsigned char)c); + if (upper == "MESSAGES") + reply << "MESSAGES " << (int)folder.size(); + else if (upper == "RECENT") + reply << "RECENT " << (int)folder.recentCount(); + else if (upper == "UIDNEXT") + reply << "UIDNEXT " << (int)folder.uidNext(); + else if (upper == "UIDVALIDITY") + reply << "UIDVALIDITY " << (int)folder.uidValidity(); + else if (upper == "UNSEEN") + reply << "UNSEEN " << (int)folder.unseenCount(); + else + first = true; // unrecognized item - skip without leaving a dangling separator + } + + session.out << "* STATUS \"" << folderName << "\" (" << reply.str() << ")" << CRLF; + session.out << session.tag << " OK STATUS completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_STATUS.h b/__IMAP_STATUS.h index 17f1d14..0f6f42a 100644 --- a/__IMAP_STATUS.h +++ b/__IMAP_STATUS.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_STATUS : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_STORE.cpp b/__IMAP_STORE.cpp new file mode 100644 index 0000000..73b12a3 --- /dev/null +++ b/__IMAP_STORE.cpp @@ -0,0 +1,80 @@ +#include "__IMAP_STORE.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include +#include + +namespace mail { + + namespace { + std::string joinFlags(const std::vector &flags) { + std::string out; + for (size_t i = 0; i < flags.size(); ++i) { + if (i > 0) + out += " "; + out += flags[i]; + } + return out; + } + } + + int storeImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode) { + if (session.state != IMAPSession::SELECTED) { + session.out << session.tag << " NO No mailbox selected" << CRLF; + return 1; + } + if (session.readOnly) { + session.out << session.tag << " NO Mailbox is read-only" << CRLF; + return 1; + } + + std::vector uids = IMAPArgs::parseSequenceSet(request, session.mailbox, uidMode); + + coreutils::ZString modeArg = IMAPArgs::nextArgument(request); + std::string mode = modeArg.str(); + bool silent = false; + // Strip a trailing ".SILENT" (case-insensitive) before inspecting + // the leading +/- sign. + if (mode.size() >= 7) { + std::string tail = mode.substr(mode.size() - 7); + for (char &c : tail) + c = std::toupper((unsigned char)c); + if (tail == ".SILENT") { + silent = true; + mode = mode.substr(0, mode.size() - 7); + } + } + + char sign = mode.empty() ? '=' : mode[0]; + std::vector flags = IMAPArgs::parseFlagList(request); + + for (uint32_t uid : uids) { + MailFolder::Message *message = session.mailbox.find(uid); + if (!message) + continue; + + if (sign == '+') + session.mailbox.addFlags(uid, flags); + else if (sign == '-') + session.mailbox.removeFlags(uid, flags); + else + session.mailbox.setFlags(uid, flags); + + if (!silent) { + uint32_t seqNum = session.mailbox.seq(uid); + session.out << "* " << (int)seqNum << " FETCH (FLAGS (" << joinFlags(message->flags) << ")"; + if (uidMode) + session.out << " UID " << (int)uid; + session.out << ")" << CRLF; + } + } + + session.out << session.tag << " OK " << (uidMode ? "UID STORE" : "STORE") << " completed" << CRLF; + return 1; + } + + int __IMAP_STORE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + return storeImpl(request, session, server, false); + } + +} diff --git a/__IMAP_STORE.h b/__IMAP_STORE.h index ba2f5f3..ee741f3 100644 --- a/__IMAP_STORE.h +++ b/__IMAP_STORE.h @@ -4,16 +4,15 @@ # include "IMAPCommand.h" namespace mail { - + + int storeImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode); + class __IMAP_STORE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_SUBSCRIBE.cpp b/__IMAP_SUBSCRIBE.cpp new file mode 100644 index 0000000..746bc23 --- /dev/null +++ b/__IMAP_SUBSCRIBE.cpp @@ -0,0 +1,56 @@ +#include "__IMAP_SUBSCRIBE.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include +#include + +namespace mail { + + std::vector readSubscriptions(const std::string &mailboxPath, const std::vector &allFolders) { + std::ifstream in(mailboxPath + "/.subscriptions"); + if (!in) + return allFolders; + std::vector result; + std::string line; + while (std::getline(in, line)) + if (!line.empty()) + result.push_back(line); + return result; + } + + void writeSubscriptions(const std::string &mailboxPath, const std::vector &subscriptions) { + std::ofstream out(mailboxPath + "/.subscriptions", std::ios::trunc); + for (const std::string &name : subscriptions) + out << name << "\n"; + } + + int __IMAP_SUBSCRIBE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString nameArg = IMAPArgs::nextArgument(request); + std::string folderName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(nameArg)); + coreutils::ZString userNameZ(session.userName); + + if (!IMAPArgs::isValidFolderName(folderName) || !server.mailFileSystem.ifFolderExists(userNameZ, folderName)) { + session.out << session.tag << " NO Mailbox does not exist" << CRLF; + return 1; + } + + std::string mailboxPath = server.mailFileSystem.getMailBoxPath(userNameZ).str(); + std::vector allFolders; + for (coreutils::MString &folder : server.mailFileSystem.listFolders(userNameZ)) + allFolders.push_back(folder.str()); + std::vector subscriptions = readSubscriptions(mailboxPath, allFolders); + + if (std::find(subscriptions.begin(), subscriptions.end(), folderName) == subscriptions.end()) + subscriptions.push_back(folderName); + writeSubscriptions(mailboxPath, subscriptions); + + session.out << session.tag << " OK SUBSCRIBE completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_SUBSCRIBE.h b/__IMAP_SUBSCRIBE.h index 3ead3bf..f705572 100644 --- a/__IMAP_SUBSCRIBE.h +++ b/__IMAP_SUBSCRIBE.h @@ -2,18 +2,26 @@ # define ____IMAP_SUBSCRIBE_h__ # include "IMAPCommand.h" +# include +# include namespace mail { - + + // Backed by a plain one-name-per-line ".subscriptions" file at the + // mailbox root, alongside Inbox/Spam/Trash and .password. If that + // file doesn't exist yet (nothing has ever called SUBSCRIBE/ + // UNSUBSCRIBE on this mailbox), every existing folder is treated as + // subscribed by default - matches how a freshly provisioned mailbox + // should behave for a client that only ever calls LSUB. + std::vector readSubscriptions(const std::string &mailboxPath, const std::vector &allFolders); + void writeSubscriptions(const std::string &mailboxPath, const std::vector &subscriptions); + class __IMAP_SUBSCRIBE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_UID.cpp b/__IMAP_UID.cpp new file mode 100644 index 0000000..3464a74 --- /dev/null +++ b/__IMAP_UID.cpp @@ -0,0 +1,31 @@ +#include "__IMAP_UID.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include "__IMAP_FETCH.h" +#include "__IMAP_STORE.h" +#include "__IMAP_SEARCH.h" +#include "__IMAP_COPY.h" +#include + +namespace mail { + + int __IMAP_UID::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + coreutils::ZString verbArg = IMAPArgs::nextArgument(request); + std::string verb = verbArg.str(); + for (char &c : verb) + c = std::toupper((unsigned char)c); + + if (verb == "FETCH") + return fetchImpl(request, session, server, true); + if (verb == "STORE") + return storeImpl(request, session, server, true); + if (verb == "SEARCH") + return searchImpl(request, session, server, true); + if (verb == "COPY") + return copyImpl(request, session, server, true); + + session.out << session.tag << " BAD Unsupported UID subcommand" << CRLF; + return 1; + } + +} diff --git a/__IMAP_UID.h b/__IMAP_UID.h index af56c29..ce65884 100644 --- a/__IMAP_UID.h +++ b/__IMAP_UID.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_UID : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__IMAP_UNSUBSCRIBE.cpp b/__IMAP_UNSUBSCRIBE.cpp new file mode 100644 index 0000000..38108a2 --- /dev/null +++ b/__IMAP_UNSUBSCRIBE.cpp @@ -0,0 +1,32 @@ +#include "__IMAP_UNSUBSCRIBE.h" +#include "__IMAP_SUBSCRIBE.h" +#include "IMAPArgs.h" +#include "IMAPServer.h" +#include + +namespace mail { + + int __IMAP_UNSUBSCRIBE::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) { + if (session.state == IMAPSession::NOT_AUTHENTICATED) { + session.out << session.tag << " NO Not authenticated" << CRLF; + return 1; + } + + coreutils::ZString nameArg = IMAPArgs::nextArgument(request); + std::string folderName = IMAPArgs::normalizeFolderName(IMAPArgs::unwrap(nameArg)); + coreutils::ZString userNameZ(session.userName); + + std::string mailboxPath = server.mailFileSystem.getMailBoxPath(userNameZ).str(); + std::vector allFolders; + for (coreutils::MString &folder : server.mailFileSystem.listFolders(userNameZ)) + allFolders.push_back(folder.str()); + std::vector subscriptions = readSubscriptions(mailboxPath, allFolders); + + subscriptions.erase(std::remove(subscriptions.begin(), subscriptions.end(), folderName), subscriptions.end()); + writeSubscriptions(mailboxPath, subscriptions); + + session.out << session.tag << " OK UNSUBSCRIBE completed" << CRLF; + return 1; + } + +} diff --git a/__IMAP_UNSUBSCRIBE.h b/__IMAP_UNSUBSCRIBE.h index af42880..ae5aaa3 100644 --- a/__IMAP_UNSUBSCRIBE.h +++ b/__IMAP_UNSUBSCRIBE.h @@ -4,16 +4,13 @@ # include "IMAPCommand.h" namespace mail { - + class __IMAP_UNSUBSCRIBE : public IMAPCommand { - - int processCommand(coreutils::ZString request, IMAPSession &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) override; + }; - + } #endif diff --git a/__POP3_CAPA.h b/__POP3_CAPA.h new file mode 100644 index 0000000..df5e5ce --- /dev/null +++ b/__POP3_CAPA.h @@ -0,0 +1,32 @@ +#ifndef ____POP3_CAPA_h__ +# define ____POP3_CAPA_h__ + +# include "POP3Command.h" + +namespace mail { + + // RFC 2449: CAPA is the standard way a POP3 client discovers what a + // server actually supports before trying it - this server had no way + // to advertise anything at all before. USER (the only login + // mechanism __POP3_USER.h/__POP3_PASS.h implement), PIPELINING + // (already true of every protocol built on this framework - + // TCPSocket::onDataReceived() loops over every complete line already + // buffered, not just the first one read), and now UIDL (real as of + // __POP3_UIDL.cpp). Still nothing claiming APOP, STLS, or SASL, + // since none of those have a real handler behind them. + class __POP3_CAPA : public POP3Command { + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override { + session.out << "+OK Capability list follows" << CRLF; + session.out << "USER" << CRLF; + session.out << "UIDL" << CRLF; + session.out << "PIPELINING" << CRLF; + session.out << "." << CRLF; + return 1; + } + + }; + +} + +#endif diff --git a/__POP3_DELE.cpp b/__POP3_DELE.cpp new file mode 100644 index 0000000..dc77ed1 --- /dev/null +++ b/__POP3_DELE.cpp @@ -0,0 +1,25 @@ +#include "__POP3_DELE.h" +#include "POP3Server.h" + +namespace mail { + + // RFC 1939 3: DELE only marks a message deleted for this session - + // the mark takes effect (actual unlink) only at QUIT, and RSET below + // can undo it beforehand. + int __POP3_DELE::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + if (!session.authenticated) { + session.out << "-ERR Not authenticated" << CRLF; + return 1; + } + request.skipWhitespace(); + int msgNum = request.getTokenExclude(" ").asInteger(); + if (!pop3FindMessage(session, msgNum)) { + session.out << "-ERR No such message" << CRLF; + return 1; + } + session.deletedMarks.insert(msgNum); + session.out << "+OK Message " << msgNum << " deleted" << CRLF; + return 1; + } + +} diff --git a/__POP3_DELE.h b/__POP3_DELE.h index 259f747..e766a17 100644 --- a/__POP3_DELE.h +++ b/__POP3_DELE.h @@ -4,16 +4,13 @@ # include "POP3Command.h" namespace mail { - + class __POP3_DELE : public POP3Command { - - int processCommand(coreutils::ZString &request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + }; - + } #endif diff --git a/__POP3_LAST.h b/__POP3_LAST.h index 4fae742..efbef78 100644 --- a/__POP3_LAST.h +++ b/__POP3_LAST.h @@ -7,8 +7,12 @@ namespace mail { class __POP3_LAST : public POP3Command { - int processCommand(coreutils::ZString &request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override { + // Still a placeholder: this command dispatches and replies with a + // syntactically valid POP3 response now ("+OK", not the SMTP-style + // "250 OK" this used to send), but does not yet do any real + // mailbox work. + session.out << "+OK" << CRLF; return 1; } diff --git a/__POP3_LIST.cpp b/__POP3_LIST.cpp new file mode 100644 index 0000000..4a733db --- /dev/null +++ b/__POP3_LIST.cpp @@ -0,0 +1,43 @@ +#include "__POP3_LIST.h" +#include "POP3Server.h" + +namespace mail { + + int __POP3_LIST::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + if (!session.authenticated) { + session.out << "-ERR Not authenticated" << CRLF; + return 1; + } + + request.skipWhitespace(); + coreutils::ZString numArg = request.getTokenExclude(" "); + + if (numArg.getLength() > 0) { + int msgNum = numArg.asInteger(); + MailFolder::Message *message = pop3FindMessage(session, msgNum); + if (!message) { + session.out << "-ERR No such message" << CRLF; + return 1; + } + session.out << "+OK " << msgNum << " " << (int)message->size << CRLF; + return 1; + } + + const auto &messages = session.mailbox.messages(); + int count = 0; + long totalSize = 0; + for (size_t i = 0; i < messages.size(); ++i) { + if (session.deletedMarks.count((int)(i + 1)) == 0) { + ++count; + totalSize += (long)messages[i].size; + } + } + session.out << "+OK " << count << " messages (" << totalSize << " octets)" << CRLF; + for (size_t i = 0; i < messages.size(); ++i) + if (session.deletedMarks.count((int)(i + 1)) == 0) + session.out << (i + 1) << " " << (int)messages[i].size << CRLF; + session.out << "." << CRLF; + return 1; + } + +} diff --git a/__POP3_LIST.h b/__POP3_LIST.h index 9601628..45f7a94 100644 --- a/__POP3_LIST.h +++ b/__POP3_LIST.h @@ -4,16 +4,13 @@ # include "POP3Command.h" namespace mail { - + class __POP3_LIST : public POP3Command { - - int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + }; - + } #endif diff --git a/__POP3_NOOP.h b/__POP3_NOOP.h index d451c71..c040a4c 100644 --- a/__POP3_NOOP.h +++ b/__POP3_NOOP.h @@ -8,7 +8,11 @@ namespace mail { class __POP3_NOOP : public POP3Command { int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; + // Still a placeholder: this command dispatches and replies with a + // syntactically valid POP3 response now ("+OK", not the SMTP-style + // "250 OK" this used to send), but does not yet do any real + // mailbox work. + session.out << "+OK" << CRLF; return 1; } diff --git a/__POP3_PASS.cpp b/__POP3_PASS.cpp new file mode 100644 index 0000000..a0c88c3 --- /dev/null +++ b/__POP3_PASS.cpp @@ -0,0 +1,41 @@ +#include "__POP3_PASS.h" +#include "POP3Server.h" + +namespace mail { + + int __POP3_PASS::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + request.skipWhitespace(); + // Deliberately the rest of the line, not just the first + // whitespace-delimited token - RFC 1939 4's PASS may + // contain any character except CR/LF, spaces included. (A known, + // documented gap of this same shape exists nowhere else in this + // codebase for POP3, since every other POP3 argument is a bare + // number or single atom that's never expected to contain a + // space.) + coreutils::ZString passArg = request.unparsed(); + + if (session.userName.empty()) { + session.out << "-ERR USER required first" << CRLF; + return 1; + } + if (passArg.getLength() == 0) { + session.out << "-ERR Usage: PASS " << CRLF; + return 1; + } + + coreutils::ZString userNameZ(session.userName); + if (!server.mailFileSystem.checkPassword(userNameZ, passArg)) { + session.out << "-ERR Authentication failed" << CRLF; + session.userName.clear(); + return 1; + } + + coreutils::ZString userNameZ2(session.userName); + std::string mailboxPath = server.mailFileSystem.getFolderPath(userNameZ2, "Inbox").str(); + session.mailbox.load(mailboxPath); + session.authenticated = true; + session.out << "+OK Logged in" << CRLF; + return 1; + } + +} diff --git a/__POP3_PASS.h b/__POP3_PASS.h index a284031..2fdb944 100644 --- a/__POP3_PASS.h +++ b/__POP3_PASS.h @@ -4,16 +4,13 @@ # include "POP3Command.h" namespace mail { - + class __POP3_PASS : public POP3Command { - - int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + }; - + } #endif diff --git a/__POP3_QUIT.h b/__POP3_QUIT.h index ccec6f6..92843e1 100644 --- a/__POP3_QUIT.h +++ b/__POP3_QUIT.h @@ -4,16 +4,38 @@ # include "POP3Command.h" namespace mail { - + class __POP3_QUIT : public POP3Command { - - int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; + + // RFC 1939 6: the UPDATE state - QUIT is what actually commits + // every DELE mark made this session (unlinking those messages), + // not DELE itself. Only happens if the session ever authenticated + // (a QUIT sent before USER/PASS in the AUTHORIZATION state has no + // maildrop to touch). + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override { + if (session.authenticated) { + // Resolve every marked message number to its UID FIRST, all + // at once, before deleting any of them - deleteMessage() + // mutates the mailbox's message list, which would shift + // every LATER message number's meaning if resolved one at a + // time interleaved with deletion. UIDs, unlike message + // numbers, are stable across that mutation. + std::vector uidsToDelete; + for (int msgNum : session.deletedMarks) { + uint32_t uid = session.mailbox.uidForSeq(msgNum); + if (uid != 0) + uidsToDelete.push_back(uid); + } + for (uint32_t uid : uidsToDelete) + session.mailbox.deleteMessage(uid); + } + session.out << "+OK BARANTMail POP3 server signing off" << CRLF; + session.terminate(); return 1; } - + }; - + } #endif diff --git a/__POP3_RETR.cpp b/__POP3_RETR.cpp new file mode 100644 index 0000000..00aa8d1 --- /dev/null +++ b/__POP3_RETR.cpp @@ -0,0 +1,23 @@ +#include "__POP3_RETR.h" +#include "POP3Server.h" + +namespace mail { + + int __POP3_RETR::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + if (!session.authenticated) { + session.out << "-ERR Not authenticated" << CRLF; + return 1; + } + request.skipWhitespace(); + int msgNum = request.getTokenExclude(" ").asInteger(); + MailFolder::Message *message = pop3FindMessage(session, msgNum); + if (!message) { + session.out << "-ERR No such message" << CRLF; + return 1; + } + session.out << "+OK " << (int)message->size << " octets" << CRLF; + pop3SendMessage(session, session.mailbox.readMessage(message->uid), -1); + return 1; + } + +} diff --git a/__POP3_RETR.h b/__POP3_RETR.h index 1881ec1..0bedec6 100644 --- a/__POP3_RETR.h +++ b/__POP3_RETR.h @@ -4,16 +4,13 @@ # include "POP3Command.h" namespace mail { - + class __POP3_RETR : public POP3Command { - - int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + }; - + } #endif diff --git a/__POP3_RPOP.h b/__POP3_RPOP.h index 80acef0..bbc1cd5 100644 --- a/__POP3_RPOP.h +++ b/__POP3_RPOP.h @@ -7,8 +7,12 @@ namespace mail { class __POP3_RPOP : public POP3Command { - int processCommand(coreutils::ZString &request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override { + // Still a placeholder: this command dispatches and replies with a + // syntactically valid POP3 response now ("+OK", not the SMTP-style + // "250 OK" this used to send), but does not yet do any real + // mailbox work. + session.out << "+OK" << CRLF; return 1; } diff --git a/__POP3_RSET.h b/__POP3_RSET.h index c659282..fa5b40c 100644 --- a/__POP3_RSET.h +++ b/__POP3_RSET.h @@ -4,16 +4,24 @@ # include "POP3Command.h" namespace mail { - + class __POP3_RSET : public POP3Command { - - int processCommand(coreutils::ZString &request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; + + // RFC 1939 5: unmarks every message DELE marked this session - + // does not undo anything already committed (nothing is committed + // until QUIT). + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override { + if (!session.authenticated) { + session.out << "-ERR Not authenticated" << CRLF; + return 1; + } + session.deletedMarks.clear(); + session.out << "+OK" << CRLF; return 1; } - + }; - + } #endif diff --git a/__POP3_STAT.cpp b/__POP3_STAT.cpp new file mode 100644 index 0000000..5b456e9 --- /dev/null +++ b/__POP3_STAT.cpp @@ -0,0 +1,24 @@ +#include "__POP3_STAT.h" +#include "POP3Server.h" + +namespace mail { + + int __POP3_STAT::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + if (!session.authenticated) { + session.out << "-ERR Not authenticated" << CRLF; + return 1; + } + int count = 0; + long totalSize = 0; + const auto &messages = session.mailbox.messages(); + for (size_t i = 0; i < messages.size(); ++i) { + if (session.deletedMarks.count((int)(i + 1)) > 0) + continue; + ++count; + totalSize += (long)messages[i].size; + } + session.out << "+OK " << count << " " << totalSize << CRLF; + return 1; + } + +} diff --git a/__POP3_STAT.h b/__POP3_STAT.h index d8715b7..a88f8f2 100644 --- a/__POP3_STAT.h +++ b/__POP3_STAT.h @@ -4,16 +4,13 @@ # include "POP3Command.h" namespace mail { - + class __POP3_STAT : public POP3Command { - - int processCommand(coreutils::ZString &request, POP3Session &session, POP3Server &server) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + }; - + } #endif diff --git a/__POP3_TOP.cpp b/__POP3_TOP.cpp new file mode 100644 index 0000000..abae645 --- /dev/null +++ b/__POP3_TOP.cpp @@ -0,0 +1,28 @@ +#include "__POP3_TOP.h" +#include "POP3Server.h" + +namespace mail { + + int __POP3_TOP::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + if (!session.authenticated) { + session.out << "-ERR Not authenticated" << CRLF; + return 1; + } + request.skipWhitespace(); + int msgNum = request.getTokenExclude(" ").asInteger(); + request.skipWhitespace(); + int lines = request.getTokenExclude(" ").asInteger(); + if (lines < 0) + lines = 0; + + MailFolder::Message *message = pop3FindMessage(session, msgNum); + if (!message) { + session.out << "-ERR No such message" << CRLF; + return 1; + } + session.out << "+OK" << CRLF; + pop3SendMessage(session, session.mailbox.readMessage(message->uid), lines); + return 1; + } + +} diff --git a/__POP3_TOP.h b/__POP3_TOP.h index 3d82926..bb12950 100644 --- a/__POP3_TOP.h +++ b/__POP3_TOP.h @@ -4,16 +4,13 @@ # include "POP3Command.h" namespace mail { - + class __POP3_TOP : public POP3Command { - - int processCommand(coreutils::ZString request, POP3Session &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + }; - + } #endif diff --git a/__POP3_UIDL.cpp b/__POP3_UIDL.cpp new file mode 100644 index 0000000..135af06 --- /dev/null +++ b/__POP3_UIDL.cpp @@ -0,0 +1,35 @@ +#include "__POP3_UIDL.h" +#include "POP3Server.h" + +namespace mail { + + int __POP3_UIDL::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + if (!session.authenticated) { + session.out << "-ERR Not authenticated" << CRLF; + return 1; + } + + request.skipWhitespace(); + coreutils::ZString numArg = request.getTokenExclude(" "); + + if (numArg.getLength() > 0) { + int msgNum = numArg.asInteger(); + MailFolder::Message *message = pop3FindMessage(session, msgNum); + if (!message) { + session.out << "-ERR No such message" << CRLF; + return 1; + } + session.out << "+OK " << msgNum << " " << (int)message->uid << CRLF; + return 1; + } + + session.out << "+OK" << CRLF; + const auto &messages = session.mailbox.messages(); + for (size_t i = 0; i < messages.size(); ++i) + if (session.deletedMarks.count((int)(i + 1)) == 0) + session.out << (i + 1) << " " << (int)messages[i].uid << CRLF; + session.out << "." << CRLF; + return 1; + } + +} diff --git a/__POP3_UIDL.h b/__POP3_UIDL.h new file mode 100644 index 0000000..fba2891 --- /dev/null +++ b/__POP3_UIDL.h @@ -0,0 +1,22 @@ +#ifndef ____POP3_UIDL_h__ +# define ____POP3_UIDL_h__ + +# include "POP3Command.h" + +namespace mail { + + // RFC 1939 7: UIDL wasn't registered/implemented at all before - + // real clients (anything doing incremental sync rather than + // download-and-delete) need it to tell messages apart across + // sessions. Reuses MailFolder's own UID (already stable/persistent + // across sessions - see MailFolder.h) directly as the "unique-id", + // formatted as decimal text per RFC 1939 7's up-to-70-char limit. + class __POP3_UIDL : public POP3Command { + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + + }; + +} + +#endif diff --git a/__POP3_USER.cpp b/__POP3_USER.cpp new file mode 100644 index 0000000..270bc66 --- /dev/null +++ b/__POP3_USER.cpp @@ -0,0 +1,23 @@ +#include "__POP3_USER.h" +#include "POP3Server.h" + +namespace mail { + + // RFC 1939 7: USER only records the pending username - it does NOT + // check whether the user exists (deferred to PASS), so a client + // probing usernames can't distinguish "no such user" from "wrong + // password" by watching this response alone. + int __POP3_USER::processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) { + request.skipWhitespace(); + coreutils::ZString nameArg = request.getTokenExclude(" "); + if (nameArg.getLength() == 0) { + session.out << "-ERR Usage: USER " << CRLF; + return 1; + } + session.userName = nameArg.str(); + session.authenticated = false; + session.out << "+OK" << CRLF; + return 1; + } + +} diff --git a/__POP3_USER.h b/__POP3_USER.h index a948620..88447da 100644 --- a/__POP3_USER.h +++ b/__POP3_USER.h @@ -4,16 +4,13 @@ # include "POP3Command.h" namespace mail { - + class __POP3_USER : public POP3Command { - - int processCommand(coreutils::ZString request, POP3Session &session) { - session.out << "250 OK" << CRLF; - return 1; - } - + + int processCommand(coreutils::ZString request, POP3Session &session, POP3Server &server) override; + }; - + } #endif diff --git a/__SMTP_AUTH.cpp b/__SMTP_AUTH.cpp index 75b58f1..760b09c 100644 --- a/__SMTP_AUTH.cpp +++ b/__SMTP_AUTH.cpp @@ -6,14 +6,33 @@ namespace mail { int __SMTP_AUTH::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { + coreutils::Base64 base64; + switch(session.authState) { case USER_UNKNOWN: - if(request[1].equals("LOGIN")) { - session.out << "334 VXNlcm5hbWU6" << CRLF; - // setTimer(10.0f); - session.authState = USER_QUERY; - grabInput(session); + // SMTPServer's CommandList splits the line only once ("AUTH", + // depth=1 - see its own constructor), so request[1] here is + // "LOGIN" alone for the classic multi-step form, but "LOGIN + // " as ONE token when a client supplies RFC 4954 4's + // optional initial-response inline to save a round trip - + // confirmed live against Python's smtplib, which does exactly + // that. The old request[1].equals("LOGIN") exact-match rejected + // every such client with a spurious 504, never reaching the + // inline response at all. + if(request[1].ifNextIgnoreCase("LOGIN")) { + request[1].skipWhitespace(); + if(!request[1].eod()) { + session.userName = base64.decode(request[1].unparsed()); + session.out << "334 UGFzc3dvcmQ6" << CRLF; + session.authState = USER_SECRET_QUERY; + grabInput(session); + } else { + session.out << "334 VXNlcm5hbWU6" << CRLF; + // setTimer(10.0f); + session.authState = USER_QUERY; + grabInput(session); + } } else { session.out << "504 AUTH method not supported." << CRLF; @@ -21,7 +40,27 @@ namespace mail { return 1; case USER_QUERY: - session.userName = request[0]; + // RFC 4954 4: a bare "*" cancels the exchange rather than being + // treated as (still-encoded) credential data. + if (request.equals("*")) { + session.out << "501 5.7.0 Authentication cancelled" << CRLF; + session.authState = USER_UNKNOWN; + clearGrab(session); + return 1; + } + // grabInput() above means this line never goes through + // CommandList's own .split() (see CommandList::processRequest's + // grab branch) - request[0] here was actually an out-of-bounds + // read on an always-empty list, not "the first word". The + // whole line IS the client's response: a single base64-encoded + // token with no embedded whitespace, exactly what the unsplit + // request already holds. It was never decoded either - AUTH + // LOGIN's wire format sends the username/password base64 + // encoded (that's what the "334 VXNlcm5hbWU6"/"334 + // UGFzc3dvcmQ6" challenges - "Username:"/"Password:" - signal) + // - comparing the raw, still-encoded text against a plaintext + // secret file could never match for any real client. + session.userName = base64.decode(request); // setTimer(0.0f); session.out << "334 UGFzc3dvcmQ6" << CRLF; // setTimer(10.0f); @@ -29,9 +68,14 @@ namespace mail { return 1; case USER_SECRET_QUERY: - session.password = request[0]; + if (request.equals("*")) { + session.out << "501 5.7.0 Authentication cancelled" << CRLF; + session.authState = USER_UNKNOWN; + clearGrab(session); + return 1; + } + session.password = base64.decode(request); // setTimer(0.0f); - coreutils::Base64 base64; if(authLogin(session.userName, session.password, server)) { session.out << "235 2.7.0 Authentication successful" << CRLF; session.relay = true; @@ -39,7 +83,16 @@ namespace mail { clearGrab(session); } else { + // Reset back to a clean, addressable state - without this, + // the session stayed grabbed and stuck expecting yet another + // "password" line forever after one wrong guess, since + // nothing else ever set authState back to USER_UNKNOWN or + // released the grab. A client that mistypes a password needs + // to be able to retry AUTH on the same connection, not + // reconnect entirely. session.out << "530 Login was unsuccessful." << CRLF; + session.authState = USER_UNKNOWN; + clearGrab(session); } return 1; } @@ -47,11 +100,10 @@ namespace mail { } bool __SMTP_AUTH::authLogin(coreutils::ZString &userName, coreutils::ZString &password, SMTPServer &server) { - coreutils::MString secretPath; - secretPath << server.mailFileSystem.getMailBoxPath(userName) << "/.password"; - coreutils::File secret(secretPath); - secret.read(); - return password == secret.asZString(); + // The actual check now lives in MailFileSystem::checkPassword(), + // shared with IMAP LOGIN and POP3 USER/PASS - see its own doc + // comment. + return server.mailFileSystem.checkPassword(userName, password); } } diff --git a/__SMTP_DATA.cpp b/__SMTP_DATA.cpp index 947b593..5e94837 100644 --- a/__SMTP_DATA.cpp +++ b/__SMTP_DATA.cpp @@ -1,11 +1,94 @@ #include "__SMTP_DATA.h" #include "SMTPServer.h" +#include "DKIMVerifier.h" +#include "DMARCChecker.h" #include "Log.h" +#include #include +#include +#include #include +#include namespace mail { + namespace { + + // Pulls the domain out of the message's own From: header (the + // identity DMARC alignment actually judges) - handles both + // "Name " and a bare "user@domain" value, and + // RFC 5322 3.2.2 folding (a continuation line is joined in before + // looking for the address). + std::string extractFromDomain(const std::string &message) { + size_t headerEnd = message.find("\r\n\r\n"); + std::string headerBlock = (headerEnd == std::string::npos) ? message : message.substr(0, headerEnd); + + std::vector lines; + size_t pos = 0; + while (pos < headerBlock.size()) { + size_t next = headerBlock.find("\r\n", pos); + lines.push_back(next == std::string::npos ? headerBlock.substr(pos) : headerBlock.substr(pos, next - pos)); + if (next == std::string::npos) + break; + pos = next + 2; + } + + std::string fromValue; + for (size_t i = 0; i < lines.size(); ++i) { + if (lines[i].size() < 5) + continue; + std::string prefix = lines[i].substr(0, 5); + for (char &c : prefix) + c = std::tolower((unsigned char)c); + if (prefix != "from:") + continue; + fromValue = lines[i].substr(5); + for (size_t j = i + 1; j < lines.size() && !lines[j].empty() && (lines[j][0] == ' ' || lines[j][0] == '\t'); ++j) + fromValue += " " + lines[j]; + break; + } + if (fromValue.empty()) + return ""; + + size_t lt = fromValue.find('<'); + size_t gt = fromValue.find('>'); + std::string address = (lt != std::string::npos && gt != std::string::npos && gt > lt) + ? fromValue.substr(lt + 1, gt - lt - 1) + : fromValue; + + size_t at = address.find_last_of('@'); + if (at == std::string::npos) + return ""; + std::string domain = address.substr(at + 1); + size_t start = domain.find_first_not_of(" \t"); + size_t end = domain.find_last_not_of(" \t"); + if (start == std::string::npos) + return ""; + return domain.substr(start, end - start + 1); + } + + } + + // RFC 5321 4.4: every SMTP server MUST prepend a Received trace + // header to a message it processes - this codebase never wrote one + // at all before. Deliberately no "for " clause: one + // already-written queue file gets hardlinked to every recipient (see + // queueMail() below), and RFC 5321 4.4 itself says that clause + // SHOULD be omitted once there's more than one anyway. + coreutils::MString __SMTP_DATA::receivedHeader(SMTPSession &session, SMTPServer &server) { + time_t now = time(nullptr); + struct tm tmResult; + localtime_r(&now, &tmResult); + char dateBuf[64]; + strftime(dateBuf, sizeof(dateBuf), "%a, %d %b %Y %H:%M:%S %z", &tmResult); + + coreutils::MString header; + header << "Received: from " << session.clientDomainName << CRLF; + header << "\tby " << server.hostName.c_str() << " with SMTP;" << CRLF; + header << "\t" << dateBuf << CRLF; + return header; + } + int __SMTP_DATA::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { switch(session.mode) { @@ -16,7 +99,20 @@ namespace mail { case RCPT: session.out << "354 Enter the mail message terminated by ." << CRLF; - session.mailData = ""; + // RFC 5321 4.4 requires a Received trace header on every message + // this server processes - previously absent entirely. Written + // once here as the seed of mailData so the body lines that + // follow just accumulate after it; omits a "for " + // clause since one already-written file gets hardlinked to every + // recipient (see queueMail() below) and RFC 5321 4.4 itself says + // that clause SHOULD be left out once there's more than one. + // session.spfHeader was computed once already, back at MAIL + // FROM time (see __SMTP_MAIL.cpp) - reused here rather than + // re-running the SPF DNS lookups a second time for the same + // transaction. Placed directly after Received:, matching + // where real-world MTAs conventionally put it. + session.mailData = receivedHeader(session, server); + session.mailData << session.spfHeader.c_str(); session.mode = RECEIVE_DATA; grabInput(session); // session.setTimer(120.0f); @@ -33,32 +129,83 @@ namespace mail { } break; - case RECEIVE_DATA: - if(request != ".") { - session.mailData << request << CRLF; - } - else { - session.mode = WAIT_FOR_DATA; - session.state = READY; - clearGrab(session); - // if(filterMessage(session.mailData)) { - if(session.recipientList.size() > 0) { - if(session.mailData.getLength() > 0) { - coreutils::MString ID = queueMail(server, session.from, session.recipientList, session.mailData); - if(ID != "") { - coreutils::Log(coreutils::LOG_INFO) << "Queued message " << ID << "."; - session.out << "250 OK Queued message " << ID << CRLF; - } - else - session.out << "550 Mail message too big" << CRLF; - } else - session.out << "550 Mail message was empty" << CRLF; - } else - session.out << "250 OK Queued message " << CRLF; - // } else - // data << "550 Message is probably spam" << CRLF; - } - break; + case RECEIVE_DATA: + if(request != ".") { + // RFC 5321 4.5.2 transparency: a line starting with '.' that + // isn't the bare terminator has been stuffed with an extra + // leading '.' by a compliant sender - strip exactly one before + // storing, or every such line comes out with a spurious + // leading dot baked permanently into the stored message body. + coreutils::ZString line = request.startsWith(".") + ? coreutils::ZString(request.getData() + 1, request.getLength() - 1) + : request; + // Cap accumulation at the advertised SIZE limit rather than + // growing mailData without bound - still consuming every line + // the client sends either way (RFC 5321 doesn't support a + // synchronous mid-DATA reply; the client isn't reading + // anything back until its own terminating "." goes out), so + // this only bounds our own memory use. The client actually + // finds out via the 552 reply below, once that terminator + // arrives. + if (session.mailData.getLength() < SMTPServer::maxMessageSize) + session.mailData << line << CRLF; + } + else { + session.mode = WAIT_FOR_DATA; + session.state = READY; + clearGrab(session); + if (session.mailData.getLength() >= SMTPServer::maxMessageSize) { + session.out << "552 5.3.4 Message size exceeds fixed maximum message size" << CRLF; + } else if(session.recipientList.size() > 0) { + if(session.mailData.getLength() > 0) { + std::string message = session.mailData.str(); + + // DKIM verification + DMARC alignment, now that the full + // message (including whatever From:/DKIM-Signature headers + // the client sent) is actually available - neither can run + // any earlier than this. + std::vector dkimResults = DKIMVerifier::verify(message); + std::string fromDomain = extractFromDomain(message); + DMARCChecker::Policy dmarcPolicy; + DMARCChecker::Result dmarcResult = DMARCChecker::evaluate( + fromDomain, session.spfDomain, session.spfResult, dkimResults, dmarcPolicy); + std::string authResults = DMARCChecker::authenticationResultsHeader( + session.spfResult, session.spfDomain, dkimResults, dmarcResult, fromDomain, server.hostName); + + // RFC 7489 6.3: pct= applies the published disposition to only + // that percentage of otherwise-failing messages - the + // remainder still gets the Authentication-Results header + // recording the real (failing) result, just isn't rejected + // for it. p=none/quarantine never reject outright either way + // (quarantine has no separate mailbox to route into here, so + // it degrades to "accept, but the header says fail" - still a + // meaningful signal for whatever reads the header downstream). + bool sampled = (dmarcPolicy.percent >= 100) || ((rand() % 100) < dmarcPolicy.percent); + bool reject = (dmarcResult == DMARCChecker::Result::Fail) && dmarcPolicy.policy == "reject" && sampled; + + if (reject) { + coreutils::Log(coreutils::LOG_INFO) << "Rejected message from " << session.from + << " - DMARC fail, p=reject for " << fromDomain.c_str() << "."; + session.out << "550 5.7.1 Message rejected: failed DMARC policy for " << fromDomain.c_str() << CRLF; + } else { + coreutils::MString withAuthResults(authResults); + withAuthResults << session.mailData; + session.mailData = withAuthResults; + + coreutils::MString ID = queueMail(server, session.from, session.recipientList, session.mailData); + if(ID != "") { + coreutils::Log(coreutils::LOG_INFO) << "Queued message " << ID << "."; + session.out << "250 OK Queued message " << ID << CRLF; + } + else + session.out << "550 Mail message too big" << CRLF; + } + } else + session.out << "550 Mail message was empty" << CRLF; + } else + session.out << "250 OK Queued message " << CRLF; + } + break; } return 1; } @@ -82,7 +229,12 @@ namespace mail { for(coreutils::MString recipient: recipientList) { coreutils::MString newName; - newName << fileName << "|" << recipient; + // Also encode the envelope sender, not just the recipient - + // SMTPServer::inCreate() needs the real MAIL FROM address to + // hand outbound (non-local) mail to SendMail, and the filename + // is the only thing that survives from this queuing step to + // that point. + newName << fileName << "|" << recipient << "|" << sender; link(fileName.c_str(), newName.c_str()); } diff --git a/__SMTP_DATA.h b/__SMTP_DATA.h index b02a1d8..673cfe0 100644 --- a/__SMTP_DATA.h +++ b/__SMTP_DATA.h @@ -14,6 +14,7 @@ namespace mail { coreutils::MString generateMailFileName(); coreutils::MString queueMail(SMTPServer &server, coreutils::MString &sender, std::vector &recipientList, coreutils::MString &mailData); + coreutils::MString receivedHeader(SMTPSession &session, SMTPServer &server); }; diff --git a/__SMTP_EHLO.cpp b/__SMTP_EHLO.cpp index b73e0c7..bc69d7b 100644 --- a/__SMTP_EHLO.cpp +++ b/__SMTP_EHLO.cpp @@ -5,12 +5,27 @@ namespace mail { int __SMTP_EHLO::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { + // RFC 5321 4.1.1.1 requires a domain/address-literal argument - + // request[1] on a bare "EHLO" with none would have been an + // out-of-bounds read on the split list (ZString::operator[] does + // no bounds checking), not a graceful rejection. + if (request.getList().size() < 2) { + session.out << "501 Syntax error: EHLO requires a domain/address-literal argument" << CRLF; + return 1; + } + session.clientDomainName = request[1]; -// std::cout << "{" << session.clientDomainName << "}" << std::endl; session.out << "250-" << server.hostName << CRLF; - // cout << "250-STARTTLS" << CRLF; - // cout << "250-PIPELINING" << CRLF; - // cout << "250-8BITMIME" << CRLF; + // Both already true of this implementation, not just advertised + // hopefully: multiple pipelined command lines arriving in one TCP + // segment are already handled correctly (TCPSocket::onDataReceived + // loops over every complete line it has buffered before returning, + // not just the first), and message bodies are stored byte-for-byte + // with no 7-bit stripping anywhere in this codebase. + session.out << "250-PIPELINING" << CRLF; + session.out << "250-8BITMIME" << CRLF; + // Enforced by __SMTP_DATA.cpp while accumulating the body. + session.out << "250-SIZE " << (long)SMTPServer::maxMessageSize << CRLF; session.out << "250-AUTH LOGIN" << CRLF; session.out << "250 HELP" << CRLF; session.state = READY; diff --git a/__SMTP_EXPN.cpp b/__SMTP_EXPN.cpp index a4214df..ef46fe9 100644 --- a/__SMTP_EXPN.cpp +++ b/__SMTP_EXPN.cpp @@ -3,10 +3,13 @@ namespace mail { - int __SMTP_EXPN::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { + int __SMTP_EXPN::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { session.out << "252 You must know recipient." << CRLF; - session.state = READY; + // EXPN is usable at any point in a session (RFC 5321 4.1.1) and + // must not disturb an in-progress MAIL/RCPT transaction - this + // used to reset state to READY unconditionally, silently + // aborting whatever transaction was already underway. return 1; - } + } } diff --git a/__SMTP_HELP.cpp b/__SMTP_HELP.cpp index f0c5450..2369937 100644 --- a/__SMTP_HELP.cpp +++ b/__SMTP_HELP.cpp @@ -3,9 +3,11 @@ namespace mail { - int __SMTP_HELP::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { - session.out << "250 Sure you need help." << CRLF; - session.state = READY; + int __SMTP_HELP::processCommand(coreutils::ZString &request, SMTPSession &session, SMTPServer &server) { + session.out << "214 " << server.hostName << " - see RFC 5321 for a list of commands." << CRLF; + // HELP is usable at any point in a session and must not disturb + // an in-progress MAIL/RCPT transaction - same reasoning as + // __SMTP_EXPN.cpp's own fix. return 1; } diff --git a/__SMTP_MAIL.cpp b/__SMTP_MAIL.cpp index fd3a461..4645a43 100644 --- a/__SMTP_MAIL.cpp +++ b/__SMTP_MAIL.cpp @@ -1,4 +1,6 @@ #include "__SMTP_MAIL.h" +#include "SMTPServer.h" +#include "SPFChecker.h" namespace mail { @@ -8,11 +10,43 @@ namespace mail { request[1].skipWhitespace(); if(request[1].ifNext("<")) { session.from = request[1].getTokenExclude(">"); - if(session.authState = USER_KNOWN) { - session.out << "250 OK" << CRLF; - session.recipientList.clear(); - session.state = MAIL; - } + // Deliberately unconditional - not gated on authState. A + // receiving MX server accepts MAIL FROM addressed to its own + // local mailboxes from anyone; sending MTAs never AUTH to a + // destination domain, only to their own outbound smarthost. + // Authentication only needs to gate relaying to a NON-local + // recipient, which __SMTP_RCPT.cpp already does via + // session.relay. This used to be + // `if(session.authState = USER_KNOWN)` - an assignment, not + // a comparison - which accidentally produced this same + // "always accept" behavior; a literal `==` fix would instead + // have started rejecting all normal inbound mail. + session.out << "250 OK" << CRLF; + session.recipientList.clear(); + session.state = MAIL; + + // RFC 7208 2.4: the identity to check is the MAIL FROM + // address's own domain, or - for the null reverse-path <> + // bounce messages use, which has no domain of its own - the + // HELO/EHLO domain instead. Computed here (not deferred to + // DATA) since MAIL FROM is where the RFC actually anchors the + // check; DATA just reuses the result via session.spfHeader + // rather than re-running the DNS lookups a second time. This + // does block this connection's EPoll worker thread for + // however long the SPF DNS lookups take (matching + // SendMail::lookupMX()'s own res_query() calls, which are + // synchronous the same way) - acceptable for a well-behaved + // sender domain, not for a slow/hostile one, same tradeoff + // already accepted elsewhere in this codebase rather than a + // new one introduced here. + std::string fromStr = session.from.str(); + size_t at = fromStr.find_last_of('@'); + std::string spfDomain = (at == std::string::npos) ? session.clientDomainName.str() : fromStr.substr(at + 1); + std::string clientIP = session.ipAddress.getClientAddress(); + session.spfDomain = spfDomain; + session.spfResult = SPFChecker::check(spfDomain, clientIP); + session.spfHeader = SPFChecker::receivedSPFHeader(session.spfResult, spfDomain, clientIP, + session.clientDomainName.str(), server.hostName); } else session.out << "550 Usage: MAIL FROM:" << CRLF; } else @@ -20,11 +54,5 @@ namespace mail { return 1; } -// std::string domainOnly(std::string email) { -// coreutils::ZString split(email); -// split.split("@"); -// return split[1].str(); -// } - } diff --git a/compile b/compile index 922ecca..49634b3 100755 --- a/compile +++ b/compile @@ -5,7 +5,7 @@ do filename="${file%.*}" list="$list $filename.o" echo -n "Compiling $filename..." - g++ -g -c -std=c++17 -I../CoreUtils -I../ServerCore $file + g++ -g -c -std=c++23 -I../CoreUtils -I../ServerCore $file if [ $? = '0' ] then echo "OK" @@ -17,7 +17,7 @@ done wait echo -n "Building executable BMAMail..." -g++ -g -std=c++17 -o BMAMail $list -I../CoreUtils -I../ServerCore -L../CoreUtils -lCoreUtils -L../ServerCore -lServerCore -lpthread -luuid -lssl -lcrypto -lresolv +g++ -g -std=c++23 -o BMAMail $list -I../CoreUtils -I../ServerCore -L../CoreUtils -lCoreUtils -L../ServerCore -lServerCore -lpthread -luuid -lssl -lcrypto -lresolv if [ $? = '0' ] then echo "OK" diff --git a/main.cpp b/main.cpp index 020fc00..4822c63 100644 --- a/main.cpp +++ b/main.cpp @@ -9,12 +9,20 @@ #include "SMTPServer.h" #include "POP3Server.h" #include "IMAPServer.h" -#include "SendMail2.h" +#include "SendMail.h" +#include "BARANTMailConfig.h" +#include "DKIMSigner.h" #include +#include #include +#include int main(int argc, char **argv) { - coreutils::Log(new coreutils::File("/var/log/barantmail.log", O_WRONLY | O_APPEND | O_CREAT, 0644)); + // /var/log is root:syslog-owned and not writable by an unprivileged + // user - this constructor throws immediately (before anything else + // in main() runs) rather than silently falling back, when run as + // the ordinary account this is actually deployed under. + coreutils::Log(new coreutils::File("barantmail.log", O_WRONLY | O_APPEND | O_CREAT, 0644)); coreutils::Log(coreutils::LOG_INFO) << "BMAMAIL called with link " << argv[0]; std::string arg = argv[0]; if(arg.substr(arg.find_last_of("\\/") + 1) == "sendmail2") { @@ -28,14 +36,44 @@ int main(int argc, char **argv) { index += len; coreutils::MString message(buffer, index); coreutils::Log(coreutils::LOG_DEBUG_1) << "Number of bytes read: " << index; - core::EPoll ePoll; - ePoll.start(1, 1000); - mail::SendMail2 mail2(ePoll, argv[1], argv[2], message); - mail2.send(); - coreutils::Log(coreutils::LOG_DEBUG_1) << "waiting..."; - coreutils::MString rc(mail2.wait()); - coreutils::Log(coreutils::LOG_DEBUG_1) << "rc: " << rc; - std::cout << rc; + + coreutils::ZString mailPath("/var/mail"); + mail::MailFileSystem mailFileSystem(mailPath); + + // Same config file the server branch defaults to - loaded here + // too (this branch never reads barantmail.cfg otherwise) purely + // for DKIM: mail injected locally through this CLI path is just + // as much "outbound mail from this server" as anything queued + // through SMTP, and should be signed the same way. A missing + // config file here (BARANTMailConfig's own JFile falls back to + // an empty "{}") just means hasDkim() is false and this + // continues unsigned, not a hard failure. + mail::DKIMSigner *dkimSigner = nullptr; + try { + mail::BARANTMailConfig config("barantmail.cfg"); + if (config.hasDkim()) + dkimSigner = new mail::DKIMSigner(config.getDkimDomain(), config.getDkimSelector(), + config.getDkimPrivateKeyPath()); + } catch (...) { + // Config missing hostName/mailPath/smtp.port (all required - + // see BARANTMailConfig's own doc comment) - fine, this + // branch doesn't need any of those, only dkim is optional. + } + + // A local, one-off message id for this injection's own log + // file - matches __SMTP_DATA::generateMailFileName()'s scheme + // (nanosecond timestamp) so both codepaths' logs sit in + // .sendlog/ with the same naming convention. + struct timespec timex; + clock_gettime(CLOCK_REALTIME, &timex); + std::stringstream idStream; + idStream << timex.tv_sec << "." << std::setfill('0') << std::setw(9) << timex.tv_nsec; + + mail::SendMail mail("localhost", mailFileSystem.getSendLogPath().str(), idStream.str(), + coreutils::MString(argv[1]), coreutils::MString(argv[2]), message, dkimSigner); + bool ok = mail.send(); + coreutils::Log(coreutils::LOG_DEBUG_1) << "send result: " << (ok ? "delivered" : "failed"); + std::cout << (ok ? "250 OK\n" : "550 delivery failed\n"); } catch(coreutils::Exception exception) { std::cout << exception.text << " Error reason is '" << strerror(exception.errorNumber) << "' in file " << exception.file << " at line " << exception.line << std::endl; @@ -46,18 +84,58 @@ int main(int argc, char **argv) { coreutils::Log(coreutils::LOG_INFO) << "BMAMail Server starting. Build " << __DATE__ << " " << __TIME__; - std::string hostName = "localhost"; + std::string configPath = (argc > 1) ? argv[1] : "barantmail.cfg"; + mail::BARANTMailConfig config(configPath); + + std::string hostName = config.getHostName(); std::string ipAddress = "0.0.0.0"; - coreutils::ZString mailPath("/var/mail"); + // ZString's std::string constructor takes a non-const reference, + // which can't bind to a temporary - a named local is required. + std::string mailPathStr = config.getMailPath(); + coreutils::ZString mailPath(mailPathStr); mail::MailFileSystem mailFileSystem(mailPath); core::EPoll ePoll; - ePoll.start(1, 1000); - mail::SMTPServer smtpServer(ePoll, hostName, mailFileSystem, core::IPAddress(ipAddress, 9025)); -// mail::POP3Server pop3Server(ePoll, hostName, mailFileSystem, core::IPAddress(ipAddress, 110)); -// mail::IMAPServer imapServer(ePoll, hostName, mailFileSystem, core::IPAddress(ipAddress, 143)); - core::ConsoleServer consoleServer(ePoll, core::IPAddress(ipAddress, 1027)); + ePoll.start(4, 1000); + + // One signer, shared by every outbound-relaying listener - it + // holds no per-message state (see DKIMSigner's own doc comment), + // so there's no reason for the plaintext and TLS SMTP listeners + // to each load their own copy of the same private key. + mail::DKIMSigner *dkimSigner = nullptr; + if (config.hasDkim()) + dkimSigner = new mail::DKIMSigner(config.getDkimDomain(), config.getDkimSelector(), + config.getDkimPrivateKeyPath()); + + // Port 25 always runs unencrypted and always owns the mail queue + // watch (see SMTPServer's own doc comment on watchQueue) - an + // inbound peer MTA relaying mail in has no TLS-first negotiation + // to fall back on, so this is the one listener every deployment + // needs regardless of what else is configured. + mail::SMTPServer smtpServer(ePoll, hostName, mailFileSystem, + core::IPAddress(ipAddress, config.getSmtpPort()), nullptr, true, + dkimSigner); + + mail::SMTPServer *smtpTLSServer = nullptr; + if (config.hasSmtpTLS()) + smtpTLSServer = new mail::SMTPServer(ePoll, hostName, mailFileSystem, + core::IPAddress(ipAddress, config.getSmtpTLSPort()), + config.getTlsInfo(), false, dkimSigner); + + mail::POP3Server *pop3Server = nullptr; + if (config.hasPop3()) + pop3Server = new mail::POP3Server(ePoll, hostName, mailFileSystem, + core::IPAddress(ipAddress, config.getPop3Port()), + config.getTlsInfo()); + + mail::IMAPServer *imapServer = nullptr; + if (config.hasImap()) + imapServer = new mail::IMAPServer(ePoll, hostName, mailFileSystem, + core::IPAddress(ipAddress, config.getImapPort()), + config.getTlsInfo()); + + core::ConsoleServer consoleServer(ePoll, core::IPAddress(ipAddress, 1027), nullptr); consoleServer.commands.add(consoleServer.commands, "help"); consoleServer.commands.add(smtpServer.commands, "smtp"); diff --git a/tests/imapdebug.py b/tests/imapdebug.py new file mode 100644 index 0000000..a009e4f --- /dev/null +++ b/tests/imapdebug.py @@ -0,0 +1,25 @@ +import socket +import ssl + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +raw = socket.create_connection(("127.0.0.1", 993), timeout=10) +s = ctx.wrap_socket(raw) + +def recv_line(): + buf = b"" + while not buf.endswith(b"\r\n"): + buf += s.recv(1) + print("S:", buf) + return buf + +recv_line() + +def send(line): + print("C:", line) + s.sendall(line.encode() + b"\r\n") + +send('a1 LOGIN "barant@barant.com" "imaptest123"') +recv_line() diff --git a/tests/imapsearchdebug.py b/tests/imapsearchdebug.py new file mode 100644 index 0000000..93f37a7 --- /dev/null +++ b/tests/imapsearchdebug.py @@ -0,0 +1,25 @@ +import imaplib, ssl + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +m = imaplib.IMAP4_SSL("127.0.0.1", 993, ssl_context=ctx) +m.login("barant@barant.com", "imaptest123") +typ, data = m.select("INBOX") +last = int(data[0]) +print("last seq:", last) + +typ, data = m.fetch(str(last), "(ENVELOPE UID)") +print("last message envelope:", data) + +typ, data = m.fetch(str(last), "(BODY[])") +print("last message body:", data) + +typ, data = m.search(None, "ALL") +print("all ids:", data) + +typ, data = m.search(None, 'SUBJECT "test"') +print('SUBJECT "test":', data) + +m.logout() diff --git a/tests/imaptest.py b/tests/imaptest.py new file mode 100644 index 0000000..4a04706 --- /dev/null +++ b/tests/imaptest.py @@ -0,0 +1,68 @@ +import imaplib +import ssl +import sys + +HOST = "127.0.0.1" +USER = "barant@barant.com" +PASS = "imaptest123" + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +def step(name, fn): + try: + result = fn() + print(f"OK {name}: {result}") + return result + except Exception as e: + print(f"FAIL {name}: {e}") + raise + +m = imaplib.IMAP4_SSL(HOST, 993, ssl_context=ctx) +print("connected:", m.welcome) + +step("LOGIN", lambda: m.login(USER, PASS)) +step("CAPABILITY", lambda: m.capability()) +step("LIST", lambda: m.list()) +typ, data = step("SELECT INBOX", lambda: m.select("INBOX")) +print(" EXISTS:", data) + +typ, data = step("SEARCH ALL", lambda: m.search(None, "ALL")) +ids = data[0].split() +print(f" {len(ids)} messages") + +if ids: + first = ids[0] + typ, data = step("FETCH ENVELOPE", lambda: m.fetch(first, "(ENVELOPE)")) + print(" ", data) + typ, data = step("FETCH FLAGS UID", lambda: m.fetch(first, "(FLAGS UID)")) + print(" ", data) + typ, data = step("FETCH BODY[]", lambda: m.fetch(first, "(BODY[])")) + print(" body length:", len(data[0][1]) if data and data[0] else 0) + step("STORE +FLAGS \\Flagged", lambda: m.store(first, "+FLAGS", "\\Flagged")) + typ, data = step("SEARCH FLAGGED", lambda: m.search(None, "FLAGGED")) + print(" flagged ids:", data) + step("STORE -FLAGS \\Flagged", lambda: m.store(first, "-FLAGS", "\\Flagged")) + +step("STATUS INBOX", lambda: m.status("INBOX", "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN)")) + +# APPEND a literal message, then verify it shows up, then clean it up via COPY+EXPUNGE +msg = b"From: test@barant.com\r\nTo: barant@barant.com\r\nSubject: IMAP APPEND test\r\n\r\nHello from APPEND.\r\n" +typ, data = step("APPEND", lambda: m.append("INBOX", "", None, msg)) +print(" ", data) + +typ, data = step("SEARCH SUBJECT", lambda: m.search(None, 'SUBJECT "APPEND test"')) +appended_ids = data[0].split() +print(" found:", appended_ids) + +if appended_ids: + step("COPY to Trash", lambda: m.copy(appended_ids[-1], "Trash")) + step("STORE +FLAGS \\Deleted", lambda: m.store(appended_ids[-1], "+FLAGS", "\\Deleted")) + step("EXPUNGE", lambda: m.expunge()) + +typ, data = step("SELECT Trash", lambda: m.select("Trash")) +print(" Trash EXISTS:", data) + +step("LOGOUT", lambda: m.logout()) +print("ALL DONE") diff --git a/tests/pop3test.py b/tests/pop3test.py new file mode 100644 index 0000000..8783c59 --- /dev/null +++ b/tests/pop3test.py @@ -0,0 +1,63 @@ +import poplib +import ssl + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +USER = "barant@barant.com" +PASS = "imaptest123" + +def step(name, fn): + try: + result = fn() + print(f"OK {name}: {result}") + return result + except Exception as e: + print(f"FAIL {name}: {e}") + raise + +m = poplib.POP3_SSL("127.0.0.1", 995, context=ctx) +print("welcome:", m.getwelcome()) + +step("USER", lambda: m.user(USER)) +step("PASS", lambda: m.pass_(PASS)) +count, size = step("STAT", lambda: m.stat()) +print(f" {count} messages, {size} octets") + +resp, items, octets = step("LIST", lambda: m.list()) +print(f" {len(items)} items, sample: {items[0] if items else None}") + +if count > 0: + resp, lines, octets = step("RETR 1", lambda: m.retr(1)) + print(f" {len(lines)} lines, first: {lines[0] if lines else None}") + + resp, uidls, octets = step("UIDL (all)", lambda: m.uidl()) + print(f" sample: {uidls[0] if uidls else None}") + + resp = step("UIDL 1", lambda: m.uidl(1)) + print(f" {resp}") + + resp, lines, octets = step("TOP 1 0", lambda: m.top(1, 0)) + print(f" {len(lines)} lines (should be 0 body lines)") + + step("DELE 1", lambda: m.dele(1)) + count2, size2 = step("STAT (after DELE)", lambda: m.stat()) + print(f" {count2} messages (should be {count - 1})") + + step("RSET", lambda: m.rset()) + count3, size3 = step("STAT (after RSET)", lambda: m.stat()) + print(f" {count3} messages (should be back to {count})") + + step("DELE 1 (again, will commit)", lambda: m.dele(1)) + +step("QUIT", lambda: m.quit()) +print("ALL DONE") + +# Reconnect to confirm the DELE really committed at QUIT. +m2 = poplib.POP3_SSL("127.0.0.1", 995, context=ctx) +m2.user(USER) +m2.pass_(PASS) +count4, size4 = m2.stat() +print(f"after reconnect: {count4} messages (should be {count - 1})") +m2.quit() diff --git a/tests/sendtest.py b/tests/sendtest.py new file mode 100644 index 0000000..3286c22 --- /dev/null +++ b/tests/sendtest.py @@ -0,0 +1,31 @@ +import smtplib +import ssl +from email.mime.text import MIMEText +from datetime import datetime, timezone + +HOST = "127.0.0.1" +PORT = 465 +USER = "barant@barant.com" +PASS = "imaptest123" +TO = "brad.arant@gmail.com" + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +msg = MIMEText( + "This is a live delivery test of the new BARANTMail server " + "(SMTP + DKIM signing + SPF/DMARC on inbound, IMAP/POP3 now real).\n\n" + f"Sent at {datetime.now(timezone.utc).isoformat()}." +) +msg["Subject"] = "BARANTMail delivery test" +msg["From"] = USER +msg["To"] = TO + +s = smtplib.SMTP_SSL(HOST, PORT, context=ctx, timeout=20) +s.set_debuglevel(1) +s.ehlo("mail.barant.com") +s.login(USER, PASS) +s.sendmail(USER, [TO], msg.as_string()) +s.quit() +print("SENT") diff --git a/tests/smtptest.py b/tests/smtptest.py new file mode 100644 index 0000000..0362d67 --- /dev/null +++ b/tests/smtptest.py @@ -0,0 +1,20 @@ +import smtplib +import sys +import time + +def send(subject, body, from_addr="test@barant.com", to_addr="barant@barant.com"): + msg = f"From: {from_addr}\r\nTo: {to_addr}\r\nSubject: {subject}\r\n\r\n{body}\r\n" + s = smtplib.SMTP("127.0.0.1", 25, timeout=15) + s.set_debuglevel(0) + s.ehlo("test-client.example.com") + s.sendmail(from_addr, [to_addr], msg) + s.quit() + +send("Auth stack test - normal mail", "This is an ordinary test message.") +print("sent normal message") + +time.sleep(1) + +GTUBE = "XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X" +send("Auth stack test - spam probe", GTUBE) +print("sent GTUBE spam-probe message") diff --git a/tests/stresstest.py b/tests/stresstest.py new file mode 100644 index 0000000..4d62cb9 --- /dev/null +++ b/tests/stresstest.py @@ -0,0 +1,75 @@ +import imaplib, poplib, smtplib, ssl, threading, time, random + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +USER = "barant@barant.com" +PASS = "imaptest123" + +errors = [] +lock = threading.Lock() + +def note_error(name, e): + with lock: + errors.append(f"{name}: {e}") + +def imap_cycle(i): + try: + m = imaplib.IMAP4_SSL("127.0.0.1", 993, ssl_context=ctx) + m.login(USER, PASS) + m.select("INBOX") + m.search(None, "ALL") + typ, data = m.fetch("1", "(FLAGS UID)") + m.logout() + except Exception as e: + note_error(f"imap#{i}", e) + +def pop3_cycle(i): + try: + m = poplib.POP3_SSL("127.0.0.1", 995, context=ctx) + m.user(USER) + m.pass_(PASS) + m.stat() + m.list() + m.quit() + except Exception as e: + note_error(f"pop3#{i}", e) + +def smtp_cycle(i): + try: + s = smtplib.SMTP("127.0.0.1", 25, timeout=10) + s.ehlo("stress-client") + msg = f"From: stress{i}@barant.com\r\nTo: barant@barant.com\r\nSubject: stress {i}\r\n\r\nstress body {i}\r\n" + s.sendmail(f"stress{i}@barant.com", ["barant@barant.com"], msg) + s.quit() + except Exception as e: + note_error(f"smtp#{i}", e) + +def rude_disconnect(i): + # Connect and drop mid-handshake / mid-command without clean close - + # exercises the abrupt-disconnect path specifically. + import socket + try: + s = socket.create_connection(("127.0.0.1", 993), timeout=5) + s.recv(50) + s.close() + except Exception as e: + note_error(f"rude#{i}", e) + +threads = [] +N = 40 +for i in range(N): + fn = random.choice([imap_cycle, pop3_cycle, smtp_cycle, rude_disconnect]) + t = threading.Thread(target=fn, args=(i,)) + threads.append(t) + t.start() + time.sleep(0.02) + +for t in threads: + t.join(timeout=15) + +print(f"Completed {N} concurrent mixed-protocol cycles.") +print(f"Errors: {len(errors)}") +for e in errors[:20]: + print(" ", e)