diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-13 20:32:47 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-13 20:32:47 +0200 |
| commit | c7a2444ed5b9bb30b9f10d3e8dff8a6d6b6bb16a (patch) | |
| tree | de416968b7dbb90e6ec4a8e1c55743ab71eea533 /src/mainwindow.cpp | |
| parent | 533c9243bede053f4ea7e1f20de13e72c2b939b8 (diff) | |
| download | qtmaildir-c7a2444ed5b9bb30b9f10d3e8dff8a6d6b6bb16a.tar.gz qtmaildir-c7a2444ed5b9bb30b9f10d3e8dff8a6d6b6bb16a.zip | |
feat: mark spam moves mail to the account's spam folder
Mark spam was a tag-only action that added the spam tag and removed inbox,
so a message marked as spam stayed in the inbox on disk. It now MOVES the
file into the account's configured spam folder, exactly mirroring Delete:
the account-relative spam key is the destination, the move records
moved-from: with the origin, and unread and inbox are stripped in the same
confirmed write so one undo returns the folder and the tags together.
NotmuchWorker::moveMessages already handled a folder generically and
applyTags already overwrote an older moved-from: tag, so the worker needed
no change; the tests pin that behaviour for the spam destination.
Five existing tests used spam as a worker-free, tag-only stand-in for the
old Delete. Since spam is now a move too, they are retargeted to flag, the
remaining selection-scoped tag-only action.
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 114 |
1 files changed, 109 insertions, 5 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6c66953..37411e6 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1784,9 +1784,8 @@ void MainWindow::registerActions() purgeSelected(); }); addAction(QStringLiteral("spam"), tr("Mark &spam"), - tr("Add spam and remove inbox"), [this]() { - tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") }, - tr("Mark spam")); + tr("Move the selected messages to the spam folder"), [this]() { + spamSelected(); }); // Item 57. The LABEL is "Important"; the action name and the tag are both // still `flag`/`flagged`, deliberately. The name is what a user writes in @@ -3972,7 +3971,8 @@ void MainWindow::refreshScopedActionLabels() tr("Move every message of the selected threads out of the " "trash")); relabel(QStringLiteral("spam"), tr("Mark thread as &spam"), - tr("Add spam and remove inbox on the selected threads")); + tr("Move every message of the selected threads to the spam " + "folder")); relabel(QStringLiteral("flag"), tr("&Important thread"), tr("Mark every message of the selected threads as important")); } else { @@ -3983,7 +3983,7 @@ void MainWindow::refreshScopedActionLabels() relabel(QStringLiteral("restore"), tr("&Restore from trash"), tr("Move the selected messages out of the trash")); relabel(QStringLiteral("spam"), tr("Mark &spam"), - tr("Add spam and remove inbox")); + tr("Move the selected messages to the spam folder")); relabel(QStringLiteral("flag"), tr("&Important"), tr("Add or remove the important tag")); } @@ -6413,6 +6413,105 @@ void MainWindow::trashThreads(const QStringList &threadIds) Q_ARG(QString, QStringLiteral("delete_thread"))); } +void MainWindow::spamSelected() +{ + const QModelIndexList rows = + m_threadView->selectionModel()->selectedRows(); + if (rows.isEmpty()) + return; + + // Delete's sibling, resolved the same per-row way (item 177): a + // conversation row spams its conversation, a thread of one spams its + // message. Both halves are run, because a selection really can hold one of + // each; they travel different routes for the reason trashSelected() + // records. + const ActionScope scope = m_model->scopeForSelection(rows); + + if (!scope.threadIds.isEmpty()) + spamThreads(scope.threadIds); + + if (scope.messageIds.isEmpty()) + return; + + QHash<QString, QString> pathById; + for (const QString &messageId : scope.messageIds) + pathById.insert(messageId, m_model->messageById(messageId).filePath); + + spamMessages(scope.messageIds, pathById, scope.messageIds.size()); +} + +void MainWindow::spamMessages(const QStringList &messageIds, + const QHash<QString, QString> &pathById, + int messageCount, + const QStringList &wholeThreadIds) +{ + if (messageIds.isEmpty()) + return; + + // Grouped by destination, exactly as trashMessages() is: moveMessages() + // takes one folder per call, and a selection can span accounts with + // different spam folders. + QHash<QString, QStringList> bySpam; + QStringList unconfigured; + for (const QString &messageId : messageIds) { + const Account account = + accountForMessagePath(pathById.value(messageId)); + if (account.spam.isEmpty()) { + unconfigured.append(messageId); + continue; + } + bySpam[account.maildir + QLatin1Char('/') + account.spam] + .append(messageId); + } + + // The second line of defence, as for trash: the config loader warns, but a + // user who never fixed it still needs the gesture to say it did nothing + // rather than move the file somewhere invented. + if (!unconfigured.isEmpty()) { + m_statusLabel->setText( + tr("%n message(s) could not be marked as spam: no spam folder is " + "configured for their account.", "", int(unconfigured.size()))); + } + + if (bySpam.isEmpty()) + return; + + for (auto it = bySpam.cbegin(); it != bySpam.cend(); ++it) { + // `unread` and `inbox` go with the message, exactly as Delete strips + // them: marking spam is a decision about the message, and without the + // `inbox` removal a message spammed FROM the inbox keeps the tag the + // Inbox filter matches on and stays in that view. The origin is the + // placeholder, resolved per message once the move is confirmed. + sendMove(it.value(), it.key(), + { QStringLiteral("spam"), kOriginTagPlaceholder() }, + { QStringLiteral("unread"), QStringLiteral("inbox") }, + tr("Mark spam"), false, wholeThreadIds); + } + + announceAction( + tr("%1: %n message(s)", "", messageCount).arg(tr("Mark spam"))); +} + +void MainWindow::spamThreads(const QStringList &threadIds) +{ + if (threadIds.isEmpty()) + return; + + // Asked of the WORKER rather than resolved here, and repainted HERE + // synchronously before it: the same shape as trashThreads(), for the same + // two reasons. An unexpanded thread's reply ids and paths exist only in + // the database; and the card must not wait for that round trip. + for (const QString &threadId : threadIds) + m_model->applyTagChange(threadId, { QStringLiteral("spam") }, + { QStringLiteral("inbox") }); + + m_pendingThreadScope = threadIds; + QMetaObject::invokeMethod(m_worker, "resolveThreadMessages", + Qt::QueuedConnection, + Q_ARG(QStringList, threadIds), + Q_ARG(QString, QStringLiteral("spam_thread"))); +} + void MainWindow::onThreadMessagesResolved(const QStringList &messageIds, const QStringList &paths, const QStringList &tags, @@ -6467,6 +6566,11 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds, return; } + if (requestTag == QStringLiteral("spam_thread")) { + spamMessages(messageIds, pathById, messageIds.size(), threadScope); + return; + } + if (requestTag == QStringLiteral("restore_messages")) { restoreResolvedMessages(messageIds, paths, tags); return; |
