summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-08-14-search-from-message-design.md
blob: c5bbfc7b6fa68d511cd0c746802645a66e39a26c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# Searching from what is on screen

Resolves the first half of backlog item **78**. Depends on nothing unshipped.

## What this is

Five surfaces in the message pane become right-clickable. Each offers two
entries: **Search for this**, which replaces the query bar, and **Add to
search**, which narrows what is already there. The value under the cursor
becomes a notmuch query and runs.

Nothing here writes a rule.

## What this is not, and why

Item 78 as filed asks for a **tagging rule** built from something visible in a
message. That is deliberately not this. The user's reasoning, taken as the
decision: a saved query can already be promoted to a rule (item 81), so the
road from "I see something interesting" to "a rule tags it" already exists as
search, save the query, build the rule from it. Three steps, each reversible,
each showing its result before the next.

Searching is the step that is missing, and it is the safe one: a query costs
nothing if it is wrong, while a rule is handed to a `post-new` hook that runs
unattended against real mail every ten minutes. Building the shortcut before
the road works would put the dangerous end first.

**Item 78 therefore stays open** after this ships, carrying the rule shortcut
alone. Revisit it once the search actions have been used enough to know which
values are worth promoting directly.

## The surfaces

| Surface | Widget | Query built | Offered when |
|---|---|---|---|
| Subject | header `QLabel` | `subject:"..."` | always |
| Date | header `QLabel` | `date:YYYY-MM-DD..YYYY-MM-DD` | always |
| From, To, Cc | header `QLabel` | `from:` / `to:` / `cc:` | single-message thread |
| Tag chip | `TagStrip` | `tag:x` | per chip |
| Body selection | `QWebEngineView` | `"..."` | selection non-empty |
| Every header, per message | details dialog | all of the above | always |

**From, To and Cc are offered exactly when the header displays them**, which is
when the thread holds one message. The header already adapts this way
(`messageview.cpp:470-489`) and the reason is recorded there: for a real thread
the recipient differs message to message and neither the union nor the
intersection is "the" recipient. The menu must not offer a value the header is
not stating, so it shares the condition rather than restating it.

For a multi-message thread those three live in the details dialog, where they
are per-message and unambiguous. That is the fallback, not the primary route: a
single-message thread is the common case and reaching its sender should take one
click, not a trip through a dialog.

**The `+N` overflow chip offers nothing.** It stands for a list, not a tag.

**The header menu lists the fields; it does not hit-test them.** The header is
one rich-text `QLabel` holding up to four rendered lines, and working out which
line the cursor is on means mapping a point through laid-out rich text, which is
fiddly and breaks as soon as the label wraps. A right-click anywhere on the
header instead offers every field available for that message, each entry naming
its value:

```
Search for subject "Invoice 4471"          >  Search for this
Search for from foo@example.org            >  Add to search
Search for mail from 2026-08-14
```

The values are kept beside the label as a small list, populated by the same pass
that renders it, rather than parsed back out of the markup. Building the display
string and the searchable value in one place is what stops them disagreeing.

Listing is also the better affordance: the user sees which fields are searchable
without discovering it by clicking in the right spot. A long value is elided in
the entry's text; the query uses the full one.

The tag strip is hit-tested rather than listed, because a chip is a discrete
widget-like thing with its own rect and the user is aiming at one.

## Two operations

**Search for this** sets the query bar to the term and runs it.

**Add to search** combines with what the bar already holds:

```
(existing) AND (new)
```

**Both sides are parenthesised, and this is load-bearing.** The bar may hold a
hand-written query containing `or`, and `a or b AND c` binds as `a or (b AND c)`,
which silently widens the search instead of narrowing it. This is the same trap
the `post-new` hook already handles when it scopes a rule with `tag:new`, and it
is recorded in CLAUDE.md for that reason.

When the bar is empty, **Add to search** behaves as **Search for this** rather
than producing `() AND (new)`.

The account scope is not touched. `MainWindow::runQuery()` wraps the bar's text
in the selected account's scope on every run (`mainwindow.cpp:1981-1983`), so a
scope applied here would be applied twice.

## Quoting

One helper, `searchTermFor(const QString &)`, used by every surface:

- trim, and collapse runs of whitespace and newlines to single spaces
- escape embedded `"`
- wrap in double quotes
- cap the length; a multi-kilobyte selection is a mis-drag, not a query
- an empty or whitespace-only result yields no menu entry at all

**This gets its own tests, and they matter more than they look.** notmuch's
parser rejects almost nothing: CLAUDE.md records that `from:((((` parses cleanly
and matches zero. A mis-quoted query therefore does not error, it silently
returns nothing, and the feature looks broken with no clue why. Body selections
are arbitrary user-chosen prose and can contain quotes, colons, parentheses,
`AND`, newlines, or a pasted log.

Assert on the constructed string, never on a provoked parser failure.

## The date needs a parser that already exists

`ParsedMessage::date` is the raw `Date:` header text, so a date search has to
parse it. The parse is already written, inside the file-local
`attachmentFolderName()` in `mimeparser.cpp`, and it carries a fix this feature
would otherwise walk straight into: **`Qt::RFC2822Date` rejects the entire
string when a trailing timezone comment is present**, and `... +0200 (CEST)` is
legal per RFC 5322 and common in the wild (verified on Qt 6.11). A second parser
written without that fix loses the date silently on a large share of real mail,
and the symptom is a menu entry that never appears.

It is therefore extracted as `MimeParser::parseDate()` and called from both
places, rather than reimplemented.

## Wiring

`MessageView` already has the right signal:

```cpp
void queryRequested(const QString &query);
```

It exists for the placeholder pane's helper lines and carries a documented
security gate: a message body is attacker-controlled HTML and can hold a
`qtmaildir-query:` link, so the signal is emitted only when the placeholder is
what is displayed (`messageview.h:140-149`).

**It is the right precedent but the wrong signature.** These actions carry a
second value, whether the query replaces the bar or narrows it, and widening
`queryRequested` would change what the placeholder's links mean. `MessageView`
therefore gains

```cpp
void searchRequested(const QString &query, bool extend);
```

beside it, and `queryRequested` keeps its gate and its meaning untouched. The
two coexist deliberately: a link in a rendered document is attacker-reachable
and stays gated, while these menus are application chrome built by our own code
from values we extracted, so they need no gate.

`TagStrip` emits a tag and a position, and the details dialog emits the same
`searchRequested` pair; `MessageView` forwards the dialog's straight through, so
one signal reaches the window whichever surface the user used. Every one of them
carries a finished query string; the panes never touch the query bar.
`MainWindow` receives, sets `m_queryEdit`, and calls the existing runner, which
keeps the account scope and the generation counter working.

**Extend needs the bar's current text**, which only `MainWindow` has. The signal
therefore carries the term plus which operation was chosen, and `MainWindow`
does the combining. A pane that built `(existing) AND (new)` itself would have
to read the query bar, which is exactly the coupling the existing signal's
comment says to avoid.

## The details dialog is rebuilt as rows

Today it is one `QPlainTextEdit` holding every message's headers as flat text,
built inline in a 40-line `showDetailsDialog()` and shown with `exec()`
(`messageview.cpp:500-552`). The user does not like that it appears as a text
box, and a flat text box cannot carry a per-value context menu without parsing
displayed text back into structure.

It becomes a scrollable column of labelled rows, one row per header per message,
each row holding its own value and its message index. The context menu then
carries the real value with no parsing.

**One property of the current dialog must survive.** It uses `setPlainText` and
a `QPlainTextEdit` deliberately, and the comment says why: header values come
from strangers, and plain text cannot interpret markup, so there is nothing to
escape and nothing that could render. Row widgets reintroduce that risk, because
a `QLabel` interprets rich text by default and Qt will guess when the format is
`Qt::AutoText`.

Every value label is therefore **explicitly `Qt::PlainText`**. Not escaped
input into a rich-text label, which is the same protection one mistake away from
failing.

The dialog moves out of `MessageView` into its own `MessageDetailsDialog` class.
A 40-line inline builder that now grows rows, menus and signals is past what
belongs inline, and the class is separately testable.

## Testing

- `searchTermFor` against quotes, newlines, colons, parentheses, `AND`, empty
  and whitespace-only input, and an over-long selection.
- Term construction per surface: a tag chip yields `tag:x`, a date yields the
  day's range, a subject is quoted.
- **Add to search** parenthesises both sides, and an `or` in the existing query
  survives it. Empty bar falls back to replace.
- `TagStrip::chipAt` against the painted rects, including a miss in the gap
  between two chips and the `+N` chip yielding nothing. The hit test and the
  paint take their geometry from one function so they cannot drift, as
  `CardDelegate::expanderRectFor` already does for the expander.
- Header menu contents: From/To/Cc entries present for a single-message thread,
  absent for a multi-message one, with subject and date present in both. **The
  absence half needs a guard proving the menu was built at all**; item 82 records
  that a test asserting only the absence of a widget passes against no
  implementation whatever. Asserting that subject and date are still there is
  that guard.
- An empty header field yields no entry. A message with no Cc must not offer
  `cc:""`, which parses cleanly and matches nothing.
- Details dialog rows carry the right value and message index, and value labels
  report `Qt::PlainText`.
- One `test_mainwindow` case that a triggered action leaves the expected text in
  the query bar.

Three traps from CLAUDE.md apply and are not re-derived here: a rendering probe
proves nothing about which widget is clickable, visible and clickable and working
are three separate properties, and a hit test asserted against `visualRect` can
endorse a broken layout.

## Out of scope

**Creating a rule directly from a value.** The remainder of item 78, above.

**Searching from the thread list.** Its context menu is tag actions today and
the row's `authors` is a display summary from
`notmuch_thread_get_authors`, not an address: it reads `Alice, Bob` or
`Alice| Bob`, so `from:` built from it matches nothing. Any thread-list search
needs a real address resolved from a message first. Worth recording because the
backlog's own approach line for item 78 assumed the row held a usable sender.

**Refining by anything not on screen.** No date-range picker, no "from anyone at
this domain". Both are reasonable and neither is this.

**The web view's standard context menu entries.** Copy and the rest stay as
they are; the search entry is added alongside.