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
+1 -1
View File
@@ -1,5 +1,5 @@
up: ## start services up: ## start services
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool libretranslate
down: ## stop services (data kept) down: ## stop services (data kept)
docker compose -f docker/docker-compose.yml down docker compose -f docker/docker-compose.yml down
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
+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.actions import router as actions_router
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.avatar import router as avatar_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.compose import router as compose_router
from app.api.invitations import router as invitations_router from app.api.invitations import router as invitations_router
from app.api.mailboxes import router as mailboxes_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.saved_searches import router as saved_searches_router
from app.api.search import router as search_router from app.api.search import router as search_router
from app.api.settings import router as settings_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.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.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.identity.routes import router as auth_router
from app.ws import router as ws_router from app.ws import router as ws_router
@@ -48,6 +53,11 @@ def register_routers(app: FastAPI) -> None:
spellcheck_router, spellcheck_router,
push_router, push_router,
invitations_router, invitations_router,
templates_router,
snooze_router,
subscriptions_router,
completion_router,
translation_router,
): ):
app.include_router(router, prefix="/api") app.include_router(router, prefix="/api")
app.include_router(ws_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.db import get_session
from app.identity.deps import CurrentUser 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 from app.outbox.service import not_being_sent
router = APIRouter(prefix="/mailboxes", tags=["mail"]) router = APIRouter(prefix="/mailboxes", tags=["mail"])
@@ -30,6 +30,7 @@ class MailboxOut(BaseModel):
class FolderCounts(BaseModel): class FolderCounts(BaseModel):
inbox_unread: int # unread messages sitting in Inbox mailboxes inbox_unread: int # unread messages sitting in Inbox mailboxes
drafts: int # drafts shown in the Drafts folder (local + un-adopted remote) 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]) @router.get("", response_model=list[MailboxOut])
@@ -54,8 +55,17 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
Message.user_id == user.id, Message.user_id == user.id,
Mailbox.special_use == "inbox", Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]), ~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 # Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror
# UIDs excluded), matching what list_drafts + list_remote_drafts return. # 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) 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 contextlib
import html as html_lib import html as html_lib
import io import io
import logging
import uuid import uuid
import zipfile import zipfile
from typing import Annotated from typing import Annotated
@@ -14,11 +15,18 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.responses import FileResponse, HTMLResponse, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app import translation
from app.api.errors import raise_api_error from app.api.errors import raise_api_error
from app.api.schemas import AttachmentOut 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.db import get_session
from app.identity.deps import CurrentUser, require_csrf from app.identity.deps import CurrentUser, require_csrf
from app.indexer import attachments as att_store 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.mime import parse_message
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
from app.indexer.thumbs import thumb_type 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"]) router = APIRouter(prefix="/messages", tags=["mail"])
@@ -101,8 +120,16 @@ _MESSAGE_CSP = (
) )
def _html(document: str) -> HTMLResponse: def _html(document: str, trackers: int = 0, extra: dict[str, str] | None = None) -> HTMLResponse:
return HTMLResponse(content=document, headers={"Content-Security-Policy": _MESSAGE_CSP}) # 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: 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 "" 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) @router.get("/{message_id}/html", response_class=HTMLResponse)
async def message_html( async def message_html(
message_id: uuid.UUID, message_id: uuid.UUID,
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
images: bool = Query(False), images: bool = Query(False),
translate: str | None = Query(None, pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$"),
) -> HTMLResponse: ) -> HTMLResponse:
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8). """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 ``images=1`` is a one-time "Load images" — render remote images for this
stored copy predates the current sanitizer version (SPEC §10.4). ``images=1`` request only, without persisting the sender as trusted. ``translate=pl``
is a one-time "Load images" — render remote images for this request only, returns it translated (app/translation.py). Headers tell the page what it
without persisting the sender as trusted. 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) message = await _owned_message(db, user.id, message_id)
allow_remote = images or _sender_email(message) in allowed_image_senders(user) 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))) headers: dict[str, str] = {}
html_part = next((p for p in parts if p.kind == "html_sanitized"), None) 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). async def _names(db: AsyncSession, user: User, message: Message) -> list[str]:
# If the raw blob is unavailable, fall back to whatever is already stored. """Names a translation leaves as they are if the model would change them:
if allow_remote or stale: the people on the message (and the sender's single names, as signed:
try: "Milan"), the user's own names (accounts, signatures)."""
raw = await rawstore.ensure_bytes(message) names: list[str] = []
except FileNotFoundError: for people in (message.from_addr, message.to_addr, message.cc_addr):
raw = None names += [p.get("name", "") for p in people if p.get("name")]
if raw is not None: for p in message.from_addr:
parsed = parse_message(raw) names += [w for w in str(p.get("name", "")).split() if len(w) >= 3 and w[0].isupper()]
if parsed.html_body is not None: rows = await db.execute(
result = sanitize_html( select(Account.display_name, Account.signature).where(Account.user_id == user.id)
parsed.html_body, message.id, allow_remote_images=allow_remote )
) for display_name, signature in rows:
if html_part is not None and not allow_remote: if display_name:
html_part.content = result.html # persist the upgraded sanitization names.append(display_name)
html_part.sanitizer_v = result.sanitizer_v if signature:
await db.commit() names += find_names_in_signature(html_to_text(signature))
return _html(_wrap(result.html)) return names
if html_part is not None:
return _html(_wrap(html_part.content)) async def _translated(
text_part = next((p for p in parts if p.kind == "text"), None) db: AsyncSession,
body = html_lib.escape(text_part.content) if text_part else "" user: User,
return _html(_wrap(f'<div class="wm-plaintext">{body}</div>')) 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)]) @router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
+10
View File
@@ -27,6 +27,8 @@ class ThreadSummary(BaseModel):
has_attachments: bool has_attachments: bool
pinned: bool pinned: bool
last_message_at: dt.datetime 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): class ThreadListOut(BaseModel):
@@ -64,6 +66,13 @@ class MessageEnvelope(BaseModel):
snippet: str snippet: str
# Other accounts this same delivery also landed in (collapsed duplicates). # Other accounts this same delivery also landed in (collapsed duplicates).
also_in_account_ids: list[uuid.UUID] = [] 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): class ThreadDetail(BaseModel):
@@ -72,6 +81,7 @@ class ThreadDetail(BaseModel):
order: str # "asc" | "desc" order: str # "asc" | "desc"
pinned: bool pinned: bool
messages: list[MessageEnvelope] # always ASC (SPEC §9.4) messages: list[MessageEnvelope] # always ASC (SPEC §9.4)
snoozed_until: dt.datetime | None = None
class FlagChangeRequest(BaseModel): 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". # "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})*") _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): class ReminderPref(BaseModel):
@@ -52,6 +53,16 @@ class SettingsOut(BaseModel):
# ("auto" detects it per message). # ("auto" detects it per message).
spellcheck_enabled: bool = True spellcheck_enabled: bool = True
spellcheck_language: str = "auto" 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): class SettingsPatch(BaseModel):
@@ -68,6 +79,10 @@ class SettingsPatch(BaseModel):
invitation_file: bool | None = None invitation_file: bool | None = None
spellcheck_enabled: bool | None = None spellcheck_enabled: bool | None = None
spellcheck_language: str | 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): class QuotaOut(BaseModel):
@@ -82,6 +97,11 @@ def allowed_image_senders(user: User) -> set[str]:
return set() 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: def add_allowed_image_sender(user: User, email: str) -> None:
current = sorted(allowed_image_senders(user)) current = sorted(allowed_image_senders(user))
if email.lower() not in current: if email.lower() not in current:
@@ -127,6 +147,10 @@ def _settings_out(user: User) -> SettingsOut:
allowed_image_senders=sorted(allowed_image_senders(user)), allowed_image_senders=sorted(allowed_image_senders(user)),
spellcheck_enabled=bool(s.get("spellcheck_enabled", True)), spellcheck_enabled=bool(s.get("spellcheck_enabled", True)),
spellcheck_language=str(s.get("spellcheck_language", "auto")), 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 updates["spellcheck_enabled"] = body.spellcheck_enabled
if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language): if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language):
updates["spellcheck_language"] = 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 user.settings = updates
await db.commit() await db.commit()
return _settings_out(user) 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 import uuid
from collections.abc import Collection from collections.abc import Collection
from typing import Annotated from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import Text, func, or_, select from sqlalchemy import Text, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from app import subscriptions as subs
from app.api.errors import raise_api_error from app.api.errors import raise_api_error
from app.api.schemas import ( from app.api.schemas import (
Address, Address,
@@ -19,10 +20,10 @@ from app.api.schemas import (
ThreadListOut, ThreadListOut,
ThreadSummary, 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.db import get_session
from app.identity.deps import CurrentUser 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"]) router = APIRouter(prefix="/threads", tags=["mail"])
@@ -50,6 +51,7 @@ def _summary(
has_attachments=thread.has_attachments, has_attachments=thread.has_attachments,
pinned=thread.pinned, pinned=thread.pinned,
last_message_at=thread.last_message_at, 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, images_allowed=bool(sender) and sender in allowed_senders,
size_bytes=msg.size_bytes, size_bytes=msg.size_bytes,
snippet=msg.snippet, 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) .where(Message.user_id == user.id, Mailbox.special_use == special)
) )
conditions.append(Thread.id.in_(in_folder)) 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": elif view != "unified":
raise_api_error(400, "bad_view", "unsupported view") raise_api_error(400, "bad_view", "unsupported view")
@@ -144,11 +151,20 @@ async def list_threads(
) )
) or 0 ) or 0
order_by = ( # A thread woken from snooze sorts as if it had just arrived.
(Thread.last_message_at.asc(), Thread.id.asc()) sort_at = func.greatest(
if order == "asc" Thread.last_message_at, func.coalesce(Thread.unsnoozed_at, Thread.last_message_at)
else (Thread.last_message_at.desc(), Thread.id.desc())
) )
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) stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit)
threads = list(await db.scalars(stmt)) 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]: def _visible_in(current_special: str | None) -> ColumnElement[bool]:
"""Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam """Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam
show only their own messages; every other view hides them.""" show only their own messages; every other view hides them."""
@@ -281,6 +332,20 @@ async def get_thread(
env = _envelope(m, allowed_senders) env = _envelope(m, allowed_senders)
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"] env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
envelopes.append(env) 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" order = "desc" if sort == "desc" else "asc"
return ThreadDetail( return ThreadDetail(
@@ -289,4 +354,5 @@ async def get_thread(
order=order, order=order,
pinned=thread.pinned, pinned=thread.pinned,
messages=envelopes, 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, # Spelling and grammar checking in the compose window (LanguageTool server,
# e.g. http://languagetool:8010). Empty: the check is off for everyone. # e.g. http://languagetool:8010). Empty: the check is off for everyone.
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL") 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 # 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). # 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") 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.models import Account, Attachment, BodyPart, KnownRecipient, Message
from app.providers.base import RemoteMessage from app.providers.base import RemoteMessage
from app.search.base import SearchDoc from app.search.base import SearchDoc
from app.subscriptions import read_list_headers
from app.threading_.normalize import synthesize_message_id from app.threading_.normalize import synthesize_message_id
from app.threading_.threader import thread_message from app.threading_.threader import thread_message
@@ -167,6 +168,7 @@ async def persist_message(
else: else:
sha = rawstore.put_cached(user_id, remote.raw) sha = rawstore.put_cached(user_id, remote.raw)
parsed = await _parse_bounded(user_id, sha, 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( message_id_hdr = await _resolve_message_id(
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw 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, has_attachments=parsed.has_attachments,
snippet=parsed.snippet, snippet=parsed.snippet,
blob_sha256=sha, blob_sha256=sha,
list_id=list_headers.list_id,
list_unsubscribe=list_headers.unsubscribe,
list_unsubscribe_post=list_headers.one_click,
) )
session.add(message) session.add(message)
await session.flush() await session.flush()
@@ -224,6 +229,7 @@ async def persist_message(
kind="html_sanitized", kind="html_sanitized",
content=result.html, content=result.html,
sanitizer_v=result.sanitizer_v, 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). # 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. # 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). # 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/…", # This server's inline-image URL, however it ended up written: "/api/…",
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy. # "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
@@ -154,6 +157,94 @@ class SanitizeResult:
html: str html: str
has_remote_images: bool has_remote_images: bool
sanitizer_v: int 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: 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( 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: ) -> SanitizeResult:
"""Sanitize ``html`` for the message ``message_id``. """Sanitize ``html`` for the message ``message_id``.
Returns the safe HTML, whether any remote images were blocked, and the Returns the safe HTML, whether any remote images were blocked, and the
sanitizer version. Pass ``allow_remote_images=True`` after the user approves sanitizer version. Pass ``allow_remote_images=True`` after the user approves
a sender to render remote images and CSS backgrounds (SPEC §9.4). 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} 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: def attribute_filter(tag: str, attr: str, value: str) -> str | None:
if attr == "style": if attr == "style":
@@ -247,4 +347,5 @@ def sanitize_html(
html=cleaned, html=cleaned,
has_remote_images=state["has_remote"], has_remote_images=state["has_remote"],
sanitizer_v=SANITIZER_VERSION, 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")) has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
pinned: 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) 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): class Message(Base):
@@ -263,6 +269,17 @@ class Message(Base):
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
body_indexed: Mapped[str | None] = mapped_column(TSVECTOR) 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): class BodyPart(Base):
@@ -276,6 +293,8 @@ class BodyPart(Base):
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
# SPEC §10.4: re-sanitize on render if the sanitizer version changed. # 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")) 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): 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): class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push).""" """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" QUEUE_BULK = "bulk"
# Jobs default to the interactive queue; only bulk backfills go to the bulk queue. # 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: 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. # All three seeded inbox messages are unread; no drafts yet.
resp = await api.client.get("/api/mailboxes/counts") resp = await api.client.get("/api/mailboxes/counts")
assert resp.status_code == 200 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. # Marking one message \Seen lowers the unread count.
msg = await api.session.get(Message, api.message_ids[0]) 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 uuid
import pytest
from app.indexer.sanitize import sanitize_html from app.indexer.sanitize import sanitize_html
MID = uuid.uuid4() MID = uuid.uuid4()
@@ -99,3 +101,72 @@ def test_script_content_still_stripped_with_style_allowed() -> None:
result = sanitize_html(html, MID) result = sanitize_html(html, MID)
assert "alert(1)" not in result.html assert "alert(1)" not in result.html
assert ".a{color:red}" 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.config import get_settings
from app.indexer.gc import sweep_job from app.indexer.gc import sweep_job
from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, redis_settings 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.actions import autopurge_trash_spam
from app.sync.coordinator import SyncCoordinator from app.sync.coordinator import SyncCoordinator
from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS 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 # 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 # 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. # 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 = [ _CORE_CRON_JOBS = [
cron(wake_due_job, second=5),
cron(autopurge_trash_spam, hour=3, minute=17), cron(autopurge_trash_spam, hour=3, minute=17),
cron(sweep_job, hour=3, minute=47, timeout=3600), 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 arq import func
from app.completion import build_index_job
from app.indexer.attachments import dedupe_existing_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.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.outbox.jobs import file_sent_copy_job, send_job
from app.push.service import push_new_mail_job from app.push.service import push_new_mail_job
from app.subscriptions import backfill_list_headers_job
from app.sync.actions import ( from app.sync.actions import (
ACTION_MAX_TRIES, ACTION_MAX_TRIES,
delete_thread_job, delete_thread_job,
@@ -67,4 +69,11 @@ BULK_FUNCTIONS = [
sync_bootstrap_account, sync_bootstrap_account,
# Resumable (each batch is saved), so a long first run may span timeouts. # Resumable (each batch is saved), so a long first run may span timeouts.
func(dedupe_existing_job, timeout=6 * 3600), 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),
] ]
+30 -1
View File
@@ -71,6 +71,8 @@ x-backend-env: &backend-env
RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD} RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD}
# Spelling and grammar check in the compose window. Empty turns it off. # Spelling and grammar check in the compose window. Empty turns it off.
LANGUAGETOOL_URL: ${LANGUAGETOOL_URL-http://languagetool:8010} LANGUAGETOOL_URL: ${LANGUAGETOOL_URL-http://languagetool:8010}
# Email translation; empty turns it off.
LIBRETRANSLATE_URL: ${LIBRETRANSLATE_URL-http://libretranslate:5000}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
UNDO_SECONDS: ${UNDO_SECONDS:-10} UNDO_SECONDS: ${UNDO_SECONDS:-10}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
@@ -109,12 +111,13 @@ services:
- | - |
set -e set -e
mkdir -p /mnt/postgres /mnt/blobs /mnt/backups /mnt/redis /mnt/meili /mnt/state \ mkdir -p /mnt/postgres /mnt/blobs /mnt/backups /mnt/redis /mnt/meili /mnt/state \
/mnt/languagetool-ngrams /mnt/languagetool-ngrams /mnt/libretranslate
# Only recurse when ownership is actually wrong: `chown -R` over a large # Only recurse when ownership is actually wrong: `chown -R` over a large
# blob store on every start would be minutes of pointless disk churn. # blob store on every start would be minutes of pointless disk churn.
[ "$$(stat -c %u /mnt/blobs)" = "10001" ] || chown -R 10001:10001 /mnt/blobs [ "$$(stat -c %u /mnt/blobs)" = "10001" ] || chown -R 10001:10001 /mnt/blobs
[ "$$(stat -c %u /mnt/backups)" = "999" ] || chown -R 999:999 /mnt/backups [ "$$(stat -c %u /mnt/backups)" = "999" ] || chown -R 999:999 /mnt/backups
[ "$$(stat -c %u /mnt/redis)" = "999" ] || chown -R 999:999 /mnt/redis [ "$$(stat -c %u /mnt/redis)" = "999" ] || chown -R 999:999 /mnt/redis
[ "$$(stat -c %u /mnt/libretranslate)" = "1032" ] || chown -R 1032:65534 /mnt/libretranslate
chmod 700 /mnt/backups chmod 700 /mnt/backups
echo "[init] data directories ready" echo "[init] data directories ready"
volumes: volumes:
@@ -313,6 +316,32 @@ services:
cpus: ${LANGUAGETOOL_CPUS:-1.0} cpus: ${LANGUAGETOOL_CPUS:-1.0}
logging: *logging logging: *logging
# Email translation (app/translation.py), all on this server. The first
# start downloads every language model (a few GB) into DATA_ROOT; later
# starts reuse them. A model is loaded into memory the first time its
# language is used.
libretranslate:
image: libretranslate/libretranslate:v1.9.6
environment:
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
LT_THREADS: ${LIBRETRANSLATE_THREADS:-2}
TZ: ${TZ:-UTC}
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/libretranslate:/home/libretranslate/.local
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/languages', timeout=4)\""]
interval: 30s
timeout: 6s
retries: 5
start_period: 30m # the first start downloads the models
depends_on:
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${LIBRETRANSLATE_MEM_LIMIT:-6g}
cpus: ${LIBRETRANSLATE_CPUS:-2.0}
logging: *logging
# Bayes tokens and fuzzy hashes. Separate from the app's redis on purpose # Bayes tokens and fuzzy hashes. Separate from the app's redis on purpose
# (§C.2): this data is derived and retrainable, so it trades durability for # (§C.2): this data is derived and retrainable, so it trades durability for
# room to grow — RDB snapshots every 5 min, and an eviction policy, where the # room to grow — RDB snapshots every 5 min, and an eviction policy, where the
+19
View File
@@ -158,6 +158,24 @@ services:
retries: 12 retries: 12
restart: unless-stopped restart: unless-stopped
# Email translation (app/translation.py). The first start downloads every
# language model (several GB) into the volume; later starts reuse them.
libretranslate:
image: libretranslate/libretranslate:v1.9.6
environment:
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
ports:
- "127.0.0.1:5005:5000"
volumes:
- ltmodels:/home/libretranslate/.local
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/languages', timeout=4)\""]
interval: 15s
timeout: 6s
retries: 240
restart: unless-stopped
greenmail: greenmail:
image: greenmail/standalone:2.0.1 image: greenmail/standalone:2.0.1
environment: environment:
@@ -170,6 +188,7 @@ services:
- "127.0.0.1:3993:3993" # IMAPS - "127.0.0.1:3993:3993" # IMAPS
volumes: volumes:
ltmodels:
pgdata: pgdata:
meilidata: meilidata:
blobs: blobs:
+298
View File
@@ -0,0 +1,298 @@
# Feature ideas — making Ultimo competitive with Gmail
Status (2026-10-18): **built** (uncommitted at the time of writing) — snooze,
keyboard shortcuts, subscriptions page + unsubscribe, templates, tracker
blocking. Left out of that first pass: follow-up reminders (§1.1), the image
proxy (§2.1), the footer-link fallback and "auto-archive in future" filter on the
Subscriptions page (§1.3), and bulk snooze from the list. Everything else here is
still a proposal. Each idea notes
what it is, why it matters, how it fits the existing architecture, and a rough
size (S = days, M = 1–2 weeks, L = several weeks).
Already built, so not repeated here: undo send, scheduled send, labels, filters,
antispam, search operators (Meilisearch), VIP contacts, calendar (CalDAV, iMIP),
contacts (CardDAV), offline read-only mode, PWA + Web Push, 2FA, invitations,
hosted mailboxes (Stalwart).
---
## 1. What people miss when leaving Gmail
### 1.1 Snooze and follow-up reminders — M
- **Snooze:** "bring this back Monday 09:00". The thread leaves the inbox and
returns at the chosen time, marked unread and pinned to the top.
- **Follow-up:** when sending, "remind me if nobody replies in 3 days". If no
reply has arrived in the thread by then, it comes back to the inbox with a
"No reply yet" chip.
- **Fit:** a `snoozes` table (thread, user, wake_at, kind) plus an arq cron job
that wakes due threads. Snoozed threads move to a server-side "Snoozed" folder
so other IMAP clients see the same state. The follow-up check watches
`message.new` events for the thread. Could be a builtin plugin.
### 1.2 Keyboard shortcuts — S/M
- Gmail-compatible: `j/k` next/previous, `o`/Enter open, `u` back to list,
`r`/`a`/`f` reply / reply-all / forward, `e` archive, `#` delete, `s` star,
`l` label, `v` move, `/` search, `c` compose, `x` select, `Shift+I/U`
read/unread, `g i` / `g s` go to inbox / starred, `?` shows them all.
- **Fit:** frontend only. One central key handler that respects focus in
inputs and the compose editor, plus a setting to turn it off.
### 1.3 One-click unsubscribe and a subscriptions page — M
- An "Unsubscribe" button on mail that carries `List-Unsubscribe`, using the
one-click POST (RFC 8058) when offered, `mailto:` otherwise.
- A **Subscriptions** page: every sender who sends mailing-list mail, how often,
when last read, with Unsubscribe / "Delete all from this sender" /
"Auto-archive in future" (creates a filter).
- **How subscriptions are found:** no service records what a user signed up
for, so the page infers it from received mail (as Gmail does). A message is
bulk mail when it carries `List-Unsubscribe` (required of bulk senders by
Gmail/Yahoo since 2024), `List-Id`, `Precedence: bulk|list`, or mailing-service
markers (`Feedback-ID`, ESP headers). A footer "unsubscribe" link is a weaker
fallback, shown as "probably a newsletter". Mail is grouped by `List-Id`, else
by sender address.
- **Per group:** messages per month, last received, and **read rate** from the
`\Seen` flag ("opened 0 of 23"), sorted most frequent / least read first.
- **After unsubscribing:** mark the group unsubscribed with the date. If mail
still arrives after a grace period (RFC 8058 senders should stop within 2
days), flag "still sending" and offer block / auto-archive via a filter.
- **Limits:** lists that haven't sent anything yet, or send no list headers,
can't be seen. The page shows "bulk senders currently mailing you".
- **Fit:** the plugin SDK can read raw headers
(`backend/app/plugins/actions.py`), but it parses the stored RFC822 per
message, and with hosted mailboxes the raw message is only cached. So extract
`List-Id`, `List-Unsubscribe` and `List-Unsubscribe-Post` during sync into a
small `message_list_headers` table, backfilled once for existing mail. The
spec (§12, M3) already describes the button as an example `message.actions`
plugin. The one-click POST goes from the server, through the SSRF guard.
### 1.4 Templates (canned replies) — S
- Saved replies with placeholders (`{{first_name}}`, `{{my_name}}`), inserted
from a compose toolbar menu or by typing `/` in the editor.
- **Fit:** per-user table plus a compose-toolbar plugin slot.
### 1.5 Out-of-office reply — M
- Start/end dates, subject, message; replies at most once per sender per N days;
never to mailing lists or `Auto-Submitted` mail (RFC 3834).
- **Fit:** for hosted mailboxes, write a Sieve `vacation` script through
Stalwart. For external accounts, the outbox sends the reply from an arq job
on `message.new`.
---
## 2. Where Ultimo can beat Gmail
### 2.1 Privacy as the selling point — M
- **Tracker blocking:** detect tracking pixels (1×1 images, known tracker
domains, per-recipient URL tokens) and strip them even when remote images are
allowed. Show "3 trackers blocked" on the message.
- **Image proxy:** when remote images are allowed, load them through the server
(SSRF-guarded, cached), so senders never see the reader's IP address or when
the mail was opened.
- **Aliases on the hosted domain:** unlimited addresses such as
`shop.amazon@ultimo.email` or `name+tag@`. Each one can be switched off, and
the app shows which alias leaked to spammers.
- **Fit:** the sanitizer (`backend/app/indexer/sanitize.py`) already rewrites
HTML and blocks remote images. Aliases are Stalwart principals or catch-all
rules managed from the M6 hosted-mailbox code.
### 2.2 Encrypted mail (OpenPGP) — L
- Optional, done in the browser (OpenPGP.js), so the **private key never
reaches the server unencrypted**.
- Decrypt and verify received PGP/MIME mail. Sign and encrypt when sending.
Look up recipients' keys automatically (WKD, keys.openpgp.org) and publish
WKD for the hosted domain.
- **Fit:** compose and reading-panel plugin slots. The key backup is encrypted
with a passphrase before upload. Indexing can't search encrypted bodies (say
so in the UI).
### 2.3 AI assistant (opt-in) — L
Detailed design in **§5** below.
---
## 3. Productivity polish
### 3.1 Split inbox / categories — M
Tabs such as *Important / Newsletters / Notifications / Receipts / Other*,
filled by rules (sender, `List-Id`, labels, filters) and optionally by the AI
classifier (§5.3). The user can move a sender between tabs and the app learns
from it.
### 3.2 Everything you've received — S/M
A view listing all **attachments, links and photos**, filterable by sender, type
and date, with preview. Builds on the right panel's Attachments tab and the
deduplicated attachment store.
### 3.3 Mute a thread; send and archive — S
- **Mute:** new replies in the thread skip the inbox (still searchable). Good for
noisy reply-all threads.
- **Send and archive:** one button that sends the reply and archives the thread.
### 3.4 Easy move from Gmail — M
- Copy existing mail from Gmail over IMAP (Google OAuth is already planned),
with progress, resume, and labels → labels.
- Import Gmail filters (Gmail exports them as XML) into the filters plugin.
- Import contacts (vCard/CSV) and calendars (ICS).
- The hardest part of winning users is getting them to switch. This makes
switching painless.
### 3.5 Calendar from mail — M
- "Create event from this mail": date, time and place pre-filled from the text
(rules first, AI optional).
- "Find a time": free/busy for the thread's participants (for those whose
calendar we can see), then send an invitation.
- "Turn into task" once a tasks list exists (VTODO over CalDAV).
### 3.6 Shared mailboxes and delegation — L
For families and small teams:
- A shared `info@` mailbox visible to several users.
- Assign a thread to someone, with status (open / waiting / done).
- Internal notes on a thread that are never sent.
- Delegated access ("my assistant can read and send as me").
---
## 4. Suggested order
| Milestone | Contents | Why |
|---|---|---|
| **Next** | Snooze + follow-up, keyboard shortcuts, unsubscribe/subscriptions, templates, tracker blocking + image proxy | Moderate size, fits the current architecture, users notice right away; starts the privacy story |
| Then | Out-of-office reply, mute / send and archive, everything-received view, Gmail import | Smaller wins + removes the switching barrier |
| Later | AI assistant (§5), split inbox, calendar from mail | AI unlocks smart categories and event extraction |
| Separate | OpenPGP, aliases, shared mailboxes | Larger security / multi-user projects |
---
## 5. AI assistant — design
### 5.1 Principles
1. **Off by default, opt-in twice.** The administrator turns the plugin on and
chooses a provider. Each user then opts in, per feature. Nothing about a
user's mail is sent anywhere until they switch a feature on.
2. **The server calls the AI, never the browser.** API keys stay on the server,
encrypted with the vault (`MASTER_KEY`), like mail-account passwords.
3. **The AI suggests, the user acts.** No AI output sends mail, deletes, moves
or unsubscribes on its own. Drafts land in the compose editor; categories
are labels the user can change.
4. **Mail is untrusted input** (see §5.6). An email can contain text written to
manipulate the model ("ignore your instructions and…"). The design makes
this harmless rather than trying to detect it.
5. **Provider-independent.** One small adapter interface; Anthropic (Claude) is
the default. Other providers or a local model can be plugged in.
### 5.2 Features
| Feature | Trigger | What is sent to the model | Output |
|---|---|---|---|
| **Thread summary** | User clicks "Summarize" (or auto for threads over N messages, if enabled) | Plain-text bodies of the thread (quoted history stripped), senders, dates | 3–5 bullets + "what's asked of you" + deadlines; streamed into the reading panel |
| **Reply drafts** | "Suggest reply" in the reading panel, or a short instruction ("decline politely, offer next week") | The thread + the user's instruction + their signature/name + optional tone examples from their own sent mail | A draft inserted into the compose editor; the user edits and sends |
| **Rewrite** | Select text in compose → "Shorter / More formal / Friendlier / Fix grammar / Translate to …" | Only the selected text | Replacement text with a preview and undo |
| **Categorization** | Background, on each new message (only for users who opted in) | Sender, subject, `List-*` headers, first ~2,000 characters of text | One category from a fixed list (structured output) + optional "needs reply" flag → label |
| **Ask your mailbox** | A search-bar mode: "When does my car insurance expire?" | Only the messages that a search finds relevant (see §5.4) | An answer with links to the source messages |
| **Event extraction** | "Create event" on a message | That message | Title, start/end, place, attendees as structured data → pre-filled event form |
### 5.3 How it plugs into Ultimo
- **A new builtin plugin `webmail_plugin_ai`**, using the existing SDK:
reading-panel and compose slots for the buttons, its own tables for settings,
usage and cached results, and arq jobs for background work.
- **Provider adapter:** `complete(system, messages, schema=None, stream=False)`,
with implementations for Anthropic (official `anthropic` Python SDK), and
later Bedrock / Vertex / a local OpenAI-compatible server (Ollama, vLLM).
- **Streaming:** summaries and drafts stream token by token to the browser over
the existing WebSocket (`backend/app/ws.py`), so text appears right away.
- **Structured outputs:** categorization and event extraction use the API's
structured output mode (`output_config.format` / `messages.parse()` against
a JSON schema). The model can only answer with an allowed category, so a
malicious mail can't make it invent a "trusted" label.
- **Caching results:** a summary is stored keyed by thread + the newest message
id, so reopening a thread costs nothing until a new message arrives.
- **Prompt caching:** the fixed instructions (system prompt, category list,
style guide) come first and are marked cacheable, so repeated calls pay for
them at the much cheaper cache-read rate. Volatile parts (the email text, the
date) come after.
- **Bulk backlog:** classifying existing mail when a user first opts in goes
through the Message Batches API (asynchronous, half price).
- **Quotas and audit:** per-user monthly token budget set by the admin; a usage
table (user, feature, model, input/output tokens) shown in the admin panel.
Calls are logged without message content.
- **Refusals and errors:** check `stop_reason` (including `refusal`) before using
output; on rate limits, retry later (arq retry); the UI says "AI unavailable"
rather than failing the mail action.
### 5.4 "Ask your mailbox" in detail
Searching all mail with the model would be slow and expensive, so this uses
retrieval:
1. The question goes to the model together with **one read-only tool**,
`search_mail(query, from?, after?, before?)`, backed by the existing
Meilisearch index and run **as that user** (row-level security; it can only
see their own mail).
2. The model makes one or more searches; the server returns short snippets
(sender, date, subject, ~500 characters).
3. If needed, the model asks for the full text of a few messages with a second
read-only tool, `read_message(id)`.
4. It answers and cites message ids; the UI turns them into links.
There are no tools that write anything. Keyword search is enough to start.
Semantic search (embeddings in pgvector) could come later, but would need a
separate embeddings provider or local model, since that's a different service
from text generation.
### 5.5 Who provides the AI
The administrator chooses one in the admin panel:
| Option | Privacy | Quality | Cost | Notes |
|---|---|---|---|---|
| **Anthropic Claude API** (default) | Mail text leaves the server for Anthropic | Best | Pay per token | Simplest; official Python SDK; streaming, structured outputs, prompt caching, batches |
| **Claude through Google Vertex AI or Amazon Bedrock** | Same model, contract and data processing with Google/AWS; regional endpoints (e.g. EU) | Best | Partner pricing | Choose if EU data residency or an existing cloud contract matters |
| **Local model** (Ollama / vLLM on a GPU machine) | Nothing leaves your network | Noticeably weaker; fine for categorization and rewriting, weaker for summaries and Q&A | Hardware + electricity | The QNAP has no GPU — CPU-only is too slow for interactive use; needs a separate box |
| **Bring your own key** (per user) | As the chosen provider | — | User pays | Optional; lets power users use their own account |
**Models (Anthropic, prices cached 2026-10-06, per million tokens in / out):**
| Model | Price | Suggested use |
|---|---|---|
| `claude-opus-5-5` | $4 / $20 | Default for everything; best quality for drafts, summaries, Q&A |
| `claude-sonnet-5-5` | $2 / $10 | Option for drafts / summaries if cost matters |
| `claude-haiku-5-5` | $0.10 / $0.50 | Option for high-volume background work (categorization) |
The model is configurable **per feature** in the admin panel. Default to Opus 5.5
everywhere and switch individual features down only after checking that quality
holds. Categorization is the one where volume matters. Example: 500 new
messages/day × ~1,000 tokens ≈ 15M input tokens/month ≈ $60/month on Opus 5.5
vs. ≈ $1.50 on Haiku 5.5 (before caching). Interactive features are cheap
either way: a summary of a long thread is a few thousand tokens. Prices change;
check the provider's current price list before deciding.
**Data handling to verify before launch:** the provider's commercial terms on
training (Anthropic's API terms state that API inputs are not used for model
training by default — confirm the current wording), how long requests are
retained, and a data processing agreement (DPA). Hosting other people's mail in
the EU makes Ultimo the GDPR controller, so the opt-in screen must say which
provider receives the text.
### 5.6 Security: mail as untrusted input
- **No side effects from model output.** Every action needs a user click. Drafts
are never sent automatically.
- **Read-only tools only**, always running as the requesting user (RLS), so an
email that says "search for passwords and include them in the reply" can at
most show the user something from their own mailbox, to the user.
- **Fixed output shapes** (structured outputs) for anything that changes state,
like labels.
- **No remote content:** links in model output are shown as text or go through
the normal link-safety warning; images are never loaded from model output.
- **Clear boundaries in the prompt:** email text is wrapped and labelled as
data, not instructions. This helps, but the guarantees above don't depend on
it.
- **Minimal data:** attachments are not sent by default; bodies are trimmed of
quoted history and signatures; hidden HTML text is dropped by the sanitizer
before the model sees it.
### 5.7 Build steps
1. Plugin skeleton, provider adapter (Anthropic), admin settings (key, model per
feature, quotas), per-user opt-in screen, usage table.
2. Thread summary (streaming) + rewrite in compose.
3. Reply drafts with tone from the user's sent mail.
4. Categorization (structured output, batch backfill) → feeds split inbox (§3.1).
5. Ask your mailbox (read-only tool loop over Meilisearch).
6. Event extraction → calendar plugin.
7. Optional: Vertex/Bedrock and local-model adapters; bring-your-own-key.
+14
View File
@@ -8,9 +8,11 @@
"name": "webmail-frontend", "name": "webmail-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@emoji-mart/data": "~1.2.1",
"@tanstack/react-query": "~5.51.0", "@tanstack/react-query": "~5.51.0",
"@tanstack/react-virtual": "~3.8.0", "@tanstack/react-virtual": "~3.8.0",
"docx-preview": "^0.4.0", "docx-preview": "^0.4.0",
"emoji-mart": "~5.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"pdfjs-dist": "^4.10.38", "pdfjs-dist": "^4.10.38",
"qrcode.react": "~4.1.0", "qrcode.react": "~4.1.0",
@@ -524,6 +526,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@emoji-mart/data": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -3457,6 +3465,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"license": "MIT"
},
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+2
View File
@@ -13,9 +13,11 @@
"gen:api": "openapi-typescript http://127.0.0.1:8000/openapi.json -o src/api/schema.ts" "gen:api": "openapi-typescript http://127.0.0.1:8000/openapi.json -o src/api/schema.ts"
}, },
"dependencies": { "dependencies": {
"@emoji-mart/data": "~1.2.1",
"@tanstack/react-query": "~5.51.0", "@tanstack/react-query": "~5.51.0",
"@tanstack/react-virtual": "~3.8.0", "@tanstack/react-virtual": "~3.8.0",
"docx-preview": "^0.4.0", "docx-preview": "^0.4.0",
"emoji-mart": "~5.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"pdfjs-dist": "^4.10.38", "pdfjs-dist": "^4.10.38",
"qrcode.react": "~4.1.0", "qrcode.react": "~4.1.0",
+32 -4
View File
@@ -1,11 +1,11 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { OfflineController } from "./components/Offline"; import { OfflineController } from "./components/Offline";
import { PushController } from "./components/PushController"; import { PushController } from "./components/PushController";
import { useT } from "./i18n"; import { detectLanguage, supportedLanguage, useLocale, useT } from "./i18n";
import { isOfflineError } from "./lib/api"; import { api, isOfflineError } from "./lib/api";
import { useMe } from "./lib/auth"; import { useMe } from "./lib/auth";
import { connectWs } from "./lib/ws"; import { connectWs } from "./lib/ws";
import { PluginPage } from "./plugins/PluginPage"; import { PluginPage } from "./plugins/PluginPage";
@@ -17,6 +17,33 @@ import { Register } from "./routes/Register";
import { ResetPassword } from "./routes/ResetPassword"; import { ResetPassword } from "./routes/ResetPassword";
import { Settings } from "./routes/settings/Settings"; import { Settings } from "./routes/settings/Settings";
/** The interface language follows the account (Settings → Appearance), on
* every device. An account that has never chosen one gets the browser's or
* system's language (English if we have no catalog for it), saved right away
* — so it stays put on a device set to another language later. */
function LanguageSync() {
const { locale, setLocale } = useLocale();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const saved = settings.data?.ui_language;
useEffect(() => {
if (saved === undefined) return; // not loaded yet
if (saved) {
const code = supportedLanguage(saved) ?? "en";
if (code !== locale) setLocale(code);
return;
}
const chosen = detectLanguage();
setLocale(chosen);
void api
.patchSettings({ ui_language: chosen })
.then((data) => queryClient.setQueryData(["settings"], data))
.catch(() => undefined); // tried again on the next visit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saved]);
return null;
}
function RequireAuth({ children }: { children: JSX.Element }) { function RequireAuth({ children }: { children: JSX.Element }) {
const { data, isLoading, isError, error, refetch } = useMe(); const { data, isLoading, isError, error, refetch } = useMe();
const t = useT(); const t = useT();
@@ -29,7 +56,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
}, [data, queryClient]); }, [data, queryClient]);
if (isLoading) { if (isLoading) {
return <div className="p-8 text-gray-500">Loading…</div>; return <div className="p-8 text-gray-500">{t("common.loading")}</div>;
} }
if (isError && isOfflineError(error)) { if (isError && isOfflineError(error)) {
// No connection and nothing saved on this device: not a reason to sign out. // No connection and nothing saved on this device: not a reason to sign out.
@@ -50,6 +77,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
} }
return ( return (
<> <>
<LanguageSync />
<OfflineController /> <OfflineController />
<PushController /> <PushController />
{children} {children}
+11 -1
View File
@@ -6,6 +6,7 @@ import { useT } from "../i18n";
import { api, ApiError } from "../lib/api"; import { api, ApiError } from "../lib/api";
import { useMe } from "../lib/auth"; import { useMe } from "../lib/auth";
import type { AdminMail, AdminUser } from "../lib/types"; import type { AdminMail, AdminUser } from "../lib/types";
import { useConfirm } from "../components/AppDialog";
const GIB = 1024 ** 3; const GIB = 1024 ** 3;
@@ -519,6 +520,7 @@ function InvitationEmailDialog({ onClose }: { onClose: () => void }) {
* placeholders; previewed with sample names, and sent as a test. */ * placeholders; previewed with sample names, and sent as a test. */
function InvitationEmail() { function InvitationEmail() {
const t = useT(); const t = useT();
const [confirm, confirmDialog] = useConfirm();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const tpl = useQuery({ queryKey: ["adminInviteTemplate"], queryFn: api.adminInviteTemplate }); const tpl = useQuery({ queryKey: ["adminInviteTemplate"], queryFn: api.adminInviteTemplate });
// Sender and subject are drafts until Save; null shows the server's value. // Sender and subject are drafts until Save; null shows the server's value.
@@ -566,6 +568,7 @@ function InvitationEmail() {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
{confirmDialog}
<h2 className="text-lg font-semibold">{t("admin.inviteMail")}</h2> <h2 className="text-lg font-semibold">{t("admin.inviteMail")}</h2>
<p className="text-xs text-app-faint">{t("admin.inviteMailHelp")}</p> <p className="text-xs text-app-faint">{t("admin.inviteMailHelp")}</p>
<form <form
@@ -634,7 +637,14 @@ function InvitationEmail() {
type="button" type="button"
className="rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50" className="rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={save.isPending} disabled={save.isPending}
onClick={() => confirm(t("admin.inviteResetConfirm")) && save.mutate({ html: "" })} onClick={async () => {
const ok = await confirm({
title: t("admin.inviteReset"),
message: t("admin.inviteResetConfirm"),
confirmLabel: t("admin.inviteResetAction"),
});
if (ok) save.mutate({ html: "" });
}}
> >
{t("admin.inviteReset")} {t("admin.inviteReset")}
</button> </button>
+218
View File
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useT } from "../i18n";
// The app's own dialog, in place of the browser's alert/confirm/prompt boxes.
// Looks like Settings' confirmations. Drawn on <body>, so a compose window
// (or any other clipped, transformed box) it is opened from can't cut it off.
// Escape cancels; Enter in a form submits it.
export function AppDialog({
title,
children,
onClose,
}: {
title: string;
children: ReactNode;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
// Capture: before the app's own Escape handling (minimizing compose windows).
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-sm rounded-lg border border-app-border bg-app-surface p-5 text-app-fg shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold">{title}</h3>
{children}
</div>
</div>,
document.body,
);
}
export function DialogButtons({
confirmLabel,
onCancel,
danger = false,
disabled = false,
submit = false,
onConfirm,
}: {
confirmLabel: string;
onCancel: () => void;
danger?: boolean;
disabled?: boolean;
/** The confirm button submits the surrounding form (Enter works too). */
submit?: boolean;
onConfirm?: () => void;
}) {
const t = useT();
return (
<div className="mt-5 flex justify-end gap-2">
<button type="button" className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90" onClick={onCancel}>
{t("common.cancel")}
</button>
<button
type={submit ? "submit" : "button"}
autoFocus={!submit}
disabled={disabled}
onClick={submit ? undefined : onConfirm}
className={`rounded px-4 py-1.5 text-sm font-medium hover:opacity-90 disabled:opacity-50 ${
danger ? "bg-red-500 text-white" : "bg-app-accent text-app-accent-fg"
}`}
>
{confirmLabel}
</button>
</div>
);
}
export function ConfirmDialog({
title,
message,
detail,
confirmLabel,
danger = false,
onConfirm,
onCancel,
}: {
title: string;
message?: string;
/** A second, smaller paragraph (what exactly will happen). */
detail?: string;
confirmLabel: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<AppDialog title={title} onClose={onCancel}>
{message && <p className="mt-2 text-sm text-app-muted">{message}</p>}
{detail && <p className="mt-2 text-xs text-app-faint">{detail}</p>}
<DialogButtons confirmLabel={confirmLabel} danger={danger} onConfirm={onConfirm} onCancel={onCancel} />
</AppDialog>
);
}
export interface ConfirmOptions {
title: string;
message?: string;
detail?: string;
confirmLabel: string;
danger?: boolean;
}
/** `await confirm({...})` in place of window.confirm: true when confirmed.
* Render the returned element somewhere in the component. */
export function useConfirm(): [(options: ConfirmOptions) => Promise<boolean>, ReactNode] {
const [request, setRequest] = useState<(ConfirmOptions & { resolve: (ok: boolean) => void }) | null>(
null,
);
const confirm = useCallback(
(options: ConfirmOptions) => new Promise<boolean>((resolve) => setRequest({ ...options, resolve })),
[],
);
const finish = (ok: boolean) => {
request?.resolve(ok);
setRequest(null);
};
const element = request ? (
<ConfirmDialog
title={request.title}
message={request.message}
detail={request.detail}
confirmLabel={request.confirmLabel}
danger={request.danger}
onConfirm={() => finish(true)}
onCancel={() => finish(false)}
/>
) : null;
return [confirm, element];
}
export interface PromptOptions {
title: string;
message?: string;
label: string;
initial?: string;
placeholder?: string;
confirmLabel: string;
maxLength?: number;
}
function PromptDialog({
options,
onDone,
}: {
options: PromptOptions;
onDone: (value: string | null) => void;
}) {
const [value, setValue] = useState(options.initial ?? "");
const cancel = useCallback(() => onDone(null), [onDone]);
return (
<AppDialog title={options.title} onClose={cancel}>
<form
onSubmit={(e) => {
e.preventDefault();
if (value.trim()) onDone(value.trim());
}}
>
{options.message && <p className="mt-2 text-sm text-app-muted">{options.message}</p>}
<label className="mt-4 block text-sm">
<span className="text-app-muted">{options.label}</span>
<input
className="mt-1 w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
value={value}
maxLength={options.maxLength}
placeholder={options.placeholder}
onChange={(e) => setValue(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
autoFocus
/>
</label>
<DialogButtons submit confirmLabel={options.confirmLabel} disabled={!value.trim()} onCancel={cancel} />
</form>
</AppDialog>
);
}
/** `await prompt({...})` in place of window.prompt: the text, or null if cancelled. */
export function usePrompt(): [(options: PromptOptions) => Promise<string | null>, ReactNode] {
const [request, setRequest] = useState<
(PromptOptions & { resolve: (value: string | null) => void }) | null
>(null);
const prompt = useCallback(
(options: PromptOptions) =>
new Promise<string | null>((resolve) => setRequest({ ...options, resolve })),
[],
);
const onDone = useCallback(
(value: string | null) => {
request?.resolve(value);
setRequest(null);
},
[request],
);
const element = request ? <PromptDialog options={request} onDone={onDone} /> : null;
return [prompt, element];
}
/** A short notice in the corner (components/Toast.tsx), in place of window.alert. */
export function notify(text: string, ms?: number): void {
window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text, ms } }));
}
+61
View File
@@ -0,0 +1,61 @@
import { useT } from "../i18n";
import { SHORTCUT_GROUPS } from "../lib/shortcuts";
/** Every shortcut in groups, keys drawn as keycaps. Settings and the "?" overlay. */
export function ShortcutList() {
const t = useT();
return (
<div className="grid gap-x-10 gap-y-6 sm:grid-cols-2">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title}>
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t(group.title)}
</h3>
<ul className="space-y-1.5 text-sm">
{group.items.map((item) => (
<li key={item.label} className="flex items-center justify-between gap-4">
<span className="text-app-fg">{t(item.label)}</span>
<span className="flex shrink-0 items-center gap-1 text-app-faint">
{item.keys.map((k, i) => (
<span key={k} className="flex items-center gap-1">
{i > 0 && (
<span className="text-[11px]">{item.keys[0] === "Shift" ? "+" : t("shortcuts.then")}</span>
)}
<kbd className="min-w-[1.6rem] rounded border border-app-border bg-app-surface px-1.5 py-0.5 text-center font-mono text-[12px] text-app-fg shadow-[0_1px_0_rgb(var(--app-border))]">
{k}
</kbd>
</span>
))}
</span>
</li>
))}
</ul>
</div>
))}
</div>
);
}
/** The "?" overlay. */
export function ShortcutsHelp({ onClose }: { onClose: () => void }) {
const t = useT();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t("shortcuts.title")}
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-app-border bg-app-elevated p-6 shadow-2xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("shortcuts.title")}</h2>
<button className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg" onClick={onClose} aria-label={t("common.close")}>
✕
</button>
</div>
<ShortcutList />
</div>
</div>
);
}
@@ -82,7 +82,7 @@ export function ComposeDock({
: "hidden" : "hidden"
} }
> >
<Composer init={win.init} onClose={() => onClose(win.id)} fullWindow /> <Composer init={win.init} snapshotKey={win.id} onClose={() => onClose(win.id)} fullWindow />
</div> </div>
))} ))}
</> </>
@@ -162,6 +162,7 @@ export function ComposeDock({
> >
<Composer <Composer
init={win.init} init={win.init}
snapshotKey={win.id}
onTitle={(s) => noteTitle(win.id, s)} onTitle={(s) => noteTitle(win.id, s)}
minimized={!open} minimized={!open}
expanded={open && win.expanded} expanded={open && win.expanded}
+181 -6
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
@@ -16,11 +16,14 @@ import {
putLocal, putLocal,
uploadFiles, uploadFiles,
} from "../../lib/scheduled"; } from "../../lib/scheduled";
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types"; import type { AccountOut, AttachmentOut, DraftInput, DraftOut, MailTemplate } from "../../lib/types";
import { showOfflineNotice } from "../../lib/offlineNotice"; import { showOfflineNotice } from "../../lib/offlineNotice";
import { RecipientChips } from "./RecipientChips"; import { RecipientChips } from "./RecipientChips";
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor"; import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
import { hasUndoHost, offerUndo } from "./undoSend"; import { hasUndoHost, offerUndo } from "./undoSend";
import { ConfirmDialog } from "../AppDialog";
import { SaveTemplateDialog } from "./SaveTemplateDialog";
import { noteSnapshotDraft, saveSnapshot } from "./restore";
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
@@ -48,6 +51,9 @@ export interface ComposerInit {
localState?: LocalState; // "scheduled" when reopened from Scheduled localState?: LocalState; // "scheduled" when reopened from Scheduled
attachments?: AttachmentOut[]; // known already: no need to ask the server attachments?: AttachmentOut[]; // known already: no need to ask the server
localFiles?: LocalFile[]; localFiles?: LocalFile[];
// Brought back after leaving the mail view (./restore.ts) with changes that
// may not have been saved yet: save again.
restoredDirty?: boolean;
} }
// Build the composer seed from a saved draft (used to reopen it in place or in a // Build the composer seed from a saved draft (used to reopen it in place or in a
@@ -161,6 +167,7 @@ const caret = (
export function Composer({ export function Composer({
init, init,
snapshotKey,
onClose, onClose,
expanded = false, expanded = false,
onToggleExpand, onToggleExpand,
@@ -171,6 +178,9 @@ export function Composer({
fullWindow = false, fullWindow = false,
}: { }: {
init: ComposerInit; init: ComposerInit;
/** Keep this window's content when it goes away for any reason but
* Send/Discard (restore.ts), under this key. */
snapshotKey?: string;
onClose: () => void; onClose: () => void;
expanded?: boolean; expanded?: boolean;
onToggleExpand?: () => void; onToggleExpand?: () => void;
@@ -211,9 +221,21 @@ export function Composer({
const [spellLang, setSpellLang] = useState<string | null>(null); const [spellLang, setSpellLang] = useState<string | null>(null);
const [spellMenuOpen, setSpellMenuOpen] = useState(false); const [spellMenuOpen, setSpellMenuOpen] = useState(false);
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings }); const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
// The language list folds away whenever the More menu closes. // More → Templates: saved replies (api/templates.py).
const [templatesOpen, setTemplatesOpen] = useState(false);
const queryClient = useQueryClient();
const templatesQuery = useQuery({
queryKey: ["templates"],
queryFn: api.templates,
enabled: moreOpen,
staleTime: 60_000,
});
// The language and template lists fold away whenever the More menu closes.
useEffect(() => { useEffect(() => {
if (!moreOpen) setSpellMenuOpen(false); if (!moreOpen) {
setSpellMenuOpen(false);
setTemplatesOpen(false);
}
}, [moreOpen]); }, [moreOpen]);
// What the engine can check — and whether this server has one at all. // What the engine can check — and whether this server has one at all.
const spellLanguages = useQuery({ const spellLanguages = useQuery({
@@ -250,6 +272,9 @@ export function Composer({
onTitleRef.current?.(subject); onTitleRef.current?.(subject);
}, [subject]); }, [subject]);
const draftId = useRef<string | null>(init.draftId ?? null); const draftId = useRef<string | null>(init.draftId ?? null);
// Where this window keeps its content when it goes away (restore.ts).
const snapshotKeyRef = useRef(snapshotKey);
snapshotKeyRef.current = snapshotKey;
// This message's copy on this device, while there is one (lib/scheduled.ts). // This message's copy on this device, while there is one (lib/scheduled.ts).
const localId = useRef<string | null>(init.localId ?? null); const localId = useRef<string | null>(init.localId ?? null);
const discardedRef = useRef(false); const discardedRef = useRef(false);
@@ -258,7 +283,7 @@ export function Composer({
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
// Whether the user actually edited anything. Merely opening a draft to view it // Whether the user actually edited anything. Merely opening a draft to view it
// must not re-save/re-mirror it (that spawned duplicate IMAP copies). // must not re-save/re-mirror it (that spawned duplicate IMAP copies).
const dirtyRef = useRef(false); const dirtyRef = useRef(Boolean(init.restoredDirty));
const markDirty = () => { const markDirty = () => {
dirtyRef.current = true; dirtyRef.current = true;
}; };
@@ -278,6 +303,41 @@ export function Composer({
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [moreOpen, fromOpen, minimized]); }, [moreOpen, fromOpen, minimized]);
// A template holds what was written: not the signature, not the quoted mail.
const templateContent = (): string => {
const box = document.createElement("div");
box.innerHTML = bodyHtml;
box.querySelectorAll(".wmc-signature, .wmc-quoted, .wmc-quote-toggle").forEach((n) => n.remove());
// Blank lines at either end (the editor keeps one above the signature).
const blank = (n: ChildNode | null) =>
n !== null &&
(n.nodeType === Node.TEXT_NODE
? !n.textContent?.trim()
: n instanceof HTMLElement && !n.textContent?.trim() && !n.querySelector("img"));
while (blank(box.firstChild)) box.firstChild?.remove();
while (blank(box.lastChild)) box.lastChild?.remove();
return box.textContent?.trim() || box.querySelector("img") ? box.innerHTML.trim() : "";
};
// Our own dialogs (components/AppDialog.tsx), not the browser's prompt/confirm.
const [savingTemplate, setSavingTemplate] = useState<string | null>(null); // the content
const [deletingTemplate, setDeletingTemplate] = useState<MailTemplate | null>(null);
const saveTemplate = async (name: string) => {
if (savingTemplate === null) return;
await api.saveTemplate(name, subject, savingTemplate);
void queryClient.invalidateQueries({ queryKey: ["templates"] });
};
const insertTemplate = (tpl: MailTemplate) => {
setMoreOpen(false);
markDirty();
if (!subject.trim() && tpl.subject) setSubject(tpl.subject);
editorRef.current?.insertHtml(tpl.body_html);
};
const deleteTemplate = async (tpl: MailTemplate) => {
setDeletingTemplate(null);
await api.deleteTemplate(tpl.id).catch(() => undefined);
void queryClient.invalidateQueries({ queryKey: ["templates"] });
};
const account = accounts.find((a) => a.id === accountId); const account = accounts.find((a) => a.id === accountId);
const fromName = account?.display_name || ""; const fromName = account?.display_name || "";
const fromEmail = account?.email ?? ""; const fromEmail = account?.email ?? "";
@@ -533,7 +593,11 @@ export function Composer({
setLocalFiles([]); setLocalFiles([]);
} }
if (draftId.current) await api.updateDraft(draftId.current, payload(atts)); if (draftId.current) await api.updateDraft(draftId.current, payload(atts));
else draftId.current = (await api.createDraft(payload(atts))).id; else {
draftId.current = (await api.createDraft(payload(atts))).id;
// This window may have gone away while the draft was being made.
if (snapshotKeyRef.current) noteSnapshotDraft(snapshotKeyRef.current, draftId.current);
}
if (localId.current) { if (localId.current) {
await deleteLocal(localId.current); await deleteLocal(localId.current);
localId.current = null; localId.current = null;
@@ -586,8 +650,44 @@ export function Composer({
const saveLocalRef = useRef(saveLocal); const saveLocalRef = useRef(saveLocal);
saveLocalRef.current = saveLocal; saveLocalRef.current = saveLocal;
// What this window holds right now, to bring it back as it is (restore.ts).
// A ref: the unmount below sees only the first render's values otherwise.
const snapshotRef = useRef<() => ComposerInit>(() => init);
snapshotRef.current = () => ({
...init,
accountId,
to,
cc,
bcc,
subject,
html: bodyHtml || initialHtml,
text: undefined,
quote: undefined, // inside html now
forwardFrom: undefined, // its attachments are in `attachments` now
draftId: draftId.current ?? undefined,
localId: localId.current ?? undefined,
localState: localState.current,
attachments,
localFiles,
restoredDirty: dirtyRef.current,
});
const keepSnapshot = () => {
const key = snapshotKeyRef.current;
if (key && !sentRef.current && !discardedRef.current) saveSnapshot(key, snapshotRef.current());
};
const keepSnapshotRef = useRef(keepSnapshot);
keepSnapshotRef.current = keepSnapshot;
// A reload or a closed tab unmounts nothing: keep the content then too.
useEffect(() => {
const onPageHide = () => keepSnapshotRef.current();
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
}, []);
useEffect( useEffect(
() => () => { () => () => {
keepSnapshotRef.current(); // leaving the mail view (Settings…): back as it was
if (undoTimer.current) window.clearTimeout(undoTimer.current); if (undoTimer.current) window.clearTimeout(undoTimer.current);
// On close, mirror the draft to the IMAP Drafts folder so it can be // On close, mirror the draft to the IMAP Drafts folder so it can be
// continued on any mail client — but only if the user actually edited it // continued on any mail client — but only if the user actually edited it
@@ -773,6 +873,24 @@ export function Composer({
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")} {dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
</div> </div>
)} )}
{savingTemplate !== null && (
<SaveTemplateDialog
initialName={subject.trim()}
existing={templatesQuery.data ?? []}
onSave={saveTemplate}
onClose={() => setSavingTemplate(null)}
/>
)}
{deletingTemplate && (
<ConfirmDialog
title={t("compose.deleteTemplate")}
message={t("compose.templateDelete", { name: deletingTemplate.name })}
confirmLabel={t("compose.deleteTemplateAction")}
danger
onConfirm={() => void deleteTemplate(deletingTemplate)}
onCancel={() => setDeletingTemplate(null)}
/>
)}
{showClosePrompt && ( {showClosePrompt && (
<div <div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4" className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
@@ -884,6 +1002,63 @@ export function Composer({
> >
{t("compose.insertSignature")} {t("compose.insertSignature")}
</button> </button>
<button
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
aria-expanded={templatesOpen}
onClick={() => setTemplatesOpen((v) => !v)}
>
<span>{t("compose.templates")}</span>
<span className="shrink-0 text-app-faint">{templatesOpen ? "▾" : "›"}</span>
</button>
{templatesOpen && (
<div className="max-h-64 overflow-y-auto border-y border-app-border bg-app-surface py-1">
{(() => {
const content = templateContent();
return (
<button
className="block w-full px-3 py-1 text-left text-[13px] font-medium text-app-accent hover:bg-app-hover disabled:cursor-default disabled:text-app-faint disabled:hover:bg-transparent"
disabled={!content}
title={content ? undefined : t("compose.templateEmpty")}
onClick={() => {
setMoreOpen(false);
setSavingTemplate(content);
}}
>
{t("compose.saveAsTemplate")}
{!content && (
<span className="block text-[11px] font-normal">{t("compose.templateEmptyShort")}</span>
)}
</button>
);
})()}
<div className="my-1 border-t border-app-border" />
{templatesQuery.data?.length === 0 && (
<div className="px-3 py-1 text-[13px] text-app-faint">{t("compose.noTemplates")}</div>
)}
{templatesQuery.data?.map((tpl) => (
<div key={tpl.id} className="group flex items-center hover:bg-app-hover">
<button
className="min-w-0 flex-1 truncate px-3 py-1 text-left text-[13px] text-app-fg"
title={tpl.subject || tpl.name}
onClick={() => insertTemplate(tpl)}
>
{tpl.name}
</button>
<button
className="mr-1 shrink-0 rounded px-1.5 text-app-faint opacity-0 hover:text-red-500 focus:opacity-100 group-hover:opacity-100"
title={t("compose.deleteTemplate")}
aria-label={t("compose.deleteTemplate")}
onClick={() => {
setMoreOpen(false);
setDeletingTemplate(tpl);
}}
>
×
</button>
</div>
))}
</div>
)}
{spellLanguages.data && (() => { {spellLanguages.data && (() => {
// The language in force: this message's choice, else Settings. // The language in force: this message's choice, else Settings.
const fallback = settingsQuery.data?.spellcheck_enabled const fallback = settingsQuery.data?.spellcheck_enabled
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../../i18n";
import { useTheme } from "../../lib/theme";
// emoji-mart, loaded on first use: its emoji data is a few hundred KB that the
// rest of the app does without. The data is bundled — nothing is fetched from
// outside this server.
const loadData = async () => (await import("@emoji-mart/data")).default;
const PICKER_W = 352;
const PICKER_H = 435;
/** emoji-mart's picker (a web component), made and removed here: its React
* wrapper builds it twice under StrictMode, doubling sections. */
function PickerHost({
theme,
onSelect,
loading,
}: {
theme: "light" | "dark";
onSelect: (native: string) => void;
loading: string;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const [ready, setReady] = useState(false);
useEffect(() => {
let picker: HTMLElement | null = null;
let alive = true;
void import("emoji-mart").then(({ Picker }) => {
if (!alive || !hostRef.current) return;
picker = new Picker({
data: loadData,
theme,
previewPosition: "none",
skinTonePosition: "search",
maxFrequentRows: 2,
onEmojiSelect: (emoji: { native?: string }) => {
if (emoji.native) onSelectRef.current(emoji.native);
},
}) as unknown as HTMLElement;
hostRef.current.appendChild(picker);
setReady(true);
});
return () => {
alive = false;
picker?.remove();
};
}, [theme]);
return (
<div ref={hostRef}>
{!ready && (
<div
className="flex items-center justify-center border border-app-border bg-app-elevated text-sm text-app-faint"
style={{ width: PICKER_W, height: PICKER_H }}
>
{loading}
</div>
)}
</div>
);
}
/** The formatting bar's emoji button and its picker. ``onOpen`` runs before
* the picker takes focus (the editor keeps its caret); ``onPick`` inserts. */
export function EmojiButton({
onOpen,
onPick,
className,
}: {
onOpen: () => void;
onPick: (emoji: string) => void;
className: string;
}) {
const t = useT();
const { resolved } = useTheme();
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const open = () => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
onOpen();
// Below the button, or above it when the window ends first; inside the viewport.
const below = r.bottom + 6 + PICKER_H <= window.innerHeight;
setPos({
left: Math.max(8, Math.min(r.left, window.innerWidth - PICKER_W - 8)),
top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - PICKER_H),
});
};
useEffect(() => {
if (!pos) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (!popRef.current?.contains(target) && !btnRef.current?.contains(target)) setPos(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation(); // the picker, not the compose window
setPos(null);
}
};
document.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey, true);
};
}, [pos]);
return (
<>
<button
ref={btnRef}
type="button"
title={t("compose.fmtEmoji")}
aria-label={t("compose.fmtEmoji")}
aria-expanded={pos !== null}
onMouseDown={(e) => e.preventDefault()} // the editor keeps focus and caret
onClick={() => (pos ? setPos(null) : open())}
className={className}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5c.9 1.3 2.1 2 3.5 2s2.6-.7 3.5-2" />
<path d="M9 9.5h.01M15 9.5h.01" strokeWidth="2.6" />
</svg>
</button>
{pos &&
createPortal(
<div
ref={popRef}
className="fixed z-[70] overflow-hidden rounded-xl shadow-2xl"
style={{ left: pos.left, top: pos.top }}
>
<PickerHost
theme={resolved}
loading={t("compose.emojiLoading")}
onSelect={(native) => {
onPick(native);
setPos(null);
}}
/>
</div>,
document.body,
)}
</>
);
}
@@ -11,6 +11,17 @@ import {
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { notify } from "../AppDialog";
import { EmojiButton } from "./EmojiButton";
import {
GHOST,
acceptGhost,
caretContext,
ghostEl,
removeGhost,
showGhost,
typedIntoGhost,
} from "./completion";
import { import {
canHighlight, canHighlight,
clearPaint, clearPaint,
@@ -94,9 +105,10 @@ function addQuoteToggle(el: HTMLElement, title: string) {
} }
function contentHtml(el: HTMLElement): string { function contentHtml(el: HTMLElement): string {
if (!el.querySelector(`.${QUOTE_TOGGLE}`)) return el.innerHTML; // The quote pill and a suggestion's grey text are the editor's, not the message's.
if (!el.querySelector(`.${QUOTE_TOGGLE}, .${GHOST}`)) return el.innerHTML;
const copy = el.cloneNode(true) as HTMLElement; const copy = el.cloneNode(true) as HTMLElement;
copy.querySelectorAll(`.${QUOTE_TOGGLE}`).forEach((n) => n.remove()); copy.querySelectorAll(`.${QUOTE_TOGGLE}, .${GHOST}`).forEach((n) => n.remove());
return copy.innerHTML; return copy.innerHTML;
} }
@@ -247,6 +259,70 @@ export const RichTextEditor = forwardRef<
const [spellPos, setSpellPos] = useState<{ left: number; top: number } | null>(null); const [spellPos, setSpellPos] = useState<{ left: number; top: number } | null>(null);
const spellPopupRef = useRef<HTMLDivElement>(null); const spellPopupRef = useRef<HTMLDivElement>(null);
// Sentence completion (./completion.ts): grey text after the caret, Tab takes it.
const completionOn = settings.data?.completion_enabled ?? true;
const completionOnRef = useRef(completionOn);
completionOnRef.current = completionOn;
const completionSeq = useRef(0);
const completionTimer = useRef<number | null>(null);
const composing = useRef(false); // an input method is building a character
const scheduleCompletion = () => {
const el = ref.current;
if (completionTimer.current) window.clearTimeout(completionTimer.current);
const seq = ++completionSeq.current;
if (!el || !completionOnRef.current || ghostEl(el)) return;
completionTimer.current = window.setTimeout(async () => {
if (composing.current || document.activeElement !== el) return;
const at = caretContext(el);
if (!at) return;
let suggestion: string | null = null;
try {
suggestion = (await api.complete(at.before)).suggestion;
} catch {
return; // offline, or the server says no: simply no suggestion
}
// Still where it was asked for? (The user may have typed on, or moved.)
const now = caretContext(el);
if (!suggestion || seq !== completionSeq.current || !now || now.before !== at.before) return;
removeGhost(el);
showGhost(now.range, suggestion, "Tab");
}, 300);
};
const dropGhost = () => {
completionSeq.current++;
if (ref.current) removeGhost(ref.current);
};
// Native input events the ghost answers to: typing over it, leaving, IME.
useEffect(() => {
const el = ref.current;
if (!el) return;
const onBeforeInput = (e: InputEvent) => {
if (typedIntoGhost(el, e)) completionSeq.current++; // kept: no new request
};
const onCompositionStart = () => {
composing.current = true;
dropGhost();
};
const onCompositionEnd = () => {
composing.current = false;
};
el.addEventListener("beforeinput", onBeforeInput);
el.addEventListener("blur", dropGhost);
el.addEventListener("mousedown", dropGhost);
el.addEventListener("compositionstart", onCompositionStart);
el.addEventListener("compositionend", onCompositionEnd);
return () => {
el.removeEventListener("beforeinput", onBeforeInput);
el.removeEventListener("blur", dropGhost);
el.removeEventListener("mousedown", dropGhost);
el.removeEventListener("compositionstart", onCompositionStart);
el.removeEventListener("compositionend", onCompositionEnd);
if (completionTimer.current) window.clearTimeout(completionTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A reply's quoted original starts folded away behind the "•••" pill. // A reply's quoted original starts folded away behind the "•••" pill.
const [quoteOpen, setQuoteOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false);
@@ -274,6 +350,7 @@ export const RichTextEditor = forwardRef<
replaceSignature: (html: string) => { replaceSignature: (html: string) => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
removeGhost(el);
const sig = el.querySelector(".wmc-signature"); const sig = el.querySelector(".wmc-signature");
if (html) { if (html) {
if (sig) sig.innerHTML = html; if (sig) sig.innerHTML = html;
@@ -285,6 +362,7 @@ export const RichTextEditor = forwardRef<
insertSignature: (html: string) => { insertSignature: (html: string) => {
const el = ref.current; const el = ref.current;
if (!el || !html) return; if (!el || !html) return;
removeGhost(el);
el.querySelector(".wmc-signature")?.remove(); el.querySelector(".wmc-signature")?.remove();
const spacerBefore = document.createElement("div"); const spacerBefore = document.createElement("div");
spacerBefore.innerHTML = "<br>"; spacerBefore.innerHTML = "<br>";
@@ -304,6 +382,7 @@ export const RichTextEditor = forwardRef<
insertHtml: (html: string) => { insertHtml: (html: string) => {
const el = ref.current; const el = ref.current;
if (!el || !html) return; if (!el || !html) return;
removeGhost(el);
el.focus(); el.focus();
// Insert at the current caret; if that fails (no selection in the editor), // Insert at the current caret; if that fails (no selection in the editor),
// append before the signature/quoted block. // append before the signature/quoted block.
@@ -430,6 +509,7 @@ export const RichTextEditor = forwardRef<
}; };
const exec = (command: string, value?: string) => { const exec = (command: string, value?: string) => {
dropGhost();
ref.current?.focus(); ref.current?.focus();
document.execCommand(command, false, value); document.execCommand(command, false, value);
emit(); emit();
@@ -481,7 +561,7 @@ export const RichTextEditor = forwardRef<
const insertImage = (file: File) => { const insertImage = (file: File) => {
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
window.alert(t("compose.imageTooLarge")); notify(t("compose.imageTooLarge"), 5000);
return; return;
} }
const reader = new FileReader(); const reader = new FileReader();
@@ -771,6 +851,23 @@ export const RichTextEditor = forwardRef<
}; };
const onEditorKeyDown = (e: React.KeyboardEvent) => { const onEditorKeyDown = (e: React.KeyboardEvent) => {
const el = ref.current;
if (el && ghostEl(el)) {
if (e.key === "Tab" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
if (acceptGhost(el)) emit();
return;
}
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation(); // the suggestion, not the compose window
dropGhost();
return;
}
// Moving, deleting, Enter…: the suggestion no longer fits. Typing is
// handled by beforeinput (it may shorten the suggestion instead).
if (e.key.length > 1 && e.key !== "Shift" && e.key !== "Dead") dropGhost();
}
// Typing (or Escape) in the message puts the link popup away. Escape stops // Typing (or Escape) in the message puts the link popup away. Escape stops
// here: the mail view's own Escape would otherwise minimise the whole window. // here: the mail view's own Escape would otherwise minimise the whole window.
if (spellIssue) { if (spellIssue) {
@@ -926,6 +1023,15 @@ export const RichTextEditor = forwardRef<
e.target.value = ""; e.target.value = "";
}} }}
/> />
<EmojiButton
className="flex h-7 w-7 items-center justify-center rounded text-app-muted hover:bg-app-hover hover:text-app-fg"
onOpen={saveSelection}
onPick={(emoji) => {
ref.current?.focus();
restoreSelection();
exec("insertText", emoji);
}}
/>
<Sep /> <Sep />
<Btn <Btn
onClick={() => exec("justifyLeft")} onClick={() => exec("justifyLeft")}
@@ -964,6 +1070,7 @@ export const RichTextEditor = forwardRef<
onInput={(e) => { onInput={(e) => {
onInput(contentHtml(e.currentTarget)); onInput(contentHtml(e.currentTarget));
scheduleSpellcheck(); scheduleSpellcheck();
scheduleCompletion();
}} }}
// Our own checker replaces the browser's, rather than doubling it up. // Our own checker replaces the browser's, rather than doubling it up.
spellCheck={!spellOn} spellCheck={!spellOn}
@@ -0,0 +1,69 @@
import { useState } from "react";
import { useT } from "../../i18n";
import { ApiError } from "../../lib/api";
import type { MailTemplate } from "../../lib/types";
import { AppDialog, DialogButtons } from "../AppDialog";
/** Compose → More → Templates → Save as template: name it, save it. A name
* already taken turns the button into "Replace" rather than asking again. */
export function SaveTemplateDialog({
initialName,
existing,
onSave,
onClose,
}: {
initialName: string;
existing: MailTemplate[];
onSave: (name: string) => Promise<void>;
onClose: () => void;
}) {
const t = useT();
const [name, setName] = useState(initialName);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const trimmed = name.trim();
const taken = existing.some((x) => x.name.toLowerCase() === trimmed.toLowerCase());
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!trimmed || saving) return;
setSaving(true);
setError("");
try {
await onSave(trimmed);
onClose();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("compose.templateSaveFailed"));
setSaving(false);
}
};
return (
<AppDialog title={t("compose.saveTemplateTitle")} onClose={onClose}>
<form onSubmit={submit}>
<p className="mt-2 text-sm text-app-muted">{t("compose.saveTemplateHint")}</p>
<label className="mt-4 block text-sm">
<span className="text-app-muted">{t("compose.templateName")}</span>
<input
className="mt-1 w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
value={name}
maxLength={80}
placeholder={t("compose.templateNamePlaceholder")}
onChange={(e) => setName(e.target.value)}
autoFocus
onFocus={(e) => e.currentTarget.select()}
/>
</label>
{taken && <p className="mt-2 text-xs text-amber-600 dark:text-amber-400">{t("compose.templateReplaceNote")}</p>}
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
<DialogButtons
submit
confirmLabel={saving ? t("compose.templateSaving") : taken ? t("compose.templateReplaceAction") : t("common.save")}
disabled={!trimmed || saving}
onCancel={onClose}
/>
</form>
</AppDialog>
);
}
@@ -0,0 +1,110 @@
// Sentence completion in the compose editor: the grey "ghost" text after the
// caret (suggested by /api/completion, app/completion.py) that Tab accepts.
//
// The ghost is a non-editable span right after the caret, so it wraps with the
// paragraph like real text. It is never part of the message: contentHtml()
// leaves it out, the spell checker skips it, and it goes on any other key.
export const GHOST = "wmc-ghost";
// Where a suggestion would not belong.
const NO_GHOST = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle, blockquote, a, pre, code";
const BLOCKS = "div, p, li, h1, h2, h3";
export function ghostEl(root: HTMLElement): HTMLElement | null {
return root.querySelector<HTMLElement>(`.${GHOST}`);
}
export function removeGhost(root: HTMLElement): void {
root.querySelectorAll(`.${GHOST}`).forEach((n) => n.remove());
}
/** The current line's text up to the caret, when a suggestion may be shown
* there: a collapsed caret at the end of its line, outside signature, quote
* and links. Null otherwise. */
export function caretContext(root: HTMLElement): { before: string; range: Range } | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
const range = sel.getRangeAt(0);
if (!root.contains(range.startContainer)) return null;
const node = range.startContainer;
const el = node instanceof Element ? node : node.parentElement;
if (!el || el.closest(NO_GHOST)) return null;
const block = el.closest(BLOCKS);
const scope = block && block !== root && root.contains(block) ? block : root;
// Only at the end of the line: nothing but whitespace after the caret.
const after = document.createRange();
after.selectNodeContents(scope);
after.setStart(range.startContainer, range.startOffset);
if (after.toString().replace(/[\s\u200b]/g, "")) return null;
// The text before the caret, a <br> counting as a line break.
let text = "";
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
for (let n: Node | null = walker.nextNode(); n; n = walker.nextNode()) {
if (n === range.startContainer && n.nodeType === Node.TEXT_NODE) {
text += (n as Text).data.slice(0, range.startOffset);
break;
}
if (n.nodeType === Node.ELEMENT_NODE && n === range.startContainer) {
// The caret sits between children: take those before it.
const kids = Array.from(n.childNodes).slice(0, range.startOffset);
text += kids.map((k) => k.textContent ?? "").join("");
break;
}
if (range.comparePoint(n, 0) > 0) break; // past the caret
if (n.nodeType === Node.TEXT_NODE) text += (n as Text).data;
else if ((n as Element).tagName === "BR") text += "\n";
}
const line = (text.split("\n").pop() ?? "").replace(/\u00a0/g, " ");
if (!line.trim()) return null;
return { before: line, range: range.cloneRange() };
}
/** Show ``text`` in grey right after the caret (``range``), the caret kept before it. */
export function showGhost(range: Range, text: string, hint: string): void {
const span = document.createElement("span");
span.className = GHOST;
span.setAttribute("contenteditable", "false");
span.setAttribute("aria-hidden", "true");
span.dataset.hint = hint; // "Tab", drawn after the text by CSS
span.textContent = text;
range.insertNode(span);
const caret = document.createRange();
caret.setStartBefore(span);
caret.collapse(true);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(caret);
}
/** Take the suggestion: real, undoable text where the ghost was. */
export function acceptGhost(root: HTMLElement): boolean {
const ghost = ghostEl(root);
const text = ghost?.textContent ?? "";
if (!ghost || !text) return false;
const caret = document.createRange();
caret.setStartBefore(ghost);
caret.collapse(true);
ghost.remove();
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(caret);
document.execCommand("insertText", false, text);
return true;
}
/** Typing what the ghost shows shortens it ("hea" → "ring from you") rather
* than throwing it away. True when the ghost stays. */
export function typedIntoGhost(root: HTMLElement, e: InputEvent): boolean {
const ghost = ghostEl(root);
if (!ghost) return false;
const shown = ghost.textContent ?? "";
if (e.inputType === "insertText" && e.data && shown.length > e.data.length && shown.startsWith(e.data)) {
ghost.textContent = shown.slice(e.data.length);
return true;
}
ghost.remove();
return false;
}
@@ -0,0 +1,77 @@
// Open compose windows outlive leaving the mail view (Settings, a plugin page)
// and a reload: kept for this tab in sessionStorage, and put back when the mail
// view opens again.
//
// Two parts: the window list (which windows, minimized or expanded), written as
// it changes; and each window's content, written by its Composer as it goes
// away (with the latest text, which autosave may not have reached yet). Only a
// window with content comes back: one that was sent or discarded leaves none.
import type { ComposerInit } from "./Composer";
import type { ComposeWin } from "./windows";
const LIST = "compose.windows";
const SNAP = "compose.snap.";
type Listed = Pick<ComposeWin, "id" | "minimized" | "expanded" | "activated">;
function read<T>(key: string): T | null {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null; // storage blocked or garbled: nothing to restore
}
}
function write(key: string, value: unknown): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage blocked or full: the windows just won't come back */
}
}
export function saveWindowList(wins: ComposeWin[]): void {
write(
LIST,
wins.map(({ id, minimized, expanded, activated }) => ({ id, minimized, expanded, activated })),
);
}
/** A window's content as it goes away (called by its Composer). */
export function saveSnapshot(id: string, init: ComposerInit): void {
write(SNAP + id, init);
}
/** A save that finished after its window went away: the kept content learns
* its draft id, so the window brought back updates that draft instead of
* starting a second one. */
export function noteSnapshotDraft(id: string, draftId: string): void {
const kept = read<ComposerInit>(SNAP + id);
if (kept && !kept.draftId) write(SNAP + id, { ...kept, draftId });
}
/** The windows to reopen, each with its last content. Reads only: React may
* call a state initializer twice (StrictMode), and both must see the same. */
export function restoredWindows(): ComposeWin[] {
const listed = read<Listed[]>(LIST) ?? [];
const out: ComposeWin[] = [];
for (const w of listed) {
const init = read<ComposerInit>(SNAP + w.id);
if (init) out.push({ ...w, init });
}
return out;
}
/** Drop the kept contents once the windows are open again (they keep their own). */
export function clearSnapshots(): void {
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (key?.startsWith(SNAP)) sessionStorage.removeItem(key);
}
} catch {
/* nothing to clear */
}
}
@@ -22,7 +22,7 @@ export interface PlacedIssue extends SpellIssue {
} }
// The signature and the quoted original are not the user's writing to check. // The signature and the quoted original are not the user's writing to check.
const SKIP = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle"; const SKIP = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle, .wmc-ghost";
const BLOCK = /^(DIV|P|LI|UL|OL|BLOCKQUOTE|H[1-6]|TABLE|TR|PRE|SECTION)$/; const BLOCK = /^(DIV|P|LI|UL|OL|BLOCKQUOTE|H[1-6]|TABLE|TR|PRE|SECTION)$/;
/** /**
+14 -2
View File
@@ -10,6 +10,7 @@ import { useSavedThreads } from "../../lib/offline";
import { formatClock, getDateLocale } from "../../lib/timeFormat"; import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types"; import type { Address, ThreadSummary } from "../../lib/types";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
import { Clock } from "../Offline";
import { AvatarImage } from "../SenderAvatar"; import { AvatarImage } from "../SenderAvatar";
import { LabelChips } from "../mail/LabelChips"; import { LabelChips } from "../mail/LabelChips";
@@ -22,6 +23,17 @@ function timeLabel(iso: string): string {
return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" }); return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
} }
// A row's date: when the conversation comes back, while it is snoozed.
function RowDate({ thread }: { thread: ThreadSummary }) {
if (!thread.snoozed_until) return <>{timeLabel(thread.last_message_at)}</>;
return (
<span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400">
<Clock size={13} />
{timeLabel(thread.snoozed_until)}
</span>
);
}
// Stable per-sender hue so a sender's avatar keeps a consistent colour. // Stable per-sender hue so a sender's avatar keeps a consistent colour.
function initials(p: Address | undefined): string { function initials(p: Address | undefined): string {
const name = (p?.name || p?.email || "?").trim(); const name = (p?.name || p?.email || "?").trim();
@@ -354,7 +366,7 @@ export function MessageList({
unread ? "font-bold" : "" unread ? "font-bold" : ""
}`} }`}
> >
{timeLabel(thread.last_message_at)} <RowDate thread={thread} />
</span> </span>
</span> </span>
{/* A narrow desktop list: the actions take the date's place on hover. */} {/* A narrow desktop list: the actions take the date's place on hover. */}
@@ -505,7 +517,7 @@ export function MessageList({
unread ? "font-bold" : "" unread ? "font-bold" : ""
}`} }`}
> >
{timeLabel(thread.last_message_at)} <RowDate thread={thread} />
</span> </span>
</div> </div>
</> </>
@@ -0,0 +1,527 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { avatarColor } from "../../lib/avatarColor";
import { useListDividers } from "../../lib/listDividers";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Subscription, SubscriptionList } from "../../lib/types";
import { useConfirm } from "../AppDialog";
import { EmptyIllustration } from "../EmptyIllustration";
import { Icon } from "../Icon";
import { AvatarImage } from "../SenderAvatar";
export function dateLabel(iso: string): string {
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString()) return formatClock(d);
if (d.getFullYear() === now.getFullYear())
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
}
export function initialsOf(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase();
return (name.trim() || "?").slice(0, 2).toUpperCase();
}
function toast(text: string) {
window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text, ms: 5000 } }));
}
/** What to unsubscribe from: a sender group, or one message's own header. */
export interface UnsubscribeTarget {
key: string;
name: string;
via: "one_click" | "mailto" | "web";
messageId?: string;
}
export function targetOf(sub: Subscription): UnsubscribeTarget {
return {
key: sub.key,
name: sub.name,
via: sub.one_click ? "one_click" : sub.has_mailto ? "mailto" : "web",
};
}
/** Unsubscribe and delete-all, with their confirmations, for the list, the
* sender pane and a message in the reading pane. */
export function useSubscriptionActions() {
const t = useT();
const queryClient = useQueryClient();
const [confirm, dialog] = useConfirm();
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
};
const unsubscribeRun = useMutation({
mutationFn: async (target: UnsubscribeTarget) => {
// Opened now, while the click on Unsubscribe still counts as the user's:
// a page opened after the request returns would be stopped as a pop-up.
const page = target.via === "web" ? window.open("", "_blank") : null;
const result = await api.unsubscribe(target.key, target.messageId);
if (result.status === "open" && result.url) {
if (page) page.location.href = result.url;
else window.open(result.url, "_blank", "noopener");
} else page?.close();
return result;
},
onSuccess: (result, sub) => {
if (!result) return;
refresh();
void queryClient.invalidateQueries({ queryKey: ["thread"] }); // the reading pane's link
toast(
result.method === "web"
? t("subs.doneWeb")
: result.method === "mailto"
? t("subs.doneMailto", { name: sub.name })
: t("subs.doneOneClick", { name: sub.name }),
);
},
onError: () => toast(t("subs.failed")),
});
const trashRun = useMutation({
mutationFn: (sub: Subscription) => api.trashSubscription(sub.key),
onSuccess: (result) => {
if (!result) return;
toast(t("subs.trashed", { count: result.moved }));
// The moves land one by one in the worker; look again once they have.
window.setTimeout(() => {
refresh();
void queryClient.invalidateQueries({ queryKey: ["threads"] });
}, 2500);
},
});
// Asked first, in our own dialog; the request (and its busy state) only after.
const unsubscribe = {
...unsubscribeRun,
mutate: async (target: UnsubscribeTarget) => {
const ok = await confirm({
title: t("subs.confirm", { name: target.name }),
message:
target.via === "one_click"
? t("subs.confirmOneClick")
: target.via === "mailto"
? t("subs.confirmMailto")
: t("subs.confirmWeb"),
confirmLabel: t("subs.unsubscribe"),
});
if (ok) unsubscribeRun.mutate(target);
},
};
const trash = {
...trashRun,
mutate: async (sub: Subscription) => {
const ok = await confirm({
title: t("subs.trashAll"),
message: t("subs.confirmTrash", { name: sub.name }),
confirmLabel: t("subs.trashAllAction"),
danger: true,
});
if (ok) trashRun.mutate(sub);
},
};
/** Render `dialog` wherever the hook is used: it is the confirmation. */
return { unsubscribe, trash, dialog };
}
// Requests in flight at once during a bulk unsubscribe: a one-click request can
// take seconds, and twenty of them one after another would be slow.
const BULK_PARALLEL = 4;
async function eachLimited<T>(items: T[], run: (item: T) => Promise<void>): Promise<void> {
let next = 0;
const worker = async () => {
while (next < items.length) await run(items[next++]);
};
await Promise.all(Array.from({ length: Math.min(BULK_PARALLEL, items.length) }, worker));
}
const isDone = (sub: Subscription) => sub.unsubscribed_at !== null && !sub.still_sending;
/** Unsubscribe from / delete the mail of several senders at once (the
* Subscriptions folder's checkboxes). Senders that can only be left on their
* own web page are collected for the user to open one by one: a browser opens
* one new tab per click, not twenty. */
export function useBulkSubscriptionActions(onDone: () => void) {
const t = useT();
const queryClient = useQueryClient();
const [confirm, dialog] = useConfirm();
const [running, setRunning] = useState(false);
const [finishOnPage, setFinishOnPage] = useState<{ name: string; url: string }[]>([]);
const unsubscribeMany = async (subs: Subscription[]) => {
const todo = subs.filter((s) => !isDone(s));
if (todo.length === 0) {
toast(t("subs.bulkNothing"));
return;
}
const ok = await confirm({
title: t("subs.bulkConfirm", { count: todo.length }),
message: t("subs.bulkHow"),
confirmLabel: t("subs.unsubscribe"),
});
if (!ok) return;
setRunning(true);
let done = 0;
let failed = 0;
const pages: { name: string; url: string }[] = [];
await eachLimited(todo, async (sub) => {
try {
const r = await api.unsubscribe(sub.key);
if (r.status === "open" && r.url) pages.push({ name: sub.name, url: r.url });
else done += 1;
} catch {
failed += 1;
}
});
setRunning(false);
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
void queryClient.invalidateQueries({ queryKey: ["thread"] });
toast(
[
t("subs.bulkDone", { count: done }),
pages.length ? t("subs.bulkPages", { count: pages.length }) : "",
failed ? t("subs.bulkFailed", { count: failed }) : "",
]
.filter(Boolean)
.join(" · "),
);
setFinishOnPage(pages);
onDone();
};
const trashMany = async (subs: Subscription[]) => {
if (subs.length === 0) return;
const ok = await confirm({
title: t("subs.trashAll"),
message: t("subs.bulkTrashConfirm", { count: subs.length }),
confirmLabel: t("subs.trashAllAction"),
danger: true,
});
if (!ok) return;
setRunning(true);
let moved = 0;
await eachLimited(subs, async (sub) => {
try {
moved += (await api.trashSubscription(sub.key)).moved;
} catch {
/* the others still go */
}
});
setRunning(false);
toast(t("subs.trashed", { count: moved }));
onDone();
// The moves land one by one in the worker; look again once they have.
window.setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
void queryClient.invalidateQueries({ queryKey: ["threads"] });
}, 2500);
};
return {
unsubscribeMany,
trashMany,
running,
finishOnPage,
closeFinish: () => setFinishOnPage([]),
dialog, // render it: the confirmation
};
}
/** After a bulk unsubscribe: the senders to finish on their own page. */
export function FinishOnPageDialog({
items,
onClose,
}: {
items: { name: string; url: string }[];
onClose: () => void;
}) {
const t = useT();
const [opened, setOpened] = useState<Set<string>>(new Set());
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t("subs.finishTitle")}
className="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
onMouseDown={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold">{t("subs.finishTitle")}</h2>
<p className="mt-1 text-sm text-app-muted">{t("subs.finishHint")}</p>
<ul className="mt-4 divide-y divide-app-border rounded-lg border border-app-border">
{items.map((item) => (
<li key={item.url} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<span className="min-w-0 truncate text-app-fg">{item.name}</span>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpened((prev) => new Set(prev).add(item.url))}
className={`shrink-0 font-medium ${opened.has(item.url) ? "text-app-faint" : "text-app-accent hover:underline"}`}
>
{opened.has(item.url) ? t("subs.pageOpened") : t("subs.openPage")}
</a>
</li>
))}
</ul>
<div className="mt-4 flex justify-end">
<button className="rounded-lg bg-app-accent px-4 py-1.5 text-sm font-medium text-app-accent-fg" onClick={onClose}>
{t("subs.finishDone")}
</button>
</div>
</div>
</div>
);
}
/** The two bulk buttons, for the reading pane's bar (or above the list on a phone). */
export function SubscriptionBulkButtons({
selected,
bulk,
}: {
selected: Subscription[];
bulk: ReturnType<typeof useBulkSubscriptionActions>;
}) {
const t = useT();
const cls =
"wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-50";
return (
<>
<button className={cls} disabled={bulk.running} onClick={() => void bulk.unsubscribeMany(selected)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="M5.6 5.6l12.8 12.8" />
</svg>
{t("subs.unsubscribe")}
</button>
<button className={cls} disabled={bulk.running} onClick={() => void bulk.trashMany(selected)}>
<Icon name="trash" size={16} />
{t("subs.trashAll")}
</button>
{bulk.running && (
<span className="ml-1 h-3.5 w-3.5 animate-spin rounded-full border-2 border-app-faint border-t-transparent" />
)}
</>
);
}
function CheckBox({ checked, partial = false }: { checked: boolean; partial?: boolean }) {
return (
<span
className={`flex h-[17px] w-[17px] items-center justify-center rounded-md border-2 transition-colors ${
checked || partial ? "border-app-accent bg-app-accent text-white" : "border-app-faint hover:border-app-muted"
}`}
>
{checked ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
) : partial ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
<path d="M4 8h8" />
</svg>
) : null}
</span>
);
}
/** One line of figures about a sender: how much, how often, how much is read. */
export function SubscriptionStats({ sub }: { sub: Subscription }) {
const t = useT();
return (
<span>
{t(sub.total === 1 ? "subs.email" : "subs.emails", { count: sub.total })}
{" · "}
{t("subs.perMonth", { count: sub.per_month })}
{sub.unread > 0 && ` · ${t("subs.unread", { count: sub.unread })}`}
</span>
);
}
export function SubscriptionStatus({ sub }: { sub: Subscription }) {
const t = useT();
if (sub.still_sending)
return (
<span className="shrink-0 rounded-full bg-amber-500/15 px-2 text-[12px] font-medium text-amber-700 dark:text-amber-300">
{t("subs.stillSending")}
</span>
);
if (sub.unsubscribed_at)
return (
<span className="shrink-0 rounded-full bg-app-hover px-2 text-[12px] text-app-muted">
{t("subs.unsubscribed")}
</span>
);
return null;
}
/** The Subscriptions folder in the list panel: one row per bulk sender. */
export function SubscriptionsList({
data,
loading,
selectedKey,
onSelect,
checkedKeys,
onToggleCheck,
busyKeys,
compact = false,
}: {
data: SubscriptionList | undefined;
loading: boolean;
selectedKey: string | null;
onSelect: (key: string) => void;
/** Checked rows (bulk actions); a row's avatar turns into its checkbox. */
checkedKeys: Set<string>;
onToggleCheck: (key: string) => void;
/** Rows a bulk action is working on (dimmed). */
busyKeys?: Set<string>;
compact?: boolean;
}) {
const t = useT();
const { unsubscribe, trash, dialog } = useSubscriptionActions();
const subs = data?.subscriptions ?? [];
const checkMode = checkedKeys.size > 0;
const dividers = useListDividers(); // Mailbox display → dividing lines, as in the mail list
return (
<div className="h-full overflow-y-auto pb-2">
{dialog}
{data && data.pending > 0 && (
<div className="mx-3 mt-2 flex items-center gap-2 rounded-lg bg-app-hover px-3 py-2 text-[13px] text-app-muted">
<span className="h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-app-faint border-t-transparent" />
{t("subs.scanning", { count: data.pending })}
</div>
)}
{!loading && subs.length === 0 && (data?.pending ?? 0) === 0 && (
<div className="flex flex-col items-center gap-4 px-6 pt-16 text-center text-app-faint">
<EmptyIllustration className="w-40 max-w-[60%]" />
<p className="text-[15px]">{t("subs.none")}</p>
<p className="max-w-xs text-[13px]">{t("subs.noneHint")}</p>
</div>
)}
{subs.map((sub, index) => {
const selected = sub.key === selectedKey;
const checked = checkedKeys.has(sub.key);
const busy =
busyKeys?.has(sub.key) ||
(unsubscribe.isPending && unsubscribe.variables?.key === sub.key) ||
(trash.isPending && trash.variables?.key === sub.key);
const done = sub.unsubscribed_at !== null && !sub.still_sending;
return (
<div
key={sub.key}
role="button"
tabIndex={0}
onClick={() => onSelect(sub.key)}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onSelect(sub.key)}
className={`group relative cursor-pointer px-2 ${compact ? "pt-3" : "pt-[7px]"} ${busy ? "opacity-50" : ""}`}
>
{dividers && index > 0 && (
// Midway through the gap above the row, lined up with its content.
<div
aria-hidden
className={`pointer-events-none absolute inset-x-5 h-px bg-app-border ${
compact ? "top-1.5" : "top-[3px]"
}`}
/>
)}
<div
className={`flex items-center gap-3 rounded-lg px-3 py-2 ${
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
}`}
>
<div className="group/av relative ml-5 h-[33px] w-[33px] shrink-0">
{sub.unread > 0 && (
<span
aria-hidden
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
/>
)}
{/* The avatar gives way to a checkbox on hover, and everywhere once
anything is checked — as in the mail list. */}
<span
className={`relative h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white ${
checkMode ? "hidden" : compact ? "flex" : "flex group-hover/av:hidden"
}`}
style={{ backgroundColor: avatarColor(sub.email, sub.name, undefined) }}
>
{initialsOf(sub.name)}
<AvatarImage email={sub.email} />
</span>
<button
type="button"
aria-label={t("subs.select")}
aria-pressed={checked}
onClick={(e) => {
e.stopPropagation();
onToggleCheck(sub.key);
}}
className={`absolute inset-0 items-center justify-center ${
checkMode ? "flex" : compact ? "flex opacity-0" : "hidden group-hover/av:flex"
}`}
>
<CheckBox checked={checked} />
</button>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-1.5">
<span className={`min-w-0 truncate text-[15px] text-app-fg ${sub.unread > 0 ? "font-black" : ""}`}>
{sub.name}
</span>
<SubscriptionStatus sub={sub} />
<span
className={`ml-auto shrink-0 whitespace-nowrap pl-2 text-[13px] tabular-nums text-app-faint ${
compact ? "" : "group-hover:hidden"
}`}
>
{dateLabel(sub.last_at)}
</span>
{!compact && (
<span className="ml-auto hidden shrink-0 items-center gap-2 pl-2 group-hover:flex">
<button
type="button"
disabled={busy || done}
onClick={(e) => {
e.stopPropagation();
unsubscribe.mutate(targetOf(sub));
}}
className="wm-online-only rounded px-1.5 text-[13px] font-medium text-app-accent hover:underline disabled:text-app-faint disabled:no-underline"
>
{done ? t("subs.unsubscribed") : t("subs.unsubscribe")}
</button>
<button
type="button"
title={t("subs.trashAll")}
aria-label={t("subs.trashAll")}
disabled={busy}
onClick={(e) => {
e.stopPropagation();
trash.mutate(sub);
}}
className="wm-online-only text-app-muted hover:text-app-fg"
>
<Icon name="trash" size={16} />
</button>
</span>
)}
</div>
<div className="truncate text-[14px] text-app-fg">{sub.last_subject || t("subs.noSubject")}</div>
<div className="truncate text-xs text-app-faint">
<SubscriptionStats sub={sub} />
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { translate } from "../../i18n";
// Small muted pills for a message/thread's labels, shown before the subject in the // Small muted pills for a message/thread's labels, shown before the subject in the
// list and after the subject in the reading pane. With `onRemove`, each pill gets a // list and after the subject in the reading pane. With `onRemove`, each pill gets a
// clickable ✕ (used in the reading pane to drop a label from the thread). // clickable ✕ (used in the reading pane to drop a label from the thread).
@@ -23,7 +25,7 @@ export function LabelChips({
{onRemove && ( {onRemove && (
<button <button
className="text-app-faint hover:text-app-fg" className="text-app-faint hover:text-app-fg"
title="Remove label" title={translate("labels.remove")}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(l); onRemove(l);
+2 -1
View File
@@ -14,6 +14,7 @@ import { api, apiFetch } from "../../lib/api";
import type { AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types"; import type { AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
import { AvatarImage } from "../SenderAvatar"; import { AvatarImage } from "../SenderAvatar";
import { useIsMobile } from "../../lib/useIsMobile"; import { useIsMobile } from "../../lib/useIsMobile";
import { getDateLocale } from "../../lib/timeFormat";
import { loadBundle } from "../../plugins/bundleLoader"; import { loadBundle } from "../../plugins/bundleLoader";
import { usePluginContext } from "../../plugins/context"; import { usePluginContext } from "../../plugins/context";
import { isPreviewable } from "../thread/previewKind"; import { isPreviewable } from "../thread/previewKind";
@@ -433,7 +434,7 @@ function AttachmentsTab({ thread }: { thread: ThreadDetail | undefined }) {
if (groups.length === 0) return <div className="px-4 py-6 text-sm text-app-faint">{t("panel.noAttachments")}</div>; if (groups.length === 0) return <div className="px-4 py-6 text-sm text-app-faint">{t("panel.noAttachments")}</div>;
const senderName = (m: MessageEnvelope) => m.from_addr[0]?.name || m.from_addr[0]?.email || ""; const senderName = (m: MessageEnvelope) => m.from_addr[0]?.name || m.from_addr[0]?.email || "";
const shortDate = (iso: string) => new Date(iso).toLocaleDateString("en-GB", { month: "short", day: "numeric" }); const shortDate = (iso: string) => new Date(iso).toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
return ( return (
<div className="px-3 py-2"> <div className="px-3 py-2">
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import type { MessageKey } from "../../i18n/en";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
const MORNING = 8; // the hour "tomorrow", "this weekend" and "next week" start at
function at(day: Date, hour: number): Date {
const d = new Date(day);
d.setHours(hour, 0, 0, 0);
return d;
}
function addDays(day: Date, n: number): Date {
const d = new Date(day);
d.setDate(d.getDate() + n);
return d;
}
/** The quick choices, in the order shown. Ones already past are left out. */
export function snoozePresets(now: Date): { label: MessageKey; when: Date }[] {
const out: { label: MessageKey; when: Date }[] = [];
// Three hours on, rounded up to the hour, while that is still this evening.
const later = new Date(now);
later.setHours(later.getHours() + 3, 0, 0, 0);
if (later.getDate() === now.getDate() && later.getHours() <= 21) {
out.push({ label: "snooze.laterToday", when: later });
}
out.push({ label: "snooze.tomorrow", when: at(addDays(now, 1), MORNING) });
const day = now.getDay(); // 0 = Sunday
if (day >= 1 && day <= 4) {
out.push({ label: "snooze.weekend", when: at(addDays(now, 6 - day), MORNING) });
}
// The coming Monday — or, on a Sunday, the one after (tomorrow is offered already).
const toMonday = ((8 - day) % 7) || 7;
out.push({ label: "snooze.nextWeek", when: at(addDays(now, toMonday === 1 ? 8 : toMonday), MORNING) });
return out;
}
function whenLabel(d: Date, now: Date): string {
const sameDay = d.toDateString() === now.toDateString();
const day = sameDay
? ""
: d.toLocaleDateString(getDateLocale(), { weekday: "short", day: "numeric", month: "short" }) + ", ";
return day + formatClock(d);
}
// "YYYY-MM-DDTHH:MM" in local time, for <input type="datetime-local">.
function localInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Popover: when should this conversation come back? */
export function SnoozeMenu({
onPick,
onClose,
align = "left",
}: {
onPick: (when: Date) => void;
onClose: () => void;
align?: "left" | "right";
}) {
const t = useT();
const ref = useRef<HTMLDivElement>(null);
const now = new Date();
const [custom, setCustom] = useState(false);
const [value, setValue] = useState(() => localInput(at(addDays(now, 1), MORNING)));
const picked = new Date(value);
const valid = !Number.isNaN(picked.getTime()) && picked.getTime() > Date.now();
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
return (
<div
ref={ref}
role="menu"
className={`absolute top-full z-40 mt-1 w-72 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl ${
align === "right" ? "right-0" : "left-0"
}`}
>
<div className="px-3 pb-1 pt-1.5 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("snooze.until")}
</div>
{snoozePresets(now).map((p) => (
<button
key={p.label}
role="menuitem"
className="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => onPick(p.when)}
>
<span>{t(p.label)}</span>
<span className="text-[13px] text-app-muted">{whenLabel(p.when, now)}</span>
</button>
))}
<div className="my-1 border-t border-app-border" />
{custom ? (
<div className="space-y-2 px-3 py-1.5">
<input
type="datetime-local"
className="w-full rounded border border-app-border bg-app-surface px-2 py-1 text-app-fg"
value={value}
min={localInput(now)}
onChange={(e) => setValue(e.target.value)}
autoFocus
/>
<div className="flex justify-end gap-2">
<button className="rounded px-2.5 py-1 text-app-muted hover:bg-app-hover" onClick={() => setCustom(false)}>
{t("common.cancel")}
</button>
<button
className="rounded bg-app-accent px-2.5 py-1 font-medium text-app-accent-fg disabled:opacity-50"
disabled={!valid}
onClick={() => onPick(picked)}
>
{t("snooze.action")}
</button>
</div>
</div>
) : (
<button
role="menuitem"
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => setCustom(true)}
>
{t("snooze.pick")}
</button>
)}
</div>
);
}
@@ -0,0 +1,151 @@
import { useQuery } from "@tanstack/react-query";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { avatarColor } from "../../lib/avatarColor";
import type { Subscription } from "../../lib/types";
import {
dateLabel,
initialsOf,
SubscriptionStatus,
targetOf,
useSubscriptionActions,
} from "../list/SubscriptionsList";
import { Icon } from "../Icon";
import { AvatarImage } from "../SenderAvatar";
/** The reading pane for a sender picked in Subscriptions: figures, actions, its mail. */
export function SubscriptionPane({
sub,
onOpenThread,
barClass,
onBack,
}: {
sub: Subscription;
onOpenThread: (id: string) => void;
barClass: string;
onBack?: () => void;
}) {
const t = useT();
const { unsubscribe, trash, dialog } = useSubscriptionActions();
const threads = useQuery({
queryKey: ["subscription-threads", sub.key, sub.total],
queryFn: async () => {
const ids = await api.subscriptionThreads(sub.key);
return ids.length ? (await api.threadsByIds(ids)).threads : [];
},
});
const done = sub.unsubscribed_at !== null && !sub.still_sending;
const stat = (label: string, value: string) => (
<div className="rounded-lg bg-app-bg px-3 py-2">
<div className="text-[12px] text-app-faint">{label}</div>
<div className="text-[17px] font-semibold tabular-nums text-app-fg">{value}</div>
</div>
);
const readShare = sub.total ? Math.round(((sub.total - sub.unread) / sub.total) * 100) : 0;
return (
<div className="flex h-full flex-col">
{dialog}
<div className={barClass}>
{onBack && (
<button
type="button"
title={t("common.back")}
aria-label={t("common.back")}
onClick={onBack}
className="-ml-1 mr-1 rounded p-1.5 text-app-fg hover:bg-app-hover"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M15 5l-7 7 7 7" />
</svg>
</button>
)}
<button
className="wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-50"
disabled={done || unsubscribe.isPending}
onClick={() => unsubscribe.mutate(targetOf(sub))}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="M5.6 5.6l12.8 12.8" />
</svg>
{done ? t("subs.unsubscribed") : t("subs.unsubscribe")}
</button>
<button
className="wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-50"
disabled={trash.isPending}
onClick={() => trash.mutate(sub)}
>
<Icon name="trash" size={16} />
{t("subs.trashAll")}
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
<div className="mx-auto max-w-2xl space-y-6">
<div className="flex items-center gap-4">
<span
className="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-full text-lg font-semibold text-white"
style={{ backgroundColor: avatarColor(sub.email, sub.name, undefined) }}
>
{initialsOf(sub.name)}
<AvatarImage email={sub.email} />
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-xl font-semibold text-app-fg">{sub.name}</h2>
<SubscriptionStatus sub={sub} />
</div>
<div className="truncate text-sm text-app-muted">{sub.email}</div>
{sub.list_id && <div className="truncate text-xs text-app-faint">{t("subs.list", { id: sub.list_id })}</div>}
</div>
</div>
{sub.still_sending && (
<p className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-app-fg">
{t("subs.stillSendingHint")}
</p>
)}
{sub.unsubscribed_at && !sub.still_sending && (
<p className="text-sm text-app-muted">
{t("subs.unsubscribedOn", { date: new Date(sub.unsubscribed_at).toLocaleDateString() })}
</p>
)}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{stat(t("subs.statTotal"), String(sub.total))}
{stat(t("subs.statPerMonth"), String(sub.per_month))}
{stat(t("subs.statRead"), `${readShare}%`)}
{stat(t("subs.statSince"), dateLabel(sub.first_at))}
</div>
<div>
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("subs.recent")}
</h3>
<ul className="divide-y divide-app-border overflow-hidden rounded-lg border border-app-border">
{(threads.data ?? []).map((th) => (
<li key={th.id}>
<button
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-app-hover"
onClick={() => onOpenThread(th.id)}
>
<span
className={`min-w-0 flex-1 truncate text-sm text-app-fg ${th.unread_count > 0 ? "font-bold" : ""}`}
>
{th.subject}
</span>
<span className="shrink-0 text-[13px] tabular-nums text-app-faint">
{dateLabel(th.last_message_at)}
</span>
</button>
</li>
))}
{threads.isLoading && <li className="px-3 py-2 text-sm text-app-faint">{t("mail.loadingThread")}</li>}
</ul>
</div>
</div>
</div>
</div>
);
}
+89 -9
View File
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n"; import { languageName, sameLanguage, splitLanguages, useLocale, useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage"; import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
import { fixInvisibleText } from "../../lib/readability"; import { fixInvisibleText } from "../../lib/readability";
@@ -19,11 +19,20 @@ import { Attachments } from "./Attachments";
export function MessageBody({ export function MessageBody({
message, message,
onMailto, onMailto,
translateTo = null,
onLanguage,
onShowOriginal,
}: { }: {
message: MessageEnvelope; message: MessageEnvelope;
onMailto?: (email: string) => void; onMailto?: (email: string) => void;
/** Show it translated into this language (app/translation.py), or as sent. */
translateTo?: string | null;
/** The message's own language, once the server knows it. */
onLanguage?: (code: string) => void;
onShowOriginal?: () => void;
}) { }) {
const t = useT(); const t = useT();
const { locale } = useLocale();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Seed from the server's trusted-sender flag so an already-approved sender // Seed from the server's trusted-sender flag so an already-approved sender
// loads images and shows no "blocked" prompt on reopen. // loads images and shows no "blocked" prompt on reopen.
@@ -42,18 +51,52 @@ export function MessageBody({
const mobileRef = useRef(false); const mobileRef = useRef(false);
mobileRef.current = useIsMobile(); mobileRef.current = useIsMobile();
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error, isPlaceholderData } = useQuery({
queryKey: ["message-html", message.id, allowed], queryKey: ["message-html", message.id, allowed, translateTo],
queryFn: async () => { queryFn: async () => {
// ?images=1 asks the server to render remote images for this one request // ?images=1 asks the server to render remote images for this one request
// (the "Load images" one-off); "Always from this sender" persists separately. // (the "Load images" one-off); "Always from this sender" persists separately.
const url = `/api/messages/${message.id}/html${allowed ? "?images=1" : ""}`; const params = new URLSearchParams();
if (allowed) params.set("images", "1");
if (translateTo) params.set("translate", translateTo);
const query = params.toString();
const url = `/api/messages/${message.id}/html${query ? `?${query}` : ""}`;
const resp = await fetch(url, { credentials: "include" }); const resp = await fetch(url, { credentials: "include" });
if (resp.headers.has("X-WM-Offline") && !resp.ok) throw new Error("offline"); if (resp.headers.has("X-WM-Offline") && !resp.ok) throw new Error("offline");
if (!resp.ok) throw new Error("failed to load message"); if (!resp.ok) {
return resp.text(); if (translateTo) throw new Error("translate");
throw new Error("failed to load message");
}
// Tracking pixels the server took out of the HTML (sanitize.py).
const trackers = Number(resp.headers.get("X-Trackers-Blocked")) || 0;
return {
html: await resp.text(),
trackers,
language: resp.headers.get("X-Content-Language") ?? "",
translatedFrom: resp.headers.get("X-Translated-From"),
};
}, },
staleTime: translateTo ? Infinity : 0, // a translation does not change
// While a translation (or the images) loads, the message stays as it was.
placeholderData: keepPreviousData,
}); });
const translating = translateTo !== null && isPlaceholderData;
// "Translated from Chinese, English": the languages it had besides the one shown.
const translatedFromLabel = (from: string) => {
const names = splitLanguages(from)
.filter((code) => !translateTo || !sameLanguage(code, translateTo))
.map((code) => languageName(code, locale));
if (names.length === 0) return t("mail.translated");
if (names.length === 1) return t("mail.translatedFrom", { language: names[0] });
return t("mail.translatedFromMany", { languages: names.join(", ") });
};
// Tell the header which language this is (for its Translate button).
const reportedLanguage = data?.language;
const onLanguageRef = useRef(onLanguage);
onLanguageRef.current = onLanguage;
useEffect(() => {
if (reportedLanguage) onLanguageRef.current?.(reportedLanguage);
}, [reportedLanguage]);
const resize = useCallback(() => { const resize = useCallback(() => {
const frame = frameRef.current; const frame = frameRef.current;
@@ -247,6 +290,14 @@ export function MessageBody({
return ( return (
<div> <div>
{translating && (
<div
className="wm-progress"
role="progressbar"
aria-label={t("mail.translating")}
title={t("mail.translating")}
/>
)}
{message.has_remote_images && !allowed && ( {message.has_remote_images && !allowed && (
<div className="flex flex-wrap items-center gap-3 border-b border-app-border bg-amber-100 px-3 py-1.5 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-200"> <div className="flex flex-wrap items-center gap-3 border-b border-app-border bg-amber-100 px-3 py-1.5 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-200">
<span>{t("mail.imagesBlocked")}</span> <span>{t("mail.imagesBlocked")}</span>
@@ -258,8 +309,37 @@ export function MessageBody({
</button> </button>
</div> </div>
)} )}
{data?.translatedFrom && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border bg-app-accent/10 px-3 py-1.5 text-xs text-app-fg">
<span>{translatedFromLabel(data.translatedFrom)}</span>
<button className="font-medium text-app-accent hover:underline" onClick={onShowOriginal}>
{t("mail.showOriginal")}
</button>
</div>
)}
{isError && error.message === "translate" && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border bg-red-500/10 px-3 py-1.5 text-xs text-app-fg">
<span>{t("mail.translateFailed")}</span>
<button className="font-medium text-app-accent hover:underline" onClick={onShowOriginal}>
{t("mail.showOriginal")}
</button>
</div>
)}
{data !== undefined && data.trackers > 0 && (
// A darker strip of its own, like the amber one about images above it.
<div
className="flex items-center gap-1.5 border-b border-app-border bg-zinc-200 px-3 py-1.5 text-xs text-zinc-700 dark:bg-zinc-700/70 dark:text-zinc-200"
title={t("mail.trackersHint")}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6z" />
<path d="M9 12l2 2 4-4" />
</svg>
{data.trackers === 1 ? t("mail.trackerBlocked") : t("mail.trackersBlocked", { count: data.trackers })}
</div>
)}
{isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>} {isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>}
{isError && ( {isError && error.message !== "translate" && (
<div className={`p-3 text-sm ${error.message === "offline" ? "text-app-faint" : "text-red-500"}`}> <div className={`p-3 text-sm ${error.message === "offline" ? "text-app-faint" : "text-red-500"}`}>
{error.message === "offline" ? t("offline.notAvailable") : t("mail.loadFailed")} {error.message === "offline" ? t("offline.notAvailable") : t("mail.loadFailed")}
</div> </div>
@@ -271,7 +351,7 @@ export function MessageBody({
ref={frameRef} ref={frameRef}
title={`message-${message.id}`} title={`message-${message.id}`}
sandbox="allow-same-origin" sandbox="allow-same-origin"
srcDoc={dark ? withDarkPage(data) : data} srcDoc={dark ? withDarkPage(data.html) : data.html}
onLoad={onLoad} onLoad={onLoad}
style={{ height }} style={{ height }}
className={`block w-full rounded-lg border-0 ${dark ? "bg-[#212427]" : "bg-white"}`} className={`block w-full rounded-lg border-0 ${dark ? "bg-[#212427]" : "bg-white"}`}
+235 -96
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n"; import { languageName, needsTranslation, sameLanguage, translate, useLocale, useT } from "../../i18n";
import type { MessageKey } from "../../i18n/en";
import { personHue } from "../../lib/avatarColor"; import { personHue } from "../../lib/avatarColor";
import { setDarkMessages, useDarkMessages } from "../../lib/darkMessage"; import { setDarkMessages, useDarkMessages } from "../../lib/darkMessage";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -17,6 +18,7 @@ import { Icon } from "../Icon";
import { AvatarImage } from "../SenderAvatar"; import { AvatarImage } from "../SenderAvatar";
import { clearPdfPasswordCache } from "./AttachmentPreview"; import { clearPdfPasswordCache } from "./AttachmentPreview";
import { LabelChips } from "../mail/LabelChips"; import { LabelChips } from "../mail/LabelChips";
import { useSubscriptionActions } from "../list/SubscriptionsList";
import { MessageBody } from "./MessageBody"; import { MessageBody } from "./MessageBody";
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4). // Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
@@ -54,13 +56,13 @@ function attributionFor(msg: MessageEnvelope): string {
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); });
const time = d.toLocaleTimeString("en-US", { const time = d.toLocaleTimeString(getDateLocale(), {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
hour12: getHour12(), hour12: getHour12(),
}); });
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || ""; const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
return `On ${date}, at ${time}, ${who} wrote:`; return translate("compose.attribution", { date, time, who });
} }
/** The header block above a forwarded message, as mail programs write it: /** The header block above a forwarded message, as mail programs write it:
@@ -137,16 +139,16 @@ function hue(msg: MessageEnvelope): number {
function fmtRelative(iso: string): string { function fmtRelative(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
const base = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); const base = d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
const diff = Date.now() - d.getTime(); const diff = Date.now() - d.getTime();
const mins = Math.round(diff / 60000); const mins = Math.round(diff / 60000);
const hrs = Math.round(diff / 3600000); const hrs = Math.round(diff / 3600000);
const days = Math.round(diff / 86400000); const days = Math.round(diff / 86400000);
let rel = ""; let rel = "";
if (mins < 1) rel = "just now"; if (mins < 1) rel = translate("time.justNow");
else if (mins < 60) rel = `${mins} min ago`; else if (mins < 60) rel = translate("time.minAgo", { count: mins });
else if (hrs < 24) rel = `${hrs} hour${hrs === 1 ? "" : "s"} ago`; else if (hrs < 24) rel = translate("time.hoursAgo", { count: hrs });
else if (days < 30) rel = `${days} day${days === 1 ? "" : "s"} ago`; else if (days < 30) rel = translate("time.daysAgo", { count: days });
return rel ? `${base} (${rel})` : `${base} ${d.getFullYear()}`; return rel ? `${base} (${rel})` : `${base} ${d.getFullYear()}`;
} }
@@ -162,23 +164,56 @@ function fmtFull(iso: string): string {
}); });
} }
// "Unsubscribe" beside the sender of bulk mail (its List-Unsubscribe header),
// as Gmail has it; "Unsubscribed" once done.
function UnsubscribeLink({ message, name }: { message: MessageEnvelope; name: string }) {
const t = useT();
const { unsubscribe, dialog } = useSubscriptionActions();
if (message.unsubscribed) {
return <span className="shrink-0 text-xs text-app-faint">{t("subs.unsubscribed")}</span>;
}
return (
<>
{dialog}
<button
className="wm-online-only shrink-0 rounded text-xs font-medium text-app-accent hover:underline disabled:opacity-50"
disabled={unsubscribe.isPending}
onClick={() =>
message.subscription_key &&
message.unsubscribe_via &&
unsubscribe.mutate({
key: message.subscription_key,
name,
via: message.unsubscribe_via,
messageId: message.id,
})
}
>
{t("subs.unsubscribe")}
</button>
</>
);
}
function fmtSize(bytes: number): string { function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
const LOCATION_LABEL: Record<string, string> = { // A message's folder, named as in the sidebar (in the interface language).
inbox: "Inbox", const LOCATION_LABEL: Record<string, MessageKey> = {
sent: "Sent", inbox: "nav.inbox",
archive: "Archive", sent: "nav.sent",
drafts: "Drafts", archive: "nav.archive",
trash: "Trash", drafts: "nav.drafts",
junk: "Spam", trash: "nav.trash",
junk: "nav.spam",
}; };
function ActionsMenu({ function ActionsMenu({
messageId, messageId,
mailboxId,
onReply, onReply,
onPrint, onPrint,
onDelete, onDelete,
@@ -187,8 +222,13 @@ function ActionsMenu({
messageLabels, messageLabels,
onToggleLabel, onToggleLabel,
onCreateLabel, onCreateLabel,
translateLanguages,
translateTo,
onTranslateTo,
}: { }: {
messageId: string; messageId: string;
/** The message's folder: a move gives plugin items a fresh start. */
mailboxId: string;
onReply: (kind: string) => void; onReply: (kind: string) => void;
onPrint: () => void; onPrint: () => void;
onDelete: () => void; onDelete: () => void;
@@ -197,10 +237,16 @@ function ActionsMenu({
messageLabels: string[]; messageLabels: string[];
onToggleLabel: (label: string) => void; onToggleLabel: (label: string) => void;
onCreateLabel: (name: string) => void; onCreateLabel: (name: string) => void;
/** Languages a message can be translated into; empty: translation is off. */
translateLanguages: string[];
translateTo: string | null;
onTranslateTo: (code: string | null) => void;
}) { }) {
const t = useT(); const t = useT();
const { locale } = useLocale();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [labelsOpen, setLabelsOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false);
const [translateOpen, setTranslateOpen] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -220,7 +266,7 @@ function ActionsMenu({
}; };
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); } if (!open) { setLabelsOpen(false); setTranslateOpen(false); setCreating(false); setNewName(""); }
}, [open]); }, [open]);
const item = (label: string, onClick: () => void, disabled = false) => ( const item = (label: string, onClick: () => void, disabled = false) => (
@@ -249,79 +295,121 @@ function ActionsMenu({
{t("mail.actions")} {t("mail.actions")}
<span className="text-[11px]">▾</span> <span className="text-[11px]">▾</span>
</button> </button>
{open && ( {/* Always rendered, hidden while closed: plugin items (below) load with
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg"> the message, not when the menu opens — on a slow connection their
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)} fetch would otherwise hold them back for seconds. */}
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)} <div
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)} hidden={!open}
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)} className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg"
{item(t("mail.print"), onPrint)} >
<div className="my-1 border-t border-app-border" /> {item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
{/* Labels submenu: multi-select checkboxes (menu stays open) + create new. */} {item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
<button {item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover" {item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
onClick={() => setLabelsOpen((v) => !v)} {item(t("mail.print"), onPrint)}
> {translateLanguages.length > 0 && (
{t("mail.labels")} <>
<span className="text-[11px] text-app-muted">{labelsOpen ? "▾" : "▸"}</span> {/* Translate to…: any language the server has; ✓ the one shown. */}
</button> <button
{labelsOpen && ( className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
<div className="max-h-52 overflow-auto border-y border-app-border bg-app-surface/40"> onClick={() => setTranslateOpen((v) => !v)}
{allLabels.map((l) => ( >
<label {t("mail.translateTo")}
key={l} <span className="text-[11px] text-app-muted">{translateOpen ? "▾" : "▸"}</span>
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs text-app-fg hover:bg-app-hover" </button>
> {translateOpen && (
<input <div className="max-h-56 overflow-auto border-y border-app-border bg-app-surface/40">
type="checkbox" {translateTo &&
className="h-3.5 w-3.5 accent-app-accent" item(t("mail.showOriginal"), () => onTranslateTo(null))}
checked={messageLabels.includes(l)} {translateLanguages
onChange={() => onToggleLabel(l)} .map((code) => ({ code, name: languageName(code, locale) }))
/> .sort((a, b) => a.name.localeCompare(b.name, locale))
<span className="truncate">{l}</span> .map(({ code, name }) => (
</label> <button
))} key={code}
{allLabels.length === 0 && ( className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs text-app-fg hover:bg-app-hover"
<div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div> onClick={() => {
)} setOpen(false);
{creating ? ( onTranslateTo(code);
}}
>
<span className="w-3 shrink-0 text-app-accent">{translateTo === code ? "✓" : ""}</span>
<span className="truncate first-letter:uppercase">{name}</span>
</button>
))}
</div>
)}
</>
)}
<div className="my-1 border-t border-app-border" />
{/* Labels submenu: multi-select checkboxes (menu stays open) + create new. */}
<button
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
onClick={() => setLabelsOpen((v) => !v)}
>
{t("mail.labels")}
<span className="text-[11px] text-app-muted">{labelsOpen ? "▾" : "▸"}</span>
</button>
{labelsOpen && (
<div className="max-h-52 overflow-auto border-y border-app-border bg-app-surface/40">
{allLabels.map((l) => (
<label
key={l}
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs text-app-fg hover:bg-app-hover"
>
<input <input
autoFocus type="checkbox"
value={newName} className="h-3.5 w-3.5 accent-app-accent"
onChange={(e) => setNewName(e.target.value)} checked={messageLabels.includes(l)}
onKeyDown={(e) => { onChange={() => onToggleLabel(l)}
if (e.key === "Enter" && newName.trim()) {
onCreateLabel(newName.trim());
setNewName("");
setCreating(false);
} else if (e.key === "Escape") {
setCreating(false);
setNewName("");
}
}}
placeholder={t("mail.labelName")}
className="m-1 w-[calc(100%-0.5rem)] rounded border border-app-border bg-app-surface px-2 py-1 text-xs outline-none focus:border-app-accent"
/> />
) : ( <span className="truncate">{l}</span>
<button </label>
className="block w-full px-3 py-1.5 text-left text-xs text-app-accent hover:bg-app-hover" ))}
onClick={() => setCreating(true)} {allLabels.length === 0 && (
> <div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div>
{t("mail.createLabel")} )}
</button> {creating ? (
)} <input
</div> autoFocus
)} value={newName}
{/* Plugin verbs that belong in the menu rather than on the header onChange={(e) => setNewName(e.target.value)}
(SPEC §11.3 message.actions.menu). Clicking anything inside closes onKeyDown={(e) => {
the menu, so a bundle only has to perform its action. */} if (e.key === "Enter" && newName.trim()) {
<div onClick={() => setOpen(false)}> onCreateLabel(newName.trim());
<PluginSlot name="message.actions.menu" props={{ messageId }} /> setNewName("");
setCreating(false);
} else if (e.key === "Escape") {
setCreating(false);
setNewName("");
}
}}
placeholder={t("mail.labelName")}
className="m-1 w-[calc(100%-0.5rem)] rounded border border-app-border bg-app-surface px-2 py-1 text-xs outline-none focus:border-app-accent"
/>
) : (
<button
className="block w-full px-3 py-1.5 text-left text-xs text-app-accent hover:bg-app-hover"
onClick={() => setCreating(true)}
>
{t("mail.createLabel")}
</button>
)}
</div> </div>
<div className="my-1 border-t border-app-border" /> )}
{item(t("mail.delete"), onDelete)} {/* Plugin verbs that belong in the menu rather than on the header
(SPEC §11.3 message.actions.menu). Clicking anything inside closes
the menu, so a bundle only has to perform its action. */}
<div onClick={() => setOpen(false)}>
<PluginSlot
key={`${messageId}:${mailboxId}`}
name="message.actions.menu"
props={{ messageId }}
/>
</div> </div>
)} <div className="my-1 border-t border-app-border" />
{item(t("mail.delete"), onDelete)}
</div>
</div> </div>
); );
} }
@@ -374,7 +462,24 @@ function MessageItem({
ownAddresses: Set<string>; ownAddresses: Set<string>;
}) { }) {
const t = useT(); const t = useT();
const { locale } = useLocale();
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
// Translation (app/translation.py): the message's languages, as the server
// found them ("zh-Hans,pl,en"), and the language it is shown in (null: as sent).
const [language, setLanguage] = useState(message.content_language ?? "");
const [translateTo, setTranslateTo] = useState<string | null>(null);
const translation = useQuery({
queryKey: ["translation-languages"],
queryFn: api.translationLanguages,
staleTime: 10 * 60_000,
});
const translateCodes = translation.data?.available
? translation.data.languages.map((l) => l.code)
: [];
const offerTranslate =
translateCodes.length > 0 &&
(translateTo !== null || needsTranslation(language, locale));
const target = translateCodes.find((c) => sameLanguage(c, locale)) ?? null;
const replyBtn = (kind: string, label: string, icon: string) => ( const replyBtn = (kind: string, label: string, icon: string) => (
<button <button
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover" className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
@@ -435,19 +540,30 @@ function MessageItem({
<div className="flex items-start gap-3 px-4 pb-2 pt-3"> <div className="flex items-start gap-3 px-4 pb-2 pt-3">
{avatar()} {avatar()}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{/* line 1: sender + email ············· date (whole row collapses) */} {/* line 1: sender + email · Unsubscribe ··········· date (the row collapses) */}
<button <div className="flex w-full items-baseline gap-2">
className="flex w-full items-baseline justify-between gap-2 text-left" <button className="min-w-0 truncate text-left text-[15px]" onClick={onToggle}>
onClick={onToggle}
>
<span className="min-w-0 truncate text-[15px]">
{senderName} {senderName}
{message.from_addr[0]?.email && ( {message.from_addr[0]?.email && (
<span className="text-app-faint"> &lt;{message.from_addr[0].email}&gt;</span> <span className="text-app-faint"> &lt;{message.from_addr[0].email}&gt;</span>
)} )}
</span> </button>
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span> {message.subscription_key && message.unsubscribe_via && (
</button> <UnsubscribeLink
message={message}
name={message.from_addr[0]?.name || message.from_addr[0]?.email || ""}
/>
)}
<button
className="min-w-4 flex-1 self-stretch"
onClick={onToggle}
tabIndex={-1}
aria-hidden
/>
<button className="shrink-0 text-xs text-app-faint" onClick={onToggle}>
{fmtRelative(message.date)}
</button>
</div>
{/* line 2: to … ▼ ············· location ○ reply actions */} {/* line 2: to … ▼ ············· location ○ reply actions */}
<div className="mt-0.5 flex items-center justify-between gap-2"> <div className="mt-0.5 flex items-center justify-between gap-2">
<button <button
@@ -485,8 +601,24 @@ function MessageItem({
{multiRecipient ? t("mail.replyAll") : t("mail.reply")} {multiRecipient ? t("mail.replyAll") : t("mail.reply")}
</button> </button>
)} )}
{offerTranslate && (target || translateTo) && (
<button
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => setTranslateTo(translateTo ? null : target)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M4 5h9M8.5 3v2M6 5c.6 3.4 2.6 6 6 7.5M11 5c-.6 3.4-2.8 6.3-7 8" />
<path d="M13 21l4-10 4 10M14.5 17.5h5" />
</svg>
{translateTo ? t("mail.showOriginal") : t("mail.translate")}
</button>
)}
<ActionsMenu <ActionsMenu
messageId={message.id} messageId={message.id}
mailboxId={message.mailbox_id}
translateLanguages={translateCodes}
translateTo={translateTo}
onTranslateTo={setTranslateTo}
onReply={onReply} onReply={onReply}
onPrint={onPrint} onPrint={onPrint}
onDelete={onDelete} onDelete={onDelete}
@@ -533,7 +665,13 @@ function MessageItem({
props={{ messageId: message.id, hasAttachments: message.has_attachments }} props={{ messageId: message.id, hasAttachments: message.has_attachments }}
/> />
<div className="px-4 pb-3"> <div className="px-4 pb-3">
<MessageBody message={message} onMailto={onMailto} /> <MessageBody
message={message}
onMailto={onMailto}
translateTo={translateTo}
onLanguage={setLanguage}
onShowOriginal={() => setTranslateTo(null)}
/>
</div> </div>
{showReplyBar && canReply && ( {showReplyBar && canReply && (
<div className="flex gap-2 border-t border-app-border px-4 py-3"> <div className="flex gap-2 border-t border-app-border px-4 py-3">
@@ -658,7 +796,8 @@ export function ThreadView({
}, [mailboxes]); }, [mailboxes]);
const locationFor = (msg: MessageEnvelope) => { const locationFor = (msg: MessageEnvelope) => {
const su = specialUse.get(msg.mailbox_id); const su = specialUse.get(msg.mailbox_id);
return su ? (LOCATION_LABEL[su] ?? su.charAt(0).toUpperCase() + su.slice(1)) : ""; if (!su) return "";
return LOCATION_LABEL[su] ? t(LOCATION_LABEL[su]) : su.charAt(0).toUpperCase() + su.slice(1);
}; };
const trashFor = (accountId: string) => const trashFor = (accountId: string) =>
(mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id; (mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id;
+169 -3
View File
@@ -1,6 +1,9 @@
// English message catalog. Adding a locale = drop a sibling file with the same // English message catalog — the main language and the reference: other
// keys and register it in i18n/index.tsx. Keys are flat, dotted namespaces. // language files (pl.ts, …) use these keys, and any string a language lacks is
// Interpolate with {name} placeholders. // shown in English. Adding a language = a sibling file typed as `Catalog`,
// registered in i18n/index.tsx.
// Keys are flat, dotted namespaces. Interpolate with {name} placeholders;
// counted strings take plural forms as "<key>_one", "<key>_few", … (index.tsx).
export const en = { export const en = {
"common.appName": "Ultimo Email", "common.appName": "Ultimo Email",
@@ -79,12 +82,74 @@ export const en = {
"nav.sent": "Sent", "nav.sent": "Sent",
"nav.spam": "Spam", "nav.spam": "Spam",
"nav.trash": "Trash", "nav.trash": "Trash",
"nav.snoozed": "Snoozed",
"nav.subscriptions": "Subscriptions",
"snooze.action": "Snooze",
"snooze.unsnooze": "Unsnooze",
"snooze.until": "Snooze until…",
"snooze.laterToday": "Later today",
"snooze.tomorrow": "Tomorrow",
"snooze.weekend": "This weekend",
"snooze.nextWeek": "Next week",
"snooze.pick": "Pick date & time",
"snooze.done": "Snoozed until {when}",
"snooze.failed": "The conversation could not be snoozed.",
"snooze.banner": "Snoozed until {when}",
"common.undo": "Undo",
"subs.unsubscribe": "Unsubscribe",
"subs.unsubscribed": "Unsubscribed",
"subs.stillSending": "Still sending",
"subs.stillSendingHint": "Mail keeps arriving although you unsubscribed. Try again, or delete their mail and block the sender with a filter.",
"subs.unsubscribedOn": "You unsubscribed on {date}.",
"subs.trashAll": "Delete all",
"subs.trashAllAction": "Move to Trash",
"subs.email": "{count} email",
"subs.emails": "{count} emails",
"subs.perMonth": "{count}/month",
"subs.unread": "{count} unread",
"subs.noSubject": "(no subject)",
"subs.list": "List: {id}",
"subs.scanning": "Looking through your mail for subscriptions… {count} messages to go",
"subs.none": "No subscriptions found",
"subs.noneHint": "Newsletters and other mail you can unsubscribe from show up here, grouped by sender.",
"subs.confirm": "Unsubscribe from {name}?",
"subs.confirmOneClick": "Ultimo will ask the sender to take you off their list.",
"subs.confirmMailto": "Ultimo will send an unsubscribe email to the sender from your account.",
"subs.confirmWeb": "The sender's unsubscribe page will open. Finish there.",
"subs.doneOneClick": "Unsubscribed from {name}",
"subs.doneMailto": "Unsubscribe email to {name} is on its way",
"subs.doneWeb": "Finish unsubscribing on the sender's page",
"subs.failed": "Could not unsubscribe. Try again later.",
"subs.confirmTrash": "Move all mail from {name} to Trash? Conversations you replied to stay where they are.",
"subs.trashed": "{count} conversations moved to Trash",
"subs.statTotal": "Emails",
"subs.statPerMonth": "Per month",
"subs.statRead": "Opened",
"subs.statSince": "Since",
"subs.recent": "Recent mail",
"subs.senders": "{count} senders",
"subs.select": "Select sender",
"subs.selected": "{count} selected",
"subs.sendersSelected": "{count} senders selected",
"subs.bulkConfirm": "Unsubscribe from {count} senders?",
"subs.bulkHow": "Ultimo asks each sender to take you off their list, or sends them an unsubscribe email from your account. Senders that only take it on their own web page are listed for you to open afterwards.",
"subs.bulkNothing": "You're already unsubscribed from these senders.",
"subs.bulkDone": "Unsubscribed from {count}",
"subs.bulkPages": "{count} to finish on their page",
"subs.bulkFailed": "{count} failed",
"subs.bulkTrashConfirm": "Move all mail from {count} senders to Trash? Conversations you replied to stay where they are.",
"subs.finishTitle": "Finish on the senders' pages",
"subs.finishHint": "These senders only take an unsubscribe on their own web page. Open each one and confirm there.",
"subs.openPage": "Open page",
"subs.pageOpened": "Opened",
"subs.finishDone": "Done",
"nav.search": "Search mail…", "nav.search": "Search mail…",
"nav.searchInTrash": "Search in Trash", "nav.searchInTrash": "Search in Trash",
"nav.searchInSpam": "Search in Spam", "nav.searchInSpam": "Search in Spam",
"search.save": "Save this search", "search.save": "Save this search",
"search.saved": "Saved searches", "search.saved": "Saved searches",
"search.saveNamePrompt": "Name this saved search:", "search.saveNamePrompt": "Name this saved search:",
"search.saveNameLabel": "Name",
"nav.storageQuota": "Storage used", "nav.storageQuota": "Storage used",
"nav.refresh": "Refresh all folders", "nav.refresh": "Refresh all folders",
@@ -187,10 +252,14 @@ export const en = {
"panel.noAttachments": "No attachments", "panel.noAttachments": "No attachments",
"panel.download": "Download", "panel.download": "Download",
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.", "mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
"mail.emptyAction": "Delete everything",
"mail.movedToTrash": "Moved to Trash", "mail.movedToTrash": "Moved to Trash",
"mail.loadFailed": "Couldn't load this message.", "mail.loadFailed": "Couldn't load this message.",
"mail.imagesBlocked": "Images from this sender are blocked.", "mail.imagesBlocked": "Images from this sender are blocked.",
"mail.loadImages": "Load images", "mail.loadImages": "Load images",
"mail.trackerBlocked": "1 tracker blocked",
"mail.trackersBlocked": "{count} trackers blocked",
"mail.trackersHint": "Invisible images that tell the sender when and where you opened the mail were removed.",
"mail.alwaysFromSender": "Always from this sender", "mail.alwaysFromSender": "Always from this sender",
"mail.to": "to", "mail.to": "to",
"mail.from": "From", "mail.from": "From",
@@ -256,6 +325,24 @@ export const en = {
"compose.fmtHighlight": "Highlight color", "compose.fmtHighlight": "Highlight color",
"compose.fmtImage": "Insert image", "compose.fmtImage": "Insert image",
"compose.insertSignature": "Insert signature", "compose.insertSignature": "Insert signature",
"compose.fmtEmoji": "Emoji",
"compose.emojiLoading": "Loading emoji…",
"compose.templates": "Templates",
"compose.saveAsTemplate": "Save as template…",
"compose.noTemplates": "No saved templates yet",
"compose.deleteTemplate": "Delete template",
"compose.templateName": "Template name",
"compose.templateEmpty": "Write something first: the template is what you've written, without the signature or quoted mail.",
"compose.templateDelete": "Delete the template \"{name}\"?",
"compose.templateSaveFailed": "The template could not be saved.",
"compose.saveTemplateTitle": "Save as template",
"compose.saveTemplateHint": "The subject and what you've written are saved. The signature and any quoted mail are left out.",
"compose.templateNamePlaceholder": "e.g. Thanks for your enquiry",
"compose.templateReplaceNote": "A template with this name exists. Saving replaces it.",
"compose.templateReplaceAction": "Replace",
"compose.templateSaving": "Saving…",
"compose.templateEmptyShort": "Write something first",
"compose.deleteTemplateAction": "Delete",
"compose.linkPrompt": "Link URL:", "compose.linkPrompt": "Link URL:",
"compose.linkOpen": "Open the link:", "compose.linkOpen": "Open the link:",
"compose.linkEdit": "Edit", "compose.linkEdit": "Edit",
@@ -498,6 +585,40 @@ export const en = {
"settings.addToCalendar": "Add to calendar", "settings.addToCalendar": "Add to calendar",
"settings.alsoFile": "Also mark the message as read and file it", "settings.alsoFile": "Also mark the message as read and file it",
"settings.integrations": "Integrations", "settings.integrations": "Integrations",
"settings.shortcuts": "Keyboard shortcuts",
"settings.privacy": "Privacy",
"settings.completion": "Suggest sentence endings",
"settings.completionHint": "While you write, the likely rest of the sentence appears in grey; press Tab to take it. Suggestions come from phrases you use in your own sent mail (plus common ones in English, Polish and German) and never leave this server.",
"settings.blockTrackers": "Block email trackers",
"settings.blockTrackersHint": "Many newsletters and sales emails hide a tiny invisible image that tells the sender when you opened the message, how often, and roughly where you were. Ultimo removes these images before showing a message. Turned off, they are treated like any other remote image: loaded only when you allow images from that sender.",
"settings.shortcutsEnabled": "Use keyboard shortcuts",
"settings.shortcutsHint": "Single keys act on the mail list and the open conversation. They are ignored while you type. Press ? in the mail view to see this list.",
"shortcuts.title": "Keyboard shortcuts",
"shortcuts.then": "then",
"shortcuts.groupNavigation": "Navigation",
"shortcuts.groupConversation": "Conversation",
"shortcuts.groupCompose": "Compose",
"shortcuts.next": "Next conversation",
"shortcuts.prev": "Previous conversation",
"shortcuts.back": "Back to the list",
"shortcuts.search": "Search",
"shortcuts.goInbox": "Go to Inbox",
"shortcuts.goSnoozed": "Go to Snoozed",
"shortcuts.goDrafts": "Go to Drafts",
"shortcuts.goSent": "Go to Sent",
"shortcuts.goSubscriptions": "Go to Subscriptions",
"shortcuts.help": "Show keyboard shortcuts",
"shortcuts.reply": "Reply",
"shortcuts.replyAll": "Reply all",
"shortcuts.forward": "Forward",
"shortcuts.archive": "Archive",
"shortcuts.trash": "Delete",
"shortcuts.pin": "Pin / unpin",
"shortcuts.snooze": "Snooze",
"shortcuts.markRead": "Mark as read",
"shortcuts.markUnread": "Mark as unread",
"shortcuts.compose": "New message",
"shortcuts.escape": "Minimize the compose windows, close a dialog",
"admin.title": "Ultimo Email administration", "admin.title": "Ultimo Email administration",
"admin.signInTitle": "Administrator sign-in", "admin.signInTitle": "Administrator sign-in",
@@ -530,6 +651,7 @@ export const en = {
"admin.inviteDownload": "Download template", "admin.inviteDownload": "Download template",
"admin.inviteReset": "Back to the built-in template", "admin.inviteReset": "Back to the built-in template",
"admin.inviteResetConfirm": "Remove your template and use the built-in one again?", "admin.inviteResetConfirm": "Remove your template and use the built-in one again?",
"admin.inviteResetAction": "Use built-in template",
"admin.invitePlaceholders": "Placeholders:", "admin.invitePlaceholders": "Placeholders:",
"admin.invitePlaceholdersHelp": "admin.invitePlaceholdersHelp":
"The template must contain {{invite_link}} or {{invite_button}}. The subject can use the names too. Images written into the HTML are sent inside the email.", "The template must contain {{invite_link}} or {{invite_button}}. The subject can use the names too. Images written into the HTML are sent inside the email.",
@@ -595,6 +717,8 @@ export const en = {
"settings.addAccount": "Add account", "settings.addAccount": "Add account",
"settings.syncState": "State: {state}", "settings.syncState": "State: {state}",
"settings.confirmRemoveAccount": "Remove this account and all its mail?", "settings.confirmRemoveAccount": "Remove this account and all its mail?",
"settings.removeAccountTitle": "Remove account",
"settings.removeAccountAction": "Remove account",
"settings.removingAccount": "Removing account…", "settings.removingAccount": "Removing account…",
"settings.editAccount": "Edit account", "settings.editAccount": "Edit account",
"settings.color": "Color", "settings.color": "Color",
@@ -701,7 +825,49 @@ export const en = {
"install.stepsFirefox": "1. Open Firefox's menu (⋮).\n2. Choose Add app to Home screen (or Install).", "install.stepsFirefox": "1. Open Firefox's menu (⋮).\n2. Choose Add app to Home screen (or Install).",
"install.hide": "Don't show this again", "install.hide": "Don't show this again",
"install.gotIt": "Got it", "install.gotIt": "Got it",
"settings.language": "Language",
"settings.languageHint": "The language of Ultimo's menus, buttons and messages. Saved with your account, so it stays the same on every device.",
"time.justNow": "just now",
"time.minAgo": "{count} min ago",
"time.hoursAgo": "{count} hours ago",
"time.hoursAgo_one": "1 hour ago",
"time.daysAgo": "{count} days ago",
"time.daysAgo_one": "1 day ago",
"reminder.atTime": "At time of event",
"reminder.minutesBefore": "{count} minutes before",
"reminder.hoursBefore": "{count} hours before",
"reminder.hoursBefore_one": "1 hour before",
"reminder.daysBefore": "{count} days before",
"reminder.daysBefore_one": "1 day before",
"color.blue": "Blue",
"color.green": "Green",
"color.red": "Red",
"color.orange": "Orange",
"color.purple": "Purple",
"color.teal": "Teal",
"color.pink": "Pink",
"color.gray": "Gray",
"color.custom": "Custom",
"settings.selectColor": "Select colour",
"labels.remove": "Remove label",
"compose.attribution": "On {date}, at {time}, {who} wrote:",
"print.attachments": "Attachments",
"print.popupBlocked": "Allow pop-ups for this site to print.",
"mail.translate": "Translate",
"mail.translateTo": "Translate to…",
"mail.showOriginal": "Show original",
"mail.translating": "Translating…",
"mail.translated": "Translated",
"mail.translatedFrom": "Translated from {language}",
"mail.translatedFromMany": "Translated from {languages}",
"mail.translateFailed": "Couldn't translate this message. The translation service may be busy or unavailable.",
} as const; } as const;
export type MessageKey = keyof typeof en; export type MessageKey = keyof typeof en;
export type Messages = Record<MessageKey, string>; export type Messages = Record<MessageKey, string>;
/** Plural forms a language may add beside a key (Intl.PluralRules categories). */
export type PluralKey = `${MessageKey}_${"zero" | "one" | "two" | "few" | "many" | "other"}`;
/** A language file. English (this file) has every key; another language may
* lack some — those show in English (i18n/index.tsx) — and adds the plural
* forms it needs. */
export type Catalog = Partial<Messages> & Partial<Record<PluralKey, string>>;
+123 -27
View File
@@ -1,15 +1,82 @@
// Minimal, dependency-free i18n. English today; add a locale by registering a // Minimal, dependency-free i18n. One file per language (en.ts, pl.ts, …), each
// catalog with the same keys. Strings are never hard-coded in components — they // with the same keys; register a new one in LANGUAGES below. Strings are never
// go through t(). // hard-coded in components — they go through t().
//
// Plurals follow each language's own rules (Intl.PluralRules): a count picks
// "<key>_one", "<key>_few", "<key>_many"… when the catalog has that form, else
// "<key>". English needs only "_one"; Polish adds "_few" (2–4, 22–24…), its
// base form being the "many" one (5+, 0, 12–14…).
import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { en, type MessageKey, type Messages } from "./en"; import { setDateLanguage } from "../lib/timeFormat";
import { en, type Catalog, type MessageKey } from "./en";
import { pl } from "./pl";
const catalogs: Record<string, Messages> = { /** The interface languages, each named in its own language for the picker. */
en, export const LANGUAGES: { code: string; name: string; catalog: Catalog }[] = [
// pl, <- add here to enable Polish, etc. { code: "en", name: "English", catalog: en },
}; { code: "pl", name: "Polski", catalog: pl },
];
const catalogs: Record<string, Catalog> = Object.fromEntries(
LANGUAGES.map((l) => [l.code, l.catalog]),
);
const STORAGE_KEY = "locale";
/** A supported language for a code such as "pl-PL", or null. */
export function supportedLanguage(code: string | null | undefined): string | null {
if (!code) return null;
const base = code.toLowerCase().split(/[-_]/)[0];
return catalogs[base] ? base : null;
}
/** The browser's / system's preferred language we have, else English. */
export function detectLanguage(): string {
const wanted = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const code of wanted) {
const found = supportedLanguage(code);
if (found) return found;
}
return "en";
}
/** The language to start with before the account's choice is known (the
* sign-in page): the one last used in this browser, else the detected one. */
function initialLanguage(): string {
try {
const stored = supportedLanguage(localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
} catch {
/* storage blocked */
}
return detectLanguage();
}
/** A language's name in the interface language ("en" in Polish → "angielski"). */
export function languageName(code: string, inLanguage: string): string {
try {
return new Intl.DisplayNames([inLanguage], { type: "language" }).of(code) ?? code;
} catch {
return code;
}
}
/** Whether two language codes are the same language ("pl" and "pl-PL"). */
export function sameLanguage(a: string, b: string): boolean {
return a.toLowerCase().split(/[-_]/)[0] === b.toLowerCase().split(/[-_]/)[0];
}
/** Whether a message in ``languages`` ("zh-Hans,pl,en", as the server found
* them) has a part that is not in ``locale``: then it can be translated. */
export function needsTranslation(languages: string, locale: string): boolean {
return splitLanguages(languages).some((code) => !sameLanguage(code, locale));
}
/** "zh-Hans,pl,en" → ["zh-Hans", "pl", "en"]. */
export function splitLanguages(languages: string | null | undefined): string[] {
return (languages ?? "").split(",").filter((code) => code && code !== "auto");
}
export type Vars = Record<string, string | number>; export type Vars = Record<string, string | number>;
export type TFunc = (key: MessageKey, vars?: Vars) => string; export type TFunc = (key: MessageKey, vars?: Vars) => string;
@@ -30,29 +97,58 @@ function interpolate(template: string, vars?: Vars): string {
); );
} }
export function I18nProvider({ children }: { children: ReactNode }) { function makeT(locale: string): TFunc {
const [locale, setLocale] = useState<string>(() => localStorage.getItem("locale") ?? "en"); const catalog = (catalogs[locale] ?? en) as Record<string, string>;
const english = en as Record<string, string>;
const plurals = new Intl.PluralRules(locale);
const lookup = (key: string): string | undefined => catalog[key];
return (key, vars) => {
let text: string | undefined;
if (typeof vars?.count === "number") {
text = lookup(`${key}_${plurals.select(vars.count)}`);
}
// A missing string (a new key not translated yet) falls back to English.
text ??= lookup(key) ?? english[key] ?? key;
return interpolate(text, vars);
};
}
const value = useMemo<I18nContextValue>(() => { // The current t() for code outside React (lib helpers, toasts).
const catalog = catalogs[locale] ?? en; let currentT: TFunc = makeT(initialLanguage());
// A count of exactly one picks the "<key>_one" form where there is one export function translate(key: MessageKey, vars?: Vars): string {
// ("1 message", not "1 messages"). return currentT(key, vars);
const lookup = (key: string): string | undefined => }
(catalog as Record<string, string>)[key] ?? (en as Record<string, string>)[key];
const t: TFunc = (key, vars) => function apply(locale: string): void {
interpolate( currentT = makeT(locale);
(vars?.count === 1 ? lookup(`${key}_one`) : undefined) ?? lookup(key) ?? key, setDateLanguage(locale);
vars, document.documentElement.lang = locale;
); try {
return { localStorage.setItem(STORAGE_KEY, locale);
} catch {
/* storage blocked: the account's setting still holds it */
}
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<string>(() => {
const first = initialLanguage();
apply(first);
return first;
});
const value = useMemo<I18nContextValue>(
() => ({
locale, locale,
setLocale: (l) => { setLocale: (l) => {
localStorage.setItem("locale", l); const code = supportedLanguage(l) ?? "en";
setLocale(l); apply(code);
setLocaleState(code);
}, },
t, t: makeT(locale),
}; }),
}, [locale]); [locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
} }
+890
View File
@@ -0,0 +1,890 @@
// Polski. Klucze jak w en.ts (język główny): brakujący tekst pokaże się po
// angielsku. Liczby: "<klucz>_one" (1), "<klucz>_few" (2–4, 22–24…), sam
// "<klucz>" dla pozostałych (0, 5–21, 25…).
import type { Catalog } from "./en";
export const pl: Catalog = {
"common.appName": "Ultimo Email",
"common.loading": "Wczytywanie…",
"common.cancel": "Anuluj",
"common.save": "Zapisz",
"common.saving": "Zapisywanie…",
"common.delete": "Usuń",
"common.remove": "Usuń",
"common.edit": "Edytuj",
"common.close": "Zamknij",
"common.clear": "Wyczyść",
"common.copy": "Kopiuj",
"common.back": "Wstecz",
"common.next": "Dalej",
"common.signOut": "Wyloguj się",
"common.retry": "Spróbuj ponownie",
"common.optional": "opcjonalnie",
"auth.signInTitle": "Zaloguj się",
"auth.registerTitle": "Załóż konto",
"auth.handle": "Nazwa użytkownika",
"auth.handlePlaceholder": "np. alicja",
"auth.localpartPlaceholder": "alicja",
"auth.handleHint": "3–30 znaków: a–z, 0–9, . _ -",
"auth.password": "Hasło",
"auth.passwordHint": "co najmniej 8 znaków",
"auth.totpCode": "Kod 2FA",
"auth.totpPrompt": "Wpisz 6-cyfrowy kod z aplikacji uwierzytelniającej",
"auth.rememberDevice": "Nie pytaj o kod na tym urządzeniu",
"auth.signIn": "Zaloguj się",
"auth.signingIn": "Logowanie…",
"auth.create": "Załóż konto",
"auth.creating": "Zakładanie…",
"auth.noAccount": "Nie masz konta?",
"auth.forgotPassword": "Nie pamiętasz hasła?",
"auth.forgotTitle": "Zresetuj hasło",
"auth.forgotHint":
"Wpisz nazwę użytkownika. Na adres e-mail do odzyskiwania konta wyślemy link do ustawienia nowego hasła.",
"auth.changePassword": "Zmień hasło",
"auth.sending": "Wysyłanie…",
"auth.forgotSent": "Jeśli to konto istnieje, na adres do odzyskiwania przyjdzie wiadomość e-mail.",
"auth.backToSignIn": "Wróć do logowania",
"auth.resetTitle": "Ustaw nowe hasło",
"auth.newPassword": "Nowe hasło",
"auth.repeatPassword": "Powtórz nowe hasło",
"auth.passwordsDiffer": "Hasła się różnią.",
"auth.passwordTooShort": "Użyj co najmniej 8 znaków.",
"auth.resetTotpPrompt":
"To konto ma weryfikację dwuetapową. Aby zakończyć, wpisz 6-cyfrowy kod z aplikacji uwierzytelniającej.",
"auth.confirm": "Potwierdź",
"auth.resetDone": "Hasło zostało zmienione. Wylogowaliśmy cię ze wszystkich urządzeń — zaloguj się nowym hasłem.",
"auth.resetInvalid":
"Ten link jest nieprawidłowy, został już użyty albo wygasł. Poproś o nowy, aby zmienić hasło.",
"auth.requestNewLink": "Poproś o nowy link",
"auth.haveAccount": "Masz już konto?",
"auth.register": "Zarejestruj się",
"auth.checking": "Sprawdzanie…",
"auth.firstUserTitle": "Załóż konto administratora",
"auth.firstUserHint":
"To pierwsze konto na tym serwerze, więc zostanie kontem administratora. Po jego utworzeniu rejestracja zamknie się automatycznie.",
"auth.registrationClosedTitle": "Rejestracja jest zamknięta",
"auth.registrationClosed":
"Ten serwer nie przyjmuje nowych kont. Poproś administratora o otwarcie rejestracji.",
"nav.mail": "Poczta",
"nav.back": "Wstecz",
"nav.settings": "Ustawienia",
"nav.compose": "Napisz",
"nav.collapseSidebar": "Zwiń pasek boczny",
"nav.expandSidebar": "Rozwiń pasek boczny",
"nav.allAccounts": "Wszystkie konta",
"nav.inbox": "Odebrane",
"nav.archive": "Archiwum",
"nav.drafts": "Wersje robocze",
"nav.sent": "Wysłane",
"nav.spam": "Spam",
"nav.trash": "Kosz",
"nav.snoozed": "Odłożone",
"nav.subscriptions": "Subskrypcje",
"snooze.action": "Odłóż",
"snooze.unsnooze": "Przywróć",
"snooze.until": "Odłóż do…",
"snooze.laterToday": "Później dzisiaj",
"snooze.tomorrow": "Jutro",
"snooze.weekend": "W ten weekend",
"snooze.nextWeek": "W przyszłym tygodniu",
"snooze.pick": "Wybierz datę i godzinę",
"snooze.done": "Odłożono do {when}",
"snooze.failed": "Nie udało się odłożyć wątku.",
"snooze.banner": "Odłożono do {when}",
"common.undo": "Cofnij",
"subs.unsubscribe": "Wypisz się",
"subs.unsubscribed": "Wypisano",
"subs.stillSending": "Nadal wysyła",
"subs.stillSendingHint":
"Wiadomości wciąż przychodzą, choć się wypisałeś. Spróbuj ponownie albo usuń ich pocztę i zablokuj nadawcę filtrem.",
"subs.unsubscribedOn": "Wypisano {date}.",
"subs.trashAll": "Usuń wszystko",
"subs.trashAllAction": "Przenieś do kosza",
"subs.email": "{count} wiadomość",
"subs.emails": "{count} wiadomości",
"subs.perMonth": "{count}/mies.",
"subs.unread": "{count} nieprzeczytanych",
"subs.unread_one": "1 nieprzeczytana",
"subs.unread_few": "{count} nieprzeczytane",
"subs.noSubject": "(bez tematu)",
"subs.list": "Lista: {id}",
"subs.scanning": "Szukam subskrypcji w poczcie… zostało {count} wiadomości",
"subs.scanning_one": "Szukam subskrypcji w poczcie… została 1 wiadomość",
"subs.scanning_few": "Szukam subskrypcji w poczcie… zostały {count} wiadomości",
"subs.none": "Nie znaleziono subskrypcji",
"subs.noneHint": "Tu pojawią się newslettery i inne wiadomości, z których można się wypisać, pogrupowane według nadawcy.",
"subs.confirm": "Wypisać się z {name}?",
"subs.confirmOneClick": "Ultimo poprosi nadawcę o usunięcie cię z listy.",
"subs.confirmMailto": "Ultimo wyśle do nadawcy wiadomość z prośbą o wypisanie, z twojego konta.",
"subs.confirmWeb": "Otworzy się strona nadawcy do wypisywania. Dokończ tam.",
"subs.doneOneClick": "Wypisano z {name}",
"subs.doneMailto": "Prośba o wypisanie do {name} jest w drodze",
"subs.doneWeb": "Dokończ wypisywanie na stronie nadawcy",
"subs.failed": "Nie udało się wypisać. Spróbuj później.",
"subs.confirmTrash": "Przenieść całą pocztę od {name} do kosza? Wątki, na które odpowiedziałeś, zostaną na miejscu.",
"subs.trashed": "Przeniesiono {count} wątków do kosza",
"subs.trashed_one": "Przeniesiono 1 wątek do kosza",
"subs.trashed_few": "Przeniesiono {count} wątki do kosza",
"subs.statTotal": "Wiadomości",
"subs.statPerMonth": "Na miesiąc",
"subs.statRead": "Otwarte",
"subs.statSince": "Od",
"subs.recent": "Ostatnia poczta",
"subs.senders": "{count} nadawców",
"subs.senders_one": "1 nadawca",
"subs.senders_few": "{count} nadawców",
"subs.select": "Zaznacz nadawcę",
"subs.selected": "Zaznaczono: {count}",
"subs.sendersSelected": "Zaznaczono {count} nadawców",
"subs.sendersSelected_one": "Zaznaczono 1 nadawcę",
"subs.sendersSelected_few": "Zaznaczono {count} nadawców",
"subs.bulkConfirm": "Wypisać się od {count} nadawców?",
"subs.bulkConfirm_one": "Wypisać się od 1 nadawcy?",
"subs.bulkHow":
"Ultimo poprosi każdego nadawcę o usunięcie cię z listy albo wyśle mu prośbę o wypisanie z twojego konta. Nadawców, którzy przyjmują to tylko na swojej stronie, pokażemy na liście do otwarcia później.",
"subs.bulkNothing": "Jesteś już wypisany od tych nadawców.",
"subs.bulkDone": "Wypisano: {count}",
"subs.bulkPages": "{count} do dokończenia na stronie",
"subs.bulkFailed": "Nieudane: {count}",
"subs.bulkTrashConfirm":
"Przenieść całą pocztę od {count} nadawców do kosza? Wątki, na które odpowiedziałeś, zostaną na miejscu.",
"subs.finishTitle": "Dokończ na stronach nadawców",
"subs.finishHint": "Ci nadawcy przyjmują wypisanie tylko na swojej stronie. Otwórz każdą i potwierdź tam.",
"subs.openPage": "Otwórz stronę",
"subs.pageOpened": "Otwarto",
"subs.finishDone": "Gotowe",
"nav.search": "Szukaj w poczcie…",
"nav.searchInTrash": "Szukaj w koszu",
"nav.searchInSpam": "Szukaj w spamie",
"search.save": "Zapisz to wyszukiwanie",
"search.saved": "Zapisane wyszukiwania",
"search.saveNamePrompt": "Nazwij zapisane wyszukiwanie:",
"search.saveNameLabel": "Nazwa",
"nav.storageQuota": "Zajęte miejsce",
"nav.refresh": "Odśwież wszystkie foldery",
"mail.noConversations": "Brak wątków.",
"mail.me": "ja",
"nav.menu": "Menu",
"mail.selectConversation": "Wybierz wątek.",
"mail.loadingThread": "Wczytywanie wątku…",
"mail.expandAll": "Rozwiń wszystko",
"mail.collapseAll": "Zwiń wszystko",
"mail.print": "Drukuj",
"mail.previewVideoUnsupported": "Tego filmu nie da się odtworzyć w przeglądarce.",
"mail.previewPage": "Strona {current} / {total}",
"mail.previewResetZoom": "Przywróć powiększenie",
"mail.previewPrev": "Poprzedni",
"mail.previewNext": "Następny",
"preview.pdfProtected": "Ten PDF jest chroniony hasłem",
"preview.password": "Hasło",
"preview.pdfWrongPassword": "Nieprawidłowe hasło — spróbuj ponownie",
"preview.unlock": "Odblokuj",
"preview.pdfError": "Nie udało się otworzyć tego PDF-a.",
"mail.orderOldest": "Od najstarszych",
"mail.orderNewest": "Od najnowszych",
"filter.all": "Wszystkie",
"filter.unread": "Nieprzeczytane",
"filter.pinned": "Przypięte",
"filter.sortOldest": "Sortuj od najstarszych",
"filter.sortNewest": "Sortuj od najnowszych",
"pager.prev": "Poprzednia strona",
"pager.next": "Następna strona",
"mail.reply": "Odpowiedz",
"mail.replyAll": "Odpowiedz wszystkim",
"mail.forward": "Przekaż dalej",
"mail.archive": "Archiwizuj",
"mail.markRead": "Oznacz jako przeczytane",
"mail.markUnread": "Oznacz jako nieprzeczytane",
"mail.star": "Oznacz gwiazdką",
"mail.unstar": "Usuń gwiazdkę",
"mail.pin": "Przypnij",
"mail.unpin": "Odepnij",
"mail.delete": "Usuń",
"mail.restore": "Przywróć",
"mail.select": "Zaznacz wątek",
"mail.selectAll": "Zaznacz wszystko",
"mail.conversationSelected": "Zaznaczono 1 wątek",
"mail.conversationsSelected": "Zaznaczono {count} wątków",
"mail.conversationsSelected_one": "Zaznaczono 1 wątek",
"mail.conversationsSelected_few": "Zaznaczono {count} wątki",
"mail.alsoIn": "Dostarczono także do",
"mail.emptyTrash": "Opróżnij kosz",
"mail.emptySpam": "Opróżnij spam",
"mail.more": "Więcej",
"mail.markAllRead": "Oznacz wszystkie jako przeczytane",
"mail.labels": "Etykiety",
"mail.noLabels": "Brak etykiet",
"mail.createLabel": "Utwórz nową…",
"mail.labelName": "Nazwa etykiety",
"panel.toggle": "Pokaż/ukryj panel boczny",
"panel.calendar": "Kalendarz",
"panel.people": "Osoby",
"panel.contacts": "Kontakty",
"panel.attachments": "Załączniki",
"panel.search": "Szukaj",
"panel.newEvent": "Nowe wydarzenie",
"panel.miniCalendar": "Mini kalendarz",
"panel.week": "Tydzień",
"panel.month": "Miesiąc",
"panel.view": "Widok",
"panel.day": "Dzień",
"panel.3days": "3 dni",
"panel.agenda": "Lista",
"panel.allCalendars": "Wszystkie kalendarze",
"panel.searchEvents": "Szukaj wydarzeń",
"panel.today": "Dzisiaj",
"panel.noEvents": "Brak wydarzeń",
"panel.untitled": "(bez tytułu)",
"panel.allDay": "Cały dzień",
"panel.openCalendar": "Otwórz kalendarz w nowej karcie",
"panel.inConversation": "W wątku",
"panel.allContacts": "Wszystkie kontakty",
"panel.recent": "Ostatnie",
"panel.vips": "VIP",
"panel.autosaved": "Zapisane automatycznie",
"panel.senders": "Nadawcy",
"panel.noPeople": "Brak osób",
"panel.composeAll": "Napisz do wszystkich",
"panel.inviteAll": "Zaproś wszystkich na nowe wydarzenie",
"panel.call": "Zadzwoń",
"panel.compose": "Napisz",
"panel.invite": "Zaproś",
"panel.contactDetails": "Szczegóły kontaktu",
"panel.showAll": "Pokaż wszystko",
"panel.mailSection": "Poczta",
"panel.noSubject": "(bez tematu)",
"panel.findAll": "Znajdź wszystkie wątki z {name}",
"panel.eventsSection": "Wydarzenia",
"panel.noMatches": "Brak wyników",
"panel.openContact": "Otwórz kontakt w nowej karcie",
"panel.openConversation": "Otwórz wątek, aby zobaczyć jego załączniki.",
"panel.noAttachments": "Brak załączników",
"panel.download": "Pobierz",
"mail.emptyConfirm": "Trwale usunąć wszystko z tego folderu? Tego nie da się cofnąć.",
"mail.emptyAction": "Usuń wszystko",
"mail.movedToTrash": "Przeniesiono do kosza",
"mail.loadFailed": "Nie udało się wczytać tej wiadomości.",
"mail.imagesBlocked": "Obrazy od tego nadawcy są zablokowane.",
"mail.loadImages": "Pokaż obrazy",
"mail.trackerBlocked": "Zablokowano 1 moduł śledzący",
"mail.trackersBlocked": "Zablokowano {count} modułów śledzących",
"mail.trackersBlocked_few": "Zablokowano {count} moduły śledzące",
"mail.trackersHint":
"Usunięto niewidoczne obrazy, które mówią nadawcy, kiedy i gdzie otworzyłeś wiadomość.",
"mail.alwaysFromSender": "Zawsze od tego nadawcy",
"mail.to": "do",
"mail.from": "Od",
"mail.subjectLabel": "Temat",
"mail.dateLabel": "Data",
"mail.sizeLabel": "Rozmiar",
"mail.actions": "Działania",
"mail.forwardAsAttachment": "Przekaż jako załącznik",
"mail.showQuoted": "Pokaż cytowany tekst",
"mail.hideQuoted": "Ukryj cytowany tekst",
"mail.markReadUnread": "Oznacz jako przeczytane / nieprzeczytane",
"mail.messageCount": "{count} wiadomości",
"mail.messageCount_one": "1 wiadomość",
"mail.downloadAll": "Pobierz wszystkie załączniki ({count})",
"mail.downloadZip": "Pobierz ZIP",
"mail.download": "Pobierz",
"compose.newMessage": "Nowa wiadomość",
"compose.showQuoted": "Pokaż lub ukryj cytowaną wiadomość",
"compose.to": "Do",
"compose.cc": "DW",
"compose.bcc": "UDW",
"compose.subject": "Temat",
"compose.send": "Wyślij",
"compose.sending": "Wysyłanie…",
"compose.discard": "Odrzuć",
"compose.saveDraft": "Zapisz wersję roboczą",
"compose.closePrompt": "Zapisać tę wiadomość jako wersję roboczą?",
"compose.sent": "Wiadomość wysłana.",
"compose.undoIn": "jeszcze {count} s:",
"compose.undoIn_one": "jeszcze 1 s:",
"compose.undo": "Cofnij",
"compose.sendFailed": "Nie udało się wysłać.",
"compose.savingDraft": "Zapisywanie wersji roboczej…",
"compose.draftSaved": "Wersja robocza zapisana",
"compose.noDrafts": "Brak wersji roboczych.",
"compose.otherClient": "inny program",
"compose.noRecipient": "(brak odbiorcy)",
"compose.noSubject": "(bez tematu)",
"compose.from": "Od",
"compose.attach": "Załącz",
"compose.more": "Więcej",
"compose.minimize": "Minimalizuj",
"compose.restore": "Przywróć",
"compose.moreWindows": "Więcej wersji roboczych",
"compose.expand": "Powiększ",
"compose.collapse": "Zmniejsz",
"compose.popOut": "Otwórz w osobnym oknie",
"compose.addRecipient": "Dodaj odbiorcę…",
"compose.fmtBold": "Pogrubienie",
"compose.fmtItalic": "Kursywa",
"compose.fmtUnderline": "Podkreślenie",
"compose.fmtStrike": "Przekreślenie",
"compose.fmtBulletList": "Lista punktowana",
"compose.fmtNumberList": "Lista numerowana",
"compose.fmtQuote": "Cytat",
"compose.fmtLink": "Wstaw link",
"compose.fmtAlignLeft": "Wyrównaj do lewej",
"compose.fmtAlignCenter": "Wyśrodkuj",
"compose.fmtAlignRight": "Wyrównaj do prawej",
"compose.fmtClear": "Usuń formatowanie",
"compose.fmtColor": "Kolor tekstu",
"compose.fmtHighlight": "Kolor wyróżnienia",
"compose.fmtImage": "Wstaw obraz",
"compose.insertSignature": "Wstaw podpis",
"compose.fmtEmoji": "Emoji",
"compose.emojiLoading": "Wczytywanie emoji…",
"compose.templates": "Szablony",
"compose.saveAsTemplate": "Zapisz jako szablon…",
"compose.noTemplates": "Brak zapisanych szablonów",
"compose.deleteTemplate": "Usuń szablon",
"compose.templateName": "Nazwa szablonu",
"compose.templateEmpty":
"Najpierw coś napisz: szablon to to, co napisałeś, bez podpisu i cytowanej wiadomości.",
"compose.templateDelete": "Usunąć szablon „{name}”?",
"compose.templateSaveFailed": "Nie udało się zapisać szablonu.",
"compose.saveTemplateTitle": "Zapisz jako szablon",
"compose.saveTemplateHint":
"Zapisujemy temat i to, co napisałeś. Podpis i cytowana wiadomość zostaną pominięte.",
"compose.templateNamePlaceholder": "np. Dziękuję za zapytanie",
"compose.templateReplaceNote": "Szablon o tej nazwie już istnieje. Zapisanie go zastąpi.",
"compose.templateReplaceAction": "Zastąp",
"compose.templateSaving": "Zapisywanie…",
"compose.templateEmptyShort": "Najpierw coś napisz",
"compose.deleteTemplateAction": "Usuń",
"compose.linkPrompt": "Adres linku:",
"compose.linkOpen": "Otwórz link:",
"compose.linkEdit": "Edytuj",
"compose.linkDelete": "Usuń",
"compose.linkSave": "Zapisz",
"compose.spellTitle": "Pisownia i gramatyka",
"compose.spellIgnore": "Ignoruj",
"compose.spellRemove": "(usuń)",
"compose.spellMenu": "Pisownia",
"compose.spellAuto": "Wykrywaj automatycznie",
"compose.spellOff": "Wyłączona",
"compose.spellOffMessage": "Wyłącz dla tej wiadomości",
"compose.imageTooLarge": "Obraz jest za duży (maks. 5 MB).",
"compose.attachTooLarge": "Plik przekracza limit 25 MB.",
"compose.attachments": "Załączniki",
"compose.dropAttach": "Upuść, aby załączyć",
"compose.dropBody": "Upuść obraz, aby wstawić · inne pliki zostaną załączone",
"wizard.title": "Dodaj konto pocztowe",
"wizard.step1": "Twój adres e-mail",
"wizard.emailPlaceholder": "ty@example.com",
"wizard.discover": "Dalej",
"wizard.discovering": "Szukam ustawień…",
"wizard.foundVia": "Znaleziono przez {source}",
"wizard.imapServer": "Serwer IMAP",
"wizard.smtpServer": "Serwer SMTP",
"wizard.host": "Host",
"wizard.port": "Port",
"wizard.security": "Zabezpieczenia",
"wizard.accountPassword": "Hasło do konta",
"wizard.appPasswordHint": "W Gmailu, Yahoo, iCloud i Outlooku użyj hasła do aplikacji.",
"wizard.help": "Pomoc",
"wizard.appPasswordHelp.title": "Utwórz hasło do aplikacji",
"wizard.appPasswordHelp.intro":
"Ci dostawcy wymagają jednorazowego hasła do aplikacji zamiast zwykłego hasła:",
"wizard.appPasswordHelp.prefix": "Upewnij się, że",
"wizard.appPasswordHelp.twoStep": "masz włączoną weryfikację dwuetapową",
"wizard.appPasswordHelp.middle": "(jeśli jeszcze nie), a potem",
"wizard.appPasswordHelp.generate": "wygeneruj hasło do aplikacji",
"wizard.test": "Sprawdź i przejdź dalej",
"wizard.testing": "Sprawdzam połączenie…",
"wizard.testOk": "Połączenie działa.",
"wizard.create": "Dodaj konto",
"wizard.creating": "Dodawanie…",
"wizard.created": "Konto dodane — synchronizuję pocztę.",
"wizard.startReading": "Zacznij czytać",
"wizard.backToAccounts": "Wróć do kont",
"wizard.manualConfig": "Wpisz ustawienia ręcznie",
"wizard.displayName": "Wyświetlana nazwa",
"wizard.syncWindow": "Ile poczty synchronizować",
"wizard.syncWindow.all": "Całą pocztę",
"wizard.syncWindow.all.hint": "Najpierw ostatnie 30 dni, resztę w tle.",
"wizard.syncWindow.3months": "Tylko ostatnie 3 miesiące",
"wizard.syncWindow.3months.hint": "Starsza poczta zostaje na serwerze, dopóki jej nie pobierzesz.",
"wizard.syncWindow.new": "Tylko nowa poczta",
"wizard.syncWindow.new.hint":
"Nic sprzed tej chwili — starszą pocztę pobierzesz w dowolnym momencie w ustawieniach konta.",
"wizard.syncSpam": "Synchronizować spam?",
"wizard.syncSpam.hint": "Importuj także folder Spam. Domyślnie wyłączone.",
"wizard.appPassword.title": "Utwórz hasło do aplikacji",
"wizard.appPassword.intro":
"Ten dostawca wymaga jednorazowego hasła do aplikacji zamiast zwykłego hasła. Otwórz poniższą stronę, wygeneruj hasło i wklej je tutaj.",
"wizard.appPassword.open": "Otwórz hasła do aplikacji ({provider})",
"wizard.appPassword.appNameHint": "Proponowana nazwa: {hint}",
"wizard.appPassword.pastePlaceholder": "Wklej hasło do aplikacji",
"wizard.appPassword.verifying": "Sprawdzanie…",
"wizard.appPassword.connected": "Połączono ✓",
"wizard.appPassword.docs": "Pomoc dostawcy",
"wizard.appPassword.useManual": "Wpisz ustawienia serwera ręcznie",
"wizard.appPassword.provider.gmail": "Google",
"wizard.appPassword.provider.yahoo": "Yahoo",
"wizard.appPassword.provider.icloud": "iCloud",
"wizard.appPassword.provider.outlook": "Microsoft",
"wizard.appPassword.failTitle": "To hasło nie zadziałało",
"wizard.appPassword.failCauses":
"Częste przyczyny: weryfikacja dwuetapowa nie jest jeszcze w pełni włączona; konto jest zupełnie nowe i dostawca wstrzymuje hasła do aplikacji, dopóki nie „dojrzeje”; albo to konto służbowe lub szkolne, w którym administrator wyłączył tę funkcję.",
"wizard.appPassword.oauthInstead": "Zaloguj się przez Google",
"wizard.help.gmail.step1": "Włącz weryfikację dwuetapową na koncie Google, jeśli jeszcze tego nie zrobiłeś.",
"wizard.help.gmail.step2": "Kliknij przycisk, aby wygenerować hasło do aplikacji.",
"wizard.help.gmail.step3": "Na stronie haseł do aplikacji nadaj hasłu nazwę (np. „ultimo.email”) i je utwórz.",
"wizard.help.gmail.step4": "Skopiuj 16-znakowe hasło i wklej je poniżej.",
"wizard.help.outlook.step1": "Włącz weryfikację dwuetapową w ustawieniach zabezpieczeń konta Microsoft.",
"wizard.help.outlook.step2": "W zaawansowanych opcjach zabezpieczeń utwórz nowe hasło do aplikacji.",
"wizard.help.outlook.step3": "Skopiuj wygenerowane hasło i wklej je poniżej.",
"wizard.help.yahoo.step1": "Otwórz Zabezpieczenia konta i włącz weryfikację dwuetapową.",
"wizard.help.yahoo.step2": "Wybierz „Wygeneruj hasło do aplikacji” i nadaj mu nazwę.",
"wizard.help.yahoo.step3": "Skopiuj hasło i wklej je poniżej.",
"wizard.help.icloud.step1": "Włącz uwierzytelnianie dwupoziomowe na koncie Apple.",
"wizard.help.icloud.step2": "W sekcji Logowanie i bezpieczeństwo wygeneruj hasło dla aplikacji.",
"wizard.help.icloud.step3": "Skopiuj hasło i wklej je poniżej.",
"settings.title": "Ustawienia",
"settings.profile": "Profil",
"settings.security": "Bezpieczeństwo",
"settings.accounts": "Konta",
"settings.downloadAllMail": "Synchronizuj resztę poczty",
"settings.syncedPartially": "Zsynchronizowane częściowo",
"backfill.importing": "Importuję wiadomości… {done} z {total}",
"backfill.preparing": "Przygotowuję import wiadomości…",
"settings.appearance": "Wygląd",
"settings.handle": "Nazwa użytkownika",
"settings.recoveryEmail": "E-mail do odzyskiwania",
"settings.spellcheck": "Sprawdzanie pisowni i gramatyki",
"settings.on": "Wł.",
"settings.off": "Wył.",
"settings.spellcheckLanguage": "Język sprawdzania pisowni",
"settings.spellcheckAuto": "Wykrywaj automatycznie (każdy akapit)",
"settings.spellcheckUnavailable": "Niedostępne: ten serwer nie ma skonfigurowanego sprawdzania pisowni.",
"settings.recoveryEmailHint": "Jeśli zapomnisz hasła, wyślemy tu link do ustawienia nowego.",
"settings.recoveryEmailNew": "Adres e-mail do odzyskiwania",
"settings.recoveryEmailRemoveHint": "Zostaw puste, aby go usunąć.",
"settings.currentPassword": "Obecne hasło",
"settings.typePassword": "Wpisz hasło — przy tym potwierdzeniu nie można go wkleić ani wypełnić automatycznie.",
"settings.change": "Zmień",
"settings.add": "Dodaj",
"settings.saving": "Zapisywanie…",
"settings.twoFactor": "Uwierzytelnianie dwuskładnikowe",
"settings.twoFactorOn": "2FA jest włączone.",
"settings.twoFactorOff": "2FA nie jest włączone.",
"settings.enable2fa": "Włącz 2FA",
"settings.disable2fa": "Wyłącz 2FA",
"settings.disable2faPrompt": "Wpisz hasło, aby wyłączyć 2FA:",
"settings.disable": "Wyłącz",
"settings.twoFactorDisabled": "2FA zostało wyłączone.",
"settings.scanQr": "Zeskanuj to w aplikacji uwierzytelniającej, a potem wpisz kod:",
"settings.secret": "Klucz",
"settings.confirm": "Potwierdź",
"settings.sessions": "Aktywne sesje",
"appPasswords.title": "Hasła do aplikacji",
"appPasswords.help":
"Aplikacje kalendarza i kontaktów — w telefonie, Thunderbird, macOS — logują się do Ultimo hasłem do aplikacji, nigdy hasłem do konta. Każdej aplikacji daj osobne, aby móc unieważnić jedno bez wpływu na pozostałe.",
"appPasswords.helpMail":
"Programy pocztowe — Thunderbird, Poczta w telefonie, Outlook — oraz aplikacje kalendarza i kontaktów logują się do Ultimo hasłem do aplikacji. Hasło do konta w nich nie działa. Każdej aplikacji daj osobne, aby móc unieważnić jedno bez wpływu na pozostałe.",
"appPasswords.mailTitle": "Konfiguracja zewnętrznych programów pocztowych:",
"appPasswords.mailIncoming": "Poczta przychodząca (IMAP)",
"appPasswords.mailOutgoing": "Poczta wychodząca (SMTP)",
"appPasswords.mailPorts": "{host}, port {port}, SSL/TLS",
"appPasswords.mailPortsAlt": "lub port {port}, STARTTLS",
"appPasswords.connectTitle": "Konfiguracja zewnętrznych aplikacji kalendarza i kontaktów:",
"appPasswords.connectServer": "Serwer",
"appPasswords.connectUser": "Nazwa użytkownika",
"appPasswords.connectPassword": "Hasło",
"appPasswords.connectPasswordValue": "hasło do aplikacji (utwórz je poniżej)",
"appPasswords.connectHint":
"iPhone/iPad: Ustawienia → Aplikacje → Kalendarz (lub Kontakty) → Konta kalendarzy → Dodaj konto → Inne → Dodaj konto CalDAV (CardDAV). Android: aplikacja DAVx⁵. Thunderbird: Nowy kalendarz → W sieci oraz Nowa książka adresowa → CardDAV.",
"appPasswords.new": "Utwórz hasło do aplikacji",
"appPasswords.name": "Nazwa",
"appPasswords.namePlaceholder": "np. Thunderbird na laptopie",
"appPasswords.confirmPassword": "Hasło do twojego konta",
"appPasswords.create": "Utwórz",
"appPasswords.madeFor": "Hasło do aplikacji „{name}”:",
"appPasswords.copy": "Kopiuj",
"appPasswords.copied": "Skopiowano",
"appPasswords.onceOnly":
"Wpisz je w polu hasła aplikacji, bez spacji. Nie zobaczysz go ponownie — jeśli je zgubisz, unieważnij je i utwórz nowe.",
"appPasswords.done": "Gotowe",
"appPasswords.created": "Utworzono {date}",
"appPasswords.lastUsed": "ostatnio użyte {date}",
"appPasswords.neverUsed": "nigdy nieużyte",
"appPasswords.lastUsedDav": "kalendarze lub kontakty zsynchronizowane {date}",
"appPasswords.confirmRevoke": "Unieważnić hasło do aplikacji „{name}”? Aplikacja, która go używa, przestanie się logować.",
"settings.thisDevice": "to urządzenie",
"settings.revoke": "Unieważnij",
"settings.threadOrder": "Kolejność w wątku",
"settings.timezone": "Strefa czasowa",
"settings.timeFormat": "Format godziny",
"settings.timeFormat24": "24-godzinny (14:30)",
"settings.timeFormat12": "12-godzinny (2:30 PM)",
"settings.calendars": "Kalendarze",
"settings.myCalendars": "Moje kalendarze",
"settings.calendarOptions": "Opcje kalendarza",
"settings.createCalendar": "Utwórz kalendarz",
"settings.editCalendar": "Edytuj kalendarz",
"settings.export": "Eksportuj",
"settings.calendarDisplay": "Wyświetlanie kalendarza",
"settings.default": "Domyślny",
"settings.defaultCalendar": "Domyślny kalendarz",
"settings.firstCalendar": "Pierwszy kalendarz",
"settings.defaultRemindersTimed": "Domyślne przypomnienia o wydarzeniach z godziną",
"settings.defaultRemindersAllday": "Domyślne przypomnienia o wydarzeniach całodniowych",
"settings.addReminder": "Dodaj przypomnienie",
"settings.name": "Nazwa",
"settings.onlyYou": "Tylko ty widzisz ten kalendarz.",
"settings.publish": "Opublikuj",
"settings.publishHint": "Udostępnij ten kalendarz (tylko do odczytu) każdemu.",
"settings.fullEventDetails": "Pełne szczegóły wydarzeń",
"settings.freeBusy": "Informacje wolny/zajęty",
"settings.addAccountHint": "Połącz zewnętrzne konto kalendarza.",
"settings.subscriptions": "Subskrypcje",
"settings.subscriptionsHint": "Dodaj kalendarz tylko do odczytu z adresu iCal (.ics).",
"settings.subscribe": "Subskrybuj",
"settings.subscribeUrl": "Adres iCal (.ics)",
"settings.subscribed": "Subskrybowane",
"settings.comingSoon": "Wkrótce",
"settings.subscribeError": "Nie udało się zasubskrybować tego adresu.",
"settings.connectTitle": "Połącz {provider}",
"settings.connect": "Połącz",
"settings.connecting": "Łączenie…",
"settings.connectError": "Nie udało się połączyć. Sprawdź dane i spróbuj ponownie.",
"settings.connectAuthFailed":
"Logowanie odrzucone. Jeśli masz włączone uwierzytelnianie dwuskładnikowe, użyj hasła do aplikacji (nie zwykłego hasła).",
"settings.connectPrivateHost":
"Ten serwer jest w sieci prywatnej, która ze względów bezpieczeństwa jest niedostępna. Użyj publicznego adresu HTTPS.",
"settings.connectUnreachable": "Nie udało się połączyć z serwerem. Sprawdź, czy adres jest poprawny i zaczyna się od https://.",
"settings.connectNoCalendars": "Połączono, ale na tym koncie nie ma kalendarzy.",
"settings.connectNeedsUrl": "Wpisz adres serwera CalDAV.",
"settings.davServerUrl": "Adres serwera CalDAV",
"settings.appleId": "Apple ID (e-mail)",
"settings.username": "Nazwa użytkownika",
"settings.appPassword": "Hasło dla aplikacji",
"settings.password": "Hasło",
"settings.icloudHint": "Użyj hasła dla aplikacji z account.apple.com → Logowanie i bezpieczeństwo.",
"settings.calendarCount": "{count} kalendarzy",
"settings.calendarCount_one": "1 kalendarz",
"settings.calendarCount_few": "{count} kalendarze",
"settings.removeSubscription": "Usuń subskrypcję",
"settings.removeSubscriptionConfirm": "Przestać synchronizować „{name}” i usunąć jego wydarzenia?",
"settings.deleteCalendar": "Usuń kalendarz",
"settings.thisCalendar": "ten kalendarz",
"settings.deleteCalendarConfirm": "Usunąć „{name}” i wszystkie jego wydarzenia? Tego nie da się cofnąć.",
"settings.showAlert": "Pokaż powiadomienie",
"settings.emailMe": "Wyślij mi e-mail",
"settings.invitations": "Zaproszenia",
"settings.inviteWhen": "Gdy dostaję wydarzenia e-mailem",
"settings.inviteAddUpdate": "Dodawaj nowe wydarzenia do kalendarza i aktualizuj zmienione",
"settings.inviteUpdateOnly": "Aktualizuj tylko wydarzenia, które już dodałem do kalendarza",
"settings.inviteNone": "Nie aktualizuj kalendarza automatycznie",
"settings.addNewFrom": "Dodawaj nowe wydarzenia od",
"settings.myContacts": "Moich kontaktów",
"settings.anyone": "Każdego",
"settings.addToCalendar": "Dodawaj do kalendarza",
"settings.alsoFile": "Oznacz też wiadomość jako przeczytaną i ją zarchiwizuj",
"settings.integrations": "Integracje",
"settings.shortcuts": "Skróty klawiszowe",
"settings.privacy": "Prywatność",
"settings.completion": "Podpowiadaj zakończenia zdań",
"settings.completionHint":
"Podczas pisania prawdopodobny ciąg dalszy zdania pojawia się na szaro; naciśnij Tab, aby go wstawić. Podpowiedzi pochodzą ze zwrotów, których używasz w wysłanej poczcie (oraz typowych zwrotów po angielsku, polsku i niemiecku), i nie opuszczają tego serwera.",
"settings.blockTrackers": "Blokuj moduły śledzące w poczcie",
"settings.blockTrackersHint":
"Wiele newsletterów i maili handlowych ukrywa maleńki, niewidoczny obraz, który mówi nadawcy, kiedy otworzyłeś wiadomość, ile razy i mniej więcej gdzie byłeś. Ultimo usuwa takie obrazy przed wyświetleniem wiadomości. Po wyłączeniu są traktowane jak każdy inny obraz z sieci: wczytywane dopiero, gdy zezwolisz na obrazy od tego nadawcy.",
"settings.shortcutsEnabled": "Używaj skrótów klawiszowych",
"settings.shortcutsHint":
"Pojedyncze klawisze działają na liście poczty i w otwartym wątku. Podczas pisania są ignorowane. Naciśnij ? w widoku poczty, aby zobaczyć tę listę.",
"shortcuts.title": "Skróty klawiszowe",
"shortcuts.then": "potem",
"shortcuts.groupNavigation": "Nawigacja",
"shortcuts.groupConversation": "Wątek",
"shortcuts.groupCompose": "Pisanie",
"shortcuts.next": "Następny wątek",
"shortcuts.prev": "Poprzedni wątek",
"shortcuts.back": "Wróć do listy",
"shortcuts.search": "Szukaj",
"shortcuts.goInbox": "Przejdź do Odebranych",
"shortcuts.goSnoozed": "Przejdź do Odłożonych",
"shortcuts.goDrafts": "Przejdź do Wersji roboczych",
"shortcuts.goSent": "Przejdź do Wysłanych",
"shortcuts.goSubscriptions": "Przejdź do Subskrypcji",
"shortcuts.help": "Pokaż skróty klawiszowe",
"shortcuts.reply": "Odpowiedz",
"shortcuts.replyAll": "Odpowiedz wszystkim",
"shortcuts.forward": "Przekaż dalej",
"shortcuts.archive": "Archiwizuj",
"shortcuts.trash": "Usuń",
"shortcuts.pin": "Przypnij / odepnij",
"shortcuts.snooze": "Odłóż",
"shortcuts.markRead": "Oznacz jako przeczytane",
"shortcuts.markUnread": "Oznacz jako nieprzeczytane",
"shortcuts.compose": "Nowa wiadomość",
"shortcuts.escape": "Zminimalizuj okna pisania, zamknij okno dialogowe",
"admin.title": "Administracja Ultimo Email",
"admin.signInTitle": "Logowanie administratora",
"admin.signInHint": "Zaloguj się kontem administratora, aby zarządzać tym serwerem.",
"admin.signOut": "Wyloguj się",
"admin.signedInAs": "Zalogowano jako {handle}",
"admin.notAdminTitle": "To nie jest konto administratora",
"admin.notAdmin": "Zalogowano jako {handle}, ale to konto nie ma uprawnień administratora na tym serwerze.",
"admin.registration": "Rejestracja nowych użytkowników",
"admin.registrationOn": "Otwarta",
"admin.registrationOff": "Zamknięta",
"admin.registrationHelp": "Gdy jest zamknięta, strona rejestracji jest ukryta, a serwer odrzuca nowe konta.",
"admin.invitations": "Zezwalaj na zaproszenia",
"admin.invitationsOn": "Wł.",
"admin.invitationsOff": "Wył.",
"admin.invitationsHelp":
"Każdy użytkownik może zaprosić 2 znajomych z paska bocznego. Link z zaproszenia pozwala założyć konto jednej osobie, nawet przy zamkniętej rejestracji, i wygasa po 14 dniach (niewykorzystane wracają do puli).",
"admin.inviteMail": "E-mail z zaproszeniem",
"admin.inviteConfigure": "Konfiguruj…",
"admin.inviteMailHelp":
"Wysyłany przez konto poczty systemowej (po prawej stronie panelu), które musi mieć prawo wysyłania z adresu nadawcy. Odpowiedzi trafiają do użytkownika, który wysłał zaproszenie.",
"admin.inviteFrom": "Adres nadawcy",
"admin.inviteFromHint": "Puste: pokazany adres zastępczy.",
"admin.inviteSubject": "Temat",
"admin.inviteTemplateDefault": "Używany jest wbudowany szablon.",
"admin.inviteTemplateCustom": "Używany jest twój przesłany szablon.",
"admin.inviteUpload": "Prześlij szablon HTML…",
"admin.inviteDownload": "Pobierz szablon",
"admin.inviteReset": "Wróć do wbudowanego szablonu",
"admin.inviteResetConfirm": "Usunąć twój szablon i znów używać wbudowanego?",
"admin.inviteResetAction": "Użyj wbudowanego szablonu",
"admin.invitePlaceholders": "Zmienne:",
"admin.invitePlaceholdersHelp":
"Szablon musi zawierać {{invite_link}} lub {{invite_button}}. Temat też może używać imion. Obrazy wpisane w HTML są wysyłane wewnątrz wiadomości.",
"admin.invitePreview": "Podgląd z przykładowymi imionami —",
"admin.inviteTestSent": "Wysłano testowe zaproszenie (jego link jest fikcyjny).",
"admin.defaultQuota": "Domyślny limit miejsca dla nowych użytkowników",
"admin.quotaHelp": "Dotyczy kont tworzonych od teraz. Istniejące skrzynki zachowują przyznany limit.",
"admin.save": "Zapisz",
"admin.saving": "Zapisywanie…",
"admin.saved": "Zapisano",
"admin.quotaInvalid": "Wpisz rozmiar większy od 0.",
"display.title": "Wygląd skrzynki",
"display.readingPane": "Okienko odczytu",
"display.side": "Z boku",
"display.bottom": "Na dole",
"display.off": "Wyłączone",
"display.dividers": "Pokazuj linię między wierszami",
"admin.mail": "Wychodząca poczta systemowa",
"admin.mailHelp":
"Konto pocztowe, z którego serwer wysyła linki do resetowania hasła. Bez niego „Nie pamiętasz hasła?” niczego nie wyśle.",
"admin.mailOn": "Skonfigurowane — linki do resetowania hasła mogą być wysyłane.",
"admin.mailOff": "Nieskonfigurowane — linki do resetowania hasła nie mogą jeszcze być wysyłane.",
"admin.smtpHost": "Serwer SMTP",
"admin.smtpPort": "Port",
"admin.smtpSecurity": "Zabezpieczenia",
"admin.smtpStarttls": "STARTTLS",
"admin.smtpSsl": "SSL/TLS",
"admin.smtpNone": "Brak",
"admin.smtpUsername": "Login",
"admin.smtpPassword": "Hasło",
"admin.smtpPasswordKept": "(zapisane — zostaw puste, aby zachować)",
"admin.smtpPasswordRemove": "Usuń zapisane hasło",
"admin.smtpFrom": "Adres nadawcy",
"admin.publicUrl": "Publiczny adres tego serwera",
"admin.publicUrlHint":
"Gdzie użytkownicy otwierają Ultimo Email, np. https://mail.example.com — tu prowadzą linki do resetowania hasła.",
"admin.testTo": "Wyślij testowy e-mail do",
"admin.sendTest": "Wyślij test",
"admin.testSent": "Wysłano testowy e-mail.",
"admin.users": "Użytkownicy",
"admin.userSearch": "Szukaj po loginie, adresie e-mail lub nazwie…",
"admin.noUsers": "Brak pasujących użytkowników.",
"admin.you": "ty",
"admin.adminBadge": "admin",
"admin.blockedBadge": "zablokowany",
"admin.usage": "{used} z {quota}",
"admin.noMailAccounts": "Brak połączonych kont pocztowych",
"admin.userQuota": "Limit",
"admin.blockedSince": "Zablokowany od {date}. Ten użytkownik nie może się zalogować.",
"admin.block": "Zablokuj konto",
"admin.unblock": "Odblokuj konto",
"admin.blockConfirm":
"Zablokować {handle}? Zostanie wylogowany ze wszystkich urządzeń i nie zaloguje się, dopóki go nie odblokujesz. Poczta i dane zostają zachowane.",
"admin.deleteUser": "Usuń konto",
"admin.deleteConfirm":
"Trwale usunąć {handle} wraz z całą pocztą, kontaktami, kalendarzami i ustawieniami? Tego nie da się cofnąć.",
"admin.selfNote": "To twoje własne konto: nie można go tu zablokować ani usunąć.",
"admin.cancel": "Anuluj",
"admin.close": "Zamknij",
"settings.jitsi": "Jitsi Meet",
"settings.jitsiHint":
"Serwer, na którym tworzone są linki do rozmów wideo dla wydarzeń w kalendarzu. Zostaw puste, aby używać publicznego serwera https://meet.jit.si/.",
"settings.addAccount": "Dodaj konto",
"settings.syncState": "Stan: {state}",
"settings.confirmRemoveAccount": "Usunąć to konto i całą jego pocztę?",
"settings.removeAccountTitle": "Usuń konto",
"settings.removeAccountAction": "Usuń konto",
"settings.removingAccount": "Usuwanie konta…",
"settings.editAccount": "Edytuj konto",
"settings.color": "Kolor",
"settings.serverSettings": "Zmień ustawienia serwera / hasło",
"settings.serverSettingsHint": "Wpisz hasło do skrzynki, aby zastosować zmiany serwera lub hasła.",
"settings.signature": "Podpis",
"settings.configureAccount": "Konfiguruj konto",
"settings.yourMailbox": "Twoja skrzynka",
"mail.darkMessages": "Ciemny tryb wiadomości",
"mail.darkMessagesOn":
"Ciemny tryb wiadomości jest włączony: wiadomości są wyświetlane na ciemnej stronie. Kliknij, aby pokazać je tak, jak zostały wysłane.",
"mail.darkMessagesOff":
"Ciemny tryb wiadomości jest wyłączony: wiadomości są pokazywane tak, jak zostały wysłane. Kliknij, aby wyświetlić je na ciemnej stronie.",
"settings.yourMailboxHint": "Twój adres w Ultimo. Zostaje tak długo, jak twoje konto.",
"settings.editSignature": "Edytuj podpis",
"settings.theme": "Motyw",
"theme.light": "Jasny",
"theme.dark": "Ciemny",
"theme.system": "Systemowy",
"theme.toggle": "Przełącz motyw",
"offline.label": "Offline",
"offline.backOnline": "Znów online",
"offline.explainSaved": "Offline — pokazuję pocztę zapisaną na tym urządzeniu, zaktualizowaną {when}.",
"offline.explainSavedNoTime": "Offline — pokazuję pocztę zapisaną na tym urządzeniu.",
"offline.explainNotSaved":
"Brak połączenia z serwerem. Aby czytać ostatnią pocztę bez połączenia, włącz dostęp offline w Ustawienia → Konta.",
"offline.noticeSaved": "Jesteś offline. Ostatnia poczta jest nadal dostępna.",
"offline.noticeNotSaved": "Jesteś offline — brak połączenia z serwerem.",
"offline.notAvailable": "Niedostępne offline",
"offline.threadNotSaved": "Ten wątek nie jest zapisany na tym urządzeniu.",
"offline.folderNotSaved": "Ten folder nie jest zapisany na tym urządzeniu.",
"offline.noticeScheduled": "Zapisano w Zaplanowanych — wyślemy, gdy znów będziesz online.",
"offline.noticeSendingScheduled": "Wysyłanie {count} zaplanowanych wiadomości…",
"offline.noticeSendingScheduled_one": "Wysyłanie 1 zaplanowanej wiadomości…",
"offline.draftsNotSaved": "Offline dostępne są tylko wersje robocze napisane na tym urządzeniu.",
"compose.sendLater": "Zaplanuj",
"compose.savedOnDevice": "Zapisano na tym urządzeniu",
"compose.forwardAttachFailed": "Nie udało się dołączyć plików z oryginalnej wiadomości.",
"compose.attachOnDevice": "Na tym urządzeniu — zostanie przesłane, gdy znów będziesz online",
"compose.onDevice": "Na tym urządzeniu",
"nav.scheduled": "Zaplanowane",
"scheduled.help": "Te wiadomości wyjdą same — gdy znów będziesz online albo gdy serwer pocztowy je przyjmie.",
"scheduled.empty": "Nic nie zaplanowano.",
"scheduled.waiting": "Czeka",
"scheduled.sending": "Wysyłanie…",
"scheduled.failed": "Nie wysłano",
"scheduled.retrying": "Ponowna próba {when}",
"scheduled.retryNow": "Spróbuj teraz",
"scheduled.edit": "Edytuj",
"scheduled.delete": "Usuń",
"scheduled.statusRetrying":
"Serwer pocztowy nie przyjął jeszcze tej wiadomości ({count} prób). Następna próba {when}; próbuje do {until}.",
"scheduled.statusRetrying_one":
"Serwer pocztowy nie przyjął jeszcze tej wiadomości (1 próba). Następna próba {when}; próbuje do {until}.",
"scheduled.statusRetrying_few":
"Serwer pocztowy nie przyjął jeszcze tej wiadomości ({count} próby). Następna próba {when}; próbuje do {until}.",
"scheduled.statusFailed": "Nie wysłano: {error}",
"scheduled.lastError": "Ostatni błąd: {error}",
"scheduled.to": "Do",
"send.noticeRetrying": "Nie udało się wysłać „{subject}” — jest w Zaplanowanych i zostanie wysłane ponownie.",
"send.noticeFailed": "Nie udało się wysłać „{subject}” — jest w Zaplanowanych: {error}",
"send.noticeSent": "Wysłano „{subject}”.",
"account.unreachable": "Brak połączenia z serwerem pocztowym od {when}",
"account.authFailed": "Logowanie nieudane — otwórz Ustawienia → Konta → Konfiguruj konto i wpisz hasło ponownie",
"settings.stateUnreachable": "Brak połączenia z serwerem od {when}",
"settings.stateAuthFailed": "Logowanie nieudane — użyj Konfiguruj konto, aby wpisać hasło ponownie",
"offline.searchPlaceholder": "Szukaj (offline)",
"offline.cantReach": "Brak połączenia z serwerem.",
"offline.settingTitle": "Dostęp offline",
"offline.settingHelp":
"Przechowuj na tym urządzeniu 100 najnowszych wątków z Odebranych oraz te, które otwierasz, aby czytać je bez połączenia. Są zapisane w tej przeglądarce bez szyfrowania i usuwane przy wylogowaniu — nie włączaj tego na wspólnych komputerach.",
"offline.settingStatus": "Zapisano {count} wątków · {size} · zaktualizowano {when}",
"offline.settingStatus_one": "Zapisano 1 wątek · {size} · zaktualizowano {when}",
"offline.settingStatus_few": "Zapisano {count} wątki · {size} · zaktualizowano {when}",
"offline.settingStatusNoTime": "Zapisano {count} wątków · {size}",
"offline.settingStatusNoTime_one": "Zapisano 1 wątek · {size}",
"offline.settingStatusNoTime_few": "Zapisano {count} wątki · {size}",
"offline.settingSaving": "Zapisywanie ostatniej poczty…",
"offline.settingUnsupported":
"Niedostępne tutaj: przeglądarki pozwalają na dostęp offline tylko wtedy, gdy aplikacja jest otwarta przez HTTPS.",
"push.settingTitle": "Powiadomienia",
"push.settingHelp":
"Dostawaj powiadomienie na tym urządzeniu, gdy do Odebranych przyjdzie nowa poczta, nawet gdy Ultimo nie jest otwarte.",
"push.needsInstall":
"Na iPhonie i iPadzie najpierw dodaj Ultimo do ekranu początkowego (Udostępnij → Do ekranu początk.), a potem włącz to w aplikacji.",
"push.blocked":
"Powiadomienia z tej strony są zablokowane w przeglądarce. Zezwól na nie w ustawieniach strony w przeglądarce, a potem włącz tę opcję.",
"push.unsupported":
"Niedostępne tutaj: ta przeglądarka nie pokazuje powiadomień ze stron albo aplikacja nie jest otwarta przez HTTPS.",
"push.notAllowed": "Nie zezwolono na powiadomienia.",
"push.waiting":
"Czekam na zgodę przeglądarki. Jeśli nie zapytała, kliknij ikonę po lewej stronie paska adresu i ustaw Powiadomienia na Zezwalaj — wtedy włączą się same.",
"push.failed": "Nie udało się włączyć powiadomień.",
"install.entry": "Zainstaluj aplikację",
"invite.entry": "Zaproś znajomego",
"invite.title": "Zaproś znajomego",
"invite.help":
"Wyślemy mu e-mailem link do założenia konta. Zostało ci {count} zaproszeń; każdy link działa raz.",
"invite.help_one":
"Wyślemy mu e-mailem link do założenia konta. To twoje ostatnie zaproszenie; link działa raz.",
"invite.help_few":
"Wyślemy mu e-mailem link do założenia konta. Zostały ci {count} zaproszenia; każdy link działa raz.",
"invite.name": "Imię znajomego",
"invite.email": "Adres e-mail znajomego",
"invite.send": "Wyślij zaproszenie",
"invite.sending": "Wysyłanie…",
"invite.sent": "Wysłano zaproszenie do: {name}",
"invite.welcome": "Cześć, {name} — {inviter} cię zaprasza. Wybierz adres i hasło.",
"invite.linkInvalid": "To zaproszenie zostało już wykorzystane albo wygasło.",
"install.title": "Zainstaluj Ultimo",
"install.stepsIos":
"1. Stuknij przycisk Udostępnij (kwadrat ze strzałką) na pasku Safari.\n2. Wybierz Do ekranu początk.\n3. Stuknij Dodaj. Ultimo otworzy się z ekranu początkowego jak aplikacja i może pokazywać powiadomienia o nowej poczcie.",
"install.stepsOpera":
"1. Otwórz menu Opery (⋮).\n2. Wybierz Dodaj do → Ekran główny.\n\nOpera doda skrót do Ultimo na ekranie głównym. Aby zainstalować Ultimo jako pełną aplikację, otwórz tę stronę w Chrome i stuknij tam Zainstaluj aplikację.",
"install.stepsFirefox": "1. Otwórz menu Firefoksa (⋮).\n2. Wybierz Dodaj aplikację do ekranu głównego (lub Zainstaluj).",
"install.hide": "Nie pokazuj więcej",
"install.gotIt": "Rozumiem",
"settings.language": "Język",
"settings.languageHint":
"Język menu, przycisków i komunikatów Ultimo. Zapisany na twoim koncie, więc jest taki sam na każdym urządzeniu.",
"time.justNow": "przed chwilą",
"time.minAgo": "{count} min temu",
"time.hoursAgo": "{count} godzin temu",
"time.hoursAgo_one": "godzinę temu",
"time.hoursAgo_few": "{count} godziny temu",
"time.daysAgo": "{count} dni temu",
"time.daysAgo_one": "1 dzień temu",
"reminder.atTime": "W chwili rozpoczęcia",
"reminder.minutesBefore": "{count} minut wcześniej",
"reminder.minutesBefore_few": "{count} minuty wcześniej",
"reminder.hoursBefore": "{count} godzin wcześniej",
"reminder.hoursBefore_one": "1 godzinę wcześniej",
"reminder.hoursBefore_few": "{count} godziny wcześniej",
"reminder.daysBefore": "{count} dni wcześniej",
"reminder.daysBefore_one": "1 dzień wcześniej",
"color.blue": "Niebieski",
"color.green": "Zielony",
"color.red": "Czerwony",
"color.orange": "Pomarańczowy",
"color.purple": "Fioletowy",
"color.teal": "Morski",
"color.pink": "Różowy",
"color.gray": "Szary",
"color.custom": "Własny",
"settings.selectColor": "Wybierz kolor",
"labels.remove": "Usuń etykietę",
"compose.attribution": "{date}, o {time}, {who} napisał(a):",
"print.attachments": "Załączniki",
"print.popupBlocked": "Zezwól tej stronie na wyskakujące okna, aby drukować.",
"mail.translate": "Przetłumacz",
"mail.translateTo": "Przetłumacz na…",
"mail.showOriginal": "Pokaż oryginał",
"mail.translating": "Tłumaczenie…",
"mail.translated": "Przetłumaczono",
"mail.translatedFrom": "Przetłumaczono z języka: {language}",
"mail.translatedFromMany": "Przetłumaczono z języków: {languages}",
"mail.translateFailed": "Nie udało się przetłumaczyć tej wiadomości. Usługa tłumaczenia może być zajęta lub niedostępna.",
};
+47
View File
@@ -129,6 +129,23 @@ body {
.wmc-editor:empty::before { .wmc-editor:empty::before {
content: ""; content: "";
} }
/* Sentence completion: the suggested rest of the sentence, in grey, with a
small "Tab" key after it (components/compose/completion.ts). */
.wmc-editor .wmc-ghost {
color: rgb(var(--app-faint));
pointer-events: none;
user-select: none;
}
.wmc-editor .wmc-ghost::after {
content: attr(data-hint);
margin-left: 6px;
padding: 0 4px;
border: 1px solid rgb(var(--app-border));
border-radius: 4px;
font-size: 10px;
line-height: 14px;
vertical-align: 1px;
}
.wmc-editor a { .wmc-editor a {
color: rgb(var(--app-accent)); color: rgb(var(--app-accent));
text-decoration: underline; text-decoration: underline;
@@ -246,6 +263,36 @@ html[data-offline] .wm-online-only:hover {
} }
} }
/* The line across a message while it is being translated (MessageBody): a
blue stripe running left to right along a white track. */
.wm-progress {
position: relative;
height: 4px;
overflow: hidden;
background-color: #fff;
}
.wm-progress::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 40%;
background-color: #2563eb;
animation: wm-progress-run 1.2s ease-in-out infinite;
}
@keyframes wm-progress-run {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}
@media (prefers-reduced-motion: reduce) {
.wm-progress::before {
animation-duration: 3s;
}
}
/* Attachment tiles (thread/Attachments.tsx) shrink with the reading column so /* Attachment tiles (thread/Attachments.tsx) shrink with the reading column so
three fit in a row; their name bar adapts to the tile's own width. */ three fit in a row; their name bar adapts to the tile's own width. */
.wm-tile { .wm-tile {
+55
View File
@@ -23,6 +23,9 @@ import type {
RegistrationStatus, RegistrationStatus,
RemoteDraftOut, RemoteDraftOut,
SavedSearch, SavedSearch,
MailTemplate,
Subscription,
SubscriptionList,
SearchOut, SearchOut,
SendResponse, SendResponse,
ServerConfig, ServerConfig,
@@ -294,11 +297,22 @@ export const api = {
| "invitation_file" | "invitation_file"
| "spellcheck_enabled" | "spellcheck_enabled"
| "spellcheck_language" | "spellcheck_language"
| "shortcuts_enabled"
| "block_trackers"
| "completion_enabled"
| "ui_language"
> >
>, >,
) => ) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }), apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"), quota: () => apiFetch<QuotaOut>("/settings/quota"),
// Sentence completion: the grey rest of the sentence (app/completion.py). A POST
// so the text being typed never appears in a URL (or an access log).
complete: (before: string) =>
apiFetch<{ suggestion: string | null }>("/completion", {
method: "POST",
body: JSON.stringify({ before }),
}),
spellcheck: (text: string, language: string) => spellcheck: (text: string, language: string) =>
apiFetch<SpellCheckOut>("/spellcheck", { apiFetch<SpellCheckOut>("/spellcheck", {
method: "POST", method: "POST",
@@ -356,6 +370,47 @@ export const api = {
deleteSavedSearch: (id: string) => deleteSavedSearch: (id: string) =>
apiFetch<{ status: string }>(`/saved-searches/${id}`, { method: "DELETE" }), apiFetch<{ status: string }>(`/saved-searches/${id}`, { method: "DELETE" }),
// --- snooze ---
snoozeThread: (threadId: string, until: Date) =>
apiFetch<{ thread_id: string; snoozed_until: string | null }>(`/threads/${threadId}/snooze`, {
method: "POST",
body: JSON.stringify({ until: until.toISOString() }),
}),
unsnoozeThread: (threadId: string) =>
apiFetch<{ thread_id: string }>(`/threads/${threadId}/unsnooze`, { method: "POST" }),
// --- subscriptions (bulk senders) ---
subscriptions: (offset = 0, limit = 50, order: "asc" | "desc" = "desc") =>
apiFetch<SubscriptionList>(`/subscriptions?offset=${offset}&limit=${limit}&order=${order}`),
subscriptionThreads: (key: string) =>
apiFetch<string[]>(`/subscriptions/threads?key=${encodeURIComponent(key)}`),
unsubscribe: (key: string, messageId?: string) =>
apiFetch<{ status: "done" | "open"; method: Subscription["method"]; url: string | null }>(
"/subscriptions/unsubscribe",
{ method: "POST", body: JSON.stringify({ key, message_id: messageId ?? null }) },
),
trashSubscription: (key: string) =>
apiFetch<{ moved: number }>("/subscriptions/trash", {
method: "POST",
body: JSON.stringify({ key }),
}),
// --- email translation (app/translation.py) ---
translationLanguages: () =>
apiFetch<{ available: boolean; languages: { code: string; name: string }[] }>(
"/translation/languages",
),
// --- templates (saved replies) ---
templates: () => apiFetch<MailTemplate[]>("/templates"),
saveTemplate: (name: string, subject: string, bodyHtml: string) =>
apiFetch<MailTemplate>("/templates", {
method: "POST",
body: JSON.stringify({ name, subject, body_html: bodyHtml }),
}),
deleteTemplate: (id: string) =>
apiFetch<{ status: string }>(`/templates/${id}`, { method: "DELETE" }),
// --- compose --- // --- compose ---
suggestRecipients: (q: string) => suggestRecipients: (q: string) =>
apiFetch<RecipientSuggestion[]>(`/recipients/suggest?q=${encodeURIComponent(q)}`), apiFetch<RecipientSuggestion[]>(`/recipients/suggest?q=${encodeURIComponent(q)}`),
+9 -7
View File
@@ -5,6 +5,8 @@
import { api } from "./api"; import { api } from "./api";
import { getDateLocale, getHour12 } from "./timeFormat"; import { getDateLocale, getHour12 } from "./timeFormat";
import type { Address, AttachmentOut, MessageEnvelope } from "./types"; import type { Address, AttachmentOut, MessageEnvelope } from "./types";
import { notify } from "../components/AppDialog";
import { translate } from "../i18n";
function esc(s: string): string { function esc(s: string): string {
return s return s
@@ -71,18 +73,18 @@ function metaRow(label: string, value: string): string {
function section(msg: MessageEnvelope, subject: string, body: string, atts: AttachmentOut[]): string { function section(msg: MessageEnvelope, subject: string, body: string, atts: AttachmentOut[]): string {
const attachLine = atts.length const attachLine = atts.length
? `<tr><th>Attachments</th><td>${atts ? `<tr><th>${esc(translate("print.attachments"))}</th><td>${atts
.map((a) => `${esc(a.filename)} (${fmtBytes(a.size_bytes)})`) .map((a) => `${esc(a.filename)} (${fmtBytes(a.size_bytes)})`)
.join(", ")}</td></tr>` .join(", ")}</td></tr>`
: ""; : "";
return `<section class="wmc-print-msg"> return `<section class="wmc-print-msg">
<div class="wmc-print-head"> <div class="wmc-print-head">
<div class="wmc-print-subject">${esc(subject || "(no subject)")}</div> <div class="wmc-print-subject">${esc(subject || translate("compose.noSubject"))}</div>
<table class="wmc-print-meta"> <table class="wmc-print-meta">
${metaRow("From", fmtList(msg.from_addr))} ${metaRow(translate("mail.from"), fmtList(msg.from_addr))}
${metaRow("To", fmtList(msg.to_addr))} ${metaRow(translate("compose.to"), fmtList(msg.to_addr))}
${metaRow("Cc", fmtList(msg.cc_addr))} ${metaRow(translate("compose.cc"), fmtList(msg.cc_addr))}
${metaRow("Date", fmtDate(msg.date))} ${metaRow(translate("mail.dateLabel"), fmtDate(msg.date))}
${attachLine} ${attachLine}
</table> </table>
</div> </div>
@@ -165,7 +167,7 @@ export async function printMessages(subject: string, messages: MessageEnvelope[]
// Open the window synchronously (inside the click) so it isn't blocked as a popup. // Open the window synchronously (inside the click) so it isn't blocked as a popup.
const win = window.open("", "_blank", "width=820,height=1000"); const win = window.open("", "_blank", "width=820,height=1000");
if (!win) { if (!win) {
window.alert("Please allow pop-ups for this site to print."); notify(translate("print.popupBlocked"), 6000);
return; return;
} }
const title = esc(subject || "(no subject)"); const title = esc(subject || "(no subject)");
+48
View File
@@ -0,0 +1,48 @@
import type { MessageKey } from "../i18n/en";
// Every keyboard shortcut of the mail view, as shown in Settings → Keyboard
// shortcuts and the "?" overlay. The handling lives in useKeyboardShortcuts.ts;
// keep the two in step. "g i" is a sequence: g, then i.
export interface ShortcutItem {
keys: string[];
label: MessageKey;
}
export const SHORTCUT_GROUPS: { title: MessageKey; items: ShortcutItem[] }[] = [
{
title: "shortcuts.groupNavigation",
items: [
{ keys: ["j"], label: "shortcuts.next" },
{ keys: ["k"], label: "shortcuts.prev" },
{ keys: ["u"], label: "shortcuts.back" },
{ keys: ["/"], label: "shortcuts.search" },
{ keys: ["g", "i"], label: "shortcuts.goInbox" },
{ keys: ["g", "z"], label: "shortcuts.goSnoozed" },
{ keys: ["g", "d"], label: "shortcuts.goDrafts" },
{ keys: ["g", "t"], label: "shortcuts.goSent" },
{ keys: ["g", "b"], label: "shortcuts.goSubscriptions" },
{ keys: ["?"], label: "shortcuts.help" },
],
},
{
title: "shortcuts.groupConversation",
items: [
{ keys: ["r"], label: "shortcuts.reply" },
{ keys: ["a"], label: "shortcuts.replyAll" },
{ keys: ["f"], label: "shortcuts.forward" },
{ keys: ["e"], label: "shortcuts.archive" },
{ keys: ["#"], label: "shortcuts.trash" },
{ keys: ["s"], label: "shortcuts.pin" },
{ keys: ["b"], label: "shortcuts.snooze" },
{ keys: ["Shift", "I"], label: "shortcuts.markRead" },
{ keys: ["Shift", "U"], label: "shortcuts.markUnread" },
],
},
{
title: "shortcuts.groupCompose",
items: [
{ keys: ["c"], label: "shortcuts.compose" },
{ keys: ["Esc"], label: "shortcuts.escape" },
],
},
];
+10 -2
View File
@@ -27,11 +27,19 @@ export function getHour12(): boolean {
return hour12; return hour12;
} }
// The interface language (i18n/index.tsx): dates are written in it.
let dateLanguage = "en";
export function setDateLanguage(code: string): void {
dateLanguage = code;
}
/** /**
* Locale for date formatting, tied to the time preference: 24-hour → en-GB * Locale for date formatting. In English it follows the time preference:
* (day/month/year, European), 12-hour → en-US (month/day/year, American). * 24-hour → en-GB (day/month/year, European), 12-hour → en-US (month/day/year,
* American). Other languages use their own (Polish: pl-PL).
*/ */
export function getDateLocale(): string { export function getDateLocale(): string {
if (dateLanguage === "pl") return "pl-PL";
return hour12 ? "en-US" : "en-GB"; return hour12 ? "en-US" : "en-GB";
} }
+57
View File
@@ -91,6 +91,8 @@ export interface ThreadSummary {
has_attachments: boolean; has_attachments: boolean;
pinned: boolean; pinned: boolean;
last_message_at: string; last_message_at: string;
/** While snoozed: when it comes back to the Inbox. */
snoozed_until?: string | null;
} }
export interface ThreadListOut { export interface ThreadListOut {
@@ -126,6 +128,12 @@ export interface MessageEnvelope {
size_bytes: number; size_bytes: number;
snippet: string; snippet: string;
also_in_account_ids: string[]; also_in_account_ids: string[];
/** Bulk mail: its Subscriptions group, how it can be left, and whether it was. */
subscription_key?: string | null;
unsubscribe_via?: "one_click" | "mailto" | "web" | null;
unsubscribed?: boolean;
/** Its language once found (the first time it is shown); "" unknown. */
content_language?: string;
} }
export interface ThreadDetail { export interface ThreadDetail {
@@ -134,6 +142,8 @@ export interface ThreadDetail {
order: "asc" | "desc"; order: "asc" | "desc";
pinned: boolean; pinned: boolean;
messages: MessageEnvelope[]; messages: MessageEnvelope[];
/** While snoozed: when it comes back to the Inbox. */
snoozed_until?: string | null;
} }
export type SyncWindow = "all" | "3months" | "new"; export type SyncWindow = "all" | "3months" | "new";
@@ -284,6 +294,14 @@ export interface UserSettings {
spellcheck_enabled: boolean; spellcheck_enabled: boolean;
/** "auto", or a LanguageTool code such as "pl-PL". */ /** "auto", or a LanguageTool code such as "pl-PL". */
spellcheck_language: string; spellcheck_language: string;
/** Gmail-style keyboard shortcuts in the mail view. */
shortcuts_enabled: boolean;
/** Remove tracking pixels from mail (Settings → Privacy). */
block_trackers: boolean;
/** Grey sentence endings in the compose window, taken with Tab. */
completion_enabled: boolean;
/** Interface language ("en", "pl"…); "" until first chosen. */
ui_language: string;
} }
/** One problem the spell checker found, by character offset in the checked text. */ /** One problem the spell checker found, by character offset in the checked text. */
@@ -310,9 +328,48 @@ export interface QuotaOut {
export interface FolderCounts { export interface FolderCounts {
inbox_unread: number; inbox_unread: number;
snoozed?: number;
drafts: number; drafts: number;
} }
/** A bulk sender mailing the user (Subscriptions page). */
export interface Subscription {
key: string;
name: string;
email: string;
list_id: string | null;
total: number;
unread: number;
last_30_days: number;
per_month: number;
first_at: string;
last_at: string;
last_subject: string;
one_click: boolean;
has_mailto: boolean;
has_web: boolean;
unsubscribed_at: string | null;
method: "one_click" | "mailto" | "web" | null;
still_sending: boolean;
}
export interface SubscriptionList {
/** One page of senders. */
subscriptions: Subscription[];
/** Senders in all. */
total: number;
/** Messages whose list headers are still being read (older mail). */
pending: number;
}
export interface MailTemplate {
id: string;
name: string;
subject: string;
body_html: string;
updated_at: string;
}
export interface SavedSearch { export interface SavedSearch {
id: string; id: string;
name: string; name: string;
+55 -37
View File
@@ -1,15 +1,28 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
// Keyboard shortcuts (SPEC §9.4). Ignores keystrokes while typing in inputs, // Keyboard shortcuts (SPEC §9.4; the list users see is lib/shortcuts.ts).
// except Escape. Supports the `g i` chord (go to inbox). // Ignores keystrokes while typing in inputs, except Escape. "g" starts a
// two-key sequence (g i = Inbox). With `enabled` false only Escape works.
export interface Shortcuts { export interface Shortcuts {
enabled?: boolean;
onSearch?: () => void; onSearch?: () => void;
onCompose?: () => void; onCompose?: () => void;
onReply?: () => void; onReply?: () => void;
onReplyAll?: () => void; onReplyAll?: () => void;
onForward?: () => void; onForward?: () => void;
onArchive?: () => void; onArchive?: () => void;
onTrash?: () => void;
onTogglePin?: () => void;
onSnooze?: () => void;
onMarkRead?: () => void;
onMarkUnread?: () => void;
onBack?: () => void;
onGoInbox?: () => void; onGoInbox?: () => void;
onGoSnoozed?: () => void;
onGoDrafts?: () => void;
onGoSent?: () => void;
onGoSubscriptions?: () => void;
onHelp?: () => void;
onEscape?: () => void; onEscape?: () => void;
onNext?: () => void; onNext?: () => void;
onPrev?: () => void; onPrev?: () => void;
@@ -18,7 +31,7 @@ export interface Shortcuts {
function isTyping(el: EventTarget | null): boolean { function isTyping(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false; if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName; const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable; return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
} }
export function useKeyboardShortcuts(shortcuts: Shortcuts): void { export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
@@ -33,49 +46,54 @@ export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
s.onEscape?.(); s.onEscape?.();
return; return;
} }
if (isTyping(e.target)) return; if (s.enabled === false || isTyping(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
const run = (fn: (() => void) | undefined) => {
if (!fn) return;
e.preventDefault();
fn();
};
if (pendingG.current) { if (pendingG.current) {
pendingG.current = false; pendingG.current = false;
if (e.key === "i") { const go: Record<string, (() => void) | undefined> = {
e.preventDefault(); i: s.onGoInbox,
s.onGoInbox?.(); z: s.onGoSnoozed,
d: s.onGoDrafts,
t: s.onGoSent,
b: s.onGoSubscriptions,
};
if (e.key in go) {
run(go[e.key]);
return; return;
} }
} }
switch (e.key) { // Shift+letter arrives as the capital letter.
case "/": const actions: Record<string, (() => void) | undefined> = {
e.preventDefault(); "/": s.onSearch,
s.onSearch?.(); "?": s.onHelp,
break; c: s.onCompose,
case "c": r: s.onReply,
s.onCompose?.(); a: s.onReplyAll,
break; f: s.onForward,
case "r": e: s.onArchive,
s.onReply?.(); "#": s.onTrash,
break; s: s.onTogglePin,
case "a": b: s.onSnooze,
s.onReplyAll?.(); I: s.onMarkRead,
break; U: s.onMarkUnread,
case "f": u: s.onBack,
s.onForward?.(); j: s.onNext,
break; k: s.onPrev,
case "e": };
s.onArchive?.(); if (e.key === "g") {
break; pendingG.current = true;
case "j": window.setTimeout(() => (pendingG.current = false), 1000);
s.onNext?.(); return;
break;
case "k":
s.onPrev?.();
break;
case "g":
pendingG.current = true;
window.setTimeout(() => (pendingG.current = false), 800);
break;
} }
if (e.key in actions) run(actions[e.key]);
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
+338 -17
View File
@@ -38,6 +38,17 @@ import { ThreadView } from "../components/thread/ThreadView";
import { MailboxDisplay } from "../components/mail/MailboxDisplay"; import { MailboxDisplay } from "../components/mail/MailboxDisplay";
import { RightPanel, type CalendarFocus, type PanelTab } from "../components/mail/RightPanel"; import { RightPanel, type CalendarFocus, type PanelTab } from "../components/mail/RightPanel";
import { ScheduledSendPane } from "../components/mail/ScheduledSendPane"; import { ScheduledSendPane } from "../components/mail/ScheduledSendPane";
import { SnoozeMenu } from "../components/mail/SnoozeMenu";
import { clearSnapshots, restoredWindows, saveWindowList } from "../components/compose/restore";
import { useConfirm, usePrompt } from "../components/AppDialog";
import { SubscriptionPane } from "../components/mail/SubscriptionPane";
import {
FinishOnPageDialog,
SubscriptionBulkButtons,
SubscriptionsList,
useBulkSubscriptionActions,
} from "../components/list/SubscriptionsList";
import { ShortcutsHelp } from "../components/ShortcutList";
import { useT } from "../i18n"; import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en"; import type { MessageKey } from "../i18n/en";
import { api, isOfflineError } from "../lib/api"; import { api, isOfflineError } from "../lib/api";
@@ -76,11 +87,13 @@ const COLLAPSED_W = 60;
// Unified per-folder views. "Spam" maps to the IMAP special-use "junk". // Unified per-folder views. "Spam" maps to the IMAP special-use "junk".
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [ const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
{ view: "folder:inbox", icon: "inbox", label: "nav.inbox" }, { view: "folder:inbox", icon: "inbox", label: "nav.inbox" },
{ view: "snoozed", icon: "snooze", label: "nav.snoozed" },
{ view: "folder:archive", icon: "archive", label: "nav.archive" }, { view: "folder:archive", icon: "archive", label: "nav.archive" },
{ view: "folder:drafts", icon: "drafts", label: "nav.drafts" }, { view: "folder:drafts", icon: "drafts", label: "nav.drafts" },
{ view: "folder:sent", icon: "sent", label: "nav.sent" }, { view: "folder:sent", icon: "sent", label: "nav.sent" },
{ view: "folder:junk", icon: "spam", label: "nav.spam" }, { view: "folder:junk", icon: "spam", label: "nav.spam" },
{ view: "folder:trash", icon: "trash", label: "nav.trash" }, { view: "folder:trash", icon: "trash", label: "nav.trash" },
{ view: "subscriptions", icon: "subscriptions", label: "nav.subscriptions" },
]; ];
// Phone swipes still in their Undo window, remembered across a refresh: the // Phone swipes still in their Undo window, remembered across a refresh: the
@@ -168,8 +181,15 @@ export function MailLayout() {
}); });
// Every open compose window, oldest first — the dock lays them out left to // Every open compose window, oldest first — the dock lays them out left to
// right and keeps each one mounted for its whole life. // right and keeps each one mounted for its whole life.
const [composeWins, setComposeWins] = useState<ComposeWin[]>([]); // Brought back from before Settings (or a reload) — compose/restore.ts.
const composeSeq = useRef(0); const [composeWins, setComposeWins] = useState<ComposeWin[]>(restoredWindows);
useEffect(() => saveWindowList(composeWins), [composeWins]);
useEffect(() => clearSnapshots(), []);
// Past every restored window's id and activation, so a new window ("c<n>")
// never takes a restored one's id.
const composeSeq = useRef(
Math.max(0, ...composeWins.map((w) => Math.max(w.activated, Number(w.id.slice(1)) || 0))),
);
// A draft opened from the Drafts folder is edited in the reading pane, the // A draft opened from the Drafts folder is edited in the reading pane, the
// way a thread is read there — not in a floating window. // way a thread is read there — not in a floating window.
const [paneDraft, setPaneDraft] = useState<ComposerInit | null>(null); const [paneDraft, setPaneDraft] = useState<ComposerInit | null>(null);
@@ -273,6 +293,69 @@ export function MailLayout() {
// Messages written offline: drafts kept on this device, and the Scheduled // Messages written offline: drafts kept on this device, and the Scheduled
// folder of ones sent offline (lib/scheduled.ts). // folder of ones sent offline (lib/scheduled.ts).
const isScheduled = view === "local:scheduled"; const isScheduled = view === "local:scheduled";
// Subscriptions: bulk senders, listed in place of conversations (api/subscriptions.py).
const isSubscriptions = view === "subscriptions";
const showSubscriptions = isSubscriptions && !term.trim();
// Its own sort order (newest / oldest latest mail), remembered apart from the
// conversations' so that flipping one leaves the other alone.
const [subOrder, setSubOrder] = useState<"newest" | "oldest">(() => {
try {
return localStorage.getItem("subs.order") === "oldest" ? "oldest" : "newest";
} catch {
return "newest";
}
});
useEffect(() => {
try {
localStorage.setItem("subs.order", subOrder);
} catch {
/* storage blocked: the order lasts for this page only */
}
}, [subOrder]);
const subscriptionsQuery = useQuery({
queryKey: ["subscriptions", page, subOrder],
queryFn: () =>
api.subscriptions(page * PAGE_SIZE, PAGE_SIZE, subOrder === "newest" ? "desc" : "asc"),
enabled: isSubscriptions,
placeholderData: keepPreviousData, // the current page stays while the next loads
// Older mail is still being read for list headers: look again until done.
refetchInterval: (q) => ((q.state.data?.pending ?? 0) > 0 ? 4000 : false),
});
const [subKey, setSubKey] = useState<string | null>(null);
// Checked senders, for the bulk Unsubscribe / Delete all.
const [subChecked, setSubChecked] = useState<Set<string>>(new Set());
// Senders deleted from the last page can leave it empty: step back to one with rows.
const subsPageEmpty =
page > 0 && subscriptionsQuery.data?.subscriptions.length === 0 && subscriptionsQuery.data.total > 0;
useEffect(() => {
if (isSubscriptions && subsPageEmpty) setPage((p) => Math.max(0, p - 1));
}, [isSubscriptions, subsPageEmpty]);
// A selection belongs to the page it was made on, as with conversations.
useEffect(() => {
setSubKey(null);
setSubChecked(new Set());
}, [view, page]);
const subBulk = useBulkSubscriptionActions(() => setSubChecked(new Set()));
const checkedSubs = (subscriptionsQuery.data?.subscriptions ?? []).filter((x) => subChecked.has(x.key));
const toggleSubCheck = (key: string) =>
setSubChecked((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const allSubsChecked =
(subscriptionsQuery.data?.subscriptions.length ?? 0) > 0 &&
(subscriptionsQuery.data?.subscriptions ?? []).every((x) => subChecked.has(x.key));
const toggleAllSubs = () => {
const all = subscriptionsQuery.data?.subscriptions ?? [];
setSubChecked((prev) =>
all.length > 0 && all.every((x) => prev.has(x.key)) ? new Set() : new Set(all.map((x) => x.key)),
);
};
const selectedSub = isSubscriptions
? (subscriptionsQuery.data?.subscriptions.find((x) => x.key === subKey) ?? null)
: null;
const localMessages = useLocalMessages(); const localMessages = useLocalMessages();
const deviceDrafts = localMessages.filter((m) => m.state === "draft"); const deviceDrafts = localMessages.filter((m) => m.state === "draft");
// Plus sends the mail server could not take, retrying on the server (or failed). // Plus sends the mail server could not take, retrying on the server (or failed).
@@ -299,7 +382,7 @@ export function MailLayout() {
} }
return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam }); return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam });
}, },
enabled: !isDrafts && !isScheduled && !searching, enabled: !isDrafts && !isScheduled && !isSubscriptions && !searching,
placeholderData: keepPreviousData, // keep the current page visible while the next loads placeholderData: keepPreviousData, // keep the current page visible while the next loads
}); });
const draftsQuery = useQuery({ const draftsQuery = useQuery({
@@ -376,8 +459,12 @@ export function MailLayout() {
pageThreads = hiddenIds.size || swipedIds.size ? all.filter((th) => !isHidden(th.id)) : all; pageThreads = hiddenIds.size || swipedIds.size ? all.filter((th) => !isHidden(th.id)) : all;
total = Math.max(0, (threadsQuery.data?.total ?? 0) - (all.length - pageThreads.length)); total = Math.max(0, (threadsQuery.data?.total ?? 0) - (all.length - pageThreads.length));
} }
// The Subscriptions folder pages its senders the same way (server-side).
const pagerTotal = showSubscriptions ? (subscriptionsQuery.data?.total ?? 0) : total;
const pageStart = page * PAGE_SIZE; const pageStart = page * PAGE_SIZE;
const pageEnd = pageStart + pageThreads.length; const pageEnd =
pageStart +
(showSubscriptions ? (subscriptionsQuery.data?.subscriptions.length ?? 0) : pageThreads.length);
// Search: fetch further pages of results as far as the page shown needs (one // Search: fetch further pages of results as far as the page shown needs (one
// conversation more, to know whether a next page exists) — or all of them // conversation more, to know whether a next page exists) — or all of them
// for oldest-first, whose first page is the end of the results. // for oldest-first, whose first page is the end of the results.
@@ -394,7 +481,7 @@ export function MailLayout() {
if (searchLoaded <= wanted && searchPages < MAX_SEARCH_PAGES) void fetchMoreSearch(); if (searchLoaded <= wanted && searchPages < MAX_SEARCH_PAGES) void fetchMoreSearch();
}, [searching, searchHasMore, searchFetchingMore, fetchMoreSearch, searchLoaded, searchPages, page, listOrder]); }, [searching, searchHasMore, searchFetchingMore, fetchMoreSearch, searchLoaded, searchPages, page, listOrder]);
// Reset to the first page whenever the list identity (not the page) changes. // Reset to the first page whenever the list identity (not the page) changes.
useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder]); useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder, subOrder]);
// Master "select all" state over the currently listed threads. // Master "select all" state over the currently listed threads.
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length; const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
@@ -439,11 +526,21 @@ export function MailLayout() {
mutationFn: (id: string) => api.deleteSavedSearch(id), mutationFn: (id: string) => api.deleteSavedSearch(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
}); });
const onSaveSearch = () => { // Our own dialogs (components/AppDialog.tsx) instead of the browser's.
const [askConfirm, confirmDialog] = useConfirm();
const [askText, promptDialog] = usePrompt();
const onSaveSearch = async () => {
const q = term.trim(); const q = term.trim();
if (!q) return; if (!q) return;
const name = window.prompt(t("search.saveNamePrompt"), q); const name = await askText({
if (name && name.trim()) saveSearch.mutate(name.trim()); title: t("search.save"),
message: q,
label: t("search.saveNameLabel"),
initial: q,
confirmLabel: t("common.save"),
maxLength: 80,
});
if (name) saveSearch.mutate(name);
}; };
const runSavedSearch = (s: SavedSearch) => { const runSavedSearch = (s: SavedSearch) => {
setTerm(s.query); setTerm(s.query);
@@ -626,6 +723,51 @@ export function MailLayout() {
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] }); queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
}; };
// Snooze (app/snooze.py): the conversation leaves the Inbox until then.
const [snoozeOpen, setSnoozeOpen] = useState(false);
const closeSnooze = useCallback(() => setSnoozeOpen(false), []);
const snoozeThread = async (when: Date) => {
setSnoozeOpen(false);
if (!threadId) return;
const id = threadId;
try {
await api.snoozeThread(id, when);
} catch {
window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text: t("snooze.failed") } }));
return;
}
const whenText = `${when.toLocaleDateString(getDateLocale(), { weekday: "short", day: "numeric", month: "short" })}, ${formatClock(when)}`;
window.dispatchEvent(
new CustomEvent("webmail:toast", {
detail: {
text: t("snooze.done", { when: whenText }),
ms: 6000,
action: {
label: t("common.undo"),
run: async () => {
await api.unsnoozeThread(id);
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
},
},
},
}),
);
afterMove();
};
const unsnooze = async () => {
if (!threadId) return;
await api.unsnoozeThread(threadId);
queryClient.invalidateQueries({ queryKey: ["thread", threadId] });
afterMove();
};
const markUnread = async () => {
if (!threadId) return;
await api.threadFlags(threadId, [], ["\\Seen"]);
afterMove(); // back to the list: staying open would mark it read again
};
const [helpOpen, setHelpOpen] = useState(false);
// Per-thread actions for the mail-list hover buttons (act on a given id, not the // Per-thread actions for the mail-list hover buttons (act on a given id, not the
// currently-open thread). Close the reading pane only if the acted thread is open. // currently-open thread). Close the reading pane only if the acted thread is open.
// //
@@ -873,7 +1015,13 @@ export function MailLayout() {
// work runs in a worker; refresh the views after it and via the WS message.deleted. // work runs in a worker; refresh the views after it and via the WS message.deleted.
const [emptying, setEmptying] = useState(false); const [emptying, setEmptying] = useState(false);
const emptyCurrentFolder = async () => { const emptyCurrentFolder = async () => {
if (!window.confirm(t("mail.emptyConfirm"))) return; const ok = await askConfirm({
title: currentFolder === "trash" ? t("mail.emptyTrash") : t("mail.emptySpam"),
message: t("mail.emptyConfirm"),
confirmLabel: t("mail.emptyAction"),
danger: true,
});
if (!ok) return;
setEmptying(true); setEmptying(true);
try { try {
await api.emptyFolder(currentFolder); await api.emptyFolder(currentFolder);
@@ -910,14 +1058,29 @@ export function MailLayout() {
}; };
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: settingsQuery.data?.shortcuts_enabled ?? true,
onSearch: () => searchRef.current?.focus(), onSearch: () => searchRef.current?.focus(),
onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }), onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }),
onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }), onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }),
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }), onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }), onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
onArchive: archive, onArchive: archive,
onTrash: () => currentFolder !== "trash" && void trash(),
onTogglePin: () => void togglePin(),
onSnooze: () => threadId && setSnoozeOpen(true),
onMarkRead: () => threadId && markRead.mutate(threadId),
onMarkUnread: () => void markUnread(),
onBack: () => navigate("/mail"),
onGoInbox: () => selectView("folder:inbox"), onGoInbox: () => selectView("folder:inbox"),
onEscape: () => setComposeWins(minimizeAll), onGoSnoozed: () => selectView("snoozed"),
onGoDrafts: () => selectView("folder:drafts"),
onGoSent: () => selectView("folder:sent"),
onGoSubscriptions: () => selectView("subscriptions"),
onHelp: () => setHelpOpen(true),
onEscape: () => {
if (helpOpen) setHelpOpen(false);
else setComposeWins(minimizeAll);
},
onNext: () => openRelative(1), onNext: () => openRelative(1),
onPrev: () => openRelative(-1), onPrev: () => openRelative(-1),
}); });
@@ -998,7 +1161,7 @@ export function MailLayout() {
}, [queryClient]); }, [queryClient]);
// On a phone the reading pane replaces the list rather than sitting beside it. // On a phone the reading pane replaces the list rather than sitting beside it.
const paneShown = Boolean(paneDraft || threadId); const paneShown = Boolean(paneDraft || threadId || selectedSub);
return ( return (
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}> <div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
@@ -1068,7 +1231,9 @@ export function MailLayout() {
const badge = const badge =
f.view === "folder:inbox" f.view === "folder:inbox"
? countsQuery.data?.inbox_unread ? countsQuery.data?.inbox_unread
: f.view === "folder:drafts" : f.view === "snoozed"
? countsQuery.data?.snoozed || undefined
: f.view === "folder:drafts"
? (countsQuery.data?.drafts ?? 0) + deviceDrafts.length || undefined ? (countsQuery.data?.drafts ?? 0) + deviceDrafts.length || undefined
: undefined; : undefined;
return ( return (
@@ -1079,7 +1244,15 @@ export function MailLayout() {
title={collapsed ? t(f.label) : undefined} title={collapsed ? t(f.label) : undefined}
onClick={() => selectView(f.view)} onClick={() => selectView(f.view)}
> >
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} /> {f.icon === "snooze" ? (
<span className="flex w-[18px] justify-center">
<Clock size={18} />
</span>
) : f.icon === "subscriptions" ? (
<SubscriptionsIcon />
) : (
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
)}
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>} {!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
{!collapsed && badge ? ( {!collapsed && badge ? (
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span> <span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
@@ -1351,7 +1524,84 @@ export function MailLayout() {
</button> </button>
)} )}
</div> </div>
{!isDrafts && !isScheduled && ( {showSubscriptions && (
// The Subscriptions folder's own bar, built like the conversations' one.
<div
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
mobile ? "px-3" : "px-6"
}`}
>
<button
type="button"
aria-label={t("mail.selectAll")}
title={t("mail.selectAll")}
onClick={toggleAllSubs}
disabled={!subscriptionsQuery.data?.subscriptions.length}
className={`${mobile ? "hidden" : "flex"} ml-[28px] h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors disabled:opacity-40 ${
allSubsChecked || subChecked.size > 0
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
}`}
>
{allSubsChecked ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
) : subChecked.size > 0 ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
<path d="M4 8h8" />
</svg>
) : null}
</button>
<span className="text-app-muted">
{subChecked.size > 0
? t("subs.selected", { count: subChecked.size })
: t("subs.senders", { count: subscriptionsQuery.data?.total ?? 0 })}
</span>
<div className="-mr-1.5 ml-auto flex items-center gap-1">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={subOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
aria-label={subOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
onClick={() => setSubOrder((o) => (o === "newest" ? "oldest" : "newest"))}
>
<SortIcon />
</button>
<div className="relative">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.more")}
aria-label={t("mail.more")}
onClick={() => setMoreOpen((o) => !o)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
</button>
{moreOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMoreOpen(false)} />
<div className="absolute right-0 z-20 mt-1 min-w-[180px] rounded-lg border border-app-border bg-app-surface py-1 shadow-lg">
<button
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setDisplayOpen(true);
}}
>
{t("display.title")}
</button>
</div>
</>
)}
{displayOpen && <MailboxDisplay onClose={closeDisplay} layouts={!mobile} />}
</div>
</div>
</div>
)}
{!isDrafts && !isScheduled && !showSubscriptions && (
<div <div
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${ className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
mobile ? "px-3" : "px-6" mobile ? "px-3" : "px-6"
@@ -1461,6 +1711,13 @@ export function MailLayout() {
</div> </div>
</div> </div>
)} )}
{single && showSubscriptions && subChecked.size > 0 && (
// As for conversations: with no pane beside the list, the bulk buttons sit above it.
<div className="flex h-[48px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-2 text-[15px]">
<span className="px-2 text-[13px] text-app-muted">{subChecked.size}</span>
<SubscriptionBulkButtons selected={checkedSubs} bulk={subBulk} />
</div>
)}
{single && checkedIds.size > 0 && ( {single && checkedIds.size > 0 && (
// Side by side, bulk actions show in the reading pane; with a single // Side by side, bulk actions show in the reading pane; with a single
// panel there is none beside the list, so they sit above it. // panel there is none beside the list, so they sit above it.
@@ -1485,6 +1742,20 @@ export function MailLayout() {
if (threadId) navigate("/mail"); if (threadId) navigate("/mail");
}} }}
/> />
) : showSubscriptions ? (
<SubscriptionsList
data={subscriptionsQuery.data}
loading={subscriptionsQuery.isLoading}
selectedKey={subKey}
onSelect={(key) => {
setSubKey(key);
if (threadId) navigate("/mail");
}}
checkedKeys={subChecked}
onToggleCheck={toggleSubCheck}
busyKeys={subBulk.running ? subChecked : undefined}
compact={mobile}
/>
) : isDrafts && !term.trim() ? ( ) : isDrafts && !term.trim() ? (
<DraftsList <DraftsList
drafts={draftsQuery.data ?? []} drafts={draftsQuery.data ?? []}
@@ -1545,10 +1816,10 @@ export function MailLayout() {
> >
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} /> <RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
</button> </button>
{!isDrafts && !isScheduled && total > 0 ? ( {!isDrafts && !isScheduled && pagerTotal > 0 ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span> <span>
{pageStart + 1}–{pageEnd} of {total} {pageStart + 1}–{pageEnd} of {pagerTotal}
</span> </span>
<div className="-mr-1 flex items-center gap-0.5"> <div className="-mr-1 flex items-center gap-0.5">
<PagerButton <PagerButton
@@ -1559,7 +1830,7 @@ export function MailLayout() {
big={mobile} big={mobile}
/> />
<PagerButton <PagerButton
disabled={pageEnd >= total} disabled={pageEnd >= pagerTotal}
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
label={t("pager.next")} label={t("pager.next")}
dir="right" dir="right"
@@ -1614,6 +1885,15 @@ export function MailLayout() {
onClose={closePaneDraft} onClose={closePaneDraft}
fullWindow fullWindow
/> />
) : isSubscriptions && subChecked.size > 0 && !single ? (
<div className="flex h-full flex-col">
<div className={paneBarClass}>
<SubscriptionBulkButtons selected={checkedSubs} bulk={subBulk} />
</div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
{t("subs.sendersSelected", { count: subChecked.size })}
</div>
</div>
) : checkedIds.size > 0 && !single ? ( ) : checkedIds.size > 0 && !single ? (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className={paneBarClass}> <div className={paneBarClass}>
@@ -1681,6 +1961,20 @@ export function MailLayout() {
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />
{t("mail.delete")} {t("mail.delete")}
</ToolbarButton> </ToolbarButton>
{detailQuery.data.snoozed_until ? (
<ToolbarButton onClick={unsnooze} active>
<Clock size={16} />
{t("snooze.unsnooze")}
</ToolbarButton>
) : (
<div className="relative">
<ToolbarButton onClick={() => setSnoozeOpen((v) => !v)} active={snoozeOpen}>
<Clock size={16} />
{t("snooze.action")}
</ToolbarButton>
{snoozeOpen && <SnoozeMenu onPick={snoozeThread} onClose={closeSnooze} />}
</div>
)}
</> </>
)} )}
<span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}> <span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}>
@@ -1746,6 +2040,13 @@ export function MailLayout() {
<NotSavedOffline /> <NotSavedOffline />
</div> </div>
</div> </div>
) : selectedSub ? (
<SubscriptionPane
sub={selectedSub}
barClass={paneBarClass}
onBack={single ? () => setSubKey(null) : undefined}
onOpenThread={(id) => navigate(`/mail/t/${id}`)}
/>
) : ( ) : (
// Clipped to the pane: dragged very narrow, the picture and the line // Clipped to the pane: dragged very narrow, the picture and the line
// are cut at its edges instead of spilling over the list beside it. // are cut at its edges instead of spilling over the list beside it.
@@ -1823,6 +2124,13 @@ export function MailLayout() {
/> />
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} /> <UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
<Toast mobile={mobile} /> <Toast mobile={mobile} />
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
{confirmDialog}
{promptDialog}
{subBulk.dialog}
{subBulk.finishOnPage.length > 0 && (
<FinishOnPageDialog items={subBulk.finishOnPage} onClose={subBulk.closeFinish} />
)}
</div> </div>
); );
} }
@@ -1985,6 +2293,19 @@ function SortIcon() {
); );
} }
// Subscriptions folder: a stack of envelopes (newsletters).
function SubscriptionsIcon() {
return (
<span className="flex w-[18px] justify-center" aria-hidden>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="8" width="15" height="11" rx="2" />
<path d="M3.5 9l7 5 7-5" />
<path d="M7 5h11a3 3 0 0 1 3 3v8" />
</svg>
</span>
);
}
function SidebarItem({ function SidebarItem({
active, active,
onClick, onClick,
+170 -21
View File
@@ -7,7 +7,8 @@ import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput"; import { TypedPasswordInput } from "../../components/TypedPasswordInput";
import { ListDividersOption } from "../../components/mail/ListDividersOption"; import { ListDividersOption } from "../../components/mail/ListDividersOption";
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker"; import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
import { useT } from "../../i18n"; import { LANGUAGES, translate, useLocale, useT } from "../../i18n";
import type { MessageKey } from "../../i18n/en";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import type { CalendarOut, ReminderPref } from "../../lib/types"; import type { CalendarOut, ReminderPref } from "../../lib/types";
import { useMe } from "../../lib/auth"; import { useMe } from "../../lib/auth";
@@ -28,8 +29,19 @@ import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat"
import { PluginSlot } from "../../plugins/PluginSlot"; import { PluginSlot } from "../../plugins/PluginSlot";
import { usePluginSettingsSections } from "../../plugins/manifest"; import { usePluginSettingsSections } from "../../plugins/manifest";
import { AccountWizard } from "../wizard/AccountWizard"; import { AccountWizard } from "../wizard/AccountWizard";
import { ShortcutList } from "../../components/ShortcutList";
import { useConfirm } from "../../components/AppDialog";
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const; const SECTIONS = [
"profile",
"security",
"privacy",
"accounts",
"calendars",
"appearance",
"shortcuts",
"integrations",
] as const;
type Section = (typeof SECTIONS)[number]; type Section = (typeof SECTIONS)[number];
export function Settings() { export function Settings() {
@@ -60,9 +72,11 @@ export function Settings() {
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />} {addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
{!addingAccount && section === "profile" && <ProfileSection />} {!addingAccount && section === "profile" && <ProfileSection />}
{!addingAccount && section === "security" && <SecuritySection />} {!addingAccount && section === "security" && <SecuritySection />}
{section === "privacy" && <PrivacySection />}
{!addingAccount && section === "accounts" && <AccountsSection />} {!addingAccount && section === "accounts" && <AccountsSection />}
{section === "calendars" && <CalendarsSection />} {section === "calendars" && <CalendarsSection />}
{section === "appearance" && <AppearanceSection />} {section === "appearance" && <AppearanceSection />}
{section === "shortcuts" && <ShortcutsSection />}
{section === "integrations" && <IntegrationsSection />} {section === "integrations" && <IntegrationsSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section {/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
per plugin, selected from the nav like any built-in section. */} per plugin, selected from the nav like any built-in section. */}
@@ -516,6 +530,7 @@ function OfflineAccessSetting() {
*/ */
function AppPasswordsSetting() { function AppPasswordsSetting() {
const t = useT(); const t = useT();
const [confirm, confirmDialog] = useConfirm();
const me = useMe().data; const me = useMe().data;
const handle = me?.handle ?? ""; const handle = me?.handle ?? "";
const mailHost = me?.mail_host ?? null; const mailHost = me?.mail_host ?? null;
@@ -559,6 +574,7 @@ function AppPasswordsSetting() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{confirmDialog}
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2> <h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
<p className="text-sm text-app-muted">{t(mailHost ? "appPasswords.helpMail" : "appPasswords.help")}</p> <p className="text-sm text-app-muted">{t(mailHost ? "appPasswords.helpMail" : "appPasswords.help")}</p>
{/* What to type into a mail app (the hosted mailbox, SPEC App. D §D.5). */} {/* What to type into a mail app (the hosted mailbox, SPEC App. D §D.5). */}
@@ -643,8 +659,14 @@ function AppPasswordsSetting() {
<button <button
className="shrink-0 text-xs text-red-500 disabled:opacity-50" className="shrink-0 text-xs text-red-500 disabled:opacity-50"
disabled={revoke.isPending} disabled={revoke.isPending}
onClick={() => { onClick={async () => {
if (confirm(t("appPasswords.confirmRevoke", { name: a.name }))) revoke.mutate(a.id); const ok = await confirm({
title: t("settings.revoke"),
message: t("appPasswords.confirmRevoke", { name: a.name }),
confirmLabel: t("settings.revoke"),
danger: true,
});
if (ok) revoke.mutate(a.id);
}} }}
> >
{t("settings.revoke")} {t("settings.revoke")}
@@ -865,6 +887,7 @@ function SecuritySection() {
function AccountsSection() { function AccountsSection() {
const t = useT(); const t = useT();
const [confirm, confirmDialog] = useConfirm();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
@@ -893,6 +916,7 @@ function AccountsSection() {
return ( return (
<section className="max-w-5xl space-y-3"> <section className="max-w-5xl space-y-3">
{confirmDialog}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2> <h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
<button <button
@@ -971,8 +995,15 @@ function AccountsSection() {
{a.kind !== "native" && ( {a.kind !== "native" && (
<button <button
className="text-xs text-red-500" className="text-xs text-red-500"
onClick={() => { onClick={async () => {
if (confirm(t("settings.confirmRemoveAccount"))) { const ok = await confirm({
title: t("settings.removeAccountTitle"),
message: t("settings.confirmRemoveAccount"),
detail: a.email,
confirmLabel: t("settings.removeAccountAction"),
danger: true,
});
if (ok) {
setDeletingId(a.id); setDeletingId(a.id);
remove.mutate(a.id); remove.mutate(a.id);
} }
@@ -1320,6 +1351,61 @@ function SpellcheckSetting() {
); );
} }
function CompletionSetting() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
});
return (
<div className="space-y-1 border-b border-app-border py-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.completion")}</span>
<SettingsToggle
checked={settings.data?.completion_enabled ?? true}
disabled={!settings.data || patch.isPending}
onChange={(v) => patch.mutate({ completion_enabled: v })}
/>
</div>
<p className="text-xs text-app-faint">{t("settings.completionHint")}</p>
</div>
);
}
function LanguageSetting() {
const t = useT();
const { locale, setLocale } = useLocale();
const queryClient = useQueryClient();
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
});
return (
<div className="space-y-1 border-b border-app-border py-2 text-sm">
<label className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.language")}</span>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1"
value={locale}
onChange={(e) => {
setLocale(e.target.value); // at once; saved with the account behind it
patch.mutate({ ui_language: e.target.value });
}}
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.name}
</option>
))}
</select>
</label>
<p className="text-xs text-app-faint">{t("settings.languageHint")}</p>
</div>
);
}
function AppearanceSection() { function AppearanceSection() {
const t = useT(); const t = useT();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1333,6 +1419,7 @@ function AppearanceSection() {
return ( return (
<section className="max-w-4xl space-y-3"> <section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2> <h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
<LanguageSetting />
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm"> <div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.theme")}</span> <span className="text-app-muted">{t("settings.theme")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5"> <div className="flex gap-1 rounded-lg border border-app-border p-0.5">
@@ -1359,6 +1446,7 @@ function AppearanceSection() {
</div> </div>
</div> </div>
<SpellcheckSetting /> <SpellcheckSetting />
<CompletionSetting />
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm"> <label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.threadOrder")}</span> <span className="text-app-muted">{t("settings.threadOrder")}</span>
<select <select
@@ -1390,6 +1478,67 @@ function AppearanceSection() {
); );
} }
function PrivacySection() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: (data) => {
queryClient.setQueryData(["settings"], data);
// Open messages render afresh, with or without their trackers.
void queryClient.invalidateQueries({ queryKey: ["message-html"] });
void queryClient.invalidateQueries({ queryKey: ["thread"] });
},
});
return (
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.privacy")}</h2>
<div className="space-y-1 border-b border-app-border py-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-app-fg">{t("settings.blockTrackers")}</span>
<SettingsToggle
checked={settings.data?.block_trackers ?? true}
disabled={!settings.data || patch.isPending}
onChange={(v) => patch.mutate({ block_trackers: v })}
/>
</div>
<p className="max-w-2xl text-app-muted">{t("settings.blockTrackersHint")}</p>
</div>
</section>
);
}
function ShortcutsSection() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
});
const on = settings.data?.shortcuts_enabled ?? true;
return (
<section className="max-w-4xl space-y-4">
<h2 className="text-lg font-semibold">{t("settings.shortcuts")}</h2>
<div className="flex items-center justify-between gap-3 border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.shortcutsEnabled")}</span>
<SettingsToggle
checked={on}
disabled={!settings.data || patch.isPending}
onChange={(v) => patch.mutate({ shortcuts_enabled: v })}
/>
</div>
<p className="text-sm text-app-muted">{t("settings.shortcutsHint")}</p>
<div className={on ? "" : "opacity-50"}>
<ShortcutList />
</div>
</section>
);
}
const DEFAULT_JITSI = "https://meet.jit.si/"; const DEFAULT_JITSI = "https://meet.jit.si/";
function IntegrationsSection() { function IntegrationsSection() {
@@ -1423,14 +1572,14 @@ function IntegrationsSection() {
} }
const CAL_COLORS: [string, string][] = [ const CAL_COLORS: [string, string][] = [
["Blue", "#3b82f6"], ["color.blue", "#3b82f6"],
["Green", "#22c55e"], ["color.green", "#22c55e"],
["Red", "#ef4444"], ["color.red", "#ef4444"],
["Orange", "#f97316"], ["color.orange", "#f97316"],
["Purple", "#a855f7"], ["color.purple", "#a855f7"],
["Teal", "#14b8a6"], ["color.teal", "#14b8a6"],
["Pink", "#ec4899"], ["color.pink", "#ec4899"],
["Gray", "#6b7280"], ["color.gray", "#6b7280"],
]; ];
function BellIcon({ className }: { className?: string }) { function BellIcon({ className }: { className?: string }) {
@@ -1456,14 +1605,14 @@ function ColorSelect({ value, onChange }: { value: string; onChange: (hex: strin
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value) const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value)
? CAL_COLORS ? CAL_COLORS
: [["Custom", value], ...CAL_COLORS]; : [[translate("color.custom"), value], ...CAL_COLORS];
return ( return (
<div className="relative inline-block"> <div className="relative inline-block">
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2.5 py-2" className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2.5 py-2"
aria-label="Select colour" aria-label={translate("settings.selectColor")}
> >
<span className="inline-block h-5 w-5 rounded-full" style={{ background: value }} /> <span className="inline-block h-5 w-5 rounded-full" style={{ background: value }} />
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 text-app-muted"> <svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 text-app-muted">
@@ -1482,7 +1631,7 @@ function ColorSelect({ value, onChange }: { value: string; onChange: (hex: strin
<button <button
key={hex} key={hex}
type="button" type="button"
title={name} title={name.startsWith("color.") ? translate(name as MessageKey) : name}
onClick={() => { onClick={() => {
onChange(hex); onChange(hex);
setOpen(false); setOpen(false);
@@ -1619,10 +1768,10 @@ const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => {
const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]; const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880];
function reminderText(m: number): string { function reminderText(m: number): string {
if (m === 0) return "At time of event"; if (m === 0) return translate("reminder.atTime");
if (m % 1440 === 0) return `${m / 1440} day${m / 1440 > 1 ? "s" : ""} before`; if (m % 1440 === 0) return translate("reminder.daysBefore", { count: m / 1440 });
if (m % 60 === 0) return `${m / 60} hour${m / 60 > 1 ? "s" : ""} before`; if (m % 60 === 0) return translate("reminder.hoursBefore", { count: m / 60 });
return `${m} minutes before`; return translate("reminder.minutesBefore", { count: m });
} }
function CalendarsSection() { function CalendarsSection() {
+24
View File
@@ -54,4 +54,28 @@ describe("antispam Actions-menu item", () => {
expect(await render(folder, null)).toBe(""); expect(await render(folder, null)).toBe("");
} }
}); });
it("loads again after a lesson, so a reopened menu is up to date", async () => {
let learned: string | null = null;
const ctx = {
api: {
pluginFetch: async (path: string) => {
if (path === "/learn") {
learned = "spam";
return {};
}
return { message_id: "m1", special_use: "inbox", verdict: learned ? { learned } : null };
},
},
};
const el = document.createElement("div");
const unmount = mount(el, ctx, { messageId: "m1" });
await new Promise((r) => setTimeout(r, 0));
expect(el.textContent).toBe("Mark as Spam");
el.querySelector("button")!.click();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
expect(el.textContent).toBe("Marked as spam");
unmount();
});
}); });
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "vitest";
import { caretContext, ghostEl, showGhost, typedIntoGhost } from "../components/compose/completion";
function editor(html: string): HTMLElement {
const el = document.createElement("div");
el.contentEditable = "true";
el.innerHTML = html;
document.body.appendChild(el);
return el;
}
function caretAt(node: Node, offset: number) {
const r = document.createRange();
r.setStart(node, offset);
r.collapse(true);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(r);
}
afterEach(() => {
document.body.innerHTML = "";
});
describe("caretContext", () => {
it("gives the current line up to the caret at a line's end", () => {
const el = editor("<div>First line</div><div>I look forward </div>");
const text = el.lastChild!.firstChild!;
caretAt(text, (text as Text).length);
expect(caretContext(el)?.before).toBe("I look forward ");
});
it("offers nothing in the middle of a line, or in the signature or a quote", () => {
const el = editor('<div>I look forward to it</div><div class="wmc-signature">Best regards</div>');
caretAt(el.firstChild!.firstChild!, 6);
expect(caretContext(el)).toBeNull();
const sig = el.querySelector(".wmc-signature")!.firstChild!;
caretAt(sig, (sig as Text).length);
expect(caretContext(el)).toBeNull();
const quoted = editor("<blockquote>Quoted text</blockquote>");
caretAt(quoted.firstChild!.firstChild!, 11);
expect(caretContext(quoted)).toBeNull();
});
});
describe("the ghost", () => {
it("sits after the caret, and typing its letters shortens it", () => {
const el = editor("<div>I look forward to hea</div>");
const text = el.firstChild!.firstChild!;
caretAt(text, (text as Text).length);
showGhost(caretContext(el)!.range, "ring from you.", "Tab");
expect(ghostEl(el)?.textContent).toBe("ring from you.");
expect(ghostEl(el)?.getAttribute("contenteditable")).toBe("false");
const typeR = new InputEvent("beforeinput", { inputType: "insertText", data: "r" });
expect(typedIntoGhost(el, typeR)).toBe(true);
expect(ghostEl(el)?.textContent).toBe("ing from you.");
const typeX = new InputEvent("beforeinput", { inputType: "insertText", data: "x" });
expect(typedIntoGhost(el, typeX)).toBe(false);
expect(ghostEl(el)).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearSnapshots,
restoredWindows,
saveSnapshot,
saveWindowList,
} from "../components/compose/restore";
import type { ComposeWin } from "../components/compose/windows";
const win = (id: string, minimized = false): ComposeWin => ({
id,
init: { accountId: "a", kind: "new" },
minimized,
expanded: false,
activated: Number(id.slice(1)),
});
afterEach(() => sessionStorage.clear());
describe("compose windows outlive leaving the mail view", () => {
it("brings back listed windows that left their content, as they were", () => {
saveWindowList([win("c1"), win("c2", true), win("c3")]);
saveSnapshot("c1", { accountId: "a", kind: "new", subject: "Quote", html: "<div>Hi</div>" });
saveSnapshot("c2", { accountId: "a", kind: "reply", draftId: "d2" });
// c3: sent or discarded, so no content kept — it does not come back.
const back = restoredWindows();
expect(back.map((w) => [w.id, w.minimized, w.init.subject ?? w.init.draftId])).toEqual([
["c1", false, "Quote"],
["c2", true, "d2"],
]);
});
it("reads the same twice, ignores windows closed meanwhile, and clears", () => {
saveWindowList([win("c1")]);
saveSnapshot("c1", { accountId: "a", kind: "new" });
saveSnapshot("c9", { accountId: "a", kind: "new" }); // closed: not in the list
expect(restoredWindows()).toHaveLength(1);
expect(restoredWindows()).toHaveLength(1); // StrictMode's second call
clearSnapshots();
expect(restoredWindows()).toHaveLength(0);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { I18nProvider, LANGUAGES, detectLanguage, useLocale, useT } from "../i18n";
import { en } from "../i18n/en";
const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
const PLURAL = /_(zero|one|two|few|many|other)$/;
describe("language files", () => {
for (const { code, catalog } of LANGUAGES.filter((l) => l.code !== "en")) {
it(`${code}: only English's keys (plus plural forms), same placeholders`, () => {
const english = en as Record<string, string>;
for (const [key, text] of Object.entries(catalog as Record<string, string>)) {
const base = english[key] !== undefined ? key : key.replace(PLURAL, "");
expect(english[base], `${code}: unknown key ${key}`).toBeDefined();
// A plural form may spell out its number ("1 wiadomość"): no {count} needed.
const want = placeholders(english[base]).filter((p) => !(PLURAL.test(key) && p === "count"));
const got = placeholders(text).filter((p) => !(PLURAL.test(key) && p === "count"));
expect(got, `${code}: placeholders of ${key}`).toEqual(want);
}
});
}
});
function Show({ k, count }: { k: Parameters<ReturnType<typeof useT>>[0]; count?: number }) {
const t = useT();
return <span>{t(k, count === undefined ? undefined : { count })}</span>;
}
function SwitchTo({ code }: { code: string }) {
const { setLocale } = useLocale();
return <button onClick={() => setLocale(code)}>go</button>;
}
afterEach(() => localStorage.clear());
describe("t()", () => {
it("uses each language's plural forms", () => {
localStorage.setItem("locale", "pl");
const forms = [1, 2, 4, 5, 12, 22, 25].map((n) => {
const { unmount } = render(
<I18nProvider>
<Show k="mail.conversationsSelected" count={n} />
</I18nProvider>,
);
const text = screen.getByText(/Zaznaczono/).textContent;
unmount();
return text;
});
expect(forms).toEqual([
"Zaznaczono 1 wątek",
"Zaznaczono 2 wątki",
"Zaznaczono 4 wątki",
"Zaznaczono 5 wątków",
"Zaznaczono 12 wątków",
"Zaznaczono 22 wątki",
"Zaznaczono 25 wątków",
]);
});
it("falls back to English for a string a language lacks", async () => {
const pl = LANGUAGES.find((l) => l.code === "pl")!.catalog as Record<string, string>;
const saved = pl["mail.print"];
delete pl["mail.print"];
localStorage.setItem("locale", "pl");
render(
<I18nProvider>
<Show k="mail.print" />
<SwitchTo code="pl" />
</I18nProvider>,
);
expect(screen.getByText("Print")).toBeTruthy();
pl["mail.print"] = saved;
});
});
describe("detectLanguage", () => {
it("takes the first preferred language we have, else English", () => {
const spy = vi.spyOn(navigator, "languages", "get");
spy.mockReturnValue(["de-DE", "pl-PL", "en"]);
expect(detectLanguage()).toBe("pl");
spy.mockReturnValue(["fr-FR", "es"]);
expect(detectLanguage()).toBe("en");
spy.mockRestore();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { snoozePresets } from "../components/mail/SnoozeMenu";
// 2026-10-18 is a Sunday.
const day = (date: number, hour = 10) => new Date(2026, 9, date, hour, 0, 0);
const labels = (now: Date) => snoozePresets(now).map((p) => p.label);
const when = (now: Date, label: string) =>
snoozePresets(now).find((p) => p.label === label)?.when.toDateString();
describe("snoozePresets", () => {
it("never offers the same time twice", () => {
for (let d = 18; d <= 24; d++) {
const times = snoozePresets(day(d)).map((p) => p.when.getTime());
expect(new Set(times).size).toBe(times.length);
}
});
it("next week is the coming Monday, or the one after on a Sunday", () => {
expect(when(day(18), "snooze.nextWeek")).toBe(day(26).toDateString()); // Sun
expect(when(day(19), "snooze.nextWeek")).toBe(day(26).toDateString()); // Mon
expect(when(day(23), "snooze.nextWeek")).toBe(day(26).toDateString()); // Fri
});
it("offers the weekend from Monday to Thursday only", () => {
expect(labels(day(20))).toContain("snooze.weekend"); // Tue
expect(when(day(20), "snooze.weekend")).toBe(day(24).toDateString());
expect(labels(day(23))).not.toContain("snooze.weekend"); // Fri
expect(labels(day(18))).not.toContain("snooze.weekend"); // Sun
});
it("drops 'later today' late in the evening", () => {
expect(labels(day(20, 10))).toContain("snooze.laterToday");
expect(labels(day(20, 20))).not.toContain("snooze.laterToday");
});
});
+115
View File
@@ -0,0 +1,115 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useBulkSubscriptionActions } from "../components/list/SubscriptionsList";
import { I18nProvider } from "../i18n";
import { api } from "../lib/api";
import type { Subscription } from "../lib/types";
function sub(key: string, extra: Partial<Subscription> = {}): Subscription {
return {
key,
name: key,
email: `${key}@x.example`,
list_id: null,
total: 1,
unread: 0,
last_30_days: 1,
per_month: 1,
first_at: "2026-01-01T00:00:00Z",
last_at: "2026-10-01T00:00:00Z",
last_subject: "s",
one_click: true,
has_mailto: false,
has_web: true,
unsubscribed_at: null,
method: null,
still_sending: false,
...extra,
};
}
type Bulk = ReturnType<typeof useBulkSubscriptionActions>;
// Renders the hook's confirmation dialog, as MailLayout does, and hands out the hook.
function setup() {
const onDone = vi.fn();
const qc = new QueryClient();
const current: { bulk?: Bulk } = {};
function Harness() {
const bulk = useBulkSubscriptionActions(onDone);
current.bulk = bulk;
return <>{bulk.dialog}</>;
}
render(
<I18nProvider>
<QueryClientProvider client={qc}>
<Harness />
</QueryClientProvider>
</I18nProvider>,
);
return { bulk: () => current.bulk as Bulk, onDone };
}
/** Start an action, then answer its dialog. */
async function answer(start: () => Promise<void>, button: string | null) {
let done!: Promise<void>;
act(() => {
done = start();
});
if (button === null) return done; // nothing to ask
const btn = await screen.findByRole("button", { name: button });
await act(async () => {
fireEvent.click(btn);
await done;
});
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("bulk unsubscribe", () => {
it("skips senders already left and collects the web-only ones", async () => {
const calls: string[] = [];
vi.spyOn(api, "unsubscribe").mockImplementation(async (key: string) => {
calls.push(key);
if (key === "web") return { status: "open", method: "web", url: "https://x.example/u" };
if (key === "bad") throw new Error("boom");
return { status: "done", method: "one_click", url: null };
});
const { bulk, onDone } = setup();
await answer(
() =>
bulk().unsubscribeMany([
sub("a"),
sub("web", { one_click: false }),
sub("gone", { unsubscribed_at: "2026-10-01T00:00:00Z" }),
sub("bad"),
]),
"Unsubscribe",
);
expect(calls.sort()).toEqual(["a", "bad", "web"]);
await waitFor(() => expect(bulk().finishOnPage).toEqual([{ name: "web", url: "https://x.example/u" }]));
expect(onDone).toHaveBeenCalled();
});
it("does nothing when the dialog is cancelled", async () => {
const spy = vi.spyOn(api, "unsubscribe");
const { bulk, onDone } = setup();
await answer(() => bulk().unsubscribeMany([sub("a")]), "Cancel");
expect(spy).not.toHaveBeenCalled();
expect(onDone).not.toHaveBeenCalled();
});
});
describe("bulk delete", () => {
it("moves every selected sender's mail", async () => {
const spy = vi.spyOn(api, "trashSubscription").mockResolvedValue({ moved: 2 });
const { bulk, onDone } = setup();
await answer(() => bulk().trashMany([sub("a"), sub("b"), sub("c")]), "Move to Trash");
expect(spy.mock.calls.map((c) => c[0]).sort()).toEqual(["a", "b", "c"]);
expect(onDone).toHaveBeenCalled();
});
});
@@ -15,15 +15,22 @@ export default function mount(el, ctx, props) {
const messageId = props && props.messageId; const messageId = props && props.messageId;
if (!messageId) return; if (!messageId) return;
let disposed = false; let disposed = false;
let generation = 0;
(async () => { // The host mounts this with the message (hidden in a closed menu), not when
// the menu opens, so the item is ready by then. It loads again after a
// lesson, so a reopened menu shows the new state without a wait.
const load = async () => {
const mine = ++generation;
let data = null; let data = null;
try { try {
data = await ctx.api.pluginFetch(`/context/${messageId}`); data = await ctx.api.pluginFetch(`/context/${messageId}`);
} catch { } catch {
return; // no antispam service, no menu item return; // no antispam service, no menu item
} }
if (disposed || !data) return; if (disposed || mine !== generation) return;
el.innerHTML = "";
if (!data) return;
// The folder decides which action is on offer — FIRST. A message sitting in // The folder decides which action is on offer — FIRST. A message sitting in
// the spam folder offers "not spam" no matter what it was taught, because // the spam folder offers "not spam" no matter what it was taught, because
@@ -52,10 +59,15 @@ export default function mount(el, ctx, props) {
/* the menu is closing regardless; nothing useful to show here */ /* the menu is closing regardless; nothing useful to show here */
} }
}); });
})(); };
const onLearned = () => void load();
window.addEventListener("webmail:antispam-learned", onLearned);
void load();
return () => { return () => {
disposed = true; disposed = true;
window.removeEventListener("webmail:antispam-learned", onLearned);
el.innerHTML = ""; el.innerHTML = "";
}; };
} }