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>
1134 lines
47 KiB
C++
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 ¶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<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());
|
|
}
|
|
|
|
}
|