2026-07-17 23:20:34 +02:00
|
|
|
"""Indexer orchestration (SPEC §6.4).
|
|
|
|
|
|
|
|
|
|
Turns a :class:`RemoteMessage` into persisted rows: raw blob (deduped), parsed
|
|
|
|
|
MIME, sanitized HTML, ``messages``/``body_parts``/``attachments`` rows, JWZ
|
|
|
|
|
threading, and a ``known_recipients`` upsert. Returns a :class:`SearchDoc` for
|
|
|
|
|
the caller to index *after commit* (FR-30 inline indexing) and to drive the
|
|
|
|
|
``message.new`` event. Idempotent on ``(mailbox_id, uid)``.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-07-19 07:13:03 +02:00
|
|
|
from app.concurrency import loop_semaphore
|
|
|
|
|
from app.config import get_settings
|
2026-10-12 19:25:10 +02:00
|
|
|
from app.indexer import blobstore, rawstore
|
2026-07-19 07:13:03 +02:00
|
|
|
from app.indexer.mime import ParsedEmail, parse_message, parse_message_file
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.indexer.sanitize import sanitize_html
|
2026-10-12 19:25:10 +02:00
|
|
|
from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.providers.base import RemoteMessage
|
|
|
|
|
from app.search.base import SearchDoc
|
|
|
|
|
from app.threading_.normalize import synthesize_message_id
|
|
|
|
|
from app.threading_.threader import thread_message
|
|
|
|
|
|
|
|
|
|
_NO_RECIPIENT_HARVEST = {"junk", "sent", "drafts", "trash"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class PersistResult:
|
|
|
|
|
message: Message
|
|
|
|
|
search_doc: SearchDoc
|
|
|
|
|
has_remote_images: bool
|
2026-08-05 18:04:49 +02:00
|
|
|
gmail_labels: tuple[str, ...] = ()
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _epoch(value: datetime) -> int:
|
|
|
|
|
return int(value.timestamp())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_search_doc(message: Message, parsed: ParsedEmail, special_use: str | None) -> SearchDoc:
|
|
|
|
|
return SearchDoc(
|
|
|
|
|
message_id=message.id,
|
|
|
|
|
thread_id=message.thread_id or message.id,
|
|
|
|
|
user_id=message.user_id,
|
|
|
|
|
account_id=message.account_id,
|
|
|
|
|
mailbox_special_use=special_use,
|
|
|
|
|
from_=parsed.from_addr,
|
|
|
|
|
to=parsed.to_addr,
|
|
|
|
|
cc=parsed.cc_addr,
|
|
|
|
|
subject=parsed.subject,
|
|
|
|
|
body_text=parsed.text_body,
|
2026-07-26 09:28:48 +02:00
|
|
|
attachment_names=[a.filename for a in parsed.attachments if a.content_id is None],
|
2026-07-17 23:20:34 +02:00
|
|
|
has_attachments=parsed.has_attachments,
|
|
|
|
|
is_unread="\\Seen" not in message.flags,
|
|
|
|
|
is_starred="\\Flagged" in message.flags,
|
|
|
|
|
date=_epoch(message.internal_date),
|
|
|
|
|
size_bytes=message.size_bytes,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _harvest_recipient(session: AsyncSession, message: Message, parsed: ParsedEmail) -> None:
|
|
|
|
|
"""Upsert the From address into known_recipients (weight 1) (SPEC §6.4)."""
|
|
|
|
|
if not parsed.from_addr:
|
|
|
|
|
return
|
|
|
|
|
sender = parsed.from_addr[0]
|
|
|
|
|
email = (sender.get("email") or "").lower()
|
|
|
|
|
if not email:
|
|
|
|
|
return
|
|
|
|
|
stmt = pg_insert(KnownRecipient).values(
|
|
|
|
|
user_id=message.user_id,
|
|
|
|
|
email=email,
|
|
|
|
|
name=sender.get("name", ""),
|
|
|
|
|
times_used=1,
|
|
|
|
|
last_used_at=message.internal_date,
|
|
|
|
|
)
|
|
|
|
|
stmt = stmt.on_conflict_do_update(
|
|
|
|
|
index_elements=[KnownRecipient.user_id, KnownRecipient.email],
|
|
|
|
|
set_={
|
|
|
|
|
"times_used": KnownRecipient.times_used + 1,
|
|
|
|
|
"last_used_at": stmt.excluded.last_used_at,
|
|
|
|
|
"name": stmt.excluded.name,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
await session.execute(stmt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _resolve_message_id(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
*,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
account_id: uuid.UUID,
|
|
|
|
|
parsed_id: str | None,
|
|
|
|
|
raw: bytes,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Resolve the Message-ID to store, applying SPEC §7.1 duplicate handling.
|
|
|
|
|
|
|
|
|
|
Missing id -> synthesize. A *duplicate* id already seen in a **different
|
|
|
|
|
account** is a separate delivery (e.g. a bulk sender that reuses one
|
|
|
|
|
Message-ID for every recipient) -> synthesize a non-authoritative id so the
|
|
|
|
|
two do not merge. A same-account duplicate (e.g. Gmail INBOX + All Mail
|
|
|
|
|
holding one message) keeps the real id so the copies stay in one thread.
|
|
|
|
|
"""
|
|
|
|
|
if parsed_id is None:
|
|
|
|
|
return synthesize_message_id(raw)
|
|
|
|
|
other_account = await session.scalar(
|
|
|
|
|
select(Message.account_id)
|
|
|
|
|
.where(Message.user_id == user_id, Message.message_id_hdr == parsed_id)
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
if other_account is not None and other_account != account_id:
|
|
|
|
|
return synthesize_message_id(raw)
|
|
|
|
|
return parsed_id
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 19:25:10 +02:00
|
|
|
async def _keeps_raw(session: AsyncSession, account_id: uuid.UUID) -> bool:
|
|
|
|
|
"""Whether the raw message is kept for good: not for a hosted mailbox, whose
|
|
|
|
|
mail server holds it (it is only cached, App. D §D.6 item 2)."""
|
|
|
|
|
kind = await session.scalar(select(Account.kind).where(Account.id == account_id))
|
|
|
|
|
return kind != "native"
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 07:13:03 +02:00
|
|
|
async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail:
|
|
|
|
|
"""Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7).
|
|
|
|
|
|
|
|
|
|
Peak RAM stays ~PARSE_CONCURRENCY x STREAM_PARSE_MB: only a few parses run at
|
|
|
|
|
once, and a message above the threshold is parsed from its (already written)
|
|
|
|
|
blob file instead of being held as an in-memory tree beside its raw bytes.
|
|
|
|
|
"""
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
threshold = settings.stream_parse_mb * 1024 * 1024
|
|
|
|
|
async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency):
|
2026-10-12 19:25:10 +02:00
|
|
|
path = rawstore.locate(user_id, sha) if len(raw) > threshold else None
|
|
|
|
|
if path is not None:
|
|
|
|
|
return parse_message_file(path)
|
2026-07-19 07:13:03 +02:00
|
|
|
return parse_message(raw)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
async def persist_message(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
*,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
account_id: uuid.UUID,
|
|
|
|
|
mailbox_id: uuid.UUID,
|
|
|
|
|
mailbox_special_use: str | None,
|
|
|
|
|
remote: RemoteMessage,
|
2026-08-05 18:04:49 +02:00
|
|
|
dedupe_account_scope: bool = False,
|
2026-07-17 23:20:34 +02:00
|
|
|
) -> PersistResult | None:
|
2026-08-05 18:04:49 +02:00
|
|
|
"""Persist one message. Returns None if it already exists (idempotent).
|
|
|
|
|
|
|
|
|
|
``dedupe_account_scope`` skips a message already stored under a *different*
|
|
|
|
|
mailbox of the same account (same Message-ID) — used for Gmail's All Mail pass so
|
|
|
|
|
a message already synced from INBOX/Sent isn't duplicated (SPEC: Gmail sync)."""
|
2026-07-17 23:20:34 +02:00
|
|
|
existing = await session.scalar(
|
|
|
|
|
select(Message.id).where(Message.mailbox_id == mailbox_id, Message.uid == remote.uid)
|
|
|
|
|
)
|
|
|
|
|
if existing is not None:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-10-12 19:25:10 +02:00
|
|
|
if await _keeps_raw(session, account_id):
|
|
|
|
|
sha = blobstore.store(user_id, remote.raw)
|
|
|
|
|
else:
|
|
|
|
|
sha = rawstore.put_cached(user_id, remote.raw)
|
2026-07-19 07:13:03 +02:00
|
|
|
parsed = await _parse_bounded(user_id, sha, remote.raw)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
message_id_hdr = await _resolve_message_id(
|
|
|
|
|
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
|
|
|
|
|
)
|
2026-08-05 18:04:49 +02:00
|
|
|
if dedupe_account_scope and message_id_hdr is not None:
|
|
|
|
|
dup = await session.scalar(
|
|
|
|
|
select(Message.id)
|
|
|
|
|
.where(
|
|
|
|
|
Message.account_id == account_id,
|
|
|
|
|
Message.message_id_hdr == message_id_hdr,
|
|
|
|
|
Message.mailbox_id != mailbox_id,
|
|
|
|
|
)
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
if dup is not None:
|
|
|
|
|
return None
|
2026-07-17 23:20:34 +02:00
|
|
|
message = Message(
|
|
|
|
|
id=uuid.uuid4(),
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
account_id=account_id,
|
|
|
|
|
mailbox_id=mailbox_id,
|
|
|
|
|
uid=remote.uid,
|
|
|
|
|
message_id_hdr=message_id_hdr,
|
2026-07-22 21:49:48 +02:00
|
|
|
# Keep the real delivered Message-ID (may differ from the stored, possibly
|
|
|
|
|
# synthesized, message_id_hdr) so cross-account copies collapse in threads.
|
|
|
|
|
delivery_id=parsed.message_id,
|
2026-07-17 23:20:34 +02:00
|
|
|
in_reply_to=parsed.in_reply_to,
|
|
|
|
|
references_hdr=parsed.references,
|
|
|
|
|
subject=parsed.subject,
|
|
|
|
|
from_addr=parsed.from_addr,
|
|
|
|
|
to_addr=parsed.to_addr,
|
|
|
|
|
cc_addr=parsed.cc_addr,
|
2026-07-18 15:27:09 +02:00
|
|
|
bcc_addr=parsed.bcc_addr,
|
2026-07-17 23:20:34 +02:00
|
|
|
date_hdr=parsed.date,
|
|
|
|
|
internal_date=remote.internal_date or datetime.now(UTC),
|
|
|
|
|
flags=sorted(remote.flags),
|
|
|
|
|
size_bytes=len(remote.raw),
|
|
|
|
|
has_attachments=parsed.has_attachments,
|
|
|
|
|
snippet=parsed.snippet,
|
|
|
|
|
blob_sha256=sha,
|
|
|
|
|
)
|
|
|
|
|
session.add(message)
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
|
|
|
|
# Body parts: plain text (for indexing) + sanitized HTML (for display).
|
|
|
|
|
has_remote_images = False
|
|
|
|
|
if parsed.text_body:
|
|
|
|
|
session.add(BodyPart(message_id=message.id, kind="text", content=parsed.text_body))
|
|
|
|
|
if parsed.html_body is not None:
|
|
|
|
|
result = sanitize_html(parsed.html_body, message.id)
|
|
|
|
|
has_remote_images = result.has_remote_images
|
|
|
|
|
session.add(
|
|
|
|
|
BodyPart(
|
|
|
|
|
message_id=message.id,
|
|
|
|
|
kind="html_sanitized",
|
|
|
|
|
content=result.html,
|
|
|
|
|
sanitizer_v=result.sanitizer_v,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-08 20:20:21 +02:00
|
|
|
# Attachments are not stored apart: they stay inside the raw blob, and are
|
|
|
|
|
# taken out of it when wanted (app/indexer/attachments.py).
|
2026-07-17 23:20:34 +02:00
|
|
|
for att in parsed.attachments:
|
|
|
|
|
session.add(
|
|
|
|
|
Attachment(
|
|
|
|
|
message_id=message.id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
filename=att.filename,
|
|
|
|
|
mime_type=att.mime_type,
|
|
|
|
|
size_bytes=len(att.payload),
|
|
|
|
|
content_id=att.content_id,
|
2026-10-08 20:20:21 +02:00
|
|
|
blob_sha256=blobstore.sha256_hex(att.payload),
|
|
|
|
|
part_index=att.part_index,
|
2026-07-17 23:20:34 +02:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
message.has_remote_images = has_remote_images
|
2026-07-18 15:27:09 +02:00
|
|
|
# Drafts are not conversation messages: keep them out of threads so they don't
|
|
|
|
|
# pollute the reading pane or thread list. They surface only via the drafts UI.
|
|
|
|
|
if mailbox_special_use != "drafts":
|
|
|
|
|
await thread_message(session, message)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
if mailbox_special_use not in _NO_RECIPIENT_HARVEST:
|
|
|
|
|
await _harvest_recipient(session, message, parsed)
|
|
|
|
|
|
|
|
|
|
await session.flush()
|
|
|
|
|
doc = _build_search_doc(message, parsed, mailbox_special_use)
|
2026-08-05 18:04:49 +02:00
|
|
|
return PersistResult(
|
|
|
|
|
message=message,
|
|
|
|
|
search_doc=doc,
|
|
|
|
|
has_remote_images=has_remote_images,
|
|
|
|
|
gmail_labels=remote.gmail_labels,
|
|
|
|
|
)
|