#include "SpamAssassinFilter.h" #include "Log.h" #include #include #include #include namespace mail { namespace { const char *SPAMD_HOST = "127.0.0.1"; const int SPAMD_PORT = 783; } // Speaks the spamc/spamd wire protocol (documented in spamd(1)'s // PROTOCOL file) directly over a plain blocking socket, rather than // shelling out to the spamc binary - one connect/send/recv is // simpler than managing a child process's stdin/stdout pipes // without risking a full-pipe deadlock. This blocks whichever // thread calls it for however long spamd takes to score the // message - same tradeoff SPFChecker::check() already makes for its // own synchronous DNS lookups (see __SMTP_MAIL.cpp's doc comment). // Called only from SMTPServer::inCreate(), on the queue-watcher's // EPoll worker thread, never inline in a client's DATA command - so // it's this thread's other queued deliveries that wait behind a // slow scan, not a connected client. bool SpamAssassinFilter::talkToSpamd(const std::string &message, std::string &response) { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) return false; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(SPAMD_PORT); if (inet_pton(AF_INET, SPAMD_HOST, &addr.sin_addr) != 1) { close(fd); return false; } if (connect(fd, (sockaddr *)&addr, sizeof(addr)) != 0) { close(fd); return false; } std::string request = "PROCESS SPAMC/1.5\r\n"; request += "Content-length: " + std::to_string(message.size()) + "\r\n"; request += "\r\n"; request += message; size_t sent = 0; while (sent < request.size()) { ssize_t n = send(fd, request.data() + sent, request.size() - sent, 0); if (n <= 0) { close(fd); return false; } sent += n; } shutdown(fd, SHUT_WR); // tell spamd it has the whole message, without closing our read side response.clear(); char buf[8192]; ssize_t n; while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) response.append(buf, n); close(fd); return !response.empty(); } SpamAssassinFilter::Result SpamAssassinFilter::scan(const std::string &message) { Result result; result.scanned = message; // fail-open default: deliver the original if anything below bails out std::string response; if (!talkToSpamd(message, response)) { coreutils::Log(coreutils::LOG_WARN) << "SpamAssassin scan failed (spamd unreachable at " << SPAMD_HOST << ":" << SPAMD_PORT << ") - delivering unscanned."; return result; } // spamd's own response is itself a small header block // ("SPAMD/1.5 0 EX_OK\r\nContent-length: N\r\n\r\n") wrapped // around the scanned message - strip that off before treating // what follows as the message to actually deliver. size_t headerEnd = response.find("\r\n\r\n"); if (headerEnd == std::string::npos) { coreutils::Log(coreutils::LOG_WARN) << "SpamAssassin scan returned an unparseable response - delivering unscanned."; return result; } std::string statusLine = response.substr(0, response.find("\r\n")); if (statusLine.find("EX_OK") == std::string::npos) { coreutils::Log(coreutils::LOG_WARN) << "SpamAssassin scan reported an error (" << statusLine << ") - delivering unscanned."; return result; } result.scanned = response.substr(headerEnd + 4); result.ok = true; // spamd's required_score (local.cf) is the actual threshold; // this just reads the Yes/No verdict spamd already reached back // out of the header it wrote, rather than re-deciding anything. static const std::string marker = "\r\nX-Spam-Status: "; size_t pos = result.scanned.find(marker); if (pos != std::string::npos) result.isSpam = result.scanned.compare(pos + marker.size(), 3, "Yes") == 0; return result; } }