- 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>
344 lines
13 KiB
C++
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;
|
|
}
|
|
|
|
}
|