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:
@@ -54,7 +54,7 @@ def _build_search_doc(message: Message, parsed: ParsedEmail, special_use: str |
|
||||
cc=parsed.cc_addr,
|
||||
subject=parsed.subject,
|
||||
body_text=parsed.text_body,
|
||||
attachment_names=[a.filename for a in parsed.attachments],
|
||||
attachment_names=[a.filename for a in parsed.attachments if a.content_id is None],
|
||||
has_attachments=parsed.has_attachments,
|
||||
is_unread="\\Seen" not in message.flags,
|
||||
is_starred="\\Flagged" in message.flags,
|
||||
|
||||
@@ -46,7 +46,9 @@ class ParsedEmail:
|
||||
|
||||
@property
|
||||
def has_attachments(self) -> bool:
|
||||
return len(self.attachments) > 0
|
||||
# Inline resources (cid: images) carry a content_id and render in-body, not
|
||||
# as attachment chips — only real attachments flip the paperclip.
|
||||
return any(a.content_id is None for a in self.attachments)
|
||||
|
||||
@property
|
||||
def snippet(self) -> str:
|
||||
@@ -85,17 +87,25 @@ def _get_body_content(msg: EmailMessage, subtype: str) -> str | None:
|
||||
|
||||
|
||||
def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
|
||||
# Walk the whole tree, not iter_attachments(): the latter is shallow and treats
|
||||
# multipart/related as a body, so an inline image nested in mixed>related>alt is
|
||||
# never yielded (its cid: reference then renders broken). A part is an attachment
|
||||
# or inline resource if it has a filename, a Content-ID, or attachment disposition;
|
||||
# the body text/plain and text/html parts have none of these and are skipped.
|
||||
out: list[ParsedAttachment] = []
|
||||
for part in msg.iter_attachments():
|
||||
if not isinstance(part, EmailMessage):
|
||||
for part in msg.walk():
|
||||
if part.is_multipart() or not isinstance(part, EmailMessage):
|
||||
continue
|
||||
cid = part.get("Content-ID")
|
||||
filename = part.get_filename()
|
||||
if not (filename or cid or part.get_content_disposition() == "attachment"):
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if not isinstance(payload, bytes):
|
||||
continue
|
||||
cid = part.get("Content-ID")
|
||||
out.append(
|
||||
ParsedAttachment(
|
||||
filename=part.get_filename() or "attachment",
|
||||
filename=filename or "attachment",
|
||||
mime_type=part.get_content_type(),
|
||||
content_id=cid.strip("<>") if cid else None,
|
||||
payload=payload,
|
||||
|
||||
+79
-14
@@ -15,7 +15,20 @@ import html
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Text, any_, cast, exists, func, literal, not_, or_, select, tuple_, update
|
||||
from sqlalchemy import (
|
||||
Text,
|
||||
any_,
|
||||
case,
|
||||
cast,
|
||||
exists,
|
||||
func,
|
||||
literal,
|
||||
not_,
|
||||
or_,
|
||||
select,
|
||||
tuple_,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app.db import get_sessionmaker
|
||||
@@ -42,12 +55,23 @@ def _prefix_tsquery(value: str) -> ColumnElement[object]:
|
||||
_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365}
|
||||
|
||||
|
||||
def _email_parts(email: str) -> str:
|
||||
"""Split an address on @/./-/+/_ so its domain and local part are searchable
|
||||
on their own — a bare "wfdif" should find dyrektor@wfdif.com.pl. Postgres
|
||||
indexes the whole address as a single lexeme, so the parts are invisible to a
|
||||
word search otherwise."""
|
||||
return re.sub(r"[@._+\-]+", " ", email)
|
||||
|
||||
|
||||
def _doc_text_blob(doc: SearchDoc) -> str:
|
||||
parts = [doc.subject, doc.body_text]
|
||||
for field in (doc.from_, doc.to, doc.cc):
|
||||
for addr in field:
|
||||
parts.append(addr.get("name", ""))
|
||||
parts.append(addr.get("email", ""))
|
||||
email = addr.get("email", "")
|
||||
if email:
|
||||
parts.append(email) # whole address (one lexeme, for exact/prefix)
|
||||
parts.append(_email_parts(email)) # + its parts as separate tokens
|
||||
parts.extend(doc.attachment_names)
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
@@ -114,7 +138,24 @@ class PgSearchBackend(SearchBackend):
|
||||
if clause.get("phrase"):
|
||||
# Quoted phrase: keep exact-adjacency semantics, no prefix.
|
||||
return Message.body_indexed.op("@@")(func.websearch_to_tsquery(_TS_CONFIG, value))
|
||||
return Message.body_indexed.op("@@")(_prefix_tsquery(value))
|
||||
like = f"%{value}%"
|
||||
addr_match = or_(
|
||||
cast(Message.from_addr, Text).ilike(like),
|
||||
cast(Message.to_addr, Text).ilike(like),
|
||||
cast(Message.cc_addr, Text).ilike(like),
|
||||
)
|
||||
if "@" in value:
|
||||
# A term with '@' is an address search: match the address substring
|
||||
# only (like from:/to:/cc:), so it narrows predictably as you type —
|
||||
# not the split-word body matching that made a partial term over-hit.
|
||||
return addr_match
|
||||
fts = Message.body_indexed.op("@@")(_prefix_tsquery(value))
|
||||
if "." in value:
|
||||
# A dotted term (e.g. "wfdif.com") stays one host lexeme in FTS, so
|
||||
# also match it as an address substring. Bare words don't need this:
|
||||
# address parts are indexed as their own tokens (see _doc_text_blob).
|
||||
return or_(fts, addr_match)
|
||||
return fts
|
||||
key = clause.get("key")
|
||||
cond = self._filter_condition(str(key), value)
|
||||
return cond
|
||||
@@ -189,7 +230,11 @@ class PgSearchBackend(SearchBackend):
|
||||
groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else []
|
||||
assert isinstance(groups, list)
|
||||
conditions = self._build_conditions(groups)
|
||||
terms = _positive_text_terms(groups)
|
||||
|
||||
# Rank a match in a sender/recipient name or address above a body/subject-only
|
||||
# match, so "who" results come first; ties break by recency.
|
||||
addr_hit = _address_hit_expr(terms)
|
||||
rank = (
|
||||
func.ts_rank_cd(Message.body_indexed, _prefix_tsquery(q.text))
|
||||
if q.text
|
||||
@@ -203,6 +248,7 @@ class PgSearchBackend(SearchBackend):
|
||||
Message.snippet,
|
||||
Message.internal_date,
|
||||
rank.label("score"),
|
||||
addr_hit.label("addr_hit"),
|
||||
)
|
||||
.select_from(Message)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
@@ -213,14 +259,16 @@ class PgSearchBackend(SearchBackend):
|
||||
|
||||
decoded = _decode_cursor(cursor)
|
||||
if decoded is not None:
|
||||
cur_date, cur_id = decoded
|
||||
cur_hit, cur_date, cur_id = decoded
|
||||
# Keyset over the full ordering (addr_hit, date, id), all descending.
|
||||
stmt = stmt.where(
|
||||
tuple_(Message.internal_date, Message.id)
|
||||
< tuple_(literal(cur_date), literal(cur_id))
|
||||
tuple_(addr_hit, Message.internal_date, Message.id)
|
||||
< tuple_(literal(cur_hit), literal(cur_date), literal(cur_id))
|
||||
)
|
||||
stmt = stmt.order_by(Message.internal_date.desc(), Message.id.desc()).limit(limit + 1)
|
||||
stmt = stmt.order_by(
|
||||
addr_hit.desc(), Message.internal_date.desc(), Message.id.desc()
|
||||
).limit(limit + 1)
|
||||
|
||||
terms = _positive_text_terms(groups)
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (await session.execute(stmt)).all()
|
||||
|
||||
@@ -236,7 +284,9 @@ class PgSearchBackend(SearchBackend):
|
||||
for row in rows
|
||||
]
|
||||
next_cursor = (
|
||||
_encode_cursor(rows[-1].internal_date, rows[-1].id) if has_more and rows else None
|
||||
_encode_cursor(int(rows[-1].addr_hit), rows[-1].internal_date, rows[-1].id)
|
||||
if has_more and rows
|
||||
else None
|
||||
)
|
||||
return hits, next_cursor
|
||||
|
||||
@@ -250,6 +300,21 @@ def _positive_text_terms(groups: list[list[dict[str, object]]]) -> list[str]:
|
||||
return terms
|
||||
|
||||
|
||||
def _address_hit_expr(terms: list[str]) -> ColumnElement[int]:
|
||||
"""1 if any positive free-text term appears in a from/to/cc name or address,
|
||||
else 0. Used to sort address/name matches ahead of body/subject-only ones."""
|
||||
conds: list[ColumnElement[bool]] = []
|
||||
for t in terms:
|
||||
if t:
|
||||
like = f"%{t}%"
|
||||
conds.append(cast(Message.from_addr, Text).ilike(like))
|
||||
conds.append(cast(Message.to_addr, Text).ilike(like))
|
||||
conds.append(cast(Message.cc_addr, Text).ilike(like))
|
||||
if not conds:
|
||||
return literal(0)
|
||||
return case((or_(*conds), 1), else_=0)
|
||||
|
||||
|
||||
def _highlights(subject: str, snippet: str, terms: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
if not terms:
|
||||
@@ -274,17 +339,17 @@ def _mark(value: str, terms: list[str]) -> str:
|
||||
return escaped
|
||||
|
||||
|
||||
def _encode_cursor(date: dt.datetime, message_id: UUID) -> str:
|
||||
raw = f"{date.isoformat()}|{message_id}"
|
||||
def _encode_cursor(addr_hit: int, date: dt.datetime, message_id: UUID) -> str:
|
||||
raw = f"{addr_hit}|{date.isoformat()}|{message_id}"
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str | None) -> tuple[dt.datetime, UUID] | None:
|
||||
def _decode_cursor(cursor: str | None) -> tuple[int, dt.datetime, UUID] | None:
|
||||
if not cursor:
|
||||
return None
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||
date_str, id_str = raw.split("|", 1)
|
||||
return dt.datetime.fromisoformat(date_str), UUID(id_str)
|
||||
hit_str, date_str, id_str = raw.split("|", 2)
|
||||
return int(hit_str), dt.datetime.fromisoformat(date_str), UUID(id_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -1,48 +1,120 @@
|
||||
"""Special-use inference from folder names (SPEC §6.3 fallback heuristics).
|
||||
"""Special-use inference from folder names (SPEC §6.3 / §12 M2 fallback).
|
||||
|
||||
Used only when the server does not advertise SPECIAL-USE. Includes Polish names
|
||||
(Wysłane, Kosz, ...) alongside English/German, per the spec's i18n intent.
|
||||
Used only when the server does not advertise the SPECIAL-USE extension. Matching
|
||||
is accent- and punctuation-insensitive (so "Wysłane", "Éléments envoyés" and
|
||||
"Junk E-mail" all resolve), covers the hierarchy leaf under any of the common
|
||||
IMAP delimiters (``/ \\ .``), and recognises provider prefixes like
|
||||
``[Gmail]/`` implicitly via the leaf. Names are curated per language rather than
|
||||
substring-matched, so a folder like "Sent to John" is *not* misfiled as Sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
|
||||
# Latin letters NFKD doesn't decompose to ASCII; fold them explicitly.
|
||||
_LATIN_SPECIAL = str.maketrans(
|
||||
{"ł": "l", "ø": "o", "đ": "d", "ð": "d", "ß": "ss", "æ": "ae", "œ": "oe", "ı": "i", "þ": "th"}
|
||||
)
|
||||
|
||||
|
||||
def _compact(name: str) -> str:
|
||||
"""Fold to a comparable key: lowercase, strip accents/diacritics, drop every
|
||||
non-alphanumeric char. "Éléments envoyés" -> "elementsenvoyes"."""
|
||||
folded = unicodedata.normalize("NFKD", name.strip().lower()).translate(_LATIN_SPECIAL)
|
||||
return "".join(ch for ch in folded if ch.isalnum())
|
||||
|
||||
|
||||
# Localized folder names by special_use. Written readably; compacted at load.
|
||||
_NAMES: dict[str, list[str]] = {
|
||||
"sent": [
|
||||
"sent", "sent items", "sent mail", "sent messages", # English / Outlook / Gmail
|
||||
"wysłane", "elementy wysłane", "wiadomości wysłane", # Polish
|
||||
"gesendet", "gesendete objekte", "gesendete elemente", # German
|
||||
"envoyés", "éléments envoyés", "messages envoyés", "courrier envoyé", # French
|
||||
"enviados", "elementos enviados", "correo enviado", # Spanish
|
||||
"inviata", "posta inviata", "messaggi inviati", # Italian
|
||||
"verzonden", "verzonden items", "verzonden berichten", # Dutch
|
||||
"enviadas", "itens enviados", "mensagens enviadas", # Portuguese
|
||||
"отправленные", "исходящие", # Russian
|
||||
"skickat", "skickade", "skickade objekt", # Swedish
|
||||
"sendt", "sendte", "sendte elementer", # Norwegian / Danish
|
||||
"lähetetyt", # Finnish
|
||||
"odeslané", "odeslaná pošta", # Czech
|
||||
],
|
||||
"drafts": [
|
||||
"drafts", "draft",
|
||||
"robocze", "kopie robocze", "wersje robocze",
|
||||
"entwürfe", "entwurf",
|
||||
"brouillons",
|
||||
"borradores",
|
||||
"bozze",
|
||||
"concepten",
|
||||
"rascunhos",
|
||||
"черновики",
|
||||
"utkast",
|
||||
"kladder", # Danish
|
||||
"luonnokset", # Finnish
|
||||
"koncepty", # Czech
|
||||
],
|
||||
"trash": [
|
||||
"trash", "deleted", "deleted items", "deleted messages", "bin", "recycle bin",
|
||||
"kosz",
|
||||
"papierkorb", "gelöschte objekte", "gelöschte elemente",
|
||||
"corbeille", "éléments supprimés",
|
||||
"papelera", "elementos eliminados",
|
||||
"cestino", "posta eliminata",
|
||||
"prullenbak", "verwijderde items",
|
||||
"lixeira", "lixo", "itens excluídos",
|
||||
"корзина", "удаленные",
|
||||
"papperskorg", "borttaget",
|
||||
"papirkurv", "slettet",
|
||||
"roskakori", "poistetut",
|
||||
"koš", # Czech
|
||||
],
|
||||
"junk": [
|
||||
"junk", "junk email", "junk e-mail", "spam", "bulk mail", "bulk",
|
||||
"wiadomości-śmieci", "niechciane",
|
||||
"unerwünscht", "werbung",
|
||||
"courrier indésirable", "indésirables", "pourriel",
|
||||
"correo no deseado", "no deseado", "correo basura",
|
||||
"posta indesiderata", "indesiderata",
|
||||
"ongewenst", "ongewenste e-mail",
|
||||
"lixo eletrônico",
|
||||
"спам", "нежелательная почта",
|
||||
"skräp", "skräppost",
|
||||
"uønsket",
|
||||
"roskaposti",
|
||||
"nevyžádaná pošta",
|
||||
],
|
||||
"archive": [
|
||||
"archive", "archives", "all mail",
|
||||
"archiwum",
|
||||
"archiv",
|
||||
"archivio",
|
||||
"archivo",
|
||||
"archief",
|
||||
"arquivo", "arquivo morto",
|
||||
"архив", "вся почта",
|
||||
"arkiv",
|
||||
"arkisto",
|
||||
],
|
||||
}
|
||||
|
||||
_NAME_MAP: dict[str, str] = {
|
||||
# sent
|
||||
"sent": "sent",
|
||||
"sent items": "sent",
|
||||
"sent mail": "sent",
|
||||
"wysłane": "sent",
|
||||
"wyslane": "sent",
|
||||
"elementy wysłane": "sent",
|
||||
"gesendet": "sent",
|
||||
# drafts
|
||||
"drafts": "drafts",
|
||||
"draft": "drafts",
|
||||
"robocze": "drafts",
|
||||
"kopie robocze": "drafts",
|
||||
"entwürfe": "drafts",
|
||||
# trash
|
||||
"trash": "trash",
|
||||
"deleted": "trash",
|
||||
"deleted items": "trash",
|
||||
"kosz": "trash",
|
||||
"papierkorb": "trash",
|
||||
# junk
|
||||
"junk": "junk",
|
||||
"spam": "junk",
|
||||
"bulk mail": "junk",
|
||||
"wiadomości-śmieci": "junk",
|
||||
# archive
|
||||
"archive": "archive",
|
||||
"archiwum": "archive",
|
||||
"all mail": "archive",
|
||||
"archiv": "archive",
|
||||
_compact(name): special for special, names in _NAMES.items() for name in names
|
||||
}
|
||||
|
||||
|
||||
def infer_special_use(path: str) -> str | None:
|
||||
"""Infer a special_use value from a mailbox path/name (case-insensitive)."""
|
||||
if path.upper() == "INBOX":
|
||||
"""Infer a special_use value from a mailbox path/name (accent-insensitive)."""
|
||||
if path.strip().upper().rstrip("/.") == "INBOX":
|
||||
return "inbox"
|
||||
leaf = path.replace("\\", "/").split("/")[-1].strip().lower()
|
||||
return _NAME_MAP.get(leaf) or _NAME_MAP.get(path.strip().lower())
|
||||
# Take the hierarchy leaf under any common IMAP delimiter (/ \ or . for
|
||||
# Dovecot/Courier namespaces), then fold for lookup.
|
||||
normalized = path
|
||||
for delim in ("/", "\\", "."):
|
||||
normalized = normalized.replace(delim, "\n")
|
||||
segments = [s for s in normalized.split("\n") if s.strip()]
|
||||
tail = segments[-1] if segments else path
|
||||
return _NAME_MAP.get(_compact(tail)) or _NAME_MAP.get(_compact(path))
|
||||
|
||||
Reference in New Issue
Block a user