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
+33
View File
@@ -93,6 +93,39 @@ def test_attachment_extracted() -> None:
assert parsed.attachments[0].payload == b"\x00\x01\x02data"
def test_inline_image_nested_in_related_is_extracted() -> None:
# Real-world structure: mixed > related > alternative(text, html) + inline image.
# iter_attachments() misses the nested image; a full walk must find it so the
# cid: reference resolves (regression: broken inline image).
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
root = MIMEMultipart("mixed")
root["Subject"] = "newsletter"
root["From"] = "biuro@kipa.pl"
root["To"] = "me@example.com"
root["Message-ID"] = "<n@x.com>"
related = MIMEMultipart("related")
alt = MIMEMultipart("alternative")
alt.attach(MIMEText("text version", "plain"))
alt.attach(MIMEText('<p>Hi <img src="cid:img001"></p>', "html"))
related.attach(alt)
img = MIMEImage(b"\x89PNG\r\n\x1a\ndata", "png")
img.add_header("Content-ID", "<img001>")
img.add_header("Content-Disposition", "inline", filename="image001.png")
related.attach(img)
root.attach(related)
parsed = parse_message(root.as_bytes())
assert len(parsed.attachments) == 1
att = parsed.attachments[0]
assert att.content_id == "img001"
assert att.mime_type == "image/png"
# Inline-only: no attachment chip / paperclip.
assert parsed.has_attachments is False
def test_missing_message_id_returns_none() -> None:
msg = EmailMessage()
msg["Subject"] = "no id"
+30
View File
@@ -168,6 +168,36 @@ async def test_free_text_full_email_address(seeded: Ctx) -> None:
assert await _search(seeded, "bob@corp.co") == 1 # prefix while still typing
async def test_free_text_email_matches_address_consistently(seeded: Ctx) -> None:
# A term with '@' is treated as an address search and matches as a substring
# of from/to/cc, so it narrows *predictably* as you type more of the address
# (a partial term never returns more than the fuller one).
assert await _search(seeded, "bob@") == 1 # local part -> bob only
assert await _search(seeded, "bob@corp") == 1
assert await _search(seeded, "bob@co") == 1
assert await _search(seeded, "@corp.com") == 3 # all three share the domain
assert await _search(seeded, "zzz@corp") == 0 # no such sender
async def test_address_matches_rank_before_body_matches(ctx: Ctx) -> None:
# Older message matches "zebra" only via its sender address; the newer one
# matches only in the body. The address/name hit must still come first.
await _ingest(ctx, frm="Zebra Corp <hello@zebra.com>", subject="hi", body="nothing here")
await _ingest(ctx, frm="Someone <someone@corp.com>", subject="a note", body="the zebra ran")
resp = await ctx.client.get("/api/search", params={"q": "zebra"})
subjects = [t["thread"]["subject"] for t in resp.json()["threads"]]
assert subjects == ["hi", "a note"] # address hit first, then body-only
async def test_free_text_bare_domain_word_matches_address(seeded: Ctx) -> None:
# A bare domain/local word finds the address even though Postgres indexes the
# whole address as one lexeme — address parts are tokenized at index time.
assert await _search(seeded, "corp") == 3 # the domain word -> all three
assert await _search(seeded, "carol") == 1 # local part -> carol only
# A dotted domain term also matches via the address-substring fallback.
assert await _search(seeded, "corp.com") == 3
async def test_from_operator(seeded: Ctx) -> None:
assert await _search(seeded, "from:bob") == 1
assert await _search(seeded, "from:corp.com") == 3 # all three share the domain
+50 -10
View File
@@ -1,4 +1,4 @@
"""Folder-name special-use heuristics (SPEC §6.3)."""
"""Folder-name special-use heuristics (SPEC §6.3 / §12 M2)."""
from __future__ import annotations
@@ -10,24 +10,64 @@ from app.sync.folder_names import infer_special_use
@pytest.mark.parametrize(
"path,expected",
[
# inbox
("INBOX", "inbox"),
("inbox", "inbox"),
("INBOX.", "inbox"), # Dovecot may show a trailing delimiter
# English / Outlook / Gmail
("Sent", "sent"),
("Wysłane", "sent"), # Polish
("Elementy wysłane", "sent"),
("Gesendet", "sent"), # German
("Sent Items", "sent"),
("[Gmail]/Sent Mail", "sent"),
("Drafts", "drafts"),
("Robocze", "drafts"),
("Trash", "trash"),
("Kosz", "trash"), # Polish
("Papierkorb", "trash"), # German
("Spam", "junk"),
("Deleted Items", "trash"),
("Bin", "trash"),
("Junk", "junk"),
("Junk E-mail", "junk"), # Outlook, punctuation-insensitive
("[Gmail]/Spam", "junk"),
("Archive", "archive"),
("[Gmail]/All Mail", "archive"),
# Polish (accents)
("Wysłane", "sent"),
("Elementy wysłane", "sent"),
("Robocze", "drafts"),
("Kosz", "trash"),
("Wiadomości-śmieci", "junk"),
("Archiwum", "archive"),
("INBOX/Sent", "sent"), # nested leaf
# German
("Gesendet", "sent"),
("Gesendete Elemente", "sent"),
("Entwürfe", "drafts"),
("Papierkorb", "trash"),
("Gelöschte Elemente", "trash"),
# French
("Éléments envoyés", "sent"),
("Brouillons", "drafts"),
("Corbeille", "trash"),
("Courrier indésirable", "junk"),
# Spanish / Italian / Dutch / Portuguese
("Enviados", "sent"),
("Papelera", "trash"),
("Posta inviata", "sent"),
("Cestino", "trash"),
("Verzonden", "sent"),
("Prullenbak", "trash"),
("Itens enviados", "sent"),
("Lixeira", "trash"),
# Russian (Cyrillic)
("Отправленные", "sent"),
("Корзина", "trash"),
("Черновики", "drafts"),
("Спам", "junk"),
# delimiters / nesting
("INBOX/Sent", "sent"),
("INBOX.Sent", "sent"), # Dovecot dot delimiter
("INBOX.Trash", "trash"),
# negatives — must not misfire
("Work", None),
("Random Folder", None),
("Sent to John", None), # curated names, not substring match
("Newsletter", None),
("Projects/Client", None),
],
)
def test_infer_special_use(path: str, expected: str | None) -> None: