Files
BARANTMail/MailFolder.cpp
T
Brad Arant 5f3e6ed2e6 Log MailFolder save failures instead of failing silently
std::ofstream does not throw on a failed open, so a permissions or
disk-full problem writing .imapindex/.uidnext/.uidvalidity previously
just... did not happen, with no trace anywhere. Confirmed live: the
barant accounts inbox and Trash index files were root-owned mode 644
while the mail service runs as a non-root user, so every flag/counter
persist attempt (marking a message \Seen, for instance) silently
failed for weeks - the in-memory session reflected the change for that
one connection, but nothing ever reached disk, so any fresh IMAP
session saw the old state again. Ownership fixed directly on the
affected files; this logs the failure mode so a recurrence is
visible.
2026-09-07 02:29:16 +00:00

316 lines
11 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;
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);
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;
}
}