summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md49
-rw-r--r--CLAUDE.md52
-rw-r--r--tests/test_mainwindow.cpp17
-rw-r--r--translations/qtmaildir_it_IT.ts12
4 files changed, 120 insertions, 10 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75d2de1..e514fc8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,55 @@ point at which they are stable.
## [Unreleased]
+### Added
+
+- Delete now moves mail into the account's trash folder instead of only
+ tagging it. A **Trash** filter sits beside Unread, Inbox, Important and
+ Sent, and composes with the account selector like the others.
+- **Restore from trash** (`Ctrl+R`), enabled while the trash view is showing.
+ A message this application deleted returns to the folder it came from; one
+ trashed by another client returns to the inbox.
+- **Find stranded deleted mail** (`Ctrl+Alt+T`), in the Message menu. It lists
+ mail tagged `deleted` that never moved anywhere. Run it whenever you like;
+ it reports and moves nothing on its own.
+- An optional per-account `inbox` key, naming the inbox folder a restore falls
+ back to when a message carries no record of where it came from. It defaults
+ to `Inbox`, so an account whose inbox is named that needs nothing.
+- `Del` now deletes, alongside `Ctrl+D`. It still edits text in the query bar
+ and in any other text field, so nothing is lost where the key already had a
+ job.
+
+### Changed
+
+- Open thread, Clear message pane and Clear selection appear in the View menu.
+ All three existed and were reachable only by their shortcuts.
+
+### Upgrading
+
+**Every account now needs a `trash` key** in `qtmaildir.conf`, naming its
+trash folder relative to `maildir`:
+
+ [account.work]
+ maildir = work
+ trash = Trash
+
+The folder must be one your `mbsync` configuration actually syncs, or the move
+will never reach the server. Accounts without the key still load and still
+read mail, but Delete cannot work on them and a warning says so at startup.
+
+**Name the folder exactly as it exists on the server.** A trash or inbox name
+that does not match creates that folder rather than reporting an error, and
+under mbsync's `Create Both` the wrongly named folder then propagates to the
+mail server, where other clients will see it.
+
+**Mail deleted by earlier versions is not migrated.** It carries the `deleted`
+tag and sits wherever it always was. Use **Find stranded deleted mail** to
+review it, and Delete on what should really go.
+
+Note that Delete's reversibility depends on your provider: a trash folder the
+provider purges on a timer will eventually remove the mail for good.
+
+
## [0.25.0] - 2026-08-17
Acting on a row now means the message that row displays, not the whole
diff --git a/CLAUDE.md b/CLAUDE.md
index 50faf4b..2065a10 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -227,6 +227,34 @@ combined `thread:a or thread:b` query rather than one query per thread.
The only escape hatch is `general/notmuch_config`, pointing at an alternate notmuch config.
Per-account subdirectories *are* configured, since notmuch does not model accounts at all.
+**Delete MOVES the file, and a wrong folder name reaches the mail server.**
+Item 103. Every account carries a mandatory `trash` key and an optional
+`inbox` one, both naming a folder relative to `maildir`. Naming a folder that
+does not exist does not fail: the move CREATES it, mbsync adopts it and writes
+state files for it, and under `Create Both` it then propagates to the server,
+where every other client sees it. This is not theoretical. A folder name
+containing a space was truncated by the origin tag, a bogus folder was created
+beside the real one, and four messages of a thread were stranded in it on the
+user's real mail. Treat any code that composes a folder name as reaching the
+server, because it does.
+
+**A message records where it came from in a tag, because nothing else can.**
+`deleted-from:<folder>` is written when Delete moves the file, and read back by
+Restore. The file has moved, so neither the path nor anything in notmuch still
+knows the original folder. A notmuch tag MAY contain a space, so tags crossing
+the thread boundary are joined by a TAB rather than a space; joining on a space
+truncated every folder name containing one. A message trashed by another client
+carries no such tag at all, which is why the trash view is path-based and why
+Restore falls back to the account's inbox rather than refusing.
+
+**Restore reads the DATABASE, never the model.** The model's tags come from the
+query, so a row whose delete has not been re-queried still carries its pre-delete
+tags: measured `[inbox,unread]` on a message already in the trash, one run in
+three. The origin tag is then not found, the message falls into the no-origin
+branch, and it goes to the inbox instead of where it came from, silently and
+irreversibly. A restore must be right about its destination or it is worse than
+doing nothing.
+
**The sync script lives here, in `assets/mailsync.sh`.** It moved from the
companion `mailctl` project, which documents that it never calls it: the script
is `mbsync` plus `notmuch new` with a lock, and qtmaildir is the only thing that
@@ -577,15 +605,27 @@ a union over the conversation, so it can arm for a thread whose displayed
message is already read. The write is still scoped to that message, so the cost
is a no-op rather than a wrong write.
-**Adding an action is four places, and three of them are enforced by tests that
+**Adding an action is FIVE places, and four of them are enforced by tests that
fail in confusing ways.** `KeyMap::knownActions()` (a `Q_ASSERT` in the
constructor fires otherwise, and it surfaces in whichever suite happens to build
a `MainWindow` first — `test_tagrules` did), `defaultBindings()` (every action
-must be keyboard-reachable), and the icon table (every action must carry one).
-The no-duplicate-icons rule is narrowed to actions that can reach the toolbar,
-by a named exception list; the five thread actions share their twins' icons
-because a submenu entry always carries text, and the test asserts none of them
-is on the toolbar so the exemption cannot be abused.
+must be keyboard-reachable), the icon table (every action must carry one), and
+a MENU. The no-duplicate-icons rule is narrowed to actions that can reach the
+toolbar, by a named exception list; the five thread actions share their twins'
+icons because a submenu entry always carries text, and the test asserts none of
+them is on the toolbar so the exemption cannot be abused.
+
+**The menu was the fifth place, and this document said four until item 103.**
+Nothing enforced it, so `restore` shipped on the trash branch reachable by
+`Ctrl+R` and by nothing a user could see or discover. The three existing
+coverage tests each assert a different property and all three pass against an
+action that appears nowhere in the interface.
+`everyActionIsReachableFromAMenu()` closes it, walking every menu and submenu
+from the menu bar; it found three more of the same the moment it was written
+(`open_thread`, `clear_pane`, `clear_selection`). The toolbar is deliberately
+NOT the test's instrument: it is a small chosen subset and always will be. An
+action owning a submenu is not itself counted as reachable, since Qt emits no
+`triggered` for it.
**A toggle must read the state of what the row STANDS FOR, not of its thread.**
`MainWindow::everySelectedRowHasTag()` is the one question `delete` and
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 8a41c85..578606b 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -9079,10 +9079,19 @@ void TestMainWindow::deletingTwiceLeavesNoOriginTagBehind()
// The origin tag really was written, so the assertion after the second
// delete is about it being REMOVED rather than never having existed.
- queryEdit->setText(QStringLiteral(
- "id:twice@example.org and tag:\"deleted-from:inbox\""));
- queryEdit->returnPressed();
- QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ //
+ // Asked of the DATABASE, not through the query bar. The file arriving in
+ // the trash is not the end of the delete: the tag writes land after the
+ // rename this test waits for, and a query bar run inside that gap returns
+ // zero rows FOREVER, because QTRY_VERIFY re-reads rowCount() and never
+ // re-runs the query. Measured 1 failure in 3 runs, each burning the full
+ // 15s timeout on a guard that was correct about a database it had asked
+ // too early.
+ QTRY_VERIFY_WITH_TIMEOUT(
+ notmuchCount(backed.fixture().configPath(),
+ QStringLiteral("id:twice@example.org and "
+ "tag:\"deleted-from:inbox\"")) == 1,
+ 15000);
// Second press on the same message, which restores it.
queryEdit->setText(QStringLiteral("id:twice@example.org"));
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index 1c5eedd..b9515b4 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -278,6 +278,10 @@
</translation>
</message>
<message>
+ <source>Mail tagged deleted but not in a trash folder. Select what should go and press Delete.</source>
+ <translation>Posta etichettata come eliminata ma non in un cestino. Seleziona cosa deve essere rimosso e premi Elimina.</translation>
+ </message>
+ <message>
<source>Undelete</source>
<translation>Ripristina</translation>
</message>
@@ -396,6 +400,14 @@
<translation>Sposta i messaggi selezionati fuori dal cestino</translation>
</message>
<message>
+ <source>Find &amp;stranded deleted mail</source>
+ <translation>&amp;Cerca posta eliminata non spostata</translation>
+ </message>
+ <message>
+ <source>Show mail tagged deleted that is not in a trash folder</source>
+ <translation>Mostra la posta etichettata come eliminata che non si trova in un cestino</translation>
+ </message>
+ <message>
<source>Add spam and remove inbox on whole threads</source>
<translation>Aggiunge spam e rimuove inbox su intere conversazioni</translation>
</message>