aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-23 21:13:51 +0200
committerDanilo M. <danix@danix.xyz>2026-08-23 21:13:51 +0200
commitddcae8d02ef46db522b3cf6c228196c7a66a6432 (patch)
tree0de4222c1e2aab58c38d34c9e0e3c37c68298cc8
parenta603b41771dc7c2612ca37f3d728c16717546a7e (diff)
downloadqtmaildir-ddcae8d02ef46db522b3cf6c228196c7a66a6432.tar.gz
qtmaildir-ddcae8d02ef46db522b3cf6c228196c7a66a6432.zip
docs: close out compose and send, and drop the v1/v2 split
Task 13 of item 123, the documentation that was left when the code was finished. The README claimed "sending is not implemented. Compose, reply, forward and send are planned for v2 and need a companion send script that does not exist yet", which had been false for three days. It also documented none of `trash`, `send_command` or the `[compose]` section: `trash` has been effectively mandatory since item 103 and its absence breaks Delete, so a user reading only the README could not configure a working install. Both are item 122, which closes with this. Every default in the new [compose] block was read out of config.h rather than from the prose around it, which was worth doing: send_html defaults to TRUE and the first draft of this documented it as false, and the key is autosave_interval_ms rather than the autosave_delay_ms that reads more naturally. The v1/v2 language is retired rather than updated. The project follows semver on its user-visible surface and those labels described a scope split that stopped being true when compose shipped. CLAUDE.md says so explicitly rather than deleting the sentence, since the older spec and plan documents still use the phrase and are historical records: read it there as "before compose". CLAUDE.md's architecture diagram listed none of the compose units. It does now, and it distinguishes the classes from the namespaces, because most of them are namespaces of free functions over values: the markdown, the MIME assembly and the account-picking are all testable without a widget. There is no FormatToolbar class, which the first draft of this diagram invented, and that is the exact mistake the paragraph under it already warns about for QueryBar and AttachmentBar. Also records item 148 from the notes: Ctrl+W does not close the composer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q2koFevoSxTLhfexJTZWQd
-rw-r--r--CHANGELOG.md78
-rw-r--r--CLAUDE.md35
-rw-r--r--README.md88
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md5
4 files changed, 190 insertions, 16 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f0748a..9e19ff2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,8 +11,86 @@ point at which they are stable.
## [Unreleased]
+### Added
+
+- **Composing and sending.** Ctrl+N opens a composer; Reply, Reply all, Reply
+ without quoting and Forward start one from the selected message. Each is a
+ window in its own right, so several can be open at once and the main window
+ stays usable behind them.
+- The body is markdown, sent as plain text exactly as typed. "Also send a
+ formatted copy" renders an HTML part from the same source and sends both in
+ a `multipart/alternative`; `[compose] send_html` sets the default, and a
+ reply follows what the message being answered used.
+- Drafts autosave to the account's `drafts` folder as ordinary Maildir files,
+ so mbsync carries them to the server and another client can pick one up.
+ Closing a composer with unsaved edits asks first, and so does quitting with
+ one open.
+- Sending goes to a per-account `send_command` on stdin, so any sendmail
+ compatible program works (msmtp, ssmtp, sendmail) and the credentials stay
+ in that program's own store. The application still speaks no network
+ protocol of its own. An account with no `send_command` is receive-only, and
+ the composer says so rather than failing at the end.
+- A send counts down before it runs, and Undo during that window stops it and
+ returns you to the composer with everything intact. Nothing reaches the
+ network until the countdown ends. `[compose] send_delay_ms` sets the length;
+ 0 removes it.
+- A copy of every sent message is filed in the account's `sent` folder.
+- The notmuch hooks that auto-tag incoming mail now live in this repository,
+ under `assets/hooks/`. They moved from the companion `mailctl` project,
+ which is being retired.
+
### Fixed
+- Sent mail and drafts no longer appear in the inbox. notmuch tags every newly
+ indexed file with `inbox`, including the copy this application files after a
+ send and the drafts it autosaves, so both turned up in the Inbox view and in
+ any `tag:inbox` search. The `post-new` hook now removes it from mail inside a
+ configured `sent` or `drafts` folder, which is mail that never arrived. Only
+ `inbox` is touched, and trash is deliberately left alone so Restore can still
+ find where a message came from.
+- Quitting with a composer open no longer leaves it behind. A composer is a
+ top-level window with no parent, so closing the main window did not take it
+ down and the process stayed alive for it: the main window vanished, the
+ composer stayed on screen, and closing it then asked about unsaved edits for
+ a session that had already ended.
+
+### Upgrading
+
+**To send, an account needs a `send_command`.** Without one it is
+receive-only: it still reads, tags and syncs exactly as before, and the
+compose actions are simply disabled for it. Nothing breaks by doing nothing.
+
+```ini
+[account.work]
+send_command = /usr/bin/msmtp -a work -t
+```
+
+The command receives the finished message on stdin and is run **without a
+shell**, so pipes and redirections do not work; give an absolute path and
+plain arguments. Credentials belong to that program, not to this one.
+
+An account that sends should also name `drafts` and `sent`, both relative to
+its `maildir`. Without `drafts` a composer cannot autosave and says so; without
+`sent` no copy of what you sent is kept locally.
+
+**If you run the auto-tagging hook, redeploy it.** It moved here from the
+`mailctl` project and gained the sent-and-drafts carve-out described above.
+Copy `assets/hooks/post-new`, `mailrules.py` and `qtmaildirconf.py` into
+`<database.path>/.notmuch/hooks/`, all three together: `post-new` imports the
+other two, and the new one reads your `qtmaildir.conf` to learn which folders
+are yours rather than arrivals. The rules file itself is unchanged, and
+`mailctl` can still read it.
+
+**Existing sent mail and drafts keep their `inbox` tag**, since the hook only
+sees newly indexed mail. To clear the backlog in one pass:
+
+```sh
+notmuch tag -inbox -- 'tag:inbox and (path:"work/Sent/**" or path:"work/Drafts/**")'
+```
+
+naming your own folders. This is a tag change only: no file moves, nothing
+reaches the server, and re-adding `inbox` to the same query undoes it.
+
- Clicking a link in a message opens it in the system browser. Links carrying
`target="_blank"`, which is most links in HTML mail, did nothing at all: no
error, nothing on screen. Chromium routes those to a new-window request
diff --git a/CLAUDE.md b/CLAUDE.md
index b065741..031b3d2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,10 +64,19 @@ MainWindow NotmuchWorker
│ ONE column of cards; CardDelegate paints each whole, from CardLayout
└ MessageView (header QLabel, QWebEngineView, attachment bar, TagStrip)
+ComposeWindow (its own top-level window, one per message being written)
+ ├ MarkdownFormat (namespace: what the formatting buttons do to a selection)
+ ├ MarkdownRenderer (namespace, cmark-gfm) MessageBuilder (namespace, GMime)
+ ├ MessageSender (QProcess, the per-account send_command on stdin)
+ └ SendDialog (the undo countdown) DraftStore (autosave to the drafts folder)
+
+ComposeContext (a struct: what a Reply or Forward inherits)
+ComposeContextBuilder (namespace: fills one, and picks the account)
CardLayout (pure geometry, no painting)
SearchTerm (pure query strings, no widget)
Config (INI) KeyMap MailSync (QProcess) MimeParser (GMime)
SyncMonitor (/proc/locks) TagColors QueryCompleter ThreadCidMap
+MaildirName (fresh Maildir filenames)
```
The query row and the message-pane header are **built inline in `MainWindow` and
@@ -75,11 +84,20 @@ The query row and the message-pane header are **built inline in `MainWindow` and
listed `QueryBar`, `SavedQueryBar`, `HeaderWidget` and `AttachmentBar`; none of
those types have ever existed, and looking for them wastes a search. The widget
classes that do exist are `MessageView`, `ThreadListView`, `TagStrip`,
-`TagDialog`, `MessageDetailsDialog`, `RowStyleDelegate` and `CardDelegate`;
-`TagChip` is a namespace of painting helpers, not a widget, `SearchTerm` is a
-namespace of query builders, and `ThreadCidMap`, `CardLayout`, `SearchOffer`
-and `HeaderRow` are structs. `SubjectDelegate` existed until item 53 and is
-gone.
+`TagDialog`, `MessageDetailsDialog`, `RowStyleDelegate`, `CardDelegate`,
+`ComposeWindow`, `SendDialog` and `BusyIndicator`; `TagChip` is a namespace of
+painting helpers, not a widget, `SearchTerm` is a namespace of query builders,
+and `ThreadCidMap`, `CardLayout`, `SearchOffer` and `HeaderRow` are structs.
+`SubjectDelegate` existed until item 53 and is gone.
+
+**The compose units are mostly NAMESPACES, and the same warning applies to
+them.** `MarkdownRenderer`, `MarkdownFormat`, `MessageBuilder`,
+`ComposeContextBuilder`, `DraftStore` and `MaildirName` are namespaces of free
+functions over values, deliberately, so the markdown, the MIME assembly and
+the account-picking are all testable without a widget. `MessageSender` IS a
+QObject, because it owns a `QProcess`. There is no `FormatToolbar` class: the
+composer's formatting row is built inline in `ComposeWindow` and asks
+`MarkdownFormat` what each button does to the selection.
**`MessageDetailsDialog` was a `QPlainTextEdit` inside `MessageView` until item
85.** It is rows now so each value can carry its own context menu, and its
@@ -925,7 +943,12 @@ test" position — it is the only code that writes to a notmuch index.
Work goes directly on `master`, no PR flow. Commits must be GPG-signed (`git commit -S`).
`HANDOFF.md` is local-only and gitignored; never stage or commit it.
-v1 is read-and-organize only. Compose and send are v2.
+**There is no "v1" and no "v2".** The project follows semver on its
+user-visible surface, and those labels described a scope split that stopped
+being true when compose and send shipped. Reading, organizing and sending are
+all part of the application now. The phrase survives in the older spec and
+plan documents, which are historical records and are not being rewritten; read
+it there as "before compose" and "after compose".
## Cutting a release
diff --git a/README.md b/README.md
index 1801bbd..75f9cbe 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# qtmaildir
-A Qt6 desktop mail client for reading and organizing a local,
-notmuch-indexed Maildir. A GUI counterpart to neomutt for the parts of mail
-handling that are easier with a mouse and a real HTML renderer.
+A Qt6 desktop mail client for reading, organizing and writing mail in a
+local, notmuch-indexed Maildir. A GUI counterpart to neomutt for the parts of
+mail handling that are easier with a mouse and a real HTML renderer.
## What it does not do
@@ -18,8 +18,10 @@ script means joining the `flock` that already serializes it against cron; a
built-in implementation would sit outside that lock and could run two
`mbsync` processes over one Maildir, which corrupts UID state.
-Sending is not implemented. Compose, reply, forward and send are planned for
-v2 and need a companion send script that does not exist yet.
+Sending follows the same rule. The application builds the message and hands
+it to a command you configure, on stdin; it speaks no SMTP itself. Any sendmail
+compatible program does (msmtp, ssmtp, the real sendmail), which keeps the
+credentials in that program's own store rather than in this one's config.
There is also no dry-run and no "are you sure?" on destructive actions. The
answer for a human at a GUI is undo, which is implemented, and which is
@@ -225,9 +227,21 @@ identity.
[account.work]
name = Your Name
address = you@example.org
-maildir = work-mail ; relative to notmuch's database.path
-drafts = Drafts ; recorded for v2; unused today
-sent = Sent ; optional; enables the Sent button for this account
+maildir = work-mail ; relative to notmuch's mail root
+trash = Trash ; Delete moves the file here. Not optional in
+ ; practice: without it the application reports a
+ ; config problem and Delete does not work.
+drafts = Drafts ; optional; where the composer autosaves
+sent = Sent ; optional; enables the Sent button, and where a
+ ; sent copy is filed
+inbox = Inbox ; optional; where Restore puts a message whose
+ ; origin is unknown. Defaults to "Inbox"
+
+; Optional, and its absence is what makes an account receive-only: with no
+; send_command the compose actions are disabled for it. The message is written
+; to the command's stdin. Run WITHOUT a shell, so no pipes or redirections;
+; give an absolute path and plain arguments.
+send_command = /usr/bin/msmtp -a work -t
label = W ; optional chip text; defaults to the key
color = #2f6fa8 ; optional chip colour; generated when unset
channel = work ; optional mbsync channel; defaults to the key
@@ -236,8 +250,38 @@ channel = work ; optional mbsync channel; defaults to the key
name = Your Name
address = you@example.net
maildir = personal
+trash = Trash
drafts = Drafts
+; Optional, and global rather than per-account. Every key below shows its
+; default, so an omitted [compose] section behaves exactly like this one.
+[compose]
+; Send an HTML part alongside the plain text one. The composer's own checkbox
+; overrides this per message. It seeds New and Forward only: a Reply follows
+; whether the message being answered carried an HTML part, which is a fact
+; about the sender's software rather than a guess about their taste.
+send_html = true
+
+; Where the quoted original goes in a reply: above or below.
+quote_position = above
+
+; How long the send popup counts down before the command runs, in
+; milliseconds. This is the window in which Undo can still stop it; 0 skips
+; the countdown and sends at once.
+send_delay_ms = 5000
+
+; How often an open composer autosaves its draft, in milliseconds. Values
+; below 1000 are raised to it.
+autosave_interval_ms = 30000
+
+; Preferred account for a new message while the dropdown is on All accounts.
+; Ignored when it names an account that cannot send.
+; default_account = work
+
+; Warn before attaching a file larger than this, in bytes. 25 MiB by default,
+; which is the limit most providers enforce.
+attachment_warn_bytes = 26214400
+
[tagcolors]
; Optional. Colours resolve by exact tag first, then by top-level prefix, so
; one entry covers a whole hierarchy.
@@ -441,6 +485,34 @@ an attachment, so it is visible without opening the thread. It comes from the
`attachment` tag notmuch applies while indexing, not from parsing the message,
and costs no extra query.
+## Composing
+
+**Ctrl+N** opens a composer; Reply, Reply all, Reply without quoting and
+Forward start one from the selected message. Each is a window in its own
+right, so several can be open at once and the main window stays usable behind
+them.
+
+The body is **markdown**. It is sent as plain text, and the markdown is what
+you typed rather than a rendering of it, so a recipient reading plain text
+sees exactly the source. Tick "Also send a formatted copy" and an HTML part is
+rendered from that same source and sent alongside it, in a
+`multipart/alternative`; `[compose] send_html` sets the default.
+
+Drafts autosave to the account's `drafts` folder while you type, as ordinary
+Maildir files, so mbsync carries them to the server like any other message and
+another client can pick one up. Closing a composer with unsaved edits asks
+first, and so does quitting with one open.
+
+Sending goes through the account's `send_command`, which receives the finished
+message on stdin. An account without one is receive-only, and the composer
+says so rather than failing at the end. A copy of what was sent is filed in
+the account's `sent` folder.
+
+**Send waits.** A popup counts down before the command runs, and Undo during
+that window stops it and returns you to the composer with everything intact.
+Nothing has reached the network until the countdown ends;
+`[compose] send_delay_ms` sets how long it lasts, and 0 removes it.
+
## Tagging
Archive, delete, spam, mark-important and toggle-unread write fixed tags. For anything
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 61b73c5..e008641 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -188,8 +188,8 @@ taking that too literally.
| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept |
| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank |
-| 122 | The README documents a version of the app that no longer exists | documentation | M | open, 2026-08-20, from the notes. Delete-to-trash is entirely undocumented, including a config key a user must now set |
-| 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. Tasks 1 to 12 of 13 built 2026-08-20 to 2026-08-22; task 13, the close-out, is the remainder. **Never hand tested**: nothing had wired a composer to an action until task 12, so no composer has yet been opened by a human. Twenty-two defects were found in the plan's own draft code across tasks 4 to 12, so treat every code block in it as a draft |
+| 122 | The README documents a version of the app that no longer exists | documentation | M | **done** 2026-08-23, unreleased, inside item 123 task 13. `trash`, `send_command` and the whole `[compose]` section were undocumented; a Composing section is added and "sending is not implemented" removed. Every default was read from `config.h` rather than from the prose, which caught `send_html` documented as false when it defaults to true |
+| 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. Tasks 1 to 13 of 13 built 2026-08-20 to 2026-08-23; task 13 closed the documentation out and retired the v1/v2 split, which semver had made meaningless. **Hand tested 2026-08-22 and 2026-08-23** against a fake send command: New, Reply and Forward all send, a forwarded attachment survives intact, and the sent copy is filed. Found two defects, both fixed (the orphaned composer, and sent mail carrying `inbox`). Twenty-two defects were found in the plan's own draft code across tasks 4 to 12, so treat every code block in it as a draft |
| 124 | The worker reads the index directory as the mail root | defect | S | **done** 2026-08-20, unreleased. `mailRootOf()` over `NOTMUCH_CONFIG_MAIL_ROOT`, correct under both layouts. Verified by migrating the developer's own index to NVMe the same day: cold start 38.6 s to 0.67 s |
@@ -217,6 +217,7 @@ taking that too literally.
| 145 | Cc and Bcc are permanent rows on every composer | presentation | S | open, 2026-08-23, from the notes. Verified: both are unconditional `form->addRow` calls. Most messages use neither. Collapse behind a disclosure next to To:, expanded automatically when a draft or a reply already carries a value |
| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **duplicate of 119**, recorded 2026-08-23 from the notes. Same request, and 119 already carries the blocker: one of the four things the count sums holds no message ids, so a list cannot be complete without changing how the count is kept |
| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes. The notes ask for exactly what 99 describes: "Mark as read" on an unread message and the reverse. 99 already records that the label is harder than it looks, since a multi-row selection has no single direction |
+| 148 | Ctrl+W does not close the composer | discoverability | XS | open, 2026-08-23, from the notes. Verified: nothing binds `Ctrl+W` anywhere, and the composer has no close action of its own. Belongs with item 21's table rather than bound in isolation |
Sizes are rough: XS under an hour, S a sitting, M a session.