aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-11 12:41:14 +0200
committerDanilo M. <danix@danix.xyz>2026-08-11 12:41:14 +0200
commit44d62143a83af8acbd1c1d14653d39da37e5de4a (patch)
tree360b8fae46487055fbd65bb1601f78345db7e27f
parent694ec02eb652fcfdbf65c27f68f4607f88615f76 (diff)
downloadqtmaildir-44d62143a83af8acbd1c1d14653d39da37e5de4a.tar.gz
qtmaildir-44d62143a83af8acbd1c1d14653d39da37e5de4a.zip
feat(sent): add a Sent view, flat and by recipient
Adds a `sent` key to [account.*] naming that account's sent folder, and a Sent button beside the saved queries that composes its query from every account carrying one. An account without the key is omitted silently, as a real account may keep no sent mail locally. With no account selected the button spans all of them; selecting one narrows it through the existing scope wrap rather than a second path. Composed at run time rather than shipped as a [queries] entry. A saved query is one fixed string: it cannot narrow to the selected account, and it goes stale the moment an account is added or a provider renames a folder. The design and the measurements behind it are in docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are worth repeating here. The composed path is QUOTED, and that is load-bearing. A real provider nests its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax: unquoted, the query parses rather than matches and returns nothing while looking entirely plausible. Composition happens in one place so there is one chance to get it right, and a bracketed path is pinned in a test. Recipients are opt-in per query, which is a performance contract rather than a preference. notmuch_message_get_header(m, "To") is not served from the index, it reads the message file: folding every thread of a 4411-thread inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The worker skips the walk entirely unless asked, and the refresh path carries the same flag so a background sync cannot blank the column mid-read. Always folding is mutation-tested: the data would be right and only the cost wrong, which nothing else here would notice. The messages reached through the thread are owned by it and freed with it, so recipientsOf() holds them raw and finishes while the thread is alive, exactly as walkReplies does. An NmMessage wrapper there is a double-free. Sent mail is presented flat, and the pane follows. A message you sent otherwise drags in the replies you received, so a view labelled Sent shows conversations rather than what you sent. ThreadListModel::setFlatMode() makes hasChildren() and ReplyCountRole answer differently and changes nothing else; runQuery() sets it on EVERY run, so any other query restores the tree on its way through and the flag cannot outlive the button that set it. The pane needed its own fix for the same reason: the single-message path depends on a field only filled when a thread is expanded, which never happens in a flat list, so loadThread() gained matchedOnly and drops the messages that did not match instead of rendering them as stubs. Recipients replace the sender through the existing SendersRole rather than a new one, so the delegate needs no branch and cannot disagree with the model about which name a row shows. It falls back to the sender when a To header is absent or unparseable, since a blank where a name belongs reads as a rendering fault. Address parsing uses GMime: a display name may contain a comma, so "Rossi, Mario" <m@example.org>, info@example.net is two addresses and splitting reports three. internet_address_list_parse returns NULL for an empty string, which is a crash if unguarded. Backlog item 63.
-rw-r--r--CHANGELOG.md8
-rw-r--r--README.md39
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md2
-rw-r--r--docs/superpowers/specs/2026-08-11-sent-mail-design.md62
-rw-r--r--src/config.cpp37
-rw-r--r--src/config.h31
-rw-r--r--src/mainwindow.cpp50
-rw-r--r--src/mainwindow.h28
-rw-r--r--src/mimeparser.cpp59
-rw-r--r--src/mimeparser.h17
-rw-r--r--src/notmuchworker.cpp57
-rw-r--r--src/notmuchworker.h15
-rw-r--r--src/threadlistmodel.cpp39
-rw-r--r--src/threadlistmodel.h19
-rw-r--r--src/types.h12
-rw-r--r--tests/notmuchfixture.h7
-rw-r--r--tests/test_config.cpp161
-rw-r--r--tests/test_mainwindow.cpp147
-rw-r--r--tests/test_mimeparser.cpp76
-rw-r--r--tests/test_notmuchworker.cpp164
-rw-r--r--tests/test_threadlistmodel.cpp104
21 files changed, 1108 insertions, 26 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55aa11f..22a6159 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,14 @@ point at which they are stable.
### Added
+- **A Sent view.** A new `sent` key on each `[account.*]` names that account's
+ sent folder, and a Sent button beside Inbox, Unread and Important shows what
+ you sent across every account that configures one. Selecting an account
+ narrows it to that account. The button is absent entirely when no account has
+ the key.
+- Sent mail is shown as a flat list rather than as threads, and the cards name
+ the **recipients** instead of the sender, which is you on every row.
+ Selecting one opens what you sent, not the conversation your message started.
- `[general] date_format`, an optional pattern for the date on a thread card.
Absent or empty keeps the system locale's short format, which is unchanged
and remains the default. A pattern containing no date or time field is
diff --git a/README.md b/README.md
index 17146d5..6dcbb66 100644
--- a/README.md
+++ b/README.md
@@ -165,6 +165,7 @@ name = Your Name
address = you@example.org
maildir = work-mail ; relative to notmuch's database.path
drafts = Drafts ; recorded for v2; unused today
+sent = Sent ; optional; enables the Sent button for this account
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
@@ -203,6 +204,44 @@ The button text is the key you write here, so these names are yours to
choose. `Important = tag:flagged` and `Flagged = tag:flagged` run the same
query and differ only in what the button says.
+### Sent mail
+
+A **Sent** button appears beside the saved queries once at least one account
+carries a `sent` key naming its sent folder, relative to that account's
+`maildir`:
+
+```ini
+[account.work]
+maildir = work-mail
+sent = Sent
+
+[account.webmail]
+maildir = webmail
+sent = [Provider]/Posta inviata ; nested and localised folders are fine
+
+[account.list-only]
+maildir = list-only
+; no sent key: this account is simply left out of the Sent view
+```
+
+The button composes its query from those keys every time you press it, rather
+than storing one, so adding an account or correcting a folder name is a config
+edit and nothing else. With no account selected it shows every configured
+account's sent mail; selecting one narrows it to that account. An account
+without the key is omitted silently, since keeping no sent mail locally is a
+legitimate setup rather than a mistake.
+
+Sent mail is presented differently from the rest, because it reads differently:
+
+- **A flat list, not threads.** A message you sent otherwise drags in the
+ replies you received, and a view labelled Sent then shows conversations.
+- **Cards name the recipients**, not the sender, which is you on every row.
+- **Selecting one opens what you sent**, rather than the whole conversation it
+ started.
+
+This applies only to the Sent button. The same query typed into the bar by hand
+behaves like any other query, threads and all.
+
## The query bar
The bar at the top takes a notmuch query and shows the matching threads.
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 534dc6e..a187874 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
@@ -119,7 +119,7 @@ taking that too literally.
| 60 | Next thread dead-ends on the last reply of an expanded thread | defect | XS | **done**; already fixed by 5487d58, see below |
| 61 | `test_mainwindow` fails intermittently, about 1 run in 20 | testing | S | open; predates the card list, reproduced on f72dba9 |
| 62 | No config option for the date format on a card | presentation | XS | **done** 2026-08-11 |
-| 63 | No way to see sent mail, and no filter for it | workflow | M | open; specified 2026-08-11 in `specs/2026-08-11-sent-mail-design.md` |
+| 63 | No way to see sent mail, and no filter for it | workflow | M | **done** 2026-08-11; see `specs/2026-08-11-sent-mail-design.md` |
| 64 | The Sync button carries a mailbox icon, not a refresh one | presentation | XS | **done** 2026-08-11 |
| 65 | No full code review and optimization pass | correctness | ? | open, unspecified |
diff --git a/docs/superpowers/specs/2026-08-11-sent-mail-design.md b/docs/superpowers/specs/2026-08-11-sent-mail-design.md
index 53fbb6c..4934241 100644
--- a/docs/superpowers/specs/2026-08-11-sent-mail-design.md
+++ b/docs/superpowers/specs/2026-08-11-sent-mail-design.md
@@ -1,6 +1,7 @@
# Sent mail: a per-account folder, a composed button, and recipients on the card
-**Status:** specified 2026-08-11, not implemented.
+**Status:** specified and built 2026-08-11, hand-verified by the user. See
+"Outcome" at the end for what the spec did not anticipate.
**Resolves:** backlog item 63.
**Size:** M, revised up from the backlog's S. The query half is the S that was
scoped correctly; the recipients half is its own piece of work.
@@ -114,6 +115,18 @@ Built beside the saved-query buttons, running the OR of every non-empty
`ThreadSummary` gains a recipients summary, filled in the worker, shown by
`CardDelegate` in the sender's place when the row belongs to a Sent view.
+**The fold must be OPT-IN per query, and this is not a preference.** Measured
+2026-08-11 against the real database: `notmuch_message_get_header(m, "To")` is
+NOT served from the index, it reads the message file. Folding it for every
+thread of a 4411-thread inbox took **38.2 seconds**, 8.7 ms per thread. The
+same fold over the 601-thread Sent view took **663 ms**, 1.1 ms per thread,
+which the existing 200-thread batching hides.
+
+So the worker takes a flag on the query, set only when the query is a Sent one,
+and skips the walk entirely otherwise. A version that always folds turns an
+instant inbox into a 38-second one, and it would look correct in every test:
+the data is right, only the cost is wrong.
+
## Constraints
**Do not invent a tag qtmaildir applies itself.** v1 is read-and-organize;
@@ -178,3 +191,50 @@ named `signals`, which Qt defines as a macro.
The first and last are the ones that fail loudest if the quoting is wrong, and
they are the reason to write them before the UI work rather than after.
+
+## Outcome (done 2026-08-11)
+
+Built in the three pieces above and hand-verified by the user. Four things the
+spec did not anticipate, each found by using it rather than by reading code.
+
+**A flat list, which the spec never mentioned.** The user's first report was
+that the Sent view showed the replies they had RECEIVED. That is correct
+behaviour, since the query matches messages and the list groups them into
+threads, but it is not what a Sent view is for: their stated mental model is
+that sent mail "lives on its own". `ThreadListModel::setFlatMode()` makes
+`hasChildren()` and `ReplyCountRole` answer differently and nothing else
+changes. It is one flag on the existing model rather than a second model or a
+filtered query, which was the user's condition for building it at all.
+
+**Flat mode cannot leak, and that is structural rather than careful.**
+`runQuery()` sets the mode on EVERY run, so any query that is not the Sent
+button restores the tree on its way through. The window test mutates this
+directly: making the flag one-way leaves it passing every model test and
+flattens the whole application from the first Sent click.
+
+**The pane needed its own fix, and the flat list is why.** With the list flat
+and correct, selecting a Sent row still opened the whole conversation. The
+single-message path needs `ThreadNode::first`, which is only filled when a
+thread is EXPANDED, so in a flat list it is always empty and every selection
+falls through to `loadThread`. That now takes `matchedOnly`, dropping the
+messages that did not match rather than rendering them as stubs. The per-message
+`matched` flag it needs was already computed.
+
+**The recipient fold is cheaper than the spec's measurement.** 251 ms for the
+601-thread Sent view against the 663 ms measured while specifying, because the
+worker stops at the first usable `To` per thread rather than reading every
+message. The inbox, with the flag off, is unchanged at 148 ms for 4411 threads.
+The opt-in is mutation-tested: always folding fails with "the To header was read
+for a query that never asked for it".
+
+**One mutation survived, and the comment was corrected rather than the code.**
+Removing the `haveMatchSet` guard beside `matchedOnly` changes nothing, because
+`ref.matched` is already true for every message when no query was given. The
+guard is redundant today and kept as a stated invariant at the point that
+depends on it; the test that appeared to cover it now says plainly that it does
+not.
+
+**Known limit, accepted.** A flat row is still one row per THREAD, not per sent
+message: 795 sent messages live in 601 threads here, so a thread written to
+twice appears once, dated by its newest match. The model is thread-keyed
+throughout, so per-message rows would be a different piece of work.
diff --git a/src/config.cpp b/src/config.cpp
index 9e223f8..47fadec 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -47,6 +47,37 @@ QString Account::scopedQuery(const QString &query) const
return QStringLiteral("%1 and (%2)").arg(prefix, query);
}
+QString Account::sentQuery() const
+{
+ if (sent.isEmpty())
+ return QString();
+
+ // The QUOTES are load-bearing, not decoration. A real provider nests its
+ // sent folder under a bracketed parent, "[Provider]/Posta inviata", and
+ // "[" and "]" are Xapian syntax: unquoted, the term is parsed rather than
+ // matched and the query silently returns nothing while looking correct.
+ //
+ // The path is user config and is interpolated into a query, so this is the
+ // only place that composition happens; a caller building it by hand would
+ // be a second chance to forget the quotes.
+ return QStringLiteral("path:\"%1/%2/**\"").arg(maildir, sent);
+}
+
+QString Config::allSentQuery() const
+{
+ // Collect first, join after. Appending "or" per account and trimming the
+ // result is the version that produced the defect this guards: an account
+ // with no sent key contributes an empty term, notmuch accepts the bare
+ // "or" without complaint, and the query quietly means something else.
+ QStringList parts;
+ for (const Account &account : m_accounts) {
+ const QString query = account.sentQuery();
+ if (!query.isEmpty())
+ parts.append(query);
+ }
+ return parts.join(QStringLiteral(" or "));
+}
+
QString Config::defaultPath()
{
const QString base =
@@ -281,6 +312,12 @@ void Config::load(const QString &path)
account.maildir = settings.value(QStringLiteral("maildir")).toString();
account.drafts = settings.value(QStringLiteral("drafts")).toString();
+ // Optional, and absent for an account that keeps no sent mail locally.
+ // Trimmed because a trailing space would land inside the quoted path
+ // and match nothing, which is invisible in a config file.
+ account.sent =
+ settings.value(QStringLiteral("sent")).toString().trimmed();
+
// Both optional, and both describe this account's chip in the thread
// list. An account tag is a different taxonomy from a functional one,
// saying which mailbox a thread arrived in rather than what state it
diff --git a/src/config.h b/src/config.h
index bcbfb34..e3c5b6e 100644
--- a/src/config.h
+++ b/src/config.h
@@ -36,6 +36,18 @@ struct Account
QString maildir; ///< Relative to notmuch's database.path.
QString drafts; ///< Unused in v1; send is v2.
+ /// The account's sent folder, relative to maildir. Optional and empty for
+ /// an account that has none, which is a real case rather than a
+ /// misconfiguration: an account may keep no sent mail locally at all.
+ ///
+ /// A key rather than a <maildir>/Sent convention because the folder is not
+ /// uniform across providers. Measured across one real setup: two accounts
+ /// use `Sent`, two nest a localised name under a bracketed parent
+ /// (`[Provider]/Posta inviata`), and one has no sent folder whatsoever. A
+ /// convention would produce an empty view for the nested ones and a wrong
+ /// one for the account that has none.
+ QString sent;
+
/// Chip colour in the thread list. Invalid when unset, in which case one
/// is generated from the account tag's name.
QColor color;
@@ -62,6 +74,13 @@ struct Account
/// Restricts a notmuch query to this account's subtree.
QString scopedQuery(const QString &query) const;
+
+ /// Matches this account's sent mail, or empty when `sent` is unset.
+ ///
+ /// Composes with scopedQuery() rather than replacing it: the account
+ /// selector wraps whatever query runs, so a Sent view under one account
+ /// intersects to that account's sent mail and cannot leak another's.
+ QString sentQuery() const;
};
struct SavedQuery
@@ -114,6 +133,18 @@ public:
/// Optional alternate notmuch config file. Empty means "let notmuch decide".
QString notmuchConfig() const { return m_notmuchConfig; }
+ /// Matches every configured account's sent mail, or empty when no account
+ /// configures one.
+ ///
+ /// Joins only the NON-EMPTY sentQuery() results. An account without a
+ /// `sent` key contributes nothing, and joining it anyway would leave a bare
+ /// `or` in the query. notmuch does not reject that: `A or or B` returns
+ /// 190 messages where the correct pair returns 211, measured directly. A
+ /// malformed query that still returns plausible mail is the failure that
+ /// ships, which is why the join lives here and is tested rather than being
+ /// open-coded at the call site.
+ QString allSentQuery() const;
+
/// A QDateTime::toString() pattern for the date on a card, or empty for the
/// system locale's short format.
///
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 374f228..be6cfdb 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -457,6 +457,7 @@ void MainWindow::buildUi()
this, &MainWindow::runCurrentQuery);
m_queryEdit = new QLineEdit(central);
+ m_queryEdit->setObjectName(QStringLiteral("queryEdit"));
m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox"));
// Qt draws the clear button inside the field and shows it only when there
// is text, themed by the desktop. A hand-rolled button beside the bar would
@@ -553,6 +554,28 @@ void MainWindow::buildUi()
});
queryRow->addWidget(button);
}
+
+ // Sent sits with the saved queries and is not one: its query is COMPOSED
+ // from the accounts' `sent` keys at click time, so adding an account or
+ // correcting a folder name is a config edit and nothing else. A [queries]
+ // entry holding the same string would go stale silently, and could not
+ // narrow to the selected account the way this does through
+ // runCurrentQuery()'s existing scope wrap.
+ //
+ // Hidden entirely when no account configures a sent folder, rather than
+ // offering a button that always finds nothing.
+ if (!m_config.allSentQuery().isEmpty()) {
+ auto *sentButton = new QPushButton(tr("Sent"), central);
+ sentButton->setObjectName(QStringLiteral("sentButton"));
+ connect(sentButton, &QPushButton::clicked, this, [this]() {
+ m_queryEdit->setText(m_config.allSentQuery());
+ // Flat for this query only. runCurrentQuery() clears it again for
+ // anything else, including the same query typed by hand, so the
+ // flag cannot outlive the button that set it.
+ runQuery(FlatResult::Yes);
+ });
+ queryRow->addWidget(sentButton);
+ }
layout->addLayout(queryRow);
// Thread list and message pane.
@@ -1471,8 +1494,14 @@ void MainWindow::showWarnings()
problems.join(QLatin1Char('\n')));
}
-void MainWindow::runCurrentQuery()
+void MainWindow::runQuery(FlatResult flat)
{
+ // Set on EVERY run, not only when Yes. This is the line that stops flat
+ // mode leaking: any query that is not the Sent button restores the tree,
+ // so the flag cannot survive into the next view.
+ m_sentView = flat == FlatResult::Yes;
+ m_model->setFlatMode(m_sentView);
+
QString query = m_queryEdit->text().trimmed();
const QString accountKey = m_accountBox->currentData().toString();
@@ -1516,10 +1545,13 @@ void MainWindow::runCurrentQuery()
const auto sort = m_sortOrder->currentIndex() == 1
? NotmuchWorker::OldestFirst
: NotmuchWorker::NewestFirst;
+ // Recipients only for the Sent view: the fold reads message FILES, which
+ // is tens of seconds over an inbox. See ThreadSummary::recipients.
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, query),
Q_ARG(quint64, m_generation),
- Q_ARG(NotmuchWorker::SortOrder, sort));
+ Q_ARG(NotmuchWorker::SortOrder, sort),
+ Q_ARG(bool, m_sentView));
}
void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads,
@@ -1863,10 +1895,16 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
+ // In the Sent view the pane shows only what matched, which is what the
+ // user sent. Without this the flat list is right and the pane still opens
+ // the whole conversation, replies included, under a heading that says
+ // Sent: the row was never expanded, so the model never learned the
+ // thread's first message and this is the only path a Sent row can take.
QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection,
Q_ARG(QString, m_currentThreadId),
Q_ARG(QString, m_lastQuery),
- Q_ARG(quint64, m_generation));
+ Q_ARG(quint64, m_generation),
+ Q_ARG(bool, m_sentView));
}
void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages,
@@ -2250,10 +2288,14 @@ void MainWindow::refreshCurrentQuery()
const auto sort = m_sortOrder->currentIndex() == 1
? NotmuchWorker::OldestFirst
: NotmuchWorker::NewestFirst;
+ // The SAME recipients flag the visible view was built with. A refresh that
+ // dropped it would quietly replace a Sent view's recipients with empty
+ // strings on the first background sync, while the user was reading it.
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, m_lastQuery),
Q_ARG(quint64, m_refreshGeneration),
- Q_ARG(NotmuchWorker::SortOrder, sort));
+ Q_ARG(NotmuchWorker::SortOrder, sort),
+ Q_ARG(bool, m_sentView));
}
void MainWindow::updateStaleThreadNotice()
diff --git a/src/mainwindow.h b/src/mainwindow.h
index a7ea5c3..6e90ba1 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -173,8 +173,25 @@ protected:
/// this the action fires from inside the bar and the query never runs.
bool eventFilter(QObject *watched, QEvent *event) override;
+public:
+ /// Whether the result of a query is shown as a flat list of threads rather
+ /// than as an expandable tree.
+ ///
+ /// Only the Sent button asks for Yes. Every other route runs the no-arg
+ /// slot, which passes No, so flat mode cannot outlive the view that asked
+ /// for it: the same query typed by hand comes back as a tree.
+ enum class FlatResult { No, Yes };
+ Q_ENUM(FlatResult)
+
+private:
+ /// The real query runner. Kept off the slot list deliberately: a slot with
+ /// a defaulted argument does not satisfy QObject::connect, which matches
+ /// signal and slot arity at compile time, so the zero-argument slot below
+ /// is what widgets connect to.
+ void runQuery(FlatResult flat);
+
private slots:
- void runCurrentQuery();
+ void runCurrentQuery() { runQuery(FlatResult::No); }
/// Brings back a thread that stopped matching, and restores the reader's
/// place inside it.
@@ -642,6 +659,15 @@ private:
/// the whole result set rather than the batches that have arrived so far.
/// Gates mark_all_read, which cannot honestly say "all" before then.
bool m_queryComplete = false;
+
+ /// Whether the CURRENT view is the Sent one, and therefore whether it is
+ /// flat and carries recipients.
+ ///
+ /// Held rather than recomputed because a background refresh re-runs the
+ /// same query without going through the Sent button: without this, the
+ /// first cron sync would silently turn a Sent view back into a tree of
+ /// senders while the user was reading it.
+ bool m_sentView = false;
QString m_lastQuery;
QString m_currentThreadId;
diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp
index 4ad617e..ecf56f1 100644
--- a/src/mimeparser.cpp
+++ b/src/mimeparser.cpp
@@ -213,6 +213,65 @@ QString Attachment::saveWithoutOverwriting(const QString &directory,
return target;
}
+QString recipientSummary(const QString &rawTo, int maxNames)
+{
+ const QByteArray utf8 = rawTo.trimmed().toUtf8();
+ if (utf8.isEmpty())
+ return {};
+
+ // Returns NULL rather than an empty list for input it cannot make anything
+ // of, including the empty string. Guarded above and again here: the header
+ // is untrusted and this is the crash if it is not.
+ InternetAddressList *list = internet_address_list_parse(nullptr,
+ utf8.constData());
+ if (!list)
+ return {};
+
+ QStringList names;
+ int total = 0;
+ const int count = internet_address_list_length(list);
+ for (int i = 0; i < count; ++i) {
+ InternetAddress *address = internet_address_list_get_address(list, i);
+ if (!address)
+ continue;
+
+ // A group (`undisclosed-recipients:;`) has a name but no address, and
+ // its members, if any, are a list of their own. Only mailboxes are
+ // counted: naming the group would print "undisclosed-recipients" as
+ // though it were a person.
+ if (!INTERNET_ADDRESS_IS_MAILBOX(address))
+ continue;
+
+ ++total;
+ if (names.size() >= maxNames)
+ continue;
+
+ const char *name = internet_address_get_name(address);
+ const QString display = name ? QString::fromUtf8(name).trimmed()
+ : QString();
+ if (!display.isEmpty()) {
+ names.append(display);
+ continue;
+ }
+ const char *addr = internet_address_mailbox_get_addr(
+ INTERNET_ADDRESS_MAILBOX(address));
+ if (addr)
+ names.append(QString::fromUtf8(addr));
+ else
+ --total; // Neither a name nor an address: nothing to show.
+ }
+ g_object_unref(list);
+
+ if (names.isEmpty())
+ return {};
+
+ QString summary = names.join(QStringLiteral(", "));
+ const int hidden = total - names.size();
+ if (hidden > 0)
+ summary += QStringLiteral(" +%1").arg(hidden);
+ return summary;
+}
+
QString attachmentFolderName(const QString &rfc822Date, const QString &subject)
{
// The date prefix sorts chronologically in a file manager. A Date: header
diff --git a/src/mimeparser.h b/src/mimeparser.h
index af7619f..9fceb2f 100644
--- a/src/mimeparser.h
+++ b/src/mimeparser.h
@@ -94,6 +94,23 @@ struct Attachment
/// subject can be far longer than that.
QString attachmentFolderName(const QString &rfc822Date, const QString &subject);
+/// A one-line summary of a raw To: header, for the sender's place on a card in
+/// a Sent view.
+///
+/// Parsed with GMime rather than split on commas: a display name may CONTAIN a
+/// comma, so `"Rossi, Mario" <m@example.org>, info@example.net` is two
+/// addresses and naive splitting reports three.
+///
+/// Each address renders as its display name, falling back to the address when
+/// it has none, so a list does not mix "Mario Rossi" with a bare address. With
+/// more than `maxNames` recipients the rest collapse into "+N", mirroring the
+/// tag strip's overflow chip rather than being elided mid-name.
+///
+/// The header is untrusted and may be empty, malformed, or a group such as
+/// `undisclosed-recipients:;`. Returns an empty string when nothing usable can
+/// be read, never a partial parse.
+QString recipientSummary(const QString &rawTo, int maxNames = 2);
+
struct ParsedMessage
{
bool ok = false;
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index d34c032..24f3fd9 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -24,6 +24,7 @@
#include <cstdlib>
+#include "mimeparser.h"
#include "nmraii.h"
namespace {
@@ -46,6 +47,45 @@ QStringList tagsOf(notmuch_thread_t *thread)
return result;
}
+/// Who a thread's messages were addressed to, summarised for one line.
+///
+/// EXPENSIVE, and only called when a query asks. "To" is not served from
+/// notmuch's index, so every call here reads message FILES: 8.7 ms per thread
+/// measured against a real database, which is 38 seconds over a 4411-thread
+/// inbox. See ThreadSummary::recipients.
+///
+/// The messages come from the THREAD and are owned by it, freed when it is
+/// freed (notmuch.h:1637). They are therefore held raw and never wrapped in
+/// NmMessage, which would call notmuch_message_destroy on memory the thread
+/// frees again, and the walk finishes before the caller drops the thread. This
+/// is the same rule walkReplies follows, and getting it wrong is a double-free
+/// rather than a leak.
+QString recipientsOf(notmuch_thread_t *thread)
+{
+ // The first message with a usable To wins. A thread is one conversation,
+ // and the alternative, folding every message's recipients together, is the
+ // participants-list problem item 2 rejected: it produces a union that
+ // misdescribes itself the moment a thread has replies going both ways.
+ notmuch_messages_t *messages = notmuch_thread_get_messages(thread);
+ for (; notmuch_messages_valid(messages);
+ notmuch_messages_move_to_next(messages)) {
+ notmuch_message_t *message = notmuch_messages_get(messages);
+ if (!message)
+ continue;
+
+ // Returns "" for a missing header and NULL on error, and the two mean
+ // different things only to notmuch: both are "nothing to show" here.
+ const char *to = notmuch_message_get_header(message, "To");
+ if (!to || !*to)
+ continue;
+
+ const QString summary = recipientSummary(QString::fromUtf8(to));
+ if (!summary.isEmpty())
+ return summary;
+ }
+ return QString();
+}
+
/// Collects the message ids a query matches. Returns false if the query could
/// not be run at all, which is different from a query that matched nothing.
bool collectMessageIds(notmuch_database_t *db, const QString &query,
@@ -187,7 +227,7 @@ void NotmuchWorker::close()
}
void NotmuchWorker::runQuery(const QString &query, quint64 generation,
- SortOrder sort)
+ SortOrder sort, bool withRecipients)
{
if (!openReadOnly())
return;
@@ -231,6 +271,8 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
summary.totalCount = notmuch_thread_get_total_messages(thread.get());
summary.matchedCount = notmuch_thread_get_matched_messages(thread.get());
summary.tags = tagsOf(thread.get());
+ if (withRecipients)
+ summary.recipients = recipientsOf(thread.get());
batch.append(summary);
++total;
@@ -250,7 +292,7 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
void NotmuchWorker::loadThread(const QString &threadId,
const QString &matchQuery,
- quint64 generation)
+ quint64 generation, bool matchedOnly)
{
if (!openReadOnly())
return;
@@ -303,6 +345,17 @@ void NotmuchWorker::loadThread(const QString &threadId,
ref.filePath = QString::fromUtf8(notmuch_message_get_filename(message.get()));
ref.tags = tagsOf(message.get());
ref.matched = !haveMatchSet || matchedIds.contains(ref.messageId);
+
+ // Dropped rather than rendered as a stub.
+ //
+ // haveMatchSet is redundant here and kept deliberately: ref.matched is
+ // already true for every message when no query was given, so the two
+ // conditions cannot disagree today. It states the invariant this
+ // depends on at the point that depends on it, so a later change to how
+ // ref.matched is computed cannot silently empty the pane.
+ if (matchedOnly && haveMatchSet && !ref.matched)
+ continue;
+
result.append(ref);
}
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 1d8c8c0..2dd766f 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -60,14 +60,25 @@ public:
public slots:
/// Runs a query. generation lets the UI discard results from a superseded
/// query without the worker needing to know about cancellation.
+ /// `withRecipients` fills ThreadSummary::recipients by reading each
+ /// thread's To headers. OFF by default and deliberately opt-in: To is not
+ /// in notmuch's index, so this reads message FILES, at roughly 8.7 ms per
+ /// thread. Only a Sent query asks for it; turning it on for an inbox query
+ /// costs tens of seconds and changes nothing a user can see.
void runQuery(const QString &query, quint64 generation,
- SortOrder sort = NewestFirst);
+ SortOrder sort = NewestFirst, bool withRecipients = false);
/// Loads the messages of one thread, oldest first. matchQuery is the
/// user's current query; messages matching it render expanded, the rest
/// as stubs.
+ /// `matchedOnly` drops the messages that did not match `matchQuery` rather
+ /// than rendering them as stubs. For the Sent view, where the thread is not
+ /// the unit the user is reading: a sent message pulls in the replies it
+ /// received, and a pane claiming to show what they sent then shows a
+ /// conversation. Ignored when `matchQuery` is empty, since nothing was
+ /// filtered and every message counts as matched.
void loadThread(const QString &threadId, const QString &matchQuery,
- quint64 generation);
+ quint64 generation, bool matchedOnly = false);
/// Loads a thread as a reply TREE, for the message rows in the list.
///
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 33356f0..5874648 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -176,6 +176,21 @@ QModelIndex ThreadListModel::parent(const QModelIndex &child) const
return createIndex(static_cast<int>(id), 0, static_cast<quintptr>(-1));
}
+void ThreadListModel::setFlatMode(bool flat)
+{
+ if (m_flatMode == flat)
+ return;
+
+ // A full reset, not dataChanged. Flat mode changes what hasChildren() and
+ // rowCount() answer for every thread row, and a view that has already
+ // expanded one is holding indexes below it: dataChanged says "these rows
+ // are different", not "the shape under them is gone", and leaves the view
+ // drawing children the model no longer offers.
+ beginResetModel();
+ m_flatMode = flat;
+ endResetModel();
+}
+
int ThreadListModel::rowCount(const QModelIndex &parent) const
{
if (!parent.isValid())
@@ -190,6 +205,11 @@ int ThreadListModel::rowCount(const QModelIndex &parent) const
if (parent.row() < 0 || parent.row() >= m_threads.size())
return 0;
+ // Hidden, not discarded: leaving flat mode restores the tree with no
+ // reload, and an expansion loaded before the switch is still there.
+ if (m_flatMode)
+ return 0;
+
return m_threads.at(parent.row()).children.size();
}
@@ -209,6 +229,10 @@ bool ThreadListModel::hasChildren(const QModelIndex &parent) const
if (parent.row() < 0 || parent.row() >= m_threads.size())
return false;
+ // No expander in a flat list, whatever the thread turns out to contain.
+ if (m_flatMode)
+ return false;
+
const ThreadNode &node = m_threads.at(parent.row());
// Once loaded the children are the truth, including "there are none", which
@@ -469,6 +493,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// twice on the same card.
return thread.subject;
case SendersRole:
+ // Recipients in their place when the query supplied them, which only a
+ // Sent query does. The sender there is the user on every row and says
+ // nothing; who it went TO is the question the view exists to answer.
+ //
+ // Falls back to authors when the fold found no usable To, so a message
+ // with a malformed or absent recipient header shows the sender rather
+ // than a blank line where a name belongs.
+ if (!thread.recipients.isEmpty())
+ return thread.recipients;
return thread.authors;
case DateRole:
// The QDateTime itself. Formatting belongs to the delegate now: the
@@ -480,6 +513,12 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
case IsFlaggedRole:
return thread.isFlagged();
case ReplyCountRole:
+ // Zero in a flat list, so the card draws no expander pill. The count
+ // and hasChildren() must agree: a card advertising "3 replies" that
+ // cannot be opened is the inert-glyph defect this project has already
+ // shipped once.
+ if (m_flatMode)
+ return 0;
// totalCount includes the root message, which is the card itself.
return qMax(0, thread.totalCount - 1);
case DateFormatRole:
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 777841e..8b8f414 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -170,6 +170,24 @@ public:
/// The pattern DateFormatRole answers with. Empty means the system format.
void setDateFormat(const QString &format) { m_dateFormat = format; }
+ /// One row per thread, with no expander and no reply count.
+ ///
+ /// For the Sent view, where a thread is the wrong unit: the user's model of
+ /// "what I sent" is a list, and a matching sent message otherwise drags in
+ /// the replies they RECEIVED, under a view that claims to be their outbox.
+ ///
+ /// A flag on this model rather than a second model or a filtered query, and
+ /// that is what keeps it from leaking: it is off by default, only the Sent
+ /// button turns it on, and every other query turns it off again. The
+ /// expander already comes from hasChildren() and the card's count from
+ /// ReplyCountRole, so flat mode is those two answering differently and
+ /// nothing else changes.
+ ///
+ /// The children are not discarded, only hidden. Leaving flat mode restores
+ /// the tree without reloading anything.
+ void setFlatMode(bool flat);
+ bool flatMode() const { return m_flatMode; }
+
QModelIndex index(int row, int column,
const QModelIndex &parent = {}) const override;
QModelIndex parent(const QModelIndex &child) const override;
@@ -284,4 +302,5 @@ private:
QVector<ThreadNode> m_threads;
const TagColors *m_tagColors = nullptr;
QString m_dateFormat;
+ bool m_flatMode = false;
};
diff --git a/src/types.h b/src/types.h
index d04670e..2211619 100644
--- a/src/types.h
+++ b/src/types.h
@@ -33,6 +33,18 @@ struct ThreadSummary
int matchedCount = 0;
QStringList tags;
+ /// Who the thread's messages were sent TO, summarised for one line.
+ ///
+ /// Empty unless the query asked for it, and that is a performance
+ /// contract rather than a default: To is NOT served from notmuch's index,
+ /// so filling this reads every message file. Measured at 8.7 ms per thread,
+ /// which is 38 seconds over a 4411-thread inbox and 663 ms over a
+ /// 601-thread sent view. Only a Sent query asks.
+ ///
+ /// Shown in the sender's place there, where `authors` is the user on every
+ /// row and carries nothing.
+ QString recipients;
+
bool isUnread() const { return tags.contains(QStringLiteral("unread")); }
bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); }
bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); }
diff --git a/tests/notmuchfixture.h b/tests/notmuchfixture.h
index 4d2e59b..bb25526 100644
--- a/tests/notmuchfixture.h
+++ b/tests/notmuchfixture.h
@@ -47,10 +47,13 @@ public:
/// Writes one message into <folder>/cur (or new/ when unread).
///
/// Returns false if the file could not be written. Call index() afterwards.
+ /// `to` defaults to a single generic recipient. Pass one explicitly to
+ /// exercise the recipient summary, which is the only thing that reads it.
bool addMessage(const QString &folder, const QString &messageId,
const QString &subject, const QString &from,
const QString &date, const QString &body,
- bool unread = true, const QString &inReplyTo = QString())
+ bool unread = true, const QString &inReplyTo = QString(),
+ const QString &to = QStringLiteral("you@example.org"))
{
// Unread messages must not carry the maildir "S" flag, so they go to
// new/ where no flags exist at all.
@@ -77,7 +80,7 @@ public:
QTextStream out(&file);
out << "From: " << from << "\n"
- << "To: you@example.org\n"
+ << "To: " << to << "\n"
<< "Subject: " << subject << "\n"
<< "Message-ID: <" << messageId << ">\n"
<< "Date: " << date << "\n";
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index b9321e0..60ed071 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -64,6 +64,13 @@ private slots:
void malformedExtraMimetypeIsSkipped();
void syncChannelDefaultsToTheAccountKey();
void syncChannelIsActuallyRead();
+ void sentQueryIsEmptyWithoutTheKey();
+ void sentQueryComposesThePath();
+ void sentQuerySurvivesABracketedPath();
+ void sentQueryComposesWithScopedQuery();
+ void allSentQueryIsEmptyWhenNoAccountHasOne();
+ void allSentQuerySkipsAccountsWithoutTheKey();
+ void allSentQueryJoinsEveryConfiguredAccount();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -755,5 +762,159 @@ void TestConfig::syncChannelIsActuallyRead()
QStringLiteral("mail-firstlast"));
}
+void TestConfig::sentQueryIsEmptyWithoutTheKey()
+{
+ // Optional exactly as drafts is. A real account can legitimately have no
+ // sent folder at all, and the Sent view omits it silently rather than
+ // reporting a config problem on every launch about nothing.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.provider-c]\n"
+ "maildir = provider-c\n")));
+
+ QCOMPARE(config.accounts().size(), 1);
+ QVERIFY(config.accounts().at(0).sentQuery().isEmpty());
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::sentQueryComposesThePath()
+{
+ // Relative to maildir, the same way the account's own scope is, so the two
+ // cannot disagree about where the account lives.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n")));
+
+ QCOMPARE(config.accounts().at(0).sentQuery(),
+ QStringLiteral("path:\"webmail-primary/Sent/**\""));
+}
+
+void TestConfig::sentQuerySurvivesABracketedPath()
+{
+ // The load-bearing case, and the reason this is a config key rather than a
+ // <maildir>/Sent convention. A real provider nests its sent folder under a
+ // BRACKETED parent and localises the name: "[Provider]/Posta inviata".
+ //
+ // "[" and "]" are Xapian syntax. The quotes around the whole path are what
+ // make the query work at all, and an implementation that built this without
+ // them returns nothing while looking entirely plausible.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.provider-a]\n"
+ "maildir = provider-a\n"
+ "sent = [Provider]/Posta inviata\n")));
+
+ const QString query = config.accounts().at(0).sentQuery();
+ QCOMPARE(query,
+ QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\""));
+
+ // Stated separately from the QCOMPARE above: the quoting is the property
+ // that matters, and a later change to the surrounding syntax must not be
+ // able to drop it while still matching a rewritten expected string.
+ QVERIFY2(query.contains(QStringLiteral("\"provider-a/[Provider]")),
+ "the composed path is not quoted, so Xapian will read the "
+ "brackets as syntax and the query will match nothing");
+}
+
+void TestConfig::sentQueryComposesWithScopedQuery()
+{
+ // A Sent view under one account must not show another account's sent mail.
+ // The account selector wraps whatever query runs, so the composed sent
+ // query has to survive being scoped rather than bypassing it.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n")));
+
+ const Account account = config.accounts().at(0);
+ const QString scoped = account.scopedQuery(account.sentQuery());
+
+ QCOMPARE(scoped,
+ QStringLiteral("path:\"webmail-primary/**\" and "
+ "(path:\"webmail-primary/Sent/**\")"));
+}
+
+void TestConfig::allSentQueryIsEmptyWhenNoAccountHasOne()
+{
+ // Empty rather than a query matching nothing, so the caller can hide the
+ // Sent button entirely instead of offering one that finds no mail.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.provider-c]\n"
+ "maildir = provider-c\n")));
+
+ QVERIFY(config.allSentQuery().isEmpty());
+}
+
+void TestConfig::allSentQuerySkipsAccountsWithoutTheKey()
+{
+ // Joining an account with no `sent` key would leave a bare "or" in the
+ // query, and notmuch does not reject that: it silently returns a DIFFERENT
+ // result. Measured directly against a real database, `A or or B` returns
+ // 190 where the correct pair returns 211.
+ //
+ // A malformed query that still returns plausible mail is the failure that
+ // ships, so this asserts the shape of the string rather than a count.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n"
+ "\n"
+ "[account.provider-c]\n"
+ "maildir = provider-c\n"
+ "\n"
+ "[account.webmail-secondary]\n"
+ "maildir = webmail-secondary\n"
+ "sent = Sent\n")));
+
+ const QString all = config.allSentQuery();
+
+ QVERIFY2(!all.contains(QStringLiteral("or or")),
+ "an account without a sent key left a bare 'or' in the query");
+ QVERIFY2(!all.trimmed().endsWith(QStringLiteral("or")),
+ "the query ends in a dangling 'or'");
+ QVERIFY2(!all.trimmed().startsWith(QStringLiteral("or")),
+ "the query starts with a dangling 'or'");
+ QVERIFY(!all.contains(QStringLiteral("provider-c")));
+
+ // Exactly two terms joined, one per account that configures the key.
+ QCOMPARE(all.count(QStringLiteral("path:")), 2);
+ QCOMPARE(all.count(QStringLiteral(" or ")), 1);
+}
+
+void TestConfig::allSentQueryJoinsEveryConfiguredAccount()
+{
+ // Including a bracketed provider path, which is the case the quoting
+ // exists for and the one most likely to be broken by a later rewrite of
+ // this composition.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n"
+ "\n"
+ "[account.provider-a]\n"
+ "maildir = provider-a\n"
+ "sent = [Provider]/Posta inviata\n")));
+
+ const QString all = config.allSentQuery();
+
+ QVERIFY(all.contains(QStringLiteral("path:\"webmail-primary/Sent/**\"")));
+ QVERIFY(all.contains(
+ QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\"")));
+ QCOMPARE(all.count(QStringLiteral(" or ")), 1);
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 504c10c..faa8481 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -170,6 +170,10 @@ private slots:
void theImportantActionIsLabelledImportant();
void theImportantActionStillWritesTheFlaggedTag();
void theToolbarUsesTheConfiguredIconSize();
+ void thereIsNoSentButtonWithoutASentKey();
+ void theSentButtonRunsEveryConfiguredAccount();
+ void theSentButtonSurvivesABracketedPath();
+ void flatModeDoesNotSurviveTheNextQuery();
void noTwoActionsShareAnIcon();
};
@@ -4540,6 +4544,149 @@ void TestMainWindow::theToolbarUsesTheConfiguredIconSize()
QCOMPARE(toolBar->iconSize(), QSize(40, 40));
}
+namespace {
+
+/// A config whose accounts carry the given maildir/sent pairs. An empty `sent`
+/// writes no key at all, which is the account-without-a-sent-folder case.
+QString writeSentConfig(const QTemporaryDir &dir,
+ const QList<QPair<QString, QString>> &accounts)
+{
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QSettings s(path, QSettings::IniFormat);
+ for (const auto &account : accounts) {
+ s.beginGroup(QStringLiteral("account.") + account.first);
+ s.setValue(QStringLiteral("maildir"), account.first);
+ if (!account.second.isEmpty())
+ s.setValue(QStringLiteral("sent"), account.second);
+ s.endGroup();
+ }
+ s.sync();
+ return path;
+}
+
+} // namespace
+
+void TestMainWindow::thereIsNoSentButtonWithoutASentKey()
+{
+ // Hidden entirely rather than present and finding nothing. An account may
+ // legitimately keep no sent mail locally, and a button that always returns
+ // an empty list reads as a broken feature rather than an absent one.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {{QStringLiteral("provider-c"), {}}}));
+ QVERIFY(config.allSentQuery().isEmpty());
+
+ MainWindow window(config);
+ QVERIFY(!window.findChild<QPushButton *>(QStringLiteral("sentButton")));
+}
+
+void TestMainWindow::theSentButtonRunsEveryConfiguredAccount()
+{
+ // The button composes its query rather than storing one, which is the whole
+ // reason it is not a [queries] entry: a saved query is a fixed string and
+ // would not gain the third account here without the user editing it.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("webmail-primary"), QStringLiteral("Sent")},
+ {QStringLiteral("provider-c"), {}},
+ {QStringLiteral("webmail-secondary"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ button->click();
+ const QString query = queryEdit->text();
+
+ QVERIFY(query.contains(QStringLiteral("webmail-primary/Sent")));
+ QVERIFY(query.contains(QStringLiteral("webmail-secondary/Sent")));
+
+ // The account with no key contributes nothing, and leaves no bare "or"
+ // behind: notmuch accepts that and silently returns a different result.
+ QVERIFY(!query.contains(QStringLiteral("provider-c")));
+ QVERIFY(!query.contains(QStringLiteral("or or")));
+ QCOMPARE(query.count(QStringLiteral(" or ")), 1);
+}
+
+void TestMainWindow::theSentButtonSurvivesABracketedPath()
+{
+ // A real provider nests its sent folder under a bracketed parent, and "["
+ // and "]" are Xapian syntax. The quoting has to survive the trip from the
+ // config through Account::sentQuery() into the query bar; unquoted, the
+ // query looks plausible and matches nothing.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("provider-a"), QStringLiteral("[Provider]/Posta inviata")},
+ }));
+
+ MainWindow window(config);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ button->click();
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\""));
+}
+
+void TestMainWindow::flatModeDoesNotSurviveTheNextQuery()
+{
+ // The condition the user set for this feature: a flat Sent list is fine, a
+ // flat anything-else is not. Asserted at the window rather than the model,
+ // because the leak this guards against is in the WIRING, not in the model:
+ // setFlatMode(true) from the button with no matching false anywhere else
+ // passes every model test and flattens the app from the first Sent click
+ // until it restarts.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("webmail-primary"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ QVERIFY2(!model->flatMode(), "the model starts flat");
+
+ button->click();
+ QVERIFY2(model->flatMode(), "the Sent button did not flatten the list");
+
+ // Any other query restores the tree. Typed by hand rather than through a
+ // saved-query button, since that is the route with no flag of its own and
+ // therefore the one most likely to be forgotten.
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ QVERIFY2(!model->flatMode(),
+ "flat mode survived into an ordinary query, so every view after "
+ "one Sent click lost its replies");
+
+ // And back, so the button still works after the round trip.
+ button->click();
+ QVERIFY(model->flatMode());
+
+ // Even the SAME query typed by hand comes back as a tree: the flag follows
+ // the button, not the text, which is the rule the user chose.
+ queryEdit->setText(config.allSentQuery());
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ QVERIFY(!model->flatMode());
+}
+
void TestMainWindow::noTwoActionsShareAnIcon()
{
// Reported by the user against the icons shipped in 0.12.0: Archive and
diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp
index d16735f..bac71f6 100644
--- a/tests/test_mimeparser.cpp
+++ b/tests/test_mimeparser.cpp
@@ -41,6 +41,10 @@ private slots:
void safeFilenameStripsPathComponents();
void pathInsideDirectoryRejectsSiblingPrefix();
void attachmentFolderNameIsASinglePlainComponent();
+ void recipientSummaryPrefersDisplayNames();
+ void recipientSummaryKeepsACommaInsideADisplayName();
+ void recipientSummaryCollapsesTheOverflow();
+ void recipientSummarySurvivesUnusableInput();
void folderNameSurvivesATimezoneComment();
void savingABatchNeverOverwrites();
@@ -409,5 +413,77 @@ void TestMimeParser::savingABatchNeverOverwrites()
qPrintable(second_tar));
}
+void TestMimeParser::recipientSummaryPrefersDisplayNames()
+{
+ // A display name where there is one, the address where there is not, so a
+ // list does not mix "Mario Rossi" with a bare address for no reason the
+ // reader can see.
+ QCOMPARE(recipientSummary(
+ QStringLiteral("Mario Rossi <mario@example.org>")),
+ QStringLiteral("Mario Rossi"));
+
+ QCOMPARE(recipientSummary(QStringLiteral("info@example.net")),
+ QStringLiteral("info@example.net"));
+
+ QCOMPARE(recipientSummary(QStringLiteral(
+ "Mario Rossi <mario@example.org>, info@example.net")),
+ QStringLiteral("Mario Rossi, info@example.net"));
+}
+
+void TestMimeParser::recipientSummaryKeepsACommaInsideADisplayName()
+{
+ // The reason this uses GMime rather than QString::split(','). A quoted
+ // display name may CONTAIN a comma, and splitting reports three recipients
+ // where there are two, with "Mario" alone as one of them.
+ const QString summary = recipientSummary(QStringLiteral(
+ "\"Rossi, Mario\" <mario@example.org>, info@example.net"));
+
+ QCOMPARE(summary, QStringLiteral("Rossi, Mario, info@example.net"));
+
+ // Stated separately, because the QCOMPARE above would also pass a naive
+ // implementation that happened to rejoin the pieces in the same order.
+ QVERIFY2(!summary.contains(QStringLiteral("+")),
+ "a comma inside a display name was counted as another recipient");
+}
+
+void TestMimeParser::recipientSummaryCollapsesTheOverflow()
+{
+ // "+N" rather than eliding mid-name, mirroring the tag strip's overflow
+ // chip: a card has one line for this and a truncated name is worse than an
+ // honest count.
+ QCOMPARE(recipientSummary(QStringLiteral(
+ "a@example.org, b@example.org, c@example.org, "
+ "d@example.org")),
+ QStringLiteral("a@example.org, b@example.org +2"));
+
+ // Exactly at the limit does not collapse: a "+0" would be absurd.
+ QCOMPARE(recipientSummary(
+ QStringLiteral("a@example.org, b@example.org")),
+ QStringLiteral("a@example.org, b@example.org"));
+}
+
+void TestMimeParser::recipientSummarySurvivesUnusableInput()
+{
+ // The header is untrusted and every one of these is real mail.
+ //
+ // The empty string is the one that crashes if unguarded:
+ // internet_address_list_parse returns NULL for it rather than an empty
+ // list, verified against GMime directly.
+ QVERIFY(recipientSummary(QString()).isEmpty());
+ QVERIFY(recipientSummary(QStringLiteral("")).isEmpty());
+ QVERIFY(recipientSummary(QStringLiteral(" ")).isEmpty());
+
+ // Not a crash and not a lie: a group with no members has no names to show.
+ const QString group =
+ recipientSummary(QStringLiteral("undisclosed-recipients:;"));
+ QVERIFY2(!group.contains(QStringLiteral("@")),
+ qPrintable(QStringLiteral("a memberless group produced an "
+ "address: %1").arg(group)));
+
+ // Garbage parses to something or to nothing, but never to a crash.
+ recipientSummary(QStringLiteral("<<<>>>"));
+ recipientSummary(QStringLiteral("\"unterminated <a@example.org>"));
+}
+
QTEST_MAIN(TestMimeParser)
#include "test_mimeparser.moc"
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 88dcf0b..fe0247c 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -65,6 +65,13 @@ private slots:
void loadThreadTreeReportsReplyDepth();
void loadThreadTreeCarriesTheFactsARowNeeds();
+ void loadThreadMatchedOnlyDropsTheRest();
+ void loadThreadMatchedOnlyWithNoQueryKeepsEverything();
+
+ void recipientsAreAbsentUnlessAskedFor();
+ void recipientsAreFoldedWhenAskedFor();
+ void recipientsCrossAQueuedCall();
+
void requestCountsAnswersOneCountPerQuery();
void requestCountsKeepsPositionOnAnInvalidQuery();
void requestDatabaseStatsCountsMessagesNotThreads();
@@ -74,10 +81,12 @@ private:
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
QVector<MessageRef> messagesOfThread(const QString &threadId,
- const QString &matchQuery = QString());
+ const QString &matchQuery = QString(),
+ bool matchedOnly = false);
QVector<ThreadSummary> runQuery(
const QString &query,
- NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst);
+ NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst,
+ bool withRecipients = false);
QString threadIdOf(const QString &subject);
NotmuchFixture m_fixture;
@@ -114,17 +123,38 @@ void TestNotmuchWorker::initTestCase()
QStringLiteral("Thu, 4 Jun 2026 10:00:00 +0000"),
QStringLiteral("fourth message"), false));
+ // Thread D: in a "sent" folder, with real recipients. The To header is the
+ // only thing that distinguishes these from the threads above, and it is
+ // what the recipient fold reads.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("d1@example.org"),
+ QStringLiteral("Preventivo"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Fri, 5 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("fifth message"), false, QString(),
+ QStringLiteral("Mario Rossi <mario@example.org>")));
+
+ // Thread E: several recipients, one of them with a comma inside a quoted
+ // display name, which is what defeats splitting on commas.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("e1@example.org"),
+ QStringLiteral("Riunione"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Sat, 6 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("sixth message"), false, QString(),
+ QStringLiteral("\"Rossi, Mario\" <mario@example.org>, "
+ "info@example.net, "
+ "third@example.org")));
+
QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
}
QVector<ThreadSummary> TestNotmuchWorker::runQuery(
- const QString &query, NotmuchWorker::SortOrder sort)
+ const QString &query, NotmuchWorker::SortOrder sort, bool withRecipients)
{
NotmuchWorker worker(m_fixture.configPath());
QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
QSignalSpy finished(&worker, &NotmuchWorker::queryFinished);
- worker.runQuery(query, 1, sort);
+ worker.runQuery(query, 1, sort, withRecipients);
QVector<ThreadSummary> all;
for (const QList<QVariant> &args : ready)
@@ -143,11 +173,12 @@ QString TestNotmuchWorker::threadIdOf(const QString &subject)
}
QVector<MessageRef> TestNotmuchWorker::messagesOfThread(const QString &threadId,
- const QString &matchQuery)
+ const QString &matchQuery,
+ bool matchedOnly)
{
NotmuchWorker worker(m_fixture.configPath());
QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded);
- worker.loadThread(threadId, matchQuery, 1);
+ worker.loadThread(threadId, matchQuery, 1, matchedOnly);
if (loaded.isEmpty())
return {};
return loaded.first().at(0).value<QVector<MessageRef>>();
@@ -256,7 +287,7 @@ void TestNotmuchWorker::loadThreadTreeCarriesTheFactsARowNeeds()
void TestNotmuchWorker::queryReturnsAllThreads()
{
const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
- QCOMPARE(threads.size(), 3);
+ QCOMPARE(threads.size(), 5);
}
void TestNotmuchWorker::queryFiltersByTag()
@@ -325,7 +356,7 @@ void TestNotmuchWorker::queryPassesGenerationThrough()
QCOMPARE(ready.size(), 1);
QCOMPARE(ready.first().at(1).value<quint64>(), quint64(42));
QCOMPARE(finished.size(), 1);
- QCOMPARE(finished.first().at(0).toInt(), 3);
+ QCOMPARE(finished.first().at(0).toInt(), 5);
QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42));
}
@@ -513,7 +544,7 @@ void TestNotmuchWorker::queryStillWorksAfterWrite()
worker.runQuery(QStringLiteral("*"), 2);
QCOMPARE(ready.size(), 2);
- QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 3);
+ QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 5);
worker.applyTags(change.inverted());
}
@@ -611,6 +642,113 @@ void TestNotmuchWorker::requestAllTagsOnUnreadableConfigEmitsError()
QVERIFY(ready.isEmpty());
}
+void TestNotmuchWorker::loadThreadMatchedOnlyDropsTheRest()
+{
+ // Thread A is two messages, and only the reply carries "hamsterwheel".
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ // Without the flag: both messages, the non-matching one marked as a stub.
+ // This is the reading pane's normal behaviour and must not change.
+ const QVector<MessageRef> whole =
+ messagesOfThread(threadId, QStringLiteral("hamsterwheel"));
+ QCOMPARE(whole.size(), 2);
+
+ // With it: only the message that matched. The pane in a Sent view shows
+ // what the user sent, not the conversation their message started.
+ const QVector<MessageRef> matched =
+ messagesOfThread(threadId, QStringLiteral("hamsterwheel"), true);
+ QCOMPARE(matched.size(), 1);
+ QVERIFY(matched.at(0).matched);
+ QCOMPARE(matched.at(0).messageId, QStringLiteral("a2@example.org"));
+}
+
+void TestNotmuchWorker::loadThreadMatchedOnlyWithNoQueryKeepsEverything()
+{
+ // No query means nothing was filtered, so every message counts as matched
+ // and the flag has nothing to drop.
+ //
+ // This does NOT prove the haveMatchSet guard in loadThread: ref.matched is
+ // already true for every message in this case, so removing that guard
+ // leaves this passing, confirmed by mutation. It pins the BEHAVIOUR, which
+ // is what a caller depends on, and the guard is a stated invariant rather
+ // than a branch a test can reach.
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ const QVector<MessageRef> all = messagesOfThread(threadId, QString(), true);
+ QCOMPARE(all.size(), 2);
+}
+
+void TestNotmuchWorker::recipientsAreAbsentUnlessAskedFor()
+{
+ // Opt-in, and this is a PERFORMANCE contract rather than a preference.
+ // notmuch_message_get_header(m, "To") is not served from the index, it
+ // reads the message file: measured 2026-08-11 against a real database,
+ // folding every thread of a 4411-thread inbox took 38.2 seconds, 8.7 ms
+ // per thread, against 1.1 ms per thread over the 601-thread sent view.
+ //
+ // A version that always folds is correct in every other respect, which is
+ // exactly why it needs a test: nothing else here would notice.
+ const QVector<ThreadSummary> threads =
+ runQuery(QStringLiteral("subject:Preventivo"));
+
+ QCOMPARE(threads.size(), 1);
+ QVERIFY2(threads.at(0).recipients.isEmpty(),
+ "the To header was read for a query that never asked for it");
+}
+
+void TestNotmuchWorker::recipientsAreFoldedWhenAskedFor()
+{
+ const QVector<ThreadSummary> one =
+ runQuery(QStringLiteral("subject:Preventivo"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(one.size(), 1);
+ QCOMPARE(one.at(0).recipients, QStringLiteral("Mario Rossi"));
+
+ // The comma-inside-a-display-name case, end to end through the worker
+ // rather than only against recipientSummary(): the header survives being
+ // written to a real maildir, indexed, and read back out of notmuch.
+ const QVector<ThreadSummary> many =
+ runQuery(QStringLiteral("subject:Riunione"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(many.size(), 1);
+
+ const QString summary = many.at(0).recipients;
+ QVERIFY2(summary.startsWith(QStringLiteral("Rossi, Mario")),
+ qPrintable(QStringLiteral("lost the quoted display name: %1")
+ .arg(summary)));
+ QVERIFY2(summary.endsWith(QStringLiteral("+1")),
+ qPrintable(QStringLiteral("three recipients did not collapse to "
+ "two plus one: %1").arg(summary)));
+}
+
+void TestNotmuchWorker::recipientsCrossAQueuedCall()
+{
+ // The trap CLAUDE.md records for SortOrder, in the shape it takes for this
+ // argument. A bool is a registered metatype already, so this cannot fail
+ // the way an unregistered enum would, and the test exists to prove that
+ // rather than to assume it: the flag arriving as a default-constructed
+ // false would silently give an empty recipients column and nothing else.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+
+ QVERIFY(QMetaObject::invokeMethod(
+ &worker, "runQuery", Qt::DirectConnection,
+ Q_ARG(QString, QStringLiteral("subject:Preventivo")),
+ Q_ARG(quint64, 1),
+ Q_ARG(NotmuchWorker::SortOrder, NotmuchWorker::NewestFirst),
+ Q_ARG(bool, true)));
+
+ QVector<ThreadSummary> all;
+ for (const QList<QVariant> &args : ready)
+ all += args.at(0).value<QVector<ThreadSummary>>();
+
+ QCOMPARE(all.size(), 1);
+ QVERIFY2(!all.at(0).recipients.isEmpty(),
+ "the recipients flag was dropped crossing invokeMethod");
+}
+
void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
{
NotmuchWorker worker(m_fixture.configPath());
@@ -626,7 +764,7 @@ void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
// Threads, not messages: thread A holds two messages and must count once,
// which is the number the pane's "N in inbox" line claims to be showing.
const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
- QCOMPARE(counts, QVector<int>({ 1, 3, 0 }));
+ QCOMPARE(counts, QVector<int>({ 1, 5, 0 }));
}
void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
@@ -656,7 +794,7 @@ void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
// The queries either side keep their own answers, which is the property
// the pane depends on.
QCOMPARE(counts.at(0), 1);
- QCOMPARE(counts.at(2), 3);
+ QCOMPARE(counts.at(2), 5);
}
void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
@@ -676,8 +814,8 @@ void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
// one counts messages, which is what a user means by "how much mail". A
// reimplementation that reused the thread count would report 3 here and be
// confidently wrong under the label "messages".
- QCOMPARE(stats.messages, 4);
- QCOMPARE(stats.threads, 3);
+ QCOMPARE(stats.messages, 6);
+ QCOMPARE(stats.threads, 5);
QVERIFY2(stats.messages != stats.threads,
"messages and threads are equal, so this fixture cannot prove the "
"two counts are distinct: add a reply to it");
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 947a6b3..1fb8a1f 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -81,6 +81,9 @@ private slots:
void reconcileWithAnIdenticalResultChangesNothing();
void reconcileMovesAThreadBumpedByANewReply();
void reconcileKeepsAMovedRowsPersistentIndex();
+ void flatModeOffersNoExpanderAndNoReplyCount();
+ void flatModeIsOffByDefaultAndReversible();
+ void recipientsReplaceTheSenderWhenPresent();
};
static ThreadSummary makeThread(const QString &id, const QString &subject)
@@ -1420,5 +1423,106 @@ void TestThreadListModel::reconcileKeepsAMovedRowsPersistentIndex()
QCOMPARE(moved.row(), 2);
}
+void TestThreadListModel::flatModeOffersNoExpanderAndNoReplyCount()
+{
+ // A sent message lives on its own: the user's mental model of "what I sent"
+ // is a list, not a set of conversations, and a thread pulled in whole shows
+ // the replies they received under a view that claims to be their outbox.
+ //
+ // Deliberately not a second model or a filtered query. The expander is
+ // driven by hasChildren() and the card's count by ReplyCountRole, both
+ // already here, so flat mode is those two answering differently.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("Subject")) });
+
+ const QModelIndex thread = model.index(0, 0);
+ QVERIFY(thread.isValid());
+
+ // The tree shape, before anything is turned off. Guards the assertions
+ // below: a test whose subject was already flat would pass either way.
+ QVERIFY2(model.hasChildren(thread),
+ "the fixture thread is not expandable, so this proves nothing");
+ QCOMPARE(model.data(thread, ThreadListModel::ReplyCountRole).toInt(), 1);
+
+ model.setFlatMode(true);
+
+ QVERIFY2(!model.hasChildren(thread),
+ "a flat list still offered an expander");
+ QCOMPARE(model.data(thread, ThreadListModel::ReplyCountRole).toInt(), 0);
+
+ // rowCount has to agree, or the view draws an expander it cannot open, or
+ // opens onto rows the card said were not there.
+ QCOMPARE(model.rowCount(thread), 0);
+
+ // The thread itself is still a row. Flat means one row per thread, not
+ // fewer threads.
+ QCOMPARE(model.rowCount(), 1);
+}
+
+void TestThreadListModel::flatModeIsOffByDefaultAndReversible()
+{
+ // The whole condition the user set for this feature: it must not leak into
+ // any other view. Off by default is what guarantees that, and returning to
+ // false has to restore the tree rather than leaving the model flattened
+ // for the next query.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("Subject")) });
+
+ const QModelIndex thread = model.index(0, 0);
+ QVERIFY2(model.hasChildren(thread),
+ "a fresh model is flat, so every ordinary view lost its replies");
+
+ model.setFlatMode(true);
+ QVERIFY(!model.hasChildren(thread));
+
+ model.setFlatMode(false);
+ QVERIFY2(model.hasChildren(thread),
+ "leaving flat mode did not restore the tree");
+ QCOMPARE(model.data(thread, ThreadListModel::ReplyCountRole).toInt(), 1);
+}
+
+void TestThreadListModel::recipientsReplaceTheSenderWhenPresent()
+{
+ // In a Sent view the sender is the user on every row, so the card shows
+ // who it went TO instead. One role, so the delegate needs no branch and
+ // cannot disagree with the model about which name a row is showing.
+ ThreadListModel model;
+
+ ThreadSummary sent = makeThread(QStringLiteral("t1"),
+ QStringLiteral("Preventivo"));
+ sent.authors = QStringLiteral("You");
+ sent.recipients = QStringLiteral("Mario Rossi");
+
+ // No recipients: an ordinary view, where authors is the answer. Same
+ // fixture otherwise, so the difference is the field and nothing else.
+ ThreadSummary received = makeThread(QStringLiteral("t2"),
+ QStringLiteral("Newsletter"));
+ received.authors = QStringLiteral("Carol");
+
+ model.appendBatch({ sent, received });
+
+ QCOMPARE(model.data(model.index(0, 0),
+ ThreadListModel::SendersRole).toString(),
+ QStringLiteral("Mario Rossi"));
+ QCOMPARE(model.data(model.index(1, 0),
+ ThreadListModel::SendersRole).toString(),
+ QStringLiteral("Carol"));
+
+ // A thread whose To could not be parsed falls back rather than showing an
+ // empty name. The fold returns an empty string for a malformed or absent
+ // header, and a blank where a sender belongs reads as a rendering fault.
+ ThreadSummary unparseable = makeThread(QStringLiteral("t3"),
+ QStringLiteral("Broken"));
+ unparseable.authors = QStringLiteral("You");
+ unparseable.recipients = QString();
+ model.appendBatch({ unparseable });
+
+ QCOMPARE(model.data(model.index(2, 0),
+ ThreadListModel::SendersRole).toString(),
+ QStringLiteral("You"));
+}
+
QTEST_MAIN(TestThreadListModel)
#include "test_threadlistmodel.moc"