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 /src | |
| 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 'src')
| -rw-r--r-- | src/keymap.cpp | 5 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 6 | ||||
| -rw-r--r-- | src/messageview.cpp | 115 | ||||
| -rw-r--r-- | src/messageview.h | 11 |
4 files changed, 132 insertions, 5 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp index d8aacc5..b18f5d5 100644 --- a/src/keymap.cpp +++ b/src/keymap.cpp @@ -36,6 +36,7 @@ QStringList KeyMap::knownActions() QStringLiteral("complete_query"), QStringLiteral("toggle_html"), QStringLiteral("load_remote"), + QStringLiteral("message_details"), QStringLiteral("zoom_in"), QStringLiteral("zoom_out"), QStringLiteral("zoom_reset"), @@ -71,6 +72,10 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings() { QStringLiteral("Ctrl+Space"), QStringLiteral("complete_query") }, { QStringLiteral("Ctrl+H"), QStringLiteral("toggle_html") }, { QStringLiteral("Ctrl+M"), QStringLiteral("load_remote") }, + // Shifted because Ctrl+D is delete. Both are "D for details/delete" + // words, and the destructive one keeps the unshifted key it already + // had rather than being moved to make room. + { QStringLiteral("Ctrl+Shift+D"), QStringLiteral("message_details") }, // Ctrl++ is what the '+' key really delivers on a layout where '+' is // unshifted, an Italian one among them, confirmed against the actual // keyboard. QTest::keyClick() cannot reproduce it, so a synthetic-input diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0f169d9..a0ae7c4 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -458,6 +458,11 @@ void MainWindow::registerActions() tr("Load remote images for the current thread"), [this]() { m_messageView->loadRemoteContent(); }); + addAction(QStringLiteral("message_details"), tr("Message &details"), + tr("Show the full headers of every message in the thread"), + [this]() { + m_messageView->showDetailsDialog(); + }); addAction(QStringLiteral("zoom_in"), tr("Zoom &in"), tr("Enlarge the message text"), [this]() { m_messageView->zoomIn(); @@ -539,6 +544,7 @@ void MainWindow::buildMenus() viewMenu->addSeparator(); viewMenu->addAction(m_actions.value(QStringLiteral("toggle_html"))); viewMenu->addAction(m_actions.value(QStringLiteral("load_remote"))); + viewMenu->addAction(m_actions.value(QStringLiteral("message_details"))); viewMenu->addSeparator(); viewMenu->addAction(m_actions.value(QStringLiteral("zoom_in"))); viewMenu->addAction(m_actions.value(QStringLiteral("zoom_out"))); diff --git a/src/messageview.cpp b/src/messageview.cpp index 4423791..d7167bf 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -22,6 +22,8 @@ #include <QDesktopServices> #include <QDialog> #include <QDialogButtonBox> +#include <QFontDatabase> +#include <QPlainTextEdit> #include <QDir> #include <QFileDialog> #include <QHBoxLayout> @@ -124,6 +126,22 @@ MessageView::MessageView(QWidget *parent) m_headerLabel->setWordWrap(true); m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + // To the right of the header, per the user's decision: the summary answers + // "who is this from", this answers "what actually happened to it". A button + // and not only a shortcut, since "everything needs a memorized key" is the + // complaint this whole backlog started from. + m_detailsButton = new QPushButton(tr("Details..."), this); + m_detailsButton->setObjectName(QStringLiteral("messageDetails")); + m_detailsButton->setToolTip(tr("Show the full headers of every message")); + connect(m_detailsButton, &QPushButton::clicked, + this, &MessageView::showDetailsDialog); + m_detailsButton->hide(); + + auto *headerRow = new QHBoxLayout; + headerRow->addWidget(m_headerLabel, 1); + // Top-aligned so it stays put as the header grows to four rows. + headerRow->addWidget(m_detailsButton, 0, Qt::AlignTop); + m_blockedLabel = new QLabel(tr("Remote content blocked"), this); m_loadRemoteButton = new QPushButton(tr("Load remote content"), this); connect(m_loadRemoteButton, &QPushButton::clicked, @@ -135,6 +153,7 @@ MessageView::MessageView(QWidget *parent) blockedRow->addStretch(); m_attachmentBar = new QWidget(this); + m_attachmentBar->setObjectName(QStringLiteral("attachmentBar")); new QHBoxLayout(m_attachmentBar); // Tags live under the message rather than in the thread list, where @@ -143,7 +162,7 @@ MessageView::MessageView(QWidget *parent) m_tagStrip->hide(); auto *layout = new QVBoxLayout(this); - layout->addWidget(m_headerLabel); + layout->addLayout(headerRow); layout->addLayout(blockedRow); layout->addWidget(m_view, 1); layout->addWidget(m_attachmentBar); @@ -260,17 +279,103 @@ void MessageView::updateHeader() { if (m_items.isEmpty()) { m_headerLabel->clear(); + m_detailsButton->hide(); return; } + m_detailsButton->show(); + // The thread's subject comes from its first message; later replies carry // Re: prefixes that add nothing. const QString subject = m_items.first().message.subject; - m_headerLabel->setText( - QStringLiteral("<b>%1</b><br><small>%2</small>") - .arg(subject.toHtmlEscaped(), - tr("%n message(s) in thread", "", m_items.size()))); + QString text = QStringLiteral("<b>%1</b>").arg(subject.toHtmlEscaped()); + + // The header adapts to what it can say honestly. From, To and Cc are + // per-message, and the pane shows a whole thread, so they are only + // unambiguous when the thread holds exactly one message. For a real thread + // the recipient differs message to message (once the user replies, one is + // addressed to them and the next to the other party), and neither the union + // nor the intersection is "the" recipient. Rather than pick one or compute + // a participants list, the thread case says only the subject and the count, + // and the per-message detail belongs to the dialog. + if (m_items.size() == 1) { + const ParsedMessage &message = m_items.first().message; + + // Every value here is attacker-controlled and the label is RichText, so + // escaping is not cosmetic: an unescaped From injects markup into the + // application's own chrome rather than into the sandboxed page. + auto row = [&text](const QString &label, const QString &value) { + if (value.isEmpty()) + return; // An empty row reads as a rendering fault. + text += QStringLiteral("<br><small>%1 %2</small>") + .arg(label.toHtmlEscaped(), value.toHtmlEscaped()); + }; + + row(tr("From:"), message.from); + row(tr("To:"), message.to); + row(tr("Cc:"), message.cc); + } else { + text += QStringLiteral("<br><small>%1</small>") + .arg(tr("%n message(s) in thread", "", m_items.size())); + } + + m_headerLabel->setText(text); +} + +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); + dialog.exec(); } void MessageView::render() diff --git a/src/messageview.h b/src/messageview.h index 244a1ba..1be17f8 100644 --- a/src/messageview.h +++ b/src/messageview.h @@ -65,6 +65,15 @@ public: /// Tags of the thread on display, shown as chips along the bottom. void setTags(const QStringList &tags); + /// The full headers of every message in the thread, read-only. Also + /// reachable from the button beside the header; public so the window's + /// message_details action can call it. + /// + /// Plain text, not rich: header values are attacker-controlled and this + /// dialog exists to show them verbatim, so the format that cannot + /// interpret markup is the right one. + void showDetailsDialog(); + /// The body zoom factor. Chromium's own range is roughly 0.25 to 5.0; /// these are tighter, since a pane at either extreme is unusable and the /// only visible way back is a menu entry the user cannot read. @@ -126,6 +135,7 @@ private: /// Every attachment in the thread, in the order the messages render. QList<Attachment> allAttachments() const; + QList<ThreadRenderItem> m_items; bool m_preferHtml = true; @@ -137,6 +147,7 @@ private: QLabel *m_headerLabel = nullptr; QLabel *m_blockedLabel = nullptr; QPushButton *m_loadRemoteButton = nullptr; + QPushButton *m_detailsButton = nullptr; QWidget *m_attachmentBar = nullptr; TagStrip *m_tagStrip = nullptr; }; |
