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