Add real IMAP4rev1/POP3 implementations, DKIM/DMARC/SPF, SpamAssassin delivery hook

IMAP (RFC 3501): LOGIN through UID, previously all stubs except
CAPABILITY/LOGOUT/a partial APPEND. Multi-folder support with per-folder
UID/flag indexing (new MailFolder class, backed by .uidvalidity/.uidnext/
.imapindex sidecar files), ENVELOPE support (new MessageHeaders class:
header parsing, address-list parsing, IMAP quoting), and shared
cursor-based argument parsing (new IMAPArgs class: sequence sets, flag
lists, dates, folder-name normalization, and path-traversal protection
via isValidFolderName - a bare ".." folder name would otherwise resolve
into a sibling user's mailbox directory).

POP3 (RFC 1939): USER through QUIT, previously only CAPA. Added UIDL.
POP3 handlers use cursor-based parsing throughout rather than indexed
access - ZString's copy constructor doesn't copy its `list` field, and
POP3's dispatch passes the request by value, so indexed access into a
copy was silently reading nothing.

SMTP: DMARC evaluation added to the DATA-terminator path (DKIM
verification + alignment/policy, Authentication-Results header,
conditional rejection matching RFC 7489 6.3's p=reject + sampled pct=).
AUTH LOGIN now handles RFC 4954's optional inline initial response
(e.g. Python smtplib's "AUTH LOGIN <base64>" in one line) instead of
only the exact three-line form. Outbound relay now forces IPv4
(AF_INET) instead of AF_UNSPEC - this server's IPv4 has a correct PTR
record and SPF coverage, its IPv6 doesn't, and Gmail was rejecting
outbound mail with 550 5.7.25 as a result.

Delivery pipeline: local delivery now runs the message through
SpamAssassin (new SpamAssassinFilter class, speaking spamd's PROCESS
protocol directly over a raw socket - fails open/delivers unscanned if
spamd is unreachable) instead of a plain hardlink, since scanning can
tag/modify content.

BARANTMailConfig: extracted config loading into its own class.

compile: (see individual file changes - build script adjustments to
match the above).

Deliberately excluded from this commit: barantmail.log/
barantmail-service.log (operational logs, not source - barantmail.log
in particular was the runaway 55GB/12.9GB file from a busy-loop bug
fixed separately in ServerCore) and barantmail.cfg (this server's own
live deployment config, matching how APIServer's equivalent
config also stays untracked).
This commit is contained in:
Brad Arant committed 2026-09-03 05:24:22 +00:00
1 parent 641368ea85
commit cd5e0a9905
122 files changed
+6238 -749

No files matched your search

+240 -120
View File
@@ -1,130 +1,250 @@
#include "SendMail.h"
#include <chrono>
#include <sys/types.h>
#include <netinet/in.h>
#include "Log.h"
#include <algorithm>
#include <arpa/inet.h>
#include <arpa/nameser.h>
#include <cstdlib>
#include <netdb.h>
#include <netinet/in.h>
#include <resolv.h>
#include <sstream>
#include <sys/socket.h>
#include <unistd.h>
#include <utility>
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 &copy) : 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<std::string> SendMail::lookupMX(const std::string &domain) {
std::vector<std::pair<int, std::string>> 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<int, std::string> &a, const std::pair<int, std::string> &b) {
return a.first < b.first;
});
std::vector<std::string> 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<coreutils::MString>& 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<std::string> 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;
}
}