Line data Source code
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 <QTimer>
13 :
14 : #include <functional>
15 :
16 : #include "data/AccountConfig.h"
17 : #include "data/Models.h"
18 :
19 : // ImapService manages a single IMAP connection.
20 : // Communicates asynchronously via Qt signals/slots.
21 : // All network I/O happens on the caller's thread (main thread in Sprint 01).
22 : class ImapService : public QObject {
23 : Q_OBJECT
24 : #ifdef MAILJD_UNIT_TEST
25 : friend class TestImapServiceSerialization;
26 : friend class TestSprint55Controller;
27 : friend class TestConnectionHealth;
28 : friend class TestSprint79Imap;
29 : friend class TestSprint79CrossFolder;
30 : #endif
31 :
32 : public:
33 : enum class State {
34 : Disconnected,
35 : Connecting,
36 : Connected, // TCP connected, waiting for server greeting
37 : Greeting, // Server greeting received
38 : Capability, // CAPABILITY response received
39 : StartingTLS, // STARTTLS in progress
40 : Authenticating, // LOGIN sent, waiting for response
41 : Authenticated, // LOGIN successful, ready for commands
42 : Selected, // Folder selected, ready for FETCH commands
43 : Idling, // IDLE command active, waiting for server push
44 : Error
45 : };
46 3 : Q_ENUM(State)
47 :
48 : explicit ImapService(QObject *parent = nullptr);
49 : ~ImapService() override;
50 :
51 : void connectToServer(const ImapConfig &config);
52 : void disconnect();
53 :
54 : // Commands (only valid when Authenticated or Selected)
55 : void listFolders();
56 : void selectFolder(const QString &folderPath);
57 : void fetchHeaders(qint64 uidFrom = 1);
58 : void fetchBody(qint64 uid);
59 : void fetchBody(qint64 uid, qint64 maxBytes);
60 :
61 : // Full-message downloads are deliberately bounded below the protocol hard
62 : // cap. Users can raise/lower the limit in Settings; callers with a tighter
63 : // protocol payload (for example settings sync) may pass an explicit limit.
64 : static constexpr int DefaultMaxMessageSizeMiB = 25;
65 : static constexpr int MinMaxMessageSizeMiB = 1;
66 : static constexpr int MaxMaxMessageSizeMiB = 64;
67 : static qint64 configuredMaxMessageBytes();
68 :
69 : // T-205: Pipeline SELECT + FETCH BODY in one burst (for body connection).
70 : void selectAndFetchBody(const QString &folderPath, qint64 uid);
71 : void markSeen(qint64 uid);
72 : void markUnseen(qint64 uid);
73 : void markAllSeen(); // T-200: UID STORE 1:* +FLAGS (\Seen)
74 :
75 : // Generic flag store: add or remove any flag (system flag or keyword)
76 : // e.g. storeFlag(uid, "\\Flagged", true) or storeFlag(uid, "$label1", false)
77 : void storeFlag(qint64 uid, const QString &flag, bool add);
78 :
79 : // T-100: Move/copy messages between folders
80 : void moveMessage(qint64 uid, const QString &targetFolder);
81 : void moveMessages(const QList<qint64> &uids, const QString &targetFolder);
82 : void copyMessage(qint64 uid, const QString &targetFolder);
83 :
84 : // Search UIDs in the selected folder (fromUid=1 → all, fromUid>1 → delta).
85 : void searchAllUids(qint64 fromUid = 1);
86 :
87 : // T-187: Text-based IMAP SEARCH in the selected folder.
88 : // criteria: "TEXT", "SUBJECT", "FROM", "TO", "BODY"
89 : void searchText(const QString &query,
90 : const QString &criteria = QStringLiteral("TEXT"));
91 :
92 : // Sprint 59 (S2): composite IMAP SEARCH for the visual search facets.
93 : // ImapService stays independent of MailCache — the caller (MailController)
94 : // translates SearchFilter into this server-friendly subset. Tri-state mirrors
95 : // SearchFilter::Tri: Any adds no constraint.
96 : enum class SearchTri { Any, Yes, No };
97 : struct SearchCriteria {
98 : QString text; // TEXT "..."
99 : QString from; // FROM "..."
100 : QString to; // TO "..."
101 : QString subject; // SUBJECT "..."
102 : QDate since; // SINCE dd-MMM-yyyy (inclusive)
103 : QDate before; // BEFORE dd-MMM-yyyy (exclusive per RFC 3501)
104 : SearchTri unread = SearchTri::Any; // Yes ⇒ UNSEEN, No ⇒ SEEN
105 : SearchTri flagged = SearchTri::Any; // Yes ⇒ FLAGGED, No ⇒ UNFLAGGED
106 : SearchTri answered = SearchTri::Any; // Yes ⇒ ANSWERED, No ⇒ UNANSWERED
107 : QStringList keywords; // KEYWORD "..." per entry
108 :
109 : // True when nothing constrains the search — such criteria are not sent.
110 : bool isEmpty() const;
111 : };
112 :
113 : // Run a composite SEARCH built from the criteria. No-op when isEmpty().
114 : void search(const SearchCriteria &criteria);
115 :
116 : // T-211: Search by Message-ID header (for undo-move).
117 : void searchByMessageId(const QString &messageId);
118 :
119 : // Fetch headers for a specific set of UIDs (comma-separated).
120 : void fetchHeadersByUids(const QList<qint64> &uids);
121 :
122 : // T-176: IMAP APPEND – Upload a complete RFC-2822 message to a folder.
123 : // flags: e.g. "\\Seen", "\\Draft", or "\\Seen \\Draft"
124 : void appendMessage(const QString &folder, const QByteArray &rfcMessage,
125 : const QString &flags = {});
126 :
127 : // T-176: EXPUNGE – Permanently remove messages marked \Deleted in the
128 : // currently selected folder. Used by T-177 (Drafts) to delete old drafts
129 : // after re-saving.
130 : void expunge();
131 :
132 : // T-281: Folder management commands (RFC 3501 §6.3.3–§6.3.5)
133 : void createFolder(const QString &folderPath);
134 : void deleteFolder(const QString &folderPath);
135 : void renameFolder(const QString &oldPath, const QString &newPath);
136 :
137 : // IDLE support (RFC 2177)
138 : void startIdle(); // Enter IDLE mode (must be in Selected state)
139 : void stopIdle(); // Send DONE to exit IDLE mode
140 68 : bool isIdling() const { return m_isIdling; }
141 : bool hasIdleCapability() const;
142 : bool hasCondstoreCapability() const; // T-208: CONDSTORE support
143 :
144 : // T-320: NOTIFY support (RFC 5465) — replaces IDLE + STATUS polling
145 : void startNotify(const QStringList &subscribedFolders);
146 : void stopNotify(); // Send NOTIFY NONE
147 79 : bool isNotifying() const { return m_isNotifying; }
148 : bool hasNotifyCapability() const;
149 : void executeAfterNotify(std::function<void()> command);
150 :
151 : // T-205: Disable automatic IDLE entry after FETCH_BODY/STORE.
152 : // Set to false for connections that only do body fetches.
153 41 : void setAutoIdle(bool enabled) { m_autoIdle = enabled; }
154 :
155 : // Execute a command after safely stopping IDLE or NOTIFY.
156 : // If not idling/notifying: executes immediately.
157 : // If idling: sends DONE, queues command, executes on IDLE OK.
158 : // If notifying: sends NOTIFY NONE, queues command, executes on NOTIFY OK.
159 : void executeAfterIdle(std::function<void()> command);
160 :
161 : // T-114: Clear all pending deferred commands (e.g. on folder switch).
162 : // Prevents stale SELECTs/FETCHes from executing after the user
163 : // switches folders rapidly.
164 : void clearDeferredCommands();
165 :
166 : // Flag sync: fetch only UID+FLAGS for all messages in selected folder.
167 : void fetchFlags();
168 :
169 : // T-207: Pipeline SELECT + FETCH FLAGS in one burst (saves one round-trip).
170 : // Sends both commands immediately; server processes them in-order per RFC 3501.
171 : void selectAndFetchFlags(const QString &folderPath);
172 :
173 : // T-208: Incremental flag sync using CONDSTORE (RFC 4551).
174 : // Only fetches flags changed since the given HIGHESTMODSEQ value.
175 : void fetchFlagsChanged(quint64 modseq);
176 :
177 : // Fetch UID+FLAGS for a single sequence number (used after IDLE flag push).
178 : void fetchUidForSeqNo(int seqNo);
179 :
180 : // Folder status: query UNSEEN/MESSAGES/RECENT without selecting.
181 : void statusFolder(const QString &folderPath);
182 :
183 : // T-540: NOOP command for keep-alive (body connection)
184 : void sendNoop();
185 :
186 : // T-720: Liveness probe API for ConnectionHealthMonitor.
187 : // Idempotent while a probe is already running. The concrete probe depends
188 : // on the current state:
189 : // Authenticated/Selected → tagged NOOP with a 15 s watchdog
190 : // Idling → DONE/OK round-trip with a 15 s watchdog
191 : // Connecting/Auth/Error/Disconnected → no-op (handled elsewhere)
192 : // On watchdog timeout the connection is failed and the monitor schedules
193 : // a reconnect.
194 : void requestLivenessProbe(const QString &reason);
195 :
196 : // T-720: Public wrapper around private failConnection() so the
197 : // ConnectionHealthMonitor can force a teardown + reconnect on
198 : // suspend/resume or a network change. Validates/logs the reason.
199 : void abortForReconnect(const QString &reason);
200 :
201 : // T-720: Test seam — true after the TCP-keepalive tuning helper ran
202 : // (i.e. onConnected()/onEncrypted() ran the platform setsockopt path).
203 3 : bool keepAliveTuned() const { return m_keepAliveTuned; }
204 :
205 : // T-720: Test seam — true while a liveness probe is in flight.
206 6 : bool isLivenessProbeInFlight() const { return !m_probeTag.isEmpty(); }
207 :
208 : // Currently selected folder path.
209 17 : QString selectedFolder() const { return m_selectedFolder; }
210 :
211 1130 : State state() const { return m_state; }
212 :
213 : signals:
214 : void stateChanged(ImapService::State newState);
215 : void errorOccurred(const QString &error);
216 : void folderListReceived(const QList<FolderInfo> &folders);
217 : void folderSelected(const QString &path, int messageCount,
218 : quint32 uidValidity, quint64 highestModseq = 0); // T-208
219 : // Emitted when a SELECT is rejected by the server (e.g. \Noselect folder).
220 : // Lets multi-folder flows (server search) skip the folder instead of
221 : // waiting forever for a folderSelected that will never arrive.
222 : void folderSelectFailed(const QString &path);
223 : void headersReceived(const QList<MailHeader> &headers);
224 : void headerFetchComplete(); // All header batches delivered
225 : void rawBodyReceived(qint64 uid, const QByteArray &rawBody);
226 : void bodyFetchTooLarge(qint64 uid, qint64 maxBytes);
227 :
228 : // IDLE push signals
229 : void idleNewMessages(int newCount); // * N EXISTS (N > previous)
230 : void idleMessageExpunged(int seqNo); // * N EXPUNGE
231 : void idleFlagsChanged(qint64 uid, quint32 flags); // * N FETCH (FLAGS ...)
232 : void idleFlagsNeedRefetch(int seqNo); // * N FETCH (FLAGS ...) without UID
233 :
234 : // Flag sync result
235 : void flagsReceived(const QList<QPair<qint64, quint32>> &uidFlags);
236 :
237 : // Search result: list of UIDs matching the search query.
238 : void searchResultReceived(const QList<qint64> &uids);
239 :
240 : // Folder status result
241 : void folderStatusReceived(const StatusResult &result);
242 : void storeComplete();
243 :
244 : // T-100: Move/copy result signals
245 : void messageMoved(qint64 uid, const QString &targetFolder);
246 : void messagesMoved(const QList<qint64> &uids, const QString &targetFolder);
247 : void messageCopied(qint64 uid, const QString &targetFolder);
248 : void moveError(const QString &error);
249 :
250 : // T-176: APPEND result signals
251 : void messageAppended(const QString &folder, qint64 uid); // uid=0 if no UIDPLUS
252 : void appendError(const QString &error);
253 :
254 : // T-176: EXPUNGE result
255 : void expungeComplete();
256 :
257 : // T-281: Folder management result signals
258 : void folderCreated(const QString &folderPath);
259 : void folderDeleted(const QString &folderPath);
260 : void folderRenamed(const QString &oldPath, const QString &newPath);
261 : void folderOperationError(const QString &operation, const QString &error);
262 :
263 : private slots:
264 : void onConnected();
265 : void onEncrypted();
266 : void onReadyRead();
267 : void onSocketError(QAbstractSocket::SocketError error);
268 : void onTimeout();
269 : void onCommandTimeout();
270 : void onCommandDeadline();
271 : void onIdleRenew();
272 : // T-720: Liveness-probe watchdog slots.
273 : void onLivenessProbeTimeout();
274 : void onIdleRenewWatchdogTimeout();
275 :
276 : private:
277 : void setState(State newState);
278 : void sendCommand(const QString &type, const QString &command);
279 : // T-720: like sendCommand() but exposes the generated tag (for probes).
280 : QString sendTaggedCommand(const QString &type, const QString &command);
281 : // T-720: Send a probe NOOP and remember its tag. Does not enqueue —
282 : // call only in a state that allows commands (Authenticated/Selected).
283 : void sendProbeNoop();
284 : // T-720: Apply SO_KEEPALIVE + platform-specific interval tuning to the
285 : // connected socket. Called once from onConnected()/onEncrypted().
286 : void tuneKeepAlive();
287 : bool beginLogin();
288 : void processLine(const QString &line);
289 : void handleUntagged(const QString &line);
290 : void handleTagged(const QString &line);
291 : void restartPush(); // T-320: restart NOTIFY or IDLE based on capabilities
292 : bool hasStatefulCommandInFlight() const;
293 : bool hasTimeoutTrackedCommandInFlight() const;
294 : void enqueueSerializedCommand(std::function<void()> command);
295 : void runNextSerializedCommand();
296 : void refreshCommandTimeout();
297 : void refreshCommandDeadline();
298 : void resetCommandAccumulators();
299 : bool accountCommandResponseBytes(qint64 bytes);
300 : bool accountCommandLiteral();
301 : bool accountCommandResultItems(qint64 items = 1);
302 : void completeLiteral();
303 : void clearCredentials();
304 : void invalidateBodyFetch();
305 : void fetchBodyWithLimits(qint64 uid, qint64 acceptedBytes,
306 : qint64 requestedBytes);
307 : void failConnection(const QString &error);
308 : QString nextTag();
309 : static QString buildFetchBodyCommand(qint64 uid, qint64 maxBytes = -1);
310 : static QString quoteImapString(const QString &str);
311 : static bool isValidImapFlag(const QString &flag);
312 : // Sprint 59 (S2): assemble "UID SEARCH <criteria…>" from the facet criteria.
313 : // Returns an empty string when no criterion is set. Pure + testable.
314 : static QString buildSearchCommand(const SearchCriteria &criteria);
315 :
316 : QSslSocket *m_socket = nullptr;
317 : QTimer *m_timeoutTimer = nullptr;
318 : QTimer *m_commandTimeoutTimer = nullptr;
319 : QTimer *m_commandDeadlineTimer = nullptr;
320 : QTimer *m_idleRenewTimer = nullptr;
321 : // T-720: Liveness-probe watchdogs (single-shot). PROBE_WATCHDOG_MS is the
322 : // budget for a probe NOOP or an IDLE DONE/OK round-trip; it is shorter
323 : // than COMMAND_TIMEOUT_MS so the monitor can claim a ~30 s worst-case
324 : // detection window.
325 : QTimer *m_livenessProbeWatchdog = nullptr;
326 : QTimer *m_idleRenewWatchdog = nullptr;
327 : QString m_probeTag; // T-720: tag of the in-flight probe NOOP
328 : QString m_lastProbeReason; // T-720: reason string for diagnostic logging
329 : // T-720: True once tuneKeepAlive() ran (test seam for native tuning).
330 : bool m_keepAliveTuned = false;
331 :
332 : State m_state = State::Disconnected;
333 : ImapConfig m_config;
334 :
335 : int m_tagCounter = 0;
336 : QByteArray m_readBuffer;
337 :
338 : // Track pending commands: tag → command-type ("CAPABILITY", "LOGIN", "LIST")
339 : QMap<QString, QString> m_pendingCommands;
340 :
341 : // Accumulate LIST responses until the tagged OK
342 : QList<FolderInfo> m_pendingFolders;
343 :
344 : // Accumulate FETCH responses until the tagged OK
345 : QList<MailHeader> m_pendingHeaders;
346 :
347 : // Accumulate FETCH FLAGS responses
348 : QList<QPair<qint64, quint32>> m_pendingFlags;
349 :
350 : // Accumulate SEARCH responses
351 : QList<qint64> m_pendingSearchUids;
352 :
353 : // SELECT state
354 : QString m_selectedFolder;
355 : QString m_pendingSelectFolder; // Bug 34: assigned on SELECT OK, not before
356 : int m_selectedMessageCount = 0;
357 : quint32 m_selectedUidValidity = 0;
358 : quint64 m_selectedHighestModseq = 0; // T-208: CONDSTORE HIGHESTMODSEQ
359 :
360 : QStringList m_capabilities;
361 : bool m_autoIdle = true; // T-205: false for body-fetch connections
362 :
363 : // IDLE state
364 : bool m_isIdling = false;
365 : QString m_idleTag; // Tag of the IDLE command (for matching tagged OK)
366 :
367 : // T-320: NOTIFY state (RFC 5465)
368 : bool m_isNotifying = false;
369 : QString m_notifyTag; // Tag of the NOTIFY SET command
370 : QStringList m_notifyFolders; // Folders being watched via NOTIFY
371 :
372 : QString m_pendingStatusFolder; // Folder path for STATUS command in-flight
373 : QString m_pendingMoveTarget; // T-100: Target folder for MOVE/COPY
374 : QList<qint64> m_pendingMoveUids; // T-100: UIDs being moved/copied
375 :
376 : // T-176: APPEND literal state
377 : QByteArray m_pendingAppendData; // RFC-2822 message bytes to send after +
378 : QString m_pendingAppendFolder; // Target folder for APPEND
379 :
380 : // T-281: Folder management pending state
381 : QString m_pendingFolderOp; // Folder path for CREATE/DELETE/RENAME
382 : QString m_pendingFolderNewPath; // New path for RENAME
383 :
384 : // Deferred command queue: commands to execute after IDLE stops
385 : QQueue<std::function<void()>> m_deferredCommands;
386 : QQueue<std::function<void()>> m_serializedCommands;
387 :
388 : // IMAP literal accumulation: {N}\r\n followed by N raw bytes
389 : qint64 m_literalBytesRemaining = 0; // T-510: was int, overflow on >2GB
390 : QString m_literalLine; // line that triggered the literal
391 : QByteArray m_literalData; // accumulated raw bytes
392 : bool m_discardingOversizedBody = false;
393 : bool m_skipFetchLiteralRemainder = false;
394 :
395 : // Body literal bypass: when fetching BODY[], keep raw QByteArray
396 : // instead of converting through QString (which corrupts binary MIME data)
397 : bool m_isBodyLiteral = false;
398 : qint64 m_bodyLiteralUid = -1;
399 : bool m_discardingInvalidBody = false;
400 : qint64 m_activeBodyFetchUid = -1;
401 : qint64 m_activeBodyFetchLimit = 0;
402 : qint64 m_activeBodyFetchRequestBytes = 0;
403 : bool m_bodyFetchRequiresSelect = false;
404 :
405 : // A command budget spans one serialized command or one intentionally
406 : // pipelined command pair (SELECT+FETCH). It is reset only after the final
407 : // non-IDLE tagged response, so slow-drip progress cannot evade it.
408 : qint64 m_commandResponseBytes = 0;
409 : qint64 m_commandLiteralCount = 0;
410 : qint64 m_commandResultItems = 0;
411 :
412 : // T-210: Per-command timing — maps tag → elapsed timer
413 : QMap<QString, QElapsedTimer> m_commandTimers;
414 :
415 : static constexpr int TIMEOUT_MS = 10000; // 10 seconds
416 : // SEC-2026-07-21-12: deadline for the pre-auth setup phases (greeting,
417 : // TLS handshake, CAPABILITY, LOGIN). Without this, a silent server can
418 : // stall the account indefinitely after TCP connect — no error, no
419 : // reconnect. The timer is re-armed on each setup-state transition and
420 : // stopped once Authenticated is reached (command timeout takes over).
421 : static constexpr int SESSION_SETUP_TIMEOUT_MS = 30000; // 30 seconds
422 : static constexpr int COMMAND_TIMEOUT_MS = 30000; // 30 s inactivity
423 : static constexpr int COMMAND_DEADLINE_MS = 120000; // 2 min absolute
424 : // T-720: Liveness probe budget. The monitor's 15 s probe interval + this
425 : // 15 s watchdog yields ~30 s worst-case dead-socket detection.
426 : static constexpr int PROBE_WATCHDOG_MS = 15000;
427 : static constexpr qint64 MAX_SOCKET_READ_BUFFER_SIZE = 1024LL * 1024;
428 : static constexpr qint64 MAX_READ_BUFFER_SIZE = 4LL * 1024 * 1024;
429 : static constexpr qint64 MAX_LITERAL_SIZE =
430 : (static_cast<qint64>(MaxMaxMessageSizeMiB) * 1024 * 1024) + 1;
431 : static constexpr qint64 MAX_COMMAND_RESPONSE_SIZE = 96LL * 1024 * 1024;
432 : static constexpr qint64 MAX_LITERALS_PER_COMMAND = 50000;
433 : static constexpr qint64 MAX_RESULT_ITEMS_PER_COMMAND = 50000;
434 : // Hard cap for one folded logical response. Unlike a lifetime transfer
435 : // quota, this protects command-free phases without disconnecting healthy
436 : // long-running IDLE/NOTIFY sessions after normal cumulative traffic.
437 : static constexpr qint64 MAX_NON_BODY_LITERAL_LINE_SIZE = 8LL * 1024 * 1024;
438 : static constexpr int IDLE_RENEW_MS = 25 * 60 * 1000; // T-271: 25 min (was 28)
439 : static constexpr int HEADER_BATCH_SIZE = 50; // Streaming batch
440 : };
|