service/ImapService.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <QDate> | ||
| 4 | #include <QElapsedTimer> | ||
| 5 | #include <QList> | ||
| 6 | #include <QMap> | ||
| 7 | #include <QObject> | ||
| 8 | #include <QPair> | ||
| 9 | #include <QQueue> | ||
| 10 | #include <QSslSocket> | ||
| 11 | #include <QStringList> | ||
| 12 | #include <QTemporaryFile> | ||
| 13 | #include <QTimer> | ||
| 14 | |||
| 15 | #include <chrono> | ||
| 16 | #include <functional> | ||
| 17 | #include <memory> | ||
| 18 | #include <optional> | ||
| 19 | |||
| 20 | #include "data/AccountConfig.h" | ||
| 21 | #include "data/MailRequest.h" | ||
| 22 | #include "data/Models.h" | ||
| 23 | |||
| 24 | class ImapCommandTable; | ||
| 25 | struct TaggedResponse; | ||
| 26 | struct ImapPendingCommand; | ||
| 27 | class ImapConnectionSetup; | ||
| 28 | class ImapPushSession; | ||
| 29 | class ImapBodyFetch; | ||
| 30 | class ImapMutationCommands; | ||
| 31 | class ImapAppendStream; | ||
| 32 | class ImapLineReader; | ||
| 33 | |||
| 34 | // ImapService manages a single IMAP connection. | ||
| 35 | // Communicates asynchronously via Qt signals/slots. | ||
| 36 | // All network I/O happens on the caller's thread (main thread in Sprint 01). | ||
| 37 | class ImapService : public QObject { | ||
| 38 | Q_OBJECT | ||
| 39 | |||
| 40 | public: | ||
| 41 | enum class State { | ||
| 42 | Disconnected, | ||
| 43 | Connecting, | ||
| 44 | Connected, // TCP connected, waiting for server greeting | ||
| 45 | Greeting, // Server greeting received | ||
| 46 | Capability, // CAPABILITY response received | ||
| 47 | StartingTLS, // STARTTLS in progress | ||
| 48 | Authenticating, // LOGIN sent, waiting for response | ||
| 49 | Authenticated, // LOGIN successful, ready for commands | ||
| 50 | Selected, // Folder selected, ready for FETCH commands | ||
| 51 | Idling, // IDLE command active, waiting for server push | ||
| 52 | Error | ||
| 53 | }; | ||
| 54 | 2 | Q_ENUM(State) | |
| 55 | |||
| 56 | enum class DisconnectReason { | ||
| 57 | UserRequested, | ||
| 58 | AccountReload, | ||
| 59 | ApplicationShutdown, | ||
| 60 | RemoteBye, | ||
| 61 | SocketError, | ||
| 62 | ProtocolError, | ||
| 63 | CommandTimeout, | ||
| 64 | NetworkUnavailable, | ||
| 65 | ForcedHealthProbe, | ||
| 66 | AuthenticationRejected, | ||
| 67 | TlsPolicyRejected, | ||
| 68 | }; | ||
| 69 | 53 | Q_ENUM(DisconnectReason) | |
| 70 | |||
| 71 | enum class MutationOutcome { | ||
| 72 | Confirmed, | ||
| 73 | Rejected, | ||
| 74 | CancelledBeforeDispatch, | ||
| 75 | Ambiguous, | ||
| 76 | }; | ||
| 77 | 2 | Q_ENUM(MutationOutcome) | |
| 78 | |||
| 79 | explicit ImapService(QObject *parent = nullptr); | ||
| 80 | // Post-login capabilities of the current connection (Sprint 95: the | ||
| 81 | // IMAPSIEVE= rule-server hint, RFC 6785). | ||
| 82 | 31 | QStringList serverCapabilities() const { return m_capabilities; } | |
| 83 | ~ImapService() override; | ||
| 84 | |||
| 85 | void connectToServer(const ImapConfig &config); | ||
| 86 | void disconnect(DisconnectReason reason = DisconnectReason::UserRequested); | ||
| 87 | |||
| 88 | // Commands (only valid when Authenticated or Selected) | ||
| 89 | void listFolders(); | ||
| 90 | void selectFolder(const QString &folderPath); | ||
| 91 | void fetchHeaders(qint64 uidFrom = 1); | ||
| 92 | void fetchBody(qint64 uid); | ||
| 93 | void fetchBody(qint64 uid, qint64 maxBytes); | ||
| 94 | void requestBody(quint64 requestId, qint64 uid); | ||
| 95 | |||
| 96 | // Full-message downloads are deliberately bounded below the protocol hard | ||
| 97 | // cap. Users can raise/lower the limit in Settings; callers with a tighter | ||
| 98 | // protocol payload (for example settings sync) may pass an explicit limit. | ||
| 99 | // F-34 (SPRINT-104, D-9): ONE limit — the composer accepts up to | ||
| 100 | // 100 MiB of attachments (MaxComposeAttachmentsBytes), so a mail sent | ||
| 101 | // from MailJD must also be openable in its own Sent folder. The read | ||
| 102 | // limit matches the send limit; large payloads still spool to a | ||
| 103 | // private temp file instead of RAM (T-87.C2) and stay subject to the | ||
| 104 | // payload cache budget. | ||
| 105 | static constexpr int DefaultMaxMessageSizeMiB = 100; | ||
| 106 | static constexpr int MinMaxMessageSizeMiB = 1; | ||
| 107 | static constexpr int MaxMaxMessageSizeMiB = 100; | ||
| 108 | static qint64 configuredMaxMessageBytes(); | ||
| 109 | |||
| 110 | // T-205: Pipeline SELECT + FETCH BODY in one burst (for body connection). | ||
| 111 | void selectAndFetchBody(const QString &folderPath, qint64 uid); | ||
| 112 | void requestBodyInFolder(quint64 requestId, const QString &folderPath, | ||
| 113 | qint64 uid); | ||
| 114 | void markSeen(qint64 uid); | ||
| 115 | void markUnseen(qint64 uid); | ||
| 116 | |||
| 117 | // Generic flag store: add or remove any flag (system flag or keyword) | ||
| 118 | // e.g. storeFlag(uid, "\\Flagged", true) or storeFlag(uid, "$label1", false) | ||
| 119 | void storeFlag(qint64 uid, const QString &flag, bool add, | ||
| 120 | qint64 mutationToken = 0); | ||
| 121 | |||
| 122 | // T-100: Move/copy messages between folders | ||
| 123 | void moveMessage(qint64 uid, const QString &targetFolder, | ||
| 124 | qint64 mutationToken = 0); | ||
| 125 | void moveMessages(const QList<qint64> &uids, const QString &targetFolder, | ||
| 126 | qint64 mutationToken = 0); | ||
| 127 | // F4.3: one UID MOVE uid-set wire command for a batch of journal records; | ||
| 128 | // every token completes with the command's terminal response. | ||
| 129 | void moveMessageSet(const QList<qint64> &uids, const QString &targetFolder, | ||
| 130 | const QList<qint64> &mutationTokens); | ||
| 131 | void copyMessage(qint64 uid, const QString &targetFolder, | ||
| 132 | qint64 mutationToken = 0); | ||
| 133 | |||
| 134 | // Search UIDs in the selected folder (fromUid=1 → all, fromUid>1 → delta). | ||
| 135 | void searchAllUids(qint64 fromUid = 1); | ||
| 136 | |||
| 137 | // T-187: Text-based IMAP SEARCH in the selected folder. | ||
| 138 | // criteria: "TEXT", "SUBJECT", "FROM", "TO", "BODY" | ||
| 139 | void searchText(const QString &query, | ||
| 140 | const QString &criteria = QStringLiteral("TEXT")); | ||
| 141 | |||
| 142 | // Sprint 59 (S2): composite IMAP SEARCH for the visual search facets. | ||
| 143 | // ImapService stays independent of MailCache — the caller (MailController) | ||
| 144 | // translates SearchFilter into this server-friendly subset. Tri-state mirrors | ||
| 145 | // SearchFilter::Tri: Any adds no constraint. | ||
| 146 | enum class SearchTri { Any, Yes, No }; | ||
| 147 | struct SearchCriteria { | ||
| 148 | QString text; // TEXT "..." | ||
| 149 | QString from; // FROM "..." | ||
| 150 | QString to; // OR TO "..." CC "..." | ||
| 151 | QString listId; // HEADER List-Id "..." (Sprint 95) | ||
| 152 | QString subject; // SUBJECT "..." | ||
| 153 | QDate since; // SINCE dd-MMM-yyyy (inclusive) | ||
| 154 | QDate before; // BEFORE dd-MMM-yyyy (exclusive per RFC 3501) | ||
| 155 | SearchTri unread = SearchTri::Any; // Yes ⇒ UNSEEN, No ⇒ SEEN | ||
| 156 | SearchTri flagged = SearchTri::Any; // Yes ⇒ FLAGGED, No ⇒ UNFLAGGED | ||
| 157 | SearchTri answered = SearchTri::Any; // Yes ⇒ ANSWERED, No ⇒ UNANSWERED | ||
| 158 | QStringList keywords; // KEYWORD "..." per entry | ||
| 159 | // Sprint 101 D-14: negated criteria, each "NOT …" (UNKEYWORD for labels). | ||
| 160 | QStringList notText, notFrom, notTo, notListId, notSubject, notKeywords; | ||
| 161 | |||
| 162 | // True when nothing constrains the search — such criteria are not sent. | ||
| 163 | bool isEmpty() const; | ||
| 164 | }; | ||
| 165 | |||
| 166 | // Run a composite SEARCH built from the criteria. No-op when isEmpty(). | ||
| 167 | void search(const SearchCriteria &criteria); | ||
| 168 | |||
| 169 | // T-211: Search by Message-ID header (for undo-move). Returns the search's | ||
| 170 | // sequence number; messageIdSearchFinished carries it back together with the | ||
| 171 | // mailbox the SEARCH actually ran in (S106-187). | ||
| 172 | quint64 searchByMessageId(const QString &messageId); | ||
| 173 | |||
| 174 | // Fetch headers for a specific set of UIDs (comma-separated). | ||
| 175 | void fetchHeadersByUids(const QList<qint64> &uids); | ||
| 176 | |||
| 177 | // T-176: IMAP APPEND – Upload a complete RFC-2822 message to a folder. | ||
| 178 | // flags: e.g. "\\Seen", "\\Draft", or "\\Seen \\Draft" | ||
| 179 | quint64 appendMessage(const QString &folder, const QByteArray &rfcMessage, | ||
| 180 | const QString &flags = {}, qint64 mutationToken = 0); | ||
| 181 | quint64 reserveAppendRequestId(); | ||
| 182 | void appendMessage(quint64 requestId, const QString &folder, | ||
| 183 | const QByteArray &rfcMessage, | ||
| 184 | const QString &flags = {}, qint64 mutationToken = 0); | ||
| 185 | |||
| 186 | // S106-011/016: the folder-wide EXPUNGE and "UID STORE 1:* \Seen" had no | ||
| 187 | // production caller and a queued plain EXPUNGE could hit another mailbox; | ||
| 188 | // both were removed. Only the UID-exact form below exists. | ||
| 189 | // RFC 4315 / RFC 9051: remove only these \Deleted UIDs, with an explicit | ||
| 190 | // selected-mailbox epoch. No folder-wide fallback is permitted. | ||
| 191 | void expungeUids(const QList<qint64> &uids, const QString &folder, | ||
| 192 | quint32 uidValidity); | ||
| 193 | bool hasUidExpungeCapability() const; | ||
| 194 | |||
| 195 | // T-281: Folder management commands (RFC 3501 §6.3.3–§6.3.5) | ||
| 196 | void createFolder(const QString &folderPath); | ||
| 197 | void deleteFolder(const QString &folderPath); | ||
| 198 | void renameFolder(const QString &oldPath, const QString &newPath); | ||
| 199 | |||
| 200 | // IDLE support (RFC 2177) | ||
| 201 | void startIdle(); // Enter IDLE mode (must be in Selected state) | ||
| 202 | void stopIdle(); // Send DONE to exit IDLE mode | ||
| 203 | bool isIdling() const; | ||
| 204 | bool hasIdleCapability() const; | ||
| 205 | bool hasCondstoreCapability() const; // T-208: CONDSTORE support | ||
| 206 | |||
| 207 | // T-320: NOTIFY support (RFC 5465) — replaces IDLE + STATUS polling | ||
| 208 | void startNotify(const QStringList &subscribedFolders); | ||
| 209 | void stopNotify(); // Send NOTIFY NONE | ||
| 210 | bool isNotifying() const; | ||
| 211 | bool hasNotifyCapability() const; | ||
| 212 | // NOTIFY (RFC 5465) push is used only when allowed. It was never run | ||
| 213 | // against a real server (GreenMail lacks NOTIFY; Dovecot's post-login | ||
| 214 | // NOTIFY was invisible before its capabilities were read), so IDLE plus | ||
| 215 | // folder polling stays the default until the Dovecot online benchmark | ||
| 216 | // validates new-mail, flag and STATUS pushes. | ||
| 217 | bool usesNotifyPush() const; | ||
| 218 | void setAllowNotifyPush(bool allow); | ||
| 219 | void executeAfterNotify(std::function<void()> command); | ||
| 220 | |||
| 221 | // T-205: Disable automatic IDLE entry after FETCH_BODY/STORE. | ||
| 222 | // Set to false for connections that only do body fetches. | ||
| 223 | void setAutoIdle(bool enabled); | ||
| 224 | |||
| 225 | // Execute a command after safely stopping IDLE or NOTIFY. | ||
| 226 | // If not idling/notifying: executes immediately. | ||
| 227 | // If idling: sends DONE, queues command, executes on IDLE OK. | ||
| 228 | // If notifying: sends NOTIFY NONE, queues command, executes on NOTIFY OK. | ||
| 229 | void executeAfterIdle(std::function<void()> command); | ||
| 230 | |||
| 231 | // T-114: Clear all pending deferred commands (e.g. on folder switch). | ||
| 232 | // Prevents stale SELECTs/FETCHes from executing after the user | ||
| 233 | // switches folders rapidly. | ||
| 234 | void clearDeferredCommands(); | ||
| 235 | |||
| 236 | // Flag sync: fetch only UID+FLAGS for all messages in selected folder. | ||
| 237 | void fetchFlags(); | ||
| 238 | // F-38: verification readback of one changed UID (partial snapshot). | ||
| 239 | void fetchVerificationFlags(qint64 uid); | ||
| 240 | |||
| 241 | // T-207: Pipeline SELECT + FETCH FLAGS in one burst (saves one round-trip). | ||
| 242 | // Sends both commands immediately; server processes them in-order per RFC 3501. | ||
| 243 | void selectAndFetchFlags(const QString &folderPath); | ||
| 244 | // First open of a folder with an empty cache: SELECT + UID SEARCH ALL. | ||
| 245 | // The UID list is enough to fetch the newest headers first; their FETCH | ||
| 246 | // carries the flags, so no separate UID FETCH 1:* (FLAGS) is needed. | ||
| 247 | void selectAndSearchAll(const QString &folderPath); | ||
| 248 | // Reopen of a cached folder on a CONDSTORE server (RFC 7162): SELECT | ||
| 249 | // (CONDSTORE) + UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE modseq) + the | ||
| 250 | // folder's UID list (ESEARCH when available). Emits changedFlagsReceived | ||
| 251 | // (only messages changed since `modseq`) and uidMembershipReceived. | ||
| 252 | void selectAndFetchChangedFlags(const QString &folderPath, quint64 modseq); | ||
| 253 | bool hasEsearchCapability() const; | ||
| 254 | // Bytes received on this connection since construction (diagnostics and | ||
| 255 | // mailjd-bench transfer metrics). | ||
| 256 | quint64 receivedBytes() const; | ||
| 257 | |||
| 258 | // T-208: Incremental flag sync using CONDSTORE (RFC 4551). | ||
| 259 | // Only fetches flags changed since the given HIGHESTMODSEQ value. | ||
| 260 | void fetchFlagsChanged(quint64 modseq); | ||
| 261 | |||
| 262 | // Fetch UID+FLAGS for a single sequence number (used after IDLE flag push). | ||
| 263 | void fetchUidForSeqNo(int seqNo); | ||
| 264 | |||
| 265 | // Folder status: query UNSEEN/MESSAGES/RECENT without selecting. | ||
| 266 | void statusFolder(const QString &folderPath); | ||
| 267 | |||
| 268 | // T-540: NOOP command for keep-alive (body connection) | ||
| 269 | void sendNoop(); | ||
| 270 | |||
| 271 | // T-720: Liveness probe API for ConnectionHealthMonitor. | ||
| 272 | // Idempotent while a probe is already running. The concrete probe depends | ||
| 273 | // on the current state: | ||
| 274 | // Authenticated/Selected → tagged NOOP with a 15 s watchdog | ||
| 275 | // Idling → DONE/OK round-trip with a 15 s watchdog | ||
| 276 | // Connecting/Auth/Error/Disconnected → no-op (handled elsewhere) | ||
| 277 | // On watchdog timeout the connection is failed and the monitor schedules | ||
| 278 | // a reconnect. | ||
| 279 | void requestLivenessProbe(const QString &reason); | ||
| 280 | // S92.5.6: another connection of the account detected a dead network | ||
| 281 | // path (its probe timed out). Kept when it received data within 3 s, | ||
| 282 | // failed when a command waits in the silent path, probed otherwise. | ||
| 283 | void verifyAfterPathFailure(const QString &reason); | ||
| 284 | |||
| 285 | // T-720: Public wrapper around private failConnection() so the | ||
| 286 | // ConnectionHealthMonitor can force a teardown + reconnect on | ||
| 287 | // suspend/resume or a network change. Validates/logs the reason. | ||
| 288 | void abortForReconnect(const QString &reason); | ||
| 289 | |||
| 290 | // T-720: Test seam — true after the TCP-keepalive tuning helper ran | ||
| 291 | // (i.e. onConnected()/onEncrypted() ran the platform setsockopt path). | ||
| 292 | bool keepAliveTuned() const; | ||
| 293 | |||
| 294 | // T-720: Test seam — true while a liveness probe is in flight. | ||
| 295 | bool isLivenessProbeInFlight() const; | ||
| 296 | |||
| 297 | // Currently selected folder path. | ||
| 298 | 134 | QString selectedFolder() const { return m_selectedFolder; } | |
| 299 | 79 | quint32 selectedUidValidity() const { return m_selectedUidValidity; } | |
| 300 | |||
| 301 | 9239 | State state() const { return m_state; } | |
| 302 | |||
| 303 | #ifdef MAILJD_E2E_TESTING | ||
| 304 | // One-shot completion control for deterministic real-server UI races. The | ||
| 305 | // fetch still traverses GreenMail/TLS; only delivery of its terminal result | ||
| 306 | // is delayed or replaced after the server response has completed. | ||
| 307 | void setNextBodyCompletionForE2e(int delayMs, bool fail, | ||
| 308 | const QString &folderPath = {}, qint64 uid = 0); | ||
| 309 | |||
| 310 | // Correlate a product command boundary with one TLS-blind proxy connection | ||
| 311 | // without exposing commands, credentials, or payload bytes. | ||
| 312 | quint16 localPortForE2e() const { return m_socket->localPort(); } | ||
| 313 | #endif | ||
| 314 | |||
| 315 | signals: | ||
| 316 | // Lifecycle intent is separate from stateChanged(): monitors must not turn | ||
| 317 | // an explicit account switch/shutdown into an automatic reconnect. | ||
| 318 | void connectionAttemptStarted(); | ||
| 319 | void intentionalDisconnect(); | ||
| 320 | void disconnectReasonChanged(ImapService::DisconnectReason reason); | ||
| 321 | void stateChanged(ImapService::State newState); | ||
| 322 | void errorOccurred(const QString &error); | ||
| 323 | void folderListReceived(const QList<FolderInfo> &folders); | ||
| 324 | void folderSelected(const QString &path, int messageCount, | ||
| 325 | quint32 uidValidity, quint64 highestModseq = 0); // T-208 | ||
| 326 | // Emitted when a SELECT is rejected by the server (e.g. \Noselect folder). | ||
| 327 | // Lets multi-folder flows (server search) skip the folder instead of | ||
| 328 | // waiting forever for a folderSelected that will never arrive. | ||
| 329 | void folderSelectFailed(const QString &path); | ||
| 330 | void headersReceived(const QList<MailHeader> &headers); | ||
| 331 | void headerFetchComplete(); // All header batches delivered | ||
| 332 | void rawBodyReceived(qint64 uid, const QByteArray &rawBody); | ||
| 333 | void bodyFetchTooLarge(qint64 uid, qint64 maxBytes); | ||
| 334 | void bodyRequestCompleted(const BodyResult &result); | ||
| 335 | void bodyMailboxSelected(quint64 requestId, bool success); | ||
| 336 | |||
| 337 | // IDLE push signals | ||
| 338 | void idleNewMessages(int newCount); // * N EXISTS (N > previous) | ||
| 339 | void idleMessageExpunged(int seqNo); // * N EXPUNGE | ||
| 340 | void idleFlagsChanged(qint64 uid, quint32 flags); // * N FETCH (FLAGS ...) | ||
| 341 | void idleFlagsNeedRefetch(int seqNo); // * N FETCH (FLAGS ...) without UID | ||
| 342 | |||
| 343 | // Flag sync result | ||
| 344 | void flagsReceived(const QList<QPair<qint64, quint32>> &uidFlags); | ||
| 345 | // CHANGEDSINCE result: a partial list, never a membership snapshot. | ||
| 346 | void changedFlagsReceived(const QList<QPair<qint64, quint32>> &uidFlags); | ||
| 347 | // Every UID of the selected folder (UID SEARCH ALL / ESEARCH ALL). | ||
| 348 | void uidMembershipReceived(const QList<qint64> &uids); | ||
| 349 | void flagSnapshotsReceived(const QList<ImapFlagSnapshot> &snapshots, | ||
| 350 | const QString &folderPath); | ||
| 351 | // F-38: partial readback of fetchVerificationFlags — never a complete | ||
| 352 | // snapshot, so the flag-sync reconciliation must not consume it. | ||
| 353 | // S106-195: `requestedUid` is the one UID the readback asked for; its | ||
| 354 | // absence is evidence about that UID only. | ||
| 355 | void verifyFlagsReceived(const QList<ImapFlagSnapshot> &snapshots, | ||
| 356 | const QString &folderPath, qint64 requestedUid); | ||
| 357 | |||
| 358 | // Search result: list of UIDs matching the search query. | ||
| 359 | void searchResultReceived(const QList<qint64> &uids); | ||
| 360 | // S106-024: the server answered a SEARCH with NO/BAD (emitted before the | ||
| 361 | // empty searchResultReceived of the same completion). | ||
| 362 | void searchFailed(const QString &message); | ||
| 363 | // S106-187: the identified completion of searchByMessageId(). `folder` and | ||
| 364 | // `uidValidity` are the selected mailbox at the moment the SEARCH was sent; | ||
| 365 | // `ok` is false for a tagged NO/BAD (uids then empty). Emitted before | ||
| 366 | // searchResultReceived for the same completion. | ||
| 367 | void messageIdSearchFinished(quint64 sequence, const QString &folder, | ||
| 368 | quint32 uidValidity, const QList<qint64> &uids, bool ok); | ||
| 369 | |||
| 370 | // Folder status result | ||
| 371 | void folderStatusReceived(const StatusResult &result); | ||
| 372 | // F-26: a tagged NO/BAD for STATUS — the folder's counts stay as they | ||
| 373 | // were; the polling round continues with the next folder. | ||
| 374 | void folderStatusFailed(const QString &folder); | ||
| 375 | void storeComplete(); | ||
| 376 | void mutationDispatched(qint64 token); | ||
| 377 | void mutationCompleted(qint64 token, ImapService::MutationOutcome outcome, | ||
| 378 | const QString &error, | ||
| 379 | const QString &uidPlusEvidence); | ||
| 380 | |||
| 381 | #ifdef MAILJD_E2E_TESTING | ||
| 382 | // Metadata-only command-boundary seam for the external TLS-blind fault | ||
| 383 | // proxy. It intentionally exposes neither command text nor payload bytes. | ||
| 384 | void e2eTransportPhase(const QString &phase, quint64 requestToken); | ||
| 385 | #endif | ||
| 386 | |||
| 387 | // T-100: Move/copy result signals | ||
| 388 | void messageMoved(qint64 uid, const QString &targetFolder); | ||
| 389 | void messagesMoved(const QList<qint64> &uids, const QString &targetFolder); | ||
| 390 | void messageCopied(qint64 uid, const QString &targetFolder); | ||
| 391 | void moveError(const QString &error); | ||
| 392 | |||
| 393 | // T-176: APPEND result signals | ||
| 394 | void messageAppended(const QString &folder, qint64 uid); // uid=0 if no UIDPLUS | ||
| 395 | void appendError(const QString &error); | ||
| 396 | // uid/uidValidity from APPENDUID (RFC 4315); 0 without UIDPLUS. | ||
| 397 | void appendCompleted(quint64 requestId, const QString &folder, qint64 uid, quint32 uidValidity); | ||
| 398 | void appendFailed(quint64 requestId, const QString &error); | ||
| 399 | |||
| 400 | // T-176: EXPUNGE result | ||
| 401 | void expungeComplete(); | ||
| 402 | void expungeFailed(const QString &error); | ||
| 403 | |||
| 404 | // T-281: Folder management result signals | ||
| 405 | void folderCreated(const QString &folderPath); | ||
| 406 | void folderDeleted(const QString &folderPath); | ||
| 407 | void folderRenamed(const QString &oldPath, const QString &newPath); | ||
| 408 | void folderOperationError(const QString &operation, const QString &error); | ||
| 409 | |||
| 410 | // Socket and timer handlers. Public only for the test/diagnostic state | ||
| 411 | // contract below (tests drive them without a live socket or real timeouts). | ||
| 412 | public slots: | ||
| 413 | void onConnected(); | ||
| 414 | void onEncrypted(); | ||
| 415 | void onReadyRead(); | ||
| 416 | void onSocketError(QAbstractSocket::SocketError error); | ||
| 417 | void onTimeout(); | ||
| 418 | void onCommandTimeout(); | ||
| 419 | void onCommandDeadline(); | ||
| 420 | void onIdleRenew(); | ||
| 421 | // T-720: Liveness-probe watchdog slots. | ||
| 422 | void onLivenessProbeTimeout(); | ||
| 423 | void onIdleRenewWatchdogTimeout(); | ||
| 424 | |||
| 425 | public: | ||
| 426 | // Test/diagnostic state contract (Sprint 92 S92.0.2 replaced the former | ||
| 427 | // test friend declarations, Sprint 91 rule 8). No product caller outside | ||
| 428 | // ImapService uses it; tests drive the protocol state machine with | ||
| 429 | // synthetic server lines and inject or inspect connection state through | ||
| 430 | // it. The contract stays stable across the Sprint 92 unit extraction: | ||
| 431 | // accessors of state that moves into an internal unit forward to it. | ||
| 432 | enum class SetupPhase { | ||
| 433 | AwaitingGreeting, | ||
| 434 | InitialCapability, | ||
| 435 | StartingTls, | ||
| 436 | PostTlsCapability, | ||
| 437 | PreAuthCapability, // S106-005: PREAUTH greeting on an encrypted connection | ||
| 438 | Login, | ||
| 439 | Complete, | ||
| 440 | }; | ||
| 441 | // In-flight commands (tag → command type), a snapshot in tag order. | ||
| 442 | // Accessors of state owned by an internal unit are defined in | ||
| 443 | // ImapServiceContract.cpp. | ||
| 444 | QMap<QString, QString> pendingCommands() const; | ||
| 445 | // FUNC-08: injects the record of an in-flight command; tests set the | ||
| 446 | // command's own state on the returned record. | ||
| 447 | ImapPendingCommand &injectPendingCommand(const QString &tag, const QString &type); | ||
| 448 | // Record of an in-flight tag (nullptr when unknown). | ||
| 449 | ImapPendingCommand *pendingRecord(const QString &tag); | ||
| 450 | // Writes a command with a prepared record (like the product's senders). | ||
| 451 | void sendRecordedCommand(const QString &type, const QString &command, | ||
| 452 | const ImapPendingCommand &record); | ||
| 453 | void removePendingCommand(const QString &tag); | ||
| 454 | void clearPendingCommands(); | ||
| 455 | QMap<QString, QElapsedTimer> commandTimers() const; | ||
| 456 | void injectCommandTimer(const QString &tag, const QElapsedTimer &timer); | ||
| 457 | // Connection and session state. | ||
| 458 | 263 | State &mutableState() { return m_state; } | |
| 459 | ImapConfig &config(); | ||
| 460 | 22 | QSslSocket *socket() const { return m_socket; } | |
| 461 | QByteArray &readBuffer(); | ||
| 462 | SetupPhase &setupPhase(); | ||
| 463 | 56 | QStringList &capabilities() { return m_capabilities; } | |
| 464 | QStringList &pendingCapabilities(); | ||
| 465 | bool &mutableKeepAliveTuned(); | ||
| 466 | bool &autoIdle(); | ||
| 467 | QTimer *setupTimeoutTimer() const; | ||
| 468 | // Selected mailbox. | ||
| 469 | 217 | QString &mutableSelectedFolder() { return m_selectedFolder; } | |
| 470 | // Folder of the in-flight SELECT (empty when none). | ||
| 471 | QString pendingSelectFolder() const; | ||
| 472 | 17 | int &selectedMessageCount() { return m_selectedMessageCount; } | |
| 473 | 19 | quint32 &mutableSelectedUidValidity() { return m_selectedUidValidity; } | |
| 474 | 4 | quint64 &selectedHighestModseq() { return m_selectedHighestModseq; } | |
| 475 | // Command queues, timers and budgets. | ||
| 476 | QQueue<std::function<void()>> &deferredCommands(); | ||
| 477 | QQueue<std::function<void()>> &serializedCommands(); | ||
| 478 | QTimer *commandTimeoutTimer() const; | ||
| 479 | QTimer *commandDeadlineTimer() const; | ||
| 480 | qint64 &commandResponseBytes(); | ||
| 481 | qint64 &commandLiteralCount(); | ||
| 482 | qint64 &commandResultItems(); | ||
| 483 | // Response accumulators. | ||
| 484 | 5 | QList<FolderInfo> &pendingFolders() { return m_pendingFolders; } | |
| 485 | 4 | QList<MailHeader> &pendingHeaders() { return m_pendingHeaders; } | |
| 486 | 10 | QList<QPair<qint64, quint32>> &pendingFlags() { return m_pendingFlags; } | |
| 487 | 6 | QList<qint64> &pendingSearchUids() { return m_pendingSearchUids; } | |
| 488 | // Push session (IDLE/NOTIFY) and liveness probe. | ||
| 489 | bool &mutableIdling(); | ||
| 490 | bool &idleStartPending(); | ||
| 491 | QString &idleTag(); | ||
| 492 | bool &mutableNotifying(); | ||
| 493 | QString ¬ifyTag(); | ||
| 494 | QStringList ¬ifyFolders(); | ||
| 495 | QTimer *idleRenewWatchdog() const; | ||
| 496 | QTimer *livenessProbeWatchdog() const; | ||
| 497 | QString &probeTag(); | ||
| 498 | QString &lastProbeReason(); | ||
| 499 | // S92.5.3: RTT-aware probe budget of the push session. | ||
| 500 | void noteRoundTrip(qint64 elapsedMs); | ||
| 501 | int probeWatchdogMs() const; | ||
| 502 | // S92.5.2: monotonic clock of the quiet-time measurement (fake in tests). | ||
| 503 | 13 | void setMonotonicClock(std::function<qint64()> nowMs) { m_nowMs = std::move(nowMs); } | |
| 504 | qint64 quietMs() const; | ||
| 505 | qint64 &lastReceiveMs(); | ||
| 506 | // Per-command state, read from the record of the in-flight command | ||
| 507 | // (FUNC-08; default values when no such command is in flight). | ||
| 508 | QString pendingStatusFolder() const; | ||
| 509 | std::optional<StatusResult> pendingStatusResult() const; | ||
| 510 | qint64 statusRequestStartedNs() const; | ||
| 511 | 3 | QSet<QString> &queuedStatusFolders() { return m_queuedStatusFolders; } | |
| 512 | QString pendingMoveTarget() const; // in-flight MOVE/COPY family | ||
| 513 | QList<qint64> pendingMoveUids() const; | ||
| 514 | QList<qint64> pendingMoveMutationTokens() const; | ||
| 515 | qint64 pendingStoreMutationToken() const; // in-flight STORE | ||
| 516 | QSet<qint64> &knownMutationTokens(); | ||
| 517 | QByteArray pendingAppendData() const; // in-flight APPEND | ||
| 518 | QString pendingAppendFolder() const; | ||
| 519 | quint64 pendingAppendRequestId() const; | ||
| 520 | qint64 pendingAppendMutationToken() const; | ||
| 521 | quint64 &nextAppendRequestId(); | ||
| 522 | // Literal framing and the active body request. | ||
| 523 | qint64 &literalBytesRemaining(); | ||
| 524 | QString &literalLine(); | ||
| 525 | QByteArray &literalData(); | ||
| 526 | std::unique_ptr<QTemporaryFile> &bodySpool(); | ||
| 527 | qint64 &bodySpoolThreshold(); | ||
| 528 | qint64 &activeBodyFetchUid(); | ||
| 529 | quint64 &activeBodyRequestId(); | ||
| 530 | QString &activeBodyRequestFolder(); | ||
| 531 | qint64 &activeBodyFetchLimit(); | ||
| 532 | qint64 &activeBodyFetchRequestBytes(); | ||
| 533 | bool &bodyFetchRequiresSelect(); | ||
| 534 | // Protocol steps (tests call them with synthetic input). | ||
| 535 | void setState(State newState); | ||
| 536 | void sendCommand(const QString &type, const QString &command); | ||
| 537 | void processLine(const QString &line); | ||
| 538 | void handleUntagged(const QString &line); | ||
| 539 | void handleTagged(const QString &line); | ||
| 540 | void restartPush(); // T-320: restart NOTIFY or IDLE based on capabilities | ||
| 541 | bool hasStatefulCommandInFlight() const; | ||
| 542 | bool hasTimeoutTrackedCommandInFlight() const; | ||
| 543 | void enqueueSerializedCommand(std::function<void()> command); | ||
| 544 | void runNextSerializedCommand(); | ||
| 545 | void refreshCommandTimeout(); | ||
| 546 | void refreshCommandDeadline(); | ||
| 547 | bool accountCommandResponseBytes(qint64 bytes); | ||
| 548 | bool accountCommandLiteral(); | ||
| 549 | bool accountCommandResultItems(qint64 items = 1); | ||
| 550 | void completeLiteral(); | ||
| 551 | void fetchBodyWithLimits(quint64 requestId, qint64 uid, | ||
| 552 | qint64 acceptedBytes, qint64 requestedBytes); | ||
| 553 | void completeBodyRequest(BodyOutcome outcome, QByteArray bytes = {}, | ||
| 554 | const QString &error = {}, qint64 maxBytes = 0); | ||
| 555 | void failConnection( | ||
| 556 | const QString &error, | ||
| 557 | DisconnectReason reason = DisconnectReason::ProtocolError); | ||
| 558 | // T-720: Apply SO_KEEPALIVE + platform-specific interval tuning to the | ||
| 559 | // connected socket. Called once from onConnected()/onEncrypted(). | ||
| 560 | void tuneKeepAlive(); | ||
| 561 | bool beginLogin(); | ||
| 562 | static QString buildFetchBodyCommand(qint64 uid, qint64 maxBytes = -1); | ||
| 563 | static QString quoteImapString(const QString &str); | ||
| 564 | // F-03: modified-UTF-7 encode + quote for every mailbox-name argument. | ||
| 565 | static QString quoteMailboxName(const QString &path); | ||
| 566 | static bool isValidImapFlag(const QString &flag); | ||
| 567 | // Sprint 59 (S2): assemble "UID SEARCH <criteria…>" from the facet criteria. | ||
| 568 | // Returns an empty string when no criterion is set. Pure + testable. | ||
| 569 | static QString buildSearchCommand(const SearchCriteria &criteria); | ||
| 570 | // Protocol limits. | ||
| 571 | static constexpr qint64 MAX_LITERAL_SIZE = | ||
| 572 | (static_cast<qint64>(MaxMaxMessageSizeMiB) * 1024 * 1024) + 1; | ||
| 573 | static constexpr qint64 MAX_COMMAND_RESPONSE_SIZE = 96LL * 1024 * 1024; | ||
| 574 | static constexpr qint64 MAX_LITERALS_PER_COMMAND = 50000; | ||
| 575 | static constexpr qint64 MAX_RESULT_ITEMS_PER_COMMAND = 50000; | ||
| 576 | // Hard cap for one folded logical response. Unlike a lifetime transfer | ||
| 577 | // quota, this protects command-free phases without disconnecting healthy | ||
| 578 | // long-running IDLE/NOTIFY sessions after normal cumulative traffic. | ||
| 579 | static constexpr qint64 MAX_NON_BODY_LITERAL_LINE_SIZE = 8LL * 1024 * 1024; | ||
| 580 | |||
| 581 | private: | ||
| 582 | // S92.1.2: builds the command table with its Deps bound to this service. | ||
| 583 | std::unique_ptr<ImapCommandTable> createCommandTable(); | ||
| 584 | // S92.1.1: builds the line reader with its Deps bound to this service. | ||
| 585 | std::unique_ptr<ImapLineReader> createLineReader(); | ||
| 586 | // S92.1.3: builds the connection setup with its Deps bound to this service. | ||
| 587 | std::unique_ptr<ImapConnectionSetup> createConnectionSetup(); | ||
| 588 | // S92.1.4: builds the push session with its Deps bound to this service. | ||
| 589 | std::unique_ptr<ImapPushSession> createPushSession(); | ||
| 590 | // S92.1.5: builds the body fetch unit with its Deps bound to this service. | ||
| 591 | std::unique_ptr<ImapBodyFetch> createBodyFetch(); | ||
| 592 | // S92.1.6: builds the mutation commands with their Deps bound to this service. | ||
| 593 | std::unique_ptr<ImapMutationCommands> createMutationCommands(); | ||
| 594 | // S92.1.7: builds the APPEND stream with its Deps bound to this service. | ||
| 595 | std::unique_ptr<ImapAppendStream> createAppendStream(); | ||
| 596 | // S92.1.8: response dispatch. Tagged completions go through an ordered | ||
| 597 | // handler table; untagged responses are cut by response kind. | ||
| 598 | void dispatchTagged(ImapPendingCommand &command, const TaggedResponse &response); | ||
| 599 | void handleTaggedList(const TaggedResponse &response); | ||
| 600 | void handleTaggedSelect(const TaggedResponse &response, ImapPendingCommand &command); | ||
| 601 | void handleTaggedFetchHeaders(const TaggedResponse &response); | ||
| 602 | void handleTaggedFetchFlags(const TaggedResponse &response); | ||
| 603 | void handleTaggedVerifyFlags(const TaggedResponse &response); | ||
| 604 | void handleTaggedFetchFlagsChanged(const TaggedResponse &response); | ||
| 605 | void handleTaggedSearchMembership(const TaggedResponse &response); | ||
| 606 | void handleTaggedSearch(const TaggedResponse &response); | ||
| 607 | quint64 sendMessageIdSearch(const QString &messageId, quint64 sequence); | ||
| 608 | void handleTaggedFetchSeqno(const TaggedResponse &response); | ||
| 609 | void handleTaggedStatus(const TaggedResponse &response, ImapPendingCommand &command); | ||
| 610 | void handleTaggedCreate(const TaggedResponse &response, ImapPendingCommand &command); | ||
| 611 | void handleTaggedDelete(const TaggedResponse &response, ImapPendingCommand &command); | ||
| 612 | void handleTaggedRename(const TaggedResponse &response, ImapPendingCommand &command); | ||
| 613 | void handleUntaggedList(const QString &data); | ||
| 614 | void handleUntaggedFetch(const QString &type, const QString &data); | ||
| 615 | bool searchResultExpected() const; | ||
| 616 | void handleUntaggedEsearch(const QString &data); | ||
| 617 | void handleUntaggedSearch(const QString &data); | ||
| 618 | void handleUntaggedExists(const QString &type); | ||
| 619 | void handleUntaggedExpunge(const QString &type); | ||
| 620 | void handleUntaggedStatus(const QString &data); | ||
| 621 | void handleUntaggedOk(const QString &data); | ||
| 622 | #ifdef MAILJD_E2E_TESTING | ||
| 623 | quint64 e2eRequestTokenFor(const ImapPendingCommand &command) const; | ||
| 624 | void reportE2eTransportPhase(const QString &phase, | ||
| 625 | quint64 requestToken = 0); | ||
| 626 | #endif | ||
| 627 | |||
| 628 | QSslSocket *m_socket = nullptr; | ||
| 629 | // Monotonic milliseconds for the connection quiet time (S92.5.2). | ||
| 630 | 3038 | std::function<qint64()> m_nowMs = [] { | |
| 631 |
1/1✓ Branch 4 → 5 taken 3038 times.
|
3038 | return qint64(std::chrono::duration_cast<std::chrono::milliseconds>( |
| 632 | 6076 | std::chrono::steady_clock::now().time_since_epoch()) | |
| 633 | 6076 | .count()); | |
| 634 | }; | ||
| 635 | // S92.1.2: tags, pending commands, queues, command timeout/deadline and | ||
| 636 | // budgets (declared here so its timers keep their creation position). | ||
| 637 | std::unique_ptr<ImapCommandTable> m_commands; | ||
| 638 | // S92.1.3: TLS/CAPABILITY/LOGIN setup, setup timer, credentials. | ||
| 639 | std::unique_ptr<ImapConnectionSetup> m_setup; | ||
| 640 | // S92.1.4: IDLE/NOTIFY push and the liveness probe (creates the IDLE | ||
| 641 | // renew timer and both watchdogs at their former position). | ||
| 642 | std::unique_ptr<ImapPushSession> m_push; | ||
| 643 | // S92.1.5: body requests and the active body request. | ||
| 644 | std::unique_ptr<ImapBodyFetch> m_body; | ||
| 645 | // S92.1.6: STORE/MOVE/COPY/EXPUNGE and the mutation tokens. | ||
| 646 | std::unique_ptr<ImapMutationCommands> m_mutations; | ||
| 647 | // S92.1.7: APPEND request ids, literal upload and completion. | ||
| 648 | std::unique_ptr<ImapAppendStream> m_append; | ||
| 649 | |||
| 650 | State m_state = State::Disconnected; | ||
| 651 | |||
| 652 | // Accumulate LIST responses until the tagged OK | ||
| 653 | QList<FolderInfo> m_pendingFolders; | ||
| 654 | |||
| 655 | // Accumulate FETCH responses until the tagged OK | ||
| 656 | QList<MailHeader> m_pendingHeaders; | ||
| 657 | |||
| 658 | // Accumulate FETCH FLAGS responses | ||
| 659 | QList<QPair<qint64, quint32>> m_pendingFlags; | ||
| 660 | QList<ImapFlagSnapshot> m_pendingFlagSnapshots; | ||
| 661 | |||
| 662 | // Accumulate SEARCH responses | ||
| 663 | QList<qint64> m_pendingSearchUids; | ||
| 664 | // S106-187: identity of the in-flight Message-ID SEARCH (SEARCH is a | ||
| 665 | // stateful command, so at most one is on the wire). Sequence 0 = none. | ||
| 666 | struct MessageIdSearch { | ||
| 667 | quint64 sequence = 0; | ||
| 668 | QString folder; | ||
| 669 | quint32 uidValidity = 0; | ||
| 670 | }; | ||
| 671 | MessageIdSearch m_activeMessageIdSearch; | ||
| 672 | quint64 m_nextMessageIdSearch = 0; | ||
| 673 | |||
| 674 | // SELECT state | ||
| 675 | QString m_selectedFolder; | ||
| 676 | int m_selectedMessageCount = 0; | ||
| 677 | quint32 m_selectedUidValidity = 0; | ||
| 678 | qint64 m_verificationUid = 0; // UID of the FETCH_VERIFY_FLAGS in flight | ||
| 679 | quint64 m_selectedHighestModseq = 0; // T-208: CONDSTORE HIGHESTMODSEQ | ||
| 680 | |||
| 681 | QStringList m_capabilities; | ||
| 682 | |||
| 683 | QSet<QString> m_queuedStatusFolders; | ||
| 684 | |||
| 685 | |||
| 686 | |||
| 687 | // S92.1.1: response framing (read buffer, literals, body spool); created | ||
| 688 | // after the body fetch unit whose active request it references. | ||
| 689 | std::unique_ptr<ImapLineReader> m_reader; | ||
| 690 | |||
| 691 | static constexpr int TIMEOUT_MS = 10000; // 10 seconds | ||
| 692 | static constexpr qint64 MAX_SOCKET_READ_BUFFER_SIZE = 1024LL * 1024; | ||
| 693 | static constexpr int HEADER_BATCH_SIZE = 50; // Streaming batch | ||
| 694 | }; | ||
| 695 |