Files

150 lines
5.2 KiB
Python
Raw Permalink Normal View History

"""Rebuild the search index for every message, from Postgres alone.
Both backends index the same :class:`SearchDoc` (SPEC §5.2), and every field of
it is already stored in the database — subject/addresses/flags on ``messages``,
the plain-text body on ``body_parts``, filenames on ``attachments``. So the
index is fully derivable and never needs its own backup: if the Meilisearch
volume is lost, or you switch SEARCH_BACKEND, run this.
python scripts/reindex_search.py # everything
python scripts/reindex_search.py --user <uuid> # one user
python scripts/reindex_search.py --purge # drop stale docs first
In Docker: ./deploy/webmail.sh reindex [--purge]
``--purge`` clears each user's existing documents before rebuilding, which is
what you want after deleting mail outside the app or when the index has drifted;
without it the rebuild is a pure upsert (safe to re-run, safe to interrupt).
"""
from __future__ import annotations
import argparse
import asyncio
import uuid
from collections import defaultdict
from datetime import datetime
from sqlalchemy import select
from app.db import get_sessionmaker
from app.models import Attachment, BodyPart, Mailbox, Message
from app.search.base import SearchBackend, SearchDoc
from app.search.factory import get_search_backend
# Rows pulled per round-trip, and how many documents are pushed to the backend
# concurrently. Deliberately modest: this runs on a NAS alongside live sync.
BATCH = 500
CONCURRENCY = 8
def _epoch(value: datetime) -> int:
return int(value.timestamp())
async def _index_batch(backend: SearchBackend, docs: list[SearchDoc]) -> None:
sem = asyncio.Semaphore(CONCURRENCY)
async def one(doc: SearchDoc) -> None:
async with sem:
await backend.index(doc)
await asyncio.gather(*(one(d) for d in docs))
async def main() -> None:
parser = argparse.ArgumentParser(description="Rebuild the search index from Postgres")
parser.add_argument("--user", help="only this user_id (UUID)")
parser.add_argument(
"--purge",
action="store_true",
help="delete each user's existing documents before rebuilding",
)
args = parser.parse_args()
only_user = uuid.UUID(args.user) if args.user else None
backend = get_search_backend()
sm = get_sessionmaker()
if args.purge:
async with sm() as session:
users = (
[only_user]
if only_user
else list(await session.scalars(select(Message.user_id).distinct()))
)
for user_id in users:
await backend.delete_user(user_id)
print(f"purged existing documents for {len(users)} user(s)")
total = 0
after: uuid.UUID | None = None
while True:
async with sm() as session:
stmt = (
select(Message, Mailbox.special_use)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.order_by(Message.id)
.limit(BATCH)
)
if after is not None:
stmt = stmt.where(Message.id > after)
if only_user is not None:
stmt = stmt.where(Message.user_id == only_user)
rows = list(await session.execute(stmt))
if not rows:
break
ids = [row[0].id for row in rows]
after = ids[-1]
# Two batched lookups instead of 2N per-message queries.
bodies: dict[uuid.UUID, str] = {}
for message_id, content in await session.execute(
select(BodyPart.message_id, BodyPart.content).where(
BodyPart.message_id.in_(ids), BodyPart.kind == "text"
)
):
bodies.setdefault(message_id, content)
names: dict[uuid.UUID, list[str]] = defaultdict(list)
for message_id, filename in await session.execute(
select(Attachment.message_id, Attachment.filename).where(
Attachment.message_id.in_(ids),
Attachment.content_id.is_(None), # real attachments, not inline images
)
):
names[message_id].append(filename)
docs = [
SearchDoc(
message_id=msg.id,
thread_id=msg.thread_id or msg.id,
user_id=msg.user_id,
account_id=msg.account_id,
mailbox_special_use=special_use,
from_=msg.from_addr,
to=msg.to_addr,
cc=msg.cc_addr,
subject=msg.subject,
body_text=bodies.get(msg.id, ""),
attachment_names=names.get(msg.id, []),
has_attachments=msg.has_attachments,
is_unread="\\Seen" not in msg.flags,
is_starred="\\Flagged" in msg.flags,
date=_epoch(msg.internal_date),
size_bytes=msg.size_bytes,
)
for msg, special_use in rows
]
await _index_batch(backend, docs)
total += len(docs)
print(f"indexed {total} messages", flush=True)
print(f"done: {total} messages reindexed")
if __name__ == "__main__":
asyncio.run(main())