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-26-card-avatars-design.md310
-rw-r--r--docs/superpowers/specs/2026-08-27-forward-html-design.md181
2 files changed, 491 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-26-card-avatars-design.md b/docs/superpowers/specs/2026-08-26-card-avatars-design.md
new file mode 100644
index 0000000..e396a34
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-26-card-avatars-design.md
@@ -0,0 +1,310 @@
+# Card avatars and the account fade
+
+Design for backlog item 169. Brainstormed with the user on 2026-08-26.
+
+**Status:** specified, unbuilt.
+
+## The problem
+
+The user's note asks for two things about a card:
+
+> the left border of a card expresses the account the mail belongs to. the
+> background color of the card should fade left to right from the account color
+> to the current background color we are using (or to transparent to work both
+> in light and dark themes). On the left we should leave room for an account
+> avatar (a squircle), for now it could be extracted from the sender name "From:
+> john doe" becomes "JD" in the avatar. As soon as we include khard (or some
+> other vcard provider/manager) we will switch to images if the corresponding
+> vCard has one.
+
+Half of it already shipped. The account colour is drawn as a solid bar down the
+left edge (`CardLayout::accentRect`, filled in `CardDelegate::paint()` through
+`CardDelegate::accentLineColour()`), and a reply's spines carry the same colour
+muted against the pane's base. What does not exist is any gradient, and any
+avatar: `CardLayout` reserves no rect for one, so the geometry has to grow
+before the painting can.
+
+The note calls it an "account avatar", but the brainstorm settled that it is
+the SENDER's, not the account's. The account is already expressed twice, by the
+accent bar and by the fade; an avatar in the account's colour would conflate
+"which mailbox received this" with "who wrote it", which are different facts.
+
+## What the card looks like
+
+A card stays exactly three lines and every row keeps the same height, so
+`setUniformRowHeights(true)` survives untouched.
+
+```
++--+-------+------------------------------------------------+
+| | | sender 26/08 09:14 |
+|##| [AV] | * subject @ v 3 replies |
+| | | [tag] [tag] |
++--+-------+------------------------------------------------+
+ ^ ^
+ | +-- avatarRect, full height, its own gutter
+ +-------- accentRect, unchanged
+```
+
+The avatar is a full-height squircle in its own gutter, on roots and replies
+alike. `contentLeft` shifts right by the gutter, on top of the existing indent.
+
+Three alternatives were shown to the user and rejected: a two-line squircle
+with the tag strip running full width beneath it, and a small squircle inline
+on the sender line costing no width. The full-height form won on presence, with
+the cost accepted explicitly: at `kMaxDepth` the subject loses the gutter's
+width on top of the indent it already loses.
+
+## The fade
+
+A horizontal gradient of the account colour, drawn after the chrome and before
+the text, so the selection highlight and the deleted-row tint still cover it.
+That a selected row reads mostly as selection is expected, not a fault.
+
+- **On a thread root** it starts at the card's left edge.
+- **On a reply** it starts at that reply's INNERMOST spine, which is its own
+ coloured left border. The wash therefore steps right with the nesting, and a
+ deeper reply's outer spines stand in plain background.
+- **It ends at 60% of the card's width**, in both cases. The end is
+ proportional, so a reply's wash is shorter as well as further right.
+- **A reply's gradient is weaker than a root's**, so an expanded thread reads as
+ one coloured block with the root leading it.
+
+The user chose the reply origin against the alternative of starting every fade
+at the card's left edge. The spine reading is the correct one because the
+spines are ALREADY the account colour: `carddelegate.cpp` muted them at 0.55
+against `QPalette::Base` and resolves a reply's colour by walking to its root,
+so the coloured border the fade hangs off is a thing that exists rather than
+one this item introduces.
+
+60% was chosen by the user over 50%. It is a percentage of card WIDTH, which
+means the wash grows with the window; a fixed pixel distance and an
+anchor-to-the-layout variant were both offered and declined.
+
+## Whose face
+
+The **sender's**, except in the flat views (Sent and Drafts) where it is the
+**recipient's**, since those views already show recipients in the sender's slot
+and `authors` is the user on every row there.
+
+This costs no extra query. `MainWindow::m_sentView` is really "flat view": it is
+assigned from `FlatResult`, and `generatorIsFlat()` in `config.cpp` is the closed
+set `{sent, drafts}`, so both already request the recipients fold. The name is
+misleading and deserves a comment, but not a rename inside this item.
+
+For a thread row the sender is the one belonging to `firstMessageId`, the
+message the card already stands for. Nothing new is resolved.
+
+## The initials
+
+Two letters, always, so every squircle reads the same shape:
+
+| Input | Rule | Example |
+|---|---|---|
+| Display name, two or more words | first letter of the first two words | `John Doe` -> `JD` |
+| Display name, one word | first two letters of that word | `Cofidis` -> `CO` |
+| No display name | first of the local part, first of the domain | `noreply@cofidis.it` -> `NC` |
+| Nothing usable at all | first two of the account's label | see the fallback below |
+
+The user chose the local-plus-domain form over first-two-of-local (`NO`) and
+over a single letter (`N`), because it never degrades to one letter and never
+reads as a truncated word.
+
+## The fill
+
+Two fills, both generated locally from a hash of the sender's address, both
+stable per sender.
+
+- **Identicon** - a 5x5 symmetric grid from the hash bits, with a translucent
+ dark veil between the pattern and the letters so the initials stay readable
+ whatever the pattern does.
+- **Two-tone** - two related hues and a split angle from the hash, initials on a
+ large flat field.
+
+Which one, in order:
+
+1. The address is listed in `business-senders` -> **two-tone**, whatever display
+ name it presents.
+2. Otherwise a display name is present -> **identicon**.
+3. Otherwise -> **two-tone**.
+
+Rule 2 is the one the user asked for by name: `Ian Farrell
+<notifications@github.com>` gets `IF` and the identicon, because it presented
+itself as a person, even though the address is corporate. Rule 1 is the override
+that lets a listed sender be forced back to two-tone.
+
+Colours are generated at a FIXED lightness, so `TagColors::textColourOn()`-style
+contrast reasoning holds and the initials stay legible in both themes.
+
+**None of this is Gravatar in the sense of contacting Gravatar.** A real lookup
+would send a hash of every correspondent's address to a third party on each
+repaint, which is out on the project's privacy stance and on the no-network
+rule. Only the generated half of the idea is taken, and it works entirely
+offline with `QCryptographicHash`, which Qt Core already provides.
+
+Item 72's vCard photo would later replace the fill without touching the rect.
+
+## The fallback
+
+There is always an avatar. When no sender address is available at all, the
+squircle is hashed from the **account's own address**, which the card always
+knows through its account, and the initials are the first two letters of the
+account's label. This keeps a stable, themed squircle rather than a hole, and
+it cannot be confused with a real sender because the two-letter source is the
+account.
+
+## `ThreadSummary::firstMessageSender`
+
+**This is the one structural change, and it exists because the card currently
+has no address to hash.**
+
+`ThreadSummary::authors` is notmuch's own summarised string and carries display
+names ONLY. Measured against the real index on 2026-08-26: `'Standreas'`,
+`'Randstad Italia'`, `'Ryanair'`, `'The Hacker News tramite LinkedIn'`. No `@`
+anywhere. The initials rule survives that, but two things do not:
+
+- the identicon has nothing stable to hash, and
+- the `business-senders` list has nothing to match.
+
+Hashing the display name instead was considered and rejected: notmuch builds
+those strings, so one sender varies its identity as the string varies
+(`The Hacker News tramite LinkedIn` is a constructed label, not a header value).
+
+So `ThreadSummary` gains `firstMessageSender`, the bare address of the message
+the card stands for. It is filled by the SAME worker walk that already fills
+`firstMessageId` and `firstMessageTags`, from the same message, and `From` is
+served from notmuch's index rather than from the file. This is exactly the
+pattern item 111 used for `firstMessageTags` and it carries the same "free, for
+the same reason" note.
+
+The Sent/normal split is already resolved in that walk and must be respected:
+`withRecipients` selects the first MATCHED message for a flat view and the
+thread's opening message otherwise, so the address follows whichever message
+the card is standing for.
+
+**Measured cost of reading senders from the index**, on the developer's own
+database: 1322 distinct senders deduplicated in 12 ms, and 5105 messages
+enumerated in 76 ms. There is no cost problem here, which is what makes the
+whole feature and its list practical.
+
+## The `business-senders` list
+
+`~/.config/qtmaildir/business-senders`. Plain text, one entry per line, `#`
+comments, blank lines ignored.
+
+Deliberately NOT in `qtmaildir.conf` and deliberately not INI. The user's stated
+workflow is grep-and-edit ("if a personal email ends in the list by mistake I
+can grep it out and remove it"), QSettings would fight a bare list, and the main
+config is already large.
+
+An entry is either:
+
+- an exact address, `noreply@cofidis.it`, or
+- a whole domain, `@cofidis.it`, which is what a company sending from six
+ addresses actually needs.
+
+**No globs.** A pattern language nobody asked for is a rule the user cannot grep
+for literally, which defeats the file's whole purpose.
+
+Read once at startup and on an explicit reload. Never per repaint, and never
+stat-per-row.
+
+## How the list fills itself
+
+At the end of every sync the application runs, the worker collects the senders
+of the newly arrived mail and appends CANDIDATES, commented out:
+
+```
+# noreply@cofidis.it (47 messages)
+```
+
+**How much mail the scan covers** depends on whether the list has ever been
+used. With no file, or a file holding no active entry, it scans the WHOLE
+database; afterwards it scans the last week. The first run is exactly when a
+full scan earns its cost: a week of mail proposes almost nothing, so a
+week-only rule would leave the list taking months to become useful. It is
+affordable because it happens once, measured at 76 ms over 5105 messages.
+
+A file holding only rejected candidates still counts as unused. Rescanning it
+re-proposes none of them, since anything already mentioned is skipped.
+
+A candidate is an address whose local part is in a small built-in word list
+(`noreply`, `no-reply`, `donotreply`, `info`, `support`, `billing`,
+`newsletter`, `notifications`, `mailer-daemon`), or one that recurs with no
+display name.
+
+Two rules make this safe, and both are borrowed from `mailrules.py`'s existing
+discipline:
+
+- **Never writes an uncommented entry.** Nothing on screen changes until the
+ user uncomments a line. A step that silently reclassified forty senders would
+ have to be audited line by line anyway.
+- **Never removes, and never re-adds.** An address already present in the file
+ in ANY form, commented or not, is skipped. An entry the user grepped out
+ therefore stays out, instead of reappearing within ten minutes with no
+ explanation.
+
+### Why the application writes it, and not the `post-new` hook
+
+Putting the scan in `assets/hooks/` was considered at length and rejected. It
+is the better placement on paper: `post-new` runs after EVERY `notmuch new`
+whatever started it, including the user's cron, whereas `mailsync.sh` is only
+the route qtmaildir drives, and the hook is already scoped to `tag:new` so it
+would see only new mail.
+
+It was rejected on weight. The hook is Python and the reader is C++, so it would
+recreate the two-implementations-of-one-format situation that `CLAUDE.md`
+documents for `rules.json`, with the same requirement that both sides change
+together and the same by-test-only agreement. That is a heavy contract for a
+cosmetic feature.
+
+The application writing it has one implementation, already knows when a sync
+finished, and the scan is free. The only case lost is mail indexed by cron while
+qtmaildir is not running, and those senders are picked up at the next run.
+
+## What is testable, and what is not
+
+Per the standing rule (`tests-only-for-measurable-things`), this feature is
+mostly judged by looking. Split accordingly:
+
+**Assert in tests:**
+
+- `CardLayout` places `avatarRect` and moves `contentLeft`, at several depths
+ and for a root against a reply. Geometry only, no painter, per the existing
+ rule that a card layout must be testable without one.
+- The initials function, over all four rules in the table above, including the
+ one-word and no-display-name cases and an address with no `@` at all.
+- The fill CHOICE, over the three ordered rules, including a listed address
+ that presents a display name.
+- Hash stability: the same address yields the same fill twice, and two
+ different addresses differ.
+- `business-senders` parsing: exact entries, `@domain` entries, comments, blank
+ lines, and an entry with leading or trailing space.
+- The append step: never writes an uncommented line, and skips an address
+ already present commented out. This is the data-loss-adjacent property, so it
+ is the one worth the most care.
+- `ThreadSummary::firstMessageSender` is filled by the query walk, for a normal
+ view and for a flat one, where the two must resolve DIFFERENT messages.
+
+**Hand to the user to look at:** the fade's weight and its 60% end, the two
+fills against real mail, whether the initials are legible over an identicon at
+the desktop's own font size, and whether the gutter costs too much subject at
+depth.
+
+A rendering probe must not be used to judge any of that, for the reasons
+`CLAUDE.md` records under "Rendering probes lie".
+
+## Risks
+
+- **The gutter compounds with the indent.** A deep reply already loses
+ `kMaxDepth * kIndentStep`; it now loses the avatar gutter as well. If it reads
+ badly, the cap is the knob to turn, not the avatar.
+- **An identicon behind two letters is the classic legibility failure.** The
+ veil exists for exactly that and its opacity is a value to tune against the
+ real font, not to fix on the first guess.
+- **`firstMessageSender` is a new field on a struct crossing the thread
+ boundary.** It is a plain `QString` and crosses like the rest, so there is no
+ ownership question, but the walk that fills it must finish while the
+ `NmThread` is alive, exactly as the surrounding code already documents.
+- **The candidate word list is a guess.** It will miss senders and propose
+ wrong ones, which is precisely why nothing it writes takes effect until the
+ user uncomments it.
diff --git a/docs/superpowers/specs/2026-08-27-forward-html-design.md b/docs/superpowers/specs/2026-08-27-forward-html-design.md
new file mode 100644
index 0000000..a71cc54
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-27-forward-html-design.md
@@ -0,0 +1,181 @@
+# Forwarding an HTML message with its formatting
+
+Item 171. Design, 2026-08-27. **Read this before the backlog row**, which
+records only the cause.
+
+## The defect
+
+`ComposeContextBuilder::quoteBody()` reads `ParsedMessage::plainBody` and
+nothing else, so the forward path drops the original's `htmlBody` entirely.
+Two consequences, and they are not the same severity:
+
+- An original with both parts forwards its text/plain alternative. The words
+ survive, the sender's formatting does not. This is what the user reported.
+- **An original with an HTML part ONLY has an empty `plainBody`**, so the
+ forward carries an attribution line and an empty quote. The content is gone
+ and nothing says so.
+
+Measured on the developer's own inbox 2026-08-27: **30 of 342 sampled
+messages** (~9%) declare `text/html` with no `text/plain` part. The silent
+half is not an edge case.
+
+## What was decided, and by whom
+
+The user chose, 2026-08-27, from three routes put to them:
+
+1. **Inline, carrying the original's markup** — CHOSEN. The forward carries
+ the original's own HTML rather than a flattened quote. Highest inline
+ fidelity, and the most work, because it is the only route that puts a
+ stranger's markup into an outgoing message.
+
+ **Amended 2026-08-27, after the first build**: a forward sends ONE part,
+ not a `multipart/alternative`. The Send-as-HTML toggle chooses which — the
+ original's markup when on, the text quote when off. The user's reasoning is
+ that a forward's shape is something they have already decided by flipping
+ that toggle, and sending both halves hands the choice to the recipient's
+ client instead. An ordinary (non-forward) message still sends the
+ alternative as before; only the forward path is single-part.
+2. Attach the original as `message/rfc822` (item 130's mechanism). Rejected
+ here, though item 130 may still build it for its own sake: the user wants
+ the content inline, not as an attachment.
+3. Text fallback only. Rejected: it fixes the silent-loss half and does not
+ answer the note at all, since formatting is still lost on every forward.
+
+**Remote content is STRIPPED BY DEFAULT, with a per-forward opt-out**, also
+the user's choice against "always strip" and "keep everything". A control in
+the composer, checked by default, reading roughly "Strip remote content from
+the forwarded message".
+
+## Why this is the security-critical item in the backlog
+
+Every other HTML path in this application renders a stranger's markup *to the
+user*, behind protections that live in `MessageView`: an off-the-record
+profile, JavaScript disabled, and `RequestInterceptor` blocking every request
+by default and failing closed.
+
+**None of those protections apply here.** The markup leaves this process and
+is rendered by somebody else's mail client, under their policy, on their
+machine. The interceptor cannot help: it intercepts requests *we* would make.
+So the sanitising has to happen to the bytes, before they are handed to
+`MessageSender`, and there is no second line of defence behind it.
+
+The concrete harm, in the user's own words when the decision was put to them:
+forwarding a tracking pixel forwards the tracking. The original sender learns
+that the forwarded copy was opened, by whom, and how many times, and the
+user's recipient never consented to that.
+
+## The allow-list rule, which is not negotiable
+
+`HtmlBuilder::namespaceCids()` is the closest prior art and it is a
+**block-list**: it names the attributes that can carry a `cid:` and rewrites
+those. It documents scoping `srcset=` out, on the reasoning that its quoting
+grammar differs and `cid:` in `srcset` is not seen in the wild.
+
+**That trade is correct for rewriting and WRONG for stripping**, and the
+asymmetry is the whole design:
+
+| | a missed reference means |
+|---|---|
+| `namespaceCids` (rewrite) | one broken image |
+| this sanitiser (strip) | a tracking beacon reaching the recipient |
+
+So the sanitiser must **allow-list what may remain**, not block-list what must
+go. Anything not recognised is removed. A new HTML attribute, a quoting form
+not anticipated, a `srcset`, a CSS `image-set()`, an `@import` — each is
+handled by the default, which is removal, rather than by having been
+enumerated in advance.
+
+Stated as the invariant to test against: **after sanitising, no attribute
+value and no CSS construct in the output may contain a URL whose scheme is
+anything other than `cid:`, and no element that fetches may remain without
+one.**
+
+## What is kept and what goes
+
+Kept:
+
+- `cid:` references. They travel inside the message, fetch nothing, and are
+ what makes an inline logo survive. Item 129 will need the same machinery.
+- Structural and presentational markup: tables, lists, headings, spans,
+ `style=""` attributes with their remote constructs removed.
+
+Removed:
+
+- Any `src`, `href`, `background`, `poster`, `srcset`, `data-*` or other
+ attribute value carrying a non-`cid:` URL scheme. `http:`, `https:`,
+ `//host/path`, `data:` (which can carry markup), `file:` above all.
+- `<link>`, `<script>`, `<iframe>`, `<object>`, `<embed>`, `<base>`, and
+ `<meta http-equiv="refresh">`.
+- CSS `url()`, `@import` and `image-set()` naming anything but a `cid:`,
+ whether in a `style=""` attribute or a `<style>` block.
+- Event-handler attributes (`onload`, `onerror`, …). The recipient's client
+ most likely disables scripting, but that is their policy and not ours to
+ assume.
+
+A stripped `<img>` leaves a gap where the image was. That is the correct
+outcome and should not be papered over with a placeholder that itself fetches.
+
+## Shape of the change
+
+- **`HtmlSanitiser`, a new namespace of free functions over values**
+ (`src/htmlsanitiser.h/.cpp`), matching `MarkdownRenderer` and
+ `MessageBuilder`. No widget, so the security property is testable without a
+ painter or a web engine. This is where the allow-list lives.
+- **`OutgoingMessage` gains the forwarded HTML and the strip flag.** It
+ currently carries `markdownBody` and `sendHtml` only, so there is nowhere to
+ put a second HTML source; both are new fields rather than a changed call
+ site.
+- **`MessageBuilder::build()` chooses the part.** On a forward with the toggle
+ ON, the body is one `text/html` part: the user's rendered markdown, a rule,
+ then the sanitised original. With the toggle OFF it is one `text/plain` part
+ carrying the markdown and the text quote. No alternative either way.
+
+ **The toggle is honoured even when the original had no plain-text part**, so
+ an HTML-only original forwarded with the toggle off goes out as the text
+ fallback and its formatting is lost. Chosen deliberately 2026-08-27 over
+ forcing HTML for those messages, so that the toggle means what it says; the
+ first build forced it and that was reversed.
+- **`quoteBody()` is not the fix and must not become it.** It is shared with
+ Reply, and the behaviour asked for is the forward's alone. It keeps
+ producing the text quote for the plain half. Its one change is the
+ silent-loss case: when `plainBody` is empty it should render `htmlBody` down
+ to text rather than emitting an empty quote, so the plain half is never
+ blank.
+- **The composer control** sits with the existing per-message toggles, checked
+ by default, and only appears on a Forward carrying HTML.
+
+## Traps recorded in advance
+
+- **`quoteBody()` is shared with Reply.** A change there reaches both paths.
+- **`QString::arg()` does not collapse `%%`.** CLAUDE.md records the 0.11.0
+ placeholder losing its mask, its glow and both gradients this way while
+ still painting a plausible pane. Any generated CSS here is subject to it.
+- **A geometry or rendering probe cannot see this.** The property under test
+ is "no remote URL survives", which is a property of the STRING. Assert on
+ the generated bytes. CLAUDE.md's "Rendering probes lie" section is the
+ general warning; here a probe that renders the result and looks at it would
+ endorse a tracking pixel it cannot see, because a 1x1 transparent image is
+ invisible by design.
+- **Test with real hostile shapes**, not tidy markup: unquoted attribute
+ values (`<img src=http://x/p>`), mixed quoting, whitespace and newlines
+ around `=`, uppercase tags and attributes, a `cid:` whose id contains a
+ URL-looking substring, and a `style` attribute carrying `url(...)` with no
+ quotes. `namespaceCids()` documents each of these forms as real.
+- **`MimeParser` must parse what we build.** A round trip through
+ `MessageBuilder` and back is the check that the nesting is right, and it is
+ cheaper than reading the RFC.
+
+## Order of work
+
+1. `HtmlSanitiser` with the allow-list, tested against the hostile shapes
+ above. Nothing else depends on decisions inside it.
+2. `quoteBody()`'s empty-plain fallback, which closes the silent-loss half on
+ its own and is independently useful.
+3. `OutgoingMessage` fields and the `MessageBuilder` nesting, with a
+ parse-back round trip.
+4. The composer control, defaulting to strip.
+5. Hand test: forward a real HTML message to yourself with the box checked and
+ unchecked, and confirm what arrives.
+
+Steps 1 and 2 are separable and each ship a real improvement, so this does not
+have to land as one commit.