aboutsummaryrefslogtreecommitdiffstats
path: root/src/notmuchworker.cpp
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/notmuchworker.cpp
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/notmuchworker.cpp')
-rw-r--r--src/notmuchworker.cpp56
1 files changed, 56 insertions, 0 deletions
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)