From 01194fa2c48019f1dcbdfefac94f8fd5f8e38122 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 7 Aug 2026 19:17:59 +0200 Subject: 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 --- CHANGELOG.md | 16 ++++++ README.md | 29 ++++++++++ assets/mailsync.sh | 20 ++++++- .../plans/2026-08-03-post-0.1.0-usability.md | 13 ++++- src/config.cpp | 5 ++ src/config.h | 13 +++++ src/mailsync.cpp | 16 +++++- src/mailsync.h | 11 +++- src/mainwindow.cpp | 62 ++++++++++++++++++++-- src/mainwindow.h | 20 +++++++ src/threadlistmodel.cpp | 18 +++++++ src/threadlistmodel.h | 10 ++++ tests/test_config.cpp | 33 ++++++++++++ tests/test_mailsync.cpp | 52 ++++++++++++++++++ tests/test_threadlistmodel.cpp | 43 +++++++++++++++ 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 #include #include +#include /// 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 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.] 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 +#include #include #include #include @@ -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 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; -- cgit v1.2.3