Files
JetCore/__imap.cpp
T
Brad Arant 41fbf0b7d9 Add pagination to the email list (was hardcoded to newest 50, no way to see older mail)
listMessages() only ever fetched everything then kept the newest
limit (default 50) messages, with no offset parameter and no way for
the client to know how many messages existed beyond what it was
shown - older mail was completely unreachable through the webmail UI.

__imap::listMessages() now takes an offset (counted back from the
newest message) and returns {"total":N,"messages":[...]} instead of
a bare array, so the client can page backward/forward and know when
it has reached either end. __imaplist tag and /email/list both pass
offset through; /email/search is unchanged (search results are not
paginated). email.js tracks currentOffset (reset alongside
currentFolder/currentUid at every existing reset site), and renders
Prev/Next controls next to the unread count showing the current
1-based range and total.
2026-09-07 04:20:42 +00:00

1109 lines
46 KiB
C++

#include "__imap.h"
#include "Base64.h"
#include "Exception.h"
#include "JFile.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <iconv.h>
#include <netdb.h>
#include <sstream>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <unordered_map>
namespace jet {
namespace {
struct PooledImapConnection {
SSL_CTX *ctx;
SSL *ssl;
int fd;
std::string host, user;
time_t lastUsed;
};
// thread_local, keyed by "sessionid|user" - same reasoning as
// __mysql.cpp's own connectionPool: this APIServer's EPoll worker
// pool is a fixed set of OS threads for the process's whole life,
// so a thread_local pool here is only ever touched by the one
// thread that created each entry, never concurrently.
//
// That thread-locality is exactly why an explicit disconnect
// (see the disconnect="yes" branch in __imap's constructor, and
// email.jet's /email/disconnect route) can't be relied on to
// reliably reach a given connection at all: EPoll's fixed worker
// pool is 4 OS threads, and there is no guarantee two requests
// from the same site session - a /email/connect and a later
// /email/disconnect - land on the SAME one. A disconnect that
// happens to run on a different thread than the one holding the
// live connection finds nothing in ITS OWN, empty-for-this-key
// pool and silently does nothing, leaving the real connection on
// the other thread open and unread indefinitely (confirmed live -
// see [[barantcom-email-page-fixes-20260904]]). The idle-sweep
// below is the actual guarantee: it runs at the top of EVERY
// <imap> construction, on whichever thread that happens to be,
// so any thread that ever handles IMAP traffic again eventually
// cleans out its own stale entries regardless of which thread
// any particular disconnect call landed on. The explicit
// disconnect="yes" close is kept too, as a same-thread fast path
// when it does happen to line up - just not the only mechanism.
thread_local std::unordered_map<std::string, PooledImapConnection> imapPool;
const time_t IMAP_POOL_IDLE_TIMEOUT_SECONDS = 120;
void closePooledConnection(PooledImapConnection &c) {
if (c.ssl) { SSL_shutdown(c.ssl); SSL_free(c.ssl); }
if (c.fd >= 0) ::close(c.fd);
if (c.ctx) SSL_CTX_free(c.ctx);
}
// Called at the top of every <imap> construction (connect or
// disconnect alike) - closes and evicts any pooled connection on
// THIS thread that's sat unused past the idle timeout, regardless
// of which user/session it belongs to. This is what actually
// bounds how long a leaked connection (an explicit disconnect
// that missed it, a browser tab closed mid-session, a crashed
// client) can stay open, independent of whether any future
// request ever happens to target that exact user again.
void sweepIdleImapConnections() {
time_t now = time(nullptr);
for (auto it = imapPool.begin(); it != imapPool.end(); ) {
if (now - it->second.lastUsed > IMAP_POOL_IDLE_TIMEOUT_SECONDS) {
closePooledConnection(it->second);
it = imapPool.erase(it);
} else {
++it;
}
}
}
std::string imapQuote(const std::string &s) {
std::string out = "\"";
for (char c : s) {
if (c == '"' || c == '\\')
out += '\\';
out += c;
}
out += "\"";
return out;
}
std::string trimWS(std::string s) {
size_t a = s.find_first_not_of(" \t");
size_t b = s.find_last_not_of(" \t");
return (a == std::string::npos) ? std::string() : s.substr(a, b - a + 1);
}
bool taggedOk(const std::vector<std::string> &lines, const std::string &tag) {
return !lines.empty() && lines.back().rfind(tag + " OK", 0) == 0;
}
// Given `line` (a literal-rejoined response string - see
// __imap::readResponseLine()'s own doc comment) and the index of
// the '}' that closes a "{n}" literal-length marker somewhere in
// it, returns the n bytes immediately following (skipping the
// "\r\n" joiner readResponseLine() inserted there).
std::string extractLiteralAt(const std::string &line, size_t braceEndPos) {
size_t braceStart = line.rfind('{', braceEndPos);
if (braceStart == std::string::npos)
return "";
std::string numStr = line.substr(braceStart + 1, braceEndPos - braceStart - 1);
if (numStr.empty() || numStr.find_first_not_of("0123456789") != std::string::npos)
return "";
long n = strtol(numStr.c_str(), nullptr, 10);
size_t contentStart = braceEndPos + 1;
if (contentStart + 2 <= line.size() && line[contentStart] == '\r' && line[contentStart + 1] == '\n')
contentStart += 2;
if (contentStart > line.size())
return "";
if (contentStart + (size_t)n > line.size())
n = (long)(line.size() - contentStart);
return line.substr(contentStart, n);
}
// Q-encoding (RFC 2047's variant of quoted-printable used inside
// "=?charset?Q?...?=" encoded-words) - '_' stands for a literal
// space (plain quoted-printable would need "=20" for that) and
// "=XX" is a hex-escaped byte, same as body quoted-printable.
std::string decodeQEncoding(const std::string &in) {
std::string out;
out.reserve(in.size());
for (size_t i = 0; i < in.size(); ++i) {
char c = in[i];
if (c == '_') {
out += ' ';
} else if (c == '=' && i + 2 < in.size() &&
isxdigit((unsigned char)in[i + 1]) && isxdigit((unsigned char)in[i + 2])) {
out += (char)strtol(in.substr(i + 1, 2).c_str(), nullptr, 16);
i += 2;
} else {
out += c;
}
}
return out;
}
// Real-world mail overwhelmingly declares UTF-8 (every encoded
// subject seen from actual traffic to this mailbox does), so that
// case is a pure passthrough; anything else goes through iconv,
// the standard POSIX charset-conversion API, falling back to the
// raw bytes if the declared charset is one iconv doesn't
// recognize rather than throwing - a garbled subject is a far
// better failure mode than a 500 on the whole message list.
std::string convertToUtf8(const std::string &bytes, const std::string &charset) {
std::string cs = charset;
std::transform(cs.begin(), cs.end(), cs.begin(), [](unsigned char c) { return std::tolower(c); });
if (cs.empty() || cs == "utf-8" || cs == "us-ascii")
return bytes;
iconv_t cd = iconv_open("UTF-8//IGNORE", charset.c_str());
if (cd == (iconv_t)-1)
return bytes;
std::string result(bytes.size() * 4 + 16, '\0');
char *inBuf = const_cast<char *>(bytes.data());
size_t inBytesLeft = bytes.size();
char *outStart = &result[0];
char *outBuf = outStart;
size_t outBytesLeft = result.size();
iconv(cd, &inBuf, &inBytesLeft, &outBuf, &outBytesLeft);
iconv_close(cd);
if (outBuf == outStart)
return bytes;
result.resize(outBuf - outStart);
return result;
}
// Decodes RFC 2047 MIME encoded-words ("=?charset?B|Q?text?=")
// that show up verbatim in raw header field values - IMAP hands
// Subject/From/To back exactly as the sender wrote them, with no
// decoding of its own, so without this every non-ASCII subject
// (extremely common in real mail - every Japanese-language
// message seen in this mailbox uses it) rendered as raw
// "=?UTF-8?B?...?=" garbage in the webmail UI instead of the
// actual text. Per RFC 2047 section 2, whitespace that sits
// ONLY between two consecutive encoded-words is itself part of
// the encoding (a folding artifact) and gets dropped; whitespace
// next to plain text is preserved as-is.
std::string decodeMimeHeader(const std::string &raw) {
std::string result;
size_t pos = 0;
bool lastWasEncoded = false;
while (pos < raw.size()) {
size_t start = raw.find("=?", pos);
if (start == std::string::npos) {
result += raw.substr(pos);
break;
}
std::string gap = raw.substr(pos, start - pos);
bool gapIsWhitespaceOnly = gap.find_first_not_of(" \t\r\n") == std::string::npos;
if (!(lastWasEncoded && gapIsWhitespaceOnly))
result += gap;
size_t q1 = raw.find('?', start + 2);
bool valid = (q1 != std::string::npos) && (q1 - start - 2 <= 40) && (q1 - start - 2 >= 1);
size_t q2 = valid ? raw.find('?', q1 + 1) : std::string::npos;
valid = valid && (q2 == q1 + 2);
char encLetter = valid ? (char)toupper((unsigned char)raw[q1 + 1]) : 0;
valid = valid && (encLetter == 'B' || encLetter == 'Q');
size_t end = valid ? raw.find("?=", q2 + 1) : std::string::npos;
valid = valid && (end != std::string::npos);
if (!valid) {
result += raw.substr(start, 2);
pos = start + 2;
lastWasEncoded = false;
continue;
}
std::string charset = raw.substr(start + 2, q1 - start - 2);
std::string encodedText = raw.substr(q2 + 1, end - q2 - 1);
std::string decodedBytes;
if (encLetter == 'B') {
coreutils::Base64 b64;
decodedBytes = b64.decode(coreutils::ZString(encodedText.c_str(), encodedText.length())).str();
} else {
decodedBytes = decodeQEncoding(encodedText);
}
result += convertToUtf8(decodedBytes, charset);
pos = end + 2;
lastWasEncoded = true;
}
return result;
}
void parseHeaderFields(const std::string &headerBlock, std::string &from, std::string &to,
std::string &subject, std::string &date) {
size_t pos = 0;
while (pos < headerBlock.size()) {
size_t nl = headerBlock.find("\r\n", pos);
std::string hline = (nl == std::string::npos) ? headerBlock.substr(pos) : headerBlock.substr(pos, nl - pos);
if (hline.rfind("From:", 0) == 0) from = hline.substr(5);
else if (hline.rfind("To:", 0) == 0) to = hline.substr(3);
else if (hline.rfind("Subject:", 0) == 0) subject = hline.substr(8);
else if (hline.rfind("Date:", 0) == 0) date = hline.substr(5);
if (nl == std::string::npos)
break;
pos = nl + 2;
}
}
// RFC 2822 header unfolding - a continuation line (one starting
// with a space or tab) is really a soft-wrapped part of the
// PREVIOUS field's value, not a new one. Real mail wraps long
// Content-Type lines this way constantly (a multipart boundary
// string alone is often 40+ characters), so without unfolding
// first, a plain per-line scan for "boundary=" would miss it
// whenever it landed on its own continuation line.
std::string unfoldHeaders(const std::string &s) {
std::string out;
size_t i = 0;
while (i < s.size()) {
if (s[i] == '\r' && i + 2 < s.size() && s[i + 1] == '\n' && (s[i + 2] == ' ' || s[i + 2] == '\t')) {
out += ' ';
i += 3;
while (i < s.size() && (s[i] == ' ' || s[i] == '\t'))
++i;
} else {
out += s[i];
++i;
}
}
return out;
}
// Case-insensitive header lookup against an already-unfolded
// header block - Content-Type/Content-Transfer-Encoding/Content-
// Disposition all show up in whatever case the sending client
// happened to use, unlike From/To/Subject/Date above which real
// mail overwhelmingly capitalizes consistently.
std::string getHeaderValueCI(const std::string &unfolded, const std::string &name) {
size_t pos = 0;
while (pos < unfolded.size()) {
size_t nl = unfolded.find("\r\n", pos);
std::string line = (nl == std::string::npos) ? unfolded.substr(pos) : unfolded.substr(pos, nl - pos);
size_t colon = line.find(':');
if (colon != std::string::npos && colon == name.size()) {
bool match = true;
for (size_t i = 0; i < name.size() && match; ++i)
match = tolower((unsigned char)line[i]) == tolower((unsigned char)name[i]);
if (match)
return trimWS(line.substr(colon + 1));
}
if (nl == std::string::npos)
break;
pos = nl + 2;
}
return "";
}
// The bare "type/subtype" portion of a header value like
// "multipart/alternative; boundary=\"...\"" or "text/html;
// charset=UTF-8" - lowercased, since MIME type/subtype names are
// case-insensitive by spec and real senders vary in practice.
std::string getMainValue(const std::string &headerValue) {
size_t semi = headerValue.find(';');
std::string main = (semi == std::string::npos) ? headerValue : headerValue.substr(0, semi);
main = trimWS(main);
std::transform(main.begin(), main.end(), main.begin(), [](unsigned char c) { return std::tolower(c); });
return main;
}
// A parameter off a header value, e.g. boundary= or charset= -
// quoted ("...") or bare, matched case-insensitively by name but
// returned with its original case intact (a charset name or a
// boundary string both matter verbatim).
std::string getParam(const std::string &headerValue, const std::string &paramName) {
std::string lowerVal = headerValue;
std::transform(lowerVal.begin(), lowerVal.end(), lowerVal.begin(), [](unsigned char c) { return std::tolower(c); });
std::string needle = paramName + "=";
size_t pos = lowerVal.find(needle);
if (pos == std::string::npos)
return "";
size_t valStart = pos + needle.size();
if (valStart >= headerValue.size())
return "";
if (headerValue[valStart] == '"') {
size_t endQuote = headerValue.find('"', valStart + 1);
if (endQuote == std::string::npos)
return "";
return headerValue.substr(valStart + 1, endQuote - valStart - 1);
}
size_t end = headerValue.find_first_of("; \t\r\n", valStart);
return (end == std::string::npos) ? headerValue.substr(valStart) : headerValue.substr(valStart, end - valStart);
}
// Body quoted-printable - distinct from decodeQEncoding above
// (RFC 2047's own "=XX plus '_' for space" variant used only
// inside header encoded-words): a message BODY uses plain RFC
// 2045 quoted-printable, where "=\r\n" (or a bare "=\n") is a
// soft line break to be removed entirely, not a literal
// character, and there is no '_'-for-space special case at all.
std::string decodeQuotedPrintableBody(const std::string &in) {
std::string out;
out.reserve(in.size());
for (size_t i = 0; i < in.size(); ++i) {
char c = in[i];
if (c != '=') {
out += c;
continue;
}
if (i + 2 < in.size() && in[i + 1] == '\r' && in[i + 2] == '\n') {
i += 2;
} else if (i + 1 < in.size() && in[i + 1] == '\n') {
i += 1;
} else if (i + 2 < in.size() && isxdigit((unsigned char)in[i + 1]) && isxdigit((unsigned char)in[i + 2])) {
out += (char)strtol(in.substr(i + 1, 2).c_str(), nullptr, 16);
i += 2;
} else {
out += c;
}
}
return out;
}
struct MimeTextPart {
std::string decodedBody;
};
// Walks a MIME part (given as separate header/body blocks - the
// top-level message the first time, one boundary-delimited
// sub-part on every recursive call after that) looking for the
// best text/html and text/plain content to show as a message
// body. Recurses into any multipart/* container uniformly
// (alternative, mixed, related - all handled the same way,
// which correctly falls through nested wrappers like a
// multipart/mixed enclosing a multipart/alternative without
// needing to special-case either), skips anything marked
// Content-Disposition: attachment, and keeps only the FIRST
// text/html and FIRST text/plain part found (document order) -
// deliberately not the most sophisticated possible MIME walker,
// but real mail practically never has more than one of each at
// the "body" level, and this matches the same pragmatic,
// purpose-built philosophy as the rest of this class (see
// __imap.h's own doc comment on readResponseLine()).
void collectBestTextParts(const std::string &headerBlock, const std::string &bodyBlock,
MimeTextPart &htmlOut, bool &haveHtml, MimeTextPart &plainOut, bool &havePlain) {
std::string unfolded = unfoldHeaders(headerBlock);
std::string contentTypeHeader = getHeaderValueCI(unfolded, "Content-Type");
std::string mainType = contentTypeHeader.empty() ? "text/plain" : getMainValue(contentTypeHeader);
if (mainType.rfind("multipart/", 0) == 0) {
std::string boundary = getParam(contentTypeHeader, "boundary");
if (boundary.empty())
return;
std::string delim = "--" + boundary;
size_t pos = bodyBlock.find(delim);
while (pos != std::string::npos) {
size_t partStart = pos + delim.size();
if (partStart + 1 < bodyBlock.size() && bodyBlock[partStart] == '-' && bodyBlock[partStart + 1] == '-')
break;
if (partStart < bodyBlock.size() && bodyBlock[partStart] == '\r')
partStart += 2;
else if (partStart < bodyBlock.size() && bodyBlock[partStart] == '\n')
partStart += 1;
size_t nextPos = bodyBlock.find(delim, partStart);
std::string partRaw = (nextPos == std::string::npos)
? bodyBlock.substr(partStart)
: bodyBlock.substr(partStart, nextPos - partStart);
size_t hEnd = partRaw.find("\r\n\r\n");
std::string partHeader = (hEnd == std::string::npos) ? partRaw : partRaw.substr(0, hEnd);
std::string partBody = (hEnd == std::string::npos) ? "" : partRaw.substr(hEnd + 4);
collectBestTextParts(partHeader, partBody, htmlOut, haveHtml, plainOut, havePlain);
if (nextPos == std::string::npos)
break;
pos = nextPos;
}
return;
}
std::string disposition = getMainValue(getHeaderValueCI(unfolded, "Content-Disposition"));
if (disposition == "attachment")
return;
if (mainType != "text/html" && mainType != "text/plain")
return;
if ((mainType == "text/html" && haveHtml) || (mainType == "text/plain" && havePlain))
return;
std::string transferEncoding = getMainValue(getHeaderValueCI(unfolded, "Content-Transfer-Encoding"));
std::string decoded;
if (transferEncoding == "base64") {
std::string stripped;
stripped.reserve(bodyBlock.size());
for (char c : bodyBlock)
if (!isspace((unsigned char)c))
stripped += c;
coreutils::Base64 b64;
decoded = b64.decode(coreutils::ZString(stripped.c_str(), stripped.length())).str();
} else if (transferEncoding == "quoted-printable") {
decoded = decodeQuotedPrintableBody(bodyBlock);
} else {
decoded = bodyBlock;
}
decoded = convertToUtf8(decoded, getParam(contentTypeHeader, "charset"));
if (mainType == "text/html") {
htmlOut.decodedBody = decoded;
haveHtml = true;
} else {
plainOut.decodedBody = decoded;
havePlain = true;
}
}
}
// Shared by listMessages() and searchMessages() - both issue the
// identical FETCH field list (UID FLAGS RFC822.SIZE
// BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)]), just against a
// different sequence set (1:* vs whatever UIDs a SEARCH turned
// up), so this is the one place that turns those response lines
// into the JSON summary objects the webmail client's message list
// already knows how to render. A private static member (not a
// plain anonymous-namespace function) specifically so it can call
// findUidField() and friends directly - those are private statics
// of this same class.
std::vector<std::string> __imap::parseSummaryLines(const std::vector<std::string> &lines) {
std::vector<std::string> items;
for (auto &line : lines) {
if (line.rfind("* ", 0) != 0 || line.find("FETCH") == std::string::npos)
continue;
uint32_t uid = findUidField(line);
if (uid == 0)
continue;
std::vector<std::string> flags = findFlagsField(line);
long size = findSizeField(line);
std::string headerBlock = findBodyLiteral(line);
std::string from, to, subject, date;
parseHeaderFields(headerBlock, from, to, subject, date);
from = decodeMimeHeader(from);
subject = decodeMimeHeader(subject);
bool seen = std::find(flags.begin(), flags.end(), "\\Seen") != flags.end();
bool flagged = std::find(flags.begin(), flags.end(), "\\Flagged") != flags.end();
std::ostringstream item;
item << "{\"uid\":" << uid
<< ",\"from\":\"" << jsonEscape(trimWS(from)) << "\""
<< ",\"subject\":\"" << jsonEscape(trimWS(subject)) << "\""
<< ",\"date\":\"" << jsonEscape(trimWS(date)) << "\""
<< ",\"size\":" << size
<< ",\"seen\":" << (seen ? "true" : "false")
<< ",\"flagged\":" << (flagged ? "true" : "false")
<< "}";
items.push_back(item.str());
}
return items;
}
__imap::__imap(coreutils::ZString &in, coreutils::MString &parentOut, Global &global, Tag *parent, Tag *local) : Tag(in, parentOut, global, parent, this) {
if (!keywordDefined("config"))
throw coreutils::Exception("config must be specified for imap tag.", __FILE__, __LINE__, 500);
if (!keywordDefined("user"))
throw coreutils::Exception("user must be specified for imap tag.", __FILE__, __LINE__, 401);
sweepIdleImapConnections();
sessionId = resolveKeyword("sessionid");
coreutils::MString userVal = resolveKeyword("user");
// A disconnect="yes" invocation only tears down this user's own
// pooled connection (see the reuse-pooled-connection branch just
// below, and email.jet's /email/disconnect route) - real IMAP
// servers keep pushing protocol state at an open connection for
// as long as it stays open, whether or not this process is still
// reading it. /email/disconnect used to just clear the site
// session's own _imap_user/_imap_password variables and leave
// the real, pooled TCP+TLS socket sitting open and completely
// unread forever (the destructor deliberately never closes it -
// see its own doc comment - by design, for reuse across requests
// on the same worker thread, but nothing ever ended that reuse
// window on an explicit disconnect). BARANTMail eventually tries
// to write something to one of these abandoned sockets and hits
// exactly the "stuck write retry loop" pattern
// [[barantmail-ssl-busyloop-diskfull]]'s own fix already catches
// and shuts down correctly - but repeatedly, once per leaked
// connection, which is what was actually producing the ongoing
// log growth (and quite possibly feeding whatever's triggering
// the separate double-free crashes seen the same day - a shutdown
// path getting exercised this often, on connections nothing else
// in this process still references, is exactly the kind of
// pattern that turns a latent use-after-free into a frequent one).
if (keywordDefined("disconnect") && resolveKeyword("disconnect") == "yes") {
std::string poolKey = sessionId.str() + "|" + userVal.str();
auto pooled = imapPool.find(poolKey);
if (pooled != imapPool.end()) {
closePooledConnection(pooled->second);
imapPool.erase(pooled);
}
return;
}
if (!keywordDefined("password"))
throw coreutils::Exception("password must be specified for imap tag.", __FILE__, __LINE__, 401);
coreutils::MString configPath = resolveKeyword("config");
coreutils::MString passwordVal = resolveKeyword("password");
coreutils::JFile config(configPath);
std::string host = config.getString("host");
long port = (long)config.getNumber("port");
if (port <= 0)
port = 993;
std::string poolKey = sessionId.str() + "|" + userVal.str();
auto pooled = imapPool.find(poolKey);
if (pooled != imapPool.end() && pooled->second.host == host && pooled->second.user == userVal.str()) {
ctx = pooled->second.ctx;
ssl = pooled->second.ssl;
fd = pooled->second.fd;
imapPool.erase(pooled);
bool alive = true;
try {
std::string tag = nextTag();
sendLine(tag + " NOOP");
auto lines = readUntilTagged(tag);
alive = taggedOk(lines, tag);
} catch (...) {
alive = false;
}
if (!alive) {
if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); }
if (fd >= 0) ::close(fd);
if (ctx) SSL_CTX_free(ctx);
ssl = nullptr;
fd = -1;
ctx = nullptr;
recvBuffer.clear();
recvPos = 0;
}
}
if (!ssl) {
struct addrinfo hints {};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo *result = nullptr;
std::string portStr = std::to_string(port);
if (getaddrinfo(host.c_str(), portStr.c_str(), &hints, &result) != 0 || !result)
throw coreutils::Exception("imap: could not resolve " + host, __FILE__, __LINE__, 502);
int newFd = -1;
for (struct addrinfo *rp = result; rp != nullptr; rp = rp->ai_next) {
newFd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (newFd < 0)
continue;
if (::connect(newFd, rp->ai_addr, rp->ai_addrlen) == 0)
break;
::close(newFd);
newFd = -1;
}
freeaddrinfo(result);
if (newFd < 0)
throw coreutils::Exception("imap: could not connect to " + host, __FILE__, __LINE__, 502);
struct timeval timeout { 30, 0 };
setsockopt(newFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(newFd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
SSL_CTX *newCtx = SSL_CTX_new(SSLv23_client_method());
if (!newCtx) {
::close(newFd);
throw coreutils::Exception("imap: SSL_CTX_new failed", __FILE__, __LINE__, 502);
}
SSL *newSsl = SSL_new(newCtx);
SSL_set_fd(newSsl, newFd);
if (SSL_connect(newSsl) != 1) {
SSL_free(newSsl);
SSL_CTX_free(newCtx);
::close(newFd);
throw coreutils::Exception("imap: TLS handshake failed", __FILE__, __LINE__, 502);
}
ctx = newCtx;
ssl = newSsl;
fd = newFd;
// Consume the server's own greeting line ("* OK ...") before
// sending anything.
readResponseLine();
std::string tag = nextTag();
sendLine(tag + " LOGIN " + imapQuote(userVal.str()) + " " + imapQuote(passwordVal.str()));
auto lines = readUntilTagged(tag);
if (!taggedOk(lines, tag)) {
SSL_shutdown(ssl);
SSL_free(ssl);
SSL_CTX_free(ctx);
::close(fd);
ssl = nullptr;
ctx = nullptr;
fd = -1;
throw coreutils::Exception("Invalid mailbox email or password.", __FILE__, __LINE__, 401);
}
}
imapPool[poolKey] = PooledImapConnection{ctx, ssl, fd, host, userVal.str(), time(nullptr)};
global.addImapSession(sessionId, this);
processContainer(container);
}
__imap::~__imap() {
global.removeImapSession(sessionId);
// ssl/fd/ctx deliberately NOT closed here - owned by this worker
// thread's persistent pool now, exactly like __mysql's own
// destructor leaves `mysql` alone. See the constructor's pooling
// logic above.
}
std::string __imap::nextTag() {
return "a" + std::to_string(++tagCounter);
}
void __imap::sendLine(const std::string &line) {
std::string data = line + "\r\n";
size_t sent = 0;
while (sent < data.size()) {
int n = SSL_write(ssl, data.data() + sent, (int)(data.size() - sent));
if (n <= 0)
throw coreutils::Exception("imap: connection lost while writing.", __FILE__, __LINE__, 502);
sent += n;
}
}
char __imap::readByte() {
while (recvPos >= recvBuffer.size()) {
char buf[8192];
int n = SSL_read(ssl, buf, sizeof(buf));
if (n <= 0)
throw coreutils::Exception("imap: connection lost while reading.", __FILE__, __LINE__, 502);
recvBuffer.append(buf, n);
}
return recvBuffer[recvPos++];
}
std::string __imap::readResponseLine() {
std::string line;
char c;
while (true) {
c = readByte();
if (c == '\r')
continue;
if (c == '\n')
break;
line += c;
}
if (!line.empty() && line.back() == '}') {
size_t brace = line.rfind('{');
if (brace != std::string::npos) {
std::string numStr = line.substr(brace + 1, line.size() - brace - 2);
bool allDigits = !numStr.empty() && numStr.find_first_not_of("0123456789") == std::string::npos;
if (allDigits) {
long n = strtol(numStr.c_str(), nullptr, 10);
std::string literalData;
literalData.reserve(n);
for (long i = 0; i < n; ++i)
literalData += readByte();
// The literal is always followed by the rest of the SAME
// logical response line (more items, then the real
// CRLF) - recursing into this same function reads that
// remainder, correctly handling a second literal on the
// same line (e.g. FETCH ... BODY[HEADER...] {n1} ...
// BODY[TEXT] {n2} ...) via a further recursion.
std::string rest = readResponseLine();
recvBuffer.erase(0, recvPos);
recvPos = 0;
return line + "\r\n" + literalData + "\r\n" + rest;
}
}
}
recvBuffer.erase(0, recvPos);
recvPos = 0;
return line;
}
std::vector<std::string> __imap::readUntilTagged(const std::string &tag) {
std::vector<std::string> lines;
std::string prefix = tag + " ";
while (true) {
std::string line = readResponseLine();
lines.push_back(line);
if (line.rfind(prefix, 0) == 0)
break;
}
return lines;
}
bool __imap::selectFolderIfNeeded(const std::string &folder) {
if (selectedFolder == folder)
return true;
std::string tag = nextTag();
sendLine(tag + " SELECT " + folder);
auto lines = readUntilTagged(tag);
if (!taggedOk(lines, tag))
return false;
selectedFolder = folder;
return true;
}
std::string __imap::jsonEscape(const std::string &s) {
std::string out;
out.reserve(s.size() + 8);
for (unsigned char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': continue;
case '\t': out += "\\t"; break;
default:
if (c < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
} else {
out += (char)c;
}
}
}
return out;
}
uint32_t __imap::findUidField(const std::string &fetchLine) {
size_t pos = fetchLine.find("UID ");
if (pos == std::string::npos)
return 0;
return (uint32_t)strtoul(fetchLine.c_str() + pos + 4, nullptr, 10);
}
long __imap::findSizeField(const std::string &fetchLine) {
size_t pos = fetchLine.find("RFC822.SIZE ");
if (pos == std::string::npos)
return 0;
return strtol(fetchLine.c_str() + pos + 12, nullptr, 10);
}
std::vector<std::string> __imap::findFlagsField(const std::string &fetchLine) {
std::vector<std::string> flags;
size_t pos = fetchLine.find("FLAGS (");
if (pos == std::string::npos)
return flags;
size_t start = pos + 7;
size_t end = fetchLine.find(')', start);
if (end == std::string::npos)
return flags;
std::istringstream ss(fetchLine.substr(start, end - start));
std::string tok;
while (ss >> tok)
flags.push_back(tok);
return flags;
}
std::string __imap::findBodyLiteral(const std::string &fetchLine) {
size_t bodyPos = fetchLine.find("BODY[");
if (bodyPos == std::string::npos)
return "";
size_t bracketEnd = fetchLine.find(']', bodyPos);
if (bracketEnd == std::string::npos)
return "";
size_t braceStart = fetchLine.find('{', bracketEnd);
if (braceStart == std::string::npos)
return "";
size_t braceEnd = fetchLine.find('}', braceStart);
if (braceEnd == std::string::npos)
return "";
return extractLiteralAt(fetchLine, braceEnd);
}
coreutils::MString __imap::listMessages(const std::string &folder, int limit, int offset) {
if (!selectFolderIfNeeded(folder))
throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502);
std::string tag = nextTag();
sendLine(tag + " FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])");
auto lines = readUntilTagged(tag);
if (!taggedOk(lines, tag))
throw coreutils::Exception("imap: FETCH failed", __FILE__, __LINE__, 502);
std::vector<std::string> items = parseSummaryLines(lines);
int total = (int)items.size();
// items is UID-ascending (oldest-first) - UIDs are assigned in
// strictly ascending delivery order (see MailFolder.h, BARANTMail,
// built earlier this session), so the tail is the newest mail.
// `offset` counts back from the newest message (0 = the very
// newest), so the page is the `limit`-wide slice ending `offset`
// messages from the end.
int end = total - offset;
if (end < 0)
end = 0;
int start = end - limit;
if (start < 0)
start = 0;
std::vector<std::string> page(items.begin() + start, items.begin() + end);
std::reverse(page.begin(), page.end());
std::string json = "{\"total\":" + std::to_string(total) + ",\"messages\":[";
for (size_t i = 0; i < page.size(); ++i) {
if (i)
json += ",";
json += page[i];
}
json += "]}";
return coreutils::MString(json);
}
// BARANTMail's own __IMAP_SEARCH.cpp supports FROM/SUBJECT/BODY and
// a chained OR, which is exactly enough to build "one query box
// matching sender, subject, or body content" without needing to run
// three separate searches and merge results by hand - OR takes
// exactly two search keys per IMAP4rev1 grammar, so a 3-way match
// is the same OR nested once: OR FROM OR SUBJECT BODY reads as
// FROM-matches OR (SUBJECT-matches OR BODY-matches).
coreutils::MString __imap::searchMessages(const std::string &folder, const std::string &query) {
if (!selectFolderIfNeeded(folder))
throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502);
std::string escaped;
escaped.reserve(query.size());
for (char c : query) {
if (c == '"' || c == '\\')
escaped += '\\';
escaped += c;
}
std::string searchTag = nextTag();
sendLine(searchTag + " UID SEARCH OR FROM \"" + escaped + "\" OR SUBJECT \"" + escaped + "\" BODY \"" + escaped + "\"");
auto searchLines = readUntilTagged(searchTag);
if (!taggedOk(searchLines, searchTag))
throw coreutils::Exception("imap: SEARCH failed", __FILE__, __LINE__, 502);
std::vector<std::string> uidStrings;
for (auto &line : searchLines) {
if (line.rfind("* SEARCH", 0) != 0)
continue;
std::istringstream iss(line.substr(8));
std::string tok;
while (iss >> tok)
uidStrings.push_back(tok);
}
if (uidStrings.empty())
return coreutils::MString("[]");
std::string seqSet;
for (size_t i = 0; i < uidStrings.size(); ++i) {
if (i)
seqSet += ",";
seqSet += uidStrings[i];
}
std::string fetchTag = nextTag();
sendLine(fetchTag + " UID FETCH " + seqSet + " (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])");
auto fetchLines = readUntilTagged(fetchTag);
if (!taggedOk(fetchLines, fetchTag))
throw coreutils::Exception("imap: FETCH failed", __FILE__, __LINE__, 502);
std::vector<std::string> items = parseSummaryLines(fetchLines);
std::reverse(items.begin(), items.end());
std::string json = "[";
for (size_t i = 0; i < items.size(); ++i) {
if (i)
json += ",";
json += items[i];
}
json += "]";
return coreutils::MString(json);
}
coreutils::MString __imap::fetchMessage(const std::string &folder, uint32_t uid) {
if (!selectFolderIfNeeded(folder))
throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502);
// A single BODY[] pulls the whole message (headers + every MIME
// part) in one literal - needed now to actually see Content-Type/
// boundary/Content-Transfer-Encoding, none of which the old
// HEADER.FIELDS-subset + BODY[TEXT] split ever exposed, so the
// raw MIME envelope (boundaries, base64/quoted-printable
// encoded parts, nested multipart wrappers) was always what
// showed up as the "body" rather than the actual message
// content. Still marks \Seen exactly like before (any non-.PEEK
// BODY fetch does).
std::string tag = nextTag();
sendLine(tag + " UID FETCH " + std::to_string(uid) + " (FLAGS BODY[])");
auto lines = readUntilTagged(tag);
if (!taggedOk(lines, tag))
throw coreutils::Exception("imap: FETCH failed", __FILE__, __LINE__, 502);
std::string rawMessage;
bool found = false;
for (auto &line : lines) {
if (line.rfind("* ", 0) != 0 || line.find("FETCH") == std::string::npos)
continue;
found = true;
rawMessage = findBodyLiteral(line);
}
if (!found)
throw coreutils::Exception("Message not found.", __FILE__, __LINE__, 404);
size_t headerEnd = rawMessage.find("\r\n\r\n");
std::string headerBlock = (headerEnd == std::string::npos) ? rawMessage : rawMessage.substr(0, headerEnd);
std::string bodyBlock = (headerEnd == std::string::npos) ? "" : rawMessage.substr(headerEnd + 4);
std::string from, to, subject, date;
parseHeaderFields(headerBlock, from, to, subject, date);
from = decodeMimeHeader(from);
to = decodeMimeHeader(to);
subject = decodeMimeHeader(subject);
MimeTextPart htmlPart, plainPart;
bool haveHtml = false, havePlain = false;
collectBestTextParts(headerBlock, bodyBlock, htmlPart, haveHtml, plainPart, havePlain);
std::string bodyText;
bool isHtml = false;
if (haveHtml) {
bodyText = htmlPart.decodedBody;
isHtml = true;
} else if (havePlain) {
bodyText = plainPart.decodedBody;
} else {
// Neither a text/html nor a text/plain part was found anywhere
// (an unusual message - e.g. one made up entirely of
// attachments) - fall back to the raw body rather than
// showing nothing at all.
bodyText = bodyBlock;
}
std::ostringstream json;
json << "{\"uid\":" << uid
<< ",\"from\":\"" << jsonEscape(trimWS(from)) << "\""
<< ",\"to\":\"" << jsonEscape(trimWS(to)) << "\""
<< ",\"subject\":\"" << jsonEscape(trimWS(subject)) << "\""
<< ",\"date\":\"" << jsonEscape(trimWS(date)) << "\""
<< ",\"body\":\"" << jsonEscape(bodyText) << "\""
<< ",\"isHtml\":" << (isHtml ? "true" : "false")
<< "}";
return coreutils::MString(json.str());
}
coreutils::MString __imap::setFlag(const std::string &folder, uint32_t uid, const std::string &flag, bool add) {
if (!selectFolderIfNeeded(folder))
throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502);
std::string tag = nextTag();
sendLine(tag + " UID STORE " + std::to_string(uid) + " " + (add ? "+FLAGS" : "-FLAGS") + " (" + flag + ")");
auto lines = readUntilTagged(tag);
bool ok = taggedOk(lines, tag);
return coreutils::MString(std::string("{\"ok\":") + (ok ? "true" : "false") + "}");
}
coreutils::MString __imap::deleteMessage(const std::string &folder, uint32_t uid) {
if (!selectFolderIfNeeded(folder))
throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502);
std::string tag = nextTag();
sendLine(tag + " UID STORE " + std::to_string(uid) + " +FLAGS (\\Deleted)");
auto lines = readUntilTagged(tag);
bool ok1 = taggedOk(lines, tag);
std::string tag2 = nextTag();
sendLine(tag2 + " EXPUNGE");
auto lines2 = readUntilTagged(tag2);
bool ok2 = taggedOk(lines2, tag2);
bool ok = ok1 && ok2;
return coreutils::MString(std::string("{\"ok\":") + (ok ? "true" : "false") + "}");
}
// BARANTMail (see __IMAP_COPY.cpp) implements COPY but not the
// RFC 6851 MOVE extension - not surprising, since the whole point
// of that extension was standardizing what every real-world client
// already did by hand: COPY into the destination, then \Deleted +
// EXPUNGE from the source. Same two IMAP round trips deleteMessage()
// above already does, with a COPY in front.
coreutils::MString __imap::moveMessage(const std::string &folder, uint32_t uid, const std::string &destFolder) {
if (!selectFolderIfNeeded(folder))
throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502);
std::string copyTag = nextTag();
sendLine(copyTag + " UID COPY " + std::to_string(uid) + " " + destFolder);
auto copyLines = readUntilTagged(copyTag);
if (!taggedOk(copyLines, copyTag))
return coreutils::MString("{\"ok\":false}");
std::string tag = nextTag();
sendLine(tag + " UID STORE " + std::to_string(uid) + " +FLAGS (\\Deleted)");
auto lines = readUntilTagged(tag);
bool ok1 = taggedOk(lines, tag);
std::string tag2 = nextTag();
sendLine(tag2 + " EXPUNGE");
auto lines2 = readUntilTagged(tag2);
bool ok2 = taggedOk(lines2, tag2);
bool ok = ok1 && ok2;
return coreutils::MString(std::string("{\"ok\":") + (ok ? "true" : "false") + "}");
}
// Message/unseen counts for the site's fixed 3-folder set (see
// email.menu - Inbox/Spam/Trash are the only folders this webmail
// client ever shows). Uses IMAP STATUS rather than a real FETCH,
// since it's just two integers per folder, not the full message
// data listMessages() pulls - BARANTMail's own __IMAP_STATUS.cpp
// doesn't require the folder to be unselected first, so this works
// regardless of whichever folder is currently SELECTed for other
// operations on this same pooled connection.
coreutils::MString __imap::folderCounts() {
static const char *folders[] = {"Inbox", "Spam", "Trash"};
std::ostringstream json;
json << "{";
bool first = true;
for (const char *folder : folders) {
std::string tag = nextTag();
sendLine(tag + " STATUS " + folder + " (MESSAGES UNSEEN)");
auto lines = readUntilTagged(tag);
int messages = 0, unseen = 0;
if (taggedOk(lines, tag)) {
for (auto &line : lines) {
if (line.rfind("* STATUS", 0) != 0)
continue;
size_t mPos = line.find("MESSAGES ");
if (mPos != std::string::npos)
messages = atoi(line.c_str() + mPos + 9);
size_t uPos = line.find("UNSEEN ");
if (uPos != std::string::npos)
unseen = atoi(line.c_str() + uPos + 7);
}
}
if (!first)
json << ",";
first = false;
json << "\"" << folder << "\":{\"messages\":" << messages << ",\"unseen\":" << unseen << "}";
}
json << "}";
return coreutils::MString(json.str());
}
}