Branch data Line data Source code
1 : : #pragma once
2 : :
3 : : #include <QList>
4 : : #include <QMap>
5 : : #include <QObject>
6 : : #include <QPair>
7 : : #include <QSqlDatabase>
8 : : #include <optional>
9 : :
10 : : #include "data/Models.h"
11 : :
12 : : // T-122: External content whitelist entry
13 : : struct WhitelistEntry {
14 : : qint64 id = 0;
15 : : QString type; // "sender" or "domain"
16 : : QString value; // e.g. "user@example.com" or "example.com"
17 : : QString createdAt;
18 : : };
19 : :
20 : : // MailCache provides persistent storage for mail headers and bodies using
21 : : // SQLite. Designed for cache-first architecture: all reads go through this
22 : : // class first, IMAP fetches only happen on cache miss or incremental sync.
23 : : //
24 : : // Performance: Uses WAL journal mode and batch transactions for fast writes.
25 : : // Thread safety: Not thread-safe. Use from a single thread or with external
26 : : // locking.
27 : : class MailCache : public QObject {
28 : : Q_OBJECT
29 : :
30 : : public:
31 : : explicit MailCache(QObject *parent = nullptr);
32 : : ~MailCache() override;
33 : :
34 : : // Open or create the SQLite database at the given path.
35 : : // Creates schema (tables + indexes) if the database is new.
36 : : // Returns false on failure (check lastError()).
37 : : bool open(const QString &dbPath);
38 : :
39 : : // Close the database connection.
40 : : void close();
41 : :
42 : : // True if the database is currently open.
43 : : bool isOpen() const;
44 : :
45 : : // Path of the database file (for opening secondary connections).
46 : 50 : QString databasePath() const { return m_dbPath; }
47 : :
48 : : // Last error message from a failed operation.
49 : : QString lastError() const;
50 : :
51 : : // --- Folder operations ---
52 : :
53 : : // Ensure a folder entry exists for the given account + IMAP path.
54 : : // Returns the folder ID (creates if not exists, returns existing if found).
55 : : // Returns -1 on error.
56 : : qint64 ensureFolder(const QString &account, const QString &path);
57 : : QString folderPath(qint64 folderId) const;
58 : :
59 : : // T-215: Find a mail by Message-ID across all folders.
60 : : // Returns {folderId, uid} or nullopt if not found.
61 : : std::optional<QPair<qint64,qint64>> findByMessageId(const QString &messageId) const;
62 : :
63 : : // Set the UIDVALIDITY for a folder. If the value differs from the stored
64 : : // value, all cached data for that folder is purged (RFC 3501 requirement).
65 : : void setUidValidity(qint64 folderId, quint32 uidvalidity);
66 : :
67 : : // Get the stored UIDVALIDITY for a folder. Returns 0 if not set.
68 : : quint32 uidValidity(qint64 folderId) const;
69 : :
70 : : // T-208: CONDSTORE HIGHESTMODSEQ per folder
71 : : void setHighestModseq(qint64 folderId, quint64 modseq);
72 : : quint64 highestModseq(qint64 folderId) const;
73 : :
74 : : // T-209: Last sync timestamp (seconds since epoch)
75 : : void setLastSync(qint64 folderId);
76 : : qint64 lastSync(qint64 folderId) const;
77 : :
78 : : // --- Header operations (batch) ---
79 : :
80 : : // Store a batch of headers for a folder. Uses a transaction for speed.
81 : : // Existing headers with the same UID are updated (UPSERT).
82 : : void storeHeaders(qint64 folderId, const QList<MailHeader> &headers);
83 : :
84 : : // Retrieve all cached headers for a folder, sorted by date DESC.
85 : : QList<MailHeader> headers(qint64 folderId) const;
86 : :
87 : : // Retrieve a single header by folder + UID. Returns nullopt if not cached.
88 : : std::optional<MailHeader> header(qint64 folderId, qint64 uid) const;
89 : :
90 : : // Get the maximum UID stored for a folder. Returns 0 if no headers cached.
91 : : // Used for incremental sync: FETCH UID maxUid+1:*
92 : : qint64 maxUid(qint64 folderId) const;
93 : :
94 : : // Total number of cached headers for a folder.
95 : : int headerCount(qint64 folderId) const;
96 : :
97 : : // --- Body operations ---
98 : :
99 : : // Store a mail body. The header must already exist in the cache.
100 : : void storeBody(qint64 folderId, qint64 uid, const MailBody &body);
101 : :
102 : : // Retrieve a cached mail body. Returns nullopt if not cached.
103 : : std::optional<MailBody> body(qint64 folderId, qint64 uid) const;
104 : :
105 : : // Check if a body is already cached (without loading the data).
106 : : bool hasBody(qint64 folderId, qint64 uid) const;
107 : :
108 : : // --- Attachment operations ---
109 : :
110 : : // Store attachment metadata and BLOB data for a mail.
111 : : // The header must already exist in the cache.
112 : : void storeAttachments(qint64 folderId, qint64 uid,
113 : : const QList<Attachment> &attachments,
114 : : const QList<QByteArray> &blobs);
115 : :
116 : : // Retrieve attachment metadata for a mail (WITHOUT BLOB data, lazy load).
117 : : QList<Attachment> attachments(qint64 folderId, qint64 uid) const;
118 : :
119 : : // Load BLOB data for a single attachment on-demand.
120 : : QByteArray attachmentData(qint64 attachmentId) const;
121 : :
122 : : // --- Flag operations ---
123 : :
124 : : // Update flags for a single header.
125 : : void updateFlags(qint64 folderId, qint64 uid, quint32 flags);
126 : :
127 : : // Batch-update flags for multiple UIDs in a single transaction.
128 : : // Input: list of (UID, flags) pairs.
129 : : void batchUpdateFlags(qint64 folderId,
130 : : const QList<QPair<qint64, quint32>> &uidFlags);
131 : :
132 : : // Remove a single header (and cascaded body/attachments) by UID.
133 : : void removeHeader(qint64 folderId, qint64 uid);
134 : :
135 : : // Count of unread (unseen) headers for a folder.
136 : : int unreadCount(qint64 folderId) const;
137 : :
138 : : // --- Label operations (T-261) ---
139 : :
140 : : // Add a label (keyword) to a cached header. No-op if already present.
141 : : void addLabel(qint64 folderId, qint64 uid, const QString &label);
142 : :
143 : : // Remove a label (keyword) from a cached header. No-op if not present.
144 : : void removeLabel(qint64 folderId, qint64 uid, const QString &label);
145 : :
146 : : // --- Maintenance ---
147 : :
148 : : // Delete all cached data (headers + bodies) for a folder.
149 : : void purgeFolder(qint64 folderId);
150 : :
151 : : // T-138: Path-based convenience overloads for FolderPropertiesDialog
152 : : int cachedHeaderCount(const QString &account, const QString &folderPath);
153 : : int cachedBodyCount(const QString &account, const QString &folderPath);
154 : : void purgeFolderByPath(const QString &account, const QString &folderPath);
155 : :
156 : : // T-286: Extended statistics for folder properties dialog
157 : : qint64 cachedDiskUsage(const QString &account, const QString &folderPath);
158 : : qint64 totalServerSize(const QString &account, const QString &folderPath);
159 : : qint64 averageMailSize(const QString &account, const QString &folderPath);
160 : : void purgeBodyCache(const QString &account, const QString &folderPath);
161 : : void renameFolderPath(const QString &account, const QString &oldPath,
162 : : const QString &newPath);
163 : :
164 : : // T-167: Convenience methods for FolderPredictor pre-training
165 : : QStringList allFolderPaths(const QString &account) const;
166 : : QList<MailHeader> headersByFolder(const QString &account,
167 : : const QString &folderPath) const;
168 : :
169 : : // --- Badge caching (T-075) ---
170 : :
171 : : // Store the polled unseen count for a folder.
172 : : void storeBadge(qint64 folderId, int unseenCount);
173 : :
174 : : // Load all cached badges for an account. Returns map of folderPath → unseen.
175 : : QMap<QString, int> loadAllBadges(const QString &account) const;
176 : :
177 : : // --- External Content Whitelist (T-122) ---
178 : :
179 : : // Add a whitelist entry. Type must be "sender" or "domain".
180 : : // Returns true on success, false on error (e.g. duplicate).
181 : : bool addWhitelistEntry(const QString &type, const QString &value);
182 : :
183 : : // Remove a whitelist entry by ID. Returns true if a row was deleted.
184 : : bool removeWhitelistEntry(qint64 id);
185 : :
186 : : // T-313: Remove all whitelist entries (for settings sync apply).
187 : : void clearWhitelist();
188 : :
189 : : // Atomically replace all whitelist entries. The payload is validated before
190 : : // the current table contents are deleted.
191 : : bool replaceWhitelistEntries(const QList<QPair<QString, QString>> &entries);
192 : :
193 : : // Retrieve all whitelist entries.
194 : : QList<WhitelistEntry> whitelistEntries() const;
195 : :
196 : : // Check if a sender email matches any whitelist entry (sender or domain).
197 : : bool isWhitelisted(const QString &senderEmail) const;
198 : :
199 : : // Get all whitelisted domains as a string list.
200 : : QStringList whitelistedDomains() const;
201 : :
202 : : // Get all whitelisted sender addresses as a string list.
203 : : QStringList whitelistedSenders() const;
204 : :
205 : : // --- Full-Text Search (T-179) ---
206 : :
207 : : struct SearchResult {
208 : : qint64 folderId = 0;
209 : : qint64 uid = 0;
210 : : QString folderPath; // resolved folder path
211 : : QString subject;
212 : : QString from;
213 : : QString snippet; // highlighted match snippet
214 : : double rank = 0.0; // BM25 relevance score
215 : : };
216 : :
217 : : // Search for emails matching a query string (FTS5 MATCH syntax).
218 : : // Returns results sorted by BM25 relevance, limited to maxResults.
219 : : QList<SearchResult> searchFts(const QString &query, int maxResults = 0,
220 : : int offset = 0) const;
221 : :
222 : : // T-192: Optional filters for search.
223 : : // Sprint 59: extended with subject, tags and tri-state flag/attachment
224 : : // facets so the visual SearchPanel can express the full set of constraints.
225 : : struct SearchFilter {
226 : : // Tri-state for flag-like facets. "Any" means the facet adds no
227 : : // constraint; it maps 1:1 to the three states of the panel comboboxes.
228 : : enum class Tri { Any, Yes, No };
229 : :
230 : : // Sprint 60 (B1): zero, one or many folder patterns. OR-Semantik: a mail
231 : : // matches when its folder path contains ANY of the patterns (LIKE
232 : : // '%pattern%'). Empty list = no folder constraint. Replaces the single
233 : : // folderPattern of Sprint 59.
234 : : QStringList folderPatterns; // e.g. {"INBOX", "Archive"} — OR-matched
235 : : QDateTime dateFrom; // inclusive lower bound (empty = no lower bound)
236 : : QDateTime dateTo; // inclusive upper bound (empty = no upper bound)
237 : : QString fromFilter; // from address contains
238 : : QString toFilter; // to address contains
239 : :
240 : : // Sprint 59 facets:
241 : : QString subjectFilter; // folded subject substring (LIKE %X% on f.subject)
242 : : QStringList tags; // mail_labels: ALL listed labels must be present (AND)
243 : : Tri unread = Tri::Any; // Yes ⇒ Seen bit NOT set; No ⇒ Seen bit set
244 : : Tri flagged = Tri::Any; // Yes/No ⇒ Flagged bit set / not set
245 : : Tri answered = Tri::Any; // Yes/No ⇒ Answered bit set / not set
246 : : Tri hasAttachment = Tri::Any; // Yes/No ⇒ has_attachments = 1 / 0
247 : :
248 : : // True when no facet constrains the search (every field at its neutral
249 : : // value). Used by the app path to distinguish "no search state" from
250 : : // "empty free text but active facets" (Sprint 59 A1).
251 : 170 : bool isEmpty() const {
252 [ + + ]: 329 : return folderPatterns.isEmpty() && !dateFrom.isValid() &&
253 [ + - + + : 300 : !dateTo.isValid() && fromFilter.isEmpty() && toFilter.isEmpty() &&
+ + + + ]
254 [ + + ]: 291 : subjectFilter.isEmpty() && tags.isEmpty() &&
255 [ + + + + ]: 141 : unread == Tri::Any && flagged == Tri::Any &&
256 [ + + + + : 329 : answered == Tri::Any && hasAttachment == Tri::Any;
+ + ]
257 : : }
258 : :
259 : 8 : bool operator==(const SearchFilter &o) const {
260 [ + - ]: 16 : return folderPatterns == o.folderPatterns && dateFrom == o.dateFrom &&
261 [ + - + - : 16 : dateTo == o.dateTo && fromFilter == o.fromFilter &&
+ - ]
262 [ + - ]: 16 : toFilter == o.toFilter && subjectFilter == o.subjectFilter &&
263 [ + - + - : 8 : tags == o.tags && unread == o.unread && flagged == o.flagged &&
+ - ]
264 [ + - + - : 16 : answered == o.answered && hasAttachment == o.hasAttachment;
+ - ]
265 : : }
266 : : bool operator!=(const SearchFilter &o) const { return !(*this == o); }
267 : : };
268 : : QList<SearchResult> searchFts(const QString &query, const SearchFilter &filter,
269 : : int maxResults = 0, int offset = 0) const;
270 : :
271 : : // Sprint 59 (F3): distinct IMAP keywords/labels known to the cache, sorted
272 : : // case-insensitively. Used to populate tag suggestions in the search UI.
273 : : QStringList knownLabels() const;
274 : :
275 : : // Index a single email for FTS5 (called after storeHeaders/storeBody).
276 : : void indexForSearch(qint64 folderId, qint64 uid);
277 : : void batchIndexForSearch(qint64 folderId, const QList<qint64> &uids);
278 : : bool searchIndexEmpty() const;
279 : :
280 : : // Rebuild the entire FTS5 index from existing cached data.
281 : : // This is a heavyweight operation, intended for initial population or repair.
282 : : void rebuildSearchIndex();
283 : :
284 : : // Fold text for the FTS index: lowercase + strip diacritics (Müller→muller,
285 : : // Café→cafe, ß→ss). Applied to BOTH indexed text and queries so the trigram
286 : : // tokenizer (no native remove_diacritics before SQLite 3.45) is accent-blind.
287 : : // Public + static so it can be unit-tested in isolation.
288 : : static QString foldForSearch(const QString &text);
289 : :
290 : : private:
291 : : #ifdef MAILJD_UNIT_TEST
292 : : friend class TestMailCache;
293 : : #endif
294 : :
295 : : // Create database schema (tables + indexes).
296 : : bool createSchema();
297 : :
298 : : // Get the internal row ID for a header by folder + UID.
299 : : qint64 headerRowId(qint64 folderId, qint64 uid) const;
300 : :
301 : : // Replace a header's FTS row from current header/body data. The caller must
302 : : // provide a transaction when this is used concurrently with other writers.
303 : : bool indexHeaderById(qint64 rowId);
304 : :
305 : : qint64 calculatePayloadCacheSize() const;
306 : : void enforcePayloadCacheLimit(qint64 protectedHeaderId = -1);
307 : :
308 : : QSqlDatabase m_db;
309 : : QString m_connectionName;
310 : : QString m_dbPath;
311 : : QString m_lastError;
312 : : qint64 m_payloadCacheBytes = 0;
313 : : qint64 m_payloadCacheLimitBytes = 1024LL * 1024 * 1024;
314 : : };
|