Files
pl_mail/assets/styles
Paul LütznerandClaude Opus 5 8dc22b49c0 feat(search): show which words a result matched on
A row asserted a match and then showed something else. The preview was the
opening hundred characters of the body, which for most hits does not contain the
search term at all, and nothing was marked — so the only way to learn why a
conversation was in the list was to open it.

Every occurrence is marked now, in the subject and in the preview, and the
preview is a window of the message AROUND the match. The fragment comes from the
same Postgres `ts_headline` the JMAP snippet has always used, over the same query
that found the mail, so the marking is stemmed exactly as the search is and
cannot mark something the search did not match on.

**It also closes a hole that was open in the JMAP API.** `ts_headline` is a
highlighter and not an escaper: it inserts the delimiters it is given and returns
the rest of the document byte for byte. Asking it for `StartSel=<mark>` produced
a string in which the sender's markup and ours were the same kind of thing, and
the check that stood in for escaping — "does it contain `<mark>`" — could not
tell them apart. A subject or text part carrying `<img src=x onerror=…>` shipped
it to every client as snippet HTML, and per RFC 8621 a client is right to render
that. The class docblock claimed the opposite and had done since it was written.

The delimiters are two control characters now. The whole string is escaped and
only then are they swapped, pairs only, in one class that owns both the options
and the conversion so they cannot be changed apart. `toHtml()` answers JMAP;
`toMarkup()` answers Twig, which is what lets the row print a fragment without
`|raw` — that file still does not contain one.

Three things worth knowing:

- `ShortWord` went from 3 to 0. At 3 `ts_headline` trims short leading words, so
  "Oak for the alcove" searched for `alcove` came back as the bare word — a row
  that has rewritten the subject rather than highlighted it.
- A highlighted subject is used only when it still equals the thread's subject
  with the markers removed; anything else falls back. And it goes to a separate
  variable, because `subject` is also spent on `aria-label`, where a screen
  reader announcing the markup would be a worse regression than the one this
  fixes.
- The headline replaces the preview only when it actually contains a marker. That
  makes the two documented `body_text` regressions self-sorting: a bulk sender's
  "this email is only available in HTML" does not contain the term, so it does
  not win.

One batched query per account per page, not one per row: the budget test is a
hard ceiling and a per-row headline would blow it instantly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:18:08 +02:00
..