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:
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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)])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)}
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
Reference in New Issue
Block a user