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:
1 parent
641368ea85
commit
cd5e0a9905
122 files changed
+6238
-749
No files matched your search
+180
-28
@@ -1,11 +1,94 @@
|
||||
#include "__SMTP_DATA.h"
|
||||
#include "SMTPServer.h"
|
||||
#include "DKIMVerifier.h"
|
||||
#include "DMARCChecker.h"
|
||||
#include "Log.h"
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <vector>
|
||||
|
||||
namespace mail {
|
||||
|
||||
namespace {
|
||||
|
||||
// Pulls the domain out of the message's own From: header (the
|
||||
// identity DMARC alignment actually judges) - handles both
|
||||
// "Name <user@domain>" 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<std::string> 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 <recipient>" 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>.<CRLF>" << 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 <recipient>"
|
||||
// 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<DKIMVerifier::SignatureResult> 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());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user