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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user