diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-19 10:03:04 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-19 10:03:04 +0200 |
| commit | 830aa264f81af1a92dec9ee95bc24a9b25dee53d (patch) | |
| tree | b1e6f4de3e5a651c3f7927627f8b03e3be1cc9b7 | |
| parent | 44b341f774a706d70509751b0251793e1c5a34f5 (diff) | |
| download | qtmaildir-830aa264f81af1a92dec9ee95bc24a9b25dee53d.tar.gz qtmaildir-830aa264f81af1a92dec9ee95bc24a9b25dee53d.zip | |
i18n: translate the trash strings, and document the trash key
The three new strings from the cleanup action, translated into Italian.
lrelease reports 383 finished and 0 unfinished; an unfinished string is
silently dropped and ships as English inside an otherwise Italian UI.
The changelog gains an Upgrading section for the mandatory `trash` key, the new
optional `inbox` key and the `Del` binding, and states the consequence that
cost real mail on this branch: a folder name that does not match the server is
created rather than reported, mbsync adopts it, and under Create Both it
propagates to the server where other clients see it.
CLAUDE.md is corrected on two counts. Adding an action is five places, not
four; the fifth is a menu, and nothing enforced it until this branch added
everyActionIsReachableFromAMenu(). And the trash design is recorded: why the
origin lives in a tag, why those tags are joined by a tab rather than a space,
and why Restore resolves against the database rather than the model.
Also repairs a race in deletingTwiceLeavesNoOriginTagBehind(). Its guard ran a
query through the bar in the gap between the file rename and the tag writes,
and a query bar run in that gap returns zero rows forever, since QTRY_VERIFY
re-reads rowCount() and never re-runs the query. Measured 3 failures in 12
runs, each burning a full 15s timeout; 0 in 8 after asking the database
directly, with the runtime down from 45s to 0.3s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | CHANGELOG.md | 49 | ||||
| -rw-r--r-- | CLAUDE.md | 52 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 17 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 12 |
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 @@ -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 &stranded deleted mail</source> + <translation>&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> |
