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

+299
View File
@@ -0,0 +1,299 @@
#include "MailFolder.h"
#include <algorithm>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <sstream>
#include <sys/stat.h>
#include <unistd.h>
#include <filesystem>
namespace mail {
namespace {
std::string joinFlags(const std::vector<std::string> &flags) {
std::string out;
for (size_t i = 0; i < flags.size(); ++i) {
if (i > 0)
out += ",";
out += flags[i];
}
return out;
}
std::vector<std::string> splitFlags(const std::string &blob) {
std::vector<std::string> 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<std::string> &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<std::string> 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<std::string> &flags) {
Message *message = find(uid);
if (!message)
return;
message->flags = flags;
save();
}
void MailFolder::addFlags(uint32_t uid, const std::vector<std::string> &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<std::string> &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<std::string> &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<uint32_t> MailFolder::expunge() {
std::vector<Message> toDelete;
for (const Message &message : mMessages)
if (hasFlag(message.flags, "\\Deleted"))
toDelete.push_back(message);
std::vector<uint32_t> 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;
}
}