#include "__imap.h" #include "Base64.h" #include "Exception.h" #include "JFile.h" #include #include #include #include #include #include #include #include #include #include #include #include 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 // 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 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 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 &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(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 ¶mName) { 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 __imap::parseSummaryLines(const std::vector &lines) { std::vector 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 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); // host/port keywords override the config file's own values when // present - lets a caller reuse one config file (still needed for // caCertificate/smtpPort) across many accounts that each live on // a different mail server, rather than requiring a separate config // file per server. Every existing caller omits these keywords, so // behavior is unchanged for them - config's own host/port is still // the only thing read in that case. std::string host = keywordDefined("host") ? resolveKeyword("host").str() : config.getString("host"); long port = keywordDefined("port") ? strtol(resolveKeyword("port").str().c_str(), nullptr, 10) : (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 __imap::readUntilTagged(const std::string &tag) { std::vector 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 __imap::findFlagsField(const std::string &fetchLine) { std::vector 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 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 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 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 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") + "}"); } coreutils::MString __imap::emptyFolder(const std::string &folder) { if (!selectFolderIfNeeded(folder)) throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502); std::string tag = nextTag(); sendLine(tag + " UID STORE 1:* +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()); } }