summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/messagedetailsdialog.cpp153
-rw-r--r--src/messagedetailsdialog.h87
-rw-r--r--src/messageview.cpp55
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_messagedetailsdialog.cpp182
6 files changed, 430 insertions, 49 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 382f3b8..ee1f621 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -22,6 +22,7 @@ add_library(qtmaildir_lib STATIC
syncmonitor.cpp
threadcidmap.cpp
messageview.cpp
+ messagedetailsdialog.cpp
mainwindow.cpp
querycompleter.cpp
rulequery.cpp
diff --git a/src/messagedetailsdialog.cpp b/src/messagedetailsdialog.cpp
new file mode 100644
index 0000000..058814b
--- /dev/null
+++ b/src/messagedetailsdialog.cpp
@@ -0,0 +1,153 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "messagedetailsdialog.h"
+
+#include <QDialogButtonBox>
+#include <QFontDatabase>
+#include <QGridLayout>
+#include <QLabel>
+#include <QMenu>
+#include <QScrollArea>
+#include <QVBoxLayout>
+
+#include "mimeparser.h"
+#include "searchterm.h"
+
+MessageDetailsDialog::MessageDetailsDialog(const QList<ThreadRenderItem> &items,
+ QWidget *parent)
+ : QDialog(parent)
+{
+ setWindowTitle(tr("Message details"));
+ setObjectName(QStringLiteral("messageDetailsDialog"));
+
+ buildRows(items);
+
+ auto *layout = new QVBoxLayout(this);
+
+ // Scrollable: a long thread has many rows, and the dialog must not grow
+ // past the screen to show them.
+ auto *scroll = new QScrollArea(this);
+ scroll->setWidgetResizable(true);
+ auto *content = new QWidget(scroll);
+ auto *grid = new QGridLayout(content);
+
+ // A monospaced value keeps a long id or address readable as the record it
+ // is, which is what the text box did well and is worth carrying over.
+ const QFont fixed = QFontDatabase::systemFont(QFontDatabase::FixedFont);
+
+ int gridRow = 0;
+ int lastMessage = -1;
+ for (const HeaderRow &row : std::as_const(m_rows)) {
+ if (items.size() > 1 && row.messageIndex != lastMessage) {
+ lastMessage = row.messageIndex;
+ auto *heading = new QLabel(
+ tr("Message %1 of %2").arg(row.messageIndex + 1)
+ .arg(items.size()),
+ content);
+ heading->setTextFormat(Qt::PlainText);
+ QFont headingFont = heading->font();
+ headingFont.setBold(true);
+ heading->setFont(headingFont);
+ grid->addWidget(heading, gridRow, 0, 1, 2);
+ ++gridRow;
+ }
+
+ auto *label = new QLabel(row.label, content);
+ label->setTextFormat(Qt::PlainText);
+ label->setAlignment(Qt::AlignTop | Qt::AlignLeft);
+ grid->addWidget(label, gridRow, 0);
+
+ // PlainText stated, not inferred. A QLabel guesses under AutoText, and
+ // this value came from a stranger.
+ auto *value = new QLabel(row.value, content);
+ value->setTextFormat(Qt::PlainText);
+ value->setFont(fixed);
+ value->setWordWrap(true);
+ value->setTextInteractionFlags(Qt::TextSelectableByMouse);
+
+ if (!row.query.isEmpty()) {
+ value->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(value, &QWidget::customContextMenuRequested, this,
+ [this, value, row](const QPoint &pos) {
+ QMenu menu(this);
+ auto *replace = menu.addAction(tr("Search for this"));
+ connect(replace, &QAction::triggered, this,
+ [this, row]() { requestSearch(row, false); });
+ auto *narrow = menu.addAction(tr("Add to search"));
+ connect(narrow, &QAction::triggered, this,
+ [this, row]() { requestSearch(row, true); });
+ menu.exec(value->mapToGlobal(pos));
+ });
+ }
+
+ grid->addWidget(value, gridRow, 1);
+ ++gridRow;
+ }
+
+ grid->setColumnStretch(1, 1);
+ grid->setRowStretch(gridRow, 1);
+ scroll->setWidget(content);
+ layout->addWidget(scroll);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ resize(700, 400);
+}
+
+void MessageDetailsDialog::buildRows(const QList<ThreadRenderItem> &items)
+{
+ for (int i = 0; i < items.size(); ++i) {
+ const ParsedMessage &message = items.at(i).message;
+
+ auto add = [this, i](const QString &field, const QString &label,
+ const QString &value, const QString &query) {
+ if (value.isEmpty())
+ return; // An empty row reads as a rendering fault.
+ m_rows.append({ field, label, value, query, i });
+ };
+
+ add(QStringLiteral("subject"), tr("Subject:"), message.subject,
+ SearchTerm::field(QStringLiteral("subject"), message.subject));
+ add(QStringLiteral("from"), tr("From:"), message.from,
+ SearchTerm::field(QStringLiteral("from"), message.from));
+ add(QStringLiteral("to"), tr("To:"), message.to,
+ SearchTerm::field(QStringLiteral("to"), message.to));
+ add(QStringLiteral("cc"), tr("Cc:"), message.cc,
+ SearchTerm::field(QStringLiteral("cc"), message.cc));
+
+ // The raw header is shown, but the query is a one-day range: a text
+ // match on an RFC 2822 string would match almost nothing.
+ const QDateTime sent = MimeParser::parseDate(message.date);
+ add(QStringLiteral("date"), tr("Date:"), message.date,
+ sent.isValid() ? SearchTerm::onDate(sent.date()) : QString());
+
+ // Shown but not searchable: an id names one message, and the thread
+ // holding it is already on screen.
+ add(QString(), tr("Message-Id:"), message.messageId, QString());
+ }
+}
+
+void MessageDetailsDialog::requestSearch(const HeaderRow &row, bool extend)
+{
+ if (row.query.isEmpty())
+ return;
+ emit searchRequested(row.query, extend);
+}
diff --git a/src/messagedetailsdialog.h b/src/messagedetailsdialog.h
new file mode 100644
index 0000000..f865913
--- /dev/null
+++ b/src/messagedetailsdialog.h
@@ -0,0 +1,87 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QList>
+#include <QString>
+
+#include "htmlbuilder.h"
+
+/// One header of one message, as shown and as searched for.
+///
+/// The query is built when the row is, from the parsed value, so nothing has
+/// to parse displayed text back into structure. That is the whole reason this
+/// dialog stopped being a text box.
+struct HeaderRow
+{
+ /// notmuch's field name, or empty for a header with no searchable form.
+ /// Wire format, never translated.
+ QString field;
+
+ /// Translated label shown at the start of the row, e.g. "From:".
+ QString label;
+
+ /// The header's value, verbatim and untrusted.
+ QString value;
+
+ /// The finished query, empty when the header has no searchable form.
+ QString query;
+
+ /// Which message of the thread this row belongs to, zero-based.
+ int messageIndex = 0;
+};
+
+/// The full headers of every message in a thread, read-only.
+///
+/// Rows rather than one text box, so a value can carry its own context menu
+/// without anything parsing rendered text back into structure. The user also
+/// asked not to be shown a text box.
+///
+/// **Every value label is explicitly `Qt::PlainText`.** This replaced a
+/// `QPlainTextEdit` whose plain-textness was a security property rather than a
+/// style: header values come from strangers, and plain text cannot interpret
+/// markup, so there is nothing to escape and nothing that can render. A QLabel
+/// guesses under `Qt::AutoText`, so stating the format is what preserves that.
+class MessageDetailsDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ explicit MessageDetailsDialog(const QList<ThreadRenderItem> &items,
+ QWidget *parent = nullptr);
+
+ /// The rows on display, in order. Exposed for testing without rendering.
+ QList<HeaderRow> rows() const { return m_rows; }
+
+ /// Emits searchRequested for `row`, or nothing when the row carries no
+ /// searchable query. The menu entries call this; a test can too, without
+ /// popping a menu.
+ void requestSearch(const HeaderRow &row, bool extend);
+
+signals:
+ /// The user chose a search from a row's menu. `extend` narrows the current
+ /// query rather than replacing it.
+ void searchRequested(const QString &query, bool extend);
+
+private:
+ /// Builds the rows from the thread, one group per message.
+ void buildRows(const QList<ThreadRenderItem> &items);
+
+ QList<HeaderRow> m_rows;
+};
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 5f7c9d2..fbd43d6 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -22,8 +22,6 @@
#include <QDesktopServices>
#include <QDialog>
#include <QDialogButtonBox>
-#include <QFontDatabase>
-#include <QPlainTextEdit>
#include <QDir>
#include <QBuffer>
#include <QFileDialog>
@@ -50,6 +48,7 @@
#include "cidschemehandler.h"
#include "htmlbuilder.h"
+#include "messagedetailsdialog.h"
#include "requestinterceptor.h"
#include "searchterm.h"
#include "tagstrip.h"
@@ -616,53 +615,11 @@ void MessageView::showDetailsDialog()
if (m_items.isEmpty())
return;
- QDialog dialog(this);
- dialog.setWindowTitle(tr("Message details"));
-
- auto *layout = new QVBoxLayout(&dialog);
-
- auto *details = new QPlainTextEdit(&dialog);
- details->setReadOnly(true);
- // A monospaced font keeps a long Received chain readable as the wrapped
- // record it is.
- details->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
- details->setLineWrapMode(QPlainTextEdit::NoWrap);
-
- // setPlainText, and a QPlainTextEdit rather than a label: this dialog shows
- // header values verbatim, and those come from strangers. Plain text cannot
- // interpret markup, so there is nothing here to escape and nothing that
- // could render.
- QString text;
- for (int i = 0; i < m_items.size(); ++i) {
- const ParsedMessage &message = m_items.at(i).message;
-
- if (i > 0)
- text += QLatin1Char('\n');
- if (m_items.size() > 1)
- text += tr("--- Message %1 of %2 ---")
- .arg(i + 1).arg(m_items.size()) + QLatin1Char('\n');
-
- auto line = [&text](const QString &label, const QString &value) {
- if (!value.isEmpty())
- text += label + QLatin1Char(' ') + value + QLatin1Char('\n');
- };
-
- line(tr("Subject:"), message.subject);
- line(tr("From:"), message.from);
- line(tr("To:"), message.to);
- line(tr("Cc:"), message.cc);
- line(tr("Date:"), message.date);
- line(tr("Message-Id:"), message.messageId);
- }
- details->setPlainText(text);
-
- layout->addWidget(details);
-
- auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog);
- connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
- layout->addWidget(buttons);
-
- dialog.resize(700, 400);
+ MessageDetailsDialog dialog(m_items, this);
+ // The dialog's searches are the pane's searches: one signal reaches the
+ // window whichever surface the user used.
+ connect(&dialog, &MessageDetailsDialog::searchRequested,
+ this, &MessageView::searchRequested);
dialog.exec();
}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 68148ee..5f7bd48 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -56,3 +56,4 @@ add_qtmaildir_test(tagrules)
add_qtmaildir_test(rulequery)
add_qtmaildir_test(searchterm)
add_qtmaildir_test(tagstrip)
+add_qtmaildir_test(messagedetailsdialog)
diff --git a/tests/test_messagedetailsdialog.cpp b/tests/test_messagedetailsdialog.cpp
new file mode 100644
index 0000000..4e685ce
--- /dev/null
+++ b/tests/test_messagedetailsdialog.cpp
@@ -0,0 +1,182 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QLabel>
+#include <QSignalSpy>
+#include <QtTest>
+
+#include "htmlbuilder.h"
+#include "messagedetailsdialog.h"
+
+/// The details dialog, which shows every header of every message in a thread.
+///
+/// Rows rather than one text box since item 85, so a value can carry its own
+/// context menu without anything parsing rendered text back into structure.
+class TestMessageDetailsDialog : public QObject
+{
+ Q_OBJECT
+private slots:
+ void showsEveryHeaderOfEveryMessage();
+ void valueLabelsCannotRenderMarkup();
+ void offersASearchForEachValue();
+ void omitsAnEmptyHeader();
+ void messageIdIsShownButNotSearchable();
+
+private:
+ /// One message, with every header populated. The date's weekday matches
+ /// the date: Qt::RFC2822Date validates the two against each other, and
+ /// 2026-08-14 is a Friday.
+ ThreadRenderItem oneMessage() const
+ {
+ ThreadRenderItem item;
+ item.message.ok = true;
+ item.message.subject = QStringLiteral("Quarterly report");
+ item.message.from = QStringLiteral("Sender <sender@example.org>");
+ item.message.to = QStringLiteral("Recipient <recipient@example.org>");
+ item.message.cc = QStringLiteral("Copied <copied@example.org>");
+ item.message.date = QStringLiteral("Fri, 14 Aug 2026 09:30:00 +0200");
+ item.message.messageId = QStringLiteral("<abc123@example.org>");
+ return item;
+ }
+};
+
+void TestMessageDetailsDialog::showsEveryHeaderOfEveryMessage()
+{
+ ThreadRenderItem second = oneMessage();
+ second.message.subject = QStringLiteral("Re: Quarterly report");
+
+ MessageDetailsDialog dialog({ oneMessage(), second });
+
+ const QList<HeaderRow> rows = dialog.rows();
+ QVERIFY2(!rows.isEmpty(), "no rows: the dialog was never populated");
+
+ // Both messages are represented, each row knowing which one it belongs to.
+ QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.messageIndex == 0;
+ }));
+ QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.messageIndex == 1;
+ }));
+
+ QStringList values;
+ for (const HeaderRow &row : rows)
+ values << row.value;
+ QVERIFY(values.contains(QStringLiteral("Sender <sender@example.org>")));
+ QVERIFY(values.contains(QStringLiteral("Re: Quarterly report")));
+ QVERIFY(values.contains(QStringLiteral("<abc123@example.org>")));
+}
+
+void TestMessageDetailsDialog::valueLabelsCannotRenderMarkup()
+{
+ // The QPlainTextEdit this replaced was plain by DESIGN, not by style:
+ // header values come from strangers and plain text cannot interpret
+ // markup. A QLabel guesses under Qt::AutoText, so every label states its
+ // format rather than relying on escaping, which is the same protection one
+ // mistake away from failing.
+ ThreadRenderItem hostile = oneMessage();
+ hostile.message.subject =
+ QStringLiteral("<b>bold</b><img src=x onerror=1>");
+
+ MessageDetailsDialog dialog({ hostile });
+
+ const QList<QLabel *> labels = dialog.findChildren<QLabel *>();
+ QVERIFY2(!labels.isEmpty(), "no labels: the dialog was never populated");
+
+ bool sawTheSubject = false;
+ for (const QLabel *label : labels) {
+ QCOMPARE(label->textFormat(), Qt::PlainText);
+ if (label->text().contains(QStringLiteral("<b>bold</b>")))
+ sawTheSubject = true;
+ }
+
+ // The markup survives AS TEXT, which is the proof it was not interpreted.
+ QVERIFY2(sawTheSubject, "the hostile subject never reached a label");
+}
+
+void TestMessageDetailsDialog::offersASearchForEachValue()
+{
+ MessageDetailsDialog dialog({ oneMessage() });
+
+ QSignalSpy spy(&dialog, &MessageDetailsDialog::searchRequested);
+ QVERIFY(spy.isValid());
+
+ const QList<HeaderRow> rows = dialog.rows();
+ const auto from = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("from");
+ });
+ QVERIFY2(from != rows.cend(), "no From row to search from");
+ QCOMPARE(from->query, QStringLiteral("from:\"Sender <sender@example.org>\""));
+
+ // The date becomes a one-day range rather than a text match on the header.
+ const auto date = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("date");
+ });
+ QVERIFY2(date != rows.cend(), "no Date row");
+ QCOMPARE(date->query, QStringLiteral("date:2026-08-14..2026-08-14"));
+
+ // Replacing and narrowing are both offered, and the flag distinguishes them.
+ dialog.requestSearch(*from, false);
+ dialog.requestSearch(*from, true);
+ QCOMPARE(spy.count(), 2);
+ QCOMPARE(spy.at(0).at(0).toString(), from->query);
+ QCOMPARE(spy.at(0).at(1).toBool(), false);
+ QCOMPARE(spy.at(1).at(1).toBool(), true);
+}
+
+void TestMessageDetailsDialog::omitsAnEmptyHeader()
+{
+ ThreadRenderItem noCc = oneMessage();
+ noCc.message.cc.clear();
+
+ MessageDetailsDialog dialog({ noCc });
+
+ const QList<HeaderRow> rows = dialog.rows();
+ // Guard first: an absence assertion alone passes against no implementation.
+ QVERIFY2(!rows.isEmpty(), "no rows: the dialog was never populated");
+ QVERIFY(std::any_of(rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("from");
+ }));
+
+ for (const HeaderRow &row : rows)
+ QVERIFY(row.field != QStringLiteral("cc"));
+}
+
+void TestMessageDetailsDialog::messageIdIsShownButNotSearchable()
+{
+ // A message id names one message, and the thread holding it is already on
+ // screen, so there is nothing useful to search for. It is still shown.
+ MessageDetailsDialog dialog({ oneMessage() });
+
+ const QList<HeaderRow> rows = dialog.rows();
+ const auto id = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.value == QStringLiteral("<abc123@example.org>");
+ });
+ QVERIFY2(id != rows.cend(), "the message id is not shown at all");
+ QVERIFY(id->query.isEmpty());
+
+ // And asking to search it emits nothing rather than an empty query.
+ QSignalSpy spy(&dialog, &MessageDetailsDialog::searchRequested);
+ dialog.requestSearch(*id, false);
+ QCOMPARE(spy.count(), 0);
+}
+
+QTEST_MAIN(TestMessageDetailsDialog)
+#include "test_messagedetailsdialog.moc"