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
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
"""MIME parsing (SPEC §6.4)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from email.message import EmailMessage
|
|
|
|
from app.indexer.mime import parse_message
|
|
|
|
|
|
def _build(
|
|
*,
|
|
subject: str = "Hello",
|
|
from_addr: str = "Alice <alice@example.com>",
|
|
to_addr: str = "bob@example.com",
|
|
message_id: str = "<m1@example.com>",
|
|
references: str | None = None,
|
|
in_reply_to: str | None = None,
|
|
text: str | None = "plain body",
|
|
html: str | None = None,
|
|
) -> bytes:
|
|
msg = EmailMessage()
|
|
msg["Subject"] = subject
|
|
msg["From"] = from_addr
|
|
msg["To"] = to_addr
|
|
msg["Message-ID"] = message_id
|
|
msg["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
|
if references:
|
|
msg["References"] = references
|
|
if in_reply_to:
|
|
msg["In-Reply-To"] = in_reply_to
|
|
if text is not None:
|
|
msg.set_content(text)
|
|
if html is not None:
|
|
if text is not None:
|
|
msg.add_alternative(html, subtype="html")
|
|
else:
|
|
msg.set_content(html, subtype="html")
|
|
return msg.as_bytes()
|
|
|
|
|
|
def test_parse_basic_headers_and_body() -> None:
|
|
parsed = parse_message(_build())
|
|
assert parsed.subject == "Hello"
|
|
assert parsed.message_id == "m1@example.com"
|
|
assert parsed.from_addr == [{"name": "Alice", "email": "alice@example.com"}]
|
|
assert parsed.to_addr == [{"name": "", "email": "bob@example.com"}]
|
|
assert "plain body" in parsed.text_body
|
|
assert parsed.date is not None
|
|
assert parsed.snippet == "plain body"
|
|
|
|
|
|
def test_references_include_in_reply_to_in_order() -> None:
|
|
parsed = parse_message(_build(references="<a@x.com> <b@x.com>", in_reply_to="<b@x.com>"))
|
|
# References first, then In-Reply-To, deduped
|
|
assert parsed.references == ["a@x.com", "b@x.com"]
|
|
assert parsed.in_reply_to == "b@x.com"
|
|
|
|
|
|
def test_html_only_derives_text() -> None:
|
|
parsed = parse_message(_build(text=None, html="<p>Hello <b>world</b></p>"))
|
|
assert parsed.html_body is not None
|
|
assert "Hello" in parsed.text_body
|
|
assert "world" in parsed.text_body
|
|
assert "<b>" not in parsed.text_body # tags stripped for indexing text
|
|
|
|
|
|
def test_multipart_alternative_prefers_html_for_display() -> None:
|
|
parsed = parse_message(_build(text="text version", html="<p>html version</p>"))
|
|
assert parsed.html_body is not None
|
|
assert "html version" in parsed.html_body
|
|
assert "text version" in parsed.text_body
|
|
|
|
|
|
def test_rfc2047_encoded_subject_is_decoded() -> None:
|
|
# "Próba" base64-encoded UTF-8
|
|
parsed = parse_message(_build(subject="=?UTF-8?B?UHLDs2Jh?="))
|
|
assert parsed.subject == "Próba"
|
|
|
|
|
|
def test_attachment_extracted() -> None:
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "with attachment"
|
|
msg["From"] = "a@b.com"
|
|
msg["To"] = "c@d.com"
|
|
msg["Message-ID"] = "<att@example.com>"
|
|
msg.set_content("see attached")
|
|
msg.add_attachment(
|
|
b"\x00\x01\x02data", maintype="application", subtype="octet-stream", filename="file.bin"
|
|
)
|
|
parsed = parse_message(msg.as_bytes())
|
|
assert parsed.has_attachments
|
|
assert parsed.attachments[0].filename == "file.bin"
|
|
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"
|
|
msg["From"] = "a@b.com"
|
|
msg["To"] = "c@d.com"
|
|
msg.set_content("body")
|
|
parsed = parse_message(msg.as_bytes())
|
|
assert parsed.message_id is None
|