aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md16
-rw-r--r--README.md29
-rwxr-xr-xassets/mailsync.sh20
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md13
-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
-rw-r--r--tests/test_config.cpp33
-rw-r--r--tests/test_mailsync.cpp52
-rw-r--r--tests/test_threadlistmodel.cpp43
15 files changed, 353 insertions, 8 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25bd39d..851e318 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,22 @@ point at which they are stable.
## [Unreleased]
+### Added
+
+- **A sync now fetches only the accounts you have edited.** Tagging mail in one
+ account and syncing no longer pulls every other account as well. A sync with
+ nothing outstanding is a plain fetch and still covers everything, since
+ narrowing that to wherever the last edit happened to be would quietly stop
+ collecting mail everywhere else.
+- **An optional `channel` key per account**, naming the mbsync channel when it
+ differs from the section key. It defaults to the key, so accounts whose two
+ names already agree need no change. The two can genuinely diverge: a QSettings
+ section key may carry dots that the channel does not, and mbsync treats an
+ unknown channel as fatal rather than skipping it.
+- **`assets/mailsync.sh` takes channel names as arguments**, syncing all
+ channels when given none. A replacement sync script that ignores its arguments
+ still works, it just always syncs everything.
+
## [0.11.0] - 2026-08-07
The empty message pane now carries the application's own identity and the
diff --git a/README.md b/README.md
index 13002d8..e49f7e6 100644
--- a/README.md
+++ b/README.md
@@ -149,6 +149,7 @@ maildir = work-mail ; relative to notmuch's database.path
drafts = Drafts ; recorded for v2; unused today
label = W ; optional chip text; defaults to the key
color = #2f6fa8 ; optional chip colour; generated when unset
+channel = work ; optional mbsync channel; defaults to the key
[account.personal]
name = Your Name
@@ -311,6 +312,34 @@ Two things any replacement has to get right, both learned the hard way:
a click landing inside one is routine. qtmaildir reports 75 as "a sync is
already running" and leaves the log pane alone, where any other non-zero code
raises an error.
+- **Accept channel names as arguments, and sync everything when given none.**
+ qtmaildir passes the mbsync channels of the accounts it has edited, so a sync
+ after tagging one account's mail does not fetch all of them. A script that
+ ignores its arguments still works, it just always syncs everything.
+
+### Per-account sync
+
+When tag changes are outstanding, a sync passes only the affected accounts'
+channel names to the command. When nothing is outstanding the run is a plain
+fetch and no names are passed, so every account is synced: narrowing a fetch to
+wherever the last edit happened to be would quietly stop collecting mail
+everywhere else.
+
+The name passed is the **mbsync channel**, which is not always the account's
+section key. `[account.mail-first.last]` may well be the channel
+`mail-firstlast`, since a section key can carry dots that the channel does not.
+Set `channel` in the account section wherever the two differ:
+
+```ini
+[account.mail-first.last]
+maildir = mail-first.last
+channel = mail-firstlast
+```
+
+Getting this wrong is not silent: `mbsync` fails on a channel it does not know,
+and qtmaildir reports the sync as failed rather than clearing the count. An
+account tag with no matching section falls back to syncing everything, since
+skipping it would strand its edits with nothing to say so.
While a sync this window started is running, the status bar shows an
indeterminate progress bar. It is deliberately not a percentage: `mbsync`
diff --git a/assets/mailsync.sh b/assets/mailsync.sh
index 0e52ddd..ea81fa5 100755
--- a/assets/mailsync.sh
+++ b/assets/mailsync.sh
@@ -27,6 +27,14 @@
# The script owns the log, so the caller must NOT redirect into it as well. A
# crontab line ending "> mailsync.log 2>&1" writes every line a second time,
# because tee has already put it there. Just call the script.
+#
+# Usage: mailsync.sh [channel ...]
+#
+# With no arguments it syncs every channel, which is what a cron timer wants
+# and what every existing caller already does. Given channel names it syncs
+# only those, which is how qtmaildir syncs just the accounts it edited. The
+# names are mbsync CHANNEL names from ~/.mbsyncrc, which are not necessarily
+# the account names qtmaildir shows: see the `channel` key in qtmaildir.conf.
# Defensive: don't rely on cron/systemd/whatever invokes this to have
# set these correctly. Explicit beats inferred, especially after the
@@ -80,7 +88,17 @@ START_TS="$(date -Iseconds)"
# which is both the progress and the account name the status bar shows.
# This is not a buffering problem and stdbuf does not help: the output
# streams fine, there simply is none to stream.
- mbsync -V -a 2>&1 | while IFS= read -r line; do
+ # "$@" when channels were named, -a otherwise. Quoted and passed as
+ # separate words, never flattened into a string: a channel name is an
+ # argument, and mbsync takes an unknown one as a fatal error rather than
+ # skipping it, which would fail the whole run.
+ #
+ # -a is NOT equivalent to naming every channel and cannot be dropped: with
+ # no arguments at all mbsync syncs nothing and exits, which would look like
+ # a clean sync that moved no mail.
+ [ "$#" -gt 0 ] || set -- -a
+
+ mbsync -V "$@" 2>&1 | while IFS= read -r line; do
echo "$(date '+%H:%M:%S') $line"
done
echo "${PIPESTATUS[0]}" > "$STATUS_DIR/mbsync"
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 1cc75a7..ffdc8d5 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -97,7 +97,7 @@ taking that too literally.
| 46 | `uiStateSurvivesARestart` fails under the offscreen platform | testing | XS | **done** |
| 47 | The query bar looks unfinished, and cannot be cleared by mouse | presentation | XS | **done** |
| 48 | Removing a tag suggests every tag, not the thread's own | workflow | XS | **done** |
-| 49 | Sync runs every account regardless of what changed | workflow | M | open |
+| 49 | Sync runs every account regardless of what changed | workflow | M | **done** |
| 50 | Esc blanks the pane but leaves the row selected | workflow | XS | **done** |
| 51 | Clicking a subject scrolls the list sideways | presentation | XS | open |
| 52 | `test_querycompleter` fails under Wayland, passes offscreen | testing | XS | **done** |
@@ -2881,6 +2881,17 @@ Nothing between the tag edit and mbsync carries which account changed.
a display `label`); mapping to a channel needs either a new per-account key or
an explicit decision that the key IS the channel. Settle this with the user
before building, it is the one design question in the item.
+
+ **Resolved 2026-08-07 by reading the user's real mbsync config against their
+ qtmaildir config: a new key is required.** Three of five accounts match their
+ channel name exactly, and two do not, because a QSettings section key may
+ carry dots that the channel does not: a section `[account.mail-first.last]`
+ against a channel `mail-firstlast`. Key-as-channel would therefore name two
+ channels mbsync does not know, and mbsync treats an unknown channel as fatal,
+ so those two accounts' syncs would fail outright rather than degrade. The
+ `maildir` key tracks the section key rather than the channel and is no help.
+ Built as an optional `channel` key defaulting to the section key, so the three
+ matching accounts need no config edit.
- **`notmuch new` still runs over everything**, and must. Restricting the fetch
does not restrict the index.
- **The script's two shipped properties survive**: it prints to stdout as well as
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.
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 4d92891..dc85968 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -51,6 +51,8 @@ private slots:
void extraMimetypesAppendToBuiltins();
void extraMimetypeDescriptionMayContainComma();
void malformedExtraMimetypeIsSkipped();
+ void syncChannelDefaultsToTheAccountKey();
+ void syncChannelIsActuallyRead();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -524,5 +526,36 @@ void TestConfig::malformedExtraMimetypeIsSkipped()
QVERIFY(!config.problems().isEmpty());
}
+void TestConfig::syncChannelDefaultsToTheAccountKey()
+{
+ // Most accounts name their mbsync channel exactly as their section key, so
+ // the common case must need no config edit at all.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir = work\n")));
+
+ QCOMPARE(config.accounts().size(), 1);
+ QCOMPARE(config.accounts().at(0).syncChannel(), QStringLiteral("work"));
+}
+
+void TestConfig::syncChannelIsActuallyRead()
+{
+ // The key exists because the two names genuinely diverge: a QSettings
+ // section key may carry dots that the mbsync channel does not, and passing
+ // the section key to mbsync would name a channel that does not exist.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.mail-first.last]\n"
+ "maildir = mail-first.last\n"
+ "channel = mail-firstlast\n")));
+
+ QCOMPARE(config.accounts().size(), 1);
+ QCOMPARE(config.accounts().at(0).syncChannel(),
+ QStringLiteral("mail-firstlast"));
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_mailsync.cpp b/tests/test_mailsync.cpp
index 6c93e8d..e91de3e 100644
--- a/tests/test_mailsync.cpp
+++ b/tests/test_mailsync.cpp
@@ -43,6 +43,9 @@ private slots:
void missingBinaryReportsFailureNotSilence();
void startDoesNotBlock();
void argumentsAreNotShellInterpreted();
+ void channelsAreAppendedToTheCommand();
+ void noChannelsMeansNoExtraArguments();
+ void channelNamesAreNotShellInterpreted();
void phaseStartsAsMbsync();
void notmuchLineSwitchesPhase();
@@ -262,6 +265,55 @@ void TestMailSync::argumentsAreNotShellInterpreted()
QVERIFY(sync.log().contains(QStringLiteral("; touch")));
}
+void TestMailSync::channelsAreAppendedToTheCommand()
+{
+ // Item 49: a sync that knows which accounts were touched passes their
+ // channel names, and they must reach the script as separate arguments
+ // after whatever the config line already carries.
+ const QString script = makeScript(QStringLiteral("chan.sh"),
+ QStringLiteral("echo \"[$@]\""));
+
+ MailSync sync(script);
+ QSignalSpy spy(&sync, &MailSync::finished);
+ QVERIFY(sync.start({ QStringLiteral("work"), QStringLiteral("personal") }));
+ QVERIFY(spy.wait(5000));
+
+ QVERIFY(sync.log().contains(QStringLiteral("[work personal]")));
+}
+
+void TestMailSync::noChannelsMeansNoExtraArguments()
+{
+ // Empty means "sync everything", which is the script's own default. It must
+ // not become an empty string argument: mbsync would read that as a channel
+ // named "" and fail the run.
+ const QString script = makeScript(QStringLiteral("nochan.sh"),
+ QStringLiteral("echo \"count=$#\""));
+
+ MailSync sync(script);
+ QSignalSpy spy(&sync, &MailSync::finished);
+ QVERIFY(sync.start());
+ QVERIFY(spy.wait(5000));
+
+ QVERIFY(sync.log().contains(QStringLiteral("count=0")));
+}
+
+void TestMailSync::channelNamesAreNotShellInterpreted()
+{
+ // Channel names are derived from config, same trust boundary as the command
+ // itself, and reach the same QProcess argument list. The injection test
+ // above covers the command; this covers the half added for item 49.
+ const QString script = makeScript(QStringLiteral("chanargs.sh"),
+ QStringLiteral("echo \"$1\""));
+
+ MailSync sync(script);
+ QSignalSpy spy(&sync, &MailSync::finished);
+ QVERIFY(sync.start({ QStringLiteral("; touch %1/chanpwned")
+ .arg(m_dir.path()) }));
+ QVERIFY(spy.wait(5000));
+
+ QVERIFY(!QFile::exists(m_dir.filePath(QStringLiteral("chanpwned"))));
+}
+
void TestMailSync::phaseStartsAsMbsync()
{
// A fresh tracker has nothing to report until it is fed, and a run is
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 82686e5..f84bbab 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -28,6 +28,9 @@ class TestThreadListModel : public QObject
Q_OBJECT
private slots:
void startsEmpty();
+ void accountKeysComeFromTheAccountTags();
+ void accountKeysCoverAThreadSpanningTwoAccounts();
+ void accountKeysAreEmptyForAnUnknownThread();
void appendsBatches();
void appendingEmptyBatchIsNoOp();
void clearResetsModel();
@@ -73,6 +76,46 @@ static ThreadSummary makeThread(const QString &id, const QString &subject)
return t;
}
+void TestThreadListModel::accountKeysComeFromTheAccountTags()
+{
+ // Item 49 reads this to decide which mbsync channels a sync needs. Only
+ // account tags count: a functional tag names no mailbox.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("Hi"));
+ t.tags.append(TagColors::tagForAccountKey(QStringLiteral("work")));
+ model.appendBatch({ t });
+
+ QCOMPARE(model.accountKeysForThread(QStringLiteral("t1")),
+ QStringList{ QStringLiteral("work") });
+}
+
+void TestThreadListModel::accountKeysCoverAThreadSpanningTwoAccounts()
+{
+ // The row shows one chip, but tagging this thread touches files under both
+ // mailboxes. Returning only the first would strand the other's edits.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("Hi"));
+ t.tags.append(TagColors::tagForAccountKey(QStringLiteral("work")));
+ t.tags.append(TagColors::tagForAccountKey(QStringLiteral("personal")));
+ model.appendBatch({ t });
+
+ QStringList keys = model.accountKeysForThread(QStringLiteral("t1"));
+ keys.sort();
+ QCOMPARE(keys, (QStringList{ QStringLiteral("personal"),
+ QStringLiteral("work") }));
+}
+
+void TestThreadListModel::accountKeysAreEmptyForAnUnknownThread()
+{
+ // A thread the model no longer holds must yield nothing rather than
+ // matching some other row.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("Hi")) });
+
+ QVERIFY(model.accountKeysForThread(QStringLiteral("nope")).isEmpty());
+}
+
void TestThreadListModel::startsEmpty()
{
ThreadListModel model;