aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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.