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