aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.h2
-rw-r--r--src/mainwindow.cpp32
-rw-r--r--src/mainwindow.h12
-rw-r--r--src/notmuchworker.cpp26
-rw-r--r--src/notmuchworker.h2
-rw-r--r--src/tagdialog.cpp2
-rw-r--r--src/types.h5
7 files changed, 55 insertions, 26 deletions
diff --git a/src/config.h b/src/config.h
index aa3c332..5a43d8c 100644
--- a/src/config.h
+++ b/src/config.h
@@ -109,7 +109,7 @@ struct Account
/// The account's inbox folder, relative to maildir. Optional.
///
/// Only Restore reads it, as the destination for a message that carries no
- /// `deleted-from:` origin, which is what mail trashed by another client
+ /// `moved-from:` origin, which is what mail trashed by another client
/// looks like. Defaults to "Inbox", the Maildir convention and mbsync's
/// own default.
///
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 2cba6b1..6c66953 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -5969,7 +5969,7 @@ MainWindow::TagPresence MainWindow::selectionTagPresence(
// three-message thread left the replies undeleted, so the union
// carried no `deleted`, so a second press read the row as
// not-deleted and deleted it AGAIN, trash-to-trash, ending with
- // `deleted-from:inbox` and `deleted-from:Trash` at once and no way
+ // `moved-from:inbox` and `moved-from:Trash` at once and no way
// back. Item 177 removes the case rather than the symptom: a
// three-message row is a conversation now and is asked about its
// conversation, above.
@@ -5979,7 +5979,7 @@ MainWindow::TagPresence MainWindow::selectionTagPresence(
// messageById() and NOT summary.firstMessageTags, which is the
// value the QUERY delivered and is not refreshed by an optimistic
// update: applyMessageTagChange() writes the row's node, so after
- // a delete the node reads `deleted, deleted-from:inbox` while the
+ // a delete the node reads `deleted, moved-from:inbox` while the
// summary still reads `inbox, unread`. Measured, and preferring
// the summary left this defect exactly as it was.
tags = own.messageId.isEmpty() ? summary.firstMessageTags
@@ -6349,7 +6349,7 @@ void MainWindow::trashMessages(const QStringList &messageIds,
// 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.
+ // on it surviving, since `moved-from:` carries the origin.
sendMove(it.value(), it.key(),
{ QStringLiteral("deleted"), kOriginTagPlaceholder() },
{ QStringLiteral("unread"), QStringLiteral("inbox") },
@@ -6362,7 +6362,7 @@ void MainWindow::trashMessages(const QStringList &messageIds,
QString MainWindow::originTagFor(const QString &dbRelativeFolder) const
{
- // `acct/inbox` becomes `deleted-from:inbox`. The tag stores the folder
+ // `acct/inbox` becomes `moved-from:inbox`. The tag stores the folder
// relative to the ACCOUNT, never to the database: the account prefix is
// recomposed from the message's own path when it is read back, so storing
// it would duplicate it and would go stale the day the user renames a
@@ -6371,7 +6371,7 @@ QString MainWindow::originTagFor(const QString &dbRelativeFolder) const
// Shared by the two sites that need the tag, rather than derived twice.
// They disagreed once already: onMessagesMoved() resolved a placeholder
// from the folder the worker reported, which on a RESTORE is the trash
- // rather than the origin, so the restore stripped `deleted-from:Trash`
+ // rather than the origin, so the restore stripped `moved-from:Trash`
// and left the real tag in place.
const Account account =
accountForMessagePath(dbRelativeFolder + QLatin1Char('/'));
@@ -6382,7 +6382,7 @@ QString MainWindow::originTagFor(const QString &dbRelativeFolder) const
}
if (accountRelative.isEmpty())
return QString();
- return QStringLiteral("deleted-from:%1").arg(accountRelative);
+ return QString(kOriginTagPrefix) + accountRelative;
}
void MainWindow::trashThreads(const QStringList &threadIds)
@@ -6476,9 +6476,9 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds,
return;
// Restore, resolved per message: each one goes back to the folder its own
- // `deleted-from:` tag names, so a thread whose messages were deleted from
+ // `moved-from:` tag names, so a thread whose messages were deleted from
// different folders reassembles correctly rather than collapsing into one.
- const QString prefix = QStringLiteral("deleted-from:");
+ const QString prefix = QString::fromLatin1(kOriginTagPrefix);
QHash<QString, QStringList> byOrigin;
QStringList unknown;
for (int i = 0; i < messageIds.size(); ++i) {
@@ -6592,7 +6592,7 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds,
if (messageIds.size() != paths.size() || messageIds.size() != tags.size())
return;
- const QString prefix = QStringLiteral("deleted-from:");
+ const QString prefix = QString::fromLatin1(kOriginTagPrefix);
QHash<QString, QStringList> byOrigin;
QHash<QString, QStringList> byInbox;
QStringList stranded;
@@ -6652,7 +6652,7 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds,
// and is out of scope here.
//
// The destination FOLDER, taken from the key rather than from
- // `origin` above: that is the finished TAG, `deleted-from:Inbox`,
+ // `origin` above: that is the finished TAG, `moved-from:Inbox`,
// which never equals `Inbox` however the account spells it. The
// comparison was therefore always false and the `inbox` tag never came
// back, so a restored message sat in the inbox folder invisible to the
@@ -6900,7 +6900,7 @@ void MainWindow::restoreSelected(bool fallbackToInbox)
// Where each message came from, read back off its own tag. This is what
// the tag exists for: the file has moved, so nothing on disk and nothing
// in notmuch still records the original folder.
- const QString prefix = QStringLiteral("deleted-from:");
+ const QString prefix = QString::fromLatin1(kOriginTagPrefix);
QHash<QString, QStringList> byOrigin;
QStringList unknown;
for (const QString &messageId : scope.messageIds) {
@@ -6986,8 +6986,8 @@ void MainWindow::restoreSelected(bool fallbackToInbox)
// onMessagesMoved() resolves the placeholder from the origin the
// WORKER reports, which is where the message is coming FROM. On a
// delete that is the inbox and correct; on a restore it is the trash,
- // so the placeholder resolved to `deleted-from:Trash` and asked to
- // remove a tag that never existed, while the real `deleted-from:inbox`
+ // so the placeholder resolved to `moved-from:Trash` and asked to
+ // remove a tag that never existed, while the real `moved-from:inbox`
// was never named. The message came home still claiming to have been
// deleted from somewhere, which then made Restore offer to move a
// message that was already back.
@@ -7102,7 +7102,7 @@ void MainWindow::sendMove(const QStringList &messageIds,
// account before the first confirmation arrives both name `acct/Trash`,
// so the second insert overwrote the first and the second confirmation
// took an empty PendingMove. That file landed in the trash carrying
- // neither `deleted` nor `deleted-from:`, which makes it unrestorable and
+ // neither `deleted` nor `moved-from:`, which makes it unrestorable and
// invisible to a `tag:deleted` query. The worker handles one move at a
// time on its own thread and emits in the order it was asked, so a plain
// FIFO matches confirmations to requests without needing a key at all.
@@ -7279,7 +7279,7 @@ void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId
return;
// The origin differs per message, so the tags do too: two messages deleted
- // from different folders get different `deleted-from:` tags out of one
+ // from different folders get different `moved-from:` tags out of one
// gesture. Grouped by the resolved tag list so identical ones still travel
// as a single write.
QHash<QString, QStringList> byOrigin;
@@ -7325,7 +7325,7 @@ void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId
// It used to be handed pending.add straight, which still holds the
// unresolved placeholder: undo then asked to remove a tag by that
// literal name, which no message carries, so the removal was a silent
- // no-op and `deleted-from:inbox` survived the undo. The file came home
+ // no-op and `moved-from:inbox` survived the undo. The file came home
// still claiming to have been deleted from somewhere. Same defect as
// the one the second-Delete path had, reached through Ctrl+Z instead.
//
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 55d67ae..97d90e5 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -1187,7 +1187,7 @@ private:
/// failed, which is the half-done state item 103 exists to remove.
///
/// `add` may contain the placeholder kOriginTagPlaceholder, which
- /// onMessagesMoved() replaces with `deleted-from:<origin>` per message.
+ /// onMessagesMoved() replaces with `moved-from:<origin>` per message.
/// The origin is not known until the worker reports it, and it differs per
/// message in a multi-row selection.
/// `fromUndo` marks a move the undo stack itself started, which must NOT
@@ -1241,7 +1241,7 @@ private:
const QString &requestTag);
/// The inverse: moves each selected row's message back to the folder its
- /// `deleted-from:` tag names, stripping both tags.
+ /// `moved-from:` tag names, stripping both tags.
///
/// `fallbackToInbox` decides what happens to a message with NO origin tag,
/// and the two callers want opposite things. From the trash view the
@@ -1320,7 +1320,7 @@ private:
/// in it, and such a message carries no tag of ours.
bool isShowingTrash() const;
- /// The `deleted-from:` tag naming `dbRelativeFolder`, or empty when no
+ /// The `moved-from:` tag naming `dbRelativeFolder`, or empty when no
/// account owns it.
///
/// One rule for both sites that need the tag: the delete that writes it
@@ -1381,7 +1381,7 @@ private:
/// account before the first confirmation arrives name the same folder, so
/// a keyed map dropped the first entry and left the second confirmation
/// with nothing to apply. That file reached the trash carrying neither
- /// `deleted` nor `deleted-from:`, unrestorable and invisible to a
+ /// `deleted` nor `moved-from:`, unrestorable and invisible to a
/// `tag:deleted` query. The worker moves one batch at a time and emits in
/// request order, so position alone matches a confirmation to its request.
struct PendingMove {
@@ -1477,7 +1477,7 @@ private:
friend class MessageTagCommand;
friend class MoveCommand;
- /// Stands in for `deleted-from:<origin>` between asking for a move and
+ /// Stands in for `moved-from:<origin>` between asking for a move and
/// learning where each message actually came from. Not a tag anyone ever
/// sees: onMessagesMoved() substitutes the real one per message before
/// anything is written.
@@ -2061,7 +2061,7 @@ private:
/// Undo entry for a message MOVE, which is a file rename plus a tag change.
///
/// The destination is CARRIED rather than derived, and that is the whole
-/// reason `deleted-from:` exists at all. A Maildir filename does not record
+/// reason `moved-from:` exists at all. A Maildir filename does not record
/// where a message came from, and once the file has moved notmuch cannot
/// answer either, so an undo that recomputed the origin would have nothing to
/// recompute it from. Each message carries its own, since one selection can
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index bc3ae65..2672f6a 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -36,6 +36,7 @@
#include <QSet>
#include <cstdlib>
+#include <utility>
#include "maildirname.h"
#include "mimeparser.h"
@@ -1122,17 +1123,40 @@ void NotmuchWorker::applyTags(const TagChange &change)
// write, so the only way to know is to look first.
const QStringList before = tagsOf(message.get());
+ // One origin tag ever: writing a `moved-from:` tag strips any OTHER
+ // tag with that prefix the message still carries, so a message that
+ // travelled inbox -> spam -> trash ends with exactly one origin and
+ // Restore has one answer. Without this the reader's first-match
+ // break() picks silently.
+ QStringList strippedOrigins;
+ const bool writingOrigin =
+ std::any_of(change.added.cbegin(), change.added.cend(),
+ [](const QString &t) {
+ return t.startsWith(QLatin1String(kOriginTagPrefix));
+ });
+ if (writingOrigin) {
+ for (const QString &tag : std::as_const(before)) {
+ if (tag.startsWith(QLatin1String(kOriginTagPrefix))
+ && !change.added.contains(tag)) {
+ strippedOrigins.append(tag);
+ }
+ }
+ }
+
bool moves = false;
for (const QString &tag : change.removed)
moves = moves || before.contains(tag);
for (const QString &tag : change.added)
moves = moves || !before.contains(tag);
+ moves = moves || !strippedOrigins.isEmpty();
if (moves)
changedIds.append(id);
notmuch_message_freeze(message.get());
for (const QString &tag : change.removed)
notmuch_message_remove_tag(message.get(), tag.toUtf8().constData());
+ for (const QString &tag : strippedOrigins)
+ notmuch_message_remove_tag(message.get(), tag.toUtf8().constData());
for (const QString &tag : change.added)
notmuch_message_add_tag(message.get(), tag.toUtf8().constData());
notmuch_message_thaw(message.get());
@@ -1689,7 +1713,7 @@ void NotmuchWorker::resolveQuery(const QString &query,
QDir(dbRoot).relativeFilePath(QString::fromUtf8(rawName)));
// Joined by a TAB, not a space. A notmuch tag may absolutely contain
// a space: a Maildir folder named "Inbox/SlackBuilds users" produces
- // `deleted-from:Inbox/SlackBuilds users`, and splitting that on spaces
+ // `moved-from:Inbox/SlackBuilds users`, and splitting that on spaces
// truncated the folder to "Inbox/SlackBuilds". Restore then moved the
// messages into a folder of that name, CREATING it, so four real
// messages ended up in a directory mbsync does not sync and the user
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 32eab58..b4e9707 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -398,7 +398,7 @@ signals:
/// order. `requestTag` is echoed back so a caller can tell which request
/// this answers.
/// `tags` carries each message's tags joined by a space, in the same
- /// order. Needed because Restore reads a message's `deleted-from:` tag to
+ /// order. Needed because Restore reads a message's `moved-from:` tag to
/// decide where to send it, and an unexpanded thread's messages have no
/// node in the model to read tags from.
void threadMessagesResolved(const QStringList &messageIds,
diff --git a/src/tagdialog.cpp b/src/tagdialog.cpp
index fb2c629..1810e63 100644
--- a/src/tagdialog.cpp
+++ b/src/tagdialog.cpp
@@ -276,7 +276,7 @@ void TagDialog::accept()
// troublesome tag being CREATED; a tag that already exists is a fact, and
// refusing to remove it because it breaks a rule leaves the user with a
// tag they can see, cannot type, and cannot get rid of. That happened with
- // `deleted-from:Inbox/SlackBuilds users`: an origin tag naming a Maildir
+ // `moved-from:Inbox/SlackBuilds users`: an origin tag naming a Maildir
// folder whose name contains a space, rejected by the space rule, so the
// one dialog that could have cleared it refused the only text that names
// it. Whether such a tag SHOULD exist is a separate question from whether
diff --git a/src/types.h b/src/types.h
index fb7901c..6fd54a2 100644
--- a/src/types.h
+++ b/src/types.h
@@ -23,6 +23,11 @@
#include <QString>
#include <QStringList>
+/// Prefix of the origin tag a move writes. One origin tag per message,
+/// overwritten on each move, so a reader cannot be handed two and forced to
+/// pick one silently. Not translated, not user-facing.
+inline constexpr auto kOriginTagPrefix = "moved-from:";
+
struct ThreadSummary
{
QString threadId;