diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-04 10:39:47 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-04 12:54:41 +0200 |
| commit | f762e4ca0051a7a34123b5a526696f2feb5c6e03 (patch) | |
| tree | af4f3932946ae8937dd8f81ddaf95686e37105ca /tests/test_messageview.cpp | |
| parent | 1a8fdb48140cb40243bd46bff94d39f610d70ef3 (diff) | |
| download | qtmaildir-f762e4ca0051a7a34123b5a526696f2feb5c6e03.tar.gz qtmaildir-f762e4ca0051a7a34123b5a526696f2feb5c6e03.zip | |
feat(message): show From/To/Cc and add a details dialog
MimeParser has filled To and Cc all along and HtmlBuilder simply never
interpolated them, so both were parsed on every message and then
discarded. The header strip showed the subject and a message count and
nothing else, which is item 2 of the usability backlog.
The header now adapts to what it can say honestly. A thread holding one
message shows From, To and Cc under the subject, where every field is
unambiguous. A thread holding several keeps showing the subject and the
count alone: the recipient differs message to message, and once the user
has replied there is no single address the thread is addressed to, so
naming one would be a guess presented as a fact. Per-message detail is
what the dialog is for.
That dialog lists Subject, From, To, Cc, Date and Message-Id for every
message, numbered when there is more than one, in a read-only plain-text
widget. Plain text is the security decision, not a stylistic one: these
values come from strangers and the dialog exists to show them verbatim,
so the format that cannot interpret markup is the right one. The header
label is RichText and every value interpolated into it is escaped, since
an unescaped From injects into the application's own chrome rather than
into the sandboxed page.
Reached by a Details... button beside the subject and by Ctrl+Shift+D.
Both, because a shortcut alone restates the complaint this backlog opened
with. The binding is shifted because Ctrl+D is delete, and the
destructive action keeps the key it already had rather than being moved
to make room.
An empty Cc omits its row instead of printing a label with nothing after
it. Both header shapes were rendered to PNG and inspected, not only
asserted.
The new button also exposed a latent flaw in an older test:
attachmentButtonLabels() identified attachment buttons by excluding the
one other button's label, so it counted the details button as an
attachment as soon as one existed. It now finds the bar by object name
and reads only its children.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_messageview.cpp')
| -rw-r--r-- | tests/test_messageview.cpp | 166 |
1 files changed, 159 insertions, 7 deletions
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp index 278270a..57d7b42 100644 --- a/tests/test_messageview.cpp +++ b/tests/test_messageview.cpp @@ -16,6 +16,7 @@ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ +#include <QLabel> #include <QPushButton> #include <QSignalSpy> #include <QWebEngineUrlScheme> @@ -41,6 +42,11 @@ private slots: void zoomSurvivesANewDocument(); void attachmentBarOffersEveryAttachment(); void attachmentBarClearsBetweenThreads(); + void singleMessageHeaderShowsFromToAndCc(); + void threadHeaderShowsOnlySubjectAndCount(); + void headerEscapesUntrustedValues(); + void headerOmitsAnAbsentCc(); + void detailsDialogIsOfferedForEveryThread(); private: QWebEngineView *webViewOf(MessageView *view) const @@ -233,16 +239,20 @@ void TestMessageView::zoomSurvivesANewDocument() QCOMPARE(view.zoomFactor(), 1.5); } -/// The buttons in the attachment bar, by their label. Excludes the -/// "Load remote content" button, which lives in the same pane but is not part -/// of the bar. +/// The buttons in the attachment bar, by their label. +/// +/// Identified by the bar being their parent, not by excluding the labels of +/// the other buttons in the pane: an exclusion list silently adopts every +/// button added later, and it did, counting the details button as an +/// attachment the moment one was added beside the header. static QStringList attachmentButtonLabels(MessageView *view) { QStringList labels; - for (QPushButton *button : view->findChildren<QPushButton *>()) { - if (button->text() != QStringLiteral("Load remote content")) - labels.append(button->text()); - } + QWidget *bar = view->findChild<QWidget *>(QStringLiteral("attachmentBar")); + if (!bar) + return labels; + for (QPushButton *button : bar->findChildren<QPushButton *>()) + labels.append(button->text()); return labels; } @@ -340,5 +350,147 @@ void TestMessageView::attachmentBarClearsBetweenThreads() QVERIFY(attachmentButtonLabels(&view).isEmpty()); } +/// The header strip's text. It is rich text, so the assertions below are +/// against markup as well as content. +static QString headerTextOf(MessageView *view) +{ + for (QLabel *label : view->findChildren<QLabel *>()) { + if (label->textFormat() == Qt::RichText) + return label->text(); + } + return QString(); +} + +/// One message, from the same shape the other tests build. +static ThreadRenderItem oneMessage() +{ + ParsedMessage message; + message.ok = true; + message.from = QStringLiteral("Sender <sender@example.org>"); + message.to = QStringLiteral("Recipient <recipient@example.org>"); + message.cc = QStringLiteral("Copied <copied@example.org>"); + message.subject = QStringLiteral("Quarterly report"); + message.date = QStringLiteral("Mon, 4 Aug 2026 09:00:00 +0200"); + message.plainBody = QStringLiteral("body"); + + ThreadRenderItem item; + item.message = message; + item.cidPrefix = QStringLiteral("m0"); + item.expanded = true; + return item; +} + +void TestMessageView::singleMessageHeaderShowsFromToAndCc() +{ + // MimeParser filled To and Cc all along; HtmlBuilder simply never + // interpolated them, so they were parsed and dropped. With one message in + // the thread every field is unambiguous, which is why this is the case that + // shows them. + MessageView view; + view.showThread({ oneMessage() }); + + const QString header = headerTextOf(&view); + QVERIFY2(header.contains(QStringLiteral("sender@example.org")), + qPrintable(QStringLiteral("no From in '%1'").arg(header))); + QVERIFY2(header.contains(QStringLiteral("recipient@example.org")), + qPrintable(QStringLiteral("no To in '%1'").arg(header))); + QVERIFY2(header.contains(QStringLiteral("copied@example.org")), + qPrintable(QStringLiteral("no Cc in '%1'").arg(header))); + QVERIFY(header.contains(QStringLiteral("Quarterly report"))); +} + +void TestMessageView::threadHeaderShowsOnlySubjectAndCount() +{ + // A thread's To differs per message: once the user replies, one message is + // addressed to them and the next to the other party. Rather than pick a + // message arbitrarily or compute a participants list, the thread header + // says only what it can say honestly. Per-message detail is the dialog's + // job. This test is what stops a recipient line reappearing here. + ThreadRenderItem first = oneMessage(); + + ThreadRenderItem second = oneMessage(); + second.message.from = QStringLiteral("Recipient <recipient@example.org>"); + second.message.to = QStringLiteral("Sender <sender@example.org>"); + second.message.cc = QString(); + second.cidPrefix = QStringLiteral("m1"); + + MessageView view; + view.showThread({ first, second }); + + const QString header = headerTextOf(&view); + QVERIFY(header.contains(QStringLiteral("Quarterly report"))); + QVERIFY2(!header.contains(QStringLiteral("recipient@example.org")), + qPrintable(QStringLiteral("a recipient leaked into '%1'") + .arg(header))); + QVERIFY2(!header.contains(QStringLiteral("copied@example.org")), + qPrintable(QStringLiteral("a Cc leaked into '%1'").arg(header))); +} + +void TestMessageView::headerEscapesUntrustedValues() +{ + // Every one of these values comes from a stranger, and the label is + // Qt::RichText, so an unescaped From is markup injection into the chrome of + // the application rather than into the sandboxed page. + ThreadRenderItem item = oneMessage(); + item.message.from = + QStringLiteral("<b>bold</b> <script>x</script> <evil@example.org>"); + item.message.to = QStringLiteral("<i>italic</i> <to@example.org>"); + item.message.cc = QStringLiteral("<u>under</u> <cc@example.org>"); + item.message.subject = QStringLiteral("<h1>huge</h1>"); + + MessageView view; + view.showThread({ item }); + + const QString header = headerTextOf(&view); + QVERIFY2(!header.contains(QStringLiteral("<b>bold</b>")), + qPrintable(QStringLiteral("unescaped From in '%1'").arg(header))); + QVERIFY(!header.contains(QStringLiteral("<script>"))); + QVERIFY(!header.contains(QStringLiteral("<i>italic</i>"))); + QVERIFY(!header.contains(QStringLiteral("<u>under</u>"))); + QVERIFY(!header.contains(QStringLiteral("<h1>huge</h1>"))); + + // Escaped, not merely stripped: the text must still be readable. + QVERIFY(header.contains(QStringLiteral("<b>bold</b>"))); +} + +void TestMessageView::headerOmitsAnAbsentCc() +{ + // Most mail carries no Cc. An empty label with nothing after it reads as a + // rendering fault, so the row is omitted rather than left blank. + ThreadRenderItem item = oneMessage(); + item.message.cc = QString(); + + MessageView view; + view.showThread({ item }); + + const QString header = headerTextOf(&view); + QVERIFY(header.contains(QStringLiteral("recipient@example.org"))); + QVERIFY2(!header.contains(QStringLiteral("Cc")), + qPrintable(QStringLiteral("empty Cc row left in '%1'") + .arg(header))); +} + +void TestMessageView::detailsDialogIsOfferedForEveryThread() +{ + // The button is the discoverable half of the feature: the shortcut alone + // repeats the complaint that started this backlog. It must be present for a + // thread as well as a single message, since a thread is exactly the case + // where the header withholds the most. + MessageView view; + view.showThread({ oneMessage() }); + QVERIFY(view.findChild<QPushButton *>(QStringLiteral("messageDetails"))); + + ThreadRenderItem second = oneMessage(); + second.cidPrefix = QStringLiteral("m1"); + view.showThread({ oneMessage(), second }); + QVERIFY(view.findChild<QPushButton *>(QStringLiteral("messageDetails"))); + + // And it goes away when there is nothing to describe. + view.clear(); + QPushButton *button = + view.findChild<QPushButton *>(QStringLiteral("messageDetails")); + QVERIFY(!button || !button->isVisible()); +} + QTEST_MAIN(TestMessageView) #include "test_messageview.moc" |
