diff options
| -rw-r--r-- | CHANGELOG.md | 16 | ||||
| -rw-r--r-- | README.md | 29 | ||||
| -rwxr-xr-x | assets/mailsync.sh | 20 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 13 | ||||
| -rw-r--r-- | src/config.cpp | 5 | ||||
| -rw-r--r-- | src/config.h | 13 | ||||
| -rw-r--r-- | src/mailsync.cpp | 16 | ||||
| -rw-r--r-- | src/mailsync.h | 11 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 62 | ||||
| -rw-r--r-- | src/mainwindow.h | 20 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 18 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 10 | ||||
| -rw-r--r-- | tests/test_config.cpp | 33 | ||||
| -rw-r--r-- | tests/test_mailsync.cpp | 52 | ||||
| -rw-r--r-- | 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 @@ -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; |
