MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - service - ImapService.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 94.6 % 1336 1264
Test Date: 2026-06-21 21:10:19 Functions: 99.1 % 106 105
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 56.0 % 3291 1842

             Branch data     Line data    Source code
       1                 :             : #include "ImapService.h"
       2                 :             : #include "ImapResponseParser.h"
       3                 :             : #include "util/SecureUtil.h"
       4                 :             : 
       5                 :             : #include <QLocale>
       6                 :             : #include <QLoggingCategory>
       7                 :             : #include <QMetaEnum>
       8                 :             : #include <QRegularExpression>
       9                 :             : #include <QSet>
      10                 :             : #include <QSocketNotifier>
      11                 :             : 
      12                 :             : // T-720: TCP keepalive platform headers.
      13                 :             : #ifdef Q_OS_LINUX
      14                 :             : #include <sys/socket.h>
      15                 :             : #include <netinet/in.h>
      16                 :             : #include <netinet/tcp.h>
      17                 :             : #endif
      18                 :             : #ifdef Q_OS_WIN
      19                 :             : #include <winsock2.h>
      20                 :             : #include <ws2tcpip.h>
      21                 :             : #include <mstcpip.h>
      22                 :             : #endif
      23                 :             : #ifdef Q_OS_MACOS
      24                 :             : #include <sys/socket.h>
      25                 :             : #include <netinet/in.h>
      26                 :             : #include <netinet/tcp.h>
      27                 :             : #endif
      28                 :             : 
      29   [ +  +  +  -  :        5481 : Q_LOGGING_CATEGORY(lcImap, "mailjd.imap")
             +  -  -  - ]
      30   [ +  +  +  -  :         440 : Q_LOGGING_CATEGORY(lcImapTiming, "mailjd.imap.timing")
             +  -  -  - ]
      31                 :             : 
      32                 :         265 : ImapService::ImapService(QObject *parent)
      33   [ +  -  -  +  :         265 :     : QObject(parent), m_socket(new QSslSocket(this)),
                   -  - ]
      34   [ +  -  +  -  :         265 :       m_timeoutTimer(new QTimer(this)),
             -  +  -  - ]
      35   [ +  -  +  -  :         265 :       m_commandTimeoutTimer(new QTimer(this)),
             -  +  -  - ]
      36   [ +  -  +  -  :         265 :       m_idleRenewTimer(new QTimer(this)),
             -  +  -  - ]
      37   [ +  -  +  -  :         265 :       m_livenessProbeWatchdog(new QTimer(this)),
             -  +  -  - ]
      38   [ +  -  +  -  :         530 :       m_idleRenewWatchdog(new QTimer(this)) {
          +  -  -  +  +  
                -  -  - ]
      39         [ +  - ]:         265 :   m_timeoutTimer->setSingleShot(true);
      40         [ +  - ]:         265 :   m_commandTimeoutTimer->setSingleShot(true);
      41         [ +  - ]:         265 :   m_idleRenewTimer->setSingleShot(true);
      42         [ +  - ]:         265 :   m_livenessProbeWatchdog->setSingleShot(true);
      43         [ +  - ]:         265 :   m_idleRenewWatchdog->setSingleShot(true);
      44                 :             : 
      45                 :             :   // T-720/T-72.1: Enable SO_KEEPALIVE at the Qt level. Native interval
      46                 :             :   // tuning is applied in tuneKeepAlive() once the socket descriptor is
      47                 :             :   // valid (onConnected()/onEncrypted()). assertable on an unconnected
      48                 :             :   // socket per the sprint plan.
      49         [ +  - ]:         265 :   m_socket->setSocketOption(QAbstractSocket::KeepAliveOption, 1);
      50                 :             : 
      51         [ +  - ]:         265 :   connect(m_socket, &QSslSocket::connected, this, &ImapService::onConnected);
      52         [ +  - ]:         265 :   connect(m_socket, &QSslSocket::encrypted, this, &ImapService::onEncrypted);
      53         [ +  - ]:         265 :   connect(m_socket, &QSslSocket::readyRead, this, &ImapService::onReadyRead);
      54                 :         265 :   connect(m_socket, &QAbstractSocket::errorOccurred, this,
      55         [ +  - ]:         265 :           &ImapService::onSocketError);
      56                 :             :   // Bug 33: Log and forward SSL certificate errors
      57                 :         265 :   connect(m_socket, &QSslSocket::sslErrors, this,
      58         [ +  - ]:         265 :           [this](const QList<QSslError> &errors) {
      59         [ +  + ]:          18 :             for (const auto &e : errors)
      60   [ +  -  +  -  :          18 :               qCWarning(lcImap) << "SSL error:" << e.errorString();
          +  -  +  -  +  
                -  +  + ]
      61                 :             :             // E2E testing: GreenMail uses a self-signed certificate.
      62                 :             :             // Qt rejects self-signed certs even if they're in the system
      63                 :             :             // trust store. Allow bypassing SSL errors via environment variable.
      64                 :             :             // T-607/SEC-03: SSL error bypass is now compile-time only.
      65                 :             :             // Only available when built with -DBUILD_E2E_TESTS=ON
      66                 :             :             // (CMake sets the MAILJD_E2E_TESTING define).
      67                 :             : #ifdef MAILJD_E2E_TESTING
      68   [ +  -  +  -  :          18 :             qCWarning(lcImap) << "Ignoring SSL errors (E2E test build)";
             +  -  +  + ]
      69                 :           9 :             m_socket->ignoreSslErrors();
      70                 :             : #endif
      71                 :           9 :           });
      72         [ +  - ]:         265 :   connect(m_timeoutTimer, &QTimer::timeout, this, &ImapService::onTimeout);
      73                 :         265 :   connect(m_commandTimeoutTimer, &QTimer::timeout, this,
      74         [ +  - ]:         265 :           &ImapService::onCommandTimeout);
      75         [ +  - ]:         265 :   connect(m_idleRenewTimer, &QTimer::timeout, this, &ImapService::onIdleRenew);
      76                 :             :   // T-720: Watchdog handlers — both end in failConnection() so a probe
      77                 :             :   // or IDLE renew that the server never ACKs cannot stall the monitor.
      78                 :         265 :   connect(m_livenessProbeWatchdog, &QTimer::timeout, this,
      79         [ +  - ]:         265 :           &ImapService::onLivenessProbeTimeout);
      80                 :         265 :   connect(m_idleRenewWatchdog, &QTimer::timeout, this,
      81         [ +  - ]:         265 :           &ImapService::onIdleRenewWatchdogTimeout);
      82                 :         265 : }
      83                 :             : 
      84                 :         306 : ImapService::~ImapService() {
      85                 :             :   // Block signals to prevent stateChanged emission during destruction.
      86                 :             :   // Without this, disconnect() → setState(Disconnected) → emits stateChanged,
      87                 :             :   // which can invoke connected lambdas on already-destroyed parent objects
      88                 :             :   // (e.g. MainWindow members) causing use-after-free crashes.
      89                 :         203 :   blockSignals(true);
      90                 :         203 :   disconnect();
      91                 :         306 : }
      92                 :             : 
      93                 :         149 : void ImapService::connectToServer(const ImapConfig &config) {
      94   [ +  +  +  + ]:         149 :   if (m_state != State::Disconnected && m_state != State::Error) {
      95   [ +  -  +  -  :           4 :     qCWarning(lcImap) << "Already connected or connecting";
             +  -  +  + ]
      96                 :           2 :     return;
      97                 :             :   }
      98                 :             : 
      99                 :         147 :   m_config = config;
     100                 :         147 :   m_keepAliveTuned = false;
     101                 :         147 :   m_tagCounter = 0;
     102                 :         147 :   m_readBuffer.clear();
     103                 :         147 :   m_pendingCommands.clear();
     104                 :             :   // Clear any queued work left over from a previous (failed) session so a
     105                 :             :   // reconnect from Error does not replay stale commands.
     106                 :         147 :   m_serializedCommands.clear();
     107                 :         147 :   m_deferredCommands.clear();
     108                 :         147 :   m_commandTimers.clear();
     109                 :         147 :   m_pendingFolders.clear();
     110                 :         147 :   m_pendingHeaders.clear();
     111                 :         147 :   m_capabilities.clear();
     112                 :         147 :   m_selectedFolder.clear();
     113                 :         147 :   m_selectedMessageCount = 0;
     114                 :         147 :   m_selectedUidValidity = 0;
     115                 :         147 :   m_isIdling = false;
     116                 :         147 :   m_idleTag.clear();
     117                 :         147 :   m_isNotifying = false;
     118                 :         147 :   m_notifyTag.clear();
     119                 :             : 
     120                 :         147 :   setState(State::Connecting);
     121                 :         147 :   m_timeoutTimer->start(TIMEOUT_MS);
     122                 :             : 
     123         [ +  + ]:         147 :   if (config.security == "ssl") {
     124   [ +  -  +  -  :         292 :     qCInfo(lcImap) << "Connecting via SSL to" << config.host << ":"
          +  -  +  -  +  
                -  +  + ]
     125         [ +  - ]:         146 :                    << config.port;
     126         [ +  - ]:         146 :     m_socket->connectToHostEncrypted(config.host, config.port);
     127                 :             :   } else {
     128   [ +  -  +  -  :           2 :     qCInfo(lcImap) << "Connecting via STARTTLS to" << config.host << ":"
          +  -  +  -  +  
                -  +  + ]
     129         [ +  - ]:           1 :                    << config.port;
     130         [ +  - ]:           1 :     m_socket->connectToHost(config.host, config.port);
     131                 :             :   }
     132                 :             : }
     133                 :             : 
     134                 :         226 : void ImapService::disconnect() {
     135                 :         226 :   m_timeoutTimer->stop();
     136                 :         226 :   m_commandTimeoutTimer->stop();
     137                 :             :   // T-720: Stop the probe/IDLE-renew watchdogs on explicit teardown so
     138                 :             :   // they cannot fire against a torn-down socket.
     139                 :         226 :   m_livenessProbeWatchdog->stop();
     140                 :         226 :   m_idleRenewWatchdog->stop();
     141                 :         226 :   m_probeTag.clear();
     142         [ +  + ]:         226 :   if (m_socket->state() != QAbstractSocket::UnconnectedState) {
     143                 :             :     // Try to send LOGOUT gracefully
     144   [ +  +  +  + ]:          37 :     if (m_state == State::Authenticated || m_state == State::Selected) {
     145         [ +  - ]:           7 :       auto tag = nextTag();
     146   [ +  -  +  -  :           7 :       m_socket->write((tag + " LOGOUT\r\n").toUtf8());
                   +  - ]
     147         [ +  - ]:           7 :       m_socket->flush();
     148                 :           7 :     }
     149                 :          37 :     m_socket->disconnectFromHost();
     150                 :             :   }
     151                 :             :   // T-405/Bug 16: Reset all state variables to prevent corruption on reconnect
     152                 :         226 :   m_isIdling = false;
     153                 :         226 :   m_literalBytesRemaining = 0;
     154                 :         226 :   m_literalData.clear();
     155                 :         226 :   m_literalLine.clear();
     156                 :         226 :   m_isBodyLiteral = false;
     157                 :         226 :   m_bodyLiteralUid = 0;
     158                 :         226 :   m_pendingCommands.clear();
     159                 :         226 :   m_commandTimers.clear();
     160                 :         226 :   m_deferredCommands.clear();
     161                 :         226 :   m_serializedCommands.clear();
     162                 :         226 :   setState(State::Disconnected);
     163                 :         226 : }
     164                 :             : 
     165                 :          20 : void ImapService::listFolders() {
     166   [ +  +  +  + ]:          20 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     167   [ +  -  +  -  :          22 :     qCWarning(lcImap) << "Cannot list folders: not authenticated";
             +  -  +  + ]
     168                 :          11 :     return;
     169                 :             :   }
     170         [ +  + ]:           9 :   if (hasStatefulCommandInFlight()) {
     171         [ +  - ]:           2 :     enqueueSerializedCommand([this]() { listFolders(); });
     172                 :           1 :     return;
     173                 :             :   }
     174                 :             : 
     175                 :           8 :   m_pendingFolders.clear();
     176   [ +  -  +  -  :           8 :   sendCommand("LIST", R"(LIST "" "*")");
                   +  - ]
     177                 :             : }
     178                 :             : 
     179                 :         102 : void ImapService::selectFolder(const QString &folderPath) {
     180   [ +  +  +  + ]:         102 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     181   [ +  -  +  -  :          24 :     qCWarning(lcImap) << "Cannot select folder: not authenticated";
             +  -  +  + ]
     182                 :          12 :     return;
     183                 :             :   }
     184         [ +  + ]:          90 :   if (hasStatefulCommandInFlight()) {
     185   [ +  -  +  - ]:          30 :     enqueueSerializedCommand([this, folderPath]() { selectFolder(folderPath); });
     186                 :          15 :     return;
     187                 :             :   }
     188                 :             : 
     189                 :          75 :   m_pendingSelectFolder = folderPath; // Bug 34: defer until SELECT OK
     190                 :          75 :   m_selectedMessageCount = 0;
     191                 :          75 :   m_selectedUidValidity = 0;
     192   [ +  -  +  -  :         150 :   sendCommand("SELECT", QString("SELECT %1").arg(quoteImapString(folderPath)));
          +  -  +  -  +  
                      - ]
     193                 :             : }
     194                 :             : 
     195                 :          15 : void ImapService::fetchHeaders(qint64 uidFrom) {
     196         [ +  + ]:          15 :   if (m_state != State::Selected) {
     197   [ +  -  +  -  :          10 :     qCWarning(lcImap) << "Cannot fetch: no folder selected";
             +  -  +  + ]
     198                 :           5 :     return;
     199                 :             :   }
     200         [ +  + ]:          10 :   if (hasStatefulCommandInFlight()) {
     201         [ +  - ]:           9 :     enqueueSerializedCommand([this, uidFrom]() { fetchHeaders(uidFrom); });
     202                 :           5 :     return;
     203                 :             :   }
     204                 :             : 
     205                 :           5 :   m_pendingHeaders.clear();
     206   [ +  -  +  - ]:           5 :   sendCommand(
     207                 :             :       "FETCH_HEADERS",
     208                 :           0 :       QString("UID FETCH %1:* (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE "
     209   [ +  -  +  - ]:          15 :               "BODY.PEEK[HEADER.FIELDS (References X-Spam X-Spam-Status X-Spam-Flag)])").arg(uidFrom));
     210                 :             : }
     211                 :             : 
     212                 :          22 : void ImapService::fetchBody(qint64 uid) {
     213                 :          22 :   fetchBody(uid, -1);
     214                 :          22 : }
     215                 :             : 
     216                 :          38 : void ImapService::fetchBody(qint64 uid, qint64 maxBytes) {
     217         [ +  + ]:          38 :   if (m_state != State::Selected) {
     218   [ +  -  +  -  :           4 :     qCWarning(lcImap) << "Cannot fetch body: no folder selected";
             +  -  +  + ]
     219                 :           2 :     return;
     220                 :             :   }
     221         [ +  + ]:          36 :   if (hasStatefulCommandInFlight()) {
     222   [ +  -  +  - ]:          28 :     enqueueSerializedCommand([this, uid, maxBytes]() { fetchBody(uid, maxBytes); });
     223                 :          15 :     return;
     224                 :             :   }
     225                 :             : 
     226   [ +  -  +  -  :          21 :   sendCommand("FETCH_BODY", buildFetchBodyCommand(uid, maxBytes));
                   +  - ]
     227                 :             : }
     228                 :             : 
     229                 :             : // T-205: Pipeline SELECT + FETCH BODY — sends both commands back-to-back
     230                 :             : // so the body connection doesn't need to wait for SELECT OK before fetching.
     231                 :          10 : void ImapService::selectAndFetchBody(const QString &folderPath, qint64 uid) {
     232   [ +  +  +  + ]:          10 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     233   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Cannot selectAndFetchBody: not authenticated";
             +  -  +  + ]
     234                 :           1 :     return;
     235                 :             :   }
     236         [ +  + ]:           9 :   if (hasStatefulCommandInFlight()) {
     237         [ +  - ]:           1 :     enqueueSerializedCommand(
     238   [ +  -  -  - ]:           3 :         [this, folderPath, uid]() { selectAndFetchBody(folderPath, uid); });
     239                 :           1 :     return;
     240                 :             :   }
     241                 :             : 
     242                 :           8 :   m_pendingSelectFolder = folderPath; // Bug 34: defer until SELECT OK
     243                 :           8 :   m_selectedMessageCount = 0;
     244                 :           8 :   m_selectedUidValidity = 0;
     245                 :           8 :   m_selectedHighestModseq = 0;
     246                 :             : 
     247                 :             :   // Send both commands back-to-back (pipelining)
     248   [ +  -  +  -  :          16 :   sendCommand("SELECT", QString("SELECT %1").arg(quoteImapString(folderPath)));
          +  -  +  -  +  
                      - ]
     249   [ +  -  +  -  :          16 :   sendCommand("FETCH_BODY", QString("UID FETCH %1 (UID BODY.PEEK[])").arg(uid));
             +  -  +  - ]
     250                 :             : 
     251   [ +  -  +  -  :          16 :   qCInfo(lcImap) << "T-205: Pipelined SELECT + FETCH_BODY for" << folderPath
          +  -  +  -  +  
                      + ]
     252   [ +  -  +  - ]:           8 :                  << "UID" << uid;
     253                 :             : }
     254                 :             : 
     255                 :          34 : void ImapService::markSeen(qint64 uid) {
     256         [ +  - ]:          34 :   storeFlag(uid, QStringLiteral("\\Seen"), true);
     257                 :          34 : }
     258                 :             : 
     259                 :             : // T-200: Mark ALL messages in the currently selected folder as seen
     260                 :           5 : void ImapService::markAllSeen() {
     261         [ +  + ]:           5 :   if (m_state != State::Selected) {
     262   [ +  -  +  -  :           4 :     qCWarning(lcImap) << "Cannot markAllSeen: no folder selected";
             +  -  +  + ]
     263                 :           2 :     return;
     264                 :             :   }
     265         [ +  + ]:           3 :   if (hasStatefulCommandInFlight()) {
     266         [ +  - ]:           2 :     enqueueSerializedCommand([this]() { markAllSeen(); });
     267                 :           1 :     return;
     268                 :             :   }
     269   [ +  -  +  - ]:           2 :   sendCommand("STORE",
     270                 :           4 :               QStringLiteral("UID STORE 1:* +FLAGS (\\Seen)"));
     271                 :             : }
     272                 :             : 
     273                 :           9 : void ImapService::markUnseen(qint64 uid) {
     274         [ +  - ]:           9 :   storeFlag(uid, QStringLiteral("\\Seen"), false);
     275                 :           9 : }
     276                 :             : 
     277                 :         102 : void ImapService::storeFlag(qint64 uid, const QString &flag, bool add) {
     278         [ +  + ]:         102 :   if (m_state != State::Selected) {
     279   [ +  -  +  -  :         110 :     qCWarning(lcImap) << "Cannot store flag: no folder selected, state:"
             +  -  +  + ]
     280   [ +  -  +  -  :          55 :                       << static_cast<int>(m_state) << "UID:" << uid
                   +  - ]
     281   [ +  -  +  -  :          55 :                       << "flag:" << flag << "add:" << add;
             +  -  +  - ]
     282                 :          62 :     return;
     283                 :             :   }
     284   [ +  -  +  + ]:          47 :   if (hasStatefulCommandInFlight()) {
     285   [ +  -  +  -  :           7 :     enqueueSerializedCommand([this, uid, flag, add]() {
                   -  - ]
     286                 :           1 :       storeFlag(uid, flag, add);
     287                 :           1 :     });
     288                 :           7 :     return;
     289                 :             :   }
     290                 :             : 
     291                 :             :   // T-609/SEC-07: Sanitize flag to prevent IMAP command injection via CRLF
     292                 :          40 :   QString safeFlag = flag;
     293         [ +  - ]:          40 :   safeFlag.remove('\r');
     294         [ +  - ]:          40 :   safeFlag.remove('\n');
     295         [ +  - ]:          40 :   safeFlag.remove(QChar(0));
     296                 :             : 
     297   [ +  +  +  +  :          80 :   QString op = add ? QStringLiteral("+FLAGS") : QStringLiteral("-FLAGS");
                   +  + ]
     298   [ +  -  +  - ]:          40 :   sendCommand("STORE",
     299   [ +  -  +  -  :         200 :               QString("UID STORE %1 %2 (%3)").arg(uid).arg(op).arg(safeFlag));
             +  -  +  - ]
     300                 :          40 : }
     301                 :             : 
     302                 :           3 : void ImapService::moveMessage(qint64 uid, const QString &targetFolder) {
     303   [ +  -  +  - ]:           3 :   moveMessages({uid}, targetFolder);
     304                 :           3 : }
     305                 :             : 
     306                 :          23 : void ImapService::moveMessages(const QList<qint64> &uids,
     307                 :             :                                const QString &targetFolder) {
     308         [ +  + ]:          23 :   if (m_state != State::Selected) {
     309   [ +  -  +  -  :          28 :     qCWarning(lcImap) << "Cannot move messages: no folder selected";
             +  -  +  + ]
     310         [ +  - ]:          14 :     emit moveError(QStringLiteral("No folder selected"));
     311                 :          17 :     return;
     312                 :             :   }
     313   [ +  -  +  + ]:           9 :   if (hasStatefulCommandInFlight()) {
     314   [ +  -  +  -  :           3 :     enqueueSerializedCommand([this, uids, targetFolder]() {
                   -  - ]
     315                 :           1 :       moveMessages(uids, targetFolder);
     316                 :           1 :     });
     317                 :           3 :     return;
     318                 :             :   }
     319                 :             : 
     320                 :           6 :   m_pendingMoveUids = uids;
     321                 :           6 :   m_pendingMoveTarget = targetFolder;
     322                 :             : 
     323                 :             :   // Build UID set string: "100,200,300"
     324                 :           6 :   QStringList uidStrs;
     325         [ +  + ]:          16 :   for (qint64 u : uids)
     326   [ +  -  +  - ]:          10 :     uidStrs.append(QString::number(u));
     327         [ +  - ]:           6 :   auto uidSet = uidStrs.join(',');
     328                 :             : 
     329         [ +  + ]:          12 :   if (m_capabilities.contains(QStringLiteral("MOVE"), Qt::CaseInsensitive)) {
     330   [ +  -  +  - ]:           5 :     sendCommand("MOVE",
     331   [ +  -  +  -  :          10 :                 QString("UID MOVE %1 %2").arg(uidSet, quoteImapString(targetFolder)));
                   +  - ]
     332                 :             :   } else {
     333   [ +  -  +  - ]:           1 :     sendCommand("COPY",
     334   [ +  -  +  -  :           2 :                 QString("UID COPY %1 %2").arg(uidSet, quoteImapString(targetFolder)));
                   +  - ]
     335                 :             :   }
     336                 :           6 : }
     337                 :             : 
     338                 :           3 : void ImapService::copyMessage(qint64 uid, const QString &targetFolder) {
     339         [ -  + ]:           3 :   if (m_state != State::Selected) {
     340   [ #  #  #  #  :           0 :     qCWarning(lcImap) << "Cannot copy message: no folder selected";
             #  #  #  # ]
     341                 :           0 :     return;
     342                 :             :   }
     343         [ +  + ]:           3 :   if (hasStatefulCommandInFlight()) {
     344   [ +  -  +  - ]:           2 :     enqueueSerializedCommand([this, uid, targetFolder]() {
     345                 :           1 :       copyMessage(uid, targetFolder);
     346                 :           1 :     });
     347                 :           2 :     return;
     348                 :             :   }
     349                 :             : 
     350         [ +  - ]:           1 :   m_pendingMoveUids = {uid};
     351                 :           1 :   m_pendingMoveTarget = targetFolder;
     352   [ +  -  +  - ]:           1 :   sendCommand("COPY_ONLY",
     353   [ +  -  +  -  :           4 :               QString("UID COPY %1 %2").arg(uid).arg(quoteImapString(targetFolder)));
             +  -  +  - ]
     354                 :             : }
     355                 :             : 
     356                 :             : // T-176: IMAP APPEND – upload a message to a folder
     357                 :          11 : void ImapService::appendMessage(const QString &folder,
     358                 :             :                                  const QByteArray &rfcMessage,
     359                 :             :                                  const QString &flags) {
     360   [ +  -  +  + ]:          11 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     361   [ +  -  +  -  :           8 :     qCWarning(lcImap) << "Cannot APPEND: not authenticated";
             +  -  +  + ]
     362         [ +  - ]:           4 :     emit appendError(QStringLiteral("Not authenticated"));
     363                 :           5 :     return;
     364                 :             :   }
     365   [ +  -  +  + ]:           7 :   if (hasStatefulCommandInFlight()) {
     366   [ +  -  +  -  :           1 :     enqueueSerializedCommand([this, folder, rfcMessage, flags]() {
             -  -  -  - ]
     367                 :           1 :       appendMessage(folder, rfcMessage, flags);
     368                 :           1 :     });
     369                 :           1 :     return;
     370                 :             :   }
     371                 :             : 
     372                 :           6 :   m_pendingAppendData = rfcMessage;
     373                 :           6 :   m_pendingAppendFolder = folder;
     374                 :             : 
     375                 :             :   // Build: APPEND "folder" (\Flags) {size}
     376   [ +  -  +  - ]:          12 :   QString cmd = QStringLiteral("APPEND %1").arg(quoteImapString(folder));
     377         [ +  - ]:           6 :   if (!flags.isEmpty()) {
     378                 :             :     // T-500: Sanitize CRLF/NUL to prevent IMAP command injection
     379                 :           6 :     QString safeFlags = flags;
     380         [ +  - ]:           6 :     safeFlags.remove('\r');
     381         [ +  - ]:           6 :     safeFlags.remove('\n');
     382         [ +  - ]:           6 :     safeFlags.remove(QChar(0));
     383   [ +  -  +  - ]:          12 :     cmd += QStringLiteral(" (%1)").arg(safeFlags);
     384                 :           6 :   }
     385   [ +  -  +  - ]:          12 :   cmd += QStringLiteral(" {%1}").arg(rfcMessage.size());
     386                 :             : 
     387   [ +  -  +  - ]:           6 :   sendCommand("APPEND", cmd);
     388                 :           6 : }
     389                 :             : 
     390                 :             : // T-176: EXPUNGE – permanently remove \Deleted messages
     391                 :           6 : void ImapService::expunge() {
     392         [ +  + ]:           6 :   if (m_state != State::Selected) {
     393   [ +  -  +  -  :           4 :     qCWarning(lcImap) << "Cannot EXPUNGE: no folder selected";
             +  -  +  + ]
     394                 :           2 :     return;
     395                 :             :   }
     396         [ +  + ]:           4 :   if (hasStatefulCommandInFlight()) {
     397         [ +  - ]:           3 :     enqueueSerializedCommand([this]() { expunge(); });
     398                 :           2 :     return;
     399                 :             :   }
     400   [ +  -  +  -  :           2 :   sendCommand("EXPUNGE", "EXPUNGE");
                   +  - ]
     401                 :             : }
     402                 :             : 
     403                 :             : // T-281: Create a new folder on the server (RFC 3501 §6.3.3)
     404                 :          12 : void ImapService::createFolder(const QString &folderPath) {
     405   [ +  +  +  + ]:          12 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     406         [ +  - ]:           6 :     emit folderOperationError(QStringLiteral("CREATE"),
     407                 :          12 :                               QStringLiteral("Not authenticated"));
     408                 :           6 :     return;
     409                 :             :   }
     410         [ +  + ]:           6 :   if (hasStatefulCommandInFlight()) {
     411   [ +  -  +  - ]:           2 :     enqueueSerializedCommand([this, folderPath]() { createFolder(folderPath); });
     412                 :           1 :     return;
     413                 :             :   }
     414                 :           5 :   m_pendingFolderOp = folderPath;
     415   [ +  -  +  -  :          10 :   sendCommand("CREATE", QString("CREATE %1").arg(quoteImapString(folderPath)));
          +  -  +  -  +  
                      - ]
     416                 :             : }
     417                 :             : 
     418                 :             : // T-281: Delete a folder on the server (RFC 3501 §6.3.4)
     419                 :           9 : void ImapService::deleteFolder(const QString &folderPath) {
     420   [ +  +  +  + ]:           9 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     421         [ +  - ]:           5 :     emit folderOperationError(QStringLiteral("DELETE"),
     422                 :          10 :                               QStringLiteral("Not authenticated"));
     423                 :           5 :     return;
     424                 :             :   }
     425         [ +  + ]:           4 :   if (hasStatefulCommandInFlight()) {
     426   [ +  -  +  - ]:           3 :     enqueueSerializedCommand([this, folderPath]() { deleteFolder(folderPath); });
     427                 :           2 :     return;
     428                 :             :   }
     429                 :           2 :   m_pendingFolderOp = folderPath;
     430   [ +  -  +  -  :           4 :   sendCommand("DELETE", QString("DELETE %1").arg(quoteImapString(folderPath)));
          +  -  +  -  +  
                      - ]
     431                 :             : }
     432                 :             : 
     433                 :             : // T-281: Rename (or move) a folder on the server (RFC 3501 §6.3.5)
     434                 :           7 : void ImapService::renameFolder(const QString &oldPath, const QString &newPath) {
     435   [ +  -  +  + ]:           7 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     436         [ +  - ]:           4 :     emit folderOperationError(QStringLiteral("RENAME"),
     437                 :           8 :                               QStringLiteral("Not authenticated"));
     438                 :           4 :     return;
     439                 :             :   }
     440         [ +  + ]:           3 :   if (hasStatefulCommandInFlight()) {
     441   [ +  -  +  -  :           1 :     enqueueSerializedCommand([this, oldPath, newPath]() {
                   -  - ]
     442                 :           1 :       renameFolder(oldPath, newPath);
     443                 :           1 :     });
     444                 :           1 :     return;
     445                 :             :   }
     446                 :           2 :   m_pendingFolderOp = oldPath;
     447                 :           2 :   m_pendingFolderNewPath = newPath;
     448   [ +  -  +  -  :           4 :   sendCommand("RENAME", QString("RENAME %1 %2")
                   +  - ]
     449   [ +  -  +  -  :           4 :       .arg(quoteImapString(oldPath), quoteImapString(newPath)));
                   +  - ]
     450                 :             : }
     451                 :             : 
     452                 :          21 : void ImapService::searchAllUids(qint64 fromUid) {
     453         [ +  + ]:          21 :   if (m_state != State::Selected) {
     454   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Cannot search: no folder selected";
             +  -  +  + ]
     455                 :           1 :     return;
     456                 :             :   }
     457         [ +  + ]:          20 :   if (hasStatefulCommandInFlight()) {
     458         [ +  - ]:           3 :     enqueueSerializedCommand([this, fromUid]() { searchAllUids(fromUid); });
     459                 :           2 :     return;
     460                 :             :   }
     461                 :             : 
     462                 :          18 :   m_pendingSearchUids.clear();
     463         [ +  + ]:          18 :   if (fromUid > 1) {
     464                 :             :     // Delta search: only UIDs >= fromUid
     465   [ +  -  +  - ]:          10 :     sendCommand("SEARCH",
     466   [ +  -  +  - ]:          30 :                 QString("UID SEARCH UID %1:*").arg(fromUid));
     467                 :             :   } else {
     468   [ +  -  +  -  :           8 :     sendCommand("SEARCH", "UID SEARCH ALL");
                   +  - ]
     469                 :             :   }
     470                 :             : }
     471                 :             : 
     472                 :             : // T-187: IMAP text-based SEARCH
     473                 :           9 : void ImapService::searchText(const QString &query, const QString &criteria) {
     474         [ -  + ]:           9 :   if (m_state != State::Selected) {
     475   [ #  #  #  #  :           0 :     qCWarning(lcImap) << "Cannot search: no folder selected";
             #  #  #  # ]
     476                 :           0 :     return;
     477                 :             :   }
     478         [ +  + ]:           9 :   if (hasStatefulCommandInFlight()) {
     479   [ +  -  +  -  :           4 :     enqueueSerializedCommand([this, query, criteria]() {
                   -  - ]
     480                 :           1 :       searchText(query, criteria);
     481                 :           1 :     });
     482                 :           4 :     return;
     483                 :             :   }
     484                 :             : 
     485                 :           5 :   m_pendingSearchUids.clear();
     486                 :             :   // RFC 3501: SEARCH <criteria> <quoted-string>
     487   [ +  -  +  - ]:           5 :   sendCommand("SEARCH",
     488   [ +  -  +  -  :          10 :               QString("UID SEARCH %1 %2").arg(criteria, quoteImapString(query)));
                   +  - ]
     489                 :             : }
     490                 :             : 
     491                 :             : // Sprint 59 (S2): composite SEARCH for the visual search facets.
     492                 :          93 : bool ImapService::SearchCriteria::isEmpty() const {
     493   [ +  +  +  -  :         110 :   return text.isEmpty() && from.isEmpty() && to.isEmpty() && subject.isEmpty() &&
                   +  + ]
     494   [ +  +  +  -  :           5 :          !since.isValid() && !before.isValid() && unread == SearchTri::Any &&
                   +  + ]
     495   [ +  +  +  -  :         113 :          flagged == SearchTri::Any && answered == SearchTri::Any &&
             +  -  +  - ]
     496                 :          96 :          keywords.isEmpty();
     497                 :             : }
     498                 :             : 
     499                 :          66 : QString ImapService::buildSearchCommand(const SearchCriteria &criteria) {
     500   [ +  -  +  + ]:          66 :   if (criteria.isEmpty())
     501                 :           1 :     return QString();
     502                 :             : 
     503                 :             :   // RFC 3501 date format is "dd-MMM-yyyy" with English month names; force the C
     504                 :             :   // locale so it never gets localized (e.g. "Feb" not "Févr").
     505                 :           2 :   auto imapDate = [](const QDate &d) {
     506   [ +  -  +  - ]:           4 :     return QLocale::c().toString(d, QStringLiteral("dd-MMM-yyyy"));
     507                 :             :   };
     508                 :             : 
     509                 :          65 :   QStringList parts;
     510         [ +  + ]:          65 :   if (!criteria.text.isEmpty())
     511   [ +  -  +  -  :          53 :     parts << QStringLiteral("TEXT") << quoteImapString(criteria.text);
                   +  - ]
     512         [ +  + ]:          65 :   if (!criteria.from.isEmpty())
     513   [ +  -  +  -  :           5 :     parts << QStringLiteral("FROM") << quoteImapString(criteria.from);
                   +  - ]
     514         [ -  + ]:          65 :   if (!criteria.to.isEmpty())
     515   [ #  #  #  #  :           0 :     parts << QStringLiteral("TO") << quoteImapString(criteria.to);
                   #  # ]
     516         [ +  + ]:          65 :   if (!criteria.subject.isEmpty())
     517   [ +  -  +  -  :           6 :     parts << QStringLiteral("SUBJECT") << quoteImapString(criteria.subject);
                   +  - ]
     518   [ +  -  +  + ]:          65 :   if (criteria.since.isValid())
     519   [ +  -  +  -  :           1 :     parts << QStringLiteral("SINCE") << imapDate(criteria.since);
                   +  - ]
     520   [ +  -  +  + ]:          65 :   if (criteria.before.isValid())
     521   [ +  -  +  -  :           1 :     parts << QStringLiteral("BEFORE") << imapDate(criteria.before);
                   +  - ]
     522         [ +  + ]:          65 :   if (criteria.unread == SearchTri::Yes)
     523         [ +  - ]:           1 :     parts << QStringLiteral("UNSEEN");
     524         [ +  + ]:          64 :   else if (criteria.unread == SearchTri::No)
     525         [ +  - ]:           1 :     parts << QStringLiteral("SEEN");
     526         [ -  + ]:          65 :   if (criteria.flagged == SearchTri::Yes)
     527         [ #  # ]:           0 :     parts << QStringLiteral("FLAGGED");
     528         [ +  + ]:          65 :   else if (criteria.flagged == SearchTri::No)
     529         [ +  - ]:           1 :     parts << QStringLiteral("UNFLAGGED");
     530         [ +  + ]:          65 :   if (criteria.answered == SearchTri::Yes)
     531         [ +  - ]:           1 :     parts << QStringLiteral("ANSWERED");
     532         [ -  + ]:          64 :   else if (criteria.answered == SearchTri::No)
     533         [ #  # ]:           0 :     parts << QStringLiteral("UNANSWERED");
     534         [ +  + ]:          66 :   for (const QString &kw : criteria.keywords) {
     535   [ +  -  +  - ]:           1 :     if (!kw.trimmed().isEmpty())
     536   [ +  -  +  -  :           1 :       parts << QStringLiteral("KEYWORD") << quoteImapString(kw);
                   +  - ]
     537                 :             :   }
     538                 :             : 
     539         [ -  + ]:          65 :   if (parts.isEmpty())
     540                 :           0 :     return QString();
     541   [ +  -  +  - ]:         130 :   return QStringLiteral("UID SEARCH ") + parts.join(QLatin1Char(' '));
     542                 :          65 : }
     543                 :             : 
     544                 :          62 : void ImapService::search(const SearchCriteria &criteria) {
     545         [ -  + ]:          62 :   if (m_state != State::Selected) {
     546   [ #  #  #  #  :           0 :     qCWarning(lcImap) << "Cannot search: no folder selected";
             #  #  #  # ]
     547                 :           1 :     return;
     548                 :             :   }
     549         [ +  - ]:          62 :   const QString command = buildSearchCommand(criteria);
     550         [ -  + ]:          62 :   if (command.isEmpty()) {
     551   [ #  #  #  #  :           0 :     qCInfo(lcImap) << "search: no server-mappable criteria — skipping";
             #  #  #  # ]
     552                 :           0 :     return;
     553                 :             :   }
     554   [ +  -  +  + ]:          62 :   if (hasStatefulCommandInFlight()) {
     555   [ +  -  +  - ]:           2 :     enqueueSerializedCommand([this, criteria]() { search(criteria); });
     556                 :           1 :     return;
     557                 :             :   }
     558                 :             : 
     559         [ +  - ]:          61 :   m_pendingSearchUids.clear();
     560   [ +  -  +  - ]:          61 :   sendCommand("SEARCH", command);
     561         [ +  + ]:          62 : }
     562                 :             : 
     563                 :             : // T-211: Search by Message-ID header (for undo-move)
     564                 :           9 : void ImapService::searchByMessageId(const QString &messageId) {
     565         [ -  + ]:           9 :   if (m_state != State::Selected) {
     566   [ #  #  #  #  :           0 :     qCWarning(lcImap) << "Cannot search: no folder selected";
             #  #  #  # ]
     567                 :           0 :     return;
     568                 :             :   }
     569         [ +  + ]:           9 :   if (hasStatefulCommandInFlight()) {
     570   [ +  -  +  - ]:           4 :     enqueueSerializedCommand(
     571                 :           9 :         [this, messageId]() { searchByMessageId(messageId); });
     572                 :           4 :     return;
     573                 :             :   }
     574                 :             : 
     575                 :           5 :   m_pendingSearchUids.clear();
     576                 :             :   // RFC 3501: UID SEARCH HEADER Message-ID <message-id>
     577   [ +  -  +  - ]:           5 :   sendCommand("SEARCH",
     578                 :          10 :               QStringLiteral("UID SEARCH HEADER Message-ID %1")
     579   [ +  -  +  - ]:          15 :                   .arg(quoteImapString(messageId)));
     580                 :             : }
     581                 :             : 
     582                 :          13 : void ImapService::fetchHeadersByUids(const QList<qint64> &uids) {
     583         [ +  + ]:          13 :   if (m_state != State::Selected) {
     584   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Cannot fetch: no folder selected";
             +  -  +  + ]
     585                 :           3 :     return;
     586                 :             :   }
     587         [ -  + ]:          12 :   if (uids.isEmpty())
     588                 :           0 :     return;
     589   [ +  -  +  + ]:          12 :   if (hasStatefulCommandInFlight()) {
     590   [ +  -  +  - ]:           3 :     enqueueSerializedCommand([this, uids]() { fetchHeadersByUids(uids); });
     591                 :           2 :     return;
     592                 :             :   }
     593                 :             : 
     594         [ +  - ]:          10 :   m_pendingHeaders.clear();
     595                 :             : 
     596                 :             :   // Build comma-separated UID set, e.g. "100,99,98,97"
     597                 :          10 :   QStringList uidStrings;
     598         [ +  - ]:          10 :   uidStrings.reserve(uids.size());
     599         [ +  + ]:          43 :   for (qint64 uid : uids) {
     600   [ +  -  +  - ]:          33 :     uidStrings.append(QString::number(uid));
     601                 :             :   }
     602         [ +  - ]:          10 :   QString uidSet = uidStrings.join(',');
     603                 :             : 
     604   [ +  -  +  - ]:          10 :   sendCommand("FETCH_HEADERS",
     605                 :           0 :               QString("UID FETCH %1 (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE "
     606                 :             :                       "BODY.PEEK[HEADER.FIELDS (References X-Spam X-Spam-Status X-Spam-Flag)])"
     607   [ +  -  +  - ]:          30 :                       ).arg(uidSet));
     608                 :          10 : }
     609                 :             : 
     610                 :          98 : void ImapService::startIdle() {
     611         [ +  + ]:          98 :   if (m_state != State::Selected) {
     612   [ +  -  +  -  :          14 :     qCWarning(lcImap) << "Cannot start IDLE: not in Selected state";
             +  -  +  + ]
     613                 :          15 :     return;
     614                 :             :   }
     615         [ -  + ]:          91 :   if (m_isIdling) {
     616   [ #  #  #  #  :           0 :     qCWarning(lcImap) << "Already idling";
             #  #  #  # ]
     617                 :           0 :     return;
     618                 :             :   }
     619   [ +  -  +  + ]:          91 :   if (!hasIdleCapability()) {
     620   [ +  -  +  -  :          16 :     qCWarning(lcImap) << "Server does not support IDLE";
             +  -  +  + ]
     621                 :           8 :     return;
     622                 :             :   }
     623                 :             : 
     624                 :          83 :   m_isIdling = true;
     625         [ +  - ]:          83 :   m_idleTag = nextTag();
     626   [ +  -  +  - ]:          83 :   m_pendingCommands.insert(m_idleTag, "IDLE");
     627         [ +  - ]:          83 :   auto cmd = m_idleTag + " IDLE\r\n";
     628   [ +  -  +  -  :         166 :   qCInfo(lcImap) << ">>>" << cmd.trimmed();
          +  -  +  -  +  
                -  +  + ]
     629   [ +  -  +  - ]:          83 :   m_socket->write(cmd.toUtf8());
     630         [ +  - ]:          83 :   setState(State::Idling);
     631         [ +  - ]:          83 :   m_idleRenewTimer->start(IDLE_RENEW_MS);
     632                 :          83 : }
     633                 :             : 
     634                 :          94 : void ImapService::stopIdle() {
     635         [ -  + ]:          94 :   if (!m_isIdling) {
     636                 :           0 :     return;
     637                 :             :   }
     638         [ +  + ]:          94 :   if (m_idleRenewWatchdog->isActive()) {
     639   [ +  -  +  -  :          28 :     qCDebug(lcImap) << "IDLE DONE/OK already pending — not sending DONE again";
             +  -  +  + ]
     640                 :          14 :     return;
     641                 :             :   }
     642                 :             : 
     643   [ +  -  +  -  :         160 :   qCInfo(lcImap) << ">>> DONE";
             +  -  +  + ]
     644                 :          80 :   m_socket->write("DONE\r\n");
     645                 :          80 :   m_idleRenewTimer->stop();
     646                 :             :   // T-720: Arm a watchdog for the IDLE tagged OK that proves the
     647                 :             :   // DONE actually reached the server and was ACKed. Covers renew
     648                 :             :   // (onIdleRenew), executeAfterIdle and liveness-probe paths. The
     649                 :             :   // handleTagged() IDLE branch stops this timer on OK arrival.
     650                 :          80 :   m_idleRenewWatchdog->start(PROBE_WATCHDOG_MS);
     651                 :             :   // m_isIdling will be cleared when we receive the tagged OK for IDLE
     652                 :             : }
     653                 :             : 
     654                 :           1 : void ImapService::onIdleRenew() {
     655         [ +  - ]:           1 :   if (!m_isIdling) {
     656                 :           1 :     return;
     657                 :             :   }
     658   [ #  #  #  #  :           0 :   qCInfo(lcImap) << "IDLE renew: sending DONE + re-IDLE";
             #  #  #  # ]
     659                 :             :   // Stop current IDLE, will re-start after receiving tagged OK
     660                 :           0 :   stopIdle();
     661                 :             :   // The re-start happens in handleTagged when IDLE OK is received
     662                 :             : }
     663                 :             : 
     664                 :         132 : bool ImapService::hasIdleCapability() const {
     665         [ +  - ]:         132 :   return m_capabilities.contains("IDLE", Qt::CaseInsensitive);
     666                 :             : }
     667                 :             : 
     668                 :             : // T-208: Check for CONDSTORE capability (RFC 4551)
     669                 :           2 : bool ImapService::hasCondstoreCapability() const {
     670         [ +  - ]:           2 :   return m_capabilities.contains("CONDSTORE", Qt::CaseInsensitive);
     671                 :             : }
     672                 :             : 
     673                 :             : // T-320: Centralized push restart — prefers NOTIFY over IDLE.
     674                 :             : // Called from every handler that previously did "if (m_autoIdle) startIdle()".
     675                 :         101 : void ImapService::restartPush() {
     676         [ +  + ]:         101 :   if (!m_autoIdle) return;
     677         [ +  + ]:          69 :   if (!m_serializedCommands.isEmpty()) {
     678                 :          12 :     runNextSerializedCommand();
     679                 :          12 :     return;
     680                 :             :   }
     681   [ -  +  -  -  :          57 :   if (hasNotifyCapability() && !m_notifyFolders.isEmpty()) {
                   -  + ]
     682                 :           0 :     startNotify(m_notifyFolders);
     683                 :             :   } else {
     684                 :          57 :     startIdle();
     685                 :             :   }
     686                 :             : }
     687                 :             : 
     688                 :             : // ═══════════════════════════════════════════════════════
     689                 :             : // T-320: IMAP NOTIFY (RFC 5465)
     690                 :             : // ═══════════════════════════════════════════════════════
     691                 :             : 
     692                 :         107 : bool ImapService::hasNotifyCapability() const {
     693         [ +  - ]:         107 :   return m_capabilities.contains("NOTIFY", Qt::CaseInsensitive);
     694                 :             : }
     695                 :             : 
     696                 :           4 : void ImapService::startNotify(const QStringList &subscribedFolders) {
     697         [ +  + ]:           4 :   if (m_state != State::Selected) {
     698   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Cannot start NOTIFY: not in Selected state";
             +  -  +  + ]
     699                 :           1 :     return;
     700                 :             :   }
     701         [ +  + ]:           3 :   if (m_isNotifying) {
     702   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Already notifying";
             +  -  +  + ]
     703                 :           1 :     return;
     704                 :             :   }
     705         [ -  + ]:           2 :   if (!hasNotifyCapability()) {
     706   [ #  #  #  #  :           0 :     qCWarning(lcImap) << "Server does not support NOTIFY";
             #  #  #  # ]
     707                 :           0 :     return;
     708                 :             :   }
     709                 :             : 
     710                 :           2 :   m_isNotifying = true;
     711                 :           2 :   m_notifyFolders = subscribedFolders;
     712                 :             :   // T-720: Use sendTaggedCommand() so NOTIFY SET goes through the regular
     713                 :             :   // command timeout machinery (a server that never ACKs the SET can no
     714                 :             :   // longer stall push setup — it gets failed after COMMAND_TIMEOUT_MS).
     715         [ +  - ]:           4 :   m_notifyTag = sendTaggedCommand(
     716                 :           4 :       QStringLiteral("NOTIFY"),
     717                 :           4 :       QStringLiteral("NOTIFY SET STATUS"
     718                 :             :                     " (selected (MessageNew MessageExpunge FlagChange))"
     719                 :           2 :                     " (subscribed (MessageNew MessageExpunge FlagChange))"));
     720                 :           2 :   setState(State::Selected); // NOTIFY doesn't change state like IDLE does
     721                 :             : }
     722                 :             : 
     723                 :           3 : void ImapService::stopNotify() {
     724         [ +  + ]:           3 :   if (!m_isNotifying) {
     725                 :           1 :     return;
     726                 :             :   }
     727                 :             : 
     728                 :             :   // T-720: Use sendTaggedCommand() so NOTIFY NONE has command-timeout
     729                 :             :   // coverage symmetric to NOTIFY SET.
     730         [ +  - ]:           4 :   m_notifyTag = sendTaggedCommand(QStringLiteral("NOTIFY_NONE"),
     731                 :           6 :                                   QStringLiteral("NOTIFY NONE"));
     732                 :             :   // m_isNotifying cleared when NOTIFY_NONE OK arrives in handleTagged
     733                 :             : }
     734                 :             : 
     735                 :           1 : void ImapService::executeAfterNotify(std::function<void()> command) {
     736                 :             :   // T-320: NOTIFY is NOT a blocking state like IDLE — commands can be
     737                 :             :   // sent freely while NOTIFY is active (RFC 5465). Just execute directly.
     738                 :           1 :   command();
     739                 :           1 : }
     740                 :             : 
     741                 :         198 : void ImapService::executeAfterIdle(std::function<void()> command) {
     742         [ -  + ]:         198 :   if (m_isNotifying) {
     743                 :             :     // T-320: NOTIFY doesn't block — execute immediately, no stop needed
     744                 :           0 :     command();
     745         [ +  + ]:         198 :   } else if (m_isIdling) {
     746                 :             :     // Queue command and stop IDLE – command runs on IDLE OK
     747                 :          86 :     m_deferredCommands.enqueue(std::move(command));
     748                 :          86 :     stopIdle();
     749                 :             :   } else {
     750                 :             :     // Not idling/notifying – execute immediately
     751                 :         112 :     command();
     752                 :             :   }
     753                 :         198 : }
     754                 :             : 
     755                 :           2 : void ImapService::clearDeferredCommands() {
     756         [ +  + ]:           2 :   if (!m_deferredCommands.isEmpty()) {
     757   [ +  -  +  -  :           2 :     qCInfo(lcImap) << "Clearing" << m_deferredCommands.size()
          +  -  +  -  +  
                      + ]
     758         [ +  - ]:           1 :                    << "deferred commands (folder switch)";
     759                 :           1 :     m_deferredCommands.clear();
     760                 :             :   }
     761                 :           2 : }
     762                 :             : 
     763                 :          34 : void ImapService::fetchFlags() {
     764         [ +  + ]:          34 :   if (m_state != State::Selected) {
     765   [ +  -  +  -  :           8 :     qCWarning(lcImap) << "Cannot fetch flags: no folder selected";
             +  -  +  + ]
     766                 :           4 :     return;
     767                 :             :   }
     768         [ +  + ]:          30 :   if (hasStatefulCommandInFlight()) {
     769         [ +  - ]:           3 :     enqueueSerializedCommand([this]() { fetchFlags(); });
     770                 :           2 :     return;
     771                 :             :   }
     772                 :             : 
     773                 :          28 :   m_pendingFlags.clear();
     774   [ +  -  +  -  :          28 :   sendCommand("FETCH_FLAGS", "UID FETCH 1:* (UID FLAGS)");
                   +  - ]
     775                 :             : }
     776                 :             : 
     777                 :             : // T-207: Pipeline SELECT + FETCH FLAGS — saves one round-trip.
     778                 :             : // Both commands are sent immediately; the server processes them in-order.
     779                 :             : // The FETCH FLAGS will execute in the context of the newly selected folder.
     780                 :          35 : void ImapService::selectAndFetchFlags(const QString &folderPath) {
     781   [ +  +  +  + ]:          35 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     782   [ +  -  +  -  :           8 :     qCWarning(lcImap) << "Cannot selectAndFetchFlags: not authenticated";
             +  -  +  + ]
     783                 :           4 :     return;
     784                 :             :   }
     785         [ +  + ]:          31 :   if (hasStatefulCommandInFlight()) {
     786   [ +  -  +  - ]:           4 :     enqueueSerializedCommand(
     787                 :          10 :         [this, folderPath]() { selectAndFetchFlags(folderPath); });
     788                 :           4 :     return;
     789                 :             :   }
     790                 :             : 
     791                 :          27 :   m_pendingSelectFolder = folderPath; // Bug 34: defer until SELECT OK
     792                 :          27 :   m_selectedMessageCount = 0;
     793                 :          27 :   m_selectedUidValidity = 0;
     794                 :          27 :   m_selectedHighestModseq = 0; // T-208: reset, will be set by untagged OK
     795                 :          27 :   m_pendingFlags.clear();
     796                 :             : 
     797                 :             :   // Send both commands back-to-back (pipelining)
     798   [ +  -  +  -  :          54 :   sendCommand("SELECT", QString("SELECT %1").arg(quoteImapString(folderPath)));
          +  -  +  -  +  
                      - ]
     799   [ +  -  +  -  :          27 :   sendCommand("FETCH_FLAGS", "UID FETCH 1:* (UID FLAGS)");
                   +  - ]
     800                 :             : 
     801   [ +  -  +  -  :          54 :   qCInfo(lcImap) << "T-207: Pipelined SELECT + FETCH_FLAGS for" << folderPath;
          +  -  +  -  +  
                      + ]
     802                 :             : }
     803                 :             : 
     804                 :             : // T-208: Incremental flag sync using CONDSTORE (RFC 4551)
     805                 :           2 : void ImapService::fetchFlagsChanged(quint64 modseq) {
     806         [ -  + ]:           2 :   if (m_state != State::Selected) {
     807   [ #  #  #  #  :           0 :     qCWarning(lcImap) << "Cannot fetchFlagsChanged: no folder selected";
             #  #  #  # ]
     808                 :           0 :     return;
     809                 :             :   }
     810         [ +  + ]:           2 :   if (hasStatefulCommandInFlight()) {
     811         [ +  - ]:           1 :     enqueueSerializedCommand([this, modseq]() { fetchFlagsChanged(modseq); });
     812                 :           1 :     return;
     813                 :             :   }
     814                 :             : 
     815                 :           1 :   m_pendingFlags.clear();
     816   [ +  -  +  - ]:           1 :   sendCommand("FETCH_FLAGS",
     817   [ +  -  +  - ]:           3 :               QString("UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE %1)").arg(modseq));
     818   [ +  -  +  -  :           2 :   qCInfo(lcImap) << "T-208: CONDSTORE flag sync, CHANGEDSINCE" << modseq;
          +  -  +  -  +  
                      + ]
     819                 :             : }
     820                 :             : 
     821                 :           3 : void ImapService::fetchUidForSeqNo(int seqNo) {
     822         [ +  + ]:           3 :   if (m_state != State::Selected) {
     823   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Cannot fetch UID for seqNo: no folder selected";
             +  -  +  + ]
     824                 :           1 :     return;
     825                 :             :   }
     826         [ +  + ]:           2 :   if (hasStatefulCommandInFlight()) {
     827         [ +  - ]:           2 :     enqueueSerializedCommand([this, seqNo]() { fetchUidForSeqNo(seqNo); });
     828                 :           1 :     return;
     829                 :             :   }
     830                 :             :   // T-065 fix: Use DISTINCT command type so the single-UID result
     831                 :             :   // is NOT treated as a full flag sync (which would purge all other mails).
     832                 :           1 :   m_pendingFlags.clear();
     833   [ +  -  +  - ]:           1 :   sendCommand("FETCH_SEQNO",
     834   [ +  -  +  - ]:           3 :               QString("FETCH %1 (UID FLAGS)").arg(seqNo));
     835                 :             : }
     836                 :             : 
     837                 :          21 : void ImapService::statusFolder(const QString &folderPath) {
     838   [ +  +  +  + ]:          21 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     839   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Cannot status: not authenticated or selected";
             +  -  +  + ]
     840                 :           1 :     return;
     841                 :             :   }
     842         [ +  + ]:          20 :   if (hasStatefulCommandInFlight()) {
     843   [ +  -  +  - ]:          16 :     enqueueSerializedCommand([this, folderPath]() { statusFolder(folderPath); });
     844                 :           8 :     return;
     845                 :             :   }
     846                 :             : 
     847                 :          12 :   m_pendingStatusFolder = folderPath;
     848   [ +  -  +  -  :          24 :   sendCommand("STATUS", QString("STATUS %1 (MESSAGES UNSEEN RECENT)")
                   +  - ]
     849   [ +  -  +  - ]:          24 :                             .arg(quoteImapString(folderPath)));
     850                 :             : }
     851                 :             : 
     852                 :             : // T-540: NOOP command for keep-alive (body connection)
     853                 :           4 : void ImapService::sendNoop() {
     854   [ +  +  -  + ]:           4 :   if (m_state != State::Authenticated && m_state != State::Selected) {
     855                 :           0 :     return; // silently skip if not connected
     856                 :             :   }
     857         [ +  + ]:           4 :   if (hasStatefulCommandInFlight()) {
     858         [ +  - ]:           4 :     enqueueSerializedCommand([this]() { sendNoop(); });
     859                 :           3 :     return;
     860                 :             :   }
     861   [ +  -  +  -  :           1 :   sendCommand("NOOP", "NOOP");
                   +  - ]
     862                 :             : }
     863                 :             : 
     864                 :             : // ═══════════════════════════════════════════════════════
     865                 :             : // T-720: Liveness probe + reconnect API (Sprint 72)
     866                 :             : // ═══════════════════════════════════════════════════════
     867                 :             : 
     868                 :          41 : void ImapService::sendProbeNoop() {
     869                 :             :   // Caller ensures state is Authenticated/Selected and no stateful command
     870                 :             :   // is in flight. Tag is remembered so handleTagged() can clear it on the
     871                 :             :   // matching tagged response.
     872   [ +  -  +  -  :          41 :   m_probeTag = sendTaggedCommand("NOOP", "NOOP");
                   +  - ]
     873                 :          41 :   m_livenessProbeWatchdog->start(PROBE_WATCHDOG_MS);
     874   [ +  -  +  -  :          82 :   qCInfo(lcImap) << "Liveness probe sent (NOOP" << m_probeTag
          +  -  +  -  +  
                      + ]
     875   [ +  -  +  - ]:          41 :                  << ") reason:" << m_lastProbeReason;
     876                 :          41 : }
     877                 :             : 
     878                 :          57 : void ImapService::requestLivenessProbe(const QString &reason) {
     879                 :             :   // Idempotent while a probe is already running.
     880         [ +  + ]:          57 :   if (!m_probeTag.isEmpty()) {
     881   [ +  -  +  -  :           6 :     qCDebug(lcImap) << "Liveness probe already in flight — ignoring"
             +  -  +  + ]
     882         [ +  - ]:           3 :                     << reason;
     883                 :           3 :     return;
     884                 :             :   }
     885                 :          54 :   m_lastProbeReason = reason;
     886                 :             : 
     887   [ +  +  +  - ]:          54 :   switch (m_state) {
     888                 :          42 :   case State::Authenticated:
     889                 :             :   case State::Selected: {
     890                 :             :     // NOOP is the canonical probe. If a stateful command is in flight,
     891                 :             :     // the live round-trip already proves liveness — reset the probe state
     892                 :             :     // and rely on the command timeout to catch a stuck command.
     893         [ +  + ]:          42 :     if (hasStatefulCommandInFlight()) {
     894   [ +  -  +  -  :           2 :       qCDebug(lcImap) << "Liveness probe skipped — stateful command"
             +  -  +  + ]
     895         [ +  - ]:           1 :                       << "already in flight (command timeout covers it)";
     896                 :           1 :       return;
     897                 :             :     }
     898                 :          41 :     sendProbeNoop();
     899                 :          41 :     return;
     900                 :             :   }
     901                 :           8 :   case State::Idling: {
     902                 :             :     // NOOP is illegal during IDLE (RFC 2177). DONE → tagged OK is the
     903                 :             :     // round-trip that proves liveness; restartPush() re-arms push after.
     904   [ +  -  +  -  :          16 :     qCInfo(lcImap) << "Liveness probe via IDLE DONE/OK — reason:" << reason;
          +  -  +  -  +  
                      + ]
     905                 :           8 :     stopIdle();
     906                 :           8 :     return;
     907                 :             :   }
     908                 :           4 :   case State::Connecting:
     909                 :             :   case State::Connected:
     910                 :             :   case State::Greeting:
     911                 :             :   case State::Capability:
     912                 :             :   case State::StartingTLS:
     913                 :             :   case State::Authenticating:
     914                 :             :   case State::Disconnected:
     915                 :             :   case State::Error:
     916                 :             :     // These states are either making progress (handled by the connect
     917                 :             :     // timeout / command timeout) or already terminal. The monitor's
     918                 :             :     // reconnect arm will pick them up.
     919                 :           4 :     return;
     920                 :             :   }
     921                 :             : }
     922                 :             : 
     923                 :           3 : void ImapService::abortForReconnect(const QString &reason) {
     924   [ +  -  +  -  :           6 :   qCWarning(lcImap) << "Forced reconnect requested:" << reason;
          +  -  +  -  +  
                      + ]
     925                 :             :   // failConnection() clears pending state, aborts the socket, emits
     926                 :             :   // errorOccurred and transitions to State::Error — the monitor's
     927                 :             :   // scheduleReconnect() arm fires from there.
     928   [ +  -  +  - ]:           6 :   failConnection(QStringLiteral("Forced reconnect: %1").arg(reason));
     929                 :           3 : }
     930                 :             : 
     931                 :           1 : void ImapService::onLivenessProbeTimeout() {
     932         [ +  - ]:           1 :   failConnection(
     933         [ +  - ]:           3 :       QStringLiteral("IMAP liveness probe timeout: %1").arg(m_lastProbeReason));
     934                 :           1 : }
     935                 :             : 
     936                 :           1 : void ImapService::onIdleRenewWatchdogTimeout() {
     937         [ +  - ]:           3 :   failConnection(QStringLiteral(
     938                 :             :       "IDLE DONE/OK round-trip timeout (probe reason: %1)")
     939         [ +  - ]:           2 :       .arg(m_lastProbeReason));
     940                 :           1 : }
     941                 :             : 
     942                 :          45 : QString ImapService::sendTaggedCommand(const QString &type,
     943                 :             :                                        const QString &command) {
     944                 :             :   // Thin wrapper around sendCommand() that exposes the generated tag, so
     945                 :             :   // the probe can match its response in handleTagged().
     946                 :          45 :   QString tag;
     947                 :             :   // Inline copy of sendCommand() but return tag (cannot refactor callers
     948                 :             :   // without disturbing the existing command-timeout/reflow behaviour).
     949         [ +  - ]:          45 :   tag = nextTag();
     950         [ +  - ]:          45 :   m_pendingCommands.insert(tag, type);
     951                 :          45 :   QElapsedTimer timer;
     952                 :          45 :   timer.start();
     953         [ +  - ]:          45 :   m_commandTimers.insert(tag, timer);
     954   [ +  -  +  -  :          45 :   auto fullCommand = tag + " " + command + "\r\n";
                   +  - ]
     955   [ +  -  +  -  :          90 :   qCDebug(lcImap) << ">>>" << fullCommand.trimmed();
          +  -  +  -  +  
                -  +  + ]
     956   [ +  -  +  - ]:          45 :   m_socket->write(fullCommand.toUtf8());
     957         [ +  - ]:          45 :   refreshCommandTimeout();
     958                 :          45 :   return tag;
     959                 :          45 : }
     960                 :             : 
     961                 :          19 : void ImapService::tuneKeepAlive() {
     962         [ +  + ]:          19 :   if (m_keepAliveTuned)
     963                 :          10 :     return;
     964                 :             : 
     965                 :             :   // The Qt-level option (set in the constructor) is assertable on an
     966                 :             :   // unconnected socket; here we tune the native intervals once the
     967                 :             :   // socket descriptor is valid.
     968         [ +  - ]:          10 :   qintptr fd = m_socket->socketDescriptor();
     969         [ +  + ]:          10 :   if (fd < 0) {
     970   [ +  -  +  -  :           2 :     qCDebug(lcImap) << "tuneKeepAlive: no valid socket descriptor yet";
             +  -  +  + ]
     971                 :           1 :     return;
     972                 :             :   }
     973                 :             : 
     974                 :           9 :   m_keepAliveTuned = true;
     975                 :             : 
     976                 :             : #ifdef Q_OS_LINUX
     977                 :             :   // Linux: TCP_KEEPIDLE=60, TCP_KEEPINTVL=30, TCP_KEEPCNT=3.
     978                 :             :   // Worst-case detection ~ 60 + 3*30 = 150 s with zero app traffic.
     979                 :           9 :   const int idle = 60;
     980                 :           9 :   const int intvl = 30;
     981                 :           9 :   const int cnt = 3;
     982                 :           9 :   ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPIDLE, &idle,
     983                 :             :                sizeof(idle));
     984                 :           9 :   ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPINTVL, &intvl,
     985                 :             :                sizeof(intvl));
     986                 :           9 :   ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPCNT, &cnt,
     987                 :             :                sizeof(cnt));
     988   [ +  -  +  -  :          18 :   qCInfo(lcImap) << "TCP keepalive tuned (Linux): idle=60s intvl=30s cnt=3";
             +  -  +  + ]
     989                 :             : #elif defined(Q_OS_MACOS)
     990                 :             :   // macOS: single TCP_KEEPALIVE interval (seconds). Mirror the Linux
     991                 :             :   // worst-case budget of ~150 s by setting an idle probe interval.
     992                 :             :   const int macIdle = 60;
     993                 :             :   ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPALIVE, &macIdle,
     994                 :             :                sizeof(macIdle));
     995                 :             :   qCInfo(lcImap) << "TCP keepalive tuned (macOS): interval=60s";
     996                 :             : #elif defined(Q_OS_WIN)
     997                 :             :   // Windows: SIO_KEEPALIVE_VALS ioctl. onoff=1, idle=60000ms, intvl=30000ms.
     998                 :             :   tcp_keepalive ka;
     999                 :             :   ka.onoff = 1;
    1000                 :             :   ka.keepalivetime = 60000;
    1001                 :             :   ka.keepaliveinterval = 30000;
    1002                 :             :   DWORD bytesReturned = 0;
    1003                 :             :   ::WSAIoctl(static_cast<SOCKET>(fd), SIO_KEEPALIVE_VALS, &ka, sizeof(ka),
    1004                 :             :              nullptr, 0, &bytesReturned, nullptr, nullptr);
    1005                 :             :   qCInfo(lcImap) << "TCP keepalive tuned (Windows): idle=60000ms intvl=30000ms";
    1006                 :             : #else
    1007                 :             :   qCInfo(lcImap) << "TCP keepalive tuning not implemented on this platform;"
    1008                 :             :                  << "relying on OS defaults";
    1009                 :             : #endif
    1010                 :             : }
    1011                 :             : 
    1012                 :         572 : bool ImapService::hasStatefulCommandInFlight() const {
    1013                 :             :   static const QSet<QString> statefulTypes = {
    1014                 :           6 :       QStringLiteral("LIST"),          QStringLiteral("SELECT"),
    1015                 :           6 :       QStringLiteral("FETCH_HEADERS"), QStringLiteral("FETCH_BODY"),
    1016                 :           6 :       QStringLiteral("FETCH_FLAGS"),   QStringLiteral("FETCH_SEQNO"),
    1017                 :           6 :       QStringLiteral("SEARCH"),        QStringLiteral("STATUS"),
    1018                 :           6 :       QStringLiteral("STORE"),         QStringLiteral("MOVE"),
    1019                 :           6 :       QStringLiteral("COPY"),          QStringLiteral("STORE_DELETE"),
    1020                 :           6 :       QStringLiteral("EXPUNGE_MOVE"),  QStringLiteral("COPY_ONLY"),
    1021                 :           6 :       QStringLiteral("EXPUNGE"),       QStringLiteral("APPEND"),
    1022                 :           6 :       QStringLiteral("CREATE"),        QStringLiteral("DELETE"),
    1023   [ +  +  +  -  :         704 :       QStringLiteral("RENAME"),        QStringLiteral("NOOP")};
          +  +  -  -  -  
                      - ]
    1024                 :             : 
    1025   [ +  -  +  -  :         581 :   for (const QString &type : m_pendingCommands) {
                   +  + ]
    1026         [ +  + ]:         123 :     if (statefulTypes.contains(type))
    1027                 :         114 :       return true;
    1028                 :             :   }
    1029                 :         458 :   return false;
    1030   [ +  -  -  -  :         126 : }
                   -  - ]
    1031                 :             : 
    1032                 :        3108 : bool ImapService::hasTimeoutTrackedCommandInFlight() const {
    1033   [ +  -  +  -  :        3291 :   for (const QString &type : m_pendingCommands) {
                   +  + ]
    1034         [ +  + ]:        2584 :     if (type != QStringLiteral("IDLE"))
    1035                 :        2401 :       return true;
    1036                 :             :   }
    1037                 :         707 :   return false;
    1038                 :             : }
    1039                 :             : 
    1040                 :          90 : void ImapService::enqueueSerializedCommand(std::function<void()> command) {
    1041                 :          90 :   m_serializedCommands.enqueue(std::move(command));
    1042   [ +  -  +  -  :         180 :   qCDebug(lcImap) << "Queued stateful IMAP command; queue size:"
             +  -  +  + ]
    1043         [ +  - ]:          90 :                   << m_serializedCommands.size();
    1044                 :          90 : }
    1045                 :             : 
    1046                 :         580 : void ImapService::runNextSerializedCommand() {
    1047   [ +  +  +  -  :         580 :   if (m_serializedCommands.isEmpty() || hasStatefulCommandInFlight())
             +  +  +  + ]
    1048                 :         518 :     return;
    1049                 :             : 
    1050         [ +  - ]:          62 :   auto command = m_serializedCommands.dequeue();
    1051         [ +  - ]:          62 :   command();
    1052                 :          62 : }
    1053                 :             : 
    1054                 :        3105 : void ImapService::refreshCommandTimeout() {
    1055         [ +  + ]:        3105 :   if (hasTimeoutTrackedCommandInFlight()) {
    1056                 :        2399 :     m_commandTimeoutTimer->start(COMMAND_TIMEOUT_MS);
    1057                 :             :   } else {
    1058                 :         706 :     m_commandTimeoutTimer->stop();
    1059                 :             :   }
    1060                 :        3105 : }
    1061                 :             : 
    1062                 :          11 : void ImapService::failConnection(const QString &error) {
    1063   [ +  -  +  -  :          22 :   qCWarning(lcImap) << error;
             +  -  +  + ]
    1064                 :          11 :   m_timeoutTimer->stop();
    1065                 :          11 :   m_commandTimeoutTimer->stop();
    1066                 :          11 :   m_idleRenewTimer->stop();
    1067                 :             :   // T-720: Stop the liveness probe + IDLE renew watchdogs on failure.
    1068                 :          11 :   m_livenessProbeWatchdog->stop();
    1069                 :          11 :   m_idleRenewWatchdog->stop();
    1070                 :          11 :   m_probeTag.clear();
    1071                 :          11 :   m_pendingCommands.clear();
    1072                 :          11 :   m_commandTimers.clear();
    1073                 :          11 :   m_deferredCommands.clear();
    1074                 :          11 :   m_serializedCommands.clear();
    1075                 :          11 :   m_readBuffer.clear();
    1076                 :          11 :   m_isIdling = false;
    1077                 :          11 :   m_idleTag.clear();
    1078                 :          11 :   m_isNotifying = false;
    1079                 :          11 :   m_notifyTag.clear();
    1080                 :          11 :   m_literalBytesRemaining = 0;
    1081                 :          11 :   m_literalData.clear();
    1082                 :          11 :   m_literalLine.clear();
    1083                 :          11 :   m_isBodyLiteral = false;
    1084                 :          11 :   m_bodyLiteralUid = -1;
    1085                 :          11 :   m_socket->abort();
    1086                 :          11 :   emit errorOccurred(error);
    1087                 :          11 :   setState(State::Error);
    1088                 :          11 : }
    1089                 :             : 
    1090                 :         867 : void ImapService::setState(State newState) {
    1091         [ +  + ]:         867 :   if (m_state != newState) {
    1092   [ +  -  +  -  :        1268 :     qCInfo(lcImap) << "State:"
             +  -  +  + ]
    1093         [ +  - ]:        1268 :                    << QMetaEnum::fromType<State>().valueToKey(
    1094   [ +  -  +  - ]:         634 :                           static_cast<int>(newState));
    1095                 :         634 :     m_state = newState;
    1096                 :         634 :     emit stateChanged(newState);
    1097                 :             :   }
    1098                 :         867 : }
    1099                 :             : 
    1100                 :         411 : void ImapService::sendCommand(const QString &type, const QString &command) {
    1101         [ +  - ]:         411 :   auto tag = nextTag();
    1102         [ +  - ]:         411 :   m_pendingCommands.insert(tag, type);
    1103                 :             : 
    1104                 :             :   // T-210: Start timing for this command
    1105                 :         411 :   QElapsedTimer timer;
    1106                 :         411 :   timer.start();
    1107         [ +  - ]:         411 :   m_commandTimers.insert(tag, timer);
    1108                 :             : 
    1109   [ +  -  +  -  :         411 :   auto fullCommand = tag + " " + command + "\r\n";
                   +  - ]
    1110                 :             :   // T-400/Bug 6: Mask credentials in LOGIN commands
    1111         [ +  + ]:         411 :   if (type == QStringLiteral("LOGIN")) {
    1112   [ +  -  +  -  :          18 :     qCDebug(lcImap) << ">>>" << tag << "LOGIN <user> <***>";
          +  -  +  -  +  
                -  +  + ]
    1113                 :             :   } else {
    1114   [ +  -  +  -  :         804 :     qCDebug(lcImap) << ">>>" << fullCommand.trimmed();
          +  -  +  -  +  
                -  +  + ]
    1115                 :             :   }
    1116   [ +  -  +  - ]:         411 :   m_socket->write(fullCommand.toUtf8());
    1117         [ +  - ]:         411 :   refreshCommandTimeout();
    1118                 :         411 : }
    1119                 :             : 
    1120                 :          10 : bool ImapService::beginLogin() {
    1121         [ +  + ]:          10 :   if (!m_socket->isEncrypted()) {
    1122   [ +  -  +  -  :           2 :     qCWarning(lcImap) << "Refusing IMAP LOGIN over unencrypted connection";
             +  -  +  + ]
    1123         [ +  - ]:           1 :     failConnection(QStringLiteral("Connection is not encrypted"));
    1124                 :           1 :     return false;
    1125                 :             :   }
    1126                 :             : 
    1127                 :           9 :   setState(State::Authenticating);
    1128   [ +  -  +  -  :          18 :   sendCommand("LOGIN", QString("LOGIN %1 %2")
                   +  - ]
    1129   [ +  -  +  - ]:          18 :                            .arg(quoteImapString(m_config.username),
    1130         [ +  - ]:          18 :                                 quoteImapString(
    1131         [ +  - ]:          18 :                                     QString::fromUtf8(m_config.password))));
    1132                 :           9 :   return true;
    1133                 :             : }
    1134                 :             : 
    1135                 :         546 : QString ImapService::nextTag() {
    1136   [ +  -  +  - ]:         546 :   return QString("A%1").arg(++m_tagCounter, 3, 10, QChar('0'));
    1137                 :             : }
    1138                 :             : 
    1139                 :          23 : QString ImapService::buildFetchBodyCommand(qint64 uid, qint64 maxBytes) {
    1140         [ +  + ]:          23 :   if (maxBytes > 0) {
    1141                 :           6 :     return QStringLiteral("UID FETCH %1 (UID BODY.PEEK[]<0.%2>)")
    1142         [ +  - ]:           9 :         .arg(uid)
    1143         [ +  - ]:           3 :         .arg(maxBytes);
    1144                 :             :   }
    1145         [ +  - ]:          40 :   return QStringLiteral("UID FETCH %1 (UID BODY.PEEK[])").arg(uid);
    1146                 :             : }
    1147                 :             : 
    1148                 :         239 : QString ImapService::quoteImapString(const QString &str) {
    1149                 :             :   // RFC 3501: quoted strings must not contain CR, LF, or NUL
    1150                 :         239 :   QString escaped = str;
    1151         [ +  - ]:         239 :   escaped.remove('\r');
    1152         [ +  - ]:         239 :   escaped.remove('\n');
    1153         [ +  - ]:         239 :   escaped.remove(QChar(0));
    1154   [ +  -  +  - ]:         239 :   escaped.replace('\\', "\\\\");
    1155   [ +  -  +  - ]:         239 :   escaped.replace('"', "\\\"");
    1156   [ +  -  +  - ]:         717 :   return '"' + escaped + '"';
    1157                 :         239 : }
    1158                 :             : 
    1159                 :           9 : void ImapService::onConnected() {
    1160   [ +  -  +  -  :          18 :   qCInfo(lcImap) << "TCP connected";
             +  -  +  + ]
    1161                 :           9 :   m_timeoutTimer->stop();
    1162                 :             :   // T-720/T-72.1: Now that the socket descriptor is valid, tune the
    1163                 :             :   // native TCP keepalive intervals. Idempotent — sets m_keepAliveTuned.
    1164                 :           9 :   tuneKeepAlive();
    1165                 :             : 
    1166         [ -  + ]:           9 :   if (m_config.security == "starttls") {
    1167                 :             :     // For STARTTLS, we wait for the greeting first, then issue STARTTLS
    1168                 :           0 :     setState(State::Connected);
    1169                 :             :   }
    1170                 :             :   // For SSL, wait for onEncrypted
    1171                 :           9 : }
    1172                 :             : 
    1173                 :           9 : void ImapService::onEncrypted() {
    1174   [ +  -  +  -  :          18 :   qCInfo(lcImap) << "TLS established";
             +  -  +  + ]
    1175                 :           9 :   m_timeoutTimer->stop();
    1176                 :             :   // T-720/T-72.1: For implicit-SSL connections onConnected() may not have
    1177                 :             :   // run (Qt emits encrypted directly). Tune here too — tuneKeepAlive() is
    1178                 :             :   // idempotent via the m_keepAliveTuned flag.
    1179                 :           9 :   tuneKeepAlive();
    1180                 :           9 :   setState(State::Connected);
    1181                 :           9 : }
    1182                 :             : 
    1183                 :        2078 : void ImapService::onReadyRead() {
    1184   [ +  -  +  - ]:        2078 :   m_readBuffer.append(m_socket->readAll());
    1185         [ +  - ]:        2078 :   if (!m_readBuffer.isEmpty())
    1186                 :        2078 :     refreshCommandTimeout();
    1187                 :             : 
    1188                 :             :   // T-510: DoS prevention — reject responses larger than 256 MB
    1189         [ -  + ]:        2078 :   if (m_readBuffer.size() > MAX_READ_BUFFER_SIZE) {
    1190         [ #  # ]:           0 :     failConnection(QStringLiteral("Server response too large"));
    1191                 :           0 :     return;
    1192                 :             :   }
    1193                 :             : 
    1194                 :             :   while (true) {
    1195                 :             :     // Mode 1: Currently accumulating literal bytes
    1196         [ +  + ]:        4267 :     if (m_literalBytesRemaining > 0) {
    1197         [ -  + ]:          39 :       if (m_readBuffer.size() < m_literalBytesRemaining)
    1198                 :           0 :         break; // need more data
    1199                 :             : 
    1200                 :             :       // Consume exactly N bytes
    1201   [ +  -  +  - ]:          39 :       m_literalData.append(m_readBuffer.left(m_literalBytesRemaining));
    1202         [ +  - ]:          39 :       m_readBuffer.remove(0, m_literalBytesRemaining);
    1203                 :          39 :       m_literalBytesRemaining = 0;
    1204                 :             : 
    1205                 :             :       // --- BODY[] literal: emit raw bytes directly, skip QString conversion
    1206                 :             :       // ---
    1207         [ +  + ]:          39 :       if (m_isBodyLiteral) {
    1208   [ +  -  +  -  :          58 :         qCInfo(lcImap) << "Body literal complete:" << m_literalData.size()
          +  -  +  -  +  
                      + ]
    1209   [ +  -  +  - ]:          29 :                        << "bytes for UID" << m_bodyLiteralUid;
    1210         [ +  - ]:          29 :         emit rawBodyReceived(m_bodyLiteralUid, m_literalData);
    1211         [ +  - ]:          29 :         m_literalData.clear();
    1212                 :          29 :         m_literalLine.clear();
    1213                 :          29 :         m_isBodyLiteral = false;
    1214                 :          29 :         m_bodyLiteralUid = -1;
    1215                 :             : 
    1216                 :             :         // Skip the rest of the FETCH line (closing paren etc.)
    1217                 :          29 :         int idx = m_readBuffer.indexOf("\r\n");
    1218         [ +  + ]:          29 :         if (idx >= 0) {
    1219         [ +  - ]:          28 :           m_readBuffer.remove(0, idx + 2);
    1220                 :             :         }
    1221                 :          39 :         continue;
    1222                 :          29 :       }
    1223                 :             : 
    1224                 :             :       // --- Non-body literal: convert to QString as before ---
    1225         [ +  - ]:          10 :       QString literalStr = QString::fromUtf8(m_literalData);
    1226                 :             :       // Escape quotes inside the literal so our parsers see a clean quoted
    1227                 :             :       // string
    1228   [ +  -  +  - ]:          10 :       literalStr.replace('\\', "\\\\");
    1229   [ +  -  +  - ]:          10 :       literalStr.replace('"', "\\\"");
    1230   [ +  -  +  -  :          10 :       m_literalLine.append('"' + literalStr + '"');
                   +  - ]
    1231         [ +  - ]:          10 :       m_literalData.clear();
    1232                 :             : 
    1233                 :             :       // There may be more data on the same logical line after the literal
    1234                 :             :       // (e.g. closing parens), so continue reading lines
    1235                 :          10 :       continue;
    1236                 :          10 :     }
    1237                 :             : 
    1238                 :             :     // Mode 2: Read next \r\n-delimited line
    1239                 :        4228 :     int idx = m_readBuffer.indexOf("\r\n");
    1240         [ +  + ]:        4228 :     if (idx < 0)
    1241                 :        2077 :       break;
    1242                 :             : 
    1243         [ +  - ]:        2151 :     auto lineBytes = m_readBuffer.left(idx);
    1244         [ +  - ]:        2151 :     m_readBuffer.remove(0, idx + 2);
    1245                 :             : 
    1246   [ +  -  +  - ]:        2151 :     auto line = QString::fromUtf8(lineBytes).trimmed();
    1247         [ -  + ]:        2151 :     if (line.isEmpty())
    1248                 :           0 :       continue;
    1249                 :             : 
    1250                 :             :     // Check if this line ends with a literal marker {N}
    1251   [ +  +  +  -  :        2151 :     static QRegularExpression literalRx(R"(\{(\d+)\}$)");
          +  -  +  -  -  
                      - ]
    1252         [ +  - ]:        2151 :     auto match = literalRx.match(line);
    1253   [ +  -  +  + ]:        2151 :     if (match.hasMatch()) {
    1254                 :             :       // T-401/Bug 9: Use qint64 to avoid overflow with >2GB literals
    1255                 :             :       bool ok;
    1256   [ +  -  +  - ]:          85 :       m_literalBytesRemaining = match.captured(1).toLongLong(&ok);
    1257   [ +  -  -  + ]:          85 :       if (!ok || m_literalBytesRemaining < 0) {
    1258   [ #  #  #  #  :           0 :         qCWarning(lcImap) << "Invalid literal size:" << match.captured(1);
          #  #  #  #  #  
                #  #  # ]
    1259         [ #  # ]:           0 :         failConnection(QStringLiteral("Invalid IMAP literal size"));
    1260                 :           1 :         return;
    1261                 :             :       }
    1262         [ +  + ]:          85 :       if (m_literalBytesRemaining > MAX_LITERAL_SIZE) {
    1263   [ +  -  +  -  :           2 :         qCWarning(lcImap) << "Literal size exceeded" << MAX_LITERAL_SIZE
          +  -  +  -  +  
                      + ]
    1264   [ +  -  +  - ]:           1 :                           << "bytes:" << m_literalBytesRemaining;
    1265         [ +  - ]:           1 :         failConnection(QStringLiteral("Server literal too large"));
    1266                 :           1 :         return;
    1267                 :             :       }
    1268                 :             :       // Remove the {N} from the line — it will be replaced by the literal
    1269                 :             :       // content
    1270   [ +  -  +  -  :          84 :       m_literalLine.append(line.left(match.capturedStart()));
                   +  - ]
    1271         [ +  - ]:          84 :       m_literalData.clear();
    1272                 :             : 
    1273                 :             :       // Detect if this is a BODY[] literal fetch
    1274                 :             :       // The line looks like: "* N FETCH (UID 42 BODY[] {12345}"
    1275   [ +  -  +  + ]:         168 :       if (m_literalLine.contains(QStringLiteral("BODY[]"))) {
    1276                 :          29 :         m_isBodyLiteral = true;
    1277                 :             :         // Extract UID from the line
    1278   [ +  +  +  -  :          29 :         static QRegularExpression uidRx(R"(UID\s+(\d+))");
          +  -  +  -  -  
                      - ]
    1279         [ +  - ]:          29 :         auto uidMatch = uidRx.match(m_literalLine);
    1280   [ +  -  +  - ]:          29 :         if (uidMatch.hasMatch()) {
    1281   [ +  -  +  - ]:          29 :           m_bodyLiteralUid = uidMatch.captured(1).toLongLong();
    1282                 :             :         }
    1283   [ +  -  +  -  :          58 :         qCInfo(lcImap) << "Collecting body literal:" << m_literalBytesRemaining
          +  -  +  -  +  
                      + ]
    1284   [ +  -  +  - ]:          29 :                        << "bytes for UID" << m_bodyLiteralUid;
    1285                 :          29 :       } else {
    1286                 :          55 :         m_isBodyLiteral = false;
    1287                 :             :       }
    1288                 :             : 
    1289                 :          84 :       continue;
    1290                 :          84 :     }
    1291                 :             : 
    1292                 :             :     // If we were building a multi-literal line, finalize it
    1293         [ +  + ]:        2066 :     if (!m_literalLine.isEmpty()) {
    1294         [ +  - ]:          55 :       m_literalLine.append(line);
    1295                 :          55 :       line = m_literalLine;
    1296                 :          55 :       m_literalLine.clear();
    1297                 :             :     }
    1298                 :             : 
    1299   [ +  -  +  -  :        4132 :     qCDebug(lcImap) << "<<<" << line.left(200)
          +  -  +  -  +  
                -  +  + ]
    1300   [ +  +  +  - ]:        2066 :                     << (line.length() > 200 ? "..." : "");
    1301         [ +  - ]:        2066 :     processLine(line);
    1302   [ +  +  +  +  :        4510 :   }
             +  +  +  +  
                      + ]
    1303                 :             : }
    1304                 :             : 
    1305                 :        2077 : void ImapService::processLine(const QString &line) {
    1306         [ +  + ]:        2077 :   if (ImapResponseParser::isUntagged(line)) {
    1307                 :        1486 :     handleUntagged(line);
    1308         [ +  + ]:         591 :   } else if (ImapResponseParser::isTagged(line)) {
    1309                 :         501 :     handleTagged(line);
    1310         [ +  + ]:          90 :   } else if (ImapResponseParser::isContinuation(line)) {
    1311                 :             :     // T-176: APPEND literal continuation – server sent "+"
    1312         [ +  + ]:          88 :     if (!m_pendingAppendData.isEmpty()) {
    1313   [ +  -  +  -  :          10 :       qCInfo(lcImap) << "APPEND continuation: sending"
             +  -  +  + ]
    1314   [ +  -  +  - ]:           5 :                      << m_pendingAppendData.size() << "bytes";
    1315                 :           5 :       m_socket->write(m_pendingAppendData);
    1316                 :           5 :       m_socket->write("\r\n");
    1317                 :           5 :       m_pendingAppendData.clear();
    1318                 :           5 :       refreshCommandTimeout();
    1319                 :             :     } else {
    1320   [ +  -  +  -  :         166 :       qCDebug(lcImap) << "Continuation response (ignored):" << line;
          +  -  +  -  +  
                      + ]
    1321                 :             :     }
    1322                 :             :   } else {
    1323   [ +  -  +  -  :           4 :     qCWarning(lcImap) << "Unrecognized response:" << line;
          +  -  +  -  +  
                      + ]
    1324                 :             :   }
    1325                 :        2077 : }
    1326                 :             : 
    1327                 :        1486 : void ImapService::handleUntagged(const QString &line) {
    1328         [ +  - ]:        1486 :   auto response = ImapResponseParser::parseUntaggedResponse(line);
    1329         [ -  + ]:        1486 :   if (!response)
    1330                 :           0 :     return;
    1331                 :             : 
    1332                 :        1486 :   const auto &type = response->type;
    1333                 :        1486 :   const auto &data = response->data;
    1334                 :             : 
    1335   [ +  +  -  +  :        1486 :   if (m_state == State::Connected && (type == "OK" || type == "PREAUTH")) {
             -  -  +  + ]
    1336                 :             :     // Server greeting
    1337   [ +  -  +  -  :          22 :     qCInfo(lcImap) << "Server greeting:" << data;
          +  -  +  -  +  
                      + ]
    1338         [ +  - ]:          11 :     setState(State::Greeting);
    1339                 :             : 
    1340                 :             :     // Request capabilities
    1341   [ +  -  +  -  :          11 :     sendCommand("CAPABILITY", "CAPABILITY");
                   +  - ]
    1342                 :          11 :     return;
    1343                 :             :   }
    1344                 :             : 
    1345         [ +  + ]:        1475 :   if (type == "CAPABILITY") {
    1346         [ +  - ]:          10 :     m_capabilities = ImapResponseParser::parseCapabilities(data);
    1347   [ +  -  +  -  :          20 :     qCInfo(lcImap) << "Capabilities:" << m_capabilities;
          +  -  +  -  +  
                      + ]
    1348                 :          10 :     return;
    1349                 :             :   }
    1350                 :             : 
    1351         [ +  + ]:        1465 :   if (type == "LIST") {
    1352         [ +  - ]:          60 :     auto folder = ImapResponseParser::parseListResponse(data);
    1353         [ +  - ]:          60 :     if (folder) {
    1354   [ +  -  +  - ]:          60 :       m_pendingFolders.append(folder.value());
    1355                 :             :     }
    1356                 :          60 :     return;
    1357                 :          60 :   }
    1358                 :             : 
    1359                 :             :   // FETCH responses: "* N FETCH (...)"
    1360   [ +  -  +  -  :        1405 :   if (data.startsWith("FETCH")) {
                   +  + ]
    1361                 :             :     // type is the sequence number, data starts with "FETCH"
    1362   [ +  -  +  - ]:         518 :     auto fetchData = data.mid(5).trimmed(); // skip "FETCH"
    1363                 :             : 
    1364                 :             :     // Check if this is a flags-only fetch (from fetchFlags or IDLE/NOTIFY push)
    1365   [ +  -  +  -  :        1495 :     if (fetchData.contains("FLAGS") && !fetchData.contains("ENVELOPE") &&
          +  +  +  -  +  
          -  +  +  +  -  
          +  +  -  -  -  
                      - ]
    1366   [ +  -  +  -  :         977 :         !fetchData.contains("BODY[]")) {
          +  +  +  +  +  
                +  -  - ]
    1367                 :             :       // Parse UID and FLAGS from "(UID 123 FLAGS (\Seen \Flagged))"
    1368         [ +  - ]:         458 :       auto parsed = ImapResponseParser::parseFetchFlagsResponse(fetchData);
    1369                 :             : 
    1370                 :             :       // T-320: Determine if this is a command response or unsolicited push.
    1371                 :             :       // With IDLE: no commands in-flight → always unsolicited.
    1372                 :             :       // With NOTIFY: commands run concurrently → check pending commands.
    1373                 :             :       bool isCommandResponse =
    1374   [ +  -  -  - ]:         546 :           m_pendingCommands.values().contains("FETCH_FLAGS") ||
    1375   [ +  -  +  +  :        1004 :           m_pendingCommands.values().contains("FETCH_SEQNO") ||
          +  -  -  +  +  
                -  -  - ]
    1376   [ +  -  +  +  :         546 :           m_pendingCommands.values().contains("FETCH_HEADERS");
             +  +  -  - ]
    1377                 :             : 
    1378         [ +  + ]:         458 :       if (parsed) {
    1379         [ +  + ]:         457 :         if (isCommandResponse) {
    1380                 :             :           // Response to explicit FETCH → accumulate for batch emit
    1381   [ +  -  +  - ]:         370 :           m_pendingFlags.append(parsed.value());
    1382                 :             :         } else {
    1383                 :             :           // Unsolicited push (IDLE or NOTIFY event) → emit immediately
    1384         [ +  - ]:          87 :           emit idleFlagsChanged(parsed->first, parsed->second);
    1385                 :             :         }
    1386         [ +  - ]:           1 :       } else if (!isCommandResponse) {
    1387                 :             :         // T-065: Unsolicited flag push WITHOUT UID (common for IMAP servers).
    1388                 :             :         // The sequence number is in the 'type' field from parseUntaggedResponse.
    1389                 :           1 :         bool ok = false;
    1390         [ +  - ]:           1 :         int seqNo = type.toInt(&ok);
    1391   [ +  -  +  - ]:           1 :         if (ok && seqNo > 0) {
    1392   [ +  -  +  -  :           2 :           qCInfo(lcImap) << "Push flag change without UID, seqNo:" << seqNo;
          +  -  +  -  +  
                      + ]
    1393         [ +  - ]:           1 :           emit idleFlagsNeedRefetch(seqNo);
    1394                 :             :         }
    1395                 :             :       }
    1396                 :         458 :       return;
    1397                 :             :     }
    1398                 :             : 
    1399                 :             :     // Check if this is a header or body fetch
    1400   [ +  -  +  -  :          60 :     if (fetchData.contains("ENVELOPE")) {
                   +  + ]
    1401         [ +  - ]:          58 :       auto header = ImapResponseParser::parseFetchHeaderResponse(fetchData);
    1402         [ +  + ]:          58 :       if (header) {
    1403   [ +  -  +  - ]:          57 :         m_pendingHeaders.append(header.value());
    1404   [ +  -  +  -  :         114 :         qCDebug(lcImap) << "Parsed header for UID" << header->uid;
          +  -  +  -  +  
                      + ]
    1405                 :             :       } else {
    1406                 :             :         // parseFetchHeaderResponse() returns nullopt ONLY when the FETCH
    1407                 :             :         // data carries no UID (everything else is parsed tolerantly with
    1408                 :             :         // empty fields). The former T-067 "fallback header" branch here
    1409                 :             :         // re-extracted the UID from the same data and could therefore
    1410                 :             :         // never succeed — verified dead code, removed in Sprint 65.
    1411   [ +  -  +  -  :           2 :         qCWarning(lcImap) << "FETCH without UID – mail dropped! data:"
             +  -  +  + ]
    1412   [ +  -  +  - ]:           1 :                           << fetchData.left(300);
    1413                 :             :       }
    1414                 :             :       // Streaming: emit batch when threshold reached
    1415         [ -  + ]:          58 :       if (m_pendingHeaders.size() >= HEADER_BATCH_SIZE) {
    1416         [ #  # ]:           0 :         emit headersReceived(m_pendingHeaders);
    1417         [ #  # ]:           0 :         m_pendingHeaders.clear();
    1418                 :             :       }
    1419   [ +  -  +  -  :          60 :     } else if (fetchData.contains("BODY[]")) {
                   +  - ]
    1420         [ +  - ]:           2 :       auto body = ImapResponseParser::parseFetchBodyResponse(fetchData);
    1421         [ +  + ]:           2 :       if (body) {
    1422         [ +  - ]:           1 :         emit rawBodyReceived(body->first, body->second);
    1423                 :             :       }
    1424                 :           2 :     }
    1425                 :          60 :     return;
    1426                 :         518 :   }
    1427                 :             : 
    1428         [ +  + ]:         887 :   if (type == "SEARCH") {
    1429                 :             :     // SEARCH response: data contains space-separated UIDs
    1430                 :             :     // e.g. type="SEARCH", data="1 2 3 45 67"
    1431         [ +  + ]:          86 :     if (!data.isEmpty()) {
    1432   [ +  -  +  -  :          95 :       for (const auto &token : data.split(' ', Qt::SkipEmptyParts)) {
             +  -  +  + ]
    1433                 :          66 :         bool ok = false;
    1434         [ +  - ]:          66 :         qint64 uid = token.toLongLong(&ok);
    1435         [ +  - ]:          66 :         if (ok) {
    1436         [ +  - ]:          66 :           m_pendingSearchUids.append(uid);
    1437                 :             :         }
    1438                 :          29 :       }
    1439                 :             :     }
    1440                 :          86 :     return;
    1441                 :             :   }
    1442                 :             : 
    1443                 :             :   // EXISTS response: "* N EXISTS" → type="N", data="EXISTS"
    1444         [ +  + ]:         801 :   if (data == "EXISTS") {
    1445                 :         121 :     bool ok = false;
    1446         [ +  - ]:         121 :     int count = type.toInt(&ok);
    1447         [ +  - ]:         121 :     if (ok) {
    1448                 :             :       // T-320: Only treat as new-message push if no command is in-flight
    1449                 :             :       // that would naturally produce EXISTS (SELECT, FETCH_HEADERS, etc.)
    1450                 :             :       bool isCommandResponse =
    1451   [ +  -  -  - ]:         136 :           m_pendingCommands.values().contains("SELECT") ||
    1452   [ +  -  +  +  :         257 :           m_pendingCommands.values().contains("FETCH_HEADERS") ||
          +  -  -  +  +  
                -  -  - ]
    1453   [ +  -  +  +  :         136 :           m_pendingCommands.values().contains("FETCH_FLAGS");
             +  +  -  - ]
    1454   [ +  +  +  + ]:         121 :       if (!isCommandResponse && count > m_selectedMessageCount) {
    1455                 :             :         // New messages arrived (IDLE or NOTIFY push)
    1456   [ +  -  +  -  :          22 :         qCInfo(lcImap) << "Push: new messages detected," << count
          +  -  +  -  +  
                      + ]
    1457   [ +  -  +  -  :          11 :                        << "total (was" << m_selectedMessageCount << ")";
                   +  - ]
    1458                 :          11 :         int newCount = count - m_selectedMessageCount;
    1459                 :          11 :         m_selectedMessageCount = count;
    1460         [ +  - ]:          11 :         emit idleNewMessages(newCount);
    1461                 :          11 :       } else {
    1462                 :         110 :         m_selectedMessageCount = count;
    1463   [ +  -  +  -  :         220 :         qCInfo(lcImap) << "EXISTS:" << count << "messages";
          +  -  +  -  +  
                -  +  + ]
    1464                 :             :       }
    1465                 :             :     }
    1466                 :         121 :     return;
    1467                 :             :   }
    1468                 :             : 
    1469                 :             :   // EXPUNGE response: "* N EXPUNGE" → type="N", data="EXPUNGE"
    1470         [ +  + ]:         680 :   if (data == "EXPUNGE") {
    1471                 :          18 :     bool ok = false;
    1472         [ +  - ]:          18 :     int seqNo = type.toInt(&ok);
    1473         [ +  - ]:          18 :     if (ok) {
    1474                 :          18 :       m_selectedMessageCount = qMax(0, m_selectedMessageCount - 1);
    1475   [ +  -  +  -  :          36 :       qCInfo(lcImap) << "EXPUNGE: message" << seqNo << "removed";
          +  -  +  -  +  
                -  +  + ]
    1476                 :             :       // T-320: Only emit push signal if no command caused this EXPUNGE
    1477                 :             :       bool isCommandResponse =
    1478   [ +  +  -  - ]:          36 :           m_pendingCommands.values().contains("EXPUNGE") ||
    1479   [ +  -  +  -  :          54 :           m_pendingCommands.values().contains("MOVE") ||
          +  -  -  +  +  
                -  -  - ]
    1480   [ +  -  +  +  :          27 :           m_pendingCommands.values().contains("SELECT");
             +  -  -  - ]
    1481         [ +  + ]:          18 :       if (!isCommandResponse) {
    1482         [ +  - ]:           9 :         emit idleMessageExpunged(seqNo);
    1483                 :             :       }
    1484                 :             :     }
    1485                 :          18 :     return;
    1486                 :             :   }
    1487                 :             : 
    1488                 :             :   // STATUS response: "* STATUS "INBOX" (MESSAGES 42 UNSEEN 3 RECENT 0)"
    1489         [ +  + ]:         662 :   if (type == "STATUS") {
    1490                 :             :     // data = '"INBOX" (MESSAGES 42 UNSEEN 3 RECENT 0)'
    1491         [ +  - ]:          14 :     auto parsed = ImapResponseParser::parseStatusResponse(data);
    1492         [ +  - ]:          14 :     if (parsed) {
    1493   [ +  -  +  - ]:          14 :       emit folderStatusReceived(parsed.value());
    1494                 :             :     }
    1495                 :          14 :     return;
    1496                 :          14 :   }
    1497                 :             : 
    1498                 :             :   // UIDVALIDITY in OK response: "* OK [UIDVALIDITY 12345]"
    1499         [ +  + ]:         648 :   if (type == "OK") {
    1500         [ +  - ]:         428 :     auto uv = ImapResponseParser::parseUidValidity(data);
    1501         [ +  + ]:         428 :     if (uv) {
    1502         [ +  - ]:         106 :       m_selectedUidValidity = uv.value();
    1503   [ +  -  +  -  :         212 :       qCInfo(lcImap) << "UIDVALIDITY:" << m_selectedUidValidity;
          +  -  +  -  +  
                      + ]
    1504                 :             :     }
    1505                 :             : 
    1506                 :             :     // T-208: Parse HIGHESTMODSEQ from "* OK [HIGHESTMODSEQ 12345]"
    1507                 :             :     static QRegularExpression modseqRx(
    1508   [ +  +  +  -  :         428 :         R"(\[HIGHESTMODSEQ\s+(\d+)\])", QRegularExpression::CaseInsensitiveOption);
          +  -  +  -  -  
                      - ]
    1509         [ +  - ]:         428 :     auto modseqMatch = modseqRx.match(data);
    1510   [ +  -  -  + ]:         428 :     if (modseqMatch.hasMatch()) {
    1511   [ #  #  #  # ]:           0 :       m_selectedHighestModseq = modseqMatch.captured(1).toULongLong();
    1512   [ #  #  #  #  :           0 :       qCInfo(lcImap) << "T-208: HIGHESTMODSEQ:" << m_selectedHighestModseq;
          #  #  #  #  #  
                      # ]
    1513                 :             :     }
    1514                 :         428 :     return;
    1515                 :         428 :   }
    1516                 :             : 
    1517         [ +  + ]:         220 :   if (type == "BYE") {
    1518   [ +  -  +  -  :           2 :     qCInfo(lcImap) << "Server BYE:" << data;
          +  -  +  -  +  
                      + ]
    1519         [ +  - ]:           1 :     setState(State::Disconnected);
    1520                 :           1 :     return;
    1521                 :             :   }
    1522         [ +  + ]:        1486 : }
    1523                 :             : 
    1524                 :         566 : void ImapService::handleTagged(const QString &line) {
    1525         [ +  - ]:         566 :   auto response = ImapResponseParser::parseTaggedResponse(line);
    1526         [ -  + ]:         566 :   if (!response)
    1527                 :           0 :     return;
    1528                 :             : 
    1529         [ +  - ]:         566 :   auto commandType = m_pendingCommands.take(response->tag);
    1530                 :             :   struct SerializedDrainGuard {
    1531                 :             :     ImapService *service = nullptr;
    1532                 :         566 :     ~SerializedDrainGuard() {
    1533         [ +  - ]:         566 :       if (service)
    1534                 :         566 :         service->runNextSerializedCommand();
    1535                 :         566 :     }
    1536                 :         566 :   } drainGuard{this};
    1537                 :             : 
    1538                 :             :   // T-210: Log command duration
    1539         [ +  - ]:         566 :   auto timerIt = m_commandTimers.find(response->tag);
    1540   [ +  -  +  + ]:         566 :   if (timerIt != m_commandTimers.end()) {
    1541   [ +  -  +  -  :         880 :     qCInfo(lcImapTiming) << "IMAP timing:" << commandType
          +  -  +  -  +  
                      + ]
    1542   [ +  -  +  - ]:         440 :                          << m_selectedFolder << "→"
    1543   [ +  -  +  - ]:         440 :                          << timerIt->elapsed() << "ms";
    1544         [ +  - ]:         440 :     m_commandTimers.erase(timerIt);
    1545                 :             :   }
    1546         [ +  - ]:         566 :   refreshCommandTimeout();
    1547                 :             : 
    1548                 :             :   // T-720: Clear the liveness probe state when its tagged response arrives
    1549                 :             :   // (any OK/NO from the server proves the socket is alive). BAD responses
    1550                 :             :   // are surfaced below by the regular errorOccurred handlers, which call
    1551                 :             :   // failConnection() and stop the watchdog there.
    1552   [ +  +  +  -  :         566 :   if (!m_probeTag.isEmpty() && m_probeTag == response->tag) {
                   +  + ]
    1553   [ +  -  +  -  :          74 :     qCInfo(lcImap) << "Liveness probe OK (" << m_probeTag
          +  -  +  -  +  
                      + ]
    1554         [ +  - ]:          37 :                    << ") — disarming watchdog";
    1555                 :          37 :     m_probeTag.clear();
    1556         [ +  - ]:          37 :     m_livenessProbeWatchdog->stop();
    1557                 :             :   }
    1558                 :             : 
    1559   [ +  -  +  -  :        1132 :   qCDebug(lcImap) << "Tagged response for" << commandType << ":"
          +  -  +  -  +  
                -  +  + ]
    1560   [ +  -  +  - ]:         566 :                   << response->status << response->message;
    1561                 :             : 
    1562         [ +  + ]:         566 :   if (commandType == "CAPABILITY") {
    1563         [ +  - ]:          10 :     if (response->status == "OK") {
    1564         [ +  - ]:          10 :       setState(State::Capability);
    1565                 :             : 
    1566   [ +  +  +  + ]:          11 :       if (m_config.security == "starttls" &&
    1567   [ +  -  +  - ]:           1 :           !m_socket->isEncrypted()) {
    1568   [ +  -  -  + ]:           1 :         if (m_capabilities.contains("STARTTLS", Qt::CaseInsensitive)) {
    1569                 :             :           // Need to upgrade to TLS
    1570         [ #  # ]:           0 :           setState(State::StartingTLS);
    1571   [ #  #  #  #  :           0 :           sendCommand("STARTTLS", "STARTTLS");
                   #  # ]
    1572                 :             :         } else {
    1573                 :             :           // T-400/Bug 2: Refuse plaintext login when STARTTLS was requested
    1574   [ +  -  +  -  :           2 :           qCWarning(lcImap) << "STARTTLS requested but not offered by server"
             +  -  +  + ]
    1575         [ +  - ]:           1 :                             << "— refusing to send credentials in plaintext";
    1576         [ +  - ]:           1 :           emit errorOccurred(QStringLiteral(
    1577                 :             :               "Server does not support STARTTLS — login refused for security"));
    1578         [ +  - ]:           1 :           setState(State::Error);
    1579                 :             :         }
    1580                 :             :       } else {
    1581                 :             :         // Proceed to login
    1582         [ +  - ]:           9 :         beginLogin();
    1583                 :             :       }
    1584                 :             :     } else {
    1585   [ #  #  #  # ]:           0 :       emit errorOccurred("CAPABILITY failed: " + response->message);
    1586         [ #  # ]:           0 :       setState(State::Error);
    1587                 :             :     }
    1588                 :          10 :     return;
    1589                 :             :   }
    1590                 :             : 
    1591         [ +  + ]:         556 :   if (commandType == "STARTTLS") {
    1592         [ +  - ]:           1 :     if (response->status == "OK") {
    1593   [ -  +  -  -  :           1 :       if (!m_readBuffer.isEmpty() || m_socket->bytesAvailable() > 0) {
             -  -  +  - ]
    1594         [ +  - ]:           1 :         failConnection(
    1595                 :           2 :             QStringLiteral("Unexpected data before TLS handshake"));
    1596                 :           1 :         return;
    1597                 :             :       }
    1598   [ #  #  #  #  :           0 :       qCInfo(lcImap) << "STARTTLS accepted, starting TLS handshake...";
             #  #  #  # ]
    1599                 :             :       // Upgrade connection to TLS
    1600                 :           0 :       QObject::connect(
    1601                 :           0 :           m_socket, &QSslSocket::encrypted, this,
    1602         [ #  # ]:           0 :           [this]() {
    1603   [ #  #  #  #  :           0 :             qCInfo(lcImap) << "STARTTLS handshake complete";
             #  #  #  # ]
    1604                 :             :             // Now login
    1605                 :           0 :             beginLogin();
    1606                 :           0 :           },
    1607                 :             :           Qt::SingleShotConnection);
    1608         [ #  # ]:           0 :       m_socket->startClientEncryption();
    1609                 :             :     } else {
    1610   [ #  #  #  # ]:           0 :       emit errorOccurred("STARTTLS failed: " + response->message);
    1611         [ #  # ]:           0 :       setState(State::Error);
    1612                 :             :     }
    1613                 :           0 :     return;
    1614                 :             :   }
    1615                 :             : 
    1616         [ +  + ]:         555 :   if (commandType == "LOGIN") {
    1617                 :             :     // SEC-22: Zero password from memory after LOGIN (success or failure)
    1618         [ +  - ]:           9 :     SecureUtil::zeroMemory(m_config.password);
    1619         [ +  + ]:           9 :     if (response->status == "OK") {
    1620   [ +  -  +  -  :          16 :       qCInfo(lcImap) << "Login successful";
             +  -  +  + ]
    1621         [ +  - ]:           8 :       setState(State::Authenticated);
    1622                 :             :     } else {
    1623   [ +  -  +  - ]:           1 :       emit errorOccurred("Authentication failed: " + response->message);
    1624         [ +  - ]:           1 :       setState(State::Error);
    1625                 :             :     }
    1626                 :           9 :     return;
    1627                 :             :   }
    1628                 :             : 
    1629         [ +  + ]:         546 :   if (commandType == "LIST") {
    1630         [ +  - ]:           9 :     if (response->status == "OK") {
    1631   [ +  -  +  -  :          18 :       qCInfo(lcImap) << "LIST complete:" << m_pendingFolders.size()
          +  -  +  -  +  
                      + ]
    1632         [ +  - ]:           9 :                      << "folders";
    1633         [ +  - ]:           9 :       emit folderListReceived(m_pendingFolders);
    1634         [ +  - ]:           9 :       m_pendingFolders.clear();
    1635                 :             :     } else {
    1636   [ #  #  #  # ]:           0 :       emit errorOccurred("LIST failed: " + response->message);
    1637                 :             :     }
    1638                 :           9 :     return;
    1639                 :             :   }
    1640                 :             : 
    1641         [ +  + ]:         537 :   if (commandType == "SELECT") {
    1642         [ +  + ]:         111 :     if (response->status == "OK") {
    1643                 :             :       // Bug 34: Only assign m_selectedFolder on SELECT OK
    1644                 :         109 :       m_selectedFolder = m_pendingSelectFolder;
    1645   [ +  -  +  -  :         218 :       qCInfo(lcImap) << "SELECT complete:" << m_selectedFolder
          +  -  +  -  +  
                      + ]
    1646   [ +  -  +  - ]:         109 :                      << "msgs=" << m_selectedMessageCount
    1647   [ +  -  +  - ]:         109 :                      << "uidvalidity=" << m_selectedUidValidity;
    1648         [ +  - ]:         109 :       setState(State::Selected);
    1649         [ +  - ]:         109 :       emit folderSelected(m_selectedFolder, m_selectedMessageCount,
    1650                 :             :                           m_selectedUidValidity, m_selectedHighestModseq);
    1651                 :             :     } else {
    1652   [ +  -  +  - ]:           2 :       emit errorOccurred("SELECT failed: " + response->message);
    1653                 :             :       // Notify multi-folder flows so they can skip this folder instead of
    1654                 :             :       // stalling (real servers reject \Noselect container folders here).
    1655         [ +  - ]:           2 :       emit folderSelectFailed(m_pendingSelectFolder);
    1656                 :             :     }
    1657                 :             :     // T-401/Bug 10: Only drain deferred commands on SELECT success
    1658   [ +  +  +  +  :         111 :     if (response->status == "OK" && !m_deferredCommands.isEmpty()) {
                   +  + ]
    1659         [ +  - ]:           4 :       auto cmd = m_deferredCommands.dequeue();
    1660         [ +  - ]:           4 :       cmd();
    1661         [ +  + ]:         111 :     } else if (response->status != "OK") {
    1662                 :             :       // Discard deferred commands — they'd operate on wrong folder
    1663         [ +  + ]:           2 :       if (!m_deferredCommands.isEmpty()) {
    1664   [ +  -  +  -  :           2 :         qCWarning(lcImap) << "SELECT failed — discarding"
             +  -  +  + ]
    1665         [ +  - ]:           1 :                           << m_deferredCommands.size()
    1666         [ +  - ]:           1 :                           << "deferred commands";
    1667         [ +  - ]:           1 :         m_deferredCommands.clear();
    1668                 :             :       }
    1669                 :             :     }
    1670                 :         111 :     return;
    1671                 :             :   }
    1672                 :             : 
    1673         [ +  + ]:         426 :   if (commandType == "FETCH_HEADERS") {
    1674         [ +  - ]:          15 :     if (response->status == "OK") {
    1675   [ +  -  +  -  :          30 :       qCInfo(lcImap) << "FETCH headers complete:" << m_pendingHeaders.size()
          +  -  +  -  +  
                      + ]
    1676         [ +  - ]:          15 :                      << "remaining";
    1677                 :             :       // Emit any remaining buffered headers
    1678         [ +  + ]:          15 :       if (!m_pendingHeaders.isEmpty()) {
    1679         [ +  - ]:          11 :         emit headersReceived(m_pendingHeaders);
    1680         [ +  - ]:          11 :         m_pendingHeaders.clear();
    1681                 :             :       }
    1682                 :             :       // Signal that all header batches have been delivered
    1683         [ +  - ]:          15 :       emit headerFetchComplete();
    1684                 :             :     } else {
    1685   [ #  #  #  # ]:           0 :       emit errorOccurred("FETCH headers failed: " + response->message);
    1686                 :             :     }
    1687                 :          15 :     return;
    1688                 :             :   }
    1689                 :             : 
    1690         [ +  + ]:         411 :   if (commandType == "FETCH_BODY") {
    1691         [ -  + ]:          30 :     if (response->status != "OK") {
    1692   [ #  #  #  # ]:           0 :       emit errorOccurred("FETCH body failed: " + response->message);
    1693                 :             :     }
    1694                 :             :     // Body is emitted in handleUntagged when the data arrives
    1695                 :             :     // Drain deferred commands (e.g. next body fetch in search mode)
    1696         [ +  + ]:          30 :     if (!m_deferredCommands.isEmpty()) {
    1697         [ +  - ]:           1 :       auto cmd = m_deferredCommands.dequeue();
    1698         [ +  - ]:           1 :       cmd();
    1699                 :           1 :     } else {
    1700                 :             :       // No deferred commands — restart IDLE if possible
    1701                 :             :       // (STORE handler restarts IDLE for markSeen; this covers already-seen mails)
    1702         [ +  - ]:          29 :       restartPush();
    1703                 :             :     }
    1704                 :          30 :     return;
    1705                 :             :   }
    1706                 :             : 
    1707         [ +  + ]:         381 :   if (commandType == "FETCH_FLAGS") {
    1708         [ +  - ]:          55 :     if (response->status == "OK") {
    1709   [ +  -  +  -  :         110 :       qCInfo(lcImap) << "FETCH flags complete:" << m_pendingFlags.size()
          +  -  +  -  +  
                      + ]
    1710         [ +  - ]:          55 :                      << "entries";
    1711         [ +  - ]:          55 :       emit flagsReceived(m_pendingFlags);
    1712         [ +  - ]:          55 :       m_pendingFlags.clear();
    1713                 :             :     } else {
    1714   [ #  #  #  # ]:           0 :       emit errorOccurred("FETCH flags failed: " + response->message);
    1715                 :             :     }
    1716                 :          55 :     return;
    1717                 :             :   }
    1718                 :             : 
    1719         [ +  + ]:         326 :   if (commandType == "SEARCH") {
    1720         [ +  + ]:          91 :     if (response->status == "OK") {
    1721   [ +  -  +  -  :         180 :       qCInfo(lcImap) << "SEARCH complete:" << m_pendingSearchUids.size()
          +  -  +  -  +  
                      + ]
    1722         [ +  - ]:          90 :                      << "UIDs";
    1723         [ +  - ]:          90 :       emit searchResultReceived(m_pendingSearchUids);
    1724         [ +  - ]:          90 :       m_pendingSearchUids.clear();
    1725                 :             :     } else {
    1726   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "SEARCH failed:" << response->message;
          +  -  +  -  +  
                      + ]
    1727                 :             :       // Still emit a (empty) result so multi-folder search flows advance
    1728                 :             :       // instead of waiting forever for a result that will never arrive.
    1729         [ +  - ]:           1 :       m_pendingSearchUids.clear();
    1730         [ +  - ]:           1 :       emit searchResultReceived(m_pendingSearchUids);
    1731                 :             :     }
    1732                 :             :     // Drain deferred commands (e.g. body fetch queued during SEARCH)
    1733         [ +  + ]:          91 :     if (!m_deferredCommands.isEmpty()) {
    1734         [ +  - ]:           4 :       auto cmd = m_deferredCommands.dequeue();
    1735         [ +  - ]:           4 :       cmd();
    1736                 :           4 :     }
    1737                 :          91 :     return;
    1738                 :             :   }
    1739                 :             : 
    1740                 :             :   // T-065 fix: Single-UID flag fetch (from IDLE seqNo resolution).
    1741                 :             :   // Emit each result directly as idleFlagsChanged → targeted update, no purge.
    1742         [ +  + ]:         235 :   if (commandType == "FETCH_SEQNO") {
    1743         [ +  + ]:           3 :     if (response->status == "OK") {
    1744   [ +  -  +  -  :           4 :       qCInfo(lcImap) << "FETCH_SEQNO complete:" << m_pendingFlags.size()
          +  -  +  -  +  
                      + ]
    1745         [ +  - ]:           2 :                      << "entries";
    1746   [ +  -  +  -  :           4 :       for (const auto &[uid, flags] : m_pendingFlags) {
                   +  + ]
    1747         [ +  - ]:           2 :         emit idleFlagsChanged(uid, flags);
    1748                 :             :       }
    1749         [ +  - ]:           2 :       m_pendingFlags.clear();
    1750                 :             :     } else {
    1751   [ +  -  +  - ]:           1 :       emit errorOccurred("FETCH seqNo failed: " + response->message);
    1752                 :             :     }
    1753                 :             :     // Drain remaining deferred commands (e.g. more flag refetches from
    1754                 :             :     // multiple IDLE pushes) before restarting IDLE.
    1755         [ +  + ]:           3 :     if (!m_deferredCommands.isEmpty()) {
    1756         [ +  - ]:           1 :       auto cmd = m_deferredCommands.dequeue();
    1757         [ +  - ]:           1 :       cmd();
    1758                 :           1 :     } else {
    1759         [ +  - ]:           2 :       restartPush();
    1760                 :             :     }
    1761                 :           3 :     return;
    1762                 :             :   }
    1763                 :             : 
    1764         [ +  + ]:         232 :   if (commandType == "IDLE") {
    1765                 :          83 :     m_isIdling = false;
    1766         [ +  - ]:          83 :     setState(State::Selected);
    1767         [ +  + ]:          83 :     if (response->status == "OK") {
    1768                 :             :       // T-720: The DONE/OK round-trip (renew, executeAfterIdle, or liveness
    1769                 :             :       // probe) proved the connection is alive — disarm its watchdog.
    1770         [ +  - ]:          81 :       m_idleRenewWatchdog->stop();
    1771   [ +  -  +  -  :         162 :       qCInfo(lcImap) << "IDLE ended normally";
             +  -  +  + ]
    1772                 :             :       // Process deferred commands first
    1773         [ +  + ]:          81 :       if (!m_deferredCommands.isEmpty()) {
    1774         [ +  - ]:          73 :         auto cmd = m_deferredCommands.dequeue();
    1775         [ +  - ]:          73 :         cmd();
    1776   [ +  -  +  + ]:          81 :       } else if (!m_idleRenewTimer->isActive()) {
    1777                 :             :         // Timer expired = renew, restart IDLE
    1778         [ +  - ]:           7 :         restartPush();
    1779                 :             :       }
    1780                 :             :       // Timer still active + no deferred = manual stop, don't restart
    1781                 :             :     } else {
    1782                 :             :       // T-720: Treat a non-OK IDLE response as a dead connection. The old
    1783                 :             :       // code only drained deferred commands and left the socket nominally
    1784                 :             :       // open — failing the sprint's dead-socket detection goal and leaving
    1785                 :             :       // the watchdog disarmed.
    1786   [ +  -  +  -  :           4 :       qCWarning(lcImap) << "IDLE rejected by server:" << response->message
          +  -  +  -  +  
                      + ]
    1787         [ +  - ]:           2 :                         << "— failing connection";
    1788         [ +  - ]:           2 :       m_idleRenewWatchdog->stop();
    1789         [ +  - ]:           2 :       failConnection(
    1790         [ +  - ]:           6 :           QStringLiteral("IDLE rejected by server: %1").arg(response->message));
    1791                 :           2 :       return;
    1792                 :             :     }
    1793                 :          81 :     return;
    1794                 :             :   }
    1795                 :             : 
    1796                 :             :   // T-320: NOTIFY SET response
    1797         [ +  + ]:         149 :   if (commandType == "NOTIFY") {
    1798         [ +  - ]:           1 :     if (response->status == "OK") {
    1799   [ +  -  +  -  :           2 :       qCInfo(lcImap) << "NOTIFY active — watching"
             +  -  +  + ]
    1800   [ +  -  +  - ]:           1 :                      << m_notifyFolders.size() << "folders";
    1801                 :             :     } else {
    1802   [ #  #  #  #  :           0 :       qCWarning(lcImap) << "NOTIFY failed:" << response->message;
          #  #  #  #  #  
                      # ]
    1803                 :           0 :       m_isNotifying = false;
    1804                 :             :       // Fallback: try IDLE instead
    1805   [ #  #  #  #  :           0 :       if (m_autoIdle && hasIdleCapability()) {
             #  #  #  # ]
    1806   [ #  #  #  #  :           0 :         qCInfo(lcImap) << "Falling back to IDLE";
             #  #  #  # ]
    1807         [ #  # ]:           0 :         startIdle();
    1808                 :             :       }
    1809                 :             :     }
    1810                 :           1 :     return;
    1811                 :             :   }
    1812                 :             : 
    1813                 :             :   // T-320: NOTIFY NONE response
    1814         [ +  + ]:         148 :   if (commandType == "NOTIFY_NONE") {
    1815                 :           2 :     m_isNotifying = false;
    1816         [ +  + ]:           2 :     if (response->status == "OK") {
    1817   [ +  -  +  -  :           2 :       qCInfo(lcImap) << "NOTIFY stopped";
             +  -  +  + ]
    1818         [ +  - ]:           1 :       if (!m_deferredCommands.isEmpty()) {
    1819         [ +  - ]:           1 :         auto cmd = m_deferredCommands.dequeue();
    1820         [ +  - ]:           1 :         cmd();
    1821                 :           1 :       }
    1822                 :             :     } else {
    1823   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "NOTIFY NONE failed:" << response->message;
          +  -  +  -  +  
                      + ]
    1824         [ +  - ]:           1 :       if (!m_deferredCommands.isEmpty()) {
    1825         [ +  - ]:           1 :         auto cmd = m_deferredCommands.dequeue();
    1826         [ +  - ]:           1 :         cmd();
    1827                 :           1 :       }
    1828                 :             :     }
    1829                 :           2 :     return;
    1830                 :             :   }
    1831                 :             : 
    1832         [ +  + ]:         146 :   if (commandType == "STATUS") {
    1833                 :             :     // STATUS responses are handled in handleUntagged
    1834         [ -  + ]:          12 :     if (response->status != "OK") {
    1835                 :             :       // Non-fatal: folder may not exist (e.g. dovecot/sieve) – log, don't alarm
    1836   [ #  #  #  #  :           0 :       qCWarning(lcImap) << "STATUS failed for" << m_pendingStatusFolder
          #  #  #  #  #  
                      # ]
    1837   [ #  #  #  # ]:           0 :                         << ":" << response->message;
    1838                 :             :     }
    1839                 :          12 :     m_pendingStatusFolder.clear();
    1840                 :          12 :     return;
    1841                 :             :   }
    1842                 :             : 
    1843         [ +  + ]:         134 :   if (commandType == "STORE") {
    1844         [ +  - ]:          43 :     if (response->status == "OK") {
    1845         [ +  - ]:          43 :       emit storeComplete();
    1846                 :             :     } else {
    1847   [ #  #  #  # ]:           0 :       emit errorOccurred("STORE failed: " + response->message);
    1848                 :             :     }
    1849                 :             :     // Drain remaining deferred commands, then restart IDLE/NOTIFY
    1850         [ +  + ]:          43 :     if (!m_deferredCommands.isEmpty()) {
    1851         [ +  - ]:           8 :       auto cmd = m_deferredCommands.dequeue();
    1852         [ +  - ]:           8 :       cmd();
    1853                 :           8 :     } else {
    1854         [ +  - ]:          35 :       restartPush();
    1855                 :             :     }
    1856                 :          43 :     return;
    1857                 :             :   }
    1858                 :             : 
    1859                 :             :   // T-100: MOVE command (RFC 6851)
    1860         [ +  + ]:          91 :   if (commandType == "MOVE") {
    1861         [ +  - ]:           6 :     if (response->status == "OK") {
    1862   [ +  -  +  -  :          12 :       qCInfo(lcImap) << "MOVE complete:" << m_pendingMoveUids.size()
          +  -  +  -  +  
                      + ]
    1863   [ +  -  +  - ]:           6 :                      << "UIDs to" << m_pendingMoveTarget;
    1864   [ +  -  +  -  :          15 :       for (qint64 uid : m_pendingMoveUids)
                   +  + ]
    1865         [ +  - ]:           9 :         emit messageMoved(uid, m_pendingMoveTarget);
    1866         [ +  - ]:           6 :       emit messagesMoved(m_pendingMoveUids, m_pendingMoveTarget);
    1867                 :             :     } else {
    1868   [ #  #  #  #  :           0 :       qCWarning(lcImap) << "MOVE failed:" << response->message;
          #  #  #  #  #  
                      # ]
    1869         [ #  # ]:           0 :       emit moveError(response->message);
    1870                 :             :     }
    1871         [ +  - ]:           6 :     m_pendingMoveUids.clear();
    1872                 :           6 :     m_pendingMoveTarget.clear();
    1873         [ +  + ]:           6 :     if (!m_deferredCommands.isEmpty()) {
    1874         [ +  - ]:           1 :       auto cmd = m_deferredCommands.dequeue();
    1875         [ +  - ]:           1 :       cmd();
    1876                 :           1 :     } else {
    1877         [ +  - ]:           5 :       restartPush();
    1878                 :             :     }
    1879                 :           6 :     return;
    1880                 :             :   }
    1881                 :             : 
    1882                 :             :   // T-100: COPY (fallback for MOVE: COPY + DELETE + EXPUNGE)
    1883         [ +  + ]:          85 :   if (commandType == "COPY") {
    1884         [ +  + ]:           3 :     if (response->status == "OK") {
    1885   [ +  -  +  -  :           4 :       qCInfo(lcImap) << "COPY (move-fallback) complete, marking deleted...";
             +  -  +  + ]
    1886                 :             :       // Step 2: mark originals as \Deleted
    1887                 :           2 :       QStringList uidStrs;
    1888   [ +  -  +  -  :           5 :       for (qint64 u : m_pendingMoveUids)
                   +  + ]
    1889   [ +  -  +  - ]:           3 :         uidStrs.append(QString::number(u));
    1890   [ +  -  +  - ]:           2 :       sendCommand("STORE_DELETE",
    1891   [ +  -  +  -  :           6 :                   QString("UID STORE %1 +FLAGS (\\Deleted)").arg(uidStrs.join(',')));
                   +  - ]
    1892                 :           2 :     } else {
    1893   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "COPY failed:" << response->message;
          +  -  +  -  +  
                      + ]
    1894         [ +  - ]:           1 :       emit moveError(response->message);
    1895         [ +  - ]:           1 :       m_pendingMoveUids.clear();
    1896                 :           1 :       m_pendingMoveTarget.clear();
    1897         [ +  - ]:           1 :       restartPush();
    1898                 :             :     }
    1899                 :           3 :     return;
    1900                 :             :   }
    1901                 :             : 
    1902                 :             :   // T-100: STORE_DELETE (part of COPY+DELETE+EXPUNGE fallback)
    1903         [ +  + ]:          82 :   if (commandType == "STORE_DELETE") {
    1904         [ +  + ]:           3 :     if (response->status == "OK") {
    1905   [ +  -  +  -  :           4 :       qCInfo(lcImap) << "Marked deleted, expunging...";
             +  -  +  + ]
    1906   [ +  -  +  -  :           2 :       sendCommand("EXPUNGE_MOVE", "EXPUNGE");
                   +  - ]
    1907                 :             :     } else {
    1908   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "STORE DELETE failed:" << response->message;
          +  -  +  -  +  
                      + ]
    1909         [ +  - ]:           1 :       emit moveError(response->message);
    1910         [ +  - ]:           1 :       m_pendingMoveUids.clear();
    1911                 :           1 :       m_pendingMoveTarget.clear();
    1912         [ +  - ]:           1 :       restartPush();
    1913                 :             :     }
    1914                 :           3 :     return;
    1915                 :             :   }
    1916                 :             : 
    1917                 :             :   // T-100: EXPUNGE after COPY+DELETE (completes fallback MOVE)
    1918         [ +  + ]:          79 :   if (commandType == "EXPUNGE_MOVE") {
    1919         [ +  + ]:           5 :     if (response->status == "OK") {
    1920   [ +  -  +  -  :           6 :       qCInfo(lcImap) << "EXPUNGE complete, move-fallback done:"
             +  -  +  + ]
    1921   [ +  -  +  - ]:           3 :                      << m_pendingMoveUids.size() << "UIDs to"
    1922         [ +  - ]:           3 :                      << m_pendingMoveTarget;
    1923   [ +  -  +  -  :           6 :       for (qint64 uid : m_pendingMoveUids)
                   +  + ]
    1924         [ +  - ]:           3 :         emit messageMoved(uid, m_pendingMoveTarget);
    1925         [ +  - ]:           3 :       emit messagesMoved(m_pendingMoveUids, m_pendingMoveTarget);
    1926                 :             :     } else {
    1927   [ +  -  +  -  :           4 :       qCWarning(lcImap) << "EXPUNGE failed:" << response->message;
          +  -  +  -  +  
                      + ]
    1928         [ +  - ]:           2 :       emit moveError(response->message);
    1929                 :             :     }
    1930         [ +  - ]:           5 :     m_pendingMoveUids.clear();
    1931                 :           5 :     m_pendingMoveTarget.clear();
    1932         [ +  + ]:           5 :     if (!m_deferredCommands.isEmpty()) {
    1933         [ +  - ]:           2 :       auto cmd = m_deferredCommands.dequeue();
    1934         [ +  - ]:           2 :       cmd();
    1935                 :           2 :     } else {
    1936         [ +  - ]:           3 :       restartPush();
    1937                 :             :     }
    1938                 :           5 :     return;
    1939                 :             :   }
    1940                 :             : 
    1941                 :             :   // T-100: Standalone COPY (not part of MOVE fallback)
    1942         [ +  + ]:          74 :   if (commandType == "COPY_ONLY") {
    1943         [ +  + ]:           5 :     if (response->status == "OK") {
    1944   [ +  +  +  - ]:           3 :       auto uid = m_pendingMoveUids.isEmpty() ? -1 : m_pendingMoveUids.first();
    1945   [ +  -  +  -  :           6 :       qCInfo(lcImap) << "COPY complete: UID" << uid
          +  -  +  -  +  
                      + ]
    1946   [ +  -  +  - ]:           3 :                      << "to" << m_pendingMoveTarget;
    1947         [ +  - ]:           3 :       emit messageCopied(uid, m_pendingMoveTarget);
    1948                 :             :     } else {
    1949   [ +  -  +  - ]:           2 :       emit errorOccurred("COPY failed: " + response->message);
    1950                 :             :     }
    1951         [ +  - ]:           5 :     m_pendingMoveUids.clear();
    1952                 :           5 :     m_pendingMoveTarget.clear();
    1953         [ +  + ]:           5 :     if (!m_deferredCommands.isEmpty()) {
    1954         [ +  - ]:           2 :       auto cmd = m_deferredCommands.dequeue();
    1955         [ +  - ]:           2 :       cmd();
    1956                 :           2 :     } else {
    1957         [ +  - ]:           3 :       restartPush();
    1958                 :             :     }
    1959                 :           5 :     return;
    1960                 :             :   }
    1961                 :             : 
    1962                 :             :   // T-176: Standalone EXPUNGE (used by T-177 Drafts to delete old drafts)
    1963         [ +  + ]:          69 :   if (commandType == "EXPUNGE") {
    1964         [ +  + ]:           3 :     if (response->status == "OK") {
    1965   [ +  -  +  -  :           4 :       qCInfo(lcImap) << "EXPUNGE complete";
             +  -  +  + ]
    1966         [ +  - ]:           2 :       emit expungeComplete();
    1967                 :             :     } else {
    1968   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "EXPUNGE failed:" << response->message;
          +  -  +  -  +  
                      + ]
    1969                 :             :     }
    1970         [ +  + ]:           3 :     if (!m_deferredCommands.isEmpty()) {
    1971         [ +  - ]:           2 :       auto cmd = m_deferredCommands.dequeue();
    1972         [ +  - ]:           2 :       cmd();
    1973                 :           2 :     } else {
    1974         [ +  - ]:           1 :       restartPush();
    1975                 :             :     }
    1976                 :           3 :     return;
    1977                 :             :   }
    1978                 :             : 
    1979                 :             :   // T-176: APPEND command
    1980         [ +  + ]:          66 :   if (commandType == "APPEND") {
    1981         [ +  + ]:           8 :     if (response->status == "OK") {
    1982                 :             :       // Parse APPENDUID if present: [APPENDUID <uidvalidity> <uid>]
    1983                 :           7 :       qint64 appendedUid = 0;
    1984                 :             :       static QRegularExpression appendUidRx(
    1985   [ +  +  +  -  :           7 :           R"(\[APPENDUID\s+\d+\s+(\d+)\])", QRegularExpression::CaseInsensitiveOption);
          +  -  +  -  -  
                      - ]
    1986         [ +  - ]:           7 :       auto uidMatch = appendUidRx.match(response->message);
    1987   [ +  -  +  + ]:           7 :       if (uidMatch.hasMatch()) {
    1988   [ +  -  +  - ]:           5 :         appendedUid = uidMatch.captured(1).toLongLong();
    1989   [ +  -  +  -  :          10 :         qCInfo(lcImap) << "APPEND complete, APPENDUID:" << appendedUid
          +  -  +  -  +  
                      + ]
    1990   [ +  -  +  - ]:           5 :                        << "to" << m_pendingAppendFolder;
    1991                 :             :       } else {
    1992   [ +  -  +  -  :           4 :         qCInfo(lcImap) << "APPEND complete (no UIDPLUS) to"
             +  -  +  + ]
    1993         [ +  - ]:           2 :                        << m_pendingAppendFolder;
    1994                 :             :       }
    1995         [ +  - ]:           7 :       emit messageAppended(m_pendingAppendFolder, appendedUid);
    1996                 :           7 :     } else {
    1997   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "APPEND failed:" << response->message;
          +  -  +  -  +  
                      + ]
    1998         [ +  - ]:           1 :       emit appendError(response->message);
    1999                 :             :     }
    2000                 :           8 :     m_pendingAppendFolder.clear();
    2001         [ +  - ]:           8 :     m_pendingAppendData.clear();
    2002         [ +  + ]:           8 :     if (!m_deferredCommands.isEmpty()) {
    2003         [ +  - ]:           2 :       auto cmd = m_deferredCommands.dequeue();
    2004         [ +  - ]:           2 :       cmd();
    2005                 :           2 :     } else {
    2006         [ +  - ]:           6 :       restartPush();
    2007                 :             :     }
    2008                 :           8 :     return;
    2009                 :             :   }
    2010                 :             : 
    2011                 :             :   // T-281: CREATE folder
    2012         [ +  + ]:          58 :   if (commandType == "CREATE") {
    2013         [ +  + ]:           6 :     if (response->status == "OK") {
    2014   [ +  -  +  -  :          10 :       qCInfo(lcImap) << "CREATE complete:" << m_pendingFolderOp;
          +  -  +  -  +  
                      + ]
    2015         [ +  - ]:           5 :       emit folderCreated(m_pendingFolderOp);
    2016                 :             :     } else {
    2017   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "CREATE failed:" << response->message;
          +  -  +  -  +  
                      + ]
    2018         [ +  - ]:           2 :       emit folderOperationError(QStringLiteral("CREATE"), response->message);
    2019                 :             :     }
    2020                 :           6 :     m_pendingFolderOp.clear();
    2021         [ +  + ]:           6 :     if (!m_deferredCommands.isEmpty()) {
    2022         [ +  - ]:           2 :       auto cmd = m_deferredCommands.dequeue();
    2023         [ +  - ]:           2 :       cmd();
    2024                 :           2 :     } else {
    2025         [ +  - ]:           4 :       restartPush();
    2026                 :             :     }
    2027                 :           6 :     return;
    2028                 :             :   }
    2029                 :             : 
    2030                 :             :   // T-281: DELETE folder
    2031         [ +  + ]:          52 :   if (commandType == "DELETE") {
    2032         [ +  + ]:           4 :     if (response->status == "OK") {
    2033   [ +  -  +  -  :           6 :       qCInfo(lcImap) << "DELETE complete:" << m_pendingFolderOp;
          +  -  +  -  +  
                      + ]
    2034         [ +  - ]:           3 :       emit folderDeleted(m_pendingFolderOp);
    2035                 :             :     } else {
    2036   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "DELETE failed:" << response->message;
          +  -  +  -  +  
                      + ]
    2037         [ +  - ]:           2 :       emit folderOperationError(QStringLiteral("DELETE"), response->message);
    2038                 :             :     }
    2039                 :           4 :     m_pendingFolderOp.clear();
    2040         [ +  + ]:           4 :     if (!m_deferredCommands.isEmpty()) {
    2041         [ +  - ]:           2 :       auto cmd = m_deferredCommands.dequeue();
    2042         [ +  - ]:           2 :       cmd();
    2043                 :           2 :     } else {
    2044         [ +  - ]:           2 :       restartPush();
    2045                 :             :     }
    2046                 :           4 :     return;
    2047                 :             :   }
    2048                 :             : 
    2049                 :             :   // T-281: RENAME folder
    2050         [ +  + ]:          48 :   if (commandType == "RENAME") {
    2051         [ +  + ]:           4 :     if (response->status == "OK") {
    2052   [ +  -  +  -  :           6 :       qCInfo(lcImap) << "RENAME complete:" << m_pendingFolderOp
          +  -  +  -  +  
                      + ]
    2053   [ +  -  +  - ]:           3 :                      << "->" << m_pendingFolderNewPath;
    2054         [ +  - ]:           3 :       emit folderRenamed(m_pendingFolderOp, m_pendingFolderNewPath);
    2055                 :             :     } else {
    2056   [ +  -  +  -  :           2 :       qCWarning(lcImap) << "RENAME failed:" << response->message;
          +  -  +  -  +  
                      + ]
    2057         [ +  - ]:           2 :       emit folderOperationError(QStringLiteral("RENAME"), response->message);
    2058                 :             :     }
    2059                 :           4 :     m_pendingFolderOp.clear();
    2060                 :           4 :     m_pendingFolderNewPath.clear();
    2061         [ +  + ]:           4 :     if (!m_deferredCommands.isEmpty()) {
    2062         [ +  - ]:           2 :       auto cmd = m_deferredCommands.dequeue();
    2063         [ +  - ]:           2 :       cmd();
    2064                 :           2 :     } else {
    2065         [ +  - ]:           2 :       restartPush();
    2066                 :             :     }
    2067                 :           4 :     return;
    2068                 :             :   }
    2069   [ +  +  +  +  :        1610 : }
                   +  + ]
    2070                 :             : 
    2071                 :         120 : void ImapService::onSocketError(QAbstractSocket::SocketError error) {
    2072                 :             :   Q_UNUSED(error)
    2073         [ +  - ]:         120 :   m_timeoutTimer->stop();
    2074         [ +  - ]:         120 :   m_commandTimeoutTimer->stop();
    2075         [ +  - ]:         120 :   auto errorMsg = m_socket->errorString();
    2076   [ +  -  +  -  :         240 :   qCWarning(lcImap) << "Socket error:" << errorMsg;
          +  -  +  -  +  
                      + ]
    2077         [ +  - ]:         120 :   emit errorOccurred(errorMsg);
    2078         [ +  - ]:         120 :   setState(State::Error);
    2079                 :         120 : }
    2080                 :             : 
    2081                 :           1 : void ImapService::onTimeout() {
    2082   [ +  -  +  -  :           2 :   qCWarning(lcImap) << "Connection timeout";
             +  -  +  + ]
    2083                 :           1 :   m_socket->abort();
    2084   [ +  -  +  - ]:           1 :   emit errorOccurred("Connection timeout");
    2085                 :           1 :   setState(State::Error);
    2086                 :           1 : }
    2087                 :             : 
    2088                 :           1 : void ImapService::onCommandTimeout() {
    2089   [ +  -  -  + ]:           1 :   if (!hasTimeoutTrackedCommandInFlight()) {
    2090         [ #  # ]:           0 :     m_commandTimeoutTimer->stop();
    2091                 :           0 :     return;
    2092                 :             :   }
    2093                 :             : 
    2094                 :           1 :   QStringList pending;
    2095   [ +  -  +  -  :           2 :   for (auto it = m_pendingCommands.cbegin(); it != m_pendingCommands.cend();
                   +  + ]
    2096                 :           1 :        ++it) {
    2097         [ +  - ]:           1 :     if (it.value() != QStringLiteral("IDLE"))
    2098   [ +  -  +  - ]:           1 :       pending.append(QStringLiteral("%1:%2").arg(it.key(), it.value()));
    2099                 :             :   }
    2100                 :             : 
    2101         [ +  - ]:           1 :   failConnection(
    2102   [ +  -  +  - ]:           3 :       QStringLiteral("IMAP command timeout: %1").arg(pending.join(',')));
    2103                 :           1 : }
        

Generated by: LCOV version 2.0-1