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).
172 lines
6.8 KiB
C++
172 lines
6.8 KiB
C++
#include "DMARCChecker.h"
|
|
#include <arpa/nameser.h>
|
|
#include <cctype>
|
|
#include <resolv.h>
|
|
#include <sstream>
|
|
|
|
namespace mail {
|
|
|
|
namespace {
|
|
|
|
std::vector<std::string> lookupDMARCRecords(const std::string &domain) {
|
|
std::vector<std::string> records;
|
|
std::string queryName = "_dmarc." + domain;
|
|
unsigned char answer[4096];
|
|
int len = res_query(queryName.c_str(), C_IN, T_TXT, answer, sizeof(answer));
|
|
if (len <= 0)
|
|
return records;
|
|
ns_msg handle;
|
|
if (ns_initparse(answer, len, &handle) != 0)
|
|
return records;
|
|
int count = ns_msg_count(handle, ns_s_an);
|
|
for (int i = 0; i < count; ++i) {
|
|
ns_rr rr;
|
|
if (ns_parserr(&handle, ns_s_an, i, &rr) != 0 || ns_rr_type(rr) != ns_t_txt)
|
|
continue;
|
|
const unsigned char *rdata = ns_rr_rdata(rr);
|
|
int rdlen = ns_rr_rdlen(rr);
|
|
std::string full;
|
|
int pos = 0;
|
|
while (pos < rdlen) {
|
|
int segLen = rdata[pos];
|
|
++pos;
|
|
if (pos + segLen > rdlen)
|
|
break;
|
|
full.append((const char *)(rdata + pos), segLen);
|
|
pos += segLen;
|
|
}
|
|
std::string lower = full;
|
|
for (char &c : lower)
|
|
c = std::tolower((unsigned char)c);
|
|
if (lower.rfind("v=dmarc1", 0) == 0)
|
|
records.push_back(full);
|
|
}
|
|
return records;
|
|
}
|
|
|
|
void parseTags(const std::string &record, DMARCChecker::Policy &p) {
|
|
std::istringstream stream(record);
|
|
std::string part;
|
|
while (std::getline(stream, part, ';')) {
|
|
size_t start = part.find_first_not_of(" \t");
|
|
if (start == std::string::npos)
|
|
continue;
|
|
part = part.substr(start);
|
|
size_t eq = part.find('=');
|
|
if (eq == std::string::npos)
|
|
continue;
|
|
std::string key = part.substr(0, eq);
|
|
std::string value = part.substr(eq + 1);
|
|
while (!value.empty() && std::isspace((unsigned char)value.back()))
|
|
value.pop_back();
|
|
for (char &c : key)
|
|
c = std::tolower((unsigned char)c);
|
|
if (key == "p")
|
|
p.policy = value;
|
|
else if (key == "sp")
|
|
p.subdomainPolicy = value;
|
|
else if (key == "adkim")
|
|
p.strictDKIM = (value == "s");
|
|
else if (key == "aspf")
|
|
p.strictSPF = (value == "s");
|
|
else if (key == "pct")
|
|
p.percent = atoi(value.c_str());
|
|
}
|
|
if (p.subdomainPolicy.empty())
|
|
p.subdomainPolicy = p.policy;
|
|
}
|
|
|
|
}
|
|
|
|
std::string DMARCChecker::organizationalDomain(const std::string &domain) {
|
|
std::vector<std::string> labels;
|
|
std::istringstream stream(domain);
|
|
std::string label;
|
|
while (std::getline(stream, label, '.'))
|
|
if (!label.empty())
|
|
labels.push_back(label);
|
|
if (labels.size() <= 2)
|
|
return domain;
|
|
return labels[labels.size() - 2] + "." + labels[labels.size() - 1];
|
|
}
|
|
|
|
bool DMARCChecker::aligned(const std::string &identityDomain, const std::string &fromDomain, bool strict) {
|
|
if (identityDomain.empty() || fromDomain.empty())
|
|
return false;
|
|
auto lower = [](std::string s) { for (char &c : s) c = std::tolower((unsigned char)c); return s; };
|
|
if (strict)
|
|
return lower(identityDomain) == lower(fromDomain);
|
|
return lower(organizationalDomain(identityDomain)) == lower(organizationalDomain(fromDomain));
|
|
}
|
|
|
|
bool DMARCChecker::lookupPolicy(const std::string &domain, Policy &policyOut) {
|
|
std::vector<std::string> records = lookupDMARCRecords(domain);
|
|
if (records.size() == 1) {
|
|
parseTags(records[0], policyOut);
|
|
policyOut.found = true;
|
|
return true;
|
|
}
|
|
if (records.size() > 1)
|
|
return false; // RFC 7489 6.6.3: more than one record is treated as none published
|
|
|
|
// RFC 7489 6.6.3: no record at the exact domain falls back to the
|
|
// organizational domain's own record (using ITS sp=, or p= if sp=
|
|
// is absent, as the effective policy for this subdomain) - but
|
|
// only when domain actually IS a subdomain of something else, or
|
|
// this would just re-query the same name again.
|
|
std::string org = organizationalDomain(domain);
|
|
if (org == domain)
|
|
return false;
|
|
std::vector<std::string> orgRecords = lookupDMARCRecords(org);
|
|
if (orgRecords.size() != 1)
|
|
return false;
|
|
Policy orgPolicy;
|
|
parseTags(orgRecords[0], orgPolicy);
|
|
policyOut = orgPolicy;
|
|
policyOut.policy = orgPolicy.subdomainPolicy; // sp= (or p=, via parseTags' own fallback) governs here
|
|
policyOut.found = true;
|
|
return true;
|
|
}
|
|
|
|
DMARCChecker::Result DMARCChecker::evaluate(const std::string &fromDomain, const std::string &spfDomain,
|
|
SPFChecker::Result spfResult,
|
|
const std::vector<DKIMVerifier::SignatureResult> &dkimResults,
|
|
Policy &appliedPolicy) {
|
|
if (fromDomain.empty() || !lookupPolicy(fromDomain, appliedPolicy))
|
|
return Result::None;
|
|
|
|
bool spfAligned = (spfResult == SPFChecker::Result::Pass) && aligned(spfDomain, fromDomain, appliedPolicy.strictSPF);
|
|
|
|
bool dkimAligned = false;
|
|
for (auto &d : dkimResults)
|
|
if (d.valid && aligned(d.domain, fromDomain, appliedPolicy.strictDKIM)) {
|
|
dkimAligned = true;
|
|
break;
|
|
}
|
|
|
|
return (spfAligned || dkimAligned) ? Result::Pass : Result::Fail;
|
|
}
|
|
|
|
std::string DMARCChecker::resultName(Result result) {
|
|
switch (result) {
|
|
case Result::Pass: return "pass";
|
|
case Result::Fail: return "fail";
|
|
case Result::None: return "none";
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
std::string DMARCChecker::authenticationResultsHeader(SPFChecker::Result spfResult, const std::string &spfDomain,
|
|
const std::vector<DKIMVerifier::SignatureResult> &dkimResults,
|
|
Result dmarcResult, const std::string &fromDomain,
|
|
const std::string &receivingHost) {
|
|
std::string h = "Authentication-Results: " + receivingHost + ";\r\n";
|
|
h += "\tspf=" + SPFChecker::resultName(spfResult) + " smtp.mailfrom=" + spfDomain + ";\r\n";
|
|
for (auto &d : dkimResults)
|
|
h += "\tdkim=" + std::string(d.valid ? "pass" : "fail") + " header.d=" + d.domain + " header.s=" + d.selector + ";\r\n";
|
|
h += "\tdmarc=" + resultName(dmarcResult) + " header.from=" + fromDomain + ";\r\n";
|
|
return h;
|
|
}
|
|
|
|
}
|