Files
lukasandClaude Opus 5.5 8a1b9979e9 Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:00:31 +02:00

392 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sentence completion in the compose window: phrases the user writes, finished.
No AI: a per-user index of the word sequences the user has written at least
twice, built from their own sent mail (quoted replies and signatures cut off),
plus a few common email phrases per language to start from. Typing "I look
forward" finds what usually follows those words — "to hearing from you." — and
the compose window shows it in grey until Tab takes it.
It works in any language the user writes in: matching is on the words just
typed, so a Polish sentence only ever meets Polish phrases.
The index lives in ``completion_phrases`` (context → continuation, with how
often it was seen) and is rebuilt in the background at most once a day, when
the compose window asks for suggestions (:func:`build_index_job`).
"""
from __future__ import annotations
import datetime as dt
import html as html_lib
import logging
import re
import uuid
from collections import Counter
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert
from app.db import session_scope
from app.models import (
Account,
BodyPart,
CompletionIndexState,
CompletionPhrase,
Mailbox,
Message,
)
logger = logging.getLogger(__name__)
START = "^" # a sentence's start, as a word: "^ pozdr" completes the first word
MAX_CONTEXT = 3 # words before the cursor matched on
MAX_CONTINUATION = 6 # words suggested at most
MIN_COUNT = 2 # seen this often to be offered; one-offs are noise (and private)
EXTEND_SHARE = 0.6 # a longer suggestion only while it is what usually follows
CORPUS_MESSAGES = 500 # most recent sent messages read
CORPUS_CHARS = 3000 # of each
REBUILD_AFTER = dt.timedelta(days=1)
# Where the user's own words end and quoted mail or a signature begins: a reply
# header ("On … wrote:", Polish Gmail's "… napisał(a):", …), a forwarded or
# original-message marker, or an Outlook-style header block.
_QUOTE_START = re.compile(
r"^\s*(?:"
r"-{2,}\s*(?:original message|forwarded message|wiadomość oryginalna"
r"|wiadomość przekazana dalej|ursprüngliche nachricht|weitergeleitete nachricht)"
r"|(?:from|od|von|de):\s.+"
r"|sent from my\s"
r")",
re.IGNORECASE,
)
_QUOTE_HEADER_END = re.compile(
# German names the writer after the verb: "Am … schrieb Anna:".
r"(?:wrote|napisał\(a\)|napisała?|pisze|schrieb(?:\s+.{0,100})?|a écrit|escribió)\s*:\s*$",
re.IGNORECASE,
)
_SIGNATURE = re.compile(r"^--\s*$")
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+|\n+")
_WORD = re.compile(r"\S+")
_EDGE_PUNCT = re.compile(r"^[\"'“”„«»(\[–—-]+|[\"'“”„«»)\],.;:!?…–—-]+$")
# Words a phrase is never learned across: numbers (phones, dates, prices),
# addresses and links — useless to complete, and often private.
_NOT_A_WORD = re.compile(r"\d|@|www\.|https?:", re.IGNORECASE)
def html_to_text(html: str) -> str:
"""An HTML signature as text lines (block tags and <br> break lines)."""
text = re.sub(r"(?i)<br\s*/?>|</(?:p|div|li|tr|h[1-6])\s*>", "\n", html)
text = re.sub(r"<[^>]+>", "", text)
return html_lib.unescape(text)
def own_text(text: str, signatures: Iterable[str] = ()) -> str:
"""The part of a sent message the user wrote: no quoted mail, no signature
(the ``--`` kind, or one of ``signatures`` — the accounts' own, as text)."""
sig_lines = {norm_line(x) for sig in signatures for x in sig.splitlines() if norm_line(x)}
kept: list[str] = []
for line in text.splitlines():
if _SIGNATURE.match(line) or _QUOTE_START.match(line) or _QUOTE_HEADER_END.search(line):
break
if norm_line(line) in sig_lines:
continue
if line.lstrip().startswith(">"):
continue # an inline-quoted line
kept.append(line)
return "\n".join(kept)
def norm_line(line: str) -> str:
return " ".join(line.lower().split())
def norm(word: str) -> str:
"""A word as matched: lower case, outer punctuation off ("Hello," -> "hello")."""
return _EDGE_PUNCT.sub("", word.lower())
def sentences(text: str) -> Iterable[list[str]]:
for chunk in _SENTENCE_END.split(text):
words = _WORD.findall(chunk)
if words:
yield words
@dataclass(frozen=True)
class Phrase:
context: str # normalized words before the cursor, space-joined ("^ i look")
continuation: str # the words that follow, as written ("forward to hearing")
count: int
def build_phrases(texts: Iterable[str], weight: int = 1) -> dict[tuple[str, str], int]:
"""(context, continuation) -> count, for every sequence seen MIN_COUNT times.
Counts each sentence's word sequences (with the start marker), keeps the
frequent ones, and splits each into "what was typed" and "what follows".
"""
counts: Counter[tuple[str, ...]] = Counter()
surface: dict[tuple[str, ...], tuple[str, ...]] = {}
longest = MAX_CONTEXT + MAX_CONTINUATION
for text in texts:
for words in sentences(text):
seq = [START, *words]
keys = [START, *("" if _NOT_A_WORD.search(w) else norm(w) for w in words)]
for i in range(len(seq)):
for n in range(2, min(longest, len(seq) - i) + 1):
gram = tuple(keys[i : i + n])
if "" in gram:
break # a lone punctuation mark: no phrase across it
counts[gram] += weight
surface.setdefault(gram, tuple(seq[i : i + n]))
out: dict[tuple[str, str], int] = {}
for gram, count in counts.items():
if count < MIN_COUNT:
continue
written = surface[gram]
for k in range(1, min(MAX_CONTEXT, len(gram) - 1) + 1):
rest = len(gram) - k
if rest > MAX_CONTINUATION:
continue
key = (" ".join(gram[:k]), " ".join(written[k:]))
out[key] = max(out.get(key, 0), count)
return out
# A few common phrases per language, for users with little sent mail yet.
STARTER_PHRASES: dict[str, list[str]] = {
"en": [
"I look forward to hearing from you.",
"I'm looking forward to hearing from you.",
"Looking forward to hearing from you.",
"Looking forward to working with you.",
"Thank you for your email.",
"Thank you for your help.",
"Thank you for your quick reply.",
"Thanks in advance.",
"Please let me know if you have any questions.",
"Let me know if you have any questions.",
"Let me know what you think.",
"Please find attached the",
"I hope this email finds you well.",
"I hope you're doing well.",
"Sorry for the late reply.",
"I would be happy to help.",
"Feel free to contact me if you need anything else.",
"Could you please send me the",
"Please confirm at your earliest convenience.",
"Have a great day!",
"Have a nice weekend!",
"Best regards,",
"Kind regards,",
],
"pl": [
"Z poważaniem,",
"Pozdrawiam serdecznie,",
"Dziękuję za wiadomość.",
"Dziękuję za szybką odpowiedź.",
"Dziękuję za pomoc.",
"Z góry dziękuję.",
"W załączniku przesyłam",
"Proszę o informację zwrotną.",
"W razie pytań proszę o kontakt.",
"Czekam na odpowiedź.",
"Przepraszam za opóźnienie w odpowiedzi.",
"Będę wdzięczny za odpowiedź.",
"Będę wdzięczna za odpowiedź.",
"Zgodnie z naszą rozmową",
"Proszę o potwierdzenie.",
"Daj znać, co myślisz.",
"Miłego dnia!",
"Miłego weekendu!",
],
"de": [
"Mit freundlichen Grüßen",
"Viele Grüße",
"Beste Grüße",
"Vielen Dank für Ihre E-Mail.",
"Vielen Dank für Ihre schnelle Antwort.",
"Vielen Dank im Voraus.",
"Ich freue mich auf Ihre Antwort.",
"Bei Fragen stehe ich Ihnen gerne zur Verfügung.",
"Im Anhang finden Sie",
"Entschuldigen Sie die späte Antwort.",
"Ich hoffe, es geht Ihnen gut.",
"Schönen Tag noch!",
"Schönes Wochenende!",
],
}
# Built once: every starter phrase counts as seen MIN_COUNT times.
_STARTERS: dict[str, Counter[str]] = {}
for (_ctx, _cont), _n in build_phrases(
(p for phrases in STARTER_PHRASES.values() for p in phrases), weight=MIN_COUNT
).items():
_STARTERS.setdefault(_ctx, Counter())[_cont] = _n
_LEADING_PUNCT = re.compile(r"^[\"'“”„«»(\[]+")
def _candidates(phrases: dict[str, Counter[str]], context: str, partial: str) -> Counter[str]:
"""Continuations of ``context`` whose first word starts with ``partial``."""
out: Counter[str] = Counter()
for continuation, count in phrases.get(context, Counter()).items():
first = continuation.split(" ", 1)[0]
if partial and not first.lower().startswith(partial.lower()):
continue
if partial and len(first) <= len(partial):
continue # that word is typed in full already
out[continuation] = count
return out
def choose(candidates: Counter[str]) -> str | None:
"""The suggestion: the most frequent next word, extended word by word only
while the longer phrase is what usually follows (EXTEND_SHARE)."""
if not candidates:
return None
by_first: Counter[str] = Counter()
for continuation, count in candidates.items():
first = continuation.split(" ", 1)[0]
by_first[first] = max(by_first[first], count)
chosen, chosen_count = by_first.most_common(1)[0]
while True:
depth = chosen.count(" ") + 1
longer = [
(c, n)
for c, n in candidates.items()
if c.startswith(chosen + " ") and c.count(" ") == depth
]
if not longer:
return chosen
best, best_count = max(longer, key=lambda x: x[1])
if best_count < max(MIN_COUNT, EXTEND_SHARE * chosen_count):
return chosen
chosen, chosen_count = best, best_count
def contexts(before: str) -> tuple[list[str], str]:
"""For the text before the cursor: the contexts to try, longest first, and
the partly typed word ('' after a space). No contexts: nothing to suggest
(an empty sentence, or the cursor right after a punctuation mark)."""
if not before or not before.strip():
return [], ""
last = before[-1]
if not last.isspace() and not last.isalnum() and last not in "'’-":
return [], "" # "you," or "end." — not mid-word, and no space typed yet
tail = _SENTENCE_END.split(before)[-1]
words = _WORD.findall(tail)
partial = ""
if words and not last.isspace():
partial = _LEADING_PUNCT.sub("", words.pop())
if not words and not partial:
return [], "" # a new sentence with nothing in it yet
keys = [START, *(norm(w) for w in words)]
if "" in keys: # a lone punctuation mark: the phrase starts after it
keys = keys[len(keys) - keys[::-1].index("") :]
if not keys:
return [], ""
return [" ".join(keys[-k:]) for k in range(min(MAX_CONTEXT, len(keys)), 0, -1)], partial
def suggest(personal: dict[str, Counter[str]], before: str) -> str | None:
"""What to show after ``before`` (the text up to the cursor), or None.
The returned text starts where the cursor is: the rest of a partly typed
word, or (after a space) the next words. The user's own phrases and the
starter phrases count together.
"""
tried, partial = contexts(before)
for context in tried:
merged = _candidates(personal, context, partial)
for cont, count in _candidates(_STARTERS, context, partial).items():
merged[cont] = merged.get(cont, 0) + count
if " " not in context and not partial:
# A single word before a space is a weak hint: only a strong habit.
merged = Counter({c: n for c, n in merged.items() if n >= 2 * MIN_COUNT})
best = choose(merged)
if best:
return best[len(partial) :] if partial else best
return None
def complete(personal: dict[str, Counter[str]], before: str) -> str | None:
""":func:`suggest`, and for a word typed in full but no space yet ("I look
forward"), what follows it — with the space ("␣to hearing from you.")."""
found = suggest(personal, before)
if found is None and before[-1:].isalnum():
after_space = suggest(personal, before + " ")
if after_space:
return " " + after_space
return found
def keys_for(before: str) -> list[str]:
"""The contexts :func:`complete` may look up, to fetch them from the index."""
keys = contexts(before)[0]
if before[-1:].isalnum():
keys += contexts(before + " ")[0]
return keys
async def personal_phrases(
session: Any, user_id: uuid.UUID, keys: list[str]
) -> dict[str, Counter[str]]:
rows = await session.execute(
select(
CompletionPhrase.context, CompletionPhrase.continuation, CompletionPhrase.count
).where(CompletionPhrase.user_id == user_id, CompletionPhrase.context.in_(keys))
)
out: dict[str, Counter[str]] = {}
for context, continuation, count in rows:
out.setdefault(context, Counter())[continuation] = count
return out
async def build_index_job(ctx: dict[str, Any], user_id: str) -> int:
"""Rebuild a user's phrase index from their recent sent mail. Returns its size."""
uid = uuid.UUID(user_id)
async with session_scope() as session:
texts = list(
await session.scalars(
select(BodyPart.content)
.join(Message, Message.id == BodyPart.message_id)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == uid,
Mailbox.special_use == "sent",
BodyPart.kind == "text",
)
.order_by(Message.internal_date.desc())
.limit(CORPUS_MESSAGES)
)
)
signatures = [
html_to_text(sig)
for sig in await session.scalars(
select(Account.signature).where(Account.user_id == uid, Account.signature != "")
)
]
phrases = build_phrases(own_text(t, signatures)[:CORPUS_CHARS] for t in texts)
await session.execute(delete(CompletionPhrase).where(CompletionPhrase.user_id == uid))
rows = [
{"user_id": uid, "context": c, "continuation": cont, "count": n}
for (c, cont), n in phrases.items()
]
for start in range(0, len(rows), 1000):
await session.execute(insert(CompletionPhrase), rows[start : start + 1000])
stmt = insert(CompletionIndexState).values(user_id=uid, built_at=dt.datetime.now(dt.UTC))
await session.execute(
stmt.on_conflict_do_update(
index_elements=["user_id"], set_={"built_at": stmt.excluded.built_at}
)
)
await session.commit()
logger.info(
"completion index for %s: %d phrases from %d messages", user_id, len(rows), len(texts)
)
return len(rows)