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: 93.4 % 1630 1523
Test Date: 2026-07-27 17:53:44 Functions: 98.3 % 118 116
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 56.3 % 3715 2091

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

Generated by: LCOV version 2.0-1