aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-08-24-signatures-design.md260
1 files changed, 260 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-24-signatures-design.md b/docs/superpowers/specs/2026-08-24-signatures-design.md
new file mode 100644
index 0000000..3af88a4
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-signatures-design.md
@@ -0,0 +1,260 @@
+# Signatures
+
+Design for backlog item 152. Brainstormed with the user on 2026-08-24.
+
+**Status:** specified, unbuilt.
+
+## The problem
+
+The composer has no signature support at all. Every message is typed from
+nothing, and a user who signs their mail retypes the signature or pastes it in
+by hand.
+
+The user's own note (`notes on qtmaildir.md`) states the requirement in one
+line and one constraint:
+
+> signatures: not tied to an account, with a switch in the editor bar UI.
+
+The constraint is the whole shape of this item. It rules out the obvious
+`[account.*] signature` key as the entire answer, because a signature that
+belongs to an account cannot be switched per message.
+
+A second requirement came out of the brainstorm: one choice must serve both
+the plain and the HTML form of the message, with the format transparent to the
+user. Picking "work" must not also mean picking whether it is text or HTML.
+
+## What makes this small
+
+`MessageBuilder` already derives both parts from one string
+(`messagebuilder.cpp:321-324`): `text/plain` is `markdownBody` verbatim, and
+`text/html` is `MarkdownRenderer::toHtml()` over that same string.
+
+A signature written in markdown and placed in the composer's buffer therefore
+gets both forms for free, correctly, with **no change to `MessageBuilder`**.
+The transparency the user asked for is a property the pipeline already has.
+
+This is why the design stores markdown and splices into the buffer rather than
+appending at build time. Two alternatives were considered and dropped:
+
+- **Separate text and HTML files per signature** (`work.md` + `work.html`,
+ the second overriding the rendered form). Considered and chosen briefly,
+ then dropped by the user: "I'm overthinking it, let's drop the html part."
+ It buys designed HTML signatures (coloured text, table layouts, inline
+ logos) at the cost of the signature no longer being visible in the editor,
+ because the two parts diverge and the buffer can only hold one of them.
+ Nothing the user signs with needs it.
+- **Appending at build time** from a key on `OutgoingMessage`. Necessary
+ under the two-file design, pointless without it, and it makes the signature
+ invisible while composing.
+
+## Storage
+
+```
+~/.config/qtmaildir/signatures/
+├── work.md
+├── personal.md
+└── short.md
+```
+
+One file per signature. The filename stem is the name shown in the switch,
+listed alphabetically. Content is markdown, like the body.
+
+- A missing directory is not a misconfiguration. It means no signatures, and
+ the switch offers only "None".
+- A file that cannot be read is skipped with a warning through
+ `Config::addProblem()`, the same route every other config problem takes.
+- No management UI. The directory is edited with the user's own editor. This
+ is a deliberate stop on the ladder: a signature editor would be a text
+ editor inside a mail client, and the user already has one.
+
+The path is fixed rather than configurable, beside `qtmaildir.conf` in
+`QStandardPaths::ConfigLocation`. Nothing yet suggests a second location, and
+a key can be added later without breaking a file already on disk.
+
+## Config
+
+Three keys, all optional.
+
+| Key | Default | Meaning |
+|---|---|---|
+| `[compose] signature` | empty | Name seeded when no account override applies |
+| `[account.<key>] signature` | unset | Overrides the above, for that account |
+| `[compose] signature_position` | `end` | `end` or `above_quote` |
+
+### The account seeds, it does not bind
+
+`[account.<key>] signature` does not contradict the user's constraint. The
+constraint is that a signature is not *owned by* an account and is switchable
+per message; this key supplies only a **starting value**. Every signature stays
+reachable from the switch under any account, and switching the From: account
+does not restrict the choice.
+
+The user asked for this key explicitly after the constraint was restated
+("D, one more config but it's a nice thing to have").
+
+### `signature_position`
+
+`end` by default, which is the user's own habit ("My usual placement is always
+B"). `above_quote` exists because other clients offer the choice, and it is one
+enum key over machinery the splice needs anyway (see below).
+
+It follows `quote_position`'s exact shape (`config.cpp:529-545`): an absent key
+is silent and the struct default holds, but a **present and malformed** value
+is reported through `addProblem()` and falls back. `value(key, default)` alone
+would accept `signature_position = abov` as `above_quote` silently, and this
+file already refuses to be the one place that does that.
+
+### A name matching no file
+
+Reported as a config problem and seeds nothing. It is not a fatal error: the
+composer opens, with no signature, and the switch still works.
+
+## The switch
+
+A `QToolButton` with a checkable menu on the composer's editor bar, at the
+right end with Attach and Send as HTML. That end is where item 142 put the
+controls *of the editor*, as against the formatting buttons on the left, and a
+signature choice is one of those.
+
+Entries are "None" plus one per file, the current one checked.
+
+A menu rather than a `QComboBox` because the bar's other controls are tool
+buttons and a combo would read as a different class of thing. The count is
+small and static.
+
+**Not a `KeyMap` action.** It is parented to the composer, exactly as the
+formatting actions are (`composewindow.cpp:424-429`), so its scope is the
+composer window, the main window's namespace is untouched, and item 132's
+menu-reachability rule does not apply to it.
+
+## The splice
+
+One free function, in a new `Signatures` namespace
+(`src/signatures.h` / `src/signatures.cpp`):
+
+```cpp
+QString replace(const QString &buffer, const QString &signature,
+ Position position);
+```
+
+Stateless. No stored ranges, no tracked insertion point, nothing that can
+desync from the undo stack. This is the property that makes the switch safe to
+use repeatedly, and it is why the delimiter does the work.
+
+`ComposeWindow` gets a companion for the directory:
+
+```cpp
+QStringList names(const QString &dir); // stems, sorted
+QString text(const QString &dir, const QString &name);
+```
+
+A namespace of free functions over values, matching `MarkdownFormat`,
+`MessageBuilder`, `ComposeContextBuilder` and `DraftStore`. The splice is then
+testable with no widget, which is the point of that convention.
+
+### Finding an existing signature
+
+Scan for the **last** line equal to `-- ` (dash, dash, space: the RFC 3676
+delimiter) that is **not** followed by a run of quoted lines. From that
+delimiter to the end of its block is the existing signature.
+
+The "not followed by quoted lines" clause is what makes one scan serve both
+positions. Under `end` the signature is the buffer's tail and a naive tail rule
+would do; under `above_quote` it sits before the quote, and a tail rule would
+select the quote and destroy it. The user chose to cover both.
+
+The quote is recognised as a contiguous run of lines beginning with `>`. That
+is a scan of the buffer, not stored state, so it survives editing and undo.
+
+### Inserting
+
+- `end`: append to the buffer.
+- `above_quote`: insert before the first quoted line; with no quote in the
+ buffer, this is identical to `end`. A New message under `above_quote` is
+ therefore not a special case.
+
+Selecting "None" removes the found block and inserts nothing.
+
+### Accepted limit
+
+A buffer in which the user has typed a literal `-- ` line is indistinguishable
+from one holding a signature, and the switch will replace from there. This is
+the correct reading rather than a defect: that string is the signature
+delimiter, and typing it means what it means.
+
+## Seeding a new composer
+
+On open, after `seedBody()`, `ComposeWindow` resolves a name:
+
+```
+account override → [compose] signature → none
+```
+
+and splices it in.
+
+### A resumed draft seeds nothing
+
+The draft branch of `seedBody()` puts the saved body in verbatim, "no
+attribution, no quote markers, no blank lines added"
+(`composewindow.cpp:627-634`), because a draft is the message itself. That body
+already contains whatever signature it was saved with. Seeding again would
+append a second one, and the user would find two signatures on a message they
+had written once.
+
+### The seeded signature is not an undo step
+
+`clearUndoRedoStacks()` already covers the seeded quote for this reason
+(`composewindow.cpp:661-665`): one Ctrl+Z on a fresh composer must not wipe
+content the user never typed. The signature is seeded before that call and is
+covered by it.
+
+### Changing the From: account
+
+The signature re-splices when the account changes, **but only while the user
+has not touched the switch**. A bool on the window records the first use of the
+menu and stops the automatic follow from then on.
+
+This matches how `send_html` behaves: seeded from context, then left alone
+(`composewindow.cpp:606-610`). The rejected alternative was re-seeding
+unconditionally, which can silently discard a signature the user picked
+deliberately a moment earlier.
+
+## Testing
+
+`test_signatures`, no widget, over the namespace:
+
+- names and text read back from a directory; a missing directory yields empty
+- insert into an empty buffer, both positions
+- replace an existing signature, both positions
+- remove for "None"
+- `above_quote` with no quote in the buffer behaves as `end`
+- **the delimiter scan against a quoted reply**, which is the case a naive
+ tail rule gets wrong: assert the quote survives
+
+`test_composewindow`, for the wiring:
+
+- seeded from the account override
+- seeded from `[compose] signature` when the account has no key
+- a resumed draft seeds nothing
+- From: follows the account until the switch is used, and stops after
+- an unknown name seeds nothing and does not block the composer
+
+`test_config`:
+
+- all three keys read
+- a malformed `signature_position` is reported and falls back to `end`
+- an unknown signature name is reported
+
+### Not covered, stated rather than faked
+
+The editor bar's own layout, like item 141's, is a look-at-it property. The
+switch's position on the bar is verified by hand.
+
+## What this does not do
+
+- **No signature editor.** The directory is edited externally.
+- **No per-signature HTML.** Dropped by the user; see "What makes this small".
+- **No automatic signature on a draft resume.** By design; the draft carries
+ its own.
+- **No `From:`-address-derived signature** (e.g. one per identity within an
+ account). Accounts are the only identity this application models.