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

+154
View File
@@ -0,0 +1,154 @@
#include "IMAPArgs.h"
#include "MailFolder.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <sstream>
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<uint32_t> parseSequenceSet(coreutils::ZString &request, MailFolder &folder, bool uidMode) {
std::vector<uint32_t> 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<std::string> parseFlagList(coreutils::ZString &request) {
coreutils::ZString arg = nextArgument(request);
std::string text = unwrap(arg);
std::vector<std::string> 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;
}
}
}