search: email address search + address-first ranking; inline images; folder i18n; link security

Search (PG backend):
- '@' terms match address substrings (from/to/cc), so a partial email like
  "dyrektor@wfdif" resolves and narrows predictably as you type
- index each address split into parts, so a bare domain/local word ("wfdif")
  finds the address (indexed as one lexeme otherwise); dotted terms fall back to
  an address substring
- rank sender/recipient name+address matches above body/subject-only ones
  (addr_hit tier, then recency); cursor extended to keep keyset pagination correct
- scripts/reindex_address_parts.py backfills address tokens into existing rows

Inline images:
- extract attachments via a full MIME walk instead of shallow iter_attachments(),
  which missed inline images nested in mixed>related>alternative (cid: rendered
  broken); has_attachments counts only real (non-cid) attachments
- scripts/backfill_inline_attachments.py stores previously-missed inline images

Folder-name i18n (SPEC §12 M2):
- accent/punctuation-insensitive, multi-delimiter (/ \ .) special-use inference
  across ~13 languages; curated names (no substring false positives)

Link security (reading panel):
- links never navigate the message iframe: a parent-side click interceptor opens
  http/https in a new tab (noopener) and mailto in the composer (recipient
  pre-filled); all other schemes blocked

Backend 292 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-07-26 09:28:48 +02:00
co-authored by Claude Opus 4.8
parent 49738740c0
commit c023cfa15e
11 changed files with 483 additions and 69 deletions
+14 -1
View File
@@ -218,6 +218,7 @@ function MessageItem({
alsoIn,
onToggle,
onReply,
onMailto,
onToggleRead,
onPrint,
onDelete,
@@ -235,6 +236,7 @@ function MessageItem({
alsoIn: string[];
onToggle: () => void;
onReply: (kind: string) => void;
onMailto: (email: string) => void;
onToggleRead: () => void;
onPrint: () => void;
onDelete: () => void;
@@ -385,7 +387,7 @@ function MessageItem({
</div>
</div>
<div className="px-4 pb-3">
<MessageBody message={message} />
<MessageBody message={message} onMailto={onMailto} />
</div>
{showReplyBar && canReply && (
<div className="flex gap-2 border-t border-app-border px-4 py-3">
@@ -479,6 +481,16 @@ export function ThreadView({
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
};
// A mailto: link in the message body opens a fresh composer to that address,
// sent from the account this message belongs to.
const openMailto = (msg: MessageEnvelope, email: string) => {
setExpanded((prev) => new Set(prev).add(msg.id));
setComposing({
anchorId: msg.id,
init: { accountId: msg.account_id, kind: "new", to: email, subject: "" },
});
};
useEffect(() => {
if (replyKindSignal && newest) openReply(newest, replyKindSignal.kind);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -583,6 +595,7 @@ export function ThreadView({
alsoIn={alsoIn}
onToggle={() => toggle(message.id)}
onReply={(kind) => openReply(message, kind)}
onMailto={(email) => openMailto(message, email)}
onToggleRead={() => toggleRead(message)}
onPrint={() => void printMessages(subject, [message])}
onDelete={() => deleteMessage(message)}