aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-17 21:31:31 +0200
committerDanilo M. <danix@danix.xyz>2026-08-17 21:31:31 +0200
commit262174407eabcb986f15c116d39b7ab98fdf0150 (patch)
treef8ee5dfb2e06bedcba0f9582cb7fc60b21412e1e /src
parente125d970aa2f4ad6cd494e5f410ba1c5e53f5308 (diff)
downloadqtmaildir-262174407eabcb986f15c116d39b7ab98fdf0150.tar.gz
qtmaildir-262174407eabcb986f15c116d39b7ab98fdf0150.zip
feat(delete): move the message to the account's trash folder
Delete added the `deleted` tag and moved nothing, so deleted mail sat in the inbox indefinitely with only a chip saying otherwise. It now moves the file into the account's trash, records where it came from, and moves it back on undo. The origin is derived in the WORKER, not in the UI, because nowhere else knows it. A Maildir filename does not record the folder a message came from and notmuch cannot answer once the file has moved, so the moment the old filename exists inside moveMessages() is the only place it can be read. It travels back on a new messagesMovedFrom() signal, and the UI turns it into a `deleted-from:<folder>` tag that Restore reads days later. The account is resolved from the message's PATH rather than from its account tag: that tag is optional config, so resolving through it would silently make an account undeletable. That needed ThreadSummary to carry the first message's path, since an unexpanded thread row is the ordinary case and held no path at all. It is reported relative to the database root, because the UI knows accounts only by their maildir, itself a database-relative prefix. accountForMessagePath() accepts both an absolute and a relative path, and that is load-bearing rather than defensive: a thread row's path is relative while a reply row's is absolute, since MimeParser has to open it. Matching only one form left Delete on a reply resolving to no account and moving nothing, which is the thread-row/reply-row asymmetry this file has been bitten by before. Tags are applied only once the worker CONFIRMS the move. Tagging first would leave a message marked deleted in a folder it never left when a rename fails, which is the half-done state this removes. A move made during a sync is held in its own queue and flushed like a tag edit: the existing queue carries tag changes only, so a move pushed through it would apply `deleted` and never move the file. An account with no trash configured reports through the status bar and tags nothing, as a second line of defence behind the config-load warning. Six existing tests used `delete` as a stand-in for a message-scoped tag action on bare windows with no account; they move to `spam` and `delete_thread`, which stayed tag-only, keeping the property each was actually testing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp292
-rw-r--r--src/mainwindow.h139
-rw-r--r--src/notmuchworker.cpp56
-rw-r--r--src/notmuchworker.h20
-rw-r--r--src/threadlistmodel.cpp11
-rw-r--r--src/types.h20
6 files changed, 536 insertions, 2 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 67d3ee4..d3f2dc7 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -841,10 +841,13 @@ void MainWindow::registerActions()
// is about reading a message at all.
const bool allDeleted = everySelectedRowHasTag(QStringLiteral("deleted"));
+ // Item 103. A MOVE now, not only a tag: Delete used to add `deleted`
+ // and leave the file exactly where it was, so deleted mail sat in the
+ // inbox indefinitely and only the chip said otherwise.
if (allDeleted)
- tagSelected({}, { QStringLiteral("deleted") }, tr("Undelete"));
+ restoreSelected();
else
- tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete"));
+ trashSelected();
});
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
@@ -1604,6 +1607,12 @@ void MainWindow::wireWorker()
connect(m_worker, &NotmuchWorker::tagsApplied,
this, &MainWindow::onTagsApplied);
+ // messagesMovedFrom rather than messagesMoved: the tags a move carries can
+ // only be resolved once the origins are known, and that signal is the one
+ // that reports them.
+ connect(m_worker, &NotmuchWorker::messagesMovedFrom,
+ this, &MainWindow::onMessagesMoved);
+
m_workerThread.start();
// Queued behind the thread start, so the completer has real tags as soon
@@ -2922,6 +2931,21 @@ bool MainWindow::aSyncHoldsTheWriteLock() const
void MainWindow::flushHeldEdits()
{
+ // Moves first, and they are flushed even when no tag edit is waiting: the
+ // early return below used to be the whole guard, so a held move with an
+ // empty edit queue would never have been sent at all. That is item 106's
+ // data loss with a worse shape, since a dropped move leaves the file where
+ // the user asked it not to be.
+ if (!m_heldMoves.isEmpty()) {
+ const QVector<HeldMove> moves = m_heldMoves;
+ m_heldMoves.clear();
+ for (const HeldMove &move : moves) {
+ sendMove(move.messageIds, move.destFolder, move.add, move.remove,
+ move.description);
+ }
+ updatePendingIndicator();
+ }
+
if (m_heldEdits.isEmpty())
return;
@@ -4082,6 +4106,270 @@ void MainWindow::sendMessageTagChange(const QStringList &messageIds,
Q_ARG(TagChange, m_pendingChange));
}
+const QString &MainWindow::kOriginTagPlaceholder()
+{
+ // Not wrapped in tr(). It is never displayed: onMessagesMoved() replaces
+ // it with a real tag before anything reaches the worker, and a translated
+ // placeholder would stop matching in the one locale that translated it,
+ // which is the trap CLAUDE.md records for startup_query.
+ static const QString placeholder =
+ QStringLiteral("\x01qtmaildir-origin-placeholder");
+ return placeholder;
+}
+
+Account MainWindow::accountForMessagePath(const QString &path) const
+{
+ // From the PATH, not from the thread's account tag. The tag is optional
+ // config, so resolving through it would silently disable Delete for an
+ // account that never set one; a message's maildir prefix is what makes it
+ // belong to an account at all.
+ //
+ // Longest maildir wins, so nested account maildirs (`mail` and
+ // `mail/work`) resolve to the more specific one rather than to whichever
+ // happens to be listed first.
+ //
+ // BOTH path shapes are accepted, and that is not defensive coding. A
+ // thread row's path comes from ThreadSummary::firstMessagePath and is
+ // database-RELATIVE; a reply row's comes from MessageNode::filePath and is
+ // ABSOLUTE, because MimeParser has to open it. Matching only the relative
+ // form resolved every reply to no account, so Delete on a reply reported
+ // "no trash folder configured" and moved nothing, which is exactly the
+ // thread-row/reply-row asymmetry this file has been bitten by before.
+ //
+ // A `/` is required after the maildir in both cases, so `acctX` cannot
+ // match an account whose maildir is `acct`.
+ Account best;
+ int bestLength = -1;
+ for (const Account &account : m_config.accounts()) {
+ if (account.maildir.isEmpty())
+ continue;
+ const QString segment = QLatin1Char('/') + account.maildir
+ + QLatin1Char('/');
+ const bool matches =
+ path.startsWith(account.maildir + QLatin1Char('/'))
+ || path.contains(segment);
+ if (!matches)
+ continue;
+ if (account.maildir.length() > bestLength) {
+ best = account;
+ bestLength = account.maildir.length();
+ }
+ }
+ return best;
+}
+
+void MainWindow::trashSelected()
+{
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty())
+ return;
+
+ // Message scope, exactly as tagSelected() uses by default: a thread row
+ // stands for the ONE message its card displays. Escalating to the thread
+ // would move a whole conversation into the trash because the user deleted
+ // one reply.
+ const ActionScope scope = m_model->messageScopeFor(rows);
+ if (scope.messageIds.isEmpty())
+ return;
+
+ // Grouped by destination, because moveMessages() takes one folder per call
+ // and a selection can span accounts with different trash folders.
+ QHash<QString, QStringList> byTrash;
+ QStringList unconfigured;
+ for (const QString &messageId : scope.messageIds) {
+ const QString path = m_model->messageById(messageId).filePath;
+ const Account account = accountForMessagePath(path);
+ if (account.trash.isEmpty()) {
+ unconfigured.append(messageId);
+ continue;
+ }
+ byTrash[account.maildir + QLatin1Char('/') + account.trash]
+ .append(messageId);
+ }
+
+ // Task 2 warns at config load; this is the second line of defence, for a
+ // user who never fixed it. Reported rather than silently doing nothing,
+ // and NOT tagged either: a `deleted` tag on a file still in the inbox is
+ // precisely the half-done state this item removes.
+ if (!unconfigured.isEmpty()) {
+ m_statusLabel->setText(
+ tr("%n message(s) could not be deleted: no trash folder is "
+ "configured for their account.", "", int(unconfigured.size())));
+ }
+
+ if (byTrash.isEmpty())
+ return;
+
+ for (auto it = byTrash.cbegin(); it != byTrash.cend(); ++it) {
+ sendMove(it.value(), it.key(),
+ { QStringLiteral("deleted"), kOriginTagPlaceholder() }, {},
+ tr("Delete"));
+ }
+
+ showTransientStatus(
+ tr("%1: %n message(s)", "", scope.messageCount).arg(tr("Delete")));
+}
+
+void MainWindow::restoreSelected()
+{
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty())
+ return;
+
+ const ActionScope scope = m_model->messageScopeFor(rows);
+ if (scope.messageIds.isEmpty())
+ return;
+
+ // 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:");
+ QHash<QString, QStringList> byOrigin;
+ QStringList unknown;
+ for (const QString &messageId : scope.messageIds) {
+ const MessageNode node = m_model->messageById(messageId);
+ QString origin;
+ for (const QString &tag : node.tags) {
+ if (tag.startsWith(prefix)) {
+ origin = tag.mid(prefix.length());
+ break;
+ }
+ }
+ // An account prefix is needed to name a folder to the worker, which
+ // works in database-relative paths. The origin tag stores the folder
+ // relative to the ACCOUNT, so the two are recomposed here.
+ const Account account = accountForMessagePath(node.filePath);
+ if (origin.isEmpty() || account.maildir.isEmpty()) {
+ unknown.append(messageId);
+ continue;
+ }
+ byOrigin[account.maildir + QLatin1Char('/') + origin].append(messageId);
+ }
+
+ if (!unknown.isEmpty()) {
+ // No origin recorded, which is the case for mail deleted by an older
+ // version or tagged by hand. The tag comes off so the row stops
+ // claiming to be deleted, but no file moves: guessing a folder would
+ // put the message somewhere the user never had it.
+ sendMessageTagChange(unknown, {}, { QStringLiteral("deleted") },
+ tr("Undelete"));
+ m_undoStack.push(new MessageTagCommand(
+ this, unknown, {}, { QStringLiteral("deleted") }, tr("Undelete")));
+ }
+
+ for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
+ sendMove(it.value(), it.key(), {},
+ { QStringLiteral("deleted"), kOriginTagPlaceholder() },
+ tr("Undelete"));
+ }
+
+ showTransientStatus(
+ tr("%1: %n message(s)", "", scope.messageCount).arg(tr("Undelete")));
+}
+
+void MainWindow::sendMove(const QStringList &messageIds,
+ const QString &destFolder, const QStringList &add,
+ const QStringList &remove,
+ const QString &description)
+{
+ if (messageIds.isEmpty() || destFolder.isEmpty())
+ return;
+
+ // Held during a sync for the same reason every tag write is: the worker's
+ // read-write open BLOCKS on notmuch's exclusive lock rather than failing,
+ // so sending now would freeze the worker for the rest of the run.
+ //
+ // A move is held as the MOVE it is, not decomposed into a tag edit. The
+ // held-edit queue carries tag changes only, so a move pushed through it
+ // would apply the tags and never move the file, which is worse than
+ // waiting: the message would read as deleted and still be in the inbox.
+ if (aSyncHoldsTheWriteLock()) {
+ m_heldMoves.append(
+ HeldMove{ messageIds, destFolder, add, remove, description });
+ m_statusLabel->setText(
+ tr("A sync is running; your change will be applied when it "
+ "finishes."));
+ updatePendingIndicator();
+ return;
+ }
+
+ // 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.
+ m_pendingMoves.insert(destFolder, PendingMove{ add, remove, description });
+
+ QMetaObject::invokeMethod(m_worker, "moveMessages", Qt::QueuedConnection,
+ Q_ARG(QStringList, messageIds),
+ Q_ARG(QString, destFolder));
+}
+
+void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder)
+{
+ const PendingMove pending = m_pendingMoves.take(destFolder);
+ if (originByMessageId.isEmpty())
+ 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
+ // gesture. Grouped by the resolved tag list so identical ones still travel
+ // as a single write.
+ QHash<QString, QStringList> byOrigin;
+ for (auto it = originByMessageId.cbegin(); it != originByMessageId.cend();
+ ++it) {
+ byOrigin[it.value()].append(it.key());
+ }
+
+ for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
+ // The origin tag names the folder relative to the ACCOUNT, not to the
+ // database: `inbox`, never `acct/inbox`. Restore recomposes the
+ // account prefix from the message's own path, so storing it here would
+ // duplicate it, and a stored account prefix would go stale the day the
+ // user renames a maildir.
+ //
+ // The worker reports `acct/inbox`; the account's own maildir is
+ // `acct`, so the stored tag is `inbox`. Resolved through the first
+ // message's path, which is still the account's whichever folder it
+ // sits in now.
+ const QString dbRelativeOrigin = it.key();
+ const Account account = accountForMessagePath(dbRelativeOrigin
+ + QLatin1Char('/'));
+ QString accountRelative = dbRelativeOrigin;
+ if (!account.maildir.isEmpty()
+ && dbRelativeOrigin.startsWith(account.maildir
+ + QLatin1Char('/'))) {
+ accountRelative =
+ dbRelativeOrigin.mid(account.maildir.length() + 1);
+ }
+
+ auto resolve = [&](const QStringList &tags) {
+ QStringList out;
+ for (const QString &tag : tags) {
+ if (tag != kOriginTagPlaceholder()) {
+ out.append(tag);
+ continue;
+ }
+ if (!accountRelative.isEmpty()) {
+ out.append(QStringLiteral("deleted-from:%1")
+ .arg(accountRelative));
+ }
+ }
+ return out;
+ };
+
+ sendMessageTagChange(it.value(), resolve(pending.add),
+ resolve(pending.remove), pending.description);
+ }
+
+ // Pushed only now, because the origins are what makes the command
+ // reversible and they do not exist until the worker reports them. See
+ // MoveCommand: the destination has to be carried rather than derived.
+ m_undoStack.push(new MoveCommand(this, originByMessageId, destFolder,
+ pending.add, pending.remove,
+ pending.description));
+}
+
void MainWindow::sendThreadTagChange(const QStringList &threadIds,
const QStringList &add,
const QStringList &remove,
diff --git a/src/mainwindow.h b/src/mainwindow.h
index e741416..ada4845 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -739,6 +739,56 @@ private:
const QStringList &remove,
const QString &description);
+ /// Moves messages into `destFolder` and applies the tags that go with it.
+ ///
+ /// The counterpart to sendMessageTagChange() for the one action that is
+ /// not purely a tag change. Both trashSelected() and MoveCommand route
+ /// through this.
+ ///
+ /// The tags are NOT applied here: they are applied when the worker
+ /// confirms the move, in onMessagesMoved(). Tagging first would leave a
+ /// message marked `deleted` in a folder it never left if the rename
+ /// 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.
+ /// The origin is not known until the worker reports it, and it differs per
+ /// message in a multi-row selection.
+ void sendMove(const QStringList &messageIds, const QString &destFolder,
+ const QStringList &add, const QStringList &remove,
+ const QString &description);
+
+ /// Moves each selected row's message to its account's trash, tagging it
+ /// `deleted` and recording where it came from.
+ void trashSelected();
+
+ /// The inverse: moves each selected row's message back to the folder its
+ /// `deleted-from:` tag names, stripping both tags.
+ void restoreSelected();
+
+ /// The account whose maildir contains `path`, or an invalid account when
+ /// no configured maildir does.
+ ///
+ /// Resolved from the PATH rather than from the thread's account tag. The
+ /// tag is optional config, so an account without one would resolve to
+ /// nothing and silently disable Delete; the maildir prefix is what makes
+ /// a message belong to an account in the first place.
+ Account accountForMessagePath(const QString &path) const;
+
+ /// Confirms a move: applies the tags the move was asked to carry, with the
+ /// origin placeholder resolved per message.
+ void onMessagesMoved(const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder);
+
+ /// What a move asked to be tagged, held until the worker confirms it.
+ /// Keyed by destination folder so two moves in flight cannot be confused.
+ struct PendingMove {
+ QStringList add;
+ QStringList remove;
+ QString description;
+ };
+ QHash<QString, PendingMove> m_pendingMoves;
+
/// Undoes the optimistic model update for a write the worker rejected.
void revertPendingTagChange();
@@ -791,10 +841,35 @@ private:
/// it. Order matters: two edits touching one thread must reach the database
/// in the order they were made, or the later one does not win.
QVector<HeldEdit> m_heldEdits;
+
+ /// A MOVE not yet sent, for the same reason a tag edit is held.
+ ///
+ /// A separate queue rather than an entry in m_heldEdits, because a move is
+ /// not a tag change and cannot be replayed as one: pushing it through the
+ /// edit queue would apply `deleted` and never move the file, leaving the
+ /// message reading as deleted while still sitting in the inbox. Item 106
+ /// recorded what a dropped held edit costs, and a move dropped the same
+ /// way is worse: the tag lands and the file does not.
+ struct HeldMove {
+ QStringList messageIds;
+ QString destFolder;
+ QStringList add;
+ QStringList remove;
+ QString description;
+ };
+ QVector<HeldMove> m_heldMoves;
+
quint64 m_flushGeneration = 0;
friend class ThreadTagCommand;
friend class MessageTagCommand;
+ friend class MoveCommand;
+
+ /// Stands in for `deleted-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.
+ static const QString &kOriginTagPlaceholder();
Config m_config;
KeyMap m_keyMap;
@@ -1192,3 +1267,67 @@ private:
QString m_description;
bool m_firstRedo = true;
};
+
+/// 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
+/// 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
+/// span folders and accounts.
+///
+/// Grouped by destination: undoing a delete of five messages from three
+/// folders is three moves, not five, because moveMessages() takes one folder
+/// per call.
+class MoveCommand : public QUndoCommand
+{
+public:
+ /// `originByMessageId` names where each message came FROM, and
+ /// `destFolder` where they all went.
+ MoveCommand(MainWindow *window,
+ const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder, const QStringList &add,
+ const QStringList &remove, const QString &description)
+ : QUndoCommand(description), m_window(window),
+ m_origins(originByMessageId), m_dest(destFolder), m_add(add),
+ m_remove(remove), m_description(description) {}
+
+ /// The stack calls redo() when the command is pushed, by which point the
+ /// move has already been sent, so the first call is skipped. Same shape as
+ /// the two tag commands above.
+ void redo() override
+ {
+ if (m_firstRedo) {
+ m_firstRedo = false;
+ return;
+ }
+ m_window->sendMove(m_origins.keys(), m_dest, m_add, m_remove,
+ m_description);
+ }
+
+ void undo() override
+ {
+ // Back to each message's OWN folder, one call per distinct
+ // destination. The tags invert with the direction: what the delete
+ // added, the undo removes.
+ QHash<QString, QStringList> byOrigin;
+ for (auto it = m_origins.cbegin(); it != m_origins.cend(); ++it) {
+ if (!it.value().isEmpty())
+ byOrigin[it.value()].append(it.key());
+ }
+ for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
+ m_window->sendMove(it.value(), it.key(), m_remove, m_add,
+ QStringLiteral("Undo %1").arg(m_description));
+ }
+ }
+
+private:
+ MainWindow *m_window;
+ QMap<QString, QString> m_origins;
+ QString m_dest;
+ QStringList m_add;
+ QStringList m_remove;
+ QString m_description;
+ bool m_firstRedo = true;
+};
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 6c41839..6a0694f 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -160,6 +160,39 @@ void walkReplies(notmuch_messages_t *messages, int depth,
}
}
+/// The Maildir FOLDER a message file sits in, relative to the database root.
+///
+/// `<root>/acct/inbox/cur/12345` becomes `acct/inbox`: the `cur`/`new` segment
+/// is stripped because it is Maildir's read-state bookkeeping rather than part
+/// of the folder's name, and moveMessages() takes a folder without one. That
+/// makes the value round-trip: what comes out here can be handed straight back
+/// to move a message home.
+///
+/// Empty when the file is not under the root at all, which the caller treats as
+/// "origin unknown" rather than guessing. A wrong folder here would send a
+/// restored message somewhere the user never had it.
+QString folderOfMessageFile(const QString &root, const QString &filePath)
+{
+ const QString rootPath = QDir(root).absolutePath();
+ const QString dir = QFileInfo(filePath).absolutePath();
+
+ const QString relative = QDir(rootPath).relativeFilePath(dir);
+ // relativeFilePath happily walks upwards, so a path outside the root comes
+ // back as `../something` rather than as a failure.
+ if (relative.isEmpty() || relative == QStringLiteral(".")
+ || relative.startsWith(QStringLiteral("../"))) {
+ return QString();
+ }
+
+ QStringList parts = relative.split(QLatin1Char('/'), Qt::SkipEmptyParts);
+ if (!parts.isEmpty()
+ && (parts.last() == QStringLiteral("cur")
+ || parts.last() == QStringLiteral("new"))) {
+ parts.removeLast();
+ }
+ return parts.join(QLatin1Char('/'));
+}
+
} // namespace
/// Registers SortOrder for queued calls, once, before main() runs.
@@ -254,6 +287,13 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
}
NmThreads threads(rawThreads);
+ // Message paths are reported RELATIVE to this. An absolute path would be
+ // useless to the UI, which knows accounts only by their maildir, a
+ // database-relative prefix: comparing the two never matched and left every
+ // row resolving to no account at all.
+ const QString dbRoot =
+ QDir(QString::fromUtf8(notmuch_database_get_path(m_db))).absolutePath();
+
QVector<ThreadSummary> batch;
batch.reserve(kBatchSize);
int total = 0;
@@ -317,6 +357,10 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// The card's own tags, beside the thread's union above.
// Same walk, same index read, no extra query.
summary.firstMessageTags = tagsOf(message);
+ // Which account this belongs to, for Delete's destination.
+ summary.firstMessagePath = QDir(dbRoot).relativeFilePath(
+ QString::fromUtf8(
+ notmuch_message_get_filename(message)));
break;
}
}
@@ -329,6 +373,10 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// The card's own tags, beside the thread's union above.
// Same walk, same index read, no extra query.
summary.firstMessageTags = tagsOf(first);
+ // Which account this belongs to, for Delete's destination.
+ summary.firstMessagePath = QDir(dbRoot).relativeFilePath(
+ QString::fromUtf8(
+ notmuch_message_get_filename(first)));
}
}
}
@@ -649,6 +697,7 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
root + QLatin1Char('/') + destFolder + QStringLiteral("/cur");
QStringList moved;
+ QMap<QString, QString> origins;
for (const QString &id : messageIds) {
notmuch_message_t *raw = nullptr;
// find_message reports SUCCESS with a null message when the id is not
@@ -667,6 +716,10 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
// The handle is released before the file moves under it.
message.reset();
+ // Where it is coming FROM, captured here because this is the only
+ // moment the old filename exists. See messagesMovedFrom().
+ const QString origin = folderOfMessageFile(root, from);
+
// cur/, never new/. A file dropped in new/ is re-announced as fresh
// mail by every reader of the Maildir.
if (!QDir().mkpath(destDir)) {
@@ -680,6 +733,7 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
// Already where it was asked to go. Reported as moved, since the
// caller's request is satisfied.
moved.append(id);
+ origins.insert(id, origin);
continue;
}
@@ -712,12 +766,14 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
notmuch_database_remove_message(db, from.toUtf8().constData());
moved.append(id);
+ origins.insert(id, origin);
}
notmuch_database_close(db);
notmuch_database_destroy(db);
emit messagesMoved(moved, destFolder);
+ emit messagesMovedFrom(origins, destFolder);
}
void NotmuchWorker::requestAllTags(quint64 generation)
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index d8d8ff8..d1bec59 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -18,6 +18,7 @@
#pragma once
+#include <QMap>
#include <QObject>
#include <QStringList>
#include <QVector>
@@ -202,6 +203,25 @@ signals:
/// A stale id, a missing folder or a failed rename drops out here rather
/// than aborting the batch.
void messagesMoved(const QStringList &messageIds, const QString &destFolder);
+
+ /// The same move, reported per message with the folder it came FROM.
+ ///
+ /// Emitted alongside messagesMoved rather than replacing it: that signal's
+ /// shape is what test_notmuchworker asserts on, and a caller wanting only
+ /// "did it move" should not have to unpack a map.
+ ///
+ /// The origin has to be reported from HERE because nowhere else knows it.
+ /// A Maildir filename does not record the folder a message came from, and
+ /// once the file has moved notmuch cannot answer either; the UI holds no
+ /// path at all for a thread row it has not expanded. This is the one
+ /// moment the old filename exists, so it is the only place the origin can
+ /// be derived.
+ ///
+ /// Folders are relative to the database path and carry no `cur`/`new`
+ /// segment, matching the `destFolder` moveMessages() takes, so a value
+ /// from here can be passed straight back to move a message home.
+ void messagesMovedFrom(const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder);
void allTagsReady(const QStringList &tags, quint64 generation);
/// One entry per requested query, in the order they were asked for. A query
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 6ddd85c..6162a5f 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -696,6 +696,10 @@ ThreadListModel::nodeFor(const ThreadSummary &summary)
node.first.messageId = summary.firstMessageId;
node.first.threadId = summary.threadId;
node.first.tags = summary.firstMessageTags;
+ // Carried alongside the tags, for the same reason messageById()
+ // carries it onto a synthesised root: an unexpanded row has to know
+ // which account it belongs to before Delete can name a folder.
+ node.first.filePath = summary.firstMessagePath;
}
return node;
}
@@ -984,6 +988,12 @@ MessageNode ThreadListModel::messageById(const QString &messageId) const
root.subject = node.summary.subject;
root.date = node.summary.date;
root.tags = node.summary.tags;
+ // Carried from the query, so an UNEXPANDED row still knows which
+ // account it belongs to. Delete needs that to name a trash folder,
+ // and an unexpanded row is the ordinary case rather than an edge
+ // one: without this every thread row resolved to no account and
+ // Delete reported "no trash folder configured" for all of them.
+ root.filePath = node.summary.firstMessagePath;
return root;
}
@@ -1198,6 +1208,7 @@ void ThreadListModel::applyMessageTagChange(const QString &messageId,
node.first.messageId = node.summary.firstMessageId;
node.first.threadId = node.summary.threadId;
node.first.tags = node.summary.tags;
+ node.first.filePath = node.summary.firstMessagePath;
}
retag(node.first.tags);
diff --git a/src/types.h b/src/types.h
index 409ce79..f4d387a 100644
--- a/src/types.h
+++ b/src/types.h
@@ -62,6 +62,26 @@ struct ThreadSummary
/// file. Do not move it behind a flag by analogy with `recipients`.
QStringList firstMessageTags;
+ /// That message's file, RELATIVE to the database path, which is what says
+ /// which ACCOUNT it belongs to.
+ ///
+ /// Relative and not absolute, deliberately. The UI knows an account only
+ /// by its `maildir`, itself a database-relative prefix, so an absolute
+ /// path here matches no account and silently resolves every row to none.
+ ///
+ /// Needed because Delete moves the file (item 103) and the destination is
+ /// per account, so the action has to resolve an account before it can name
+ /// a trash folder. Resolving through the thread's account TAG instead is
+ /// not equivalent: that tag is optional config, so an account without one
+ /// would silently be undeletable, while a maildir prefix is what makes a
+ /// message belong to an account in the first place.
+ ///
+ /// Free for the same reason firstMessageId and firstMessageTags are: the
+ /// walk that finds that message is already happening, and this reads the
+ /// INDEX rather than the message file. Do not move it behind a flag by
+ /// analogy with `recipients`.
+ QString firstMessagePath;
+
/// Who the thread's messages were sent TO, summarised for one line.
///
/// Empty unless the query asked for it, and that is a performance