Files
webmail-client/backend/tests/indexer/test_mime.py
T
lukasandClaude Opus 4.8 4c200ab94a Gmail label sync + no-dup backfill, live label/UI updates, account-delete cleanup, Sync-SPAM toggle, sidebar logo
Gmail sync (SPEC: Gmail sync):
- Provider fetches X-GM-LABELS when X-GM-EXT-1 is advertised; parses custom labels
  (system labels dropped, mUTF-7 decoded) onto RemoteMessage.
- Engine syncs only system folders + All Mail for Gmail, skips every label folder,
  and dedupes the All Mail pass by Message-ID — kills the INBOX/All-Mail duplication.
- Custom labels ride the message.new event; the labels plugin subscribes and maps
  them to chips as "<name> [Gmail]" (per-message + thread aggregate + known set),
  so labels fill in gradually with the sync window. Non-ASCII labels decode right.

Labels self-healing (message-driven reconcile):
- Per-message keys are the source of truth; t: aggregates and _labels are derived.
  Reconcile drops labels of deleted messages, regroups survivors by their current
  thread (fixing JWZ thread merges), rewrites changed t: keys, and rebuilds _labels
  from live messages — keeping user labels, dropping orphaned Gmail ones.
- New SDK reads: sdk.threads.existing / sdk.messages.threads_of. Sidebar refreshes
  live via a plugin.labels.changed WS event on new labels.

Account deletion: prune threads left empty after the cascade (they were user-scoped,
so only messages cascaded) + de-list the account from surviving cross-account threads.

Sync SPAM: opt-in per-account sync_spam (migration 0015, default off) — the engine
skips the Junk folder in backfill and poll unless enabled; wizard checkbox added.

Robustness / UX:
- WebSocket auto-reconnects with backoff and resyncs on (re)connect, and account
  add/delete invalidates threads/labels — no more hard refresh for live updates.
- Backfill banner + sidebar ring also require done < total, so a completed account
  can't leave a stuck "X of X".
- Scrub lone UTF-16 surrogates from parsed headers/bodies so bad messages no longer
  crash the backfill on Postgres text/JSONB.
- Ultimo signet logo in the Mail/Calendar/Contacts sidebar header (and at the top of
  the collapsed rail), via the shared plugin-ui.css + MailLayout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
2026-08-05 18:04:49 +02:00

174 lines
6.3 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_undecodable_header_bytes_scrubbed_to_valid_utf8() -> None:
# A raw 8-bit byte the parser can't decode surfaces as a lone surrogate; it must
# be scrubbed so the value is valid UTF-8 for Postgres text/JSONB storage.
raw = _build(subject="Fakt\udce9ra", from_addr="B\udce9ta <b@example.com>")
parsed = parse_message(raw)
parsed.subject.encode("utf-8") # would raise on a lone surrogate
parsed.from_addr[0]["name"].encode("utf-8")
assert "\udce9" not in parsed.subject
assert "�" in parsed.subject
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_attachment_with_content_id_is_a_real_attachment() -> None:
# iMIP invites (e.g. Fastmail) send invite.ics as Content-Disposition: attachment
# yet also give it a Content-ID. It must show as a downloadable chip, not be
# hidden as an inline resource (regression: "no attachments" shown).
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
root = MIMEMultipart("mixed")
root["Subject"] = "invite"
root["From"] = "org@example.com"
root["To"] = "me@example.com"
root["Message-ID"] = "<inv@x.com>"
root.attach(MIMEText("body", "plain"))
ics = MIMEText("BEGIN:VCALENDAR\nEND:VCALENDAR", "calendar")
ics.add_header("Content-Disposition", "attachment", filename="invite.ics")
ics.add_header("Content-ID", "<cal123@x>")
root.attach(ics)
parsed = parse_message(root.as_bytes())
assert len(parsed.attachments) == 1
att = parsed.attachments[0]
assert att.filename == "invite.ics"
assert att.content_id is None # not treated as inline despite the Content-ID
assert parsed.has_attachments is True
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