aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp19
-rw-r--r--src/messageview.cpp192
-rw-r--r--src/messageview.h25
-rw-r--r--src/mimeparser.cpp103
-rw-r--r--src/mimeparser.h29
-rw-r--r--src/threadlistmodel.cpp35
-rw-r--r--src/threadlistmodel.h10
-rw-r--r--src/types.h7
8 files changed, 416 insertions, 4 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 2a79988..1385f01 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -100,9 +100,18 @@ void MainWindow::restoreUiState()
m_splitter->restoreState(splitter);
}
+ // A header blob saved against a different set of columns must be
+ // discarded, not restored. QHeaderView::restoreState() returns TRUE for a
+ // blob with fewer sections than the model and applies the old widths to
+ // the wrong columns: adding the attachment column in front shifted every
+ // saved width one place right, silently mangling the layout with no error
+ // to detect it by (verified on Qt 6.11). The column count is stored
+ // alongside and the blob is only used when it still matches.
const QByteArray header = state.value(QStringLiteral("threadlist/header"))
.toByteArray();
- if (!header.isEmpty()) {
+ const int savedColumns =
+ state.value(QStringLiteral("threadlist/columns")).toInt();
+ if (!header.isEmpty() && savedColumns == ThreadListModel::ColumnCount) {
m_threadView->horizontalHeader()->restoreState(header);
}
@@ -123,6 +132,9 @@ void MainWindow::saveUiState() const
state.setValue(QStringLiteral("window/splitter"), m_splitter->saveState());
state.setValue(QStringLiteral("threadlist/header"),
m_threadView->horizontalHeader()->saveState());
+ // Guards the blob above: see restoreUiState().
+ state.setValue(QStringLiteral("threadlist/columns"),
+ int(ThreadListModel::ColumnCount));
state.setValue(QStringLiteral("message/zoom"), m_messageView->zoomFactor());
}
@@ -275,6 +287,11 @@ void MainWindow::buildUi()
// Starting widths only; a drag overrides them, and they are what the
// saved-widths item will persist.
+ // Without this the attachment column cannot be narrow at all: the default
+ // minimum section size is 58px on this platform, and setColumnWidth()
+ // clamps to it silently rather than reporting the smaller value back.
+ m_threadView->horizontalHeader()->setMinimumSectionSize(24);
+ m_threadView->setColumnWidth(ThreadListModel::AttachmentColumn, 28);
m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130);
m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180);
m_threadView->setColumnWidth(ThreadListModel::SubjectColumn, 520);
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 9a1d3e4..4423791 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -18,16 +18,23 @@
#include "messageview.h"
+#include <QApplication>
#include <QDesktopServices>
+#include <QDialog>
+#include <QDialogButtonBox>
+#include <QDir>
+#include <QFileDialog>
#include <QHBoxLayout>
#include <QLabel>
-#include <QApplication>
+#include <QLocale>
#include <QMouseEvent>
#include <QPushButton>
+#include <QStandardPaths>
#include <QtNumeric>
#include <QTimer>
-#include <QWheelEvent>
+#include <QTreeWidget>
#include <QVBoxLayout>
+#include <QWheelEvent>
#include <QWebEnginePage>
#include <QWebEngineProfile>
#include <QWebEngineSettings>
@@ -185,6 +192,10 @@ void MessageView::clear()
m_headerLabel->clear();
m_blockedLabel->hide();
m_loadRemoteButton->hide();
+
+ // clear() does not go through render(), so the bar has to be emptied
+ // here or the previous thread's attachments stay offered.
+ rebuildAttachmentBar();
}
void MessageView::showThread(const QList<ThreadRenderItem> &items)
@@ -268,6 +279,7 @@ void MessageView::render()
m_preferHtml ? HtmlBuilder::PreferHtml : HtmlBuilder::ForcePlain;
setDocument(HtmlBuilder::buildThread(m_items, mode));
+ rebuildAttachmentBar();
// Blocking is discovered during load, so check shortly afterwards.
QTimer::singleShot(300, this, [this]() {
@@ -278,6 +290,182 @@ void MessageView::render()
});
}
+QList<Attachment> MessageView::allAttachments() const
+{
+ QList<Attachment> all;
+ for (const ThreadRenderItem &item : m_items)
+ all.append(item.message.attachments);
+ return all;
+}
+
+void MessageView::rebuildAttachmentBar()
+{
+ auto *layout = qobject_cast<QHBoxLayout *>(m_attachmentBar->layout());
+
+ // Rebuilt rather than updated: a thread can change under the same widget
+ // (toggle_html re-renders, and the next thread reuses this bar), and a
+ // stale button would offer a save from the message before it.
+ while (QLayoutItem *item = layout->takeAt(0)) {
+ delete item->widget();
+ delete item;
+ }
+
+ const int total = allAttachments().size();
+ if (total == 0) {
+ m_attachmentBar->hide();
+ return;
+ }
+
+ // One button whatever the count. A button per attachment made the bar as
+ // wide as the window on a thread with fifteen of them, which pushed the
+ // splitter over and left the thread list a few pixels wide.
+ auto *button = new QPushButton(tr("Attachments (%1)...").arg(total),
+ m_attachmentBar);
+ button->setToolTip(tr("List the attachments in this thread"));
+ connect(button, &QPushButton::clicked,
+ this, &MessageView::showAttachmentDialog);
+
+ layout->addWidget(button);
+ layout->addStretch();
+ m_attachmentBar->show();
+}
+
+void MessageView::showAttachmentDialog()
+{
+ QDialog dialog(this);
+ dialog.setWindowTitle(tr("Attachments"));
+
+ auto *layout = new QVBoxLayout(&dialog);
+ auto *list = new QTreeWidget(&dialog);
+ list->setColumnCount(4);
+ // The fourth column holds the per-row Save button and needs no label.
+ list->setHeaderLabels({ tr("Message"), tr("File"), tr("Size"), QString() });
+ list->setRootIsDecorated(false);
+ list->setSelectionMode(QAbstractItemView::NoSelection);
+
+ // A thread renders as one document, so the message number is what says
+ // which of them a file came from.
+ for (int index = 0; index < m_items.size(); ++index) {
+ const ParsedMessage &message = m_items.at(index).message;
+ for (const Attachment &attachment : message.attachments) {
+ auto *row = new QTreeWidgetItem(list);
+ row->setText(0, QString::number(index + 1));
+ // safeFilename(), never the raw filename: the name in a message is
+ // attacker-controlled and may carry separators or "..".
+ row->setText(1, attachment.safeFilename());
+ row->setText(2, QLocale().formattedDataSize(attachment.data.size()));
+
+ auto *save = new QPushButton(tr("Save..."), list);
+ // Copied into the lambda: m_items is replaced wholesale by the
+ // next showThread(), so a reference would dangle.
+ connect(save, &QPushButton::clicked, this,
+ [this, attachment]() { saveAttachment(attachment); });
+ list->setItemWidget(row, 3, save);
+ }
+ }
+ for (int column = 0; column < 3; ++column)
+ list->resizeColumnToContents(column);
+
+ layout->addWidget(list);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog);
+ // Only worth offering for more than one file: with a single attachment it
+ // is the same action as its own Save button, one dialog deeper.
+ if (allAttachments().size() > 1) {
+ auto *saveAll = buttons->addButton(tr("Save all..."),
+ QDialogButtonBox::ActionRole);
+ connect(saveAll, &QPushButton::clicked, this,
+ [this, &dialog]() {
+ saveAllAttachments();
+ dialog.accept();
+ });
+ }
+ connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ dialog.resize(560, 320);
+ dialog.exec();
+}
+
+void MessageView::saveAllAttachments()
+{
+ const QList<Attachment> attachments = allAttachments();
+ if (attachments.isEmpty())
+ return;
+
+ // The subfolder is stated up front rather than discovered afterwards: the
+ // user picks a parent, and what lands in it is one directory, not fifteen
+ // loose files among whatever is already there.
+ const QString subject = m_items.isEmpty() ? QString()
+ : m_items.first().message.subject;
+ const QString rfc822Date = m_items.isEmpty() ? QString()
+ : m_items.first().message.date;
+ const QString folder = attachmentFolderName(rfc822Date, subject);
+
+ const QString parent = QFileDialog::getExistingDirectory(
+ this,
+ tr("Choose a folder. A subfolder \"%1\" will be created inside it.")
+ .arg(folder),
+ QStandardPaths::writableLocation(QStandardPaths::DownloadLocation));
+ if (parent.isEmpty())
+ return; // cancelled
+
+ // Never overwrite an existing directory: a second save of the same thread
+ // gets its own folder rather than merging into the first.
+ QDir parentDir(parent);
+ QString unique = folder;
+ for (int suffix = 2; parentDir.exists(unique); ++suffix)
+ unique = tr("%1 (%2)").arg(folder).arg(suffix);
+
+ if (!parentDir.mkpath(unique)) {
+ emit statusMessage(tr("Could not create %1").arg(unique));
+ return;
+ }
+ const QString target = parentDir.absoluteFilePath(unique);
+
+ int saved = 0;
+ QStringList failures;
+ for (const Attachment &attachment : attachments) {
+ QString error;
+ // Not saveTo(): several messages in a thread commonly attach the same
+ // filename, and overwriting silently lost six of sixteen files while
+ // still reporting every one as saved.
+ if (attachment.saveWithoutOverwriting(target, &error).isEmpty())
+ failures.append(attachment.safeFilename());
+ else
+ ++saved;
+ }
+
+ if (failures.isEmpty()) {
+ emit statusMessage(tr("Saved %1 attachment(s) to %2")
+ .arg(saved).arg(target));
+ } else {
+ emit statusMessage(tr("Saved %1 of %2 to %3; failed: %4")
+ .arg(saved).arg(attachments.size())
+ .arg(target, failures.join(QStringLiteral(", "))));
+ }
+}
+
+void MessageView::saveAttachment(const Attachment &attachment)
+{
+ const QString directory = QFileDialog::getExistingDirectory(
+ this, tr("Save attachment to"),
+ QStandardPaths::writableLocation(QStandardPaths::DownloadLocation));
+ if (directory.isEmpty())
+ return; // cancelled
+
+ QString error;
+ const QString written = attachment.saveTo(directory, &error);
+ if (written.isEmpty()) {
+ emit statusMessage(tr("Could not save attachment: %1").arg(error));
+ return;
+ }
+
+ // Reported, not silent: a save with no feedback is the same failure as
+ // acting on a thread and seeing nothing change.
+ emit statusMessage(tr("Saved %1").arg(written));
+}
+
void MessageView::toggleHtml()
{
const bool anyHtml = std::any_of(
diff --git a/src/messageview.h b/src/messageview.h
index 09e7d71..244a1ba 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -101,6 +101,31 @@ private:
void updateHeader();
void setDocument(const QString &html);
+ /// Rebuilds the attachment bar from m_items. Called from render(), so a
+ /// toggle between HTML and plain text keeps the bar in step with what is
+ /// on screen.
+ ///
+ /// The bar holds ONE button however many attachments a thread carries. A
+ /// button per file resized the splitter and crushed the thread list on a
+ /// thread with fifteen of them.
+ void rebuildAttachmentBar();
+
+ /// The list of attachments, with a save button each and a "save all".
+ void showAttachmentDialog();
+
+ /// Saves one attachment, asking for the target directory. Writing goes
+ /// through Attachment::saveTo(), which is where the path-traversal guard
+ /// lives; the filename in a message is attacker-controlled.
+ void saveAttachment(const Attachment &attachment);
+
+ /// Saves every attachment into a new subdirectory of a directory the user
+ /// picks, so fifteen files do not land loose among hundreds of others and
+ /// cannot collide with what is already there.
+ void saveAllAttachments();
+
+ /// Every attachment in the thread, in the order the messages render.
+ QList<Attachment> allAttachments() const;
+
QList<ThreadRenderItem> m_items;
bool m_preferHtml = true;
diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp
index 71cfc64..f5ef38a 100644
--- a/src/mimeparser.cpp
+++ b/src/mimeparser.cpp
@@ -164,6 +164,109 @@ QString Attachment::safeFilename() const
return name;
}
+QString Attachment::saveWithoutOverwriting(const QString &directory,
+ QString *error) const
+{
+ const QString name = safeFilename();
+ const QFileInfo info(name);
+ const QString base = info.completeBaseName();
+ // Kept whole: "archive.tar.gz" must not become "archive (2).gz".
+ const QString suffix = info.suffix().isEmpty()
+ ? QString()
+ : QLatin1Char('.') + info.suffix();
+
+ const QDir dir(directory);
+ QString candidate = name;
+ for (int n = 2; dir.exists(candidate); ++n)
+ candidate = QStringLiteral("%1 (%2)%3").arg(base).arg(n).arg(suffix);
+
+ // The containment check still applies: candidate is derived from
+ // safeFilename(), but the guarantee belongs at the write, not upstream.
+ const QString target = dir.absoluteFilePath(candidate);
+ if (!isPathInsideDirectory(directory, target)) {
+ if (error) {
+ *error = QStringLiteral("Refusing to write outside %1")
+ .arg(QDir::cleanPath(QDir(directory).absolutePath()));
+ }
+ return {};
+ }
+
+ QFile file(target);
+ if (!file.open(QIODevice::WriteOnly)) {
+ if (error)
+ *error = file.errorString();
+ return {};
+ }
+ if (file.write(data) != data.size()) {
+ if (error)
+ *error = file.errorString();
+ return {};
+ }
+ file.close();
+ return target;
+}
+
+QString attachmentFolderName(const QString &rfc822Date, const QString &subject)
+{
+ // The date prefix sorts chronologically in a file manager. A Date: header
+ // that does not parse is simply dropped rather than guessed at.
+ // A trailing timezone comment, "... +0200 (CEST)", is legal per RFC 5322
+ // and common in the wild, but Qt::RFC2822Date rejects the whole string
+ // when one is present (verified on Qt 6.11). Strip comments before
+ // parsing, or every such message silently loses its date prefix.
+ QString cleaned = rfc822Date;
+ cleaned.remove(QRegularExpression(QStringLiteral("\\s*\\([^)]*\\)")));
+ cleaned = cleaned.trimmed();
+
+ QString prefix;
+ const QDateTime parsed = QDateTime::fromString(cleaned, Qt::RFC2822Date);
+ if (parsed.isValid())
+ prefix = parsed.toString(QStringLiteral("yyyy-MM-dd"));
+
+ // The subject is attacker-controlled and is about to become a directory
+ // name. Everything that could make it more than one plain component goes:
+ // separators, and the control characters that can hide what a name really
+ // is when it is displayed.
+ QString name = subject.simplified();
+ name.remove(QLatin1Char('/'));
+ name.remove(QLatin1Char('\\'));
+ QString stripped;
+ stripped.reserve(name.size());
+ for (const QChar c : name) {
+ if (!c.isNull() && c.category() != QChar::Other_Control)
+ stripped.append(c);
+ }
+ // Leading dots would make a hidden directory, and a name of "." or ".."
+ // would escape or alias the parent; removing them handles every case.
+ while (stripped.startsWith(QLatin1Char('.')))
+ stripped.remove(0, 1);
+ stripped = stripped.trimmed();
+
+ QString combined;
+ if (!prefix.isEmpty() && !stripped.isEmpty())
+ combined = prefix + QLatin1Char(' ') + stripped;
+ else if (!prefix.isEmpty())
+ combined = prefix;
+ else
+ combined = stripped;
+
+ // A subject can be far longer than a filesystem component allows. Cut to
+ // a conservative 120 characters, well under the usual 255-byte limit even
+ // once multi-byte characters are counted as bytes.
+ constexpr int maxLength = 120;
+ if (combined.size() > maxLength)
+ combined = combined.left(maxLength).trimmed();
+
+ // Nothing usable survived: no parseable date and a subject that was empty,
+ // punctuation, or control characters only.
+ if (combined.isEmpty()) {
+ return QStringLiteral("attachments-%1").arg(
+ QUuid::createUuid().toString(QUuid::Id128).left(8));
+ }
+
+ return combined;
+}
+
bool Attachment::isPathInsideDirectory(const QString &directory, const QString &candidatePath)
{
// Compare candidatePath itself, not QFileInfo(candidatePath).absolutePath()
diff --git a/src/mimeparser.h b/src/mimeparser.h
index 64ea0ce..af7619f 100644
--- a/src/mimeparser.h
+++ b/src/mimeparser.h
@@ -44,8 +44,22 @@ struct Attachment
/// Writes the attachment into directory. Returns the full path written, or
/// an empty string on failure with *error set.
+ ///
+ /// **Overwrites an existing file of the same name.** That is right for a
+ /// single save the user just confirmed a location for, and wrong for
+ /// saving a batch: several messages in one thread commonly attach the
+ /// same filename. Use saveWithoutOverwriting() there.
QString saveTo(const QString &directory, QString *error) const;
+ /// Writes the attachment into directory under a name that is not already
+ /// taken, appending " (2)", " (3)" and so on before the extension.
+ /// Returns the full path written, or an empty string on failure.
+ ///
+ /// Saving a thread's attachments with saveTo() silently destroyed files:
+ /// six of sixteen were lost to same-name collisions and every write still
+ /// reported success.
+ QString saveWithoutOverwriting(const QString &directory, QString *error) const;
+
/// True if candidatePath (need not exist) is directory itself or strictly
/// beneath it, by path-boundary comparison after QDir::cleanPath on both
/// sides (so ".." segments are resolved rather than compared textually).
@@ -65,6 +79,21 @@ struct Attachment
static bool isPathInsideDirectory(const QString &directory, const QString &candidatePath);
};
+/// A directory name for a thread's saved attachments, "<date> <subject>".
+///
+/// `rfc822Date` is a raw Date: header as ParsedMessage stores it; it is
+/// reduced to "yyyy-MM-dd" when it parses and dropped when it does not.
+///
+/// Both inputs are untrusted: a subject is attacker-controlled and may carry
+/// path separators, "..", control characters, or nothing usable at all. The
+/// result is always a single plain component, never a path, and never "." or
+/// "..". Falls back to the date alone, then to a generated name, so it is
+/// never empty.
+///
+/// Length is capped: many filesystems limit one component to 255 bytes, and a
+/// subject can be far longer than that.
+QString attachmentFolderName(const QString &rfc822Date, const QString &subject);
+
struct ParsedMessage
{
bool ok = false;
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 2f2882e..2289e6c 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -20,6 +20,27 @@
#include <QBrush>
#include <QFont>
+#include <QFontDatabase>
+#include <QFontMetrics>
+
+QString ThreadListModel::attachmentGlyph()
+{
+ // U+1F4CE PAPERCLIP, with a fallback for a system whose default font
+ // cannot draw it: an unrenderable codepoint shows as a tofu box, which
+ // reads as "something is broken" rather than "this has an attachment".
+ // Computed once; the font does not change under a running application.
+ static const QString glyph = [] {
+ const char32_t paperclip = 0x1F4CE;
+ const QString preferred = QString::fromUcs4(&paperclip, 1);
+ const QFontMetrics metrics{QFontDatabase::systemFont(
+ QFontDatabase::GeneralFont)};
+ // "*" as the fallback: ASCII, present in every practical font, and
+ // unambiguous in a column that shows nothing else.
+ return metrics.inFontUcs4(paperclip) ? preferred
+ : QStringLiteral("*");
+ }();
+ return glyph;
+}
QColor ThreadListModel::deletedColour()
{
@@ -85,8 +106,19 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return {};
}
+ if (role == Qt::ToolTipRole && index.column() == AttachmentColumn)
+ return thread.hasAttachment() ? tr("Has an attachment") : QVariant();
+
+ if (role == Qt::TextAlignmentRole && index.column() == AttachmentColumn)
+ return QVariant::fromValue(Qt::AlignCenter);
+
if (role == Qt::DisplayRole) {
switch (index.column()) {
+ case AttachmentColumn:
+ // A glyph rather than an icon resource: no new asset to ship, and
+ // it inherits the row's font, so it strikes through with a doomed
+ // thread like every other cell.
+ return thread.hasAttachment() ? attachmentGlyph() : QString();
case DateColumn:
return thread.date.toString(QStringLiteral("yyyy-MM-dd hh:mm"));
case AuthorsColumn:
@@ -141,6 +173,9 @@ QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation,
return {};
switch (section) {
+ // No label: any text would set a minimum width far wider than the icon,
+ // which defeats the point of a narrow column.
+ case AttachmentColumn: return QString();
case DateColumn: return QStringLiteral("Date");
case AuthorsColumn: return QStringLiteral("From");
case SubjectColumn: return QStringLiteral("Subject");
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 7ed8fef..ab9378e 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -36,7 +36,11 @@ public:
/// under the message pane, and the account tag renders as a chip in front
/// of the subject.
enum Column {
- DateColumn = 0,
+ /// A paperclip when the thread has an attachment, so it is visible
+ /// without opening the thread. Icon only and deliberately narrow;
+ /// it carries no text.
+ AttachmentColumn = 0,
+ DateColumn,
AuthorsColumn,
SubjectColumn,
ColumnCount,
@@ -64,6 +68,10 @@ public:
/// Muted rather than saturated: a bulk delete paints every selected row,
/// and a wall of pure red is harder to read than the list it replaces.
/// Exposed so a test names the same colour the model uses.
+ /// The character shown in AttachmentColumn for a thread that has one.
+ /// A paperclip when the system font can draw it, "*" otherwise.
+ static QString attachmentGlyph();
+
static QColor deletedColour();
static QColor spamColour();
diff --git a/src/types.h b/src/types.h
index e25c3a9..59d1d06 100644
--- a/src/types.h
+++ b/src/types.h
@@ -38,6 +38,13 @@ struct ThreadSummary
bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); }
bool isSpam() const { return tags.contains(QStringLiteral("spam")); }
+ /// notmuch applies "attachment" itself while indexing, so this needs no
+ /// MIME parsing and no extra worker query: the tag is already in tags.
+ bool hasAttachment() const
+ {
+ return tags.contains(QStringLiteral("attachment"));
+ }
+
/// True while the thread is tagged for removal. notmuch deletes nothing
/// itself: the tag marks the thread for whatever the user's sync script
/// does next, so the row has to show it is on its way out.