Files
BARANTMail/MailFolder.cpp
Brad ArantandClaude Sonnet 5 407cc2e915 Add per-domain DKIM signing, admin console TLS, log rotation, and resolver timeout scoping
- DKIM signing was hardcoded to one domain; now keyed per-domain off
  the sending address, fixing mail for every domain but the original.
- Admin console (port 1027) is now TLS-wrapped instead of plaintext.
- barantmail.log now rotates by size (config-driven) instead of
  growing unbounded (previously hit 23GB).
- ResolverThreadScope.h: a <setenv> scoping fix for name resolution
  inside a <try> block that previously did not survive its own
  container.
- IMAPArgs.cpp: fixed "UID STORE 1:*" wrongly requiring UID 1 to
  exist, which silently made empty-folder operations delete nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 17:39:06 +00:00

344 lines
13 KiB
C++

#include "MailFolder.h"
#include "Log.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;
// ofstream doesn't throw on a failed open (e.g. a permissions
// problem, a full/read-only filesystem) - confirmed live: two
// pre-existing mailbox index files were root-owned mode 644
// while the mail service runs as a non-root user, so every
// counter/flag persist attempt failed here completely silently
// for weeks. Logging it is the only way this becomes visible
// instead of just quietly not happening.
if (!out)
coreutils::Log(coreutils::LOG_EXCEPT) << "MailFolder: failed to write " << path << " - check ownership/permissions.";
}
}
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;
try {
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);
// A short/truncated line (a write cut off mid-flush by a
// crash or restart racing save() - which already logs that
// this exact failure mode is possible, see save() below)
// leaves uidStr/dateStr empty, and stoul/stoll throw
// std::invalid_argument on that - uncaught, that previously
// propagated all the way up through load() and every IMAP
// handler that calls it (SELECT, EXAMINE) into
// CommandList::processRequest()'s own generic catch-all,
// which silently swallowed it and sent NO response at all -
// indistinguishable from the server hanging, since the
// client just waits forever for a reply that was never
// coming. Confirmed live: a single truncated trailing line
// ("Seen", missing its filename/uid/date - the tail end of
// what should have been a real message line) in a real
// user's Inbox .imapindex made every single SELECT/EXAMINE
// of that folder hang the connection, indefinitely,
// deterministically, until this line was found and
// skipped. Catching here instead means one corrupt line is
// dropped (logged) rather than taking the whole folder
// load - and therefore the whole command - down; the
// in-memory message list this produces is what save() (also
// called at the end of load(), unconditionally) rewrites
// the index file from, so the corrupt line is
// self-healed away on the very next successful load().
message.uid = (uint32_t)std::stoul(uidStr);
message.internalDate = (time_t)std::stoll(dateStr);
message.flags = splitFlags(flagsBlob);
mMessages.push_back(message);
} catch (...) {
coreutils::Log(coreutils::LOG_EXCEPT) << "MailFolder: skipping malformed .imapindex line in " << path << ": [" << line << "]";
}
}
}
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);
if (!out) {
coreutils::Log(coreutils::LOG_EXCEPT) << "MailFolder: failed to open " << path << "/.imapindex for writing - flag/message changes will not persist. Check ownership/permissions.";
return;
}
for (const Message &message : mMessages)
out << message.fileName << " " << message.uid << " " << (long long)message.internalDate
<< " " << joinFlags(message.flags) << "\n";
if (!out)
coreutils::Log(coreutils::LOG_EXCEPT) << "MailFolder: write to " << path << "/.imapindex failed partway through - index may be incomplete.";
}
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;
}
}