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>
This commit is contained in:
2026-10-18 20:00:31 +02:00
co-authored by Claude Opus 5.5
parent 463f96ad57
commit 8a1b9979e9
83 changed files with 8677 additions and 293 deletions
+10
View File
@@ -15,6 +15,7 @@ def register_routers(app: FastAPI) -> None:
from app.api.actions import router as actions_router
from app.api.admin import router as admin_router
from app.api.avatar import router as avatar_router
from app.api.completion import router as completion_router
from app.api.compose import router as compose_router
from app.api.invitations import router as invitations_router
from app.api.mailboxes import router as mailboxes_router
@@ -25,8 +26,12 @@ def register_routers(app: FastAPI) -> None:
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.snooze import router as snooze_router
from app.api.spellcheck import router as spellcheck_router
from app.api.subscriptions import router as subscriptions_router
from app.api.templates import router as templates_router
from app.api.threads import router as threads_router
from app.api.translation import router as translation_router
from app.identity.routes import router as auth_router
from app.ws import router as ws_router
@@ -48,6 +53,11 @@ def register_routers(app: FastAPI) -> None:
spellcheck_router,
push_router,
invitations_router,
templates_router,
snooze_router,
subscriptions_router,
completion_router,
translation_router,
):
app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api")
+44
View File
@@ -0,0 +1,44 @@
"""Sentence completion for the compose window (app/completion.py)."""
from __future__ import annotations
import datetime as dt
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 import completion
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import CompletionIndexState
from app.queue import enqueue_job
router = APIRouter(prefix="/completion", tags=["compose"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
class CompletionIn(BaseModel):
# The text before the cursor (its last line is all that matters). A POST,
# not a query string: what the user is typing stays out of access logs.
before: str = Field(max_length=2000)
class CompletionOut(BaseModel):
suggestion: str | None
@router.post("", response_model=CompletionOut)
async def suggest(body: CompletionIn, user: CurrentUser, db: DbSession) -> CompletionOut:
before = body.before[-300:]
built = await db.scalar(
select(CompletionIndexState.built_at).where(CompletionIndexState.user_id == user.id)
)
if built is None or built < dt.datetime.now(dt.UTC) - completion.REBUILD_AFTER:
# In the background; until it is built, the starter phrases still answer.
await enqueue_job("build_index_job", str(user.id), _job_id=f"complete:{user.id}")
personal = await completion.personal_phrases(db, user.id, completion.keys_for(before))
return CompletionOut(suggestion=completion.complete(personal, before))
+16 -2
View File
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.identity.deps import CurrentUser
from app.models import Draft, Mailbox, Message
from app.models import Draft, Mailbox, Message, Thread
from app.outbox.service import not_being_sent
router = APIRouter(prefix="/mailboxes", tags=["mail"])
@@ -30,6 +30,7 @@ class MailboxOut(BaseModel):
class FolderCounts(BaseModel):
inbox_unread: int # unread messages sitting in Inbox mailboxes
drafts: int # drafts shown in the Drafts folder (local + un-adopted remote)
snoozed: int = 0 # conversations snoozed out of the Inbox
@router.get("", response_model=list[MailboxOut])
@@ -54,8 +55,17 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
Message.user_id == user.id,
Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]),
# A snoozed conversation is out of the Inbox until it wakes.
~Message.thread_id.in_(
select(Thread.id).where(Thread.user_id == user.id, Thread.snoozed_until.isnot(None))
),
)
)
snoozed = await db.scalar(
select(func.count())
.select_from(Thread)
.where(Thread.user_id == user.id, Thread.snoozed_until.isnot(None))
)
# Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror
# UIDs excluded), matching what list_drafts + list_remote_drafts return.
@@ -77,4 +87,8 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
)
remote = sum(1 for r in remote_rows if (r.account_id, r.uid) not in owned)
return FolderCounts(inbox_unread=int(inbox_unread or 0), drafts=int(local or 0) + remote)
return FolderCounts(
inbox_unread=int(inbox_unread or 0),
drafts=int(local or 0) + remote,
snoozed=int(snoozed or 0),
)
+176 -36
View File
@@ -6,6 +6,7 @@ import asyncio
import contextlib
import html as html_lib
import io
import logging
import uuid
import zipfile
from typing import Annotated
@@ -14,11 +15,18 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse, HTMLResponse, Response
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app import translation
from app.api.errors import raise_api_error
from app.api.schemas import AttachmentOut
from app.api.settings import add_allowed_image_sender, allowed_image_senders
from app.api.settings import (
add_allowed_image_sender,
allowed_image_senders,
blocks_trackers,
)
from app.completion import html_to_text
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.indexer import attachments as att_store
@@ -26,7 +34,18 @@ from app.indexer import rawstore, thumbs
from app.indexer.mime import parse_message
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
from app.indexer.thumbs import thumb_type
from app.models import Attachment, BodyPart, ComposeAttachment, Message
from app.models import (
Account,
Attachment,
BodyPart,
ComposeAttachment,
Message,
MessageTranslation,
User,
)
from app.translation_prep import find_names_in_signature
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/messages", tags=["mail"])
@@ -101,8 +120,16 @@ _MESSAGE_CSP = (
)
def _html(document: str) -> HTMLResponse:
return HTMLResponse(content=document, headers={"Content-Security-Policy": _MESSAGE_CSP})
def _html(document: str, trackers: int = 0, extra: dict[str, str] | None = None) -> HTMLResponse:
# The page reads what it needs from headers: the body is the message.
return HTMLResponse(
content=document,
headers={
"Content-Security-Policy": _MESSAGE_CSP,
"X-Trackers-Blocked": str(trackers),
**(extra or {}),
},
)
async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message:
@@ -118,54 +145,167 @@ def _sender_email(message: Message) -> str:
return message.from_addr[0].get("email", "").lower() if message.from_addr else ""
async def _render_fragment(
db: AsyncSession, message: Message, allow_remote: bool, block: bool
) -> tuple[str, int]:
"""The sanitized body the user sees, and how many trackers came out of it.
Re-sanitizes from the raw blob when remote images are allowed, the stored
copy predates the current sanitizer (SPEC §10.4), or trackers are to stay
in; otherwise the stored copy is served as is.
"""
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)
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
# The stored copy has its trackers taken out; with blocking switched off, a
# message that had some is rendered afresh with them left in.
trackers_kept = not block and html_part is not None and html_part.trackers_blocked > 0
if html_part is not None and not allow_remote and not stale and not trackers_kept:
return html_part.content, html_part.trackers_blocked if block else 0
raw = None
if html_part is not None or allow_remote: # a plain-text message has nothing to redo
try:
raw = await rawstore.ensure_bytes(message)
except FileNotFoundError:
raw = None
parsed = parse_message(raw) if raw is not None else None
if parsed is not None and parsed.html_body is not None:
if stale and html_part is not None:
# The stored copy is always the plain one: no remote images, trackers out.
upgraded = sanitize_html(parsed.html_body, message.id)
html_part.content = upgraded.html
html_part.sanitizer_v = upgraded.sanitizer_v
html_part.trackers_blocked = upgraded.trackers_blocked
await db.commit()
if block and not allow_remote:
return upgraded.html, upgraded.trackers_blocked
result = sanitize_html(
parsed.html_body, message.id, allow_remote_images=allow_remote, block_trackers=block
)
return result.html, result.trackers_blocked
if html_part is not None:
return html_part.content, html_part.trackers_blocked
text_part = next((p for p in parts if p.kind == "text"), None)
body = html_lib.escape(text_part.content) if text_part else ""
return f'<div class="wm-plaintext">{body}</div>', 0
async def _content_language(db: AsyncSession, message: Message) -> str:
"""The message's language, found (and kept) the first time it is shown."""
if message.content_language is not None or not translation.enabled():
return message.content_language or ""
text = await db.scalar(
select(BodyPart.content).where(BodyPart.message_id == message.id, BodyPart.kind == "text")
)
try:
found = await translation.detect(text or message.snippet or "")
except translation.TranslationError as exc:
logger.info("language detection unavailable: %s", exc)
return "" # not kept: tried again once the service is back
message.content_language = found
await db.commit()
return found
@router.get("/{message_id}/html", response_class=HTMLResponse)
async def message_html(
message_id: uuid.UUID,
user: CurrentUser,
db: DbSession,
images: bool = Query(False),
translate: str | None = Query(None, pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$"),
) -> 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). ``images=1``
is a one-time "Load images" — render remote images for this request only,
without persisting the sender as trusted.
``images=1`` is a one-time "Load images" — render remote images for this
request only, without persisting the sender as trusted. ``translate=pl``
returns it translated (app/translation.py). Headers tell the page what it
got: X-Trackers-Blocked, X-Content-Language (the message's own language,
when known) and, translated, X-Translated-From / X-Translated-To.
"""
message = await _owned_message(db, user.id, message_id)
allow_remote = images or _sender_email(message) in allowed_image_senders(user)
block = blocks_trackers(user)
fragment, trackers = await _render_fragment(db, message, allow_remote, block)
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)
headers: dict[str, str] = {}
language = await _content_language(db, message)
if language:
headers["X-Content-Language"] = language
if translate:
fragment, source = await _translated(
db, user, message, fragment, language, translate, allow_remote, block
)
headers["X-Translated-From"] = source
headers["X-Translated-To"] = translate
return _html(_wrap(fragment), trackers, headers)
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
if html_part is not None and not allow_remote and not stale:
return _html(_wrap(html_part.content))
# Re-sanitize the original HTML (image-approved sender, or a stale stored copy).
# If the raw blob is unavailable, fall back to whatever is already stored.
if allow_remote or stale:
try:
raw = await rawstore.ensure_bytes(message)
except FileNotFoundError:
raw = None
if raw is not None:
parsed = parse_message(raw)
if parsed.html_body is not None:
result = sanitize_html(
parsed.html_body, message.id, allow_remote_images=allow_remote
)
if html_part is not None and not allow_remote:
html_part.content = result.html # persist the upgraded sanitization
html_part.sanitizer_v = result.sanitizer_v
await db.commit()
return _html(_wrap(result.html))
async def _names(db: AsyncSession, user: User, message: Message) -> list[str]:
"""Names a translation leaves as they are if the model would change them:
the people on the message (and the sender's single names, as signed:
"Milan"), the user's own names (accounts, signatures)."""
names: list[str] = []
for people in (message.from_addr, message.to_addr, message.cc_addr):
names += [p.get("name", "") for p in people if p.get("name")]
for p in message.from_addr:
names += [w for w in str(p.get("name", "")).split() if len(w) >= 3 and w[0].isupper()]
rows = await db.execute(
select(Account.display_name, Account.signature).where(Account.user_id == user.id)
)
for display_name, signature in rows:
if display_name:
names.append(display_name)
if signature:
names += find_names_in_signature(html_to_text(signature))
return names
if html_part is not None:
return _html(_wrap(html_part.content))
text_part = next((p for p in parts if p.kind == "text"), None)
body = html_lib.escape(text_part.content) if text_part else ""
return _html(_wrap(f'<div class="wm-plaintext">{body}</div>'))
async def _translated(
db: AsyncSession,
user: User,
message: Message,
fragment: str,
language: str,
target: str,
allow_remote: bool,
block: bool,
) -> tuple[str, str]:
"""The fragment in ``target`` — from the cache, or translated and kept."""
version = SANITIZER_VERSION * 1000 + translation.PIPELINE_VERSION
cached = await db.get(MessageTranslation, (message.id, target, allow_remote))
if cached is not None and cached.sanitizer_v == version:
return cached.html, cached.source
try:
out = await translation.translate_html(
fragment, language or "auto", target, names=await _names(db, user, message)
)
except translation.TranslationError as exc:
raise_api_error(503, "translation_failed", str(exc))
# The translation comes back as HTML: cleaned like any HTML we show.
clean = sanitize_html(
out, message.id, allow_remote_images=allow_remote, block_trackers=block
).html
source = language or "auto"
stmt = insert(MessageTranslation).values(
message_id=message.id,
target=target,
images=allow_remote,
source=source,
html=clean,
sanitizer_v=version,
)
await db.execute(
stmt.on_conflict_do_update(
index_elements=["message_id", "target", "images"],
set_={"html": clean, "source": source, "sanitizer_v": version},
)
)
await db.commit()
return clean, source
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
+10
View File
@@ -27,6 +27,8 @@ class ThreadSummary(BaseModel):
has_attachments: bool
pinned: bool
last_message_at: dt.datetime
# Set while the thread is snoozed: when it comes back to the Inbox.
snoozed_until: dt.datetime | None = None
class ThreadListOut(BaseModel):
@@ -64,6 +66,13 @@ class MessageEnvelope(BaseModel):
snippet: str
# Other accounts this same delivery also landed in (collapsed duplicates).
also_in_account_ids: list[uuid.UUID] = []
# Bulk mail: the Subscriptions group it belongs to, how it can be left
# (one_click | mailto | web), and whether the user already did.
subscription_key: str | None = None
unsubscribe_via: str | None = None
unsubscribed: bool = False
# Its language, once found (the first time it is shown): "" unknown.
content_language: str = ""
class ThreadDetail(BaseModel):
@@ -72,6 +81,7 @@ class ThreadDetail(BaseModel):
order: str # "asc" | "desc"
pinned: bool
messages: list[MessageEnvelope] # always ASC (SPEC §9.4)
snoozed_until: dt.datetime | None = None
class FlagChangeRequest(BaseModel):
+32
View File
@@ -27,6 +27,7 @@ _ALLOWED_INVITE_FROM = {"contacts", "anyone"}
# "auto", or a LanguageTool language code such as "pl-PL", "en-GB", "de".
_LANG_CODE = re.compile(r"auto|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*")
_UI_LANGUAGE = re.compile(r"[a-z]{2,3}(-[A-Za-z]{2,4})?")
class ReminderPref(BaseModel):
@@ -52,6 +53,16 @@ class SettingsOut(BaseModel):
# ("auto" detects it per message).
spellcheck_enabled: bool = True
spellcheck_language: str = "auto"
# Gmail-style keyboard shortcuts in the mail view (Settings → Keyboard shortcuts).
shortcuts_enabled: bool = True
# Remove tracking pixels from mail (Settings → Privacy).
block_trackers: bool = True
# Grey sentence endings in the compose window, taken with Tab.
completion_enabled: bool = True
# Interface language ("en", "pl", …); "" until chosen — the web app then
# picks one from the browser on first sign-in and saves it here.
ui_language: str = ""
# Names and words email translation leaves as they are.
class SettingsPatch(BaseModel):
@@ -68,6 +79,10 @@ class SettingsPatch(BaseModel):
invitation_file: bool | None = None
spellcheck_enabled: bool | None = None
spellcheck_language: str | None = None
shortcuts_enabled: bool | None = None
block_trackers: bool | None = None
completion_enabled: bool | None = None
ui_language: str | None = None
class QuotaOut(BaseModel):
@@ -82,6 +97,11 @@ def allowed_image_senders(user: User) -> set[str]:
return set()
def blocks_trackers(user: User) -> bool:
"""Whether tracking pixels are taken out of this user's mail (on unless switched off)."""
return bool(user.settings.get("block_trackers", True))
def add_allowed_image_sender(user: User, email: str) -> None:
current = sorted(allowed_image_senders(user))
if email.lower() not in current:
@@ -127,6 +147,10 @@ def _settings_out(user: User) -> SettingsOut:
allowed_image_senders=sorted(allowed_image_senders(user)),
spellcheck_enabled=bool(s.get("spellcheck_enabled", True)),
spellcheck_language=str(s.get("spellcheck_language", "auto")),
shortcuts_enabled=bool(s.get("shortcuts_enabled", True)),
block_trackers=blocks_trackers(user),
completion_enabled=bool(s.get("completion_enabled", True)),
ui_language=str(s.get("ui_language", "")),
)
@@ -175,6 +199,14 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
updates["spellcheck_enabled"] = body.spellcheck_enabled
if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language):
updates["spellcheck_language"] = body.spellcheck_language
if body.shortcuts_enabled is not None:
updates["shortcuts_enabled"] = body.shortcuts_enabled
if body.block_trackers is not None:
updates["block_trackers"] = body.block_trackers
if body.completion_enabled is not None:
updates["completion_enabled"] = body.completion_enabled
if body.ui_language is not None and _UI_LANGUAGE.fullmatch(body.ui_language):
updates["ui_language"] = body.ui_language
user.settings = updates
await db.commit()
return _settings_out(user)
+76
View File
@@ -0,0 +1,76 @@
"""Snooze endpoints: hide a conversation from the Inbox until a set time."""
from __future__ import annotations
import datetime as dt
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import snooze
from app.api.errors import raise_api_error
from app.db import get_session
from app.events import get_event_bus
from app.identity.deps import CurrentUser, require_csrf
from app.models import Thread
router = APIRouter(prefix="/threads", tags=["mail"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
class SnoozeIn(BaseModel):
until: dt.datetime
class SnoozeOut(BaseModel):
thread_id: uuid.UUID
snoozed_until: dt.datetime | None
async def _thread(db: AsyncSession, user_id: uuid.UUID, thread_id: uuid.UUID) -> Thread:
thread = await db.scalar(
select(Thread).where(Thread.id == thread_id, Thread.user_id == user_id)
)
if thread is None:
raise_api_error(404, "not_found", "thread not found")
return thread
async def _announce(user_id: uuid.UUID, thread_id: uuid.UUID) -> None:
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
)
@router.post("/{thread_id}/snooze", response_model=SnoozeOut)
async def snooze_thread(
thread_id: uuid.UUID, body: SnoozeIn, user: CurrentUser, db: DbSession
) -> SnoozeOut:
until = body.until
if until.tzinfo is None:
raise_api_error(400, "invalid_time", "the time needs a time zone")
now = dt.datetime.now(dt.UTC)
if until <= now:
raise_api_error(400, "invalid_time", "pick a time in the future")
if until > now + snooze.MAX_SNOOZE:
raise_api_error(400, "invalid_time", "snooze for at most a year")
thread = await _thread(db, user.id, thread_id)
snooze.snooze(thread, until, now)
await db.commit()
await _announce(user.id, thread_id)
return SnoozeOut(thread_id=thread_id, snoozed_until=thread.snoozed_until)
@router.post("/{thread_id}/unsnooze", response_model=SnoozeOut)
async def unsnooze_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> SnoozeOut:
thread = await _thread(db, user.id, thread_id)
if thread.snoozed_until is not None:
snooze.wake(thread, dt.datetime.now(dt.UTC))
await db.commit()
await _announce(user.id, thread_id)
return SnoozeOut(thread_id=thread_id, snoozed_until=None)
+372
View File
@@ -0,0 +1,372 @@
"""Subscriptions page: bulk senders mailing the user (app/subscriptions.py)."""
from __future__ import annotations
import datetime as dt
import logging
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import case, func, literal, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement
from app import subscriptions as subs
from app.accounts.ssrf import guarded_request
from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import Mailbox, Message, SubscriptionState
from app.outbox.schemas import DraftIn, DraftPayload
from app.outbox.service import DraftError, create_outbox, upsert_draft
from app.queue import enqueue_job
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/subscriptions", tags=["mail"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
# RFC 8058 senders should stop within two days of a one-click request.
GRACE = dt.timedelta(days=2)
# Not counted: mail the user sent, and mail already thrown away (Trash, Spam) —
# a sender whose mail was all deleted drops off the list.
_LEFT_OUT = ("sent", "drafts", "trash", "junk")
class SubscriptionOut(BaseModel):
key: str
name: str
email: str
list_id: str | None
total: int
unread: int
last_30_days: int
per_month: float
first_at: dt.datetime
last_at: dt.datetime
last_subject: str
one_click: bool
has_mailto: bool
has_web: bool
unsubscribed_at: dt.datetime | None
method: str | None
still_sending: bool
class SubscriptionListOut(BaseModel):
subscriptions: list[SubscriptionOut] # one page
total: int # senders in all
pending: int # messages whose headers are still being read
class KeyIn(BaseModel):
key: str
class UnsubscribeIn(KeyIn):
# From the reading pane: this message's own header, wherever it is filed
# (Spam included, which the page's groups leave out).
message_id: uuid.UUID | None = None
class UnsubscribeOut(BaseModel):
status: str # done | open
method: str # one_click | mailto | web
url: str | None = None
def _sender() -> ColumnElement[Any]:
return func.lower(func.coalesce(Message.from_addr[0]["email"].astext, ""))
def _key() -> ColumnElement[Any]:
return case(
(Message.list_id.isnot(None), literal("list:") + Message.list_id),
else_=literal("from:") + _sender(),
)
def _bulk(user_id: uuid.UUID) -> list[ColumnElement[bool]]:
return [
Message.user_id == user_id,
Message.list_unsubscribe.isnot(None),
Message.list_unsubscribe != "",
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_LEFT_OUT)),
]
async def _start_backfill(user_id: uuid.UUID) -> None:
# One job per user at a time: arq refuses a second while the first exists.
await enqueue_job("backfill_list_headers_job", str(user_id), _job_id=f"listhdr:{user_id}")
@router.get("", response_model=SubscriptionListOut)
async def list_subscriptions(
user: CurrentUser,
db: DbSession,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
order: str = Query("desc"), # desc: heard from most recently first | asc: longest ago
) -> SubscriptionListOut:
"""One page of senders, by when their latest mail came."""
pending = await subs.pending_count(db, user.id)
if pending:
await _start_backfill(user.id)
now = dt.datetime.now(dt.UTC)
key = _key().label("key")
seen = Message.flags.contains(["\\Seen"])
groups = (
select(
key,
func.count().label("total"),
func.count().filter(~seen).label("unread"),
func.count()
.filter(Message.internal_date > now - dt.timedelta(days=30))
.label("recent"),
func.min(Message.internal_date).label("first_at"),
func.max(Message.internal_date).label("last_at"),
)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id))
.group_by(key)
)
total = await db.scalar(select(func.count()).select_from(groups.subquery())) or 0
agg_rows = (
await db.execute(
groups.order_by(
func.max(Message.internal_date).asc()
if order == "asc"
else func.max(Message.internal_date).desc(),
key,
)
.offset(offset)
.limit(limit)
)
).all()
page_keys = [r.key for r in agg_rows]
if not page_keys:
return SubscriptionListOut(subscriptions=[], total=total, pending=pending)
in_page = _key().in_(page_keys)
latest_rows = (
await db.execute(
select(
key,
Message.from_addr,
Message.subject,
Message.list_id,
Message.list_unsubscribe,
Message.list_unsubscribe_post,
)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id), in_page)
.distinct(key)
.order_by(key, Message.internal_date.desc())
)
).all()
latest = {r.key: r for r in latest_rows}
states = {
s.key: s
for s in await db.scalars(
select(SubscriptionState).where(
SubscriptionState.user_id == user.id, SubscriptionState.key.in_(page_keys)
)
)
}
# Senders still mailing after the grace period, for the whole page at once.
still_sending = set(
await db.scalars(
select(key)
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.join(
SubscriptionState,
(SubscriptionState.user_id == user.id) & (SubscriptionState.key == _key()),
)
.where(
*_bulk(user.id),
in_page,
Message.internal_date > SubscriptionState.unsubscribed_at + GRACE,
)
.distinct()
)
)
out: list[SubscriptionOut] = []
for row in agg_rows:
last = latest.get(row.key)
if last is None:
continue
sender = last.from_addr[0] if last.from_addr else {}
targets = subs.unsubscribe_targets(last.list_unsubscribe or "")
state = states.get(row.key)
months = max(1.0, (row.last_at - row.first_at).days / 30.44)
out.append(
SubscriptionOut(
key=row.key,
name=sender.get("name") or sender.get("email", ""),
email=sender.get("email", ""),
list_id=last.list_id,
total=row.total,
unread=row.unread,
last_30_days=row.recent,
per_month=round(row.total / months, 1),
first_at=row.first_at,
last_at=row.last_at,
last_subject=last.subject,
one_click=bool(last.list_unsubscribe_post and targets.https),
has_mailto=targets.mailto is not None,
has_web=targets.https is not None,
unsubscribed_at=state.unsubscribed_at if state else None,
method=state.method if state else None,
still_sending=row.key in still_sending,
)
)
return SubscriptionListOut(subscriptions=out, total=total, pending=pending)
@router.get("/threads", response_model=list[uuid.UUID])
async def subscription_threads(
user: CurrentUser, db: DbSession, key: str = Query(...), limit: int = Query(50, le=200)
) -> list[uuid.UUID]:
"""The group's conversations, newest first (for the reading pane)."""
rows = await db.execute(
select(Message.thread_id, func.max(Message.internal_date).label("at"))
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id), _key() == key, Message.thread_id.isnot(None))
.group_by(Message.thread_id)
.order_by(func.max(Message.internal_date).desc())
.limit(limit)
)
return [r.thread_id for r in rows if r.thread_id is not None]
async def _latest_with_header(db: AsyncSession, user_id: uuid.UUID, key: str) -> Message:
message = await db.scalar(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user_id), _key() == key)
.order_by(Message.internal_date.desc())
.limit(1)
)
if message is None:
raise_api_error(404, "not_found", "no mail from this sender")
return message
async def _record(db: AsyncSession, user_id: uuid.UUID, key: str, method: str) -> None:
stmt = insert(SubscriptionState).values(user_id=user_id, key=key, method=method)
await db.execute(
stmt.on_conflict_do_update(
index_elements=["user_id", "key"],
set_={"method": method, "unsubscribed_at": func.now()},
)
)
await db.commit()
async def _one_click(url: str) -> bool:
try:
resp = await guarded_request(
"POST",
url,
headers={"Content-Type": "application/x-www-form-urlencoded"},
content="List-Unsubscribe=One-Click",
max_bytes=256 * 1024,
)
except Exception as exc: # SSRF refusal, network, TLS: any failure tries the next way
logger.info("one-click unsubscribe failed: %s", exc)
return False
return 200 <= resp.status < 400
async def _send_mailto(
db: AsyncSession, user_id: uuid.UUID, message: Message, targets: subs.UnsubscribeTargets
) -> bool:
assert targets.mailto is not None
body = DraftIn(
account_id=message.account_id,
kind="new",
payload=DraftPayload(
to=[targets.mailto], subject=targets.mailto_subject, text=targets.mailto_body
),
)
try:
draft = await upsert_draft(db, user_id=user_id, body=body, draft_id=None)
except DraftError:
return False
outbox = await create_outbox(db, user_id=user_id, draft=draft)
await db.commit()
await enqueue_job(
"send_job", str(outbox.id), _defer_by=dt.timedelta(seconds=get_settings().undo_seconds)
)
return True
@router.post("/unsubscribe", response_model=UnsubscribeOut)
async def unsubscribe(body: UnsubscribeIn, user: CurrentUser, db: DbSession) -> UnsubscribeOut:
"""Unsubscribe from a sender: one-click if offered, else by mail, else the web page."""
if body.message_id is not None:
found = await db.scalar(
select(Message).where(
Message.id == body.message_id,
Message.user_id == user.id,
Message.list_unsubscribe.isnot(None),
Message.list_unsubscribe != "",
)
)
if found is None:
raise_api_error(404, "not_found", "this message offers no way to unsubscribe")
message = found
else:
message = await _latest_with_header(db, user.id, body.key)
targets = subs.unsubscribe_targets(message.list_unsubscribe or "")
if message.list_unsubscribe_post and targets.https and await _one_click(targets.https):
await _record(db, user.id, body.key, "one_click")
return UnsubscribeOut(status="done", method="one_click")
if targets.mailto and await _send_mailto(db, user.id, message, targets):
await _record(db, user.id, body.key, "mailto")
return UnsubscribeOut(status="done", method="mailto")
if targets.https:
# The sender wants it done on their page: the user opens it.
await _record(db, user.id, body.key, "web")
return UnsubscribeOut(status="open", method="web", url=targets.https)
raise_api_error(409, "no_unsubscribe", "this sender offers no way to unsubscribe")
@router.post("/trash")
async def trash_all(body: KeyIn, user: CurrentUser, db: DbSession) -> dict[str, int]:
"""Move every conversation from this sender to Trash.
Only conversations made up entirely of this sender's mail: one where the
user replied, or someone else wrote, stays where it is.
"""
rows = (
await db.execute(
select(Message.thread_id, _key().label("key"), Mailbox.special_use)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user.id,
Message.thread_id.in_(
select(Message.thread_id)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id), _key() == body.key)
),
)
)
).all()
own: dict[uuid.UUID, bool] = {}
for thread_id, key, special in rows:
if thread_id is None or special == "trash":
continue
own[thread_id] = own.get(thread_id, True) and key == body.key
moved = [tid for tid, only_theirs in own.items() if only_theirs]
for tid in moved:
await enqueue_job("move_thread_to_folder_job", str(tid), "trash")
return {"moved": len(moved)}
+129
View File
@@ -0,0 +1,129 @@
"""Templates: saved replies, inserted from the compose window's More menu."""
from __future__ import annotations
import datetime as dt
import uuid
from typing import Annotated
import nh3
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import func, 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 MailTemplate
router = APIRouter(prefix="/templates", tags=["compose"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
MAX_TEMPLATES = 200
MAX_BODY_BYTES = 256 * 1024
# What the compose editor writes. A template goes back into the editor as HTML,
# so it is cleaned like any HTML the app renders: no scripts, no handlers.
_TAGS = {
"a", "b", "blockquote", "br", "code", "div", "em", "font", "h1", "h2", "h3",
"hr", "i", "img", "li", "ol", "p", "pre", "s", "span", "strike", "strong",
"sub", "sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul",
} # fmt: skip
_GENERIC = {"style", "class", "dir", "align"}
_SPECIFIC = {
"a": {"href", "title"},
"img": {"src", "alt", "width", "height"},
"font": {"color", "face", "size"},
"td": {"colspan", "rowspan"},
"th": {"colspan", "rowspan"},
}
# nh3 takes no wildcard: the generic attributes go onto every tag.
_ATTRS = {tag: _GENERIC | _SPECIFIC.get(tag, set()) for tag in _TAGS}
class TemplateOut(BaseModel):
id: uuid.UUID
name: str
subject: str
body_html: str
updated_at: dt.datetime
class TemplateIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
subject: str = Field(default="", max_length=500)
body_html: str = ""
def _out(t: MailTemplate) -> TemplateOut:
return TemplateOut(
id=t.id, name=t.name, subject=t.subject, body_html=t.body_html, updated_at=t.updated_at
)
def clean_body(html: str) -> str:
return nh3.clean(
html,
tags=_TAGS,
attributes=_ATTRS,
url_schemes={"http", "https", "mailto", "data"},
link_rel="noopener noreferrer",
)
@router.get("", response_model=list[TemplateOut])
async def list_templates(user: CurrentUser, db: DbSession) -> list[TemplateOut]:
rows = await db.scalars(
select(MailTemplate)
.where(MailTemplate.user_id == user.id)
.order_by(func.lower(MailTemplate.name))
)
return [_out(t) for t in rows]
@router.post("", response_model=TemplateOut)
async def save_template(body: TemplateIn, user: CurrentUser, db: DbSession) -> TemplateOut:
"""Save a template. Saving under a name that exists replaces that template."""
name = " ".join(body.name.split())
if not name:
raise_api_error(400, "invalid", "a template needs a name")
if len(body.body_html.encode()) > MAX_BODY_BYTES:
raise_api_error(400, "too_large", "the template is too large")
html = clean_body(body.body_html)
subject = " ".join(body.subject.split())
existing = await db.scalar(
select(MailTemplate).where(
MailTemplate.user_id == user.id, func.lower(MailTemplate.name) == name.lower()
)
)
if existing is not None:
existing.name, existing.subject, existing.body_html = name, subject, html
existing.updated_at = dt.datetime.now(dt.UTC)
saved = existing
else:
count = await db.scalar(
select(func.count()).select_from(MailTemplate).where(MailTemplate.user_id == user.id)
)
if (count or 0) >= MAX_TEMPLATES:
raise_api_error(409, "too_many", f"at most {MAX_TEMPLATES} templates")
saved = MailTemplate(user_id=user.id, name=name, subject=subject, body_html=html)
db.add(saved)
await db.commit()
await db.refresh(saved)
return _out(saved)
@router.delete("/{template_id}")
async def delete_template(
template_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> dict[str, str]:
row = await db.scalar(
select(MailTemplate).where(MailTemplate.id == template_id, MailTemplate.user_id == user.id)
)
if row is None:
raise_api_error(404, "not_found", "template not found")
await db.delete(row)
await db.commit()
return {"status": "deleted"}
+73 -7
View File
@@ -4,13 +4,14 @@ from __future__ import annotations
import uuid
from collections.abc import Collection
from typing import Annotated
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Text, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement
from app import subscriptions as subs
from app.api.errors import raise_api_error
from app.api.schemas import (
Address,
@@ -19,10 +20,10 @@ from app.api.schemas import (
ThreadListOut,
ThreadSummary,
)
from app.api.settings import allowed_image_senders
from app.api.settings import allowed_image_senders, blocks_trackers
from app.db import get_session
from app.identity.deps import CurrentUser
from app.models import Mailbox, Message, Thread, User
from app.models import BodyPart, Mailbox, Message, SubscriptionState, Thread, User
router = APIRouter(prefix="/threads", tags=["mail"])
@@ -50,6 +51,7 @@ def _summary(
has_attachments=thread.has_attachments,
pinned=thread.pinned,
last_message_at=thread.last_message_at,
snoozed_until=thread.snoozed_until,
)
@@ -74,6 +76,7 @@ def _envelope(msg: Message, allowed_senders: Collection[str] = ()) -> MessageEnv
images_allowed=bool(sender) and sender in allowed_senders,
size_bytes=msg.size_bytes,
snippet=msg.snippet,
content_language=msg.content_language or "",
)
@@ -130,6 +133,10 @@ async def list_threads(
.where(Message.user_id == user.id, Mailbox.special_use == special)
)
conditions.append(Thread.id.in_(in_folder))
if special == "inbox":
conditions.append(Thread.snoozed_until.is_(None)) # back when it wakes
elif view == "snoozed":
conditions.append(Thread.snoozed_until.isnot(None))
elif view != "unified":
raise_api_error(400, "bad_view", "unsupported view")
@@ -144,11 +151,20 @@ async def list_threads(
)
) or 0
order_by = (
(Thread.last_message_at.asc(), Thread.id.asc())
if order == "asc"
else (Thread.last_message_at.desc(), Thread.id.desc())
# A thread woken from snooze sorts as if it had just arrived.
sort_at = func.greatest(
Thread.last_message_at, func.coalesce(Thread.unsnoozed_at, Thread.last_message_at)
)
if view == "snoozed":
# Soonest back first, whatever the list order.
order_by: tuple[ColumnElement[Any], ...] = (
Thread.snoozed_until.asc(),
Thread.id.asc(),
)
elif order == "asc":
order_by = (sort_at.asc(), Thread.id.asc())
else:
order_by = (sort_at.desc(), Thread.id.desc())
stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit)
threads = list(await db.scalars(stmt))
@@ -177,6 +193,41 @@ async def list_threads(
)
async def _mark_subscriptions(
db: AsyncSession,
user_id: uuid.UUID,
messages: list[Message],
envelopes: list[MessageEnvelope],
) -> None:
"""Tell the reading pane which messages can be unsubscribed from, and how."""
keys: dict[uuid.UUID, str] = {}
for m, env in zip(messages, envelopes, strict=True):
if not m.list_unsubscribe:
continue
targets = subs.unsubscribe_targets(m.list_unsubscribe)
if m.list_unsubscribe_post and targets.https:
env.unsubscribe_via = "one_click"
elif targets.mailto:
env.unsubscribe_via = "mailto"
elif targets.https:
env.unsubscribe_via = "web"
else:
continue
sender = m.from_addr[0].get("email", "") if m.from_addr else ""
env.subscription_key = keys[m.id] = subs.group_key(m.list_id, sender)
if keys:
done = set(
await db.scalars(
select(SubscriptionState.key).where(
SubscriptionState.user_id == user_id,
SubscriptionState.key.in_(set(keys.values())),
)
)
)
for env in envelopes:
env.unsubscribed = env.subscription_key in done
def _visible_in(current_special: str | None) -> ColumnElement[bool]:
"""Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam
show only their own messages; every other view hides them."""
@@ -281,6 +332,20 @@ async def get_thread(
env = _envelope(m, allowed_senders)
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
envelopes.append(env)
await _mark_subscriptions(db, user.id, ordered, envelopes)
if not blocks_trackers(user) and ordered:
# Trackers left in are remote images: offer "Load images" for them too.
with_trackers = set(
await db.scalars(
select(BodyPart.message_id).where(
BodyPart.message_id.in_([m.id for m in ordered]),
BodyPart.kind == "html_sanitized",
BodyPart.trackers_blocked > 0,
)
)
)
for env in envelopes:
env.has_remote_images = env.has_remote_images or env.id in with_trackers
order = "desc" if sort == "desc" else "asc"
return ThreadDetail(
@@ -289,4 +354,5 @@ async def get_thread(
order=order,
pinned=thread.pinned,
messages=envelopes,
snoozed_until=thread.snoozed_until,
)
+30
View File
@@ -0,0 +1,30 @@
"""What email translation offers (app/translation.py), for the reading pane."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from app import translation
from app.identity.deps import CurrentUser
router = APIRouter(prefix="/translation", tags=["mail"])
class LanguageOut(BaseModel):
code: str
name: str
class TranslationInfoOut(BaseModel):
available: bool
languages: list[LanguageOut]
@router.get("/languages", response_model=TranslationInfoOut)
async def translation_languages(user: CurrentUser) -> TranslationInfoOut:
found = await translation.languages()
return TranslationInfoOut(
available=bool(found),
languages=[LanguageOut(code=x.code, name=x.name) for x in found],
)
+391
View File
@@ -0,0 +1,391 @@
"""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)
+3
View File
@@ -71,6 +71,9 @@ class Settings(BaseSettings):
# Spelling and grammar checking in the compose window (LanguageTool server,
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
# Email translation: a LibreTranslate server (app/translation.py), e.g.
# http://libretranslate:5000. Empty: translation is off.
libretranslate_url: str = Field(default="", alias="LIBRETRANSLATE_URL")
# New passwords are checked against known data breaches (Have I Been Pwned's
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
+6
View File
@@ -25,6 +25,7 @@ from app.indexer.sanitize import sanitize_html
from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
from app.providers.base import RemoteMessage
from app.search.base import SearchDoc
from app.subscriptions import read_list_headers
from app.threading_.normalize import synthesize_message_id
from app.threading_.threader import thread_message
@@ -167,6 +168,7 @@ async def persist_message(
else:
sha = rawstore.put_cached(user_id, remote.raw)
parsed = await _parse_bounded(user_id, sha, remote.raw)
list_headers = read_list_headers(remote.raw) # for the Subscriptions page
message_id_hdr = await _resolve_message_id(
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
@@ -207,6 +209,9 @@ async def persist_message(
has_attachments=parsed.has_attachments,
snippet=parsed.snippet,
blob_sha256=sha,
list_id=list_headers.list_id,
list_unsubscribe=list_headers.unsubscribe,
list_unsubscribe_post=list_headers.one_click,
)
session.add(message)
await session.flush()
@@ -224,6 +229,7 @@ async def persist_message(
kind="html_sanitized",
content=result.html,
sanitizer_v=result.sanitizer_v,
trackers_blocked=result.trackers_blocked,
)
)
+103 -2
View File
@@ -19,7 +19,10 @@ import nh3
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
# v5: this server's own inline-image URLs quoted in a message load from here.
# v6: links keep only http(s) and mailto (no data: pages).
SANITIZER_VERSION = 6
# v7: tracking pixels are removed (and counted), even when images are allowed.
# v8: CSS sizes in other units (in, em, %, pt) no longer read as pixels — v7
# took 0.31in signature logos for trackers.
SANITIZER_VERSION = 8
# This server's inline-image URL, however it ended up written: "/api/…",
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
@@ -154,6 +157,94 @@ class SanitizeResult:
html: str
has_remote_images: bool
sanitizer_v: int
trackers_blocked: int = 0
# --- Tracking pixels ---------------------------------------------------------------
# A remote image that is invisible (1×1, or hidden by CSS) has no use but to tell
# the sender the mail was opened, and by whom. A few well-known open-tracking URL
# shapes are caught whatever their size. Matching runs on the raw HTML before nh3:
# a miss only leaves an image in, and nh3 still cleans whatever remains.
_IMG_TAG = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
_ATTR = re.compile(r"""([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+)""")
_CSS_HIDDEN = re.compile(
r"display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d]*[1-9])",
re.IGNORECASE,
)
_TRACKER_URL = re.compile(
r"//[^/]*mailtrack\.io/"
r"|/track/open\b"
r"|/wf/open\b"
r"|/open\.(?:php|aspx|gif)\b"
r"|[?&]tracking[_-]?pixel=",
re.IGNORECASE,
)
_PX = re.compile(r"\s*(\d+(?:\.\d*)?|\.\d+)\s*(px)?\s*$", re.IGNORECASE)
def _px(value: str | None) -> float | None:
"""A size in pixels ("1", "1px"), or None for anything else: other units
(0.31in is a 30-pixel signature logo), auto, %, or malformed (".", "1.2.3")."""
if value is None:
return None
match = _PX.match(value)
return float(match.group(1)) if match else None
def _tiny(value: str | None) -> bool:
px = _px(value)
return px is not None and px <= 2
def _css(style: str) -> dict[str, str]:
"""An inline style's declarations, lower-cased, without !important."""
out: dict[str, str] = {}
for decl in style.split(";"):
name, _, value = decl.partition(":")
value = value.lower().replace("!important", "").strip()
if name.strip() and value:
out[name.strip().lower()] = value
return out
def is_tracking_pixel(tag: str) -> bool:
"""Whether an ``<img …>`` tag is a remote open-tracking pixel."""
attrs = {name.lower(): value.strip("\"'") for name, value in _ATTR.findall(tag)}
src = attrs.get("src", "").strip()
if not src.lower().startswith(("http:", "https:", "//")):
return False # inline (cid:/data:) images can't report anything
if _TRACKER_URL.search(src):
return True
style = attrs.get("style", "")
if _CSS_HIDDEN.search(style):
return True
# The size the browser draws: a style width/height overrides the attribute
# (and "auto", % or another unit is no tiny size); a max-width/max-height
# of a pixel or two caps it; height "auto" follows the width.
css = _css(style)
wide = _tiny(css["width"]) if "width" in css else _tiny(attrs.get("width"))
tall = _tiny(css["height"]) if "height" in css else _tiny(attrs.get("height"))
wide = wide or _tiny(css.get("max-width"))
tall = tall or _tiny(css.get("max-height"))
if css.get("height") == "auto":
tall = wide
return wide and tall
def strip_tracking_pixels(html: str) -> tuple[str, int]:
"""Remove tracking pixels from raw HTML; returns the HTML and how many."""
count = 0
def repl(match: re.Match[str]) -> str:
nonlocal count
if is_tracking_pixel(match.group(0)):
count += 1
return ""
return match.group(0)
return _IMG_TAG.sub(repl, html), count
def _sanitize_style(value: str, allow_remote: bool) -> str | None:
@@ -188,15 +279,24 @@ def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, boo
def sanitize_html(
html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False
html: str,
message_id: uuid.UUID,
*,
allow_remote_images: bool = False,
block_trackers: bool = True,
) -> SanitizeResult:
"""Sanitize ``html`` for the message ``message_id``.
Returns the safe HTML, whether any remote images were blocked, and the
sanitizer version. Pass ``allow_remote_images=True`` after the user approves
a sender to render remote images and CSS backgrounds (SPEC §9.4).
``block_trackers=False`` (Settings → Privacy) leaves tracking pixels in; they
are remote images like any other then.
"""
state = {"has_remote": False}
trackers = 0
if block_trackers:
html, trackers = strip_tracking_pixels(html)
def attribute_filter(tag: str, attr: str, value: str) -> str | None:
if attr == "style":
@@ -247,4 +347,5 @@ def sanitize_html(
html=cleaned,
has_remote_images=state["has_remote"],
sanitizer_v=SANITIZER_VERSION,
trackers_blocked=trackers,
)
+91
View File
@@ -199,6 +199,12 @@ class Thread(Base):
has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
# Snooze: out of the Inbox until this time (NULL when not snoozed). A message
# that arrives after ``snoozed_at`` wakes it early; ``unsnoozed_at`` (when it
# last woke) puts it back at the top of the Inbox.
snoozed_until: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
snoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
unsnoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
class Message(Base):
@@ -263,6 +269,17 @@ class Message(Base):
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
body_indexed: Mapped[str | None] = mapped_column(TSVECTOR)
# Mailing-list headers, for the Subscriptions page. ``list_unsubscribe`` is
# NULL until read (a backfill job fills older mail), '' when there is none.
list_id: Mapped[str | None] = mapped_column(Text)
list_unsubscribe: Mapped[str | None] = mapped_column(Text)
# RFC 8058: the sender takes a one-click unsubscribe POST.
list_unsubscribe_post: Mapped[bool] = mapped_column(
nullable=False, server_default=text("false")
)
# The language of its text (app/translation.py), found the first time it is
# shown: NULL not looked at yet, '' could not tell.
content_language: Mapped[str | None] = mapped_column(Text)
class BodyPart(Base):
@@ -276,6 +293,8 @@ class BodyPart(Base):
content: Mapped[str] = mapped_column(Text, nullable=False)
# SPEC §10.4: re-sanitize on render if the sanitizer version changed.
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
# Tracking pixels taken out of this HTML (shown as "N trackers blocked").
trackers_blocked: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
class Attachment(Base):
@@ -404,6 +423,78 @@ class Invitation(Base):
)
class SubscriptionState(Base):
"""What a user did about a bulk sender on the Subscriptions page."""
__tablename__ = "subscription_states"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
# The sender group: "list:<List-Id>" or "from:<address>".
key: Mapped[str] = mapped_column(Text, primary_key=True)
unsubscribed_at: Mapped[dt.datetime] = _now()
method: Mapped[str] = mapped_column(Text, nullable=False) # one_click | mailto | web
class MailTemplate(Base):
"""A saved reply, inserted from the compose window."""
__tablename__ = "mail_templates"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(Text, nullable=False)
subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
body_html: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
created_at: Mapped[dt.datetime] = _now()
updated_at: Mapped[dt.datetime] = _now()
class MessageTranslation(Base):
"""A message's HTML translated into a language (app/translation.py), kept so
it is translated once. ``images``: with remote images shown or not."""
__tablename__ = "message_translations"
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), primary_key=True
)
target: Mapped[str] = mapped_column(Text, primary_key=True)
images: Mapped[bool] = mapped_column(primary_key=True)
source: Mapped[str] = mapped_column(Text, nullable=False)
html: Mapped[str] = mapped_column(Text, nullable=False)
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[dt.datetime] = _now()
class CompletionPhrase(Base):
"""Sentence completion (app/completion.py): after these words, the user
usually writes this — seen ``count`` times in their sent mail."""
__tablename__ = "completion_phrases"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
context: Mapped[str] = mapped_column(Text, primary_key=True) # normalized words
continuation: Mapped[str] = mapped_column(Text, primary_key=True) # as written
count: Mapped[int] = mapped_column(Integer, nullable=False)
class CompletionIndexState(Base):
"""When a user's completion index was last built."""
__tablename__ = "completion_index_state"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
built_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push)."""
+8 -1
View File
@@ -21,7 +21,14 @@ QUEUE_INTERACTIVE = "interactive"
QUEUE_BULK = "bulk"
# Jobs default to the interactive queue; only bulk backfills go to the bulk queue.
_BULK_JOBS = frozenset({"sync_bootstrap_account", "dedupe_existing_job"})
_BULK_JOBS = frozenset(
{
"sync_bootstrap_account",
"dedupe_existing_job",
"backfill_list_headers_job",
"build_index_job",
}
)
def queue_for(job_name: str) -> str:
+92
View File
@@ -0,0 +1,92 @@
"""Snooze: a conversation leaves the Inbox until a chosen time, then comes back.
Kept in this database only (the mail server knows nothing of it): a snoozed
thread is hidden from the Inbox view and listed under Snoozed. The wake job,
run every minute, brings back threads whose time has come — or that got a new
message meanwhile, as Gmail does — at the top of the Inbox, the newest message
marked unread.
"""
from __future__ import annotations
import datetime as dt
import logging
import uuid
from typing import Any
from sqlalchemy import or_, select
from app.db import session_scope
from app.events import get_event_bus
from app.models import Mailbox, Message, Thread
from app.queue import enqueue_job
from app.sync.actions import apply_flags_to_messages
logger = logging.getLogger(__name__)
MAX_SNOOZE = dt.timedelta(days=366)
_SEEN = frozenset({"\\Seen"})
def snooze(thread: Thread, until: dt.datetime, now: dt.datetime) -> None:
thread.snoozed_until = until
thread.snoozed_at = now
def wake(thread: Thread, now: dt.datetime) -> None:
thread.snoozed_until = None
thread.snoozed_at = None
thread.unsnoozed_at = now
async def wake_due_job(ctx: dict[str, Any]) -> int:
"""Cron (every minute): wake snoozed threads that are due. Returns how many."""
now = dt.datetime.now(dt.UTC)
woken: list[tuple[uuid.UUID, uuid.UUID]] = []
unread: list[tuple[uuid.UUID, Message]] = []
async with session_scope() as session:
due = list(
await session.scalars(
select(Thread)
.where(
Thread.snoozed_until.isnot(None),
or_(Thread.snoozed_until <= now, Thread.last_message_at > Thread.snoozed_at),
)
.with_for_update(skip_locked=True)
)
)
for thread in due:
on_time = thread.snoozed_until is not None and thread.snoozed_until <= now
wake(thread, now)
woken.append((thread.user_id, thread.id))
if not on_time:
continue # a new message woke it: that message is unread already
# Back as new: the newest Inbox message becomes unread again.
newest = await session.scalar(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.thread_id == thread.id,
Message.user_id == thread.user_id,
Mailbox.special_use == "inbox",
)
.order_by(Message.internal_date.desc())
.limit(1)
)
if newest is not None and "\\Seen" in newest.flags:
await apply_flags_to_messages(session, [newest], frozenset(), _SEEN)
unread.append((thread.user_id, newest))
await session.commit()
bus = get_event_bus()
for user_id, message in unread:
await enqueue_job("push_flags_job", str(message.id), [], ["\\Seen"])
await bus.publish(
"flags.changed",
{"user_id": str(user_id), "message_id": str(message.id), "flags": list(message.flags)},
)
for user_id, thread_id in woken:
await bus.publish("thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)})
if woken:
logger.info("woke %d snoozed thread(s)", len(woken))
return len(woken)
+151
View File
@@ -0,0 +1,151 @@
"""Subscriptions: the bulk senders mailing a user, and unsubscribing from them.
No service records what a user signed up for, so the list is worked out from
received mail, as Gmail does: a message is a subscription's when it carries a
``List-Unsubscribe`` header (required of bulk senders by Gmail and Yahoo since
2024). Messages are grouped by ``List-Id`` when there is one (a company can run
several lists), otherwise by sender address.
The headers are read when a message is stored (indexer.persist_message); older
mail is read once by :func:`backfill_list_headers_job`. Unsubscribing uses the
sender's one-click POST (RFC 8058) when offered, else mails the ``mailto:``
address, else hands the web page back for the user to open.
"""
from __future__ import annotations
import logging
import re
import uuid
from dataclasses import dataclass
from email.parser import BytesHeaderParser
from email.policy import default as default_policy
from typing import Any
from urllib.parse import parse_qs, unquote, urlsplit
from sqlalchemy import select, update
from app.db import session_scope
from app.indexer import rawstore
from app.models import Message
logger = logging.getLogger(__name__)
_MAX_HEADER = 2000
_ANGLE = re.compile(r"<([^<>]+)>")
_BATCH = 200
@dataclass(frozen=True)
class ListHeaders:
list_id: str | None
unsubscribe: str # '' when the message has none
one_click: bool
def read_list_headers(raw: bytes) -> ListHeaders:
"""The mailing-list headers of a raw message (headers only are parsed)."""
end = raw.find(b"\r\n\r\n")
if end < 0:
end = raw.find(b"\n\n")
head = raw if end < 0 else raw[: end + 2]
try:
msg = BytesHeaderParser(policy=default_policy).parsebytes(head)
list_id_raw = str(msg.get("List-Id") or "")
unsubscribe = str(msg.get("List-Unsubscribe") or "")
post = str(msg.get("List-Unsubscribe-Post") or "")
except Exception: # a malformed header must not stop the message being stored
return ListHeaders(None, "", False)
list_id = None
if list_id_raw:
match = _ANGLE.search(list_id_raw)
list_id = (match.group(1) if match else list_id_raw).strip().lower()[:300] or None
unsubscribe = " ".join(unsubscribe.split())[:_MAX_HEADER]
one_click = "list-unsubscribe=one-click" in post.replace(" ", "").lower()
return ListHeaders(list_id, unsubscribe, one_click and bool(unsubscribe))
@dataclass(frozen=True)
class UnsubscribeTargets:
https: str | None
mailto: str | None # the address
mailto_subject: str
mailto_body: str
def unsubscribe_targets(header: str) -> UnsubscribeTargets:
"""The https and mailto targets listed in a ``List-Unsubscribe`` header."""
https = mailto = None
subject, body = "unsubscribe", "unsubscribe"
for target in _ANGLE.findall(header):
target = target.strip()
lowered = target.lower()
if https is None and lowered.startswith("https://"):
https = target
elif mailto is None and lowered.startswith("mailto:"):
parts = urlsplit(target)
address = unquote(parts.path).strip()
if "@" in address and "\n" not in address and "\r" not in address:
mailto = address
query = parse_qs(parts.query)
subject = (query.get("subject") or [subject])[0][:200]
body = (query.get("body") or [body])[0][:2000]
return UnsubscribeTargets(https, mailto, subject, body)
def group_key(list_id: str | None, sender: str) -> str:
return f"list:{list_id}" if list_id else f"from:{sender.lower()}"
async def pending_count(session: Any, user_id: uuid.UUID) -> int:
from sqlalchemy import func
return int(
await session.scalar(
select(func.count())
.select_from(Message)
.where(Message.user_id == user_id, Message.list_unsubscribe.is_(None))
)
or 0
)
async def backfill_list_headers_job(ctx: dict[str, Any], user_id: str) -> int:
"""Read the list headers of a user's older mail (stored before this feature).
Works in batches; a message whose raw copy can't be had is marked as having
none, so it is not tried again. Returns how many messages were read.
"""
uid = uuid.UUID(user_id)
done = 0
while True:
async with session_scope() as session:
batch = list(
await session.scalars(
select(Message)
.where(Message.user_id == uid, Message.list_unsubscribe.is_(None))
.limit(_BATCH)
)
)
if not batch:
break
for message in batch:
try:
raw = await rawstore.ensure_bytes(message)
headers = read_list_headers(raw)
except Exception: # missing blob, server unreachable: give up on it
headers = ListHeaders(None, "", False)
await session.execute(
update(Message)
.where(Message.id == message.id)
.values(
list_id=headers.list_id,
list_unsubscribe=headers.unsubscribe,
list_unsubscribe_post=headers.one_click,
)
)
await session.commit()
done += len(batch)
if done:
logger.info("read list headers of %d message(s) for user %s", done, user_id)
return done
+303
View File
@@ -0,0 +1,303 @@
"""Email translation, on this server: LibreTranslate (Argos models) in its own
container, reached only by the API. A message's text never leaves the server.
- A message's language is detected the first time it is shown (from its own
text: quoted replies and signatures left out) and kept on the message, so the
reading pane can offer "Translate" when it differs from the interface language.
- A translation is of the message HTML the user sees (already sanitized; the
result is sanitized again), cached per message, target language and whether
remote images were shown.
Off when ``LIBRETRANSLATE_URL`` is empty.
"""
from __future__ import annotations
import asyncio
import html as html_lib
import logging
import re
import time
from collections.abc import Iterable
from dataclasses import dataclass
import httpx
from app import translation_prep as prep
from app.completion import own_text
from app.config import get_settings
logger = logging.getLogger(__name__)
DETECT_CHARS = 1500 # of the message's own text: plenty to tell the language
MIN_CONFIDENCE = 50.0 # below this, LibreTranslate is guessing
MAX_HTML_CHARS = 150_000 # a translation request bigger than this is refused
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # long messages take a while on a CPU
_LANG_CACHE_S = 600
class TranslationError(Exception):
"""Translation is off, the service is unreachable, or it refused the text."""
@dataclass(frozen=True)
class Language:
code: str
name: str
def enabled() -> bool:
return bool(get_settings().libretranslate_url.strip())
def _url(path: str) -> str:
return get_settings().libretranslate_url.rstrip("/") + path
async def _post(path: str, payload: dict[str, object]) -> object:
if not enabled():
raise TranslationError("translation is not set up on this server")
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.post(_url(path), json=payload)
except httpx.HTTPError as exc:
raise TranslationError(f"the translation service is unreachable: {exc}") from exc
if resp.status_code != 200:
detail = resp.text[:200]
raise TranslationError(f"the translation service refused: {resp.status_code} {detail}")
return resp.json()
_languages: tuple[float, list[Language]] = (0.0, [])
async def languages() -> list[Language]:
"""What the service can translate between (cached for a few minutes)."""
global _languages
stamp, cached = _languages
if cached and time.monotonic() - stamp < _LANG_CACHE_S:
return cached
if not enabled():
return []
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
resp = await client.get(_url("/languages"))
resp.raise_for_status()
rows = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.info("translation languages unavailable: %s", exc)
return []
found = [Language(code=str(r["code"]), name=str(r["name"])) for r in rows if "code" in r]
_languages = (time.monotonic(), found)
return found
_CJK = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]")
MIN_LETTERS = 25 # fewer, and the detector guesses ("Jacqueline Lian" → Esperanto)
MAX_DETECT = 60 # pieces of one message asked about
MIN_SHARE = 0.15 # of a message's letters, for its language to count
async def _detect_one(text: str) -> str:
result = await _post("/detect", {"q": text[:DETECT_CHARS]})
if not isinstance(result, list) or not result:
return ""
best = result[0]
if not isinstance(best, dict) or float(best.get("confidence", 0)) < MIN_CONFIDENCE:
return ""
return str(best.get("language", ""))
def _weight(text: str) -> int:
"""How much text this is: letters, a Chinese/Japanese/Korean sign as a word."""
return sum(3 if _CJK.match(c) else 1 for c in text if c.isalpha())
async def languages_of(parts: list[str]) -> list[str]:
"""The language of each of ``parts`` (paragraphs, in order). Parts too
short to tell take the language of the next part that could be told (a
greeting goes with what follows it), the last ones that of the one before;
parts without letters get ''."""
# Addresses say nothing about a language ("E-mail: lianshaojuan.lsj@…" → Estonian).
parts = [_ADDRESS.sub(" ", part) for part in parts]
told: list[str] = [""] * len(parts)
asked = [
i
for i, part in enumerate(parts)
if _CJK.search(part) or sum(c.isalpha() for c in part) >= MIN_LETTERS
][:MAX_DETECT]
gate = asyncio.Semaphore(4)
async def one(i: int) -> None:
async with gate:
told[i] = await _detect_one(parts[i])
await asyncio.gather(*(one(i) for i in asked))
out = list(told)
following = ""
for i in reversed(range(len(parts))):
if told[i]:
following = told[i]
elif any(c.isalpha() for c in parts[i]):
out[i] = following
previous = ""
for i, part in enumerate(parts):
if told[i]:
previous = told[i]
elif not out[i] and any(c.isalpha() for c in part):
out[i] = previous
return out
async def detect(text: str) -> str:
"""The languages of ``text`` (a message's plain text), most of it first,
comma-separated ("zh-Hans,pl,en"); '' if unsure. A message can be in
several: each language that makes up a good part of it counts."""
own = own_text(text)
if len(" ".join(own.split())) < 20:
return "" # a line or two says too little
lines: list[str] = []
total = 0
for line in (x.strip() for x in own.splitlines()):
if line and total < DETECT_CHARS * 4:
lines.append(line)
total += len(line)
found = await languages_of(lines)
weight: dict[str, int] = {}
for line, language in zip(lines, found, strict=True):
if language:
weight[language] = weight.get(language, 0) + _weight(_ADDRESS.sub(" ", line))
if not weight:
return await _detect_one(" ".join(own.split()))
all_of_it = sum(weight.values())
ranked = sorted(weight, key=lambda k: weight[k], reverse=True)
return ",".join(k for k in ranked if weight[k] / all_of_it >= MIN_SHARE)
def _base(code: str) -> str:
return code.lower().split("-")[0]
async def _translate_paragraphs(html: str, target: str) -> str:
"""``html`` in more than one language, in ``target``: each paragraph from
its own language; paragraphs already in ``target`` are left as they are."""
pieces, paragraphs = prep.split_pieces(html)
found = await languages_of([p.text for p in paragraphs])
# Chinese and Japanese go without spaces: two pieces of one sentence
# ("…奎琳,</span>我从…") need one between them once translated.
followed = {i for p in paragraphs for i in p.pieces[:-1]}
by_language: dict[str, list[int]] = {}
for paragraph, language in zip(paragraphs, found, strict=True):
if language and _base(language) != _base(target):
by_language.setdefault(language, []).extend(paragraph.pieces)
for language, where in by_language.items():
todo: list[tuple[int, str, str, str]] = []
for i in where:
m = _EDGE_SPACE.match(html_lib.unescape(pieces[i]))
lead, core, trail = m.groups() if m else ("", "", "")
if not trail and i in followed and _base(language) in _NO_SPACES:
trail = " "
if any(c.isalnum() for c in core):
todo.append((i, lead, core, trail))
for start in range(0, len(todo), 100):
batch = todo[start : start + 100]
result = await _post(
"/translate",
{"q": [core for _, _, core, _ in batch], "source": language, "target": target},
)
out = result.get("translatedText") if isinstance(result, dict) else None
if not isinstance(out, list) or len(out) != len(batch):
raise TranslationError("the translation service sent no translation")
for (i, lead, _, trail), text in zip(batch, out, strict=True):
pieces[i] = html_lib.escape(f"{lead}{text}{trail}", quote=False)
return "".join(pieces)
_NO_SPACES = {"zh", "ja", "th"}
_EDGE_SPACE = re.compile(r"^(\s*)(.*?)(\s*)$", re.DOTALL)
# Web and mail addresses in the text: the models break them up ("https: / /
# vimeo.com"), so they are marked translate="no", which LibreTranslate keeps.
_ADDRESS = re.compile(r"(?:https?://|www\.)[^\s<>\"']+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+", re.IGNORECASE)
_TAG = re.compile(r"(<[^>]*>)")
def protect_addresses(html: str) -> str:
"""Mark the addresses in ``html``'s text as not to be translated (style
blocks and tag attributes are left alone)."""
out: list[str] = []
in_style = False
for piece in _TAG.split(html):
if piece.startswith("<"):
lowered = piece.lower()
if lowered.startswith("<style"):
in_style = True
elif lowered.startswith("</style"):
in_style = False
out.append(piece)
elif in_style or not piece:
out.append(piece)
else:
out.append(_ADDRESS.sub(lambda m: f'<span translate="no">{m.group(0)}</span>', piece))
return "".join(out)
_PUNCT = re.compile(r"[\W_]+")
MAX_NAMES = 200 # names checked for one message
async def names_to_keep(names: Iterable[str], source: str, target: str) -> set[str]:
"""Of ``names``, those the model changes when it translates them alone
("Milan" → "Mediolan", "Imaginary Pixels" → "Imaginanal Pixels"); one
request for them all. If the check fails, all of them."""
wanted = sorted({n.strip() for n in names if len(n.strip()) >= 2})[:MAX_NAMES]
if not wanted:
return set()
try:
result = await _post(
"/translate", {"q": wanted, "source": source or "auto", "target": target}
)
except TranslationError:
return set(wanted)
out = result.get("translatedText") if isinstance(result, dict) else None
if not isinstance(out, list) or len(out) != len(wanted):
return set(wanted)
def same(a: str, b: str) -> bool:
return _PUNCT.sub("", a).casefold() == _PUNCT.sub("", b).casefold()
return {name for name, got in zip(wanted, out, strict=True) if not same(name, str(got))}
# Bumped when what goes to the service, or what is done with the result,
# changes: translations kept from before are made again.
PIPELINE_VERSION = 5
async def translate_html(
html: str, source: str, target: str, keep: Iterable[str] = (), names: Iterable[str] = ()
) -> str:
"""``html`` (a sanitized message body) in ``target``; source "auto", a code,
or several ("zh-Hans,pl,en": then each paragraph from its own language).
``keep``: words always left as they are; ``names``: left as they are if
the model would change them (translation_prep)."""
if len(html) > MAX_HTML_CHARS:
raise TranslationError("this message is too long to translate")
sources = [x for x in (source or "").split(",") if x and x != "auto"]
prepared = prep.join_wrapped_lines(html)
for language in sources:
prepared = prep.translate_phrases(prepared, language, target)
single = sources[0] if len(sources) == 1 else "auto"
candidates = {*names, *prep.find_names(prepared, single if sources else "")}
terms = {*keep, *await names_to_keep(candidates, single, target)}
prepared = prep.keep_symbols(protect_addresses(prep.keep_terms(prepared, terms)))
if len(sources) > 1:
return prep.tidy(await _translate_paragraphs(prepared, target))
result = await _post(
"/translate",
{"q": prepared, "source": source or "auto", "target": target, "format": "html"},
)
if not isinstance(result, dict) or "translatedText" not in result:
raise TranslationError("the translation service sent no translation")
return prep.tidy(str(result["translatedText"]))
+332
View File
@@ -0,0 +1,332 @@
"""Getting a message ready for translation, and tidying what comes back.
LibreTranslate translates each piece of text between tags on its own, and its
small models stumble on things a reader would not: a sentence wrapped onto two
lines becomes two sentences ("ask you about" / "Plan for next month"),
"Best," becomes "Najlepsze," and a company name gets translated word by word.
So before a message goes out:
- lines that carry on a sentence (no end punctuation, next starts lowercase)
are joined, in HTML (<br>, one <div> per line) and in wrapped plain text;
- greetings and sign-offs that make a whole line are put in the target
language from a small phrase table (English, Polish, German);
- names are marked translate="no", which LibreTranslate keeps — the people
on the message, the user's own names and signature names, and capitalised
multi-word names mid-sentence — but only the names the model would change
("Milan" → "Mediolan"): a kept name splits its sentence, and LibreTranslate
translates each piece alone ("Dear <kept>Lukas Remis</kept>," →
"Kochanie."), so a name the model leaves alone stays in the sentence;
- so are emoji, file names ("A_0023C017_p1CAC.mov") and text without a letter;
- a message in more than one language is translated paragraph by paragraph,
each from its own language (split_pieces); those in the target stay;
- and afterwards, the space LibreTranslate drops after a kept word comes back.
"""
from __future__ import annotations
import html as html_lib
import re
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
KEEP_OPEN = '<span translate="no">'
_TAG = re.compile(r"(<[^>]*>)")
_LOWER_START = "a-ząćęłńóśźżäöüßàâçéèêëîïôûùüÿñ"
_UPPER = "A-ZĄĆĘŁŃÓŚŹŻÄÖÜÀÂÇÉÈÊËÎÏÔÛÙÜŸÑ"
# --- joining wrapped lines -------------------------------------------------------
# A line break between a word (or comma) and a lowercase word carries a sentence on.
_HTML_SOFT_BREAK = re.compile(
rf"([\w,;–-])[ \t]*(?:<br\s*/?>|</div>\s*<div[^>]*>|</p>\s*<p[^>]*>)\s*(?=[{_LOWER_START}])"
)
_TEXT_SOFT_BREAK = re.compile(rf"([^\s.!?:;])[ \t]*\n[ \t]*(?=[{_LOWER_START}])")
def join_wrapped_lines(html: str) -> str:
html = _HTML_SOFT_BREAK.sub(r"\1 ", html)
# Plain text (wm-plaintext): newlines inside text, outside tags.
return "".join(
p if p.startswith("<") else _TEXT_SOFT_BREAK.sub(r"\1 ", p) for p in _TAG.split(html)
)
# --- greetings and sign-offs -----------------------------------------------------
# source language → the line as written (lower case) → target language → line.
_PHRASES: dict[str, dict[str, dict[str, str]]] = {
"en": {
"best": {"pl": "Pozdrawiam", "de": "Viele Grüße"},
"best regards": {"pl": "Pozdrawiam serdecznie", "de": "Beste Grüße"},
"best wishes": {"pl": "Najlepsze życzenia", "de": "Beste Grüße"},
"kind regards": {"pl": "Z pozdrowieniami", "de": "Freundliche Grüße"},
"warm regards": {"pl": "Serdecznie pozdrawiam", "de": "Herzliche Grüße"},
"regards": {"pl": "Pozdrawiam", "de": "Grüße"},
"cheers": {"pl": "Pozdrawiam", "de": "Viele Grüße"},
"sincerely": {"pl": "Z poważaniem", "de": "Mit freundlichen Grüßen"},
"yours sincerely": {"pl": "Z poważaniem", "de": "Mit freundlichen Grüßen"},
"thanks": {"pl": "Dzięki", "de": "Danke"},
"many thanks": {"pl": "Wielkie dzięki", "de": "Vielen Dank"},
"thank you": {"pl": "Dziękuję", "de": "Vielen Dank"},
"thanks in advance": {"pl": "Z góry dziękuję", "de": "Vielen Dank im Voraus"},
"all the best": {"pl": "Wszystkiego dobrego", "de": "Alles Gute"},
"talk soon": {"pl": "Do usłyszenia", "de": "Bis bald"},
"have a great day": {"pl": "Miłego dnia", "de": "Einen schönen Tag noch"},
"have a nice day": {"pl": "Miłego dnia", "de": "Einen schönen Tag noch"},
},
"pl": {
"pozdrawiam": {"en": "Best regards", "de": "Viele Grüße"},
"pozdrawiam serdecznie": {"en": "Warm regards", "de": "Herzliche Grüße"},
"serdecznie pozdrawiam": {"en": "Warm regards", "de": "Herzliche Grüße"},
"z poważaniem": {"en": "Kind regards", "de": "Mit freundlichen Grüßen"},
"z pozdrowieniami": {"en": "Kind regards", "de": "Freundliche Grüße"},
"dziękuję": {"en": "Thank you", "de": "Vielen Dank"},
"dzięki": {"en": "Thanks", "de": "Danke"},
"z góry dziękuję": {"en": "Thanks in advance", "de": "Vielen Dank im Voraus"},
"miłego dnia": {"en": "Have a nice day", "de": "Einen schönen Tag noch"},
"do usłyszenia": {"en": "Talk soon", "de": "Bis bald"},
},
"de": {
"mit freundlichen grüßen": {"en": "Kind regards", "pl": "Z poważaniem"},
"freundliche grüße": {"en": "Kind regards", "pl": "Z pozdrowieniami"},
"viele grüße": {"en": "Best regards", "pl": "Pozdrawiam"},
"beste grüße": {"en": "Best regards", "pl": "Pozdrawiam"},
"liebe grüße": {"en": "Warm regards", "pl": "Serdecznie pozdrawiam"},
"herzliche grüße": {"en": "Warm regards", "pl": "Serdecznie pozdrawiam"},
"danke": {"en": "Thanks", "pl": "Dzięki"},
"vielen dank": {"en": "Many thanks", "pl": "Bardzo dziękuję"},
"bis bald": {"en": "Talk soon", "pl": "Do usłyszenia"},
},
}
# A whole line: after a tag or line start, before a tag or line end.
_LINE = re.compile(r"(^|>|\n)([ \t]*)([^<>\n]{2,40}?)([ \t]*[,.!]?)([ \t]*)(?=<|\n|$)")
def _base(code: str) -> str:
return code.lower().split("-")[0]
def translate_phrases(html: str, source: str, target: str) -> str:
table = _PHRASES.get(_base(source), {})
if not table:
return html
tgt = _base(target)
def repl(m: re.Match[str]) -> str:
found = table.get(" ".join(m.group(3).lower().split()), {}).get(tgt)
if found is None:
return m.group(0)
return f"{m.group(1)}{m.group(2)}{KEEP_OPEN}{found}{m.group(4).strip()}</span>{m.group(5)}"
# The pattern never crosses a tag: it stops at "<" and ">".
return _LINE.sub(repl, html)
# --- names ------------------------------------------------------------------------
# A capitalised word; dots only inside it ("U.S"), never at its end — a name
# does not run on over a sentence's end ("NukeX. My work…").
_WORD = rf"[{_UPPER}][\w'’&-]*(?:\.[\w'’&-]+)*"
# Two or more capitalised words in a row ("Imaginary Pixels", "Trusted Partner
# Network"), after a lowercase word: a name, not the start of a sentence.
_MULTI_NAME = re.compile(
rf"(?<=[{_LOWER_START},] )({_WORD}(?: (?:&|of|the|de|von|van|{_WORD}))* {_WORD})"
)
# Languages that capitalise every noun: capitals say nothing about names there.
_CAPITALISE_NOUNS = {"de", "lb"}
def _title_case(text: str) -> bool:
"""A Headline Like This: nearly every longer word capitalised (short ones
— of, and, in — often are not). A sentence full of names is not one."""
words = [w for w in re.findall(r"[^\W\d_][\w'’-]*", text) if len(w) >= 4]
return len(words) >= 3 and sum(w[0].isupper() for w in words) / len(words) > 0.8
def find_names(html: str, source: str) -> set[str]:
"""Capitalised multi-word names in the middle of sentences (not in headings
or Title Case lines, and not for languages that capitalise all nouns)."""
if _base(source) in _CAPITALISE_NOUNS:
return set()
found: set[str] = set()
for piece in _TAG.split(html):
if piece.startswith("<") or _title_case(piece):
continue
found.update(m.group(1).rstrip(".") for m in _MULTI_NAME.finditer(piece))
return found
_SIG_NAME = re.compile(rf"{_WORD}(?: (?:&|of|the|{_WORD}))* {_WORD}")
def find_names_in_signature(text: str) -> list[str]:
"""Capitalised multi-word names in a signature ("Imaginary Pixels"): the
user's company or title, which a translation should leave alone."""
return [m.group(0).rstrip(".") for m in _SIG_NAME.finditer(text)]
def keep_terms(html: str, terms: Iterable[str]) -> str:
"""Mark each of ``terms`` in ``html``'s text translate="no" (longest first;
whole words; style blocks and tags left alone)."""
words = sorted({t.strip() for t in terms if len(t.strip()) >= 2}, key=len, reverse=True)
if not words:
return html
pattern = re.compile(r"(?<![\w>])(" + "|".join(re.escape(w) for w in words) + r")(?![\w<])")
return _mark(html, pattern)
# Emoji and other pictographs (with their skin tones, joiners and variation
# selectors): on its own, an emoji comes back as a made-up word ("😊" →
# "Uzasadnienie").
_SYMBOLS = re.compile(
"[\u2190-\u21ff\u2300-\u23ff\u2460-\u27bf\u2900-\u297f\u2b00-\u2bff"
"\U0001f000-\U0001faff]"
"[\u2190-\u21ff\u2300-\u23ff\u2460-\u27bf\u2900-\u297f\u2b00-\u2bff"
"\U0001f000-\U0001faff\ufe0f\u200d\u20e3]*"
)
# File names and codes with underscores ("A_0023C017_260521_p1CAC.mov",
# "final_v2"): the models split them at every "_" and dot.
_FILE_NAME = re.compile(
r"(?<![\w./-])(?:[\w-]*_[\w-]*(?:\.[\w-]+)*|[\w-]+(?:\.[\w-]+)*\.[a-z][a-z0-9]{1,4})(?![\w/-])",
re.IGNORECASE,
)
# International phone numbers ("+48 780 549 877"): the models space out the
# "+". Only those: a kept piece splits its sentence, and plain numbers
# ("1300 - 2500 Euro") come through anyway.
_PHONE = re.compile(r"(?<![\w+])\+\d[\d ().-]{5,}\d(?!\w)")
# Only names with an extension a file would have, not every "e.g." or "U.S".
_FILE_EXT = re.compile(r"\.[a-z][a-z0-9]{1,4}$", re.IGNORECASE)
def keep_symbols(html: str) -> str:
"""Mark emoji, file names, handles ("Jacqueline_"), phone numbers and text
without a letter in ``html`` translate="no"."""
html = _mark(_mark(html, _SYMBOLS), _PHONE)
def is_file(m: re.Match[str]) -> bool:
word = m.group(0)
if "_" in word and word.strip("_"):
return True
return bool(_FILE_EXT.search(word)) and any(c.isdigit() or c in "_-" for c in word)
return _map_text(_mark(html, _FILE_NAME, keep=is_file), _keep_letterless)
_ENTITY = re.compile(r"&#?\w+;")
_EDGES = re.compile(r"^(\s*)(.*?)(\s*)$", re.DOTALL)
def _keep_letterless(text: str) -> str:
"""A piece of text with no letter or digit — an invisible byte-order mark,
a lone symbol — kept as it is: the models make up a word for it ("\ufeff"
→ "Uzasadnienie")."""
if any(c.isalnum() for c in _ENTITY.sub("", text)):
return text
lead, core, trail = _EDGES.match(text).groups() # type: ignore[union-attr]
if not _ENTITY.sub("", core).strip():
return text # only spaces and &nbsp;
return f"{lead}{KEEP_OPEN}{core}</span>{trail}"
def _mark(
html: str, pattern: re.Pattern[str], keep: Callable[[re.Match[str]], bool] | None = None
) -> str:
"""Wrap ``pattern``'s matches in ``html``'s text in translate="no" spans
(where ``keep(match)`` says so)."""
def repl(m: re.Match[str]) -> str:
if keep is not None and not keep(m):
return m.group(0)
return f"{KEEP_OPEN}{m.group(0)}</span>"
return _map_text(html, lambda text: pattern.sub(repl, text))
def _map_text(html: str, fn: Callable[[str], str]) -> str:
"""``fn`` applied to each piece of ``html``'s text; style blocks, tags and
text already kept are left alone."""
out: list[str] = []
in_style = in_keep = False
for piece in _TAG.split(html):
if piece.startswith("<"):
low = piece.lower()
if low.startswith("<style"):
in_style = True
elif low.startswith("</style"):
in_style = False
elif low.startswith(KEEP_OPEN):
in_keep = True
elif in_keep and low.startswith("</span"):
in_keep = False
out.append(piece)
elif in_style or in_keep or not piece:
out.append(piece)
else:
out.append(fn(piece))
return "".join(out)
# --- messages in more than one language ------------------------------------------
# Tags that end a paragraph (a line): the text between two of them is one
# piece of one language.
_BLOCK = re.compile(
r"</?(?:p|div|li|ul|ol|table|thead|tbody|tfoot|tr|td|th|h[1-6]|blockquote|pre|"
r"section|article|header|footer|center|br|hr)\b",
re.IGNORECASE,
)
@dataclass
class Paragraph:
"""One paragraph of a message: its text (for telling its language) and
where its translatable pieces are in ``split_pieces``' list."""
text: str = ""
pieces: list[int] = field(default_factory=list)
def split_pieces(html: str) -> tuple[list[str], list[Paragraph]]:
"""``html`` as a list of tags and text, and its paragraphs. Text in style
blocks is no paragraph's; text already kept counts for the language but
is not translated."""
pieces = _TAG.split(html)
paragraphs: list[Paragraph] = [Paragraph()]
in_style = in_keep = False
for i, piece in enumerate(pieces):
if piece.startswith("<"):
low = piece.lower()
if low.startswith("<style"):
in_style = True
elif low.startswith("</style"):
in_style = False
elif low.startswith(KEEP_OPEN):
in_keep = True
elif in_keep and low.startswith("</span"):
in_keep = False
if _BLOCK.match(piece) and (paragraphs[-1].text.strip() or paragraphs[-1].pieces):
paragraphs.append(Paragraph())
elif piece and not in_style:
paragraphs[-1].text += html_lib.unescape(piece)
if not in_keep:
paragraphs[-1].pieces.append(i)
return pieces, [p for p in paragraphs if p.text.strip()]
# --- after translation -------------------------------------------------------------
# LibreTranslate drops the space after a kept span ("Bruton</span>i twoje").
# Only kept spans: they still carry translate="no" here (the sanitizer that
# runs next takes it off); other spans are the sender's and stay as they are.
_KEPT = re.compile(r'(<span translate="no">[^<]*</span>)')
def tidy(html: str) -> str:
def repl(m: re.Match[str]) -> str:
before = html[m.start() - 1] if m.start() > 0 else ""
after = html[m.end()] if m.end() < len(html) else ""
lead = " " if re.match(r"[\w,;:)]", before) else ""
trail = " " if re.match(r"[\w(„\"“]", after) else ""
return f"{lead}{m.group(1)}{trail}"
return _KEPT.sub(repl, html)
@@ -0,0 +1,108 @@
"""snooze, templates, subscriptions, tracker counts
- Snooze: a thread can leave the Inbox until a set time (``snoozed_until``);
``snoozed_at`` lets a reply that arrives meanwhile wake it early, and
``unsnoozed_at`` brings a woken thread to the top of the Inbox.
- Templates: saved replies, per user, inserted from the compose window.
- Subscriptions: the mailing-list headers of each message (``List-Id``,
``List-Unsubscribe``, one-click support), read during sync. NULL
``list_unsubscribe`` means "not read yet" (filled in by a backfill job), ''
means the message has none. ``subscription_states`` records what the user did
about a sender (unsubscribed when).
- Tracker blocking: how many tracking pixels were removed from a message's HTML.
Revision ID: 0028
Revises: 0027
Create Date: 2026-10-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0028"
down_revision: str | None = "0027"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE threads
ADD COLUMN snoozed_until timestamptz,
ADD COLUMN snoozed_at timestamptz,
ADD COLUMN unsnoozed_at timestamptz
"""
)
op.execute(
"CREATE INDEX ix_threads_snoozed ON threads (snoozed_until) WHERE snoozed_until IS NOT NULL"
)
op.execute(
"""
ALTER TABLE messages
ADD COLUMN list_id text,
ADD COLUMN list_unsubscribe text,
ADD COLUMN list_unsubscribe_post boolean NOT NULL DEFAULT false
"""
)
op.execute(
"CREATE INDEX ix_messages_user_list ON messages (user_id) "
"WHERE list_unsubscribe IS NOT NULL AND list_unsubscribe <> ''"
)
op.execute(
"CREATE INDEX ix_messages_list_unread ON messages (user_id) WHERE list_unsubscribe IS NULL"
)
op.execute(
"""
CREATE TABLE subscription_states (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key text NOT NULL,
unsubscribed_at timestamptz NOT NULL DEFAULT now(),
method text NOT NULL,
PRIMARY KEY (user_id, key)
)
"""
)
op.execute(
"""
CREATE TABLE mail_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
subject text NOT NULL DEFAULT '',
body_html text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
"""
)
op.execute("CREATE INDEX ix_mail_templates_user ON mail_templates (user_id)")
op.execute("ALTER TABLE body_parts ADD COLUMN trackers_blocked integer NOT NULL DEFAULT 0")
def downgrade() -> None:
op.execute("ALTER TABLE body_parts DROP COLUMN IF EXISTS trackers_blocked")
op.execute("DROP TABLE IF EXISTS mail_templates")
op.execute("DROP TABLE IF EXISTS subscription_states")
op.execute("DROP INDEX IF EXISTS ix_messages_list_unread")
op.execute("DROP INDEX IF EXISTS ix_messages_user_list")
op.execute(
"""
ALTER TABLE messages
DROP COLUMN IF EXISTS list_id,
DROP COLUMN IF EXISTS list_unsubscribe,
DROP COLUMN IF EXISTS list_unsubscribe_post
"""
)
op.execute("DROP INDEX IF EXISTS ix_threads_snoozed")
op.execute(
"""
ALTER TABLE threads
DROP COLUMN IF EXISTS snoozed_until,
DROP COLUMN IF EXISTS snoozed_at,
DROP COLUMN IF EXISTS unsnoozed_at
"""
)
@@ -0,0 +1,49 @@
"""sentence completion: a per-user index of phrases from their sent mail
``completion_phrases`` maps the words before the cursor (normalized) to what
the user usually writes next, with how often; rebuilt from the sent mail at
most daily (app/completion.py). The primary key leads with (user_id, context),
which is the lookup.
Revision ID: 0029
Revises: 0028
Create Date: 2026-10-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0029"
down_revision: str | None = "0028"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE completion_phrases (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
context text NOT NULL,
continuation text NOT NULL,
count integer NOT NULL,
PRIMARY KEY (user_id, context, continuation)
)
"""
)
op.execute(
"""
CREATE TABLE completion_index_state (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
built_at timestamptz NOT NULL
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS completion_index_state")
op.execute("DROP TABLE IF EXISTS completion_phrases")
@@ -0,0 +1,45 @@
"""email translation: a message's detected language, and translations kept
``messages.content_language``: NULL until the message is first shown, '' when
the language could not be told. ``message_translations``: one translated HTML
per message, target language and remote-images choice (app/translation.py);
dropped with the message.
Revision ID: 0030
Revises: 0029
Create Date: 2026-10-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0030"
down_revision: str | None = "0029"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE messages ADD COLUMN content_language text")
op.execute(
"""
CREATE TABLE message_translations (
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
target text NOT NULL,
images boolean NOT NULL,
source text NOT NULL,
html text NOT NULL,
sanitizer_v integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, target, images)
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS message_translations")
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS content_language")
+149
View File
@@ -0,0 +1,149 @@
"""A signed-in user with an API client, for endpoint tests in this package."""
from __future__ import annotations
import datetime as dt
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMessage
CSRF = "csrf"
HDR = {"X-CSRF": CSRF}
@dataclass
class UserCtx:
client: httpx.AsyncClient
user_id: uuid.UUID
session: AsyncSession # for arranging rows directly
@pytest_asyncio.fixture
async def user_ctx() -> AsyncIterator[UserCtx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"t{uuid.uuid4().hex[:10]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.commit()
user_id = user.id # kept: a test may expire the session's objects
token = await create_session(user_id, "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield UserCtx(client=client, user_id=user_id, session=session)
finally:
await client.aclose()
await session.rollback()
await session.execute(delete(User).where(User.id == user_id))
await session.commit()
await session.close()
await engine.dispose()
@dataclass
class MailCtx(UserCtx):
account_id: uuid.UUID
inbox_id: uuid.UUID
sent_id: uuid.UUID
async def deliver(
self,
raw: bytes,
uid: int,
*,
seen: bool = False,
at: dt.datetime | None = None,
folder: str = "inbox",
) -> Message:
"""Store a message the way sync does (indexer.persist_message), in the
Inbox or (``folder="sent"``) the Sent folder."""
remote = RemoteMessage(
uid=uid,
raw=raw,
flags=frozenset({"\\Seen"}) if seen else frozenset(),
internal_date=at or dt.datetime.now(dt.UTC),
)
result = await persist_message(
self.session,
user_id=self.user_id,
account_id=self.account_id,
mailbox_id=self.sent_id if folder == "sent" else self.inbox_id,
mailbox_special_use=folder,
remote=remote,
)
await self.session.commit()
assert result is not None
return result.message
def raw_mail(
subject: str,
*,
sender: str = "News <news@shop.example>",
headers: dict[str, str] | None = None,
mid: str | None = None,
html: str | None = None,
) -> bytes:
m = EmailMessage()
m["From"] = sender
m["To"] = "me@example.com"
m["Subject"] = subject
m["Message-ID"] = mid or f"<{uuid.uuid4().hex}@shop.example>"
for name, value in (headers or {}).items():
m[name] = value
m.set_content(f"{subject} body")
if html is not None:
m.add_alternative(html, subtype="html")
return m.as_bytes()
@pytest_asyncio.fixture
async def mail_ctx(user_ctx: UserCtx) -> MailCtx:
s = user_ctx.session
account = Account(
user_id=user_ctx.user_id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
s.add(account)
await s.flush()
inbox = Mailbox(
account_id=account.id, user_id=user_ctx.user_id, path="INBOX", special_use="inbox"
)
trash = Mailbox(
account_id=account.id, user_id=user_ctx.user_id, path="Trash", special_use="trash"
)
sent = Mailbox(account_id=account.id, user_id=user_ctx.user_id, path="Sent", special_use="sent")
s.add_all([inbox, trash, sent])
await s.commit()
return MailCtx(
client=user_ctx.client,
user_id=user_ctx.user_id,
session=s,
account_id=account.id,
inbox_id=inbox.id,
sent_id=sent.id,
)
+1 -1
View File
@@ -391,7 +391,7 @@ async def test_folder_counts(api: ApiCtx) -> None:
# All three seeded inbox messages are unread; no drafts yet.
resp = await api.client.get("/api/mailboxes/counts")
assert resp.status_code == 200
assert resp.json() == {"inbox_unread": 3, "drafts": 0}
assert resp.json() == {"inbox_unread": 3, "drafts": 0, "snoozed": 0}
# Marking one message \Seen lowers the unread count.
msg = await api.session.get(Message, api.message_ids[0])
+41
View File
@@ -0,0 +1,41 @@
"""Sentence completion over the API, from the user's sent mail."""
from __future__ import annotations
from typing import Any
import pytest
from app.api import completion as api_completion
from app.completion import build_index_job
from tests.api.conftest import HDR, MailCtx, raw_mail
async def _suggest(ctx: MailCtx, before: str) -> str | None:
resp = await ctx.client.post("/api/completion", json={"before": before}, headers=HDR)
assert resp.status_code == 200, resp.text
return resp.json()["suggestion"]
async def test_suggests_from_sent_mail(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
queued: list[tuple[Any, ...]] = []
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
queued.append((name, *args))
monkeypatch.setattr(api_completion, "enqueue_job", fake_enqueue)
body = "Hi Anna,\nThe renders will be ready by Thursday evening.\n\nOn Mon Anna wrote:\n> ok"
for uid in (1, 2):
await mail_ctx.deliver(
raw_mail("Renders", sender="me@example.com").replace(b"Renders body", body.encode()),
uid,
folder="sent",
)
# No index yet: a build is asked for, and the starter phrases still answer.
assert await _suggest(mail_ctx, "I look forward ") == "to hearing from you."
assert queued and queued[0][0] == "build_index_job"
assert await build_index_job({}, str(mail_ctx.user_id)) > 0
assert await _suggest(mail_ctx, "The renders will be re") == "ady by Thursday evening."
assert await _suggest(mail_ctx, "> ok") is None # quoted text was not learned
+80
View File
@@ -0,0 +1,80 @@
"""Snooze: a conversation leaves the Inbox until a set time, then comes back."""
from __future__ import annotations
import datetime as dt
from sqlalchemy import select
from app.models import Message, Thread
from app.snooze import wake_due_job
from tests.api.conftest import HDR, MailCtx, raw_mail
def _in(days: float = 1) -> str:
return (dt.datetime.now(dt.UTC) + dt.timedelta(days=days)).isoformat()
async def _inbox_ids(ctx: MailCtx) -> list[str]:
resp = await ctx.client.get("/api/threads", params={"view": "folder:inbox"})
return [t["id"] for t in resp.json()["threads"]]
async def test_snoozed_thread_leaves_the_inbox(mail_ctx: MailCtx) -> None:
old = await mail_ctx.deliver(
raw_mail("Old"), 1, at=dt.datetime.now(dt.UTC) - dt.timedelta(days=2)
)
new = await mail_ctx.deliver(raw_mail("New"), 2)
c = mail_ctx.client
resp = await c.post(f"/api/threads/{new.thread_id}/snooze", json={"until": _in()}, headers=HDR)
assert resp.status_code == 200, resp.text
assert await _inbox_ids(mail_ctx) == [str(old.thread_id)]
snoozed = (await c.get("/api/threads", params={"view": "snoozed"})).json()["threads"]
assert [t["id"] for t in snoozed] == [str(new.thread_id)]
assert snoozed[0]["snoozed_until"] is not None
counts = (await c.get("/api/mailboxes/counts")).json()
assert counts["snoozed"] == 1 and counts["inbox_unread"] == 1
# Unsnooze: straight back, at the top.
await c.post(f"/api/threads/{new.thread_id}/unsnooze", headers=HDR)
assert await _inbox_ids(mail_ctx) == [str(new.thread_id), str(old.thread_id)]
async def test_snooze_needs_a_future_time(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("x"), 1)
for until in (_in(-1), _in(400), "2030-01-01T09:00:00"):
resp = await mail_ctx.client.post(
f"/api/threads/{msg.thread_id}/snooze", json={"until": until}, headers=HDR
)
assert resp.status_code == 400, until
async def test_due_thread_wakes_on_top_and_unread(mail_ctx: MailCtx) -> None:
week_ago = dt.datetime.now(dt.UTC) - dt.timedelta(days=7)
snoozed = await mail_ctx.deliver(raw_mail("Snoozed"), 1, seen=True, at=week_ago)
newer = await mail_ctx.deliver(raw_mail("Newer"), 2, seen=True)
thread = await mail_ctx.session.get(Thread, snoozed.thread_id)
assert thread is not None
thread.snoozed_until = dt.datetime.now(dt.UTC) - dt.timedelta(minutes=1)
thread.snoozed_at = week_ago
await mail_ctx.session.commit()
assert await wake_due_job({}) >= 1
assert await _inbox_ids(mail_ctx) == [str(snoozed.thread_id), str(newer.thread_id)]
snoozed_id = snoozed.id
mail_ctx.session.expire_all()
flags = await mail_ctx.session.scalar(select(Message.flags).where(Message.id == snoozed_id))
assert flags is not None and "\\Seen" not in flags
async def test_a_reply_wakes_a_snoozed_thread(mail_ctx: MailCtx) -> None:
first = await mail_ctx.deliver(raw_mail("Question", mid="<q@x>"), 1)
await mail_ctx.client.post(
f"/api/threads/{first.thread_id}/snooze", json={"until": _in(3)}, headers=HDR
)
m = raw_mail("Re: Question", headers={"In-Reply-To": "<q@x>", "References": "<q@x>"})
await mail_ctx.deliver(m, 2, at=dt.datetime.now(dt.UTC) + dt.timedelta(seconds=5))
await wake_due_job({})
assert await _inbox_ids(mail_ctx) == [str(first.thread_id)]
+239
View File
@@ -0,0 +1,239 @@
"""Subscriptions: bulk senders worked out from List-* headers, and unsubscribing."""
from __future__ import annotations
import datetime as dt
from typing import Any
import pytest
from sqlalchemy import select, update
from app.api import subscriptions as api_subs
from app.models import Draft, Mailbox, Message
from app.subscriptions import backfill_list_headers_job, read_list_headers, unsubscribe_targets
from tests.api.conftest import HDR, MailCtx, raw_mail
NEWS = {
"List-Id": '"Weekly news" <weekly.shop.example>',
"List-Unsubscribe": "<https://shop.example/u/1>, <mailto:leave@shop.example?subject=stop>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
def test_read_list_headers() -> None:
h = read_list_headers(raw_mail("x", headers=NEWS))
assert h.list_id == "weekly.shop.example"
assert h.unsubscribe.startswith("<https://shop.example/u/1>")
assert h.one_click is True
plain = read_list_headers(raw_mail("hi"))
assert (plain.list_id, plain.unsubscribe, plain.one_click) == (None, "", False)
def test_unsubscribe_targets() -> None:
t = unsubscribe_targets(NEWS["List-Unsubscribe"])
assert t.https == "https://shop.example/u/1"
assert t.mailto == "leave@shop.example" and t.mailto_subject == "stop"
assert unsubscribe_targets("<http://insecure.example/u>").https is None
assert unsubscribe_targets("<mailto:a@b.c%0d%0aBcc:x@y.z>").mailto is None
async def _list(ctx: MailCtx) -> list[dict[str, Any]]:
resp = await ctx.client.get("/api/subscriptions")
assert resp.status_code == 200, resp.text
return list(resp.json()["subscriptions"])
async def test_groups_by_list_then_sender(mail_ctx: MailCtx) -> None:
await mail_ctx.deliver(raw_mail("Issue 1", headers=NEWS), 1, seen=True)
await mail_ctx.deliver(raw_mail("Issue 2", headers=NEWS), 2)
await mail_ctx.deliver(
raw_mail(
"Sale",
sender="Deals <deals@other.example>",
headers={"List-Unsubscribe": "<mailto:u@other.example>"},
),
3,
)
await mail_ctx.deliver(raw_mail("Hi from a friend", sender="ann@friend.example"), 4)
subs = {s["key"]: s for s in await _list(mail_ctx)}
assert set(subs) == {"list:weekly.shop.example", "from:deals@other.example"}
news = subs["list:weekly.shop.example"]
assert (news["total"], news["unread"], news["name"]) == (2, 1, "News")
assert news["one_click"] and news["has_mailto"] and news["has_web"]
assert news["last_subject"] == "Issue 2"
threads = await mail_ctx.client.get(
"/api/subscriptions/threads", params={"key": "list:weekly.shop.example"}
)
assert len(threads.json()) == 2
async def test_one_click_unsubscribe(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
posted: list[str] = []
async def fake_one_click(url: str) -> bool:
posted.append(url)
return True
monkeypatch.setattr(api_subs, "_one_click", fake_one_click)
resp = await mail_ctx.client.post(
"/api/subscriptions/unsubscribe", json={"key": "list:weekly.shop.example"}, headers=HDR
)
assert resp.json() == {"status": "done", "method": "one_click", "url": None}
assert posted == ["https://shop.example/u/1"]
(news,) = await _list(mail_ctx)
assert news["unsubscribed_at"] is not None and news["still_sending"] is False
async def test_mailto_unsubscribe_and_still_sending(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
) -> None:
enqueued: list[tuple[Any, ...]] = []
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
enqueued.append((name, *args))
monkeypatch.setattr(api_subs, "enqueue_job", fake_enqueue)
headers = {"List-Unsubscribe": "<mailto:leave@shop.example?subject=stop>"}
await mail_ctx.deliver(raw_mail("Issue", headers=headers), 1)
resp = await mail_ctx.client.post(
"/api/subscriptions/unsubscribe", json={"key": "from:news@shop.example"}, headers=HDR
)
assert resp.json()["method"] == "mailto"
assert [e[0] for e in enqueued] == ["send_job"]
draft = await mail_ctx.session.scalar(select(Draft).where(Draft.user_id == mail_ctx.user_id))
assert draft is not None
assert draft.payload["to"] == ["leave@shop.example"] and draft.payload["subject"] == "stop"
# Mail still arriving after the grace period is flagged.
later = dt.datetime.now(dt.UTC) + dt.timedelta(days=3)
await mail_ctx.deliver(raw_mail("Issue again", headers=headers), 2, at=later)
(sub,) = await _list(mail_ctx)
assert sub["still_sending"] is True
async def test_web_only_unsubscribe_hands_back_the_page(mail_ctx: MailCtx) -> None:
headers = {"List-Unsubscribe": "<https://shop.example/prefs>"}
await mail_ctx.deliver(raw_mail("Issue", headers=headers), 1)
resp = await mail_ctx.client.post(
"/api/subscriptions/unsubscribe", json={"key": "from:news@shop.example"}, headers=HDR
)
assert resp.json() == {"status": "open", "method": "web", "url": "https://shop.example/prefs"}
async def test_backfill_reads_older_mail(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("Old issue", headers=NEWS), 1)
await mail_ctx.session.execute(
update(Message).where(Message.id == msg.id).values(list_unsubscribe=None, list_id=None)
)
await mail_ctx.session.commit()
resp = await mail_ctx.client.get("/api/subscriptions")
assert resp.json()["pending"] == 1
assert await backfill_list_headers_job({}, str(mail_ctx.user_id)) == 1
resp = await mail_ctx.client.get("/api/subscriptions")
assert resp.json()["pending"] == 0
assert [s["key"] for s in resp.json()["subscriptions"]] == ["list:weekly.shop.example"]
async def test_trash_all_leaves_mixed_conversations(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
) -> None:
moved: list[str] = []
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
moved.append(args[0])
monkeypatch.setattr(api_subs, "enqueue_job", fake_enqueue)
only = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS, mid="<i1@shop>"), 1)
await mail_ctx.deliver(raw_mail("Issue 2", headers=NEWS, mid="<i2@shop>"), 2)
reply = raw_mail(
"Re: Issue 2",
sender="me@example.com",
headers={"In-Reply-To": "<i2@shop>", "References": "<i2@shop>"},
)
await mail_ctx.deliver(reply, 3)
resp = await mail_ctx.client.post(
"/api/subscriptions/trash", json={"key": "list:weekly.shop.example"}, headers=HDR
)
assert resp.json() == {"moved": 1}
assert moved == [str(only.thread_id)]
async def test_deleted_mail_is_left_out(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
trash = await mail_ctx.session.scalar(
select(Mailbox.id).where(
Mailbox.account_id == mail_ctx.account_id, Mailbox.special_use == "trash"
)
)
await mail_ctx.session.execute(
update(Message).where(Message.id == msg.id).values(mailbox_id=trash)
)
await mail_ctx.session.commit()
assert await _list(mail_ctx) == []
async def test_reading_pane_offers_unsubscribe(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fake_one_click(url: str) -> bool:
return True
monkeypatch.setattr(api_subs, "_one_click", fake_one_click)
news = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
plain = await mail_ctx.deliver(raw_mail("Hi", sender="ann@friend.example"), 2)
c = mail_ctx.client
(env,) = (await c.get(f"/api/threads/{news.thread_id}")).json()["messages"]
assert env["subscription_key"] == "list:weekly.shop.example"
assert env["unsubscribe_via"] == "one_click" and env["unsubscribed"] is False
(other,) = (await c.get(f"/api/threads/{plain.thread_id}")).json()["messages"]
assert other["subscription_key"] is None and other["unsubscribe_via"] is None
resp = await c.post(
"/api/subscriptions/unsubscribe",
json={"key": env["subscription_key"], "message_id": env["id"]},
headers=HDR,
)
assert resp.json()["status"] == "done"
(env,) = (await c.get(f"/api/threads/{news.thread_id}")).json()["messages"]
assert env["unsubscribed"] is True
# A message without the header can't be used.
resp = await c.post(
"/api/subscriptions/unsubscribe",
json={"key": "from:ann@friend.example", "message_id": str(plain.id)},
headers=HDR,
)
assert resp.status_code == 404
async def test_senders_come_in_pages_newest_first(mail_ctx: MailCtx) -> None:
now = dt.datetime.now(dt.UTC)
for i, name in enumerate(["old", "mid", "new"]):
await mail_ctx.deliver(
raw_mail(
f"From {name}",
sender=f"{name}@lists.example",
headers={"List-Unsubscribe": f"<mailto:u@{name}.example>"},
),
i + 1,
at=now - dt.timedelta(days=10 - i),
)
c = mail_ctx.client
first = (await c.get("/api/subscriptions", params={"limit": 2})).json()
assert first["total"] == 3
assert [s["email"] for s in first["subscriptions"]] == [
"new@lists.example",
"mid@lists.example",
]
rest = (await c.get("/api/subscriptions", params={"limit": 2, "offset": 2})).json()
assert [s["email"] for s in rest["subscriptions"]] == ["old@lists.example"]
assert (await c.get("/api/subscriptions", params={"offset": 9})).json()["subscriptions"] == []
oldest = (await c.get("/api/subscriptions", params={"limit": 2, "order": "asc"})).json()
assert [s["email"] for s in oldest["subscriptions"]] == [
"old@lists.example",
"mid@lists.example",
]
+54
View File
@@ -0,0 +1,54 @@
"""Templates: saved replies from the compose window's More menu."""
from __future__ import annotations
from tests.api.conftest import HDR, UserCtx
async def test_save_list_replace_delete(user_ctx: UserCtx) -> None:
c = user_ctx.client
assert (await c.get("/api/templates")).json() == []
resp = await c.post(
"/api/templates",
json={"name": " Thanks ", "subject": "Re: offer", "body_html": "<p>Thank <b>you</b></p>"},
headers=HDR,
)
assert resp.status_code == 200, resp.text
first = resp.json()
assert first["name"] == "Thanks" and first["subject"] == "Re: offer"
# Same name, any case: replaces rather than adding a second one.
resp = await c.post(
"/api/templates", json={"name": "thanks", "body_html": "<p>Cheers</p>"}, headers=HDR
)
assert resp.json()["id"] == first["id"]
await c.post("/api/templates", json={"name": "Apology", "body_html": "x"}, headers=HDR)
listed = (await c.get("/api/templates")).json()
assert [t["name"] for t in listed] == ["Apology", "thanks"]
assert listed[1]["body_html"] == "<p>Cheers</p>"
assert (await c.delete(f"/api/templates/{first['id']}", headers=HDR)).status_code == 200
assert [t["name"] for t in (await c.get("/api/templates")).json()] == ["Apology"]
async def test_template_html_is_cleaned(user_ctx: UserCtx) -> None:
resp = await user_ctx.client.post(
"/api/templates",
json={
"name": "x",
"body_html": '<p onclick="evil()">Hi<script>alert(1)</script>'
'<img src="x" onerror="evil()"><a href="javascript:evil()">l</a></p>',
},
headers=HDR,
)
html = resp.json()["body_html"]
assert "script" not in html and "onclick" not in html and "onerror" not in html
assert "javascript:" not in html and "Hi" in html
async def test_template_needs_a_name(user_ctx: UserCtx) -> None:
resp = await user_ctx.client.post(
"/api/templates", json={"name": " ", "body_html": "x"}, headers=HDR
)
assert resp.status_code == 400
+45
View File
@@ -0,0 +1,45 @@
"""Tracker blocking (Settings → Privacy): on by default, and it can be switched off."""
from __future__ import annotations
from tests.api.conftest import HDR, MailCtx, raw_mail
PIXEL = "https://t.example.com/open.gif?u=42"
HTML = f'<p>Hello</p><img src="{PIXEL}" width="1" height="1">'
async def _html(ctx: MailCtx, message_id: object, images: bool = False) -> tuple[str, str]:
resp = await ctx.client.get(
f"/api/messages/{message_id}/html", params={"images": "1"} if images else {}
)
assert resp.status_code == 200
return resp.text, resp.headers["X-Trackers-Blocked"]
async def test_trackers_blocked_by_default(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("News", html=HTML), 1)
assert (await mail_ctx.client.get("/api/settings")).json()["block_trackers"] is True
body, count = await _html(mail_ctx, msg.id, images=True)
assert count == "1" and PIXEL not in body and "Hello" in body
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
assert env["has_remote_images"] is False # nothing left worth loading
async def test_switched_off_trackers_are_ordinary_remote_images(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("News", html=HTML), 1)
resp = await mail_ctx.client.patch("/api/settings", json={"block_trackers": False}, headers=HDR)
assert resp.json()["block_trackers"] is False
# Still a remote image: not loaded until images are allowed, but offered.
body, count = await _html(mail_ctx, msg.id)
assert count == "0" and PIXEL not in body
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
assert env["has_remote_images"] is True
body, count = await _html(mail_ctx, msg.id, images=True)
assert count == "0" and PIXEL in body.replace("&amp;", "&")
# Back on: gone again.
await mail_ctx.client.patch("/api/settings", json={"block_trackers": True}, headers=HDR)
body, count = await _html(mail_ctx, msg.id, images=True)
assert count == "1" and PIXEL not in body
+86
View File
@@ -0,0 +1,86 @@
"""Email translation over the API, with LibreTranslate replaced by a stand-in."""
from __future__ import annotations
import pytest
from app import translation
from tests.api.conftest import MailCtx, raw_mail
TEXT = "Hello Lukas, I would like to ask about the project schedule for next month."
@pytest.fixture
def fake_service(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
calls: dict[str, object] = {"detect": 0, "translate": 0}
async def detect(text: str) -> str:
calls["detect"] = int(calls["detect"]) + 1 # type: ignore[call-overload]
return "en"
async def translate_html(
html: str, source: str, target: str, keep: object = (), names: object = ()
) -> str:
calls["translate"] = int(calls["translate"]) + 1 # type: ignore[call-overload]
calls["keep"] = list(keep) # type: ignore[call-overload]
calls["names"] = list(names) # type: ignore[call-overload]
assert source == "en" and target == "pl"
return "<p>Cześć Łukasz</p><script>alert(1)</script>"
monkeypatch.setattr(translation, "enabled", lambda: True)
monkeypatch.setattr(translation, "detect", detect)
monkeypatch.setattr(translation, "translate_html", translate_html)
return calls
async def test_language_is_found_once_and_kept(
mail_ctx: MailCtx, fake_service: dict[str, object]
) -> None:
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
for _ in range(2):
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html")
assert resp.headers["X-Content-Language"] == "en"
assert fake_service["detect"] == 1
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
assert env["content_language"] == "en"
async def test_translation_is_cleaned_and_kept(
mail_ctx: MailCtx, fake_service: dict[str, object]
) -> None:
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
for _ in range(2):
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html", params={"translate": "pl"})
assert resp.status_code == 200
assert "Cześć Łukasz" in resp.text and "alert(1)" not in resp.text
assert resp.headers["X-Translated-From"] == "en"
assert resp.headers["X-Translated-To"] == "pl"
assert fake_service["translate"] == 1 # the second came from the cache
# The people on the message are names to keep if the model would change them.
assert "News" in fake_service["names"] # type: ignore[operator]
async def test_service_trouble_is_a_clear_error(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch, fake_service: dict[str, object]
) -> None:
async def broken(
html: str, source: str, target: str, keep: object = (), names: object = ()
) -> str:
raise translation.TranslationError("the translation service is unreachable")
monkeypatch.setattr(translation, "translate_html", broken)
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html", params={"translate": "pl"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "translation_failed"
# The message itself still shows.
assert (await mail_ctx.client.get(f"/api/messages/{msg.id}/html")).status_code == 200
async def test_off_without_a_service(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(translation, "enabled", lambda: False)
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html")
assert "X-Content-Language" not in resp.headers
info = (await mail_ctx.client.get("/api/translation/languages")).json()
assert info == {"available": False, "languages": []}
+71
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import uuid
import pytest
from app.indexer.sanitize import sanitize_html
MID = uuid.uuid4()
@@ -99,3 +101,72 @@ def test_script_content_still_stripped_with_style_allowed() -> None:
result = sanitize_html(html, MID)
assert "alert(1)" not in result.html
assert ".a{color:red}" in result.html
# --- Tracking pixels ---------------------------------------------------------------
@pytest.mark.parametrize(
"tag",
[
'<img src="https://t.example.com/o.gif?u=1" width="1" height="1">',
'<img src="https://t.example.com/o.gif" width="1px" height="1px" border="0">',
"<img src='https://t.example.com/x' style='width:1px;height:1px'>",
'<img src="https://t.example.com/x" style="display:none">',
'<img src="https://x.list-manage.com/track/open.php?u=1&id=2">',
'<img src="https://u1.ct.sendgrid.net/wf/open?upn=abc" alt="">',
'<img src="https://mailtrack.io/trace/mail/abc.png">',
# Squeezed to a pixel by max-width, its height following.
'<img src="https://x.example/p.heic" width="1" height="1" '
'style="display:block; height:auto; max-width:1px !important; width:100%">',
'<img src="https://x.example/p" width="0" height="0" style="border:0;width:0;height:0;">',
],
)
def test_tracking_pixels_are_removed_and_counted(tag: str) -> None:
html = f"<p>Hello</p>{tag}<p>Bye</p>"
for allow in (False, True):
result = sanitize_html(html, MID, allow_remote_images=allow)
assert result.trackers_blocked == 1
assert "<img" not in result.html
assert "Hello" in result.html and "Bye" in result.html
# A pixel alone is no reason to offer "Load images".
assert sanitize_html(html, MID).has_remote_images is False
@pytest.mark.parametrize(
"tag",
[
'<img src="https://cdn.example.com/logo.png" width="180" height="47">',
'<img src="https://cdn.example.com/photo.jpg">',
'<img src="https://cdn.example.com/spacer.gif" width="1" height="40">',
'<img src="cid:logo" width="1" height="1">',
# Outlook signature logos: sizes in inches, not pixels.
'<img width="30" height="30" style="width:0.3125in; height:0.3125in" '
'src="https://pfx.tv/img/sig/logo.png">',
'<img style="width:1.5em;height:1.5em" src="https://cdn.example.com/icon.png">',
'<img style="width:1%;height:1%" src="https://cdn.example.com/bar.png">',
'<img style="width:1pt;height:1pt" src="https://cdn.example.com/dot.png">',
# Attributes say 1x1, the style draws it full width.
'<img src="https://cdn.example.com/photo.jpg" width="1" height="1" '
'style="width:100%;height:auto">',
],
)
def test_real_images_are_kept(tag: str) -> None:
result = sanitize_html(f"<p>x</p>{tag}", MID, allow_remote_images=True)
assert result.trackers_blocked == 0
assert "<img" in result.html
@pytest.mark.parametrize("size", [".", "1.2.3", "..", "", "px", "1e3", "-1"])
def test_malformed_sizes_never_break_sanitizing(size: str) -> None:
tag = f'<img src="https://cdn.example.com/a.png" width="{size}" height="{size}">'
result = sanitize_html(f"<p>x</p>{tag}", MID, allow_remote_images=True)
assert result.trackers_blocked == 0
css = f'<img src="https://cdn.example.com/a.png" style="width:{size}px;height:{size}px">'
assert sanitize_html(css, MID, allow_remote_images=True).trackers_blocked == 0
def test_tracker_blocking_can_be_switched_off() -> None:
tag = '<img src="https://t.example.com/o.gif" width="1" height="1">'
kept = sanitize_html(f"<p>x</p>{tag}", MID, allow_remote_images=True, block_trackers=False)
assert kept.trackers_blocked == 0 and "t.example.com/o.gif" in kept.html
+89
View File
@@ -0,0 +1,89 @@
"""Sentence completion: phrases from the user's own sent mail, plus starters."""
from __future__ import annotations
from collections import Counter
from app.completion import build_phrases, choose, complete, html_to_text, own_text
def personal(*texts: str) -> dict[str, Counter[str]]:
out: dict[str, Counter[str]] = {}
for (ctx, cont), n in build_phrases(texts).items():
out.setdefault(ctx, Counter())[cont] = n
return out
def test_own_text_drops_quotes_and_signature() -> None:
for quote in (
"On Mon, 5 Oct 2026 Anna wrote:",
"W dniu 5.10.2026 Anna pisze:",
"Am 05.10.2026 um 10:00 schrieb Anna:",
"-----Original Message-----",
"From: Anna <anna@x.example>",
"--",
):
text = f"Sure, I'll send it today.\n\n{quote}\nOld text here."
assert own_text(text).strip() == "Sure, I'll send it today.", quote
assert own_text("Yes.\n> quoted line\nAgreed.") == "Yes.\nAgreed."
def test_only_repeated_phrases_are_kept() -> None:
phrases = build_phrases(
["I'll send the drawings by Friday.", "I'll send the drawings by Friday."]
)
assert phrases[("the", "drawings by Friday.")] == 2
once = build_phrases(["A private sentence written once."])
assert once == {}
def test_personal_phrases_complete_in_any_language() -> None:
mine = personal(
"I'll send the drawings by Friday.",
"I'll send the drawings by Friday.",
"Prześlę rysunki do piątku.",
"Prześlę rysunki do piątku.",
)
assert complete(mine, "I'll send the dr") == "awings by Friday."
assert complete(mine, "Prześlę rys") == "unki do piątku."
assert complete(mine, "Prześlę rysunki ") == "do piątku."
def test_starter_phrases_and_spacing() -> None:
assert complete({}, "I look forward ") == "to hearing from you."
assert complete({}, "I look forward") == " to hearing from you." # space added
assert complete({}, "Z pow") == "ażaniem,"
assert complete({}, "Mit freundlichen ") == "Grüßen"
def test_nothing_after_punctuation_or_on_an_empty_line() -> None:
assert complete({}, "Thanks,") is None
assert complete({}, "Done. ") is None
assert complete({}, "") is None
assert complete({}, " ") is None
def test_suggestion_stops_where_habits_part() -> None:
# "see you" is followed by "soon" or "tomorrow" equally: suggest only "you".
assert choose(Counter({"you": 4, "you soon": 2, "you tomorrow": 2})) == "you"
assert choose(Counter({"you": 4, "you soon": 4, "you soon then": 3})) == "you soon then"
def test_polish_gmail_quotes_and_own_signature_are_left_out() -> None:
text = (
"Dziękuję, prześlę jutro.\n"
"Imaginary Pixels – visual effects studio\n"
"pon., 5 paź 2026 o 10:00 Anna Nowak <anna@x.example> napisał(a):\n"
"> stary tekst"
)
sig = html_to_text(
"<div>Imaginary Pixels – visual effects studio</div><div>+48 602 691 231</div>"
)
assert own_text(text, [sig]).strip() == "Dziękuję, prześlę jutro."
def test_numbers_addresses_and_links_are_never_learned() -> None:
texts = ["Call me at +48 602 691 231 or write to me@x.example today."] * 3
learned = " ".join(f"{c} {n}" for (c, n) in build_phrases(texts))
assert "602" not in learned and "@" not in learned
assert ("call me", "at") in build_phrases(texts)
+128
View File
@@ -0,0 +1,128 @@
"""Getting a message ready for translation (app/translation_prep.py)."""
from __future__ import annotations
from app.translation_prep import (
find_names,
find_names_in_signature,
join_wrapped_lines,
keep_symbols,
keep_terms,
split_pieces,
tidy,
translate_phrases,
)
KEPT = '<span translate="no">'
def test_wrapped_lines_are_joined_but_paragraphs_kept() -> None:
assert join_wrapped_lines("<p>ask you about<br>the schedule.</p>") == (
"<p>ask you about the schedule.</p>"
)
assert join_wrapped_lines("<div>ask you about</div><div>the schedule.</div>") == (
"<div>ask you about the schedule.</div>"
)
text = '<div class="wm-plaintext">ask you about\nthe schedule.\n\nThanks,\nAnna</div>'
assert join_wrapped_lines(text) == (
'<div class="wm-plaintext">ask you about the schedule.\n\nThanks,\nAnna</div>'
)
# A new sentence, or a capital after the break: left alone.
for html in ("<p>Done.<br>then more</p>", "<p>Hi Lukas,<br>Natalia</p>"):
assert join_wrapped_lines(html) == html
def test_sign_offs_come_from_the_phrase_table() -> None:
assert translate_phrases("<p>Best,<br>Natalia</p>", "en", "pl") == (
f"<p>{KEPT}Pozdrawiam,</span><br>Natalia</p>"
)
assert translate_phrases("<div>Mit freundlichen Grüßen</div>", "de", "en") == (
f"<div>{KEPT}Kind regards</span></div>"
)
assert "Pozdrawiam" not in translate_phrases("<p>Best results so far</p>", "en", "pl")
assert translate_phrases("<p>Best,</p>", "fr", "pl") == "<p>Best,</p>" # no table
def test_names_mid_sentence_but_not_headlines_or_german() -> None:
html = "<p>I came across Imaginary Pixels and Trusted Partner Network, in Spain.</p>"
assert find_names(html, "en") == {"Imaginary Pixels", "Trusted Partner Network"}
assert find_names("<p>Why Matchmoving Matters, Editing Digger And More</p>", "en") == set()
assert find_names("<p>Wir haben das Neue Angebot gesehen.</p>", "de") == set()
# Not across a sentence's end.
assert find_names("<p>working mostly in NukeX. My work includes paint.</p>", "en") == set()
assert find_names_in_signature("Imaginary Pixels – visual effects studio") == [
"Imaginary Pixels"
]
def test_kept_terms_and_spacing() -> None:
out = keep_terms(
"<p>Hi Natalia Raz, see Imaginary Pixels.</p>", ["Natalia Raz", "Imaginary Pixels"]
)
assert out == f"<p>Hi {KEPT}Natalia Raz</span>, see {KEPT}Imaginary Pixels</span>.</p>"
assert keep_terms("<style>.Natalia{}</style>", ["Natalia"]) == "<style>.Natalia{}</style>"
assert tidy(f"nad {KEPT}Bruton</span>i dalej") == f"nad {KEPT}Bruton</span> i dalej"
assert tidy("<span>a</span>b") == "<span>a</span>b" # the sender's own spans
def test_emoji_kept() -> None:
html = (
'<p>nice to e-meet you <span style="font-family:Segoe UI Emoji">\n😊</span> &nbsp;</p>'
"<p>Great 👍🏽 work ❤️</p>"
)
out = keep_symbols(html)
assert f"{KEPT}😊</span>" in out
assert f"{KEPT}👍🏽</span>" in out
assert f"{KEPT}❤️</span>" in out
assert "nice to e-meet you" in out # the words still go to translation
def test_file_names_kept() -> None:
html = (
"<li>A_0023C017_260521_130157_p1CAC.mov</li>"
"<p>See final_v2 and plate-03.exr, e.g. the U.S cut.</p>"
)
out = keep_symbols(html)
assert f"{KEPT}A_0023C017_260521_130157_p1CAC.mov</span>" in out
assert f"{KEPT}final_v2</span>" in out
assert f"{KEPT}plate-03.exr</span>" in out
assert "e.g. the U.S cut" in out # no file names there
def test_symbols_not_marked_twice_or_in_attributes() -> None:
html = (
f'<a href="https://x.io/a_b.mov">{KEPT}https://x.io/a_b.mov</span></a>'
"<style>.a_b{}</style>"
)
assert keep_symbols(html) == html
def test_text_without_letters_is_kept() -> None:
# A byte-order mark alone between tags came back as "Uzasadnienie".
out = keep_symbols(
'<div dir="ltr">\ufeff<div>Dear Lukas,</div><div> &nbsp; </div><p> – </p></div>'
)
assert f"{KEPT}\ufeff</span>" in out
assert "<div> &nbsp; </div>" in out # only spaces: nothing to keep
assert f"<p> {KEPT}–</span> </p>" in out
assert "<div>Dear Lukas,</div>" in out
def test_handles_and_phone_numbers_kept() -> None:
out = keep_symbols(
"<p>WeChat: Jacquelineeeee_ WhatsApp: +48 780 549 877, for 1300 - 2500 Euro</p>"
)
assert f"{KEPT}Jacquelineeeee_</span>" in out
assert f"{KEPT}+48 780 549 877</span>" in out
assert "1300 - 2500 Euro" in out # plain numbers stay in their sentence
def test_paragraphs_split_at_block_tags() -> None:
html = (
f"<style>p{{}}</style><div>Dear <b>Anna</b>,<br>hello {KEPT}x.mov</span> there</div>"
"<p> &nbsp; </p><li>Item</li>"
)
pieces, paragraphs = split_pieces(html)
assert [p.text for p in paragraphs] == ["Dear Anna,", "hello x.mov there", "Item"]
# The kept file name counts for the language but is not translated.
assert [pieces[i] for i in paragraphs[1].pieces] == ["hello ", " there"]
+123
View File
@@ -0,0 +1,123 @@
"""What is sent to the translation service (app/translation.py)."""
from __future__ import annotations
import pytest
from app import translation
from app.translation import detect, languages_of, names_to_keep, protect_addresses, translate_html
def test_addresses_in_text_are_kept_from_translation() -> None:
html = (
'<p>My reel: <a href="https://vimeo.com/1">https://vimeo.com/1</a>, '
"write to anna@x.example or see www.example.com today.</p>"
)
out = protect_addresses(html)
assert '<span translate="no">https://vimeo.com/1</span>' in out
assert '<span translate="no">anna@x.example</span>' in out
assert '<span translate="no">www.example.com</span>' in out
assert '<a href="https://vimeo.com/1">' in out # attributes untouched
def test_style_blocks_are_left_alone() -> None:
html = "<style>.a{background:url(https://x.example/a.png)}</style><p>Hi</p>"
assert protect_addresses(html) == html
async def test_only_names_the_model_changes_are_kept(monkeypatch: pytest.MonkeyPatch) -> None:
sent: list[object] = []
async def post(path: str, payload: dict[str, object]) -> object:
sent.append(payload["q"])
alone = {
"Lukas Remis": "Lukas Remis.",
"Milan": "Mediolan",
"Imaginary Pixels": "Imaginanal",
}
return {"translatedText": [alone[q] for q in payload["q"]]} # type: ignore[attr-defined]
monkeypatch.setattr(translation, "_post", post)
kept = await names_to_keep(
["Milan", "Lukas Remis", "Imaginary Pixels", "Milan", "x"], "en", "pl"
)
assert kept == {"Milan", "Imaginary Pixels"} # "Lukas Remis" stays in its sentence
assert len(sent) == 1 # one request for all
async def test_all_names_kept_when_the_check_fails(monkeypatch: pytest.MonkeyPatch) -> None:
async def post(path: str, payload: dict[str, object]) -> object:
raise translation.TranslationError("down")
monkeypatch.setattr(translation, "_post", post)
assert await names_to_keep(["Milan", "Lukas Remis"], "en", "pl") == {"Milan", "Lukas Remis"}
def _fake_detector(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""A detector that knows Chinese signs, Polish letters, else English."""
asked: list[str] = []
async def detect_one(text: str) -> str:
asked.append(text)
if any("\u4e00" <= c <= "\u9fff" for c in text):
return "zh-Hans"
return "pl" if any(c in "ąćęłńóśźż" for c in text.lower()) else "en"
monkeypatch.setattr(translation, "_detect_one", detect_one)
return asked
async def test_short_parts_take_the_next_language(monkeypatch: pytest.MonkeyPatch) -> None:
asked = _fake_detector(monkeypatch)
parts = [
"您好!",
"Szanowny Kliencie,", # short: goes with the Polish that follows
"Widzę, że złożyłeś wniosek o otwarcie sklepu na platformie.",
"-----",
"Hello, my name is Jacqueline, the manager for Europe.",
"Jacqueline Lian", # short, last: goes with the one before
]
assert await languages_of(parts) == ["zh-Hans", "pl", "pl", "", "en", "en"]
assert "Jacqueline Lian" not in asked # too short to ask about
async def test_a_message_in_three_languages(monkeypatch: pytest.MonkeyPatch) -> None:
_fake_detector(monkeypatch)
text = (
"尊敬的客户,我是速卖通的官方客户经理,我从后台看到您申请入驻速卖通平台开店。\n"
"Szanowny Kliencie,\n"
"Widzę, że złożyłeś wniosek o otwarcie sklepu na platformie AliExpress.\n"
"Dears,\nI noticed from the backend that you have applied to join the platform.\n"
)
assert set((await detect(text)).split(",")) == {"zh-Hans", "pl", "en"}
# One language: one code.
assert await detect("I noticed from the backend that you applied, thanks a lot.") == "en"
async def test_each_paragraph_from_its_own_language(monkeypatch: pytest.MonkeyPatch) -> None:
_fake_detector(monkeypatch)
calls: list[tuple[str, list[str]]] = []
async def post(path: str, payload: dict[str, object]) -> object:
q = payload["q"]
if path == "/translate" and isinstance(q, list) and payload.get("source") != "auto":
calls.append((str(payload["source"]), list(q)))
return {"translatedText": [f"PL({x})" for x in q]}
return {"translatedText": list(q) if isinstance(q, list) else q} # names: unchanged
monkeypatch.setattr(translation, "_post", post)
html = (
"<div><span>我是速卖通的官方客户经理奎琳,</span>我从后台看到您申请入驻速卖通平台开店。</div>"
"<div>Widzę, że złożyłeś wniosek o otwarcie sklepu &amp; więcej.</div>"
"<div>I noticed that you applied to join the platform &amp; more.</div>"
"<div>WhatsApp: +48 780 549 877</div>"
)
out = await translate_html(html, "zh-Hans,pl,en", "pl")
assert [c[0] for c in calls] == ["zh-Hans", "en"] # the Polish is left alone
assert "<div>Widzę, że złożyłeś wniosek o otwarcie sklepu &amp; więcej.</div>" in out
# Two pieces of one Chinese sentence get a space between them.
assert "PL(我是速卖通的官方客户经理奎琳,) </span>PL(" in out
assert "PL(I noticed that you applied to join the platform & more.)" in out.replace(
"&amp;", "&"
)
assert '<span translate="no">+48 780 549 877</span>' in out
+3
View File
@@ -27,6 +27,7 @@ from arq.worker import Worker
from app.config import get_settings
from app.indexer.gc import sweep_job
from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, redis_settings
from app.snooze import wake_due_job
from app.sync.actions import autopurge_trash_spam
from app.sync.coordinator import SyncCoordinator
from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS
@@ -34,7 +35,9 @@ from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS
# Core scheduled jobs. Daily Trash/Spam retention purge at 03:17 (off-peak, and a
# minute that won't collide with typical top-of-hour crons); then the clean-up of
# stored files nothing uses any more (the purge's among them) at 03:47.
# Snoozed conversations are checked every minute (cheap: a partial index).
_CORE_CRON_JOBS = [
cron(wake_due_job, second=5),
cron(autopurge_trash_spam, hour=3, minute=17),
cron(sweep_job, hour=3, minute=47, timeout=3600),
]
+9
View File
@@ -14,10 +14,12 @@ from typing import Any
from arq import func
from app.completion import build_index_job
from app.indexer.attachments import dedupe_existing_job
from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job
from app.outbox.jobs import file_sent_copy_job, send_job
from app.push.service import push_new_mail_job
from app.subscriptions import backfill_list_headers_job
from app.sync.actions import (
ACTION_MAX_TRIES,
delete_thread_job,
@@ -67,4 +69,11 @@ BULK_FUNCTIONS = [
sync_bootstrap_account,
# Resumable (each batch is saved), so a long first run may span timeouts.
func(dedupe_existing_job, timeout=6 * 3600),
# Batches are saved as they go, so a run cut short resumes where it stopped.
# No stored result: its job id (one per user) must only block a second run
# while one is queued or running — a kept result would refuse new scans for
# an hour after each one.
func(backfill_list_headers_job, timeout=6 * 3600, keep_result=0),
# Sentence completion: one per user at a time, and none kept (as above).
func(build_index_job, timeout=1800, keep_result=0),
]