summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-07 19:17:59 +0200
committerDanilo M. <danix@danix.xyz>2026-08-07 19:17:59 +0200
commit01194fa2c48019f1dcbdfefac94f8fd5f8e38122 (patch)
treee833545eaa42c44f1cb984bd52fbf7bb66d36102 /src
parent6bbaa1d796d3d7828c3fa6b82c7031f8aaae84bf (diff)
downloadqtmaildir-01194fa2c48019f1dcbdfefac94f8fd5f8e38122.tar.gz
qtmaildir-01194fa2c48019f1dcbdfefac94f8fd5f8e38122.zip
feat(sync): sync only the accounts with unsynced edits
A sync ran mbsync -a regardless of what changed, so tagging mail in one account fetched all of them. The account set was not a parameter anywhere on the path: MailSync::start() took no arguments and the script hardcoded -a, so nothing between a tag edit and mbsync carried which account changed. Track which accounts have edits and pass their mbsync channels through to the script, which now takes channel names and falls back to -a when given none. An empty set means all accounts, per the request: a sync with nothing pending is a fetch, and narrowing that to wherever the last edit landed would quietly stop collecting mail everywhere else. The channel is a new optional per-account key rather than the section key. The two names genuinely diverge, because a QSettings section key may carry dots that the channel does not, and mbsync treats an unknown channel as fatal rather than skipping it, so key-as-channel would fail those accounts' syncs outright rather than degrade. It defaults to the key, so accounts whose two names already agree need no config change. The edited-account set is deliberately not netted the way the pending-edit map is: that map tracks the index, where a tag removed and re-added leaves nothing outstanding, while this tracks the mail store, where both writes have already renamed files that mbsync still has to propagate. It is also snapshotted before flushHeldEdits(), which inserts into it synchronously rather than on a queued reply, so a successful sync cannot clear accounts whose edits it never carried. Closes item 49. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/config.cpp5
-rw-r--r--src/config.h13
-rw-r--r--src/mailsync.cpp16
-rw-r--r--src/mailsync.h11
-rw-r--r--src/mainwindow.cpp62
-rw-r--r--src/mainwindow.h20
-rw-r--r--src/threadlistmodel.cpp18
-rw-r--r--src/threadlistmodel.h10
8 files changed, 149 insertions, 6 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 8267835..cac4c51 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -199,6 +199,11 @@ void Config::load(const QString &path)
// is in, so these live here rather than in [tagcolors].
account.label = settings.value(QStringLiteral("label")).toString();
+ // Optional, and absent for most accounts: syncChannel() falls back to
+ // the key. Needed only where the section key and the mbsync channel
+ // name diverge.
+ account.channel = settings.value(QStringLiteral("channel")).toString();
+
const QString colour = settings.value(QStringLiteral("color")).toString();
if (!colour.isEmpty()) {
account.color = QColor(colour);
diff --git a/src/config.h b/src/config.h
index 84f02df..d3ee767 100644
--- a/src/config.h
+++ b/src/config.h
@@ -45,6 +45,19 @@ struct Account
/// bit of information. This renames nothing in notmuch, only the display.
QString label;
+ /// mbsync channel name, when it differs from the key. Optional, and empty
+ /// for most accounts: see syncChannel().
+ QString channel;
+
+ /// The mbsync channel to sync this account, for item 49's per-account sync.
+ ///
+ /// Defaults to the key, which is right for most accounts, but the two are
+ /// genuinely separate names and cannot be collapsed. A QSettings section
+ /// key may carry dots that the channel does not ([account.mail-first.last]
+ /// against the channel `mail-firstlast`), and mbsync exits nonzero on a
+ /// channel it does not know, which qtmaildir would report as a failed sync.
+ QString syncChannel() const { return channel.isEmpty() ? key : channel; }
+
bool isValid() const { return !key.isEmpty() && !maildir.isEmpty(); }
/// Restricts a notmuch query to this account's subtree.
diff --git a/src/mailsync.cpp b/src/mailsync.cpp
index 005580c..1d2a99f 100644
--- a/src/mailsync.cpp
+++ b/src/mailsync.cpp
@@ -167,7 +167,7 @@ bool MailSync::isRunning() const
return m_process.state() != QProcess::NotRunning;
}
-bool MailSync::start()
+bool MailSync::start(const QStringList &channels)
{
if (!isAvailable() || isRunning())
return false;
@@ -180,8 +180,20 @@ bool MailSync::start()
m_log.clear();
+ QStringList arguments = parts.mid(1);
+
+ // Appended as separate list entries, never spliced into the command string:
+ // these names come from config, the same trust boundary as the command
+ // itself, and QProcess passes an argument list without a shell.
+ for (const QString &channel : channels) {
+ // An empty name would reach mbsync as a channel called "", failing the
+ // whole run, so a stray blank costs the user nothing here.
+ if (!channel.trimmed().isEmpty())
+ arguments.append(channel);
+ }
+
m_process.setProgram(parts.first());
- m_process.setArguments(parts.mid(1));
+ m_process.setArguments(arguments);
// Deliberately no waitForStarted(): the spec requires the UI stay usable
// during sync, and a failed launch arrives via errorOccurred() instead.
diff --git a/src/mailsync.h b/src/mailsync.h
index 3c0d616..f83b526 100644
--- a/src/mailsync.h
+++ b/src/mailsync.h
@@ -21,6 +21,7 @@
#include <QObject>
#include <QProcess>
#include <QString>
+#include <QStringList>
/// Which half of the sync script is running.
///
@@ -84,7 +85,15 @@ public:
/// Returns false if unavailable or already running. A true return means the
/// process was handed to the event loop, not that it launched successfully:
/// a missing binary surfaces asynchronously through finished(false, ...).
- bool start();
+ ///
+ /// \p channels names the mbsync channels to sync, appended to the
+ /// configured command as separate arguments. Empty, the default, appends
+ /// nothing and leaves the script to sync everything: a sync with nothing
+ /// pending is a fetch, and fetching only the account that happened to hold
+ /// the last edit would silently stop collecting mail for the others.
+ /// Blank entries are dropped rather than passed, since mbsync reads an
+ /// empty argument as a channel name and fails the whole run on it.
+ bool start(const QStringList &channels = {});
QString log() const { return m_log; }
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 0ce94a8..f830683 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -217,7 +217,7 @@ void MainWindow::closeEvent(QCloseEvent *event)
box.exec();
if (box.clickedButton() == sync) {
- if (m_sync->start()) {
+ if (m_sync->start(pendingSyncChannels())) {
m_syncingForExit = true;
m_syncLog->clear();
setSyncBusy(true);
@@ -238,7 +238,7 @@ void MainWindow::closeEvent(QCloseEvent *event)
return;
}
} else if (m_config.syncOnExit() == Config::SyncOnExit::Always) {
- if (m_sync->start()) {
+ if (m_sync->start(pendingSyncChannels())) {
m_syncingForExit = true;
m_syncLog->clear();
setSyncBusy(true);
@@ -1700,6 +1700,16 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
// so they go to the mail store on the NEXT run. That is the same one-run
// delay any edit made mid-sync gets, bounded by the cron interval.
const bool sentHeldEdits = !m_heldEdits.isEmpty();
+
+ // Snapshotted BEFORE the flush, and this ordering is load-bearing.
+ // flushHeldEdits() calls sendThreadTagChange(), which inserts into
+ // m_editedAccounts SYNCHRONOUSLY, unlike the pending-edit map below which
+ // is written on the worker's queued reply and so is safely counted rather
+ // than wiped. Clearing the whole set after the flush would therefore
+ // discard accounts whose edits this run did not carry, and those edits
+ // would sync only when some later edit happened to name the same account.
+ const QSet<QString> accountsThisRunCarried = m_editedAccounts;
+
flushHeldEdits();
if (success) {
@@ -1708,6 +1718,12 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
// what failed to put them there.
m_pendingTagEdits.clear();
m_unnettablePendingEdits = 0;
+
+ // Only what this run actually carried, per the snapshot above. An
+ // account added by flushHeldEdits() stays, because its edit reaches the
+ // index after the sync that would have taken it and goes out on the
+ // next run.
+ m_editedAccounts.subtract(accountsThisRunCarried);
m_lastSyncFailed = false;
updatePendingIndicator();
@@ -1997,7 +2013,7 @@ void MainWindow::startSync()
m_syncPhase.reset();
m_syncLineBuffer.clear();
- if (!m_sync->start()) {
+ if (!m_sync->start(pendingSyncChannels())) {
showTransientStatus(tr("Sync already running"));
return;
}
@@ -2022,6 +2038,34 @@ void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag,
m_pendingTagEdits.insert(key, added);
}
+QStringList MainWindow::pendingSyncChannels() const
+{
+ // Nothing pending means this run is a FETCH, and a fetch must cover every
+ // account: narrowing it to wherever the last edit happened to be would
+ // quietly stop collecting mail everywhere else. Empty is the signal for
+ // that, and MailSync::start() appends nothing.
+ if (m_editedAccounts.isEmpty())
+ return {};
+
+ QStringList channels;
+ for (const Account &account : m_config.accounts()) {
+ if (m_editedAccounts.contains(account.key))
+ channels.append(account.syncChannel());
+ }
+
+ // An account tag with no matching [account.<key>] section yields no
+ // channel, and syncing a subset that omits it would leave its edits behind
+ // with nothing to say so. Fall back to a full sync, which is correct if
+ // wasteful; the alternative is silently stranding an edit.
+ if (channels.size() != m_editedAccounts.size())
+ return {};
+
+ // Stable order so a run is reproducible and the log reads the same way
+ // twice. QSet has no order of its own.
+ channels.sort();
+ return channels;
+}
+
int MainWindow::pendingEditCount() const
{
// A held edit has NOT reached the index, so onTagsApplied() never counted
@@ -2188,6 +2232,18 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds,
for (const QString &threadId : threadIds)
m_model->applyTagChange(threadId, add, remove);
+ // Which accounts this touches, recorded HERE and not in onTagsApplied():
+ // TagChange carries message ids, while the account is a property of the
+ // thread, and by the time the worker confirms, the rows may be gone. A
+ // write that is later rejected leaves an account listed here that needed no
+ // sync, which costs one redundant channel on the next run; missing one
+ // would strand the user's edits, which is the failure worth avoiding.
+ for (const QString &threadId : threadIds) {
+ const QStringList keys = m_model->accountKeysForThread(threadId);
+ for (const QString &key : keys)
+ m_editedAccounts.insert(key);
+ }
+
// The strip shows the open thread's tags, so it has to follow a change to
// that thread rather than waiting for the next selection.
if (threadIds.contains(m_currentThreadId)) {
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 579597c..2d41b5e 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -19,6 +19,7 @@
#pragma once
#include <QHash>
+#include <QSet>
#include <QMainWindow>
#include <QPointer>
#include <QThread>
@@ -524,6 +525,25 @@ private:
/// the UI thread one user action at a time.
TagChange m_pendingChange;
QStringList m_pendingThreadIds;
+
+ /// Account keys whose mail store has edits a sync has not yet carried,
+ /// for item 49's per-account sync.
+ ///
+ /// Deliberately NOT netted the way m_pendingTagEdits is. That map tracks
+ /// the INDEX, where removing a tag and re-adding it leaves nothing
+ /// outstanding; this tracks the MAIL STORE, where both writes have already
+ /// renamed files that mbsync still has to propagate. Netting this to empty
+ /// would skip the very account whose files changed.
+ ///
+ /// Populated where the threads are known, since TagChange carries message
+ /// ids and the account is a property of the thread. Cleared only by a
+ /// SUCCESSFUL sync, alongside the pending-edit map.
+ QSet<QString> m_editedAccounts;
+
+ /// The channel names for m_editedAccounts, resolved through the config.
+ /// Empty means sync everything, which is what a fetch with nothing pending
+ /// has to do.
+ QStringList pendingSyncChannels() const;
};
/// Undo entry for a tag change over a set of threads.
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 4794d8c..c675488 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -325,6 +325,24 @@ ThreadSummary ThreadListModel::threadAt(int row) const
return m_threads.at(row);
}
+QStringList ThreadListModel::accountKeysForThread(const QString &threadId) const
+{
+ QStringList keys;
+ for (const ThreadSummary &thread : m_threads) {
+ if (thread.threadId != threadId)
+ continue;
+ for (const QString &tag : thread.tags) {
+ if (!TagColors::isAccountTag(tag))
+ continue;
+ const QString key = TagColors::accountKeyForTag(tag);
+ if (!key.isEmpty() && !keys.contains(key))
+ keys.append(key);
+ }
+ break;
+ }
+ return keys;
+}
+
void ThreadListModel::applyTagChange(const QString &threadId,
const QStringList &added,
const QStringList &removed)
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 461e467..2eaa88e 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -120,6 +120,16 @@ public:
ThreadSummary threadAt(int row) const;
+ /// The account keys behind a thread's account tags, for item 49's
+ /// per-account sync.
+ ///
+ /// Returns every one of them, not the first: the thread list shows only one
+ /// chip per row, but a thread whose messages landed in two mailboxes really
+ /// does span two accounts, and tagging it touches files under both. Syncing
+ /// only the one that happens to be shown would strand the other's edits.
+ /// Empty when the thread is unknown or carries no account tag.
+ QStringList accountKeysForThread(const QString &threadId) const;
+
/// Applies a tag change locally so the UI updates before the worker
/// confirms. To revert a failed write, call again with added and removed
/// swapped.