M2: Meili search + saved searches, sync windows + backfill progress, threading & image fixes
Search: - Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7 - Search-as-you-type: debounced query + Postgres prefix matching, built from the DB tokenizer so full email addresses match (email stays one lexeme) - Saved searches API + sidebar virtual mailboxes; save affordance in the box Account sync windows (SPEC §12 M2): - Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only - Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run) - Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid - migration 0012: accounts.sync_window + backfill_total/backfill_done Backfill progress UX: - Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that fills with the account colour; driven by sync.progress over WS Thread list pagination: - Server-side offset + accurate grand total, with filter/order in SQL; working prev/next across all synced mail Reading panel: - "Load images" one-off now actually renders images (?images=1) without trusting the sender; envelope carries images_allowed so a trusted sender shows no prompt Threading: - Subject fallback no longer merges messages that declare an explicit (different) parent; true siblings still group via a shared-parent reference match. Fixes unrelated auto-replies with the same subject landing in one thread Backend 257 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:
@@ -3,9 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# How much history to import at account setup (SPEC §12 M2).
|
||||
SyncWindow = Literal["all", "3months", "new"]
|
||||
|
||||
|
||||
class ServerConfigIn(BaseModel):
|
||||
host: str
|
||||
@@ -56,12 +60,14 @@ class CreateAccountRequest(BaseModel):
|
||||
smtp: ServerConfigIn
|
||||
username: str | None = None
|
||||
password: str = Field(min_length=1)
|
||||
sync_window: SyncWindow = "all"
|
||||
|
||||
|
||||
class UpdateAccountRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
color: str | None = None
|
||||
signature: str | None = None
|
||||
sync_window: SyncWindow | None = None
|
||||
# Connection/credential changes require the password (re-validated + re-encrypted).
|
||||
imap: ServerConfigIn | None = None
|
||||
smtp: ServerConfigIn | None = None
|
||||
@@ -76,5 +82,8 @@ class AccountDetailOut(BaseModel):
|
||||
state: str
|
||||
state_detail: str
|
||||
signature: str
|
||||
sync_window: str
|
||||
backfill_total: int
|
||||
backfill_done: int
|
||||
imap_config: dict[str, object]
|
||||
smtp_config: dict[str, object]
|
||||
|
||||
@@ -39,6 +39,7 @@ async def create_account(
|
||||
secret_ct=encrypt_secret(req.password.encode("utf-8")),
|
||||
discovery_src="manual",
|
||||
state="backfilling",
|
||||
sync_window=req.sync_window,
|
||||
)
|
||||
session.add(account)
|
||||
try:
|
||||
|
||||
@@ -17,6 +17,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.recipients import router as recipients_router
|
||||
from app.api.saved_searches import router as saved_searches_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.threads import router as threads_router
|
||||
@@ -34,6 +35,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
recipients_router,
|
||||
settings_router,
|
||||
mailboxes_router,
|
||||
saved_searches_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
@@ -82,6 +82,9 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
||||
color=a.color,
|
||||
state=a.state,
|
||||
signature=a.signature,
|
||||
sync_window=a.sync_window,
|
||||
backfill_total=a.backfill_total,
|
||||
backfill_done=a.backfill_done,
|
||||
)
|
||||
for a in accounts
|
||||
]
|
||||
@@ -186,6 +189,18 @@ async def update_account(
|
||||
if body.signature is not None:
|
||||
account.signature = body.signature
|
||||
|
||||
# Broadening the sync window (e.g. "new" -> "all", "Download all mail") means
|
||||
# importing history not fetched before, so re-run the backfill (SPEC §12 M2).
|
||||
# persist_message is idempotent on (mailbox_id, uid), so already-synced mail
|
||||
# is skipped. Narrowing keeps existing mail — nothing to fetch.
|
||||
_COVERAGE = {"new": 0, "3months": 1, "all": 2}
|
||||
broaden = False
|
||||
if body.sync_window is not None and body.sync_window != account.sync_window:
|
||||
broaden = _COVERAGE[body.sync_window] > _COVERAGE.get(account.sync_window, 0)
|
||||
account.sync_window = body.sync_window
|
||||
if broaden:
|
||||
account.state = "backfilling"
|
||||
|
||||
# Connection settings and/or password: re-validate against the servers, then
|
||||
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
|
||||
# stored secret is only decrypted in the worker, never here).
|
||||
@@ -224,7 +239,7 @@ async def update_account(
|
||||
|
||||
await db.commit()
|
||||
|
||||
if conn_change:
|
||||
if conn_change or broaden:
|
||||
await enqueue_job("sync_bootstrap_account", str(account.id))
|
||||
|
||||
return _detail(account)
|
||||
@@ -248,6 +263,9 @@ def _detail(account: Account) -> AccountDetailOut:
|
||||
state=account.state,
|
||||
state_detail=account.state_detail,
|
||||
signature=account.signature,
|
||||
sync_window=account.sync_window,
|
||||
backfill_total=account.backfill_total,
|
||||
backfill_done=account.backfill_done,
|
||||
imap_config=account.imap_config,
|
||||
smtp_config=account.smtp_config,
|
||||
)
|
||||
|
||||
@@ -65,14 +65,21 @@ def _sender_email(message: Message) -> str:
|
||||
|
||||
|
||||
@router.get("/{message_id}/html", response_class=HTMLResponse)
|
||||
async def message_html(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> HTMLResponse:
|
||||
async def message_html(
|
||||
message_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
images: bool = Query(False),
|
||||
) -> HTMLResponse:
|
||||
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8).
|
||||
|
||||
Re-sanitizes from the raw blob when the sender is image-approved, or when the
|
||||
stored copy predates the current sanitizer version (SPEC §10.4).
|
||||
stored copy predates the current sanitizer version (SPEC §10.4). ``images=1``
|
||||
is a one-time "Load images" — render remote images for this request only,
|
||||
without persisting the sender as trusted.
|
||||
"""
|
||||
message = await _owned_message(db, user.id, message_id)
|
||||
allow_remote = _sender_email(message) in allowed_image_senders(user)
|
||||
allow_remote = images or _sender_email(message) in allowed_image_senders(user)
|
||||
|
||||
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id)))
|
||||
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Saved searches — stored queries shown as virtual mailboxes (SPEC §12 M2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import SavedSearch
|
||||
|
||||
router = APIRouter(prefix="/saved-searches", tags=["search"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class SavedSearchOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
query: str
|
||||
pinned: bool
|
||||
|
||||
|
||||
class SavedSearchIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
query: str = Field(min_length=1, max_length=500)
|
||||
pinned: bool = True
|
||||
|
||||
|
||||
def _out(s: SavedSearch) -> SavedSearchOut:
|
||||
return SavedSearchOut(id=s.id, name=s.name, query=s.query, pinned=s.pinned)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SavedSearchOut])
|
||||
async def list_saved_searches(user: CurrentUser, db: DbSession) -> list[SavedSearchOut]:
|
||||
rows = await db.scalars(
|
||||
select(SavedSearch).where(SavedSearch.user_id == user.id).order_by(SavedSearch.name)
|
||||
)
|
||||
return [_out(s) for s in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=SavedSearchOut)
|
||||
async def create_saved_search(
|
||||
body: SavedSearchIn, user: CurrentUser, db: DbSession
|
||||
) -> SavedSearchOut:
|
||||
name = body.name.strip()
|
||||
query = body.query.strip()
|
||||
if not name or not query:
|
||||
raise_api_error(400, "invalid", "name and query are required")
|
||||
# Upsert by (user, name): re-saving under an existing name updates its query.
|
||||
existing = await db.scalar(
|
||||
select(SavedSearch).where(SavedSearch.user_id == user.id, SavedSearch.name == name)
|
||||
)
|
||||
if existing is not None:
|
||||
existing.query = query
|
||||
existing.pinned = body.pinned
|
||||
saved = existing
|
||||
else:
|
||||
saved = SavedSearch(user_id=user.id, name=name, query=query, pinned=body.pinned)
|
||||
db.add(saved)
|
||||
await db.commit()
|
||||
return _out(saved)
|
||||
|
||||
|
||||
@router.delete("/{search_id}")
|
||||
async def delete_saved_search(
|
||||
search_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, str]:
|
||||
saved = await db.scalar(
|
||||
select(SavedSearch).where(SavedSearch.id == search_id, SavedSearch.user_id == user.id)
|
||||
)
|
||||
if saved is None:
|
||||
raise_api_error(404, "not_found", "saved search not found")
|
||||
await db.delete(saved)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -29,6 +29,7 @@ class ThreadSummary(BaseModel):
|
||||
class ThreadListOut(BaseModel):
|
||||
threads: list[ThreadSummary]
|
||||
cursor: str | None = None
|
||||
total: int = 0
|
||||
|
||||
|
||||
class AttachmentOut(BaseModel):
|
||||
@@ -54,6 +55,8 @@ class MessageEnvelope(BaseModel):
|
||||
is_starred: bool
|
||||
has_attachments: bool
|
||||
has_remote_images: bool
|
||||
# The sender is on the user's trusted list, so remote images load without a prompt.
|
||||
images_allowed: bool = False
|
||||
size_bytes: int
|
||||
snippet: str
|
||||
# Other accounts this same delivery also landed in (collapsed duplicates).
|
||||
@@ -84,6 +87,9 @@ class AccountOut(BaseModel):
|
||||
color: str
|
||||
state: str
|
||||
signature: str = ""
|
||||
sync_window: str = "all"
|
||||
backfill_total: int = 0
|
||||
backfill_done: int = 0
|
||||
|
||||
|
||||
class SearchThreadHit(BaseModel):
|
||||
|
||||
+34
-21
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Collection
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import Text, func, literal, or_, select, tuple_
|
||||
from sqlalchemy import Text, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app.api.cursors import decode_cursor, encode_cursor
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import (
|
||||
Address,
|
||||
@@ -18,6 +19,7 @@ from app.api.schemas import (
|
||||
ThreadListOut,
|
||||
ThreadSummary,
|
||||
)
|
||||
from app.api.settings import allowed_image_senders
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Mailbox, Message, Thread, User
|
||||
@@ -48,7 +50,8 @@ def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
|
||||
)
|
||||
|
||||
|
||||
def _envelope(msg: Message) -> MessageEnvelope:
|
||||
def _envelope(msg: Message, allowed_senders: Collection[str] = ()) -> MessageEnvelope:
|
||||
sender = msg.from_addr[0].get("email", "").lower() if msg.from_addr else ""
|
||||
return MessageEnvelope(
|
||||
id=msg.id,
|
||||
account_id=msg.account_id,
|
||||
@@ -65,6 +68,7 @@ def _envelope(msg: Message) -> MessageEnvelope:
|
||||
is_starred="\\Flagged" in msg.flags,
|
||||
has_attachments=msg.has_attachments,
|
||||
has_remote_images=msg.has_remote_images,
|
||||
images_allowed=bool(sender) and sender in allowed_senders,
|
||||
size_bytes=msg.size_bytes,
|
||||
snippet=msg.snippet,
|
||||
)
|
||||
@@ -75,10 +79,14 @@ async def list_threads(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
view: str = Query("unified"),
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
filter: str = Query("all"), # all | unread | pinned
|
||||
order: str = Query("desc"), # desc (newest first) | asc (oldest first)
|
||||
) -> ThreadListOut:
|
||||
stmt = select(Thread).where(Thread.user_id == user.id)
|
||||
# Collect WHERE conditions once so the page query and the total-count query
|
||||
# stay in lockstep (offset pagination needs an accurate grand total).
|
||||
conditions: list[ColumnElement[bool]] = [Thread.user_id == user.id]
|
||||
|
||||
if view.startswith("account:"):
|
||||
# A single account's inbox: threads with a message in THIS account's
|
||||
@@ -97,7 +105,7 @@ async def list_threads(
|
||||
Mailbox.special_use == "inbox",
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_account_inbox))
|
||||
conditions.append(Thread.id.in_(in_account_inbox))
|
||||
elif view.startswith("folder:"):
|
||||
# Threads with at least one message in a mailbox of this special_use,
|
||||
# across all of the user's accounts (unified per-folder view).
|
||||
@@ -107,24 +115,28 @@ async def list_threads(
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(Message.user_id == user.id, Mailbox.special_use == special)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_folder))
|
||||
conditions.append(Thread.id.in_(in_folder))
|
||||
elif view != "unified":
|
||||
raise_api_error(400, "bad_view", "unsupported view")
|
||||
|
||||
decoded = decode_cursor(cursor)
|
||||
if decoded is not None:
|
||||
cur_date, cur_id = decoded
|
||||
stmt = stmt.where(
|
||||
tuple_(Thread.last_message_at, Thread.id) < tuple_(literal(cur_date), literal(cur_id))
|
||||
)
|
||||
stmt = stmt.order_by(Thread.last_message_at.desc(), Thread.id.desc()).limit(limit + 1)
|
||||
if filter == "unread":
|
||||
conditions.append(Thread.unread_count > 0)
|
||||
elif filter == "pinned":
|
||||
conditions.append(Thread.pinned.is_(True))
|
||||
|
||||
threads = list(await db.scalars(stmt))
|
||||
has_more = len(threads) > limit
|
||||
threads = threads[:limit]
|
||||
next_cursor = (
|
||||
encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None
|
||||
total = (
|
||||
await db.scalar(
|
||||
select(func.count()).select_from(select(Thread.id).where(*conditions).subquery())
|
||||
)
|
||||
) or 0
|
||||
|
||||
order_by = (
|
||||
(Thread.last_message_at.asc(), Thread.id.asc())
|
||||
if order == "asc"
|
||||
else (Thread.last_message_at.desc(), Thread.id.desc())
|
||||
)
|
||||
stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit)
|
||||
threads = list(await db.scalars(stmt))
|
||||
|
||||
# Per-thread visible message count matching what get_thread shows for this view:
|
||||
# Trash/Spam count only their own messages; every other view excludes them. This
|
||||
@@ -152,7 +164,7 @@ async def list_threads(
|
||||
}
|
||||
|
||||
return ThreadListOut(
|
||||
threads=[_summary(t, counts.get(t.id)) for t in threads], cursor=next_cursor
|
||||
threads=[_summary(t, counts.get(t.id)) for t in threads], total=total
|
||||
)
|
||||
|
||||
|
||||
@@ -216,9 +228,10 @@ async def get_thread(
|
||||
elif m.account_id != rep.account_id and m.account_id not in also[key]:
|
||||
also[key].append(m.account_id)
|
||||
|
||||
allowed_senders = allowed_image_senders(user)
|
||||
envelopes = []
|
||||
for m in ordered:
|
||||
env = _envelope(m)
|
||||
env = _envelope(m, allowed_senders)
|
||||
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
|
||||
envelopes.append(env)
|
||||
|
||||
|
||||
@@ -82,6 +82,11 @@ class Account(Base):
|
||||
discovery_src: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'"))
|
||||
state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
# How much history to import (SPEC §12 M2): all | 3months | new. Drives the
|
||||
# initial backfill; backfill_total/backfill_done feed the progress UX.
|
||||
sync_window: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'all'"))
|
||||
backfill_total: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
backfill_done: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
last_sync_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
created_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@@ -71,9 +71,25 @@ class MailProvider(Protocol):
|
||||
async def list_mailboxes(self) -> list[RemoteMailbox]: ...
|
||||
|
||||
def backfill(
|
||||
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
|
||||
self,
|
||||
mailbox: str,
|
||||
*,
|
||||
newest_first: bool = True,
|
||||
batch_size: int = 200,
|
||||
since: date | None = None,
|
||||
before: date | None = None,
|
||||
) -> AsyncIterator[list[RemoteMessage]]:
|
||||
"""Yields batches until the mailbox is exhausted. Headers+body in one pass."""
|
||||
"""Yields batches until the mailbox is exhausted. Headers+body in one pass.
|
||||
``since``/``before`` bound the import by internal date (SPEC §12 M2)."""
|
||||
...
|
||||
|
||||
async def message_count(self, mailbox: str, *, since: date | None = None) -> int:
|
||||
"""Number of messages to import (progress denominator); whole mailbox or
|
||||
the ``since`` window."""
|
||||
...
|
||||
|
||||
async def newest_uid(self, mailbox: str) -> int:
|
||||
"""Current high-water UID; used by the ``new`` window to skip history."""
|
||||
...
|
||||
|
||||
async def poll(
|
||||
|
||||
@@ -13,6 +13,7 @@ import contextlib
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
import aiosmtplib
|
||||
from aioimaplib import aioimaplib
|
||||
@@ -42,6 +43,21 @@ _CONNECT_TIMEOUT = 20
|
||||
_PER_ACCOUNT_CONN_CAP = 4 # SPEC §6.3 / §4: IDLE + transient + short-lived jobs
|
||||
_APPENDUID_RE = re.compile(rb"APPENDUID\s+\d+\s+(\d+)")
|
||||
_COPYUID_RE = re.compile(rb"COPYUID\s+\d+\s+\S+\s+(\d+)")
|
||||
_IMAP_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
|
||||
|
||||
def _imap_date(d: date) -> str:
|
||||
"""RFC 3501 search date, e.g. ``5-Jul-2026`` (locale-independent)."""
|
||||
return f"{d.day}-{_IMAP_MONTHS[d.month - 1]}-{d.year}"
|
||||
|
||||
|
||||
def _date_criteria(since: date | None, before: date | None) -> str:
|
||||
parts: list[str] = []
|
||||
if since is not None:
|
||||
parts.append(f"SINCE {_imap_date(since)}")
|
||||
if before is not None:
|
||||
parts.append(f"BEFORE {_imap_date(before)}")
|
||||
return " ".join(parts) if parts else "ALL"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -184,12 +200,21 @@ class ImapSmtpProvider(MailProvider):
|
||||
return 0
|
||||
|
||||
async def backfill(
|
||||
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
|
||||
self,
|
||||
mailbox: str,
|
||||
*,
|
||||
newest_first: bool = True,
|
||||
batch_size: int = 200,
|
||||
since: date | None = None,
|
||||
before: date | None = None,
|
||||
) -> AsyncIterator[list[RemoteMessage]]:
|
||||
"""Yield message batches. ``since``/``before`` bound the import by IMAP
|
||||
internal date (``SINCE`` inclusive, ``BEFORE`` exclusive) so the caller
|
||||
can sync a recent window and/or the older remainder (SPEC §12 M2)."""
|
||||
info = await self._select(mailbox)
|
||||
self._uidvalidity_seen[mailbox] = info.get("uidvalidity", 0)
|
||||
imap = await self._connect()
|
||||
search = await imap.uid_search("ALL")
|
||||
search = await imap.uid_search(_date_criteria(since, before))
|
||||
uids = parse_search_uids(search.lines)
|
||||
uids.sort(reverse=newest_first)
|
||||
for start in range(0, len(uids), batch_size):
|
||||
@@ -198,6 +223,22 @@ class ImapSmtpProvider(MailProvider):
|
||||
if messages:
|
||||
yield messages
|
||||
|
||||
async def message_count(self, mailbox: str, *, since: date | None = None) -> int:
|
||||
"""Number of messages to import for the progress denominator. Uses the
|
||||
cheap ``EXISTS`` from SELECT for the whole mailbox, or a dated SEARCH."""
|
||||
info = await self._select(mailbox)
|
||||
if since is None:
|
||||
return int(info.get("exists", 0))
|
||||
imap = await self._connect()
|
||||
search = await imap.uid_search(_date_criteria(since, None))
|
||||
return len(parse_search_uids(search.lines))
|
||||
|
||||
async def newest_uid(self, mailbox: str) -> int:
|
||||
"""Current high-water UID (``UIDNEXT - 1``); 0 for an empty mailbox. Used
|
||||
by the ``new`` sync window to skip history and only pull future mail."""
|
||||
info = await self._select(mailbox)
|
||||
return max(0, int(info.get("uidnext", 1)) - 1)
|
||||
|
||||
async def list_uids(self, mailbox: str) -> set[int]:
|
||||
"""Full set of UIDs currently present in the mailbox (expunge reconciliation).
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ _backend: SearchBackend | None = None
|
||||
|
||||
|
||||
def get_search_backend() -> SearchBackend:
|
||||
"""Return the process-wide SearchBackend (``pg`` in M0-M1, ``meili`` in M2)."""
|
||||
"""Return the process-wide SearchBackend (``pg`` or ``meili`` per SEARCH_BACKEND)."""
|
||||
global _backend
|
||||
if _backend is None:
|
||||
which = get_settings().search_backend
|
||||
if which == "meili":
|
||||
# TODO(spec): Meilisearch backend lands in M2.
|
||||
raise NotImplementedError("meili backend is implemented in M2")
|
||||
_backend = PgSearchBackend()
|
||||
from app.search.meili import MeiliSearchBackend
|
||||
|
||||
_backend = MeiliSearchBackend()
|
||||
else:
|
||||
_backend = PgSearchBackend()
|
||||
return _backend
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# INTERFACE VERSION: 1
|
||||
"""Meilisearch SearchBackend (SPEC §5.2, §8; M2).
|
||||
|
||||
Typo-tolerant free-text search with highlighted snippets over a single ``messages``
|
||||
index. Every query is filtered by ``user_id`` (invariant I-7). Structured §8.2
|
||||
operators that map to filterable attributes — ``is`` / ``has`` / ``in`` / date /
|
||||
size — become Meilisearch filters; the field operators (``from`` / ``to`` / ``cc``
|
||||
/ ``subject`` / ``filename``) fold into the query text, since Meilisearch 1.8 has
|
||||
no substring filter (the PG backend remains available for exact operator queries).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime as dt
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from meilisearch_python_sdk import AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
|
||||
|
||||
_INDEX = "messages"
|
||||
|
||||
# Priority order matters: subject/senders rank above the body.
|
||||
_SEARCHABLE = [
|
||||
"subject",
|
||||
"from_names",
|
||||
"from_emails",
|
||||
"to_names",
|
||||
"to_emails",
|
||||
"cc_names",
|
||||
"cc_emails",
|
||||
"body",
|
||||
"attachment_names",
|
||||
]
|
||||
_FILTERABLE = [
|
||||
"user_id",
|
||||
"account_id",
|
||||
"special",
|
||||
"has_attachments",
|
||||
"is_unread",
|
||||
"is_starred",
|
||||
"date",
|
||||
"size_bytes",
|
||||
]
|
||||
_TEXT_FIELD_OPS = {"from", "to", "cc", "subject", "filename"}
|
||||
_RELATIVE_DAYS = {"d": 1, "w": 7, "m": 30, "y": 365}
|
||||
|
||||
|
||||
def _emails(field: list[dict[str, str]]) -> list[str]:
|
||||
return [(a.get("email") or "").lower() for a in field if a.get("email")]
|
||||
|
||||
|
||||
def _names(field: list[dict[str, str]]) -> list[str]:
|
||||
return [a.get("name") or "" for a in field if a.get("name")]
|
||||
|
||||
|
||||
def _to_doc(doc: SearchDoc) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(doc.message_id),
|
||||
"thread_id": str(doc.thread_id),
|
||||
"user_id": str(doc.user_id),
|
||||
"account_id": str(doc.account_id),
|
||||
"special": doc.mailbox_special_use or "",
|
||||
"subject": doc.subject,
|
||||
"body": doc.body_text,
|
||||
"from_names": _names(doc.from_),
|
||||
"from_emails": _emails(doc.from_),
|
||||
"to_names": _names(doc.to),
|
||||
"to_emails": _emails(doc.to),
|
||||
"cc_names": _names(doc.cc),
|
||||
"cc_emails": _emails(doc.cc),
|
||||
"attachment_names": doc.attachment_names,
|
||||
"has_attachments": doc.has_attachments,
|
||||
"is_unread": doc.is_unread,
|
||||
"is_starred": doc.is_starred,
|
||||
"date": doc.date,
|
||||
"size_bytes": doc.size_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _parse_date_unix(value: str) -> int | None:
|
||||
try:
|
||||
d = dt.datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=dt.UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
return int(d.timestamp())
|
||||
|
||||
|
||||
def _parse_relative_days(value: str) -> int | None:
|
||||
m = re.fullmatch(r"(\d+)([dwmy])", value.strip().lower())
|
||||
return int(m.group(1)) * _RELATIVE_DAYS[m.group(2)] if m else None
|
||||
|
||||
|
||||
def _parse_size(value: str) -> int | None:
|
||||
m = re.fullmatch(r"(\d+)([km])", value.strip().lower())
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)) * (1024 if m.group(2) == "k" else 1024 * 1024)
|
||||
|
||||
|
||||
def _op_filter(key: str, value: str) -> str | None:
|
||||
"""A single §8.2 operator -> a Meilisearch filter fragment (or None)."""
|
||||
now = int(dt.datetime.now(dt.UTC).timestamp())
|
||||
if key == "in":
|
||||
return f'special = "{value.lower()}"'
|
||||
if key == "is":
|
||||
v = value.lower()
|
||||
if v == "unread":
|
||||
return "is_unread = true"
|
||||
if v == "read":
|
||||
return "is_unread = false"
|
||||
if v == "starred":
|
||||
return "is_starred = true"
|
||||
return None # 'answered' is not indexed in SearchDoc
|
||||
if key == "has":
|
||||
return "has_attachments = true" if value.lower() == "attachment" else None
|
||||
if key == "before":
|
||||
u = _parse_date_unix(value)
|
||||
return f"date < {u}" if u is not None else None
|
||||
if key == "after":
|
||||
u = _parse_date_unix(value)
|
||||
return f"date >= {u}" if u is not None else None
|
||||
if key == "newer_than":
|
||||
days = _parse_relative_days(value)
|
||||
return f"date >= {now - days * 86400}" if days is not None else None
|
||||
if key == "older_than":
|
||||
days = _parse_relative_days(value)
|
||||
return f"date < {now - days * 86400}" if days is not None else None
|
||||
if key == "larger":
|
||||
n = _parse_size(value)
|
||||
return f"size_bytes > {n}" if n is not None else None
|
||||
if key == "smaller":
|
||||
n = _parse_size(value)
|
||||
return f"size_bytes < {n}" if n is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def _build_filter(groups: list[list[dict[str, Any]]], user_id: UUID) -> str:
|
||||
ands = [f'user_id = "{user_id}"']
|
||||
for group in groups:
|
||||
ors: list[str] = []
|
||||
for clause in group:
|
||||
if clause.get("type") == "text":
|
||||
continue
|
||||
frag = _op_filter(str(clause.get("key")), str(clause.get("value", "")))
|
||||
if frag is None:
|
||||
continue
|
||||
ors.append(f"NOT ({frag})" if clause.get("negate") else frag)
|
||||
if ors:
|
||||
ands.append("(" + " OR ".join(ors) + ")")
|
||||
return " AND ".join(ands)
|
||||
|
||||
|
||||
def _query_text(groups: list[list[dict[str, Any]]]) -> str:
|
||||
"""Free text for Meili's `q`: text terms (negated as -term) + folded field ops."""
|
||||
terms: list[str] = []
|
||||
for group in groups:
|
||||
for clause in group:
|
||||
value = str(clause.get("value", "")).strip()
|
||||
if not value:
|
||||
continue
|
||||
if clause.get("type") == "text":
|
||||
terms.append(f"-{value}" if clause.get("negate") else value)
|
||||
elif clause.get("key") in _TEXT_FIELD_OPS and not clause.get("negate"):
|
||||
terms.append(value)
|
||||
return " ".join(terms).strip()
|
||||
|
||||
|
||||
class MeiliSearchBackend(SearchBackend):
|
||||
def __init__(self) -> None:
|
||||
cfg = get_settings()
|
||||
self._client = AsyncClient(cfg.meili_url, cfg.meili_key or None)
|
||||
self._index = self._client.index(_INDEX)
|
||||
self._ready = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
"""Create the index and apply settings once (idempotent)."""
|
||||
if self._ready:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._ready:
|
||||
return
|
||||
indexes = await self._client.get_indexes()
|
||||
names = {i.uid for i in indexes} if indexes else set()
|
||||
if _INDEX not in names:
|
||||
await self._client.create_index(_INDEX, primary_key="id")
|
||||
await self._index.update_searchable_attributes(_SEARCHABLE)
|
||||
await self._index.update_sortable_attributes(["date"])
|
||||
task = await self._index.update_filterable_attributes(_FILTERABLE)
|
||||
await self._client.wait_for_task(task.task_uid)
|
||||
self._ready = True
|
||||
|
||||
async def index(self, doc: SearchDoc) -> None:
|
||||
await self._ensure()
|
||||
await self._index.add_documents([_to_doc(doc)])
|
||||
|
||||
async def delete(self, message_id: UUID) -> None:
|
||||
await self._ensure()
|
||||
await self._index.delete_document(str(message_id))
|
||||
|
||||
async def delete_user(self, user_id: UUID) -> None:
|
||||
await self._ensure()
|
||||
await self._index.delete_documents_by_filter(f'user_id = "{user_id}"')
|
||||
|
||||
async def query(
|
||||
self, q: SearchQuery, *, limit: int, cursor: str | None
|
||||
) -> tuple[list[SearchHit], str | None]:
|
||||
await self._ensure()
|
||||
groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else []
|
||||
assert isinstance(groups, list)
|
||||
offset = _decode_cursor(cursor)
|
||||
res = await self._index.search(
|
||||
_query_text(groups),
|
||||
filter=_build_filter(groups, q.user_id),
|
||||
offset=offset,
|
||||
limit=limit + 1,
|
||||
sort=["date:desc"], # stable order for offset paging; typo matching still applies
|
||||
attributes_to_retrieve=["id", "thread_id"],
|
||||
attributes_to_highlight=["subject", "body"],
|
||||
attributes_to_crop=["body"],
|
||||
crop_length=30,
|
||||
highlight_pre_tag="<mark>",
|
||||
highlight_post_tag="</mark>",
|
||||
show_ranking_score=True,
|
||||
)
|
||||
rows = res.hits
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
hits = [_to_hit(row) for row in rows]
|
||||
next_cursor = _encode_cursor(offset + limit) if has_more else None
|
||||
return hits, next_cursor
|
||||
|
||||
|
||||
def _to_hit(row: dict[str, Any]) -> SearchHit:
|
||||
fmt = row.get("_formatted") or {}
|
||||
highlights: dict[str, str] = {}
|
||||
subject = fmt.get("subject") or ""
|
||||
if "<mark>" in subject:
|
||||
highlights["subject"] = subject
|
||||
body = fmt.get("body") or ""
|
||||
if "<mark>" in body:
|
||||
highlights["body"] = body
|
||||
return SearchHit(
|
||||
message_id=UUID(row["id"]),
|
||||
thread_id=UUID(row["thread_id"]),
|
||||
score=float(row.get("_rankingScore") or 0.0),
|
||||
highlights=highlights,
|
||||
)
|
||||
|
||||
|
||||
def _encode_cursor(offset: int) -> str:
|
||||
return base64.urlsafe_b64encode(str(offset).encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str | None) -> int:
|
||||
if not cursor:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(base64.urlsafe_b64decode(cursor.encode()).decode()))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
@@ -23,6 +23,22 @@ from app.models import Attachment, Mailbox, Message
|
||||
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
|
||||
|
||||
_TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0)
|
||||
|
||||
|
||||
def _prefix_tsquery(value: str) -> ColumnElement[object]:
|
||||
"""A prefix ``tsquery`` for search-as-you-type: ``invo`` finds ``invoice``.
|
||||
|
||||
We can't tokenize in Python and match the index: Postgres' parser keeps an
|
||||
email/host/URL as a *single* lexeme (``lukas.remis@gmail.com``), so splitting
|
||||
on word chars would never match it. Instead we re-parse the term with the
|
||||
same ``plainto_tsquery`` used at index time, then append ``:*`` to each
|
||||
lexeme to make it a prefix. ``plainto_tsquery`` strips tsquery operators and
|
||||
quotes, so this is injection- and error-safe on hostile input; an all-symbol
|
||||
term yields the empty query (matches nothing).
|
||||
"""
|
||||
lexemes = cast(func.plainto_tsquery(_TS_CONFIG, value), Text)
|
||||
prefixed = func.regexp_replace(lexemes, r"'([^']+)'", r"'\1':*", "g")
|
||||
return func.to_tsquery(_TS_CONFIG, prefixed)
|
||||
_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365}
|
||||
|
||||
|
||||
@@ -95,7 +111,10 @@ class PgSearchBackend(SearchBackend):
|
||||
def _clause_condition(self, clause: dict[str, object]) -> ColumnElement[bool] | None:
|
||||
value = str(clause.get("value", ""))
|
||||
if clause.get("type") == "text":
|
||||
return Message.body_indexed.op("@@")(func.websearch_to_tsquery(_TS_CONFIG, value))
|
||||
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))
|
||||
key = clause.get("key")
|
||||
cond = self._filter_condition(str(key), value)
|
||||
return cond
|
||||
@@ -172,7 +191,7 @@ class PgSearchBackend(SearchBackend):
|
||||
conditions = self._build_conditions(groups)
|
||||
|
||||
rank = (
|
||||
func.ts_rank_cd(Message.body_indexed, func.websearch_to_tsquery(_TS_CONFIG, q.text))
|
||||
func.ts_rank_cd(Message.body_indexed, _prefix_tsquery(q.text))
|
||||
if q.text
|
||||
else literal(0.0)
|
||||
)
|
||||
|
||||
+130
-12
@@ -7,6 +7,8 @@ import contextlib
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
@@ -27,6 +29,11 @@ logger = logging.getLogger(__name__)
|
||||
# Backfill priority: inbox, then sent/drafts, then everything else (SPEC §6.3).
|
||||
_PRIORITY = {"inbox": 0, "sent": 1, "drafts": 2}
|
||||
|
||||
# Sync-window import strategy (SPEC §12 M2). "all" imports the most recent
|
||||
# _RECENT_DAYS first (so the account is usable fast), then the older remainder.
|
||||
_RECENT_DAYS = 30
|
||||
_WINDOW_DAYS = {"3months": 90}
|
||||
|
||||
|
||||
async def _load_account(session: AsyncSession, account_id: uuid.UUID) -> Account:
|
||||
account = await session.get(Account, account_id)
|
||||
@@ -97,9 +104,8 @@ def _mailbox_order(mailboxes: list[Mailbox]) -> list[Mailbox]:
|
||||
return sorted(mailboxes, key=lambda m: _PRIORITY.get(m.special_use or "", 99))
|
||||
|
||||
|
||||
async def _emit_batch(
|
||||
results: list[PersistResult], account: Account, mailbox_path: str, done: int
|
||||
) -> None:
|
||||
async def _index_and_notify(results: list[PersistResult], account: Account) -> None:
|
||||
"""Index each persisted message and publish its ``message.new`` event."""
|
||||
backend = get_search_backend()
|
||||
bus = get_event_bus()
|
||||
for r in results:
|
||||
@@ -113,7 +119,13 @@ async def _emit_batch(
|
||||
"thread_id": str(r.message.thread_id),
|
||||
},
|
||||
)
|
||||
await bus.publish(
|
||||
|
||||
|
||||
async def _emit_batch(
|
||||
results: list[PersistResult], account: Account, mailbox_path: str, done: int
|
||||
) -> None:
|
||||
await _index_and_notify(results, account)
|
||||
await get_event_bus().publish(
|
||||
"sync.progress",
|
||||
{
|
||||
"user_id": str(account.user_id),
|
||||
@@ -124,14 +136,61 @@ async def _emit_batch(
|
||||
)
|
||||
|
||||
|
||||
async def backfill_mailbox(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> int:
|
||||
"""Backfill one mailbox newest-first, indexing inline per batch (FR-30)."""
|
||||
@dataclass
|
||||
class _BackfillProgress:
|
||||
"""Account-level backfill counter: persists ``backfill_done`` and emits
|
||||
``sync.progress`` with the running total so the UI can show a progress bar."""
|
||||
|
||||
account: Account
|
||||
total: int
|
||||
done: int = 0
|
||||
_mailboxes: set[str] = field(default_factory=set)
|
||||
|
||||
async def record(self, results: list[PersistResult], mailbox_path: str) -> None:
|
||||
from app.db import session_scope
|
||||
|
||||
await _index_and_notify(results, self.account)
|
||||
self.done += len(results)
|
||||
self._mailboxes.add(mailbox_path)
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Account)
|
||||
.where(Account.id == self.account.id)
|
||||
.values(backfill_done=self.done)
|
||||
)
|
||||
await get_event_bus().publish(
|
||||
"sync.progress",
|
||||
{
|
||||
"user_id": str(self.account.user_id),
|
||||
"account_id": str(self.account.id),
|
||||
"mailbox": mailbox_path,
|
||||
"done": self.done,
|
||||
"total": self.total,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def backfill_mailbox(
|
||||
provider: ImapSmtpProvider,
|
||||
account: Account,
|
||||
mailbox: Mailbox,
|
||||
progress: _BackfillProgress | None = None,
|
||||
*,
|
||||
since: date | None = None,
|
||||
before: date | None = None,
|
||||
) -> int:
|
||||
"""Backfill one mailbox newest-first, indexing inline per batch (FR-30).
|
||||
|
||||
``since``/``before`` bound the import by date (sync window, SPEC §12 M2).
|
||||
When a ``progress`` tracker is given, batches count toward the account-level
|
||||
backfill total; otherwise each batch emits a standalone ``sync.progress``.
|
||||
"""
|
||||
from app.db import session_scope
|
||||
|
||||
account_id, user_id, mailbox_id = account.id, account.user_id, mailbox.id
|
||||
special_use, path = mailbox.special_use, mailbox.path
|
||||
done = 0
|
||||
async for batch in provider.backfill(path, batch_size=200):
|
||||
async for batch in provider.backfill(path, batch_size=200, since=since, before=before):
|
||||
results: list[PersistResult] = []
|
||||
max_uid = 0
|
||||
async with session_scope() as session:
|
||||
@@ -153,12 +212,41 @@ async def backfill_mailbox(provider: ImapSmtpProvider, account: Account, mailbox
|
||||
.values(last_seen_uid=max_uid)
|
||||
)
|
||||
done += len(results)
|
||||
await _emit_batch(results, account, path, done)
|
||||
if progress is not None:
|
||||
await progress.record(results, path)
|
||||
else:
|
||||
await _emit_batch(results, account, path, done)
|
||||
return done
|
||||
|
||||
|
||||
async def _set_backfill_counts(account_id: uuid.UUID, *, total: int, done: int) -> None:
|
||||
from app.db import session_scope
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Account)
|
||||
.where(Account.id == account_id)
|
||||
.values(backfill_total=total, backfill_done=done)
|
||||
)
|
||||
|
||||
|
||||
async def _sync_new_only(provider: ImapSmtpProvider, mailboxes: list[Mailbox]) -> None:
|
||||
"""``new`` window: import no history — just move each mailbox watermark to the
|
||||
current tip so only mail arriving after setup is pulled (SPEC §12 M2)."""
|
||||
from app.db import session_scope
|
||||
|
||||
for mailbox in mailboxes:
|
||||
tip = await provider.newest_uid(mailbox.path)
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Mailbox)
|
||||
.where(Mailbox.id == mailbox.id, Mailbox.last_seen_uid < tip)
|
||||
.values(last_seen_uid=tip)
|
||||
)
|
||||
|
||||
|
||||
async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
|
||||
"""Full initial sync for an account: mailboxes + backfill; sets state=live."""
|
||||
"""Full initial sync for an account: mailboxes + windowed backfill; state=live."""
|
||||
from app.db import session_scope
|
||||
|
||||
async with session_scope() as session:
|
||||
@@ -167,12 +255,42 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
|
||||
mailboxes = _mailbox_order(await bootstrap_mailboxes(session, provider, account))
|
||||
# Detach: reuse account/mailboxes values after the session closes.
|
||||
account_obj = account
|
||||
window = account.sync_window
|
||||
mailbox_list = list(mailboxes)
|
||||
|
||||
total = 0
|
||||
try:
|
||||
if window == "new":
|
||||
# No backfill: set watermarks to the tip, go live immediately.
|
||||
await _sync_new_only(provider, mailbox_list)
|
||||
await _set_backfill_counts(account_id, total=0, done=0)
|
||||
await _set_state(account_id, "live")
|
||||
return 0
|
||||
|
||||
since = (
|
||||
date.today() - timedelta(days=_WINDOW_DAYS[window]) if window in _WINDOW_DAYS else None
|
||||
)
|
||||
# Progress denominator: count what we're about to import up front so the
|
||||
# bar has a stable total (SPEC §12 M2 backfill-progress UX).
|
||||
total = 0
|
||||
for mailbox in mailbox_list:
|
||||
total += await backfill_mailbox(provider, account_obj, mailbox)
|
||||
total += await provider.message_count(mailbox.path, since=since)
|
||||
await _set_backfill_counts(account_id, total=total, done=0)
|
||||
progress = _BackfillProgress(account_obj, total)
|
||||
|
||||
recent_cutoff = date.today() - timedelta(days=_RECENT_DAYS)
|
||||
count = 0
|
||||
for mailbox in mailbox_list:
|
||||
if since is not None: # 3months: single windowed pass
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, since=since
|
||||
)
|
||||
else: # all: recent window first (usable fast), then the remainder
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, since=recent_cutoff
|
||||
)
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, before=recent_cutoff
|
||||
)
|
||||
await _set_state(account_id, "live")
|
||||
except Exception as exc: # noqa: BLE001 - surface provider errors as account state
|
||||
logger.exception("backfill failed for account %s", account_id)
|
||||
@@ -180,7 +298,7 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
|
||||
raise
|
||||
finally:
|
||||
await provider.close()
|
||||
return total
|
||||
return count
|
||||
|
||||
|
||||
async def apply_delta(
|
||||
|
||||
@@ -39,6 +39,20 @@ def _recipient_emails(message: Message) -> set[str]:
|
||||
return emails
|
||||
|
||||
|
||||
def _parent_refs(message: Message) -> list[str]:
|
||||
"""Non-synthetic parent ids this message explicitly declares (References +
|
||||
In-Reply-To). A non-empty result means the message states its parentage, so it
|
||||
must not be subject-merged into an unrelated conversation (§7.3)."""
|
||||
refs: list[str] = list(message.references_hdr)
|
||||
if message.in_reply_to:
|
||||
refs.append(message.in_reply_to)
|
||||
out: list[str] = []
|
||||
for r in refs:
|
||||
if r and not is_synthetic(r) and r not in out:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _candidate_ids(message: Message) -> list[str]:
|
||||
candidates: list[str] = list(message.references_hdr)
|
||||
if message.in_reply_to:
|
||||
@@ -59,17 +73,26 @@ async def _find_by_references(
|
||||
user_id: uuid.UUID,
|
||||
message_id: uuid.UUID,
|
||||
candidates: list[str],
|
||||
parent_refs: list[str],
|
||||
own_id: str | None,
|
||||
) -> list[uuid.UUID]:
|
||||
"""Threads joined by reference (SPEC §7.2.2).
|
||||
|
||||
Matches bidirectionally: existing messages whose id is one of my candidate ids
|
||||
(I reference them / duplicate id), OR existing messages that reference *my* id
|
||||
(handles a late-arriving parent). Synthetic own-ids are never used to join (§7.1).
|
||||
(I reference them / duplicate id), existing messages that reference *my* id
|
||||
(late-arriving parent), OR existing messages that share one of my declared
|
||||
parents — a sibling reply to the same original, even when neither the parent
|
||||
nor a mutual reference is present. Synthetic ids are never used to join (§7.1).
|
||||
"""
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
if candidates:
|
||||
conditions.append(Message.message_id_hdr.in_(candidates))
|
||||
if parent_refs:
|
||||
# Siblings: an existing message that references, or is a reply to, one of
|
||||
# my parents. This groups replies to a common (possibly absent) original
|
||||
# without letting the subject fallback merge replies to *different* ones.
|
||||
conditions.append(Message.references_hdr.overlap(parent_refs))
|
||||
conditions.append(Message.in_reply_to.in_(parent_refs))
|
||||
if own_id is not None and not is_synthetic(own_id):
|
||||
# own_id = ANY(references_hdr): an existing message that references me.
|
||||
conditions.append(literal(own_id) == sa_any(Message.references_hdr))
|
||||
@@ -191,10 +214,17 @@ async def thread_message(session: AsyncSession, message: Message) -> uuid.UUID:
|
||||
Returns the resulting thread id and sets ``message.thread_id``.
|
||||
"""
|
||||
candidates = _candidate_ids(message)
|
||||
parent_refs = _parent_refs(message)
|
||||
own_id = message.message_id_hdr
|
||||
thread_ids = await _find_by_references(session, message.user_id, message.id, candidates, own_id)
|
||||
thread_ids = await _find_by_references(
|
||||
session, message.user_id, message.id, candidates, parent_refs, own_id
|
||||
)
|
||||
|
||||
if not thread_ids:
|
||||
# Subject fallback only for messages that declare no parentage (a broken/absent
|
||||
# References chain). A message that explicitly replies to a specific parent is
|
||||
# never subject-merged into an unrelated thread — e.g. two auto-replies to
|
||||
# different originals that happen to share the same subject (§7.3).
|
||||
if not thread_ids and not parent_refs:
|
||||
subject_thread = await _find_by_subject(session, message.user_id, message)
|
||||
if subject_thread is not None:
|
||||
thread_ids = [subject_thread]
|
||||
|
||||
Reference in New Issue
Block a user