Files
Brad ArantandClaude Sonnet 5 b8f7793773 Add global music player, invoicing/payment system, account subpages, mail account admin, and the qualityiron.vegas Jet 2.0 port
Jet interpreter (Tag.cpp, __call.cpp, __imap*.cpp/h, __smtpsend.cpp/h,
__imapemptyfolder.cpp/h - new): assorted parser/tag fixes found across
this work - a stray literal-tag-in-comment and backslash-quote parser
corruption class, a dangling-pointer fix, a __call.cpp fd leak, IMAP
folder-management tags, multi-account/per-host IMAP support, and a
smtpsend Message-ID fix.

barant.com site (jetc/barant-site/):
- A site-wide persistent music player (header band, playlist/queue,
  stereo FFT EQ, session-persisted state) fed by new library/
  ianslibrary/music song-list subpages.
- /account split into info/phones/invoices subpages; a full invoicing
  and Square payment system (create/edit/copy/pay/email invoice);
  admin invoice management and mail-account management subpages.
- /services split into overview/hosting/siptrunking/documents
  subpages, retiring the old standalone pages.
- SIPPHONE.tag: a Jet 2.0 WebRTC softphone tag.
- main.css/spa.js/email.js: the SPA loading indicator, the music
  player band styling, and assorted fixes accumulated alongside this
  work.

qualityiron-site/: the qualityiron.vegas job/contractor/customer/
document management app, ported from its original Jet 1.4 codebase to
Jet 2.0 as barant.coms APIServers second multi-tenant site.

wcwc/wcwc.jet: the WCWC site Jet source (wcwc.jtc build output not
included).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 17:42:01 +00:00

1134 lines
47 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);
// 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<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") + "}");
}
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());
}
}