aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-26 13:28:51 +0200
committerDanilo M. <danix@danix.xyz>2026-08-26 13:30:22 +0200
commit0ca4624195cdd8c78ff614e3912af5b914458497 (patch)
tree6fc739e77fed6b5a4e178cacf76ac29fd708ab0b /src
parentd835cf3c554e9657fffdb91971b66e3a74aee323 (diff)
downloadqtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.tar.gz
qtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.zip
feat: flag what you answered, mark what was forwarded to you
Item 68, which turned out to be three things once its premise was measured. The note asked to extend a "passed" subject rule to "Fw:"; there was no subject rule, and the correlation it rested on did not exist. What did exist was a gap nobody had reported. Reply and forward now flag their source. The Maildir R and P flags, which every other client sets and notmuch reads back as "replied" and "passed", had never been written here: measured on the developer's index, all 317 "replied" and all 6 "passed" came from other clients. ComposeWindow emits sourceMessageAnswered after a successful send and MainWindow routes it through sendMessageTagChange, message-scoped and off the undo stack, for the reason auto mark-read is: the flag records that the mail went, and the send cannot be undone. ComposeContext carries sourceMessageId rather than reusing inReplyTo, which is deliberately empty on a forward so the recipient's client does not file it under the thread it left. Keying on it made the "passed" half dead code that compiled and never fired. A resumed draft is excluded: its kind records how the file was opened, not what the user is doing, so flagging on it would set R from a guess. A received forward gets its own mark. Derived from the subject at paint time, storing nothing and reaching no server, because "passed" means "I forwarded this" and setting it from a guess would assert something false on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s prefix table so the two cannot disagree, strips a Re: chain first, and takes extra locale spellings from [general] forward_prefixes, which extends the built-in table rather than replacing it. A mutation survived the first round and corrected a claim in the code: QRegularExpression::escape already makes a punctuation prefix inert, so the word guard is not about pattern validity. It stops a configured "-" matching "-: x". The comment and test say that now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
Diffstat (limited to 'src')
-rw-r--r--src/carddelegate.cpp3
-rw-r--r--src/cardlayout.cpp1
-rw-r--r--src/cardlayout.h6
-rw-r--r--src/composecontext.cpp52
-rw-r--r--src/composecontext.h20
-rw-r--r--src/composewindow.cpp56
-rw-r--r--src/composewindow.h12
-rw-r--r--src/config.cpp32
-rw-r--r--src/config.h16
-rw-r--r--src/mainwindow.cpp160
-rw-r--r--src/mainwindow.h10
-rw-r--r--src/marks.cpp7
-rw-r--r--src/marks.h7
-rw-r--r--src/threadlistmodel.cpp95
-rw-r--r--src/threadlistmodel.h41
-rw-r--r--src/types.h10
16 files changed, 514 insertions, 14 deletions
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp
index 4e27e6e..9a98c69 100644
--- a/src/carddelegate.cpp
+++ b/src/carddelegate.cpp
@@ -52,6 +52,8 @@ CardLayout::Input inputFor(const QModelIndex &index)
in.flagged = index.data(ThreadListModel::IsFlaggedRole).toBool();
in.hasAttachment = index.data(ThreadListModel::HasAttachmentRole).toBool();
in.passed = index.data(ThreadListModel::IsPassedRole).toBool();
+ in.receivedForward =
+ index.data(ThreadListModel::IsReceivedForwardRole).toBool();
in.replied = index.data(ThreadListModel::IsRepliedRole).toBool();
return in;
}
@@ -256,6 +258,7 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
drawMark(card.flagRect, Marks::Mark::Flagged);
drawMark(card.attachmentRect, Marks::Mark::Attachment);
drawMark(card.passedRect, Marks::Mark::Passed);
+ drawMark(card.receivedForwardRect, Marks::Mark::ReceivedForward);
drawMark(card.repliedRect, Marks::Mark::Replied);
// The reply count, which is also the expander, drawn as a PILL.
diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp
index 3719591..d32935b 100644
--- a/src/cardlayout.cpp
+++ b/src/cardlayout.cpp
@@ -260,6 +260,7 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect,
target = QRect(markRight - side, markTop, side, side);
markRight = target.left() - kMarkGap;
};
+ placeMark(input.receivedForward, out.receivedForwardRect);
placeMark(input.replied, out.repliedRect);
placeMark(input.passed, out.passedRect);
placeMark(input.hasAttachment, out.attachmentRect);
diff --git a/src/cardlayout.h b/src/cardlayout.h
index de4c41f..92edd96 100644
--- a/src/cardlayout.h
+++ b/src/cardlayout.h
@@ -68,6 +68,9 @@ struct CardLayout
bool hasAttachment = false;
bool passed = false;
bool replied = false;
+ /// Someone forwarded this message TO the user (item 68). Derived from
+ /// the subject, so unlike the three above it corresponds to no tag.
+ bool receivedForward = false;
};
/// Width of the account accent bar down a thread card's left edge.
@@ -111,7 +114,7 @@ struct CardLayout
QRect flagRect;
/// The state marks after the subject, in this order: attachment, passed,
- /// replied. Each is empty when its state does not apply.
+ /// replied, received-forward. Each is empty when its state does not apply.
///
/// Separate rects rather than one strip, because each is independently
/// present or absent and a strip would have to encode which. They are laid
@@ -120,6 +123,7 @@ struct CardLayout
QRect attachmentRect;
QRect passedRect;
QRect repliedRect;
+ QRect receivedForwardRect;
/// The side of a square mark on line two, derived from the card's font so
/// the marks scale with the user's text size rather than being pinned to a
diff --git a/src/composecontext.cpp b/src/composecontext.cpp
index 7233330..2dfee53 100644
--- a/src/composecontext.cpp
+++ b/src/composecontext.cpp
@@ -487,6 +487,58 @@ QString ComposeContextBuilder::forwardSubject(const QString &original)
return QStringLiteral("Fwd: ") + original;
}
+bool ComposeContextBuilder::subjectIsForwarded(const QString &subject,
+ const QStringList &extraPrefixes)
+{
+ // Strip any Re: chain first, so "Re: Fwd: x" is recognised: a reply to a
+ // forward is still a forward the user received. Bounded rather than a
+ // while(true), since a crafted subject of ten thousand "Re:" is input from
+ // a stranger and this runs per row per repaint.
+ QString rest = subject;
+ for (int i = 0; i < 8; ++i) {
+ const QRegularExpressionMatch match = replyPrefix().match(rest);
+ if (!match.hasMatch())
+ break;
+ rest = rest.mid(match.capturedEnd());
+ }
+
+ if (forwardPrefix().match(rest).hasMatch())
+ return true;
+
+ if (extraPrefixes.isEmpty())
+ return false;
+
+ // The configured spellings, matched with the same shape as the built-in
+ // table: anchored, case-insensitive, tolerating the counted forms Outlook
+ // emits.
+ //
+ // Escaped, because this comes from a hand-edited config file. Measured
+ // 2026-08-26: escaping alone already makes a punctuation entry inert
+ // rather than invalid, so the word guard below is NOT about pattern
+ // validity. It is about what a non-word entry would legitimately match: a
+ // configured "-" matches "-: x", and a digit entry matches a subject
+ // opening with a number, neither of which is a forward marker in any
+ // client.
+ QStringList alternatives;
+ for (const QString &prefix : extraPrefixes) {
+ const QString trimmed = prefix.trimmed();
+ // A word only. A configured "Re" would swallow every reply, and a
+ // configured ":" or "" would match every subject in the mailbox.
+ static const QRegularExpression word(QStringLiteral("^[^\\W\\d_]+$"));
+ if (trimmed.isEmpty() || !word.match(trimmed).hasMatch())
+ continue;
+ alternatives << QRegularExpression::escape(trimmed);
+ }
+ if (alternatives.isEmpty())
+ return false;
+
+ const QRegularExpression extra(
+ QStringLiteral("^\\s*(%1)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:")
+ .arg(alternatives.join(QLatin1Char('|'))),
+ QRegularExpression::CaseInsensitiveOption);
+ return extra.match(rest).hasMatch();
+}
+
ComposeContext ComposeContextBuilder::forDraft(const Config &config,
const QString &path)
{
diff --git a/src/composecontext.h b/src/composecontext.h
index 6f311c7..b94d0f8 100644
--- a/src/composecontext.h
+++ b/src/composecontext.h
@@ -168,6 +168,26 @@ QString replySubject(const QString &original);
QString forwardSubject(const QString &original);
+/// True when \p subject reads as a message someone forwarded TO the user.
+///
+/// Item 68. This is a DISPLAY predicate: the card draws a mark from it and
+/// stores nothing. It must never write a tag or a Maildir flag, because
+/// `passed` (the `P` flag) means "I forwarded this" and is a different fact
+/// about a different person. Setting it from a subject guess would assert
+/// something false and, with maildir.synchronize_flags on, propagate that to
+/// the server.
+///
+/// Matches the same prefix table forwardSubject() uses, so the set of
+/// recognised spellings cannot drift between "do not double the prefix" and
+/// "this is a forward". \p extraPrefixes adds locale spellings the built-in
+/// table omits, from `[general] forward_prefixes`; each is a bare word without
+/// its colon ("doorst", "vs"). An entry that is empty or not a word is ignored.
+///
+/// A `Re:` chain is stripped first, so `Re: Fwd: x` is recognised: a reply to
+/// a forward is still a forward the user received.
+bool subjectIsForwarded(const QString &subject,
+ const QStringList &extraPrefixes = {});
+
/// Builds the context that RESUMES a draft from its file.
///
/// Unlike a reply, nothing here is derived: the recipients, the subject and
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index 879a9d1..afcf6a2 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -21,6 +21,7 @@
#include <QTemporaryDir>
#include "draftstore.h"
+#include "maildirname.h"
#include "messagebuilder.h"
#include "mimeparser.h"
#include "messagesender.h"
@@ -1418,11 +1419,62 @@ void ComposeWindow::send()
dialog->setStage(SendDialog::Stage::RemovingDraft);
if (!m_draftPath.isEmpty()) {
- QFile::remove(m_draftPath);
- emit draftRemoved(m_draftPath);
+ // Re-resolved, because mbsync renames an uploaded draft to add
+ // its `,U=<uid>` infix while m_draftPath still holds the name
+ // DraftStore::write() returned. Without this the remove is a
+ // silent no-op on a path that no longer exists: measured
+ // 2026-08-26 on the user's own mail, where a forwarded message
+ // was sent and filed correctly and its draft stayed in the
+ // Drafts view carrying the `D` flag.
+ //
+ // Item 163 added resolveRenamed() and wired it into the three
+ // READ sites (the pane, Reply/Forward, the draft reopen). This
+ // is the write site, and it was missed: the same rename, the
+ // same fix, one call site later.
+ //
+ // The unresolved path is emitted when nothing matches, so a
+ // draft that genuinely vanished still asks the worker to drop
+ // its index entry rather than leaving a ghost.
+ const QString actual = MaildirName::resolveRenamed(m_draftPath);
+ const QString target = actual.isEmpty() ? m_draftPath : actual;
+ QFile::remove(target);
+ emit draftRemoved(target);
m_draftPath.clear();
}
+ // Item 68. The Maildir R and P flags, recorded on the message this
+ // one answers. Both were measured missing on 2026-08-26: every one
+ // of the 317 `replied` and 6 `passed` in the developer's own index
+ // came from another client, because nothing here has ever written
+ // either.
+ //
+ // AFTER the send, never before: the flag asserts that the mail
+ // went, and an abandoned composer must leave no trace on the
+ // message it was answering.
+ //
+ // sourceMessageId, NOT inReplyTo: that header is deliberately
+ // empty on a Forward, so keying on it would have made the `passed`
+ // half dead code that compiles and never fires.
+ //
+ // A resumed Draft is deliberately excluded even when it carries a
+ // source id. Its kind records how the FILE was opened, not what the
+ // user is doing, so a draft that began as a reply cannot be told
+ // from one that began as a new message; flagging on that would set
+ // R from a guess. The cost is a missing flag on a reply finished in
+ // two sittings, which is the safe direction: maildir.synchronize_-
+ // flags is on, so a wrong flag reaches the server.
+ if (!m_context.sourceMessageId.isEmpty()) {
+ QString tag;
+ if (m_context.kind == ComposeContext::Kind::Reply
+ || m_context.kind == ComposeContext::Kind::ReplyAll) {
+ tag = QStringLiteral("replied");
+ } else if (m_context.kind == ComposeContext::Kind::Forward) {
+ tag = QStringLiteral("passed");
+ }
+ if (!tag.isEmpty())
+ emit sourceMessageAnswered(m_context.sourceMessageId, tag);
+ }
+
dialog->accept();
dialog->deleteLater();
diff --git a/src/composewindow.h b/src/composewindow.h
index 2eaeeea..c8cc12a 100644
--- a/src/composewindow.h
+++ b/src/composewindow.h
@@ -189,6 +189,18 @@ signals:
/// \p path is the file that was removed, absolute.
void draftRemoved(const QString &path);
+ /// A send succeeded, and the message it answers should record that.
+ ///
+ /// Item 68. \p sourceMessageId is the Message-ID of the message replied to
+ /// or forwarded, \p tag is "replied" or "passed". The window emits rather
+ /// than writing, because a tag write belongs to the one applyTags path in
+ /// MainWindow and the composer owns no worker.
+ ///
+ /// Emitted only after the send itself succeeded: a failed send leaves the
+ /// source untouched, since the flag asserts that the mail went.
+ void sourceMessageAnswered(const QString &sourceMessageId,
+ const QString &tag);
+
protected:
/// The one place the registry is told, whichever route closes the window.
void closeEvent(QCloseEvent *event) override;
diff --git a/src/config.cpp b/src/config.cpp
index d91259a..8b784ba 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -170,6 +170,13 @@ QString Account::inboxQuery() const
return folderQuery(maildir, inboxFolder());
}
+QString Config::generatorTagFor(const QString &generator)
+{
+ // Delegates to the file-local table rather than repeating it, so the
+ // question "which tag does this filter match" has one answer.
+ return generatorTag(generator);
+}
+
QString Config::allSentQuery() const
{
return joinAccountQueries(m_accounts, &Account::sentQuery);
@@ -338,6 +345,31 @@ void Config::load(const QString &path)
}
}
+ // Extra subject prefixes that mark a message someone forwarded TO the
+ // user, added to the built-in table in composecontext.cpp rather than
+ // replacing it: the built-ins are the spellings that repo already
+ // measured, and a user adding Dutch should not have to restate English.
+ //
+ // Bare words, no colon. The predicate ignores anything else, so a
+ // malformed entry costs that entry and not the whole key.
+ const QStringList forwardPrefixes =
+ settings.value(QStringLiteral("forward_prefixes"))
+ .toStringList();
+ for (const QString &prefix : forwardPrefixes) {
+ const QString trimmed = prefix.trimmed();
+ if (trimmed.isEmpty())
+ continue;
+ // Warned rather than dropped silently: the user asked for something
+ // and is not getting it, the same reason message_zoom warns.
+ if (trimmed.contains(QLatin1Char(':'))) {
+ addProblem(tr("Forward prefix '%1' should be written without "
+ "its colon; ignoring it.")
+ .arg(trimmed));
+ continue;
+ }
+ m_forwardPrefixes << trimmed;
+ }
+
// Absent is silent, the default being 2000. Present but unparseable warns,
// for the same reason message_zoom does: the user asked for something and
// is not getting it.
diff --git a/src/config.h b/src/config.h
index 02b4038..26fc1c1 100644
--- a/src/config.h
+++ b/src/config.h
@@ -349,6 +349,14 @@ public:
/// account that configures no sent folder would show the entire Maildir.
static QString matchNothingQuery();
+ /// The tag a built-in generator matches, or empty for the folder-backed
+ /// ones (`sent`, `drafts`, `trash`) which compose from a path instead.
+ ///
+ /// Exposed so a caller asking "which tag decides membership of this view"
+ /// reads the same table the query generator does, rather than keeping a
+ /// second copy that can drift from it.
+ static QString generatorTagFor(const QString &generator);
+
/// Empty when unset; the caller disables the Sync button in that case.
QString syncCommand() const { return m_syncCommand; }
@@ -412,6 +420,13 @@ public:
/// the same fixed string on every card rather than failing visibly.
QString dateFormat() const { return m_dateFormat; }
+ /// Extra subject prefixes marking a forward the user RECEIVED (item 68).
+ ///
+ /// Added to the built-in table in composecontext.cpp, never replacing it,
+ /// so a user adding a locale keeps the measured English/German/Iberian/
+ /// French spellings. Bare words, no colon.
+ QStringList forwardPrefixes() const { return m_forwardPrefixes; }
+
/// Interface language, or empty to follow the environment.
///
/// A locale name, short ("it") or full ("it_IT"); Qt resolves the short
@@ -536,6 +551,7 @@ private:
int m_toolbarIconSize = 24;
QString m_notmuchConfig;
QString m_dateFormat;
+ QStringList m_forwardPrefixes;
QString m_language;
qreal m_messageZoom = 1.0;
bool m_completionOnFocus = false;
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 89c01eb..9166588 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -825,6 +825,7 @@ void MainWindow::buildUi()
m_model = new ThreadListModel(this);
m_model->setTagColors(&m_tagColors);
m_model->setDateFormat(m_config.dateFormat());
+ m_model->setForwardPrefixes(m_config.forwardPrefixes());
// ThreadListView, not a plain QTableView: it paints the row-wide tag
// strip under each row's cells, which no delegate can do because a
// delegate is confined to one column's rectangle.
@@ -1071,6 +1072,11 @@ void MainWindow::openComposerFor(const MessageRef &ref,
context.kind = kind;
context.originalPath = originalPath;
+ // Item 68. Set for a forward as well as a reply, which is why it is not
+ // inReplyTo: that header is deliberately omitted from a forward below, and
+ // the P flag still belongs on the message that was forwarded.
+ context.sourceMessageId = original.messageId;
+
const bool replyAll = kind == ComposeContext::Kind::ReplyAll;
const bool forwarding = kind == ComposeContext::Kind::Forward;
@@ -1166,6 +1172,26 @@ void MainWindow::openComposer(const ComposeContext &context)
connect(composer, &ComposeWindow::draftRemoved, m_worker,
&NotmuchWorker::removeIndexedFile);
+ // Item 68. The R and P Maildir flags, on the message the send answered.
+ //
+ // sendMessageTagChange, NOT tagSelected: this deliberately does not go on
+ // the undo stack, for the reason auto mark-read does not (see
+ // markCurrentThreadRead). The flag records a fact the user brought about
+ // by sending, and the send itself cannot be undone, so offering Ctrl+Z to
+ // retract only the flag would leave the two disagreeing. Removing the tag
+ // by hand still works.
+ //
+ // Message-scoped: the message answered, never its thread.
+ connect(composer, &ComposeWindow::sourceMessageAnswered, this,
+ [this](const QString &messageId, const QString &tag) {
+ if (messageId.isEmpty() || tag.isEmpty())
+ return;
+ sendMessageTagChange({ messageId }, { tag }, {},
+ tag == QStringLiteral("passed")
+ ? tr("Mark forwarded")
+ : tr("Mark replied"));
+ });
+
composer->show();
}
@@ -1555,9 +1581,23 @@ void MainWindow::registerActions()
});
addAction(QStringLiteral("delete"), tr("&Delete"),
tr("Add or remove the deleted tag"), [this]() {
- // A toggle, like toggle_unread: pressing Delete twice is the natural
- // way to say "no, put it back", and adding a tag that is already there
- // is a no-op the user cannot see.
+ // Two directions, and since 2026-08-26 only ONE of them is reachable
+ // on ordinary mail.
+ //
+ // This began as item 16's toggle: pressing Delete twice was how a user
+ // said "no, put it back", and it existed because the deleted row
+ // STAYED in the view, tinted, with nothing else to press. Delete now
+ // strips `inbox` and the row leaves the view immediately, so there is
+ // no second press to make and the mitigation is not needed; Ctrl+Z
+ // retracts, and Restore in the trash is the deliberate route.
+ //
+ // The undelete branch survives because it is NOT dead: stranded mail
+ // (tagged `deleted`, outside any trash folder, from a version before
+ // Delete moved files) is the one place `allDeleted` is still true
+ // where Delete is visible at all, since item 168 hides the action
+ // whenever every selected row is already in a trash folder. That is
+ // what `cleanup_stranded` sends the user to, telling them to select
+ // what should go and press Delete.
//
// One direction for the WHOLE selection. Toggling each thread
// independently would leave one keystroke with the selection in two
@@ -3272,6 +3312,28 @@ void MainWindow::runQuery(FlatResult flat, AccountScope scope)
QString query = m_queryEdit->text().trimmed();
+ // Whether this is the trash view, which suppresses the doomed fill: every
+ // row there is deleted, so the crimson says nothing and only costs
+ // legibility.
+ //
+ // Derived from the QUERY rather than from which button was clicked, so a
+ // hand-typed or edited trash query gets the same treatment as the button,
+ // and set on EVERY run for the reason flat mode is: a flag left standing
+ // would paint the next view's genuinely doomed rows plain.
+ //
+ // Compared against the trash filter resolved in the CURRENT account scope,
+ // which is what runFilter() put in the bar. matchNothingQuery() is
+ // excluded because it is a real string that compares equal to itself, so
+ // an account with no trash folder would otherwise match it.
+ {
+ const QString trashQuery = m_config.resolvedQuery(
+ Config::builtinFilter(QStringLiteral("trash")),
+ m_accountBox->currentData().toString());
+ m_model->setTrashView(!query.isEmpty()
+ && trashQuery != Config::matchNothingQuery()
+ && query == trashQuery);
+ }
+
// A built-in filter arrives already resolved in the selected account's
// scope, because a generator has to be asked for the account's own query
// rather than have its all-accounts query wrapped. Scoping again here would
@@ -5459,9 +5521,15 @@ void MainWindow::trashMessages(const QStringList &messageIds,
// to touch, and the difference is who is acting: the hook tags
// arriving mail unattended, while this is an explicit gesture on a
// message in front of the user.
+ // `inbox` goes with it too. Without that a message deleted FROM the
+ // inbox keeps the tag the Inbox filter matches on, so it stays in that
+ // view after being thrown away: measured 2026-08-26 on the user's own
+ // mail, where it was the only message ever deleted from an inbox and
+ // therefore the only one that could show it. Restore does not depend
+ // on it surviving, since `deleted-from:` carries the origin.
sendMove(it.value(), it.key(),
{ QStringLiteral("deleted"), kOriginTagPlaceholder() },
- { QStringLiteral("unread") },
+ { QStringLiteral("unread"), QStringLiteral("inbox") },
tr("Delete"), false, wholeThreadIds);
}
@@ -5745,12 +5813,40 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds,
QStringList remove{ QStringLiteral("deleted") };
if (!origin.isEmpty())
remove.append(origin);
- sendMove(it.value(), it.key(), {}, remove, tr("Restore"));
+
+ // `inbox` comes back when, and only when, the message is going back
+ // to an inbox. Delete strips it (so a deleted message leaves the
+ // Inbox view), which makes restoring it the other half of that
+ // change: without this a restored message sits in the inbox FOLDER
+ // carrying no `inbox` TAG, invisible to the view it was returned to
+ // until the next hook run. Undo is unaffected either way, since
+ // TagChange::inverted() gives back exactly what the move removed.
+ //
+ // Judged on the DESTINATION folder rather than on the origin tag's
+ // text, so an account whose inbox is named something else is right for
+ // the same reason inboxFolderFor() exists. The key is
+ // `<maildir>/<folder>`, and the account is resolved back from it
+ // rather than captured above, where it belongs to the per-message loop
+ // and is out of scope here.
+ QStringList add;
+ const QString destMaildir = it.key().section(QLatin1Char('/'), 0, 0);
+ for (const Account &candidate : m_config.accounts()) {
+ if (candidate.maildir != destMaildir)
+ continue;
+ if (origin.compare(candidate.inboxFolder(), Qt::CaseInsensitive) == 0)
+ add.append(QStringLiteral("inbox"));
+ break;
+ }
+
+ sendMove(it.value(), it.key(), add, remove, tr("Restore"));
}
for (auto it = byInbox.cbegin(); it != byInbox.cend(); ++it) {
- sendMove(it.value(), it.key(), {}, { QStringLiteral("deleted") },
- tr("Restore"));
+ // This branch IS the inbox by construction: it is the fallback for a
+ // message with no origin tag, and the folder it names is the
+ // account's own inbox. So the tag always comes with it.
+ sendMove(it.value(), it.key(), { QStringLiteral("inbox") },
+ { QStringLiteral("deleted") }, tr("Restore"));
}
if (!byInbox.isEmpty()) {
@@ -6092,6 +6188,24 @@ void MainWindow::sendMove(const QStringList &messageIds,
m_model->applyMessageTagChange(messageId, displayAdd, displayRemove);
}
+ // A row that no longer belongs in the view LEAVES it, rather than sitting
+ // there repainted until the next query. Delete strips `inbox`, so in the
+ // Inbox view the message it stripped it from stops matching, and leaving
+ // it was the defect: a deleted message stayed in the inbox across
+ // restarts, since the tag really was gone from the display and really was
+ // still what the query asked for.
+ //
+ // Guarded on the VIEW's own tag, resolved from the query rather than
+ // assumed: a plain `tag:<x>` query is the only shape whose membership one
+ // tag decides. A path query (Trash, Sent, Drafts) is unaffected by a tag
+ // going away, and an arbitrary query the user typed cannot be reasoned
+ // about at all, so both are left alone and refresh at the next sync.
+ // Without that guard, deleting from an `id:` view would empty the list.
+ if (const QString viewTag = viewFilterTag();
+ !viewTag.isEmpty() && displayRemove.contains(viewTag)) {
+ m_model->removeThreadsWithoutTag(viewTag);
+ }
+
// What to tag once the move is CONFIRMED. Tagging now would leave a
// message marked deleted in a folder it never left if the rename failed.
//
@@ -6110,6 +6224,38 @@ void MainWindow::sendMove(const QStringList &messageIds,
Q_ARG(QString, destFolder));
}
+QString MainWindow::viewFilterTag() const
+{
+ const QString query = m_queryEdit->text().trimmed();
+ if (query.isEmpty())
+ return {};
+
+ const QString accountKey = m_accountBox->currentData().toString();
+
+ // The three tag-backed built-ins, matched against the query RESOLVED in
+ // the current account scope, which is what runFilter() put in the bar. The
+ // comparison is on the generated string rather than on the button's
+ // checked state, so a query the user edited by hand into the same thing
+ // behaves identically, and a label translated into another locale cannot
+ // change the answer.
+ for (const QString &generator : { QStringLiteral("unread"),
+ QStringLiteral("inbox"),
+ QStringLiteral("flagged") }) {
+ const SavedQuery filter = Config::builtinFilter(generator);
+ const QString resolved = m_config.resolvedQuery(filter, accountKey);
+ if (resolved == Config::matchNothingQuery())
+ continue;
+ if (resolved == query) {
+ // The TAG, not the generator: "flagged" happens to match its tag
+ // and "inbox" and "unread" do too, but the generator is a filter
+ // identity and the tag is what a message carries.
+ return Config::generatorTagFor(generator);
+ }
+ }
+
+ return {};
+}
+
void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId,
const QString &destFolder)
{
diff --git a/src/mainwindow.h b/src/mainwindow.h
index a5a8c31..cb8cec4 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -1104,6 +1104,16 @@ private:
/// Confirms a move: applies the tags the move was asked to carry, with the
/// origin placeholder resolved per message.
+ /// The single tag the CURRENT view's membership depends on, or empty.
+ ///
+ /// Only the three plain `tag:` filters (Unread, Inbox, Important) have
+ /// one: their query is exactly that tag, so a message losing it stops
+ /// belonging. Trash, Sent and Drafts are PATH queries, where a tag change
+ /// decides nothing, and a hand-typed query is not reasoned about at all.
+ /// Both answer empty, which is what keeps an optimistic row removal from
+ /// firing in a view it cannot judge.
+ QString viewFilterTag() const;
+
void onMessagesMoved(const QMap<QString, QString> &originByMessageId,
const QString &destFolder);
diff --git a/src/marks.cpp b/src/marks.cpp
index 8777f84..d205ea3 100644
--- a/src/marks.cpp
+++ b/src/marks.cpp
@@ -57,6 +57,13 @@ QByteArray svg(Mark mark)
"viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 6.8,2.2 V 5.0 "
"H 8.7 C 11.9,5.0 14.2,7.4 14.2,10.8 V 13.8 a 0.9,0.9 0 0 1 -1.75,0.28 C "
"11.8,12.1 10.4,10.9 8.7,10.9 H 6.8 V 13.7 L 1.0,7.95 Z\"/> </svg>");
+ case Mark::ReceivedForward:
+ return QByteArray(
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" "
+ "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 2.0,2.6 a "
+ "0.95,0.95 0 0 1 1.9,0 V 6.4 C 3.9,8.1 5.2,9.4 6.9,9.4 H 9.6 V 6.6 L "
+ "15.0,11.0 L 9.6,15.4 V 12.6 H 6.9 C 3.5,12.6 2.0,10.3 2.0,7.4 Z\"/> "
+ "</svg>");
case Mark::ExpanderCollapsed:
return QByteArray(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" "
diff --git a/src/marks.h b/src/marks.h
index 823c129..e266924 100644
--- a/src/marks.h
+++ b/src/marks.h
@@ -52,6 +52,13 @@ enum class Mark {
Flagged,
Passed,
Replied,
+ /// Someone forwarded this message TO the user.
+ ///
+ /// Not a Maildir flag and not a tag: derived from the subject line at paint
+ /// time, so it stores nothing and reaches no server. Passed means "I
+ /// forwarded this", which is a different fact about a different person,
+ /// which is why this is its own mark rather than a reuse.
+ ReceivedForward,
ExpanderCollapsed,
ExpanderExpanded,
};
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 6162a5f..fcd8e4f 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -18,6 +18,8 @@
#include "threadlistmodel.h"
+#include "composecontext.h"
+
#include <QSet>
#include <QBrush>
@@ -177,6 +179,65 @@ void ThreadListModel::setFlatMode(bool flat)
endResetModel();
}
+void ThreadListModel::setTrashView(bool trash)
+{
+ if (m_trashView == trash)
+ return;
+
+ m_trashView = trash;
+
+ // A repaint, NOT a reset: this changes two colour roles and nothing about
+ // the shape of the tree, so unlike setFlatMode() there are no child rows
+ // to invalidate and a reset would collapse every expanded thread for a
+ // change of paint. Emitted over the whole list including children, since
+ // the message-row branch reads the same flag.
+ if (m_threads.isEmpty())
+ return;
+ const QVector<int> roles{ Qt::BackgroundRole, Qt::ForegroundRole };
+ emit dataChanged(index(0, 0, QModelIndex()),
+ index(m_threads.size() - 1, 0, QModelIndex()), roles);
+ for (int row = 0; row < m_threads.size(); ++row) {
+ const QModelIndex parent = index(row, 0, QModelIndex());
+ const int children = rowCount(parent);
+ if (children > 0) {
+ emit dataChanged(index(0, 0, parent),
+ index(children - 1, 0, parent), roles);
+ }
+ }
+}
+
+void ThreadListModel::removeThreadsWithoutTag(const QString &tag)
+{
+ if (tag.isEmpty() || m_threads.isEmpty())
+ return;
+
+ // The tags a row is judged on are the ones its CARD draws: the loaded
+ // message's own when there is one, the thread's union otherwise. That is
+ // the same substitution data() makes for a thread row, and using the
+ // summary alone would keep a row whose displayed message lost the tag
+ // while a sibling still carries it.
+ const auto keeps = [&tag](const ThreadNode &node) {
+ if (!node.first.messageId.isEmpty())
+ return node.first.tags.contains(tag);
+ return node.summary.tags.contains(tag);
+ };
+
+ // Backwards, in contiguous runs, exactly as reconcile() removes: each
+ // beginRemoveRows renumbers everything after it, so walking forwards
+ // removes the wrong rows after the first deletion.
+ for (int row = m_threads.size() - 1; row >= 0; --row) {
+ if (keeps(m_threads.at(row)))
+ continue;
+ int first = row;
+ while (first > 0 && !keeps(m_threads.at(first - 1)))
+ --first;
+ beginRemoveRows({}, first, row);
+ m_threads.remove(first, row - first + 1);
+ endRemoveRows();
+ row = first;
+ }
+}
+
int ThreadListModel::rowCount(const QModelIndex &parent) const
{
if (!parent.isValid())
@@ -338,6 +399,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return node.isFlagged();
case IsPassedRole:
return node.isPassed();
+ case IsReceivedForwardRole:
+ return ComposeContextBuilder::subjectIsForwarded(node.subject,
+ m_forwardPrefixes);
case IsRepliedRole:
return node.isReplied();
case ReplyCountRole:
@@ -352,9 +416,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// thread row does. Without this branch a message-scoped Delete
// repainted a reply identically to an undeleted one, so the
// pending count moved and nothing on screen did.
- if (node.isDoomed())
+ // Suppressed in the trash view, exactly as on a thread row: see
+ // the comment there. Both branches must agree, or an expanded
+ // thread in the trash paints its replies crimson under an
+ // untinted root.
+ if (node.isDoomed()
+ && !(m_trashView && node.isDeleted() && !node.isSpam())) {
return QBrush(node.isDeleted() ? deletedColour()
: spamColour());
+ }
// Tinted, so an expanded thread reads as one block rather than as
// more table rows. Applied per cell here; ThreadListView fills the
@@ -401,8 +471,16 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// read colour is mixed toward the BACKGROUND, so leaving it here
// would compute a grey against the pane's base and then paint it
// over red.
- if (node.isDoomed())
+ //
+ // Tied to the FILL, not to isDoomed(): where the fill is
+ // suppressed in the trash view there is no red to sit on, and
+ // white text would land on the ordinary background unreadable.
+ // The strike-out below is deliberately NOT suppressed, since it
+ // is the cue that survives without colour at all.
+ if (node.isDoomed()
+ && !(m_trashView && node.isDeleted() && !node.isSpam())) {
return QBrush(QColor(Qt::white));
+ }
// Dimmed whether read or not, for the same reason as the font: a
// reply is subordinate content. An unread one is left undimmed so
@@ -611,6 +689,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return thread.isFlagged();
case IsPassedRole:
return thread.isPassed();
+ case IsReceivedForwardRole:
+ return ComposeContextBuilder::subjectIsForwarded(thread.subject,
+ m_forwardPrefixes);
case IsRepliedRole:
return thread.isReplied();
case ReplyCountRole:
@@ -633,7 +714,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// whole row: a cue on a single column disappears as soon as that column
// scrolls out of view, which is exactly how the tag change used to go
// unnoticed.
- if (thread.isDoomed()) {
+ //
+ // In the TRASH view the deleted fill is suppressed: every row there is
+ // deleted, so a list painted entirely crimson tells the user nothing they
+ // did not ask for by opening the trash, and costs the legibility the fill
+ // borrows. Only `deleted` is suppressed; a SPAM row keeps its tint, since
+ // "this is junk" is still news in a folder that only promises "this is
+ // thrown away".
+ const bool suppressed = m_trashView && thread.isDeleted() && !thread.isSpam();
+ if (thread.isDoomed() && !suppressed) {
if (role == Qt::BackgroundRole)
return QBrush(thread.isDeleted() ? deletedColour() : spamColour());
if (role == Qt::ForegroundRole)
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 717537c..2e56328 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -135,6 +135,12 @@ public:
/// Item 69 draws this as a mark where it used to read as the word
/// "passed" in the tag strip.
IsPassedRole,
+ /// True when the SUBJECT reads as a forward someone sent the user.
+ ///
+ /// Item 68. Derived from the subject at query time, not from a tag or
+ /// a Maildir flag: `passed` means "I forwarded this", which is a
+ /// different fact. Nothing is stored and nothing reaches the server.
+ IsReceivedForwardRole,
/// bool; the message was replied to, from the Maildir "R" flag.
IsRepliedRole,
@@ -187,6 +193,16 @@ public:
/// The pattern DateFormatRole answers with. Empty means the system format.
void setDateFormat(const QString &format) { m_dateFormat = format; }
+ /// Extra subject prefixes counting as a received forward (item 68).
+ ///
+ /// Pushed in from the config exactly as setDateFormat() is, rather than
+ /// giving the model a Config: both are display values the window already
+ /// holds, and the model draws rather than resolves.
+ void setForwardPrefixes(const QStringList &prefixes)
+ {
+ m_forwardPrefixes = prefixes;
+ }
+
/// 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
@@ -203,6 +219,29 @@ public:
/// The children are not discarded, only hidden. Leaving flat mode restores
/// the tree without reloading anything.
void setFlatMode(bool flat);
+
+ /// Whether the list is showing the trash view.
+ ///
+ /// The doomed fill exists to tell the user a message is on its way out of
+ /// a view it is still sitting in. In the trash that is redundant: every
+ /// row is deleted, and a list painted entirely crimson says nothing while
+ /// costing legibility. Set on EVERY query run, like flat mode, so it
+ /// cannot leak into the next view.
+ void setTrashView(bool trash);
+
+ /// Drops any top-level row whose message no longer carries \p tag.
+ ///
+ /// The optimistic counterpart to a row simply vanishing at the next query.
+ /// Delete strips `inbox`, and in the Inbox view the row it stripped it
+ /// from stops belonging there; leaving it until the next sync is what made
+ /// a deleted message sit in the inbox looking undeleted.
+ ///
+ /// Top-level rows ONLY, and deliberately: a reply that no longer matches
+ /// still belongs to the conversation the user has open, and removing it
+ /// would collapse a thread under the reader's hands. \p tag is the tag the
+ /// CURRENT VIEW requires, so a caller passes what the query filters on and
+ /// nothing else.
+ void removeThreadsWithoutTag(const QString &tag);
bool flatMode() const { return m_flatMode; }
QModelIndex index(int row, int column,
@@ -414,5 +453,7 @@ private:
QVector<ThreadNode> m_threads;
const TagColors *m_tagColors = nullptr;
QString m_dateFormat;
+ QStringList m_forwardPrefixes;
bool m_flatMode = false;
+ bool m_trashView = false;
};
diff --git a/src/types.h b/src/types.h
index 7464586..cf2411d 100644
--- a/src/types.h
+++ b/src/types.h
@@ -252,7 +252,15 @@ struct ComposeContext
QString accountKey; ///< Which account sends. Plain data here; the resolution rules live with whatever builds this context.
Kind kind = Kind::New;
QString originalPath; ///< The .eml being replied to or forwarded. Empty for New.
- QString inReplyTo; ///< Message-ID of the original.
+ QString inReplyTo; ///< Message-ID of the original. EMPTY for a Forward: carrying In-Reply-To would file the forward under the thread it left, in the recipient's client.
+
+ /// Message-ID of the message being answered, for flagging it afterwards.
+ ///
+ /// Item 68. Separate from inReplyTo because that is a THREADING header and
+ /// is deliberately empty on a Forward, while the P flag still has to land
+ /// on the message that was forwarded. Set for Reply, ReplyAll and Forward;
+ /// empty for New and for a resumed Draft.
+ QString sourceMessageId;
QStringList references; ///< The original's References plus its Message-ID.
QStringList to; ///< Pre-filled, the user's own addresses already stripped.
QStringList cc;