Files
BARANTMail/__IMAP_FETCH.cpp
Brad Arant cd5e0a9905 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).
2026-09-03 05:24:22 +00:00

303 lines
12 KiB
C++

#include "__IMAP_FETCH.h"
#include "IMAPArgs.h"
#include "IMAPServer.h"
#include "MessageHeaders.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <ctime>
#include <sstream>
namespace mail {
namespace {
// Reads one FETCH data-item token, which - unlike an ordinary IMAP
// atom - may carry a "[section]" and/or "<start.length>" suffix
// with no space before them (BODY[HEADER.FIELDS (SUBJECT)]), and
// may itself contain embedded spaces INSIDE that bracketed
// section. IMAPArgs::nextArgument() can't handle this shape (it
// only recognizes a container when the very first character is
// one of "([{<" - "BODY[...]" starts with 'B').
std::string nextFetchItem(coreutils::ZString &items) {
items.skipWhitespace();
if (items.eod())
return "";
std::string token;
while (!items.eod() && items.getChar() != ' ' && items.getChar() != '[' && items.getChar() != '<')
token += items.nextChar();
if (!items.eod() && items.getChar() == '[') {
coreutils::ZString section = items.getContainer();
token += section.str();
}
if (!items.eod() && items.getChar() == '<') {
token += items.nextChar();
while (!items.eod() && items.getChar() != '>')
token += items.nextChar();
if (!items.eod())
token += items.nextChar();
}
return token;
}
std::string upper(const std::string &s) {
std::string result = s;
for (char &c : result)
c = std::toupper((unsigned char)c);
return result;
}
std::string formatInternalDate(time_t when) {
struct tm tmValue;
gmtime_r(&when, &tmValue);
char buf[64];
strftime(buf, sizeof(buf), "%d-%b-%Y %H:%M:%S +0000", &tmValue);
return buf;
}
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;
}
// Formats a section's content as an IMAP literal ({n}\r\n<bytes>),
// always - simpler and always correct, vs. quoted-string escaping
// for content that can contain arbitrary bytes/CRLFs.
std::string asLiteral(const std::string &content) {
std::ostringstream out;
out << "{" << content.size() << "}\r\n" << content;
return out.str();
}
// Extracts the section name out of a token like
// "BODY[HEADER.FIELDS (SUBJECT TO)]<0.100>" -> "HEADER.FIELDS (SUBJECT TO)",
// and separately reports any <start.length>/<start> range.
struct FetchItem {
std::string name; // e.g. "FLAGS", "BODY.PEEK", "RFC822.SIZE"
std::string section; // interior of [...], if any
bool hasSection = false;
bool hasRange = false;
long rangeStart = 0;
long rangeLength = -1; // -1 == to end
};
FetchItem parseFetchItem(const std::string &token) {
FetchItem item;
size_t bracket = token.find('[');
size_t angle = token.find('<');
size_t nameEnd = std::min(bracket, angle);
item.name = (nameEnd == std::string::npos) ? token : token.substr(0, nameEnd);
if (bracket != std::string::npos) {
size_t closeBracket = token.find(']', bracket);
if (closeBracket != std::string::npos) {
item.section = token.substr(bracket + 1, closeBracket - bracket - 1);
item.hasSection = true;
}
}
if (angle != std::string::npos) {
size_t closeAngle = token.find('>', angle);
if (closeAngle != std::string::npos) {
std::string rangeText = token.substr(angle + 1, closeAngle - angle - 1);
size_t dot = rangeText.find('.');
item.hasRange = true;
item.rangeStart = std::strtol(rangeText.substr(0, dot).c_str(), nullptr, 10);
if (dot != std::string::npos)
item.rangeLength = std::strtol(rangeText.substr(dot + 1).c_str(), nullptr, 10);
}
}
return item;
}
std::string applyRange(const std::string &content, const FetchItem &item) {
if (!item.hasRange)
return content;
if (item.rangeStart < 0 || (size_t)item.rangeStart >= content.size())
return "";
size_t length = item.rangeLength < 0 ? std::string::npos : (size_t)item.rangeLength;
return content.substr(item.rangeStart, length);
}
std::string sectionContent(const std::string &fullMessage, const std::string &sectionSpec) {
std::string headerBlock, bodyBlock;
splitMessage(fullMessage, headerBlock, bodyBlock);
std::string spec = upper(sectionSpec);
// Strip trailing whitespace introduced by our own tokenizing.
while (!spec.empty() && spec.back() == ' ')
spec.pop_back();
if (spec.empty())
return fullMessage;
if (spec == "TEXT")
return bodyBlock;
if (spec == "HEADER")
return headerBlock + "\r\n\r\n";
if (spec.rfind("HEADER.FIELDS", 0) == 0) {
size_t paren = sectionSpec.find('(');
size_t closeParen = sectionSpec.find(')', paren);
std::string fieldsText = (paren != std::string::npos && closeParen != std::string::npos)
? sectionSpec.substr(paren + 1, closeParen - paren - 1) : "";
std::istringstream fieldStream(fieldsText);
std::string fieldName;
auto headers = parseHeaderLines(headerBlock);
std::string result;
// Only fields actually present in the message are emitted -
// matching common server behavior for a requested field the
// message simply lacks.
while (fieldStream >> fieldName) {
for (auto &h : headers) {
if (h.first.size() != fieldName.size())
continue;
bool match = true;
for (size_t i = 0; i < fieldName.size(); ++i)
if (std::toupper((unsigned char)h.first[i]) != std::toupper((unsigned char)fieldName[i])) {
match = false;
break;
}
if (match)
result += h.first + ": " + h.second + "\r\n";
}
}
result += "\r\n";
return result;
}
// Unrecognized section shape (MIME part numbers, HEADER.FIELDS.NOT,
// etc. - out of scope, see plan's deferred-items list) - fall
// back to the whole message rather than fail outright.
return fullMessage;
}
}
int fetchImpl(coreutils::ZString &request, IMAPSession &session, IMAPServer &server, bool uidMode) {
if (session.state != IMAPSession::SELECTED) {
session.out << session.tag << " NO No mailbox selected" << CRLF;
return 1;
}
std::vector<uint32_t> uids = IMAPArgs::parseSequenceSet(request, session.mailbox, uidMode);
request.skipWhitespace();
std::vector<std::string> itemTokens;
if (!request.eod() && request.getChar() == '(') {
coreutils::ZString listArg = request.getContainer();
std::string interior = IMAPArgs::unwrap(listArg);
coreutils::ZString interiorZ(interior);
while (true) {
std::string item = nextFetchItem(interiorZ);
if (item.empty())
break;
itemTokens.push_back(item);
}
} else {
std::string item = nextFetchItem(request);
if (!item.empty())
itemTokens.push_back(item);
}
if (itemTokens.size() == 1) {
std::string macro = upper(itemTokens[0]);
if (macro == "ALL")
itemTokens = {"FLAGS", "INTERNALDATE", "RFC822.SIZE", "ENVELOPE"};
else if (macro == "FAST")
itemTokens = {"FLAGS", "INTERNALDATE", "RFC822.SIZE"};
else if (macro == "FULL")
itemTokens = {"FLAGS", "INTERNALDATE", "RFC822.SIZE", "ENVELOPE"};
}
for (uint32_t uid : uids) {
MailFolder::Message *message = session.mailbox.find(uid);
if (!message)
continue;
uint32_t seqNum = session.mailbox.seq(uid);
bool needsContent = false;
for (const std::string &token : itemTokens) {
std::string name = upper(parseFetchItem(token).name);
if (name == "ENVELOPE" || name == "RFC822" || name == "RFC822.HEADER" ||
name == "RFC822.TEXT" || name.rfind("BODY", 0) == 0)
needsContent = true;
}
std::string content = needsContent ? session.mailbox.readMessage(uid) : "";
bool markSeen = false;
std::ostringstream line;
bool first = true;
bool sawFlags = false;
auto emit = [&](const std::string &text) {
if (!first)
line << " ";
first = false;
line << text;
};
for (const std::string &token : itemTokens) {
FetchItem item = parseFetchItem(token);
std::string name = upper(item.name);
if (name == "FLAGS") {
emit("FLAGS (" + joinFlags(message->flags) + ")");
sawFlags = true;
} else if (name == "UID") {
emit("UID " + std::to_string(uid));
} else if (name == "INTERNALDATE") {
emit("INTERNALDATE " + imapQuoteOrNil(formatInternalDate(message->internalDate)));
} else if (name == "RFC822.SIZE") {
emit("RFC822.SIZE " + std::to_string(message->size));
} else if (name == "ENVELOPE") {
emit("ENVELOPE " + buildEnvelope(content));
} else if (name == "RFC822") {
emit("RFC822 " + asLiteral(applyRange(content, item)));
if (!session.readOnly) markSeen = true;
} else if (name == "RFC822.HEADER") {
std::string headerBlock, bodyBlock;
splitMessage(content, headerBlock, bodyBlock);
emit("RFC822.HEADER " + asLiteral(applyRange(headerBlock + "\r\n\r\n", item)));
} else if (name == "RFC822.TEXT") {
std::string headerBlock, bodyBlock;
splitMessage(content, headerBlock, bodyBlock);
emit("RFC822.TEXT " + asLiteral(applyRange(bodyBlock, item)));
if (!session.readOnly) markSeen = true;
} else if (name == "BODY" || name == "BODY.PEEK") {
std::string sectionText = item.hasSection ? sectionContent(content, item.section) : content;
std::string responseKey = "BODY[" + item.section + "]";
if (item.hasRange)
responseKey += "<" + std::to_string(item.rangeStart) + ">";
emit(responseKey + " " + asLiteral(applyRange(sectionText, item)));
bool isPeek = (name == "BODY.PEEK");
std::string upperSection = upper(item.section);
bool headerOnly = (upperSection == "HEADER" || upperSection.rfind("HEADER.FIELDS", 0) == 0);
if (!isPeek && !headerOnly && !session.readOnly)
markSeen = true;
} else {
// Unrecognized item (BODYSTRUCTURE, bare BODY macro, etc. -
// see the plan's explicitly-deferred list) - acknowledge
// with NIL rather than silently drop the field.
emit(upper(token) + " NIL");
}
}
if (markSeen && std::find(message->flags.begin(), message->flags.end(), "\\Seen") == message->flags.end()) {
message->flags.push_back("\\Seen");
session.mailbox.setFlags(uid, message->flags);
if (!sawFlags)
emit("FLAGS (" + joinFlags(message->flags) + ")");
}
session.out << "* " << (int)seqNum << " FETCH (" << line.str() << ")" << CRLF;
}
session.out << session.tag << " OK " << (uidMode ? "UID FETCH" : "FETCH") << " completed" << CRLF;
return 1;
}
int __IMAP_FETCH::processCommand(coreutils::ZString &request, IMAPSession &session, IMAPServer &server) {
return fetchImpl(request, session, server, false);
}
}