1 /*
2 SPDX-FileCopyrightText: 2018 Daniel Vrátil <dvratil@kde.org>
3
4 SPDX-License-Identifier: GPL-2.0-or-later
5 */
6
7 #include "unifiedmailboxmanager.h"
8 #include "common.h"
9 #include "settings.h"
10 #include "unifiedmailbox.h"
11 #include "unifiedmailboxagent_debug.h"
12
13 #include <KLocalizedString>
14
15 #include <Akonadi/CollectionFetchJob>
16 #include <Akonadi/CollectionFetchScope>
17 #include <Akonadi/ItemFetchScope>
18 #include <Akonadi/KMime/SpecialMailCollections>
19 #include <Akonadi/LinkJob>
20 #include <Akonadi/SpecialCollectionAttribute>
21 #include <Akonadi/UnlinkJob>
22
23 #include <QTimer>
24
25 #include <stdexcept> // for std::out_of_range
26
27 namespace
28 {
29 /**
30 * A little RAII helper to make sure changeProcessed() and replayNext() gets
31 * called on the ChangeRecorder whenever we are done with handling a change.
32 */
33 class ReplayNextOnExit
34 {
35 public:
ReplayNextOnExit(Akonadi::ChangeRecorder & recorder)36 ReplayNextOnExit(Akonadi::ChangeRecorder &recorder)
37 : mRecorder(recorder)
38 {
39 }
40
~ReplayNextOnExit()41 ~ReplayNextOnExit()
42 {
43 mRecorder.changeProcessed();
44 mRecorder.replayNext();
45 }
46
47 private:
48 Akonadi::ChangeRecorder &mRecorder;
49 };
50 }
51
52 // static
isUnifiedMailbox(const Akonadi::Collection & col)53 bool UnifiedMailboxManager::isUnifiedMailbox(const Akonadi::Collection &col)
54 {
55 #ifdef UNIT_TESTS
56 return col.parentCollection().name() == Common::AgentIdentifier;
57 #else
58 return col.resource() == Common::AgentIdentifier;
59 #endif
60 }
61
UnifiedMailboxManager(const KSharedConfigPtr & config,QObject * parent)62 UnifiedMailboxManager::UnifiedMailboxManager(const KSharedConfigPtr &config, QObject *parent)
63 : QObject(parent)
64 , mConfig(config)
65 {
66 mMonitor.setObjectName(QStringLiteral("UnifiedMailboxChangeRecorder"));
67 mMonitor.setConfig(&mMonitorSettings);
68 mMonitor.setChangeRecordingEnabled(true);
69 mMonitor.setTypeMonitored(Akonadi::Monitor::Items);
70 mMonitor.setTypeMonitored(Akonadi::Monitor::Collections);
71 mMonitor.itemFetchScope().setCacheOnly(true);
72 mMonitor.itemFetchScope().setFetchRemoteIdentification(false);
73 mMonitor.itemFetchScope().setFetchModificationTime(false);
74 mMonitor.collectionFetchScope().fetchAttribute<Akonadi::SpecialCollectionAttribute>();
75 connect(&mMonitor, &Akonadi::Monitor::itemAdded, this, [this](const Akonadi::Item &item, const Akonadi::Collection &collection) {
76 ReplayNextOnExit replayNext(mMonitor);
77
78 qCDebug(UNIFIEDMAILBOXAGENT_LOG) << "Item" << item.id() << "added to collection" << collection.id();
79 const auto box = unifiedMailboxForSource(collection.id());
80 if (!box) {
81 qCWarning(UNIFIEDMAILBOXAGENT_LOG) << "Failed to find unified mailbox for source collection " << collection.id();
82 return;
83 }
84
85 if (box->collectionId() <= -1) {
86 qCWarning(UNIFIEDMAILBOXAGENT_LOG) << "Missing box->collection mapping for unified mailbox" << box->id();
87 return;
88 }
89
90 new Akonadi::LinkJob(Akonadi::Collection{box->collectionId()}, {item}, this);
91 });
92 connect(&mMonitor, &Akonadi::Monitor::itemsRemoved, this, [this](const Akonadi::Item::List &items) {
93 ReplayNextOnExit replayNext(mMonitor);
94
95 // Monitor did the heavy lifting for us and already figured out that
96 // we only monitor the source collection of the Items and translated
97 // it into REMOVE change.
98
99 // This relies on Akonadi never mixing Items from different sources or
100 // destination during batch-moves.
101 const auto parentId = items.first().parentCollection().id();
102 const auto box = unifiedMailboxForSource(parentId);
103 if (!box) {
104 qCWarning(UNIFIEDMAILBOXAGENT_LOG) << "Received Remove notification for Items belonging to" << parentId << "which we don't monitor";
105 return;
106 }
107 if (box->collectionId() <= -1) {
108 qCWarning(UNIFIEDMAILBOXAGENT_LOG) << "Missing box->collection mapping for unified mailbox" << box->id();
109 return;
110 }
111
112 new Akonadi::UnlinkJob(Akonadi::Collection{box->collectionId()}, items, this);
113 });
114 connect(&mMonitor,
115 &Akonadi::Monitor::itemsMoved,
116 this,
117 [this](const Akonadi::Item::List &items, const Akonadi::Collection &srcCollection, const Akonadi::Collection &dstCollection) {
118 ReplayNextOnExit replayNext(mMonitor);
119
120 if (const auto srcBox = unifiedMailboxForSource(srcCollection.id())) {
121 // Move source collection was our source, unlink the Item from a box
122 new Akonadi::UnlinkJob(Akonadi::Collection{srcBox->collectionId()}, items, this);
123 }
124 if (const auto dstBox = unifiedMailboxForSource(dstCollection.id())) {
125 // Move destination collection is our source, link the Item into a box
126 new Akonadi::LinkJob(Akonadi::Collection{dstBox->collectionId()}, items, this);
127 }
128 });
129
130 connect(&mMonitor, &Akonadi::Monitor::collectionRemoved, this, [this](const Akonadi::Collection &col) {
131 ReplayNextOnExit replayNext(mMonitor);
132
133 if (auto box = unifiedMailboxForSource(col.id())) {
134 box->removeSourceCollection(col.id());
135 mMonitor.setCollectionMonitored(col, false);
136 if (box->sourceCollections().isEmpty()) {
137 removeBox(box->id());
138 }
139 saveBoxes();
140 // No need to resync the box collection, the linked Items got removed by Akonadi
141 } else {
142 qCWarning(UNIFIEDMAILBOXAGENT_LOG) << "Received notification about removal of Collection" << col.id() << "which we don't monitor";
143 }
144 });
145 connect(&mMonitor,
146 qOverload<const Akonadi::Collection &, const QSet<QByteArray> &>(&Akonadi::Monitor::collectionChanged),
147 this,
148 [this](const Akonadi::Collection &col, const QSet<QByteArray> &parts) {
149 ReplayNextOnExit replayNext(mMonitor);
150
151 qCDebug(UNIFIEDMAILBOXAGENT_LOG) << "Collection changed:" << parts;
152 if (!parts.contains(Akonadi::SpecialCollectionAttribute().type())) {
153 return;
154 }
155
156 if (col.hasAttribute<Akonadi::SpecialCollectionAttribute>()) {
157 const auto srcBox = unregisterSpecialSourceCollection(col.id());
158 const auto dstBox = registerSpecialSourceCollection(col);
159 if (srcBox == dstBox) {
160 return;
161 }
162
163 saveBoxes();
164
165 if (srcBox && srcBox->sourceCollections().isEmpty()) {
166 removeBox(srcBox->id());
167 return;
168 }
169
170 if (srcBox) {
171 Q_EMIT updateBox(srcBox);
172 }
173 if (dstBox) {
174 Q_EMIT updateBox(dstBox);
175 }
176 } else {
177 if (const auto box = unregisterSpecialSourceCollection(col.id())) {
178 saveBoxes();
179 if (box->sourceCollections().isEmpty()) {
180 removeBox(box->id());
181 } else {
182 Q_EMIT updateBox(box);
183 }
184 }
185 }
186 });
187 }
188
189 UnifiedMailboxManager::~UnifiedMailboxManager() = default;
190
changeRecorder()191 Akonadi::ChangeRecorder &UnifiedMailboxManager::changeRecorder()
192 {
193 return mMonitor;
194 }
195
loadBoxes(FinishedCallback && finishedCb)196 void UnifiedMailboxManager::loadBoxes(FinishedCallback &&finishedCb)
197 {
198 qCDebug(UNIFIEDMAILBOXAGENT_LOG) << "loading boxes";
199 const auto group = mConfig->group("UnifiedMailboxes");
200 const auto boxGroups = group.groupList();
201 for (const auto &boxGroupName : boxGroups) {
202 const auto boxGroup = group.group(boxGroupName);
203 auto box = std::make_unique<UnifiedMailbox>();
204 box->load(boxGroup);
205 insertBox(std::move(box));
206 }
207
208 const auto cb = [this, finishedCb = std::move(finishedCb)]() {
209 qCDebug(UNIFIEDMAILBOXAGENT_LOG) << "Finished callback: enabling change recorder";
210 // Only now start processing changes from change recorder
211 connect(&mMonitor, &Akonadi::ChangeRecorder::changesAdded, &mMonitor, &Akonadi::ChangeRecorder::replayNext, Qt::QueuedConnection);
212 // And start replaying any potentially pending notification
213 QTimer::singleShot(0, &mMonitor, &Akonadi::ChangeRecorder::replayNext);
214
215 if (finishedCb) {
216 finishedCb();
217 }
218 };
219
220 qCDebug(UNIFIEDMAILBOXAGENT_LOG) << "Loaded" << mMailboxes.size() << "boxes from config";
221
222 if (mMailboxes.empty()) {
223 createDefaultBoxes(std::move(cb));
224 } else {
225 discoverBoxCollections(std::move(cb));
226 }
227 }
228
saveBoxes()229 void UnifiedMailboxManager::saveBoxes()
230 {
231 auto group = mConfig->group("UnifiedMailboxes");
232 const auto currentGroups = group.groupList();
233 for (const auto &groupName : currentGroups) {
234 group.deleteGroup(groupName);
235 }
236 for (const auto &boxIt : mMailboxes) {
237 auto boxGroup = group.group(boxIt.second->id());
238 boxIt.second->save(boxGroup);
239 }
240 mConfig->sync();
241 mConfig->reparseConfiguration();
242 }
243
insertBox(std::unique_ptr<UnifiedMailbox> box)244 void UnifiedMailboxManager::insertBox(std::unique_ptr<UnifiedMailbox> box)
245 {
246 auto it = mMailboxes.emplace(std::make_pair(box->id(), std::move(box)));
247 it.first->second->attachManager(this);
248 }
249
removeBox(const QString & id)250 void UnifiedMailboxManager::removeBox(const QString &id)
251 {
252 auto box = std::find_if(mMailboxes.begin(), mMailboxes.end(), [&id](const std::pair<const QString, std::unique_ptr<UnifiedMailbox>> &box) {
253 return box.second->id() == id;
254 });
255 if (box == mMailboxes.end()) {
256 return;
257 }
258
259 box->second->attachManager(nullptr);
260 mMailboxes.erase(box);
261 }
262
unifiedMailboxForSource(qint64 source) const263 UnifiedMailbox *UnifiedMailboxManager::unifiedMailboxForSource(qint64 source) const
264 {
265 const auto box = mSourceToBoxMap.find(source);
266 if (box == mSourceToBoxMap.cend()) {
267 return {};
268 }
269 return box->second;
270 }
271
unifiedMailboxFromCollection(const Akonadi::Collection & col) const272 UnifiedMailbox *UnifiedMailboxManager::unifiedMailboxFromCollection(const Akonadi::Collection &col) const
273 {
274 if (!isUnifiedMailbox(col)) {
275 return nullptr;
276 }
277
278 const auto box = mMailboxes.find(col.name());
279 if (box == mMailboxes.cend()) {
280 return {};
281 }
282 return box->second.get();
283 }
284
createDefaultBoxes(FinishedCallback && finishedCb)285 void UnifiedMailboxManager::createDefaultBoxes(FinishedCallback &&finishedCb)
286 {
287 if (!Settings::self()->createDefaultBoxes()) {
288 return;
289 }
290 // First build empty boxes
291 auto inbox = std::make_unique<UnifiedMailbox>();
292 inbox->attachManager(this);
293 inbox->setId(Common::InboxBoxId);
294 inbox->setName(i18n("Inbox"));
295 inbox->setIcon(QStringLiteral("mail-folder-inbox"));
296 insertBox(std::move(inbox));
297
298 auto sent = std::make_unique<UnifiedMailbox>();
299 sent->attachManager(this);
300 sent->setId(Common::SentBoxId);
301 sent->setName(i18n("Sent"));
302 sent->setIcon(QStringLiteral("mail-folder-sent"));
303 insertBox(std::move(sent));
304
305 auto drafts = std::make_unique<UnifiedMailbox>();
306 drafts->attachManager(this);
307 drafts->setId(Common::DraftsBoxId);
308 drafts->setName(i18n("Drafts"));
309 drafts->setIcon(QStringLiteral("document-properties"));
310 insertBox(std::move(drafts));
311
312 auto list = new Akonadi::CollectionFetchJob(Akonadi::Collection::root(), Akonadi::CollectionFetchJob::Recursive, this);
313 list->fetchScope().fetchAttribute<Akonadi::SpecialCollectionAttribute>();
314 list->fetchScope().setContentMimeTypes({QStringLiteral("message/rfc822")});
315 #ifdef UNIT_TESTS
316 list->fetchScope().setAncestorRetrieval(Akonadi::CollectionFetchScope::Parent);
317 #else
318 list->fetchScope().setAncestorRetrieval(Akonadi::CollectionFetchScope::None);
319 #endif
320 connect(list, &Akonadi::CollectionFetchJob::collectionsReceived, this, [this](const Akonadi::Collection::List &list) {
321 for (const auto &col : list) {
322 if (isUnifiedMailbox(col)) {
323 continue;
324 }
325
326 try {
327 switch (Akonadi::SpecialMailCollections::self()->specialCollectionType(col)) {
328 case Akonadi::SpecialMailCollections::Inbox:
329 mMailboxes.at(Common::InboxBoxId)->addSourceCollection(col.id());
330 break;
331 case Akonadi::SpecialMailCollections::SentMail:
332 mMailboxes.at(Common::SentBoxId)->addSourceCollection(col.id());
333 break;
334 case Akonadi::SpecialMailCollections::Drafts:
335 mMailboxes.at(Common::DraftsBoxId)->addSourceCollection(col.id());
336 break;
337 default:
338 continue;
339 }
340 } catch (const std::out_of_range &) {
341 qCWarning(UNIFIEDMAILBOXAGENT_LOG) << "Failed to find a special unified mailbox for source collection" << col.id();
342 continue;
343 }
344 }
345 });
346 connect(list, &Akonadi::CollectionFetchJob::result, this, [this, finishedCb = std::move(finishedCb)]() {
347 saveBoxes();
348 if (finishedCb) {
349 finishedCb();
350 }
351 });
352 #ifndef UNIT_TESTS
353 Settings::self()->setCreateDefaultBoxes(false);
354 Settings::self()->save();
355 #endif
356 }
357
discoverBoxCollections(FinishedCallback && finishedCb)358 void UnifiedMailboxManager::discoverBoxCollections(FinishedCallback &&finishedCb)
359 {
360 auto list = new Akonadi::CollectionFetchJob(Akonadi::Collection::root(), Akonadi::CollectionFetchJob::Recursive, this);
361 #ifdef UNIT_TESTS
362 list->fetchScope().setAncestorRetrieval(Akonadi::CollectionFetchScope::Parent);
363 #else
364 list->fetchScope().setResource(Common::AgentIdentifier);
365 #endif
366 connect(list, &Akonadi::CollectionFetchJob::collectionsReceived, this, [this](const Akonadi::Collection::List &list) {
367 for (const auto &col : list) {
368 if (!isUnifiedMailbox(col) || col.parentCollection() == Akonadi::Collection::root()) {
369 continue;
370 }
371 const auto it = mMailboxes.find(col.name());
372 if (it == mMailboxes.end()) {
373 qCWarning(UNIFIEDMAILBOXAGENT_LOG) << "Failed to find an unified mailbox for source collection" << col.id();
374 } else {
375 it->second->setCollectionId(col.id());
376 }
377 }
378 });
379 if (finishedCb) {
380 connect(list, &Akonadi::CollectionFetchJob::result, this, finishedCb);
381 }
382 }
383
registerSpecialSourceCollection(const Akonadi::Collection & col)384 const UnifiedMailbox *UnifiedMailboxManager::registerSpecialSourceCollection(const Akonadi::Collection &col)
385 {
386 // This is slightly awkward, wold be better if we could use SpecialMailCollections,
387 // but it also relies on Monitor internally, so there's a possible race condition
388 // between our ChangeRecorder and SpecialMailCollections' Monitor
389 auto attr = col.attribute<Akonadi::SpecialCollectionAttribute>();
390 Q_ASSERT(attr);
391 if (!attr) {
392 return {};
393 }
394
395 decltype(mMailboxes)::iterator box;
396 if (attr->collectionType() == Common::SpecialCollectionInbox) {
397 box = mMailboxes.find(Common::InboxBoxId);
398 } else if (attr->collectionType() == Common::SpecialCollectionSentMail) {
399 box = mMailboxes.find(Common::SentBoxId);
400 } else if (attr->collectionType() == Common::SpecialCollectionDrafts) {
401 box = mMailboxes.find(Common::DraftsBoxId);
402 }
403 if (box == mMailboxes.end()) {
404 return {};
405 }
406
407 box->second->addSourceCollection(col.id());
408 return box->second.get();
409 }
410
unregisterSpecialSourceCollection(qint64 colId)411 const UnifiedMailbox *UnifiedMailboxManager::unregisterSpecialSourceCollection(qint64 colId)
412 {
413 auto box = unifiedMailboxForSource(colId);
414 if (!box) {
415 return {};
416 }
417
418 if (!box->isSpecial()) {
419 qCDebug(UNIFIEDMAILBOXAGENT_LOG) << colId << "does not belong to a special unified box" << box->id();
420 return {};
421 }
422
423 box->removeSourceCollection(colId);
424 return box;
425 }
426