[pim/kmail] agents/unifiedmailboxagent: Fix unified mailbox not updating in real-time

Mark Collins <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 2e0f2539bde86c0c45dd5811333c4ff0d43cfc71 by Mark Collins.
Committed on 03/08/2026 at 13:56.
Pushed by carlschwan into branch 'master'.

Fix unified mailbox not updating in real-time

Add a cache policy with a 5-minute periodic resync interval and
syncOnDemand=true to each unified box collection.

Rewrite retrieveItems() as a two-phase reconciliation:

Phase 1: Fetch all source collections, collecting item IDs per source.
         Each source is tracked independently so a failure in one
         does not block cleanup for others.

Phase 2: Fetch the unified collection, then in a single pass:
         - Unlink items whose source collection was removed from the
           box, or whose ID is absent from the successfully fetched
           source.
         - Link items present in a source but absent from the unified
           collection (using the alreadyLinked set built from Phase 2
           fetch results — no virtualReferences() needed).

Fixes https://bugs.kde.org/show_bug.cgi?id=403770

M  +103  -26   agents/unifiedmailboxagent/unifiedmailboxagent.cpp

https://invent.kde.org/pim/kmail/-/commit/2e0f2539bde86c0c45dd5811333c4ff0d43cfc71

diff --git a/agents/unifiedmailboxagent/unifiedmailboxagent.cpp b/agents/unifiedmailboxagent/unifiedmailboxagent.cpp
index b80794dbc..382436868 100644
--- a/agents/unifiedmailboxagent/unifiedmailboxagent.cpp
+++ b/agents/unifiedmailboxagent/unifiedmailboxagent.cpp
@@ -12,6 +12,7 @@
 #include "unifiedmailboxagent_debug.h"
 #include "unifiedmailboxagentadaptor.h"
 
+#include <Akonadi/CachePolicy>
 #include <Akonadi/ChangeRecorder>
 #include <Akonadi/CollectionDeleteJob>
 #include <Akonadi/CollectionFetchJob>
@@ -31,11 +32,11 @@
 #include <KLocalizedString>
 #include <QDBusConnection>
 
+#include <QHash>
 #include <QPointer>
 #include <QTimer>
 
 #include <memory>
-#include <unordered_set>
 
 UnifiedMailboxAgent::UnifiedMailboxAgent(const QString &id)
     : Akonadi::ResourceWidgetBase(id)
@@ -144,6 +145,13 @@ void UnifiedMailboxAgent::retrieveCollections()
         col.setContentMimeTypes({Common::MailMimeType});
         col.setRights(Akonadi::Collection::CanChangeItem | Akonadi::Collection::CanDeleteItem);
         col.setVirtual(true);
+        // Periodically re-sync as a fallback in case real-time change notifications
+        // are missed, so stale or missing links are eventually corrected.
+        Akonadi::CachePolicy cachePolicy;
+        cachePolicy.setInheritFromParent(false);
+        cachePolicy.setSyncOnDemand(true);
+        cachePolicy.setIntervalCheckTime(5); // minutes
+        col.setCachePolicy(cachePolicy);
         auto displayAttr = col.attribute<Akonadi::EntityDisplayAttribute>(Akonadi::Collection::AddIfMissing);
         displayAttr->setDisplayName(box->name());
         displayAttr->setIconName(box->icon());
@@ -173,38 +181,107 @@ void UnifiedMailboxAgent::retrieveItems(const Akonadi::Collection &c)
     }
 
     const auto sources = unifiedBox->sourceCollections();
-    for (auto source : sources) {
-        auto fetch = new Akonadi::ItemFetchJob(Akonadi::Collection(source), this);
+
+    // We reconcile the unified collection in two phases:
+    //
+    // Phase 1: fetch all source collections to build a complete map of
+    //          source item IDs. Each source is tracked independently so
+    //          a fetch failure for one source does not affect the others.
+    //
+    // Phase 2: fetch the unified collection itself, then:
+    //   - unlink items whose source collection is no longer part of the box,
+    //     or whose item ID is absent from the (successfully fetched) source.
+    //   - link items that are present in a source but missing from the unified
+    //     collection.
+    //
+    // Doing both link and unlink in Phase 2 (rather than linking eagerly in
+    // Phase 1) ensures we never issue duplicate LinkJobs even when
+    // retrieveItems() is called concurrently by the periodic timer and a
+    // real-time notification.
+
+    struct SyncState {
+        QHash<qint64, QSet<Akonadi::Item::Id>> sourceItemIdsByCollection;
+        QSet<qint64> failedSources;
+        int pendingFetches = 0;
+    };
+
+    const auto startUnifiedSync = [this, c, unifiedBox](const std::shared_ptr<SyncState> &state) {
+        auto fetch = new Akonadi::ItemFetchJob(c, this);
         fetch->setDeliveryOption(Akonadi::ItemFetchJob::EmitItemsInBatches);
-        fetch->fetchScope().setFetchVirtualReferences(true);
         fetch->fetchScope().setCacheOnly(true);
-        connect(fetch, &Akonadi::ItemFetchJob::itemsReceived, this, [this, c](const Akonadi::Item::List &items) {
-            Akonadi::Item::List toLink;
-            std::copy_if(items.cbegin(), items.cend(), std::back_inserter(toLink), [&c](const Akonadi::Item &item) {
-                return !item.virtualReferences().contains(c);
-            });
-            if (!toLink.isEmpty()) {
-                new Akonadi::LinkJob(c, toLink, this);
+        fetch->fetchScope().setAncestorRetrieval(Akonadi::ItemFetchScope::Parent);
+        connect(fetch, &Akonadi::ItemFetchJob::itemsReceived, this, [this, c, unifiedBox, state](const Akonadi::Item::List &unifiedItems) {
+            // Build set of item IDs already in the unified collection.
+            QSet<Akonadi::Item::Id> alreadyLinked;
+            alreadyLinked.reserve(unifiedItems.size());
+            for (const auto &item : unifiedItems) {
+                alreadyLinked.insert(item.id());
             }
+
+            Akonadi::Item::List toUnlink;
+            for (const auto &item : unifiedItems) {
+                const auto srcColId = item.storageCollectionId();
+                if (!unifiedBox->sourceCollections().contains(srcColId)) {
+                    toUnlink.append(item); // source no longer part of this box
+                    continue;
+                }
+                if (state->failedSources.contains(srcColId)) {
+                    continue; // don't remove items whose source fetch failed
+                }
+                const auto idsIt = state->sourceItemIdsByCollection.constFind(srcColId);
+                if (idsIt != state->sourceItemIdsByCollection.cend() && !idsIt->contains(item.id())) {
+                    toUnlink.append(item); // item deleted from source
+                }
+            }
+            if (!toUnlink.isEmpty()) {
+                new Akonadi::UnlinkJob(c, toUnlink, this);
+            }
+
+            // Link items present in sources but not yet in the unified collection.
+            for (auto it = state->sourceItemIdsByCollection.cbegin(); it != state->sourceItemIdsByCollection.cend(); ++it) {
+                Akonadi::Item::List toLink;
+                for (const auto id : it.value()) {
+                    if (!alreadyLinked.contains(id)) {
+                        toLink.append(Akonadi::Item(id));
+                    }
+                }
+                if (!toLink.isEmpty()) {
+                    new Akonadi::LinkJob(c, toLink, this);
+                }
+            }
+        });
+        connect(fetch, &Akonadi::ItemFetchJob::result, this, [this]() {
+            itemsRetrievedIncremental({}, {}); // fake incremental retrieval
         });
+    };
+
+    if (sources.isEmpty()) {
+        startUnifiedSync(std::make_shared<SyncState>());
+        return;
     }
 
-    auto fetch = new Akonadi::ItemFetchJob(c, this);
-    fetch->setDeliveryOption(Akonadi::ItemFetchJob::EmitItemsInBatches);
-    fetch->fetchScope().setCacheOnly(true);
-    fetch->fetchScope().setAncestorRetrieval(Akonadi::ItemFetchScope::Parent);
-    connect(fetch, &Akonadi::ItemFetchJob::itemsReceived, this, [this, unifiedBox, c](const Akonadi::Item::List &items) {
-        Akonadi::Item::List toUnlink;
-        std::copy_if(items.cbegin(), items.cend(), std::back_inserter(toUnlink), [&unifiedBox](const Akonadi::Item &item) {
-            return !unifiedBox->sourceCollections().contains(item.storageCollectionId());
+    auto state = std::make_shared<SyncState>();
+    state->pendingFetches = sources.size();
+
+    for (auto source : sources) {
+        auto fetch = new Akonadi::ItemFetchJob(Akonadi::Collection(source), this);
+        fetch->setDeliveryOption(Akonadi::ItemFetchJob::EmitItemsInBatches);
+        fetch->fetchScope().setCacheOnly(false);
+        connect(fetch, &Akonadi::ItemFetchJob::itemsReceived, this, [state, source](const Akonadi::Item::List &items) {
+            auto &ids = state->sourceItemIdsByCollection[source];
+            for (const auto &item : items) {
+                ids.insert(item.id());
+            }
         });
-        if (!toUnlink.isEmpty()) {
-            new Akonadi::UnlinkJob(c, toUnlink, this);
-        }
-    });
-    connect(fetch, &Akonadi::ItemFetchJob::result, this, [this]() {
-        itemsRetrievedIncremental({}, {}); // fake incremental retrieval
-    });
+        connect(fetch, &Akonadi::ItemFetchJob::result, this, [this, state, source, startUnifiedSync](KJob *job) {
+            if (job->error()) {
+                state->failedSources.insert(source);
+            }
+            if (--state->pendingFetches == 0) {
+                startUnifiedSync(state);
+            }
+        });
+    }
 }
 
 bool UnifiedMailboxAgent::retrieveItems([[maybe_unused]] const Akonadi::Item::List &items, [[maybe_unused]] const QSet<QByteArray> &parts)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.