summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-17 20:04:05 +0200
committerDanilo M. <danix@danix.xyz>2026-08-17 20:04:05 +0200
commite125d970aa2f4ad6cd494e5f410ba1c5e53f5308 (patch)
treef9635cde315e80c23cec8be5875d2a33ec6a8949 /src
parentd5e9ec157946635294001b57ce8353a109ec5051 (diff)
downloadqtmaildir-e125d970aa2f4ad6cd494e5f410ba1c5e53f5308.tar.gz
qtmaildir-e125d970aa2f4ad6cd494e5f410ba1c5e53f5308.zip
feat(worker): move messages between maildir folders
The first mutation here that is not a notmuch tag. Indexes the new path before dropping the old one, since removing the last filename for a message id deletes the database entry and every tag on it.
Diffstat (limited to 'src')
-rw-r--r--src/notmuchworker.cpp106
-rw-r--r--src/notmuchworker.h18
2 files changed, 124 insertions, 0 deletions
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index b152830..6c41839 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -614,6 +614,112 @@ void NotmuchWorker::applyTags(const TagChange &change)
emit tagsApplied(change);
}
+void NotmuchWorker::moveMessages(const QStringList &messageIds,
+ const QString &destFolder)
+{
+ if (messageIds.isEmpty() || destFolder.isEmpty())
+ return;
+
+ // The read-only handle must be closed first: notmuch allows only one open
+ // handle per process. Same ordering as applyTags, for the same reason.
+ close();
+
+ const QByteArray configPath = configPathArg();
+ notmuch_database_t *db = nullptr;
+ char *error = nullptr;
+ const notmuch_status_t status = notmuch_database_open_with_config(
+ nullptr,
+ NOTMUCH_DATABASE_MODE_READ_WRITE,
+ configPath.isEmpty() ? nullptr : configPath.constData(),
+ nullptr,
+ &db,
+ &error);
+
+ if (status != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot open database for writing: %1")
+ .arg(QString::fromUtf8(error ? error
+ : notmuch_status_to_string(status))));
+ free(error);
+ return;
+ }
+
+ const QString root = QString::fromUtf8(notmuch_database_get_path(db));
+ const QString destDir =
+ root + QLatin1Char('/') + destFolder + QStringLiteral("/cur");
+
+ QStringList moved;
+ for (const QString &id : messageIds) {
+ notmuch_message_t *raw = nullptr;
+ // find_message reports SUCCESS with a null message when the id is not
+ // in the database, so both have to be checked. A stale id must not
+ // abort the batch: the live ids alongside it still need moving.
+ if (notmuch_database_find_message(db, id.toUtf8().constData(), &raw)
+ != NOTMUCH_STATUS_SUCCESS || !raw) {
+ continue;
+ }
+ NmMessage message(raw);
+
+ const char *rawName = notmuch_message_get_filename(message.get());
+ if (!rawName)
+ continue;
+ const QString from = QString::fromUtf8(rawName);
+ // The handle is released before the file moves under it.
+ message.reset();
+
+ // cur/, never new/. A file dropped in new/ is re-announced as fresh
+ // mail by every reader of the Maildir.
+ if (!QDir().mkpath(destDir)) {
+ emit errorOccurred(QStringLiteral("Cannot create folder %1")
+ .arg(destDir));
+ continue;
+ }
+
+ const QString to = destDir + QLatin1Char('/') + QFileInfo(from).fileName();
+ if (from == to) {
+ // Already where it was asked to go. Reported as moved, since the
+ // caller's request is satisfied.
+ moved.append(id);
+ continue;
+ }
+
+ if (!QFile::rename(from, to)) {
+ emit errorOccurred(QStringLiteral("Cannot move %1 to %2")
+ .arg(QFileInfo(from).fileName(), destFolder));
+ continue;
+ }
+
+ // Index the NEW path BEFORE dropping the old one. The reverse order
+ // removes the last filename for this message id, which deletes the
+ // database entry and every tag on it; the file then reindexes as a
+ // brand new message with default tags, silently.
+ notmuch_message_t *indexed = nullptr;
+ const notmuch_status_t added = notmuch_database_index_file(
+ db, to.toUtf8().constData(), nullptr, &indexed);
+ if (indexed)
+ notmuch_message_destroy(indexed);
+
+ // DUPLICATE_MESSAGE_ID is success here: it means the id was already
+ // known, which is exactly the case for a file this just moved.
+ if (added != NOTMUCH_STATUS_SUCCESS
+ && added != NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID) {
+ QFile::rename(to, from);
+ emit errorOccurred(QStringLiteral("Cannot index %1 at its new path: %2")
+ .arg(id, QString::fromUtf8(
+ notmuch_status_to_string(added))));
+ continue;
+ }
+
+ notmuch_database_remove_message(db, from.toUtf8().constData());
+ moved.append(id);
+ }
+
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+
+ emit messagesMoved(moved, destFolder);
+}
+
void NotmuchWorker::requestAllTags(quint64 generation)
{
if (!openReadOnly())
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index f07e563..d8d8ff8 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -120,6 +120,19 @@ public slots:
/// it would block the user's cron `notmuch new`.
void applyTags(const TagChange &change);
+ /// Moves messages into `destFolder`, relative to the database path.
+ ///
+ /// A folder NAME rather than a "move to trash" call, because v2's Send
+ /// needs exactly this operation for Drafts and Sent. Nothing
+ /// trash-specific belongs here.
+ ///
+ /// The first mutation in this class that is not a notmuch tag: a rename on
+ /// disk plus a reindex. Ordering is rename, index the new path, drop the
+ /// old one. Indexing first is required, not stylistic: removing the last
+ /// filename for a message id deletes the database entry and every tag on
+ /// it, so removing before indexing loses the message's tags.
+ void moveMessages(const QStringList &messageIds, const QString &destFolder);
+
/// Batch tagging over whole threads. The UI holds thread ids, not message
/// ids, for rows it has not opened, so the resolution happens here where
/// the database handle lives. This is the path the archive/flag/delete
@@ -184,6 +197,11 @@ signals:
quint64 generation);
void messageLoaded(const QVector<MessageRef> &messages, quint64 generation);
void tagsApplied(const TagChange &change);
+
+ /// Carries the ids that ACTUALLY moved, which may be fewer than requested.
+ /// 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);
void allTagsReady(const QStringList &tags, quint64 generation);
/// One entry per requested query, in the order they were asked for. A query