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).
This commit is contained in:
1 parent
641368ea85
commit
cd5e0a9905
122 files changed
+6238
-749
No files matched your search
+240
-120
@@ -1,130 +1,250 @@
|
||||
#include "SendMail.h"
|
||||
#include <chrono>
|
||||
#include <sys/types.h>
|
||||
#include <netinet/in.h>
|
||||
#include "Log.h"
|
||||
#include <algorithm>
|
||||
#include <arpa/inet.h>
|
||||
#include <arpa/nameser.h>
|
||||
#include <cstdlib>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <resolv.h>
|
||||
#include <sstream>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <utility>
|
||||
|
||||
namespace mail {
|
||||
|
||||
SendMail::SendMail(core::EPoll &ePoll, std::string from, std::string recipient, std::string mailFileName)
|
||||
: core::TCPSession2(ePoll, "Send Mail Agent"), from(from), recipient(recipient), mailFileName(mailFileName),
|
||||
mailData(mailFileName), Timer(ePoll, 0.00f), ePoll(ePoll) {
|
||||
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << this->recipient;
|
||||
this->recipient.split("@");
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << this->recipient[1];
|
||||
|
||||
u_char nsbuff[4096];
|
||||
char *domain = this->recipient[1].c_str();
|
||||
int len = res_query(domain, ns_c_any, ns_t_mx, nsbuff, sizeof(nsbuff));
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << ">" << len;
|
||||
ns_msg msg;
|
||||
|
||||
ns_initparse(nsbuff, len, &msg);
|
||||
len = ns_msg_count(msg, ns_s_an);
|
||||
|
||||
char dispbuf[4096];
|
||||
ns_rr rr;
|
||||
for (int i = 0; i < 1; i++) {
|
||||
ns_parserr(&msg, ns_s_an, i, &rr);
|
||||
ns_sprintrr(&msg, &rr, NULL, NULL, dispbuf, sizeof(dispbuf));
|
||||
printf ("%s\n", dispbuf);
|
||||
}
|
||||
|
||||
coreutils::ZString mxArray(dispbuf);
|
||||
mxArray.split("\t");
|
||||
mxArray[3].split(" ");
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << "MX: " << mxArray[3][1];
|
||||
|
||||
core::IPAddress mxAddress(mxArray[3][1].c_str(), 25);
|
||||
connect(mxAddress);
|
||||
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << "SendMail initiated...";
|
||||
|
||||
}
|
||||
|
||||
SendMail::SendMail(const SendMail ©) : core::TCPSession2(ePoll, "Send Mail Agent"), from(copy.from), recipient(copy.recipient), mailFileName(copy.mailFileName),
|
||||
mailData(mailFileName), Timer(ePoll, 0.00f), ePoll(copy.ePoll) {}
|
||||
|
||||
SendMail::~SendMail() {
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << "SendMail destructing...";
|
||||
|
||||
SendMail::SendMail(std::string hostName, std::string sendLogPath, std::string messageId,
|
||||
coreutils::MString from, coreutils::MString recipient, coreutils::MString mailData,
|
||||
DKIMSigner *dkimSigner)
|
||||
: hostName(hostName), sendLogPath(sendLogPath), messageId(messageId),
|
||||
from(from), recipient(recipient), mailData(mailData), dkimSigner(dkimSigner) {}
|
||||
|
||||
std::string SendMail::domainOf(coreutils::MString &address) {
|
||||
std::string addr = address.str();
|
||||
size_t at = addr.find_last_of('@');
|
||||
return (at == std::string::npos) ? addr : addr.substr(at + 1);
|
||||
}
|
||||
|
||||
void SendMail::onTimeout() {
|
||||
state = stateOnTimeout;
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << "onTimeout...";
|
||||
protocol(buffer);
|
||||
TCPSession2::send();
|
||||
}
|
||||
|
||||
void SendMail::protocol(coreutils::ZString &data) {
|
||||
|
||||
coreutils::Log(coreutils::LOG_DEBUG_1) << "[" << data << "..." << state;
|
||||
|
||||
switch (state) {
|
||||
|
||||
case CONNECT:
|
||||
// read recipient
|
||||
// parse email for name and domain name
|
||||
// open tcpsocket with connect
|
||||
// wait for greeting
|
||||
if(data.asInteger() == 220) {
|
||||
out << "EHLO " << "mail.barant.com" << CRLF;
|
||||
state = READY;
|
||||
}
|
||||
break;
|
||||
|
||||
case READY:
|
||||
buffer = data;
|
||||
setTimer(.05);
|
||||
stateOnTimeout = READYX;
|
||||
break;
|
||||
|
||||
case READYX:
|
||||
if(buffer.asInteger() == 250) {
|
||||
out << "MAIL FROM:<" << from << ">" << CRLF;
|
||||
state = MAIL;
|
||||
}
|
||||
break;
|
||||
|
||||
case MAIL:
|
||||
if(data.asInteger() == 250) {
|
||||
out << "RCPT TO:<" << recipient << ">" << CRLF;
|
||||
state = RCPT;
|
||||
}
|
||||
break;
|
||||
|
||||
case RCPT:
|
||||
if(data.asInteger() == 250) {
|
||||
out << "DATA" << CRLF;
|
||||
state = DATA;
|
||||
}
|
||||
break;
|
||||
|
||||
case DATA:
|
||||
if(data.asInteger() == 354) {
|
||||
while(!mailData.eof()) {
|
||||
mailData.readLine();
|
||||
out << mailData.asZString() << CRLF;
|
||||
}
|
||||
out << "." << CRLF;
|
||||
state = SENT;
|
||||
}
|
||||
break;
|
||||
|
||||
case SENT:
|
||||
if(data.asInteger() == 250) {
|
||||
out << "QUIT" << CRLF;
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
std::vector<std::string> SendMail::lookupMX(const std::string &domain) {
|
||||
std::vector<std::pair<int, std::string>> records;
|
||||
unsigned char answer[4096];
|
||||
int len = res_query(domain.c_str(), C_IN, T_MX, answer, sizeof(answer));
|
||||
if (len > 0) {
|
||||
ns_msg handle;
|
||||
if (ns_initparse(answer, len, &handle) == 0) {
|
||||
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_mx) {
|
||||
const unsigned char *rdata = ns_rr_rdata(rr);
|
||||
int preference = (rdata[0] << 8) | rdata[1];
|
||||
char host[NS_MAXDNAME];
|
||||
if (dn_expand(ns_msg_base(handle), ns_msg_end(handle), rdata + 2, host, sizeof(host)) >= 0)
|
||||
records.emplace_back(preference, std::string(host));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(records.begin(), records.end(),
|
||||
[](const std::pair<int, std::string> &a, const std::pair<int, std::string> &b) {
|
||||
return a.first < b.first;
|
||||
});
|
||||
std::vector<std::string> hosts;
|
||||
for (auto &r : records)
|
||||
hosts.push_back(r.second);
|
||||
// RFC 5321 5.1: a domain with no MX records is treated as a
|
||||
// single implicit MX naming that domain itself.
|
||||
if (hosts.empty())
|
||||
hosts.push_back(domain);
|
||||
return hosts;
|
||||
}
|
||||
|
||||
std::vector<coreutils::MString>& SendMail::extractRecipientList() {
|
||||
return recipientList;
|
||||
|
||||
std::string SendMail::readLine(int fd) {
|
||||
std::string line;
|
||||
char ch;
|
||||
while (::read(fd, &ch, 1) == 1) {
|
||||
if (ch == '\n')
|
||||
break;
|
||||
if (ch != '\r')
|
||||
line += ch;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
|
||||
void SendMail::sendLine(int fd, const std::string &line, coreutils::File &log) {
|
||||
std::string wire = line + "\r\n";
|
||||
::write(fd, wire.c_str(), wire.length());
|
||||
log.write(">> " + line + "\n");
|
||||
}
|
||||
|
||||
int SendMail::readResponse(int fd, coreutils::File &log, std::string &fullText) {
|
||||
// A multi-line SMTP reply repeats the same 3-digit code on every
|
||||
// line, with "-" (not a space) after the code on every line but
|
||||
// the last (RFC 5321 4.2.1) - keep reading until a line uses the
|
||||
// space form.
|
||||
int code = -1;
|
||||
fullText.clear();
|
||||
while (true) {
|
||||
std::string line = readLine(fd);
|
||||
log.write("<< " + line + "\n");
|
||||
if (line.length() < 4)
|
||||
return code;
|
||||
code = atoi(line.substr(0, 3).c_str());
|
||||
fullText += line + "\n";
|
||||
if (line[3] == ' ')
|
||||
break;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
bool SendMail::deliverToHost(const std::string &host, coreutils::File &log) {
|
||||
|
||||
struct addrinfo hints {};
|
||||
struct addrinfo *result = nullptr;
|
||||
// AF_UNSPEC let this pick a destination MX host's IPv6 address
|
||||
// whenever one existed, which - since the OS picks the LOCAL
|
||||
// source address to match the destination's address family - has
|
||||
// this server originate the connection from its own IPv6 address
|
||||
// instead of its IPv4 one. Confirmed live: every one of Gmail's 5
|
||||
// MX hosts rejected delivery with "550 5.7.25 ... does not have a
|
||||
// PTR record" for that IPv6 address, which really doesn't have
|
||||
// one (nor is it covered by this domain's own SPF record, which
|
||||
// only lists the IPv4 address - a second, independent reason
|
||||
// IPv6 delivery from here can never pass real-world receiver
|
||||
// policy). The IPv4 address DOES have a correct PTR (resolves
|
||||
// back to barant.com) and IS the address barant.com's SPF record
|
||||
// authorizes - AF_INET forces every outbound relay connection to
|
||||
// originate from it, matching what this domain's actual DNS
|
||||
// records promise. Revisit if IPv6 PTR + an ip6: SPF entry are
|
||||
// ever added for this host.
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
if (getaddrinfo(host.c_str(), "25", &hints, &result) != 0 || !result) {
|
||||
log.write("connect: could not resolve " + host + "\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
int fd = -1;
|
||||
for (struct addrinfo *rp = result; rp != nullptr; rp = rp->ai_next) {
|
||||
fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
|
||||
if (fd < 0)
|
||||
continue;
|
||||
if (::connect(fd, rp->ai_addr, rp->ai_addrlen) == 0)
|
||||
break;
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
freeaddrinfo(result);
|
||||
|
||||
if (fd < 0) {
|
||||
log.write("connect: unable to reach " + host + " on port 25\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
struct timeval timeout {60, 0};
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
|
||||
std::string text;
|
||||
|
||||
// Everything but the final QUIT lives in this lambda so a failure
|
||||
// partway through the conversation can bail out with a plain
|
||||
// `return false` while the caller still always sends QUIT and
|
||||
// closes the socket afterward, regardless of how this ended.
|
||||
auto converse = [&]() -> bool {
|
||||
|
||||
if (readResponse(fd, log, text) / 100 != 2)
|
||||
return false;
|
||||
|
||||
sendLine(fd, "EHLO " + hostName, log);
|
||||
if (readResponse(fd, log, text) / 100 != 2) {
|
||||
// Some older servers only speak HELO.
|
||||
sendLine(fd, "HELO " + hostName, log);
|
||||
if (readResponse(fd, log, text) / 100 != 2)
|
||||
return false;
|
||||
}
|
||||
|
||||
sendLine(fd, "MAIL FROM:<" + from.str() + ">", log);
|
||||
if (readResponse(fd, log, text) / 100 != 2)
|
||||
return false;
|
||||
|
||||
sendLine(fd, "RCPT TO:<" + recipient.str() + ">", log);
|
||||
if (readResponse(fd, log, text) / 100 != 2)
|
||||
return false;
|
||||
|
||||
sendLine(fd, "DATA", log);
|
||||
if (readResponse(fd, log, text) != 354)
|
||||
return false;
|
||||
|
||||
std::string body = mailData.str();
|
||||
std::istringstream stream(body);
|
||||
std::string bodyLine, wire;
|
||||
while (std::getline(stream, bodyLine)) {
|
||||
if (!bodyLine.empty() && bodyLine.back() == '\r')
|
||||
bodyLine.pop_back();
|
||||
// RFC 5321 4.5.2 transparency: a body line that legitimately
|
||||
// starts with '.' must be stuffed with an extra leading '.'
|
||||
// so the receiver never mistakes it for the terminating line.
|
||||
if (!bodyLine.empty() && bodyLine[0] == '.')
|
||||
wire += ".";
|
||||
wire += bodyLine;
|
||||
wire += "\r\n";
|
||||
}
|
||||
wire += ".\r\n";
|
||||
::write(fd, wire.c_str(), wire.length());
|
||||
log.write(">> [message body, " + std::to_string(body.length()) + " bytes]\n");
|
||||
|
||||
return readResponse(fd, log, text) / 100 == 2;
|
||||
};
|
||||
|
||||
bool ok = converse();
|
||||
|
||||
sendLine(fd, "QUIT", log);
|
||||
readResponse(fd, log, text);
|
||||
::close(fd);
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool SendMail::send() {
|
||||
|
||||
// ZString's std::string constructor takes a non-const reference,
|
||||
// which can't bind to a temporary - a named local is required
|
||||
// here rather than passing the concatenation expression directly.
|
||||
std::string logFileName = sendLogPath + "/" + messageId + ".log";
|
||||
coreutils::File log(logFileName, O_CREAT | O_WRONLY | O_APPEND, 0660);
|
||||
log.write("=== delivery attempt: message " + messageId + " -> " + recipient.str() + " ===\n");
|
||||
|
||||
if (dkimSigner) {
|
||||
// Signed once here, not inside deliverToHost() - deliverToHost()
|
||||
// can run again against a second MX host if the first fails, and
|
||||
// the signature (over this exact message content) is the same
|
||||
// either way, so signing once upfront avoids redundant RSA ops
|
||||
// and keeps every delivery attempt for this message consistent.
|
||||
std::string signature = dkimSigner->sign(mailData.str());
|
||||
coreutils::MString signedData;
|
||||
signedData << signature.c_str() << mailData;
|
||||
mailData = signedData;
|
||||
log.write("DKIM-signed.\n");
|
||||
}
|
||||
|
||||
std::string domain = domainOf(recipient);
|
||||
std::vector<std::string> hosts = lookupMX(domain);
|
||||
|
||||
bool delivered = false;
|
||||
for (auto &host : hosts) {
|
||||
log.write("--- trying " + host + " ---\n");
|
||||
if (deliverToHost(host, log)) {
|
||||
log.write("delivered successfully via " + host + "\n");
|
||||
delivered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!delivered)
|
||||
log.write("all hosts for " + domain + " failed\n");
|
||||
|
||||
return delivered;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user