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:
@@ -1,5 +1,5 @@
|
||||
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)
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
|
||||
|
||||
@@ -15,6 +15,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.actions import router as actions_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.avatar import router as avatar_router
|
||||
from app.api.completion import router as completion_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.invitations import router as invitations_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
@@ -25,8 +26,12 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.saved_searches import router as saved_searches_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.snooze import router as snooze_router
|
||||
from app.api.spellcheck import router as spellcheck_router
|
||||
from app.api.subscriptions import router as subscriptions_router
|
||||
from app.api.templates import router as templates_router
|
||||
from app.api.threads import router as threads_router
|
||||
from app.api.translation import router as translation_router
|
||||
from app.identity.routes import router as auth_router
|
||||
from app.ws import router as ws_router
|
||||
|
||||
@@ -48,6 +53,11 @@ def register_routers(app: FastAPI) -> None:
|
||||
spellcheck_router,
|
||||
push_router,
|
||||
invitations_router,
|
||||
templates_router,
|
||||
snooze_router,
|
||||
subscriptions_router,
|
||||
completion_router,
|
||||
translation_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Sentence completion for the compose window (app/completion.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import completion
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import CompletionIndexState
|
||||
from app.queue import enqueue_job
|
||||
|
||||
router = APIRouter(prefix="/completion", tags=["compose"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class CompletionIn(BaseModel):
|
||||
# The text before the cursor (its last line is all that matters). A POST,
|
||||
# not a query string: what the user is typing stays out of access logs.
|
||||
before: str = Field(max_length=2000)
|
||||
|
||||
|
||||
class CompletionOut(BaseModel):
|
||||
suggestion: str | None
|
||||
|
||||
|
||||
@router.post("", response_model=CompletionOut)
|
||||
async def suggest(body: CompletionIn, user: CurrentUser, db: DbSession) -> CompletionOut:
|
||||
before = body.before[-300:]
|
||||
built = await db.scalar(
|
||||
select(CompletionIndexState.built_at).where(CompletionIndexState.user_id == user.id)
|
||||
)
|
||||
if built is None or built < dt.datetime.now(dt.UTC) - completion.REBUILD_AFTER:
|
||||
# In the background; until it is built, the starter phrases still answer.
|
||||
await enqueue_job("build_index_job", str(user.id), _job_id=f"complete:{user.id}")
|
||||
personal = await completion.personal_phrases(db, user.id, completion.keys_for(before))
|
||||
return CompletionOut(suggestion=completion.complete(personal, before))
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Draft, Mailbox, Message
|
||||
from app.models import Draft, Mailbox, Message, Thread
|
||||
from app.outbox.service import not_being_sent
|
||||
|
||||
router = APIRouter(prefix="/mailboxes", tags=["mail"])
|
||||
@@ -30,6 +30,7 @@ class MailboxOut(BaseModel):
|
||||
class FolderCounts(BaseModel):
|
||||
inbox_unread: int # unread messages sitting in Inbox mailboxes
|
||||
drafts: int # drafts shown in the Drafts folder (local + un-adopted remote)
|
||||
snoozed: int = 0 # conversations snoozed out of the Inbox
|
||||
|
||||
|
||||
@router.get("", response_model=list[MailboxOut])
|
||||
@@ -54,8 +55,17 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
|
||||
Message.user_id == user.id,
|
||||
Mailbox.special_use == "inbox",
|
||||
~Message.flags.contains(["\\Seen"]),
|
||||
# A snoozed conversation is out of the Inbox until it wakes.
|
||||
~Message.thread_id.in_(
|
||||
select(Thread.id).where(Thread.user_id == user.id, Thread.snoozed_until.isnot(None))
|
||||
),
|
||||
)
|
||||
)
|
||||
snoozed = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Thread)
|
||||
.where(Thread.user_id == user.id, Thread.snoozed_until.isnot(None))
|
||||
)
|
||||
|
||||
# Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror
|
||||
# UIDs excluded), matching what list_drafts + list_remote_drafts return.
|
||||
@@ -77,4 +87,8 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
|
||||
)
|
||||
remote = sum(1 for r in remote_rows if (r.account_id, r.uid) not in owned)
|
||||
|
||||
return FolderCounts(inbox_unread=int(inbox_unread or 0), drafts=int(local or 0) + remote)
|
||||
return FolderCounts(
|
||||
inbox_unread=int(inbox_unread or 0),
|
||||
drafts=int(local or 0) + remote,
|
||||
snoozed=int(snoozed or 0),
|
||||
)
|
||||
|
||||
+176
-36
@@ -6,6 +6,7 @@ import asyncio
|
||||
import contextlib
|
||||
import html as html_lib
|
||||
import io
|
||||
import logging
|
||||
import uuid
|
||||
import zipfile
|
||||
from typing import Annotated
|
||||
@@ -14,11 +15,18 @@ from urllib.parse import quote
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import translation
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import AttachmentOut
|
||||
from app.api.settings import add_allowed_image_sender, allowed_image_senders
|
||||
from app.api.settings import (
|
||||
add_allowed_image_sender,
|
||||
allowed_image_senders,
|
||||
blocks_trackers,
|
||||
)
|
||||
from app.completion import html_to_text
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.indexer import attachments as att_store
|
||||
@@ -26,7 +34,18 @@ from app.indexer import rawstore, thumbs
|
||||
from app.indexer.mime import parse_message
|
||||
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
|
||||
from app.indexer.thumbs import thumb_type
|
||||
from app.models import Attachment, BodyPart, ComposeAttachment, Message
|
||||
from app.models import (
|
||||
Account,
|
||||
Attachment,
|
||||
BodyPart,
|
||||
ComposeAttachment,
|
||||
Message,
|
||||
MessageTranslation,
|
||||
User,
|
||||
)
|
||||
from app.translation_prep import find_names_in_signature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["mail"])
|
||||
|
||||
@@ -101,8 +120,16 @@ _MESSAGE_CSP = (
|
||||
)
|
||||
|
||||
|
||||
def _html(document: str) -> HTMLResponse:
|
||||
return HTMLResponse(content=document, headers={"Content-Security-Policy": _MESSAGE_CSP})
|
||||
def _html(document: str, trackers: int = 0, extra: dict[str, str] | None = None) -> HTMLResponse:
|
||||
# The page reads what it needs from headers: the body is the message.
|
||||
return HTMLResponse(
|
||||
content=document,
|
||||
headers={
|
||||
"Content-Security-Policy": _MESSAGE_CSP,
|
||||
"X-Trackers-Blocked": str(trackers),
|
||||
**(extra or {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message:
|
||||
@@ -118,54 +145,167 @@ def _sender_email(message: Message) -> str:
|
||||
return message.from_addr[0].get("email", "").lower() if message.from_addr else ""
|
||||
|
||||
|
||||
async def _render_fragment(
|
||||
db: AsyncSession, message: Message, allow_remote: bool, block: bool
|
||||
) -> tuple[str, int]:
|
||||
"""The sanitized body the user sees, and how many trackers came out of it.
|
||||
|
||||
Re-sanitizes from the raw blob when remote images are allowed, the stored
|
||||
copy predates the current sanitizer (SPEC §10.4), or trackers are to stay
|
||||
in; otherwise the stored copy is served as is.
|
||||
"""
|
||||
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message.id)))
|
||||
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
|
||||
|
||||
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
|
||||
# The stored copy has its trackers taken out; with blocking switched off, a
|
||||
# message that had some is rendered afresh with them left in.
|
||||
trackers_kept = not block and html_part is not None and html_part.trackers_blocked > 0
|
||||
if html_part is not None and not allow_remote and not stale and not trackers_kept:
|
||||
return html_part.content, html_part.trackers_blocked if block else 0
|
||||
|
||||
raw = None
|
||||
if html_part is not None or allow_remote: # a plain-text message has nothing to redo
|
||||
try:
|
||||
raw = await rawstore.ensure_bytes(message)
|
||||
except FileNotFoundError:
|
||||
raw = None
|
||||
parsed = parse_message(raw) if raw is not None else None
|
||||
if parsed is not None and parsed.html_body is not None:
|
||||
if stale and html_part is not None:
|
||||
# The stored copy is always the plain one: no remote images, trackers out.
|
||||
upgraded = sanitize_html(parsed.html_body, message.id)
|
||||
html_part.content = upgraded.html
|
||||
html_part.sanitizer_v = upgraded.sanitizer_v
|
||||
html_part.trackers_blocked = upgraded.trackers_blocked
|
||||
await db.commit()
|
||||
if block and not allow_remote:
|
||||
return upgraded.html, upgraded.trackers_blocked
|
||||
result = sanitize_html(
|
||||
parsed.html_body, message.id, allow_remote_images=allow_remote, block_trackers=block
|
||||
)
|
||||
return result.html, result.trackers_blocked
|
||||
|
||||
if html_part is not None:
|
||||
return html_part.content, html_part.trackers_blocked
|
||||
text_part = next((p for p in parts if p.kind == "text"), None)
|
||||
body = html_lib.escape(text_part.content) if text_part else ""
|
||||
return f'<div class="wm-plaintext">{body}</div>', 0
|
||||
|
||||
|
||||
async def _content_language(db: AsyncSession, message: Message) -> str:
|
||||
"""The message's language, found (and kept) the first time it is shown."""
|
||||
if message.content_language is not None or not translation.enabled():
|
||||
return message.content_language or ""
|
||||
text = await db.scalar(
|
||||
select(BodyPart.content).where(BodyPart.message_id == message.id, BodyPart.kind == "text")
|
||||
)
|
||||
try:
|
||||
found = await translation.detect(text or message.snippet or "")
|
||||
except translation.TranslationError as exc:
|
||||
logger.info("language detection unavailable: %s", exc)
|
||||
return "" # not kept: tried again once the service is back
|
||||
message.content_language = found
|
||||
await db.commit()
|
||||
return found
|
||||
|
||||
|
||||
@router.get("/{message_id}/html", response_class=HTMLResponse)
|
||||
async def message_html(
|
||||
message_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
images: bool = Query(False),
|
||||
translate: str | None = Query(None, pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$"),
|
||||
) -> HTMLResponse:
|
||||
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8).
|
||||
|
||||
Re-sanitizes from the raw blob when the sender is image-approved, or when the
|
||||
stored copy predates the current sanitizer version (SPEC §10.4). ``images=1``
|
||||
is a one-time "Load images" — render remote images for this request only,
|
||||
without persisting the sender as trusted.
|
||||
``images=1`` is a one-time "Load images" — render remote images for this
|
||||
request only, without persisting the sender as trusted. ``translate=pl``
|
||||
returns it translated (app/translation.py). Headers tell the page what it
|
||||
got: X-Trackers-Blocked, X-Content-Language (the message's own language,
|
||||
when known) and, translated, X-Translated-From / X-Translated-To.
|
||||
"""
|
||||
message = await _owned_message(db, user.id, message_id)
|
||||
allow_remote = images or _sender_email(message) in allowed_image_senders(user)
|
||||
block = blocks_trackers(user)
|
||||
fragment, trackers = await _render_fragment(db, message, allow_remote, block)
|
||||
|
||||
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id)))
|
||||
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
|
||||
headers: dict[str, str] = {}
|
||||
language = await _content_language(db, message)
|
||||
if language:
|
||||
headers["X-Content-Language"] = language
|
||||
if translate:
|
||||
fragment, source = await _translated(
|
||||
db, user, message, fragment, language, translate, allow_remote, block
|
||||
)
|
||||
headers["X-Translated-From"] = source
|
||||
headers["X-Translated-To"] = translate
|
||||
return _html(_wrap(fragment), trackers, headers)
|
||||
|
||||
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
|
||||
if html_part is not None and not allow_remote and not stale:
|
||||
return _html(_wrap(html_part.content))
|
||||
|
||||
# Re-sanitize the original HTML (image-approved sender, or a stale stored copy).
|
||||
# If the raw blob is unavailable, fall back to whatever is already stored.
|
||||
if allow_remote or stale:
|
||||
try:
|
||||
raw = await rawstore.ensure_bytes(message)
|
||||
except FileNotFoundError:
|
||||
raw = None
|
||||
if raw is not None:
|
||||
parsed = parse_message(raw)
|
||||
if parsed.html_body is not None:
|
||||
result = sanitize_html(
|
||||
parsed.html_body, message.id, allow_remote_images=allow_remote
|
||||
)
|
||||
if html_part is not None and not allow_remote:
|
||||
html_part.content = result.html # persist the upgraded sanitization
|
||||
html_part.sanitizer_v = result.sanitizer_v
|
||||
await db.commit()
|
||||
return _html(_wrap(result.html))
|
||||
async def _names(db: AsyncSession, user: User, message: Message) -> list[str]:
|
||||
"""Names a translation leaves as they are if the model would change them:
|
||||
the people on the message (and the sender's single names, as signed:
|
||||
"Milan"), the user's own names (accounts, signatures)."""
|
||||
names: list[str] = []
|
||||
for people in (message.from_addr, message.to_addr, message.cc_addr):
|
||||
names += [p.get("name", "") for p in people if p.get("name")]
|
||||
for p in message.from_addr:
|
||||
names += [w for w in str(p.get("name", "")).split() if len(w) >= 3 and w[0].isupper()]
|
||||
rows = await db.execute(
|
||||
select(Account.display_name, Account.signature).where(Account.user_id == user.id)
|
||||
)
|
||||
for display_name, signature in rows:
|
||||
if display_name:
|
||||
names.append(display_name)
|
||||
if signature:
|
||||
names += find_names_in_signature(html_to_text(signature))
|
||||
return names
|
||||
|
||||
if html_part is not None:
|
||||
return _html(_wrap(html_part.content))
|
||||
text_part = next((p for p in parts if p.kind == "text"), None)
|
||||
body = html_lib.escape(text_part.content) if text_part else ""
|
||||
return _html(_wrap(f'<div class="wm-plaintext">{body}</div>'))
|
||||
|
||||
async def _translated(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
message: Message,
|
||||
fragment: str,
|
||||
language: str,
|
||||
target: str,
|
||||
allow_remote: bool,
|
||||
block: bool,
|
||||
) -> tuple[str, str]:
|
||||
"""The fragment in ``target`` — from the cache, or translated and kept."""
|
||||
version = SANITIZER_VERSION * 1000 + translation.PIPELINE_VERSION
|
||||
cached = await db.get(MessageTranslation, (message.id, target, allow_remote))
|
||||
if cached is not None and cached.sanitizer_v == version:
|
||||
return cached.html, cached.source
|
||||
try:
|
||||
out = await translation.translate_html(
|
||||
fragment, language or "auto", target, names=await _names(db, user, message)
|
||||
)
|
||||
except translation.TranslationError as exc:
|
||||
raise_api_error(503, "translation_failed", str(exc))
|
||||
# The translation comes back as HTML: cleaned like any HTML we show.
|
||||
clean = sanitize_html(
|
||||
out, message.id, allow_remote_images=allow_remote, block_trackers=block
|
||||
).html
|
||||
source = language or "auto"
|
||||
stmt = insert(MessageTranslation).values(
|
||||
message_id=message.id,
|
||||
target=target,
|
||||
images=allow_remote,
|
||||
source=source,
|
||||
html=clean,
|
||||
sanitizer_v=version,
|
||||
)
|
||||
await db.execute(
|
||||
stmt.on_conflict_do_update(
|
||||
index_elements=["message_id", "target", "images"],
|
||||
set_={"html": clean, "source": source, "sanitizer_v": version},
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return clean, source
|
||||
|
||||
|
||||
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
|
||||
|
||||
@@ -27,6 +27,8 @@ class ThreadSummary(BaseModel):
|
||||
has_attachments: bool
|
||||
pinned: bool
|
||||
last_message_at: dt.datetime
|
||||
# Set while the thread is snoozed: when it comes back to the Inbox.
|
||||
snoozed_until: dt.datetime | None = None
|
||||
|
||||
|
||||
class ThreadListOut(BaseModel):
|
||||
@@ -64,6 +66,13 @@ class MessageEnvelope(BaseModel):
|
||||
snippet: str
|
||||
# Other accounts this same delivery also landed in (collapsed duplicates).
|
||||
also_in_account_ids: list[uuid.UUID] = []
|
||||
# Bulk mail: the Subscriptions group it belongs to, how it can be left
|
||||
# (one_click | mailto | web), and whether the user already did.
|
||||
subscription_key: str | None = None
|
||||
unsubscribe_via: str | None = None
|
||||
unsubscribed: bool = False
|
||||
# Its language, once found (the first time it is shown): "" unknown.
|
||||
content_language: str = ""
|
||||
|
||||
|
||||
class ThreadDetail(BaseModel):
|
||||
@@ -72,6 +81,7 @@ class ThreadDetail(BaseModel):
|
||||
order: str # "asc" | "desc"
|
||||
pinned: bool
|
||||
messages: list[MessageEnvelope] # always ASC (SPEC §9.4)
|
||||
snoozed_until: dt.datetime | None = None
|
||||
|
||||
|
||||
class FlagChangeRequest(BaseModel):
|
||||
|
||||
@@ -27,6 +27,7 @@ _ALLOWED_INVITE_FROM = {"contacts", "anyone"}
|
||||
|
||||
# "auto", or a LanguageTool language code such as "pl-PL", "en-GB", "de".
|
||||
_LANG_CODE = re.compile(r"auto|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*")
|
||||
_UI_LANGUAGE = re.compile(r"[a-z]{2,3}(-[A-Za-z]{2,4})?")
|
||||
|
||||
|
||||
class ReminderPref(BaseModel):
|
||||
@@ -52,6 +53,16 @@ class SettingsOut(BaseModel):
|
||||
# ("auto" detects it per message).
|
||||
spellcheck_enabled: bool = True
|
||||
spellcheck_language: str = "auto"
|
||||
# Gmail-style keyboard shortcuts in the mail view (Settings → Keyboard shortcuts).
|
||||
shortcuts_enabled: bool = True
|
||||
# Remove tracking pixels from mail (Settings → Privacy).
|
||||
block_trackers: bool = True
|
||||
# Grey sentence endings in the compose window, taken with Tab.
|
||||
completion_enabled: bool = True
|
||||
# Interface language ("en", "pl", …); "" until chosen — the web app then
|
||||
# picks one from the browser on first sign-in and saves it here.
|
||||
ui_language: str = ""
|
||||
# Names and words email translation leaves as they are.
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
@@ -68,6 +79,10 @@ class SettingsPatch(BaseModel):
|
||||
invitation_file: bool | None = None
|
||||
spellcheck_enabled: bool | None = None
|
||||
spellcheck_language: str | None = None
|
||||
shortcuts_enabled: bool | None = None
|
||||
block_trackers: bool | None = None
|
||||
completion_enabled: bool | None = None
|
||||
ui_language: str | None = None
|
||||
|
||||
|
||||
class QuotaOut(BaseModel):
|
||||
@@ -82,6 +97,11 @@ def allowed_image_senders(user: User) -> set[str]:
|
||||
return set()
|
||||
|
||||
|
||||
def blocks_trackers(user: User) -> bool:
|
||||
"""Whether tracking pixels are taken out of this user's mail (on unless switched off)."""
|
||||
return bool(user.settings.get("block_trackers", True))
|
||||
|
||||
|
||||
def add_allowed_image_sender(user: User, email: str) -> None:
|
||||
current = sorted(allowed_image_senders(user))
|
||||
if email.lower() not in current:
|
||||
@@ -127,6 +147,10 @@ def _settings_out(user: User) -> SettingsOut:
|
||||
allowed_image_senders=sorted(allowed_image_senders(user)),
|
||||
spellcheck_enabled=bool(s.get("spellcheck_enabled", True)),
|
||||
spellcheck_language=str(s.get("spellcheck_language", "auto")),
|
||||
shortcuts_enabled=bool(s.get("shortcuts_enabled", True)),
|
||||
block_trackers=blocks_trackers(user),
|
||||
completion_enabled=bool(s.get("completion_enabled", True)),
|
||||
ui_language=str(s.get("ui_language", "")),
|
||||
)
|
||||
|
||||
|
||||
@@ -175,6 +199,14 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
|
||||
updates["spellcheck_enabled"] = body.spellcheck_enabled
|
||||
if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language):
|
||||
updates["spellcheck_language"] = body.spellcheck_language
|
||||
if body.shortcuts_enabled is not None:
|
||||
updates["shortcuts_enabled"] = body.shortcuts_enabled
|
||||
if body.block_trackers is not None:
|
||||
updates["block_trackers"] = body.block_trackers
|
||||
if body.completion_enabled is not None:
|
||||
updates["completion_enabled"] = body.completion_enabled
|
||||
if body.ui_language is not None and _UI_LANGUAGE.fullmatch(body.ui_language):
|
||||
updates["ui_language"] = body.ui_language
|
||||
user.settings = updates
|
||||
await db.commit()
|
||||
return _settings_out(user)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Snooze endpoints: hide a conversation from the Inbox until a set time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import snooze
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.events import get_event_bus
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import Thread
|
||||
|
||||
router = APIRouter(prefix="/threads", tags=["mail"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class SnoozeIn(BaseModel):
|
||||
until: dt.datetime
|
||||
|
||||
|
||||
class SnoozeOut(BaseModel):
|
||||
thread_id: uuid.UUID
|
||||
snoozed_until: dt.datetime | None
|
||||
|
||||
|
||||
async def _thread(db: AsyncSession, user_id: uuid.UUID, thread_id: uuid.UUID) -> Thread:
|
||||
thread = await db.scalar(
|
||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user_id)
|
||||
)
|
||||
if thread is None:
|
||||
raise_api_error(404, "not_found", "thread not found")
|
||||
return thread
|
||||
|
||||
|
||||
async def _announce(user_id: uuid.UUID, thread_id: uuid.UUID) -> None:
|
||||
await get_event_bus().publish(
|
||||
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{thread_id}/snooze", response_model=SnoozeOut)
|
||||
async def snooze_thread(
|
||||
thread_id: uuid.UUID, body: SnoozeIn, user: CurrentUser, db: DbSession
|
||||
) -> SnoozeOut:
|
||||
until = body.until
|
||||
if until.tzinfo is None:
|
||||
raise_api_error(400, "invalid_time", "the time needs a time zone")
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
if until <= now:
|
||||
raise_api_error(400, "invalid_time", "pick a time in the future")
|
||||
if until > now + snooze.MAX_SNOOZE:
|
||||
raise_api_error(400, "invalid_time", "snooze for at most a year")
|
||||
thread = await _thread(db, user.id, thread_id)
|
||||
snooze.snooze(thread, until, now)
|
||||
await db.commit()
|
||||
await _announce(user.id, thread_id)
|
||||
return SnoozeOut(thread_id=thread_id, snoozed_until=thread.snoozed_until)
|
||||
|
||||
|
||||
@router.post("/{thread_id}/unsnooze", response_model=SnoozeOut)
|
||||
async def unsnooze_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> SnoozeOut:
|
||||
thread = await _thread(db, user.id, thread_id)
|
||||
if thread.snoozed_until is not None:
|
||||
snooze.wake(thread, dt.datetime.now(dt.UTC))
|
||||
await db.commit()
|
||||
await _announce(user.id, thread_id)
|
||||
return SnoozeOut(thread_id=thread_id, snoozed_until=None)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Subscriptions page: bulk senders mailing the user (app/subscriptions.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import case, func, literal, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app import subscriptions as subs
|
||||
from app.accounts.ssrf import guarded_request
|
||||
from app.api.errors import raise_api_error
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import Mailbox, Message, SubscriptionState
|
||||
from app.outbox.schemas import DraftIn, DraftPayload
|
||||
from app.outbox.service import DraftError, create_outbox, upsert_draft
|
||||
from app.queue import enqueue_job
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/subscriptions", tags=["mail"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
# RFC 8058 senders should stop within two days of a one-click request.
|
||||
GRACE = dt.timedelta(days=2)
|
||||
# Not counted: mail the user sent, and mail already thrown away (Trash, Spam) —
|
||||
# a sender whose mail was all deleted drops off the list.
|
||||
_LEFT_OUT = ("sent", "drafts", "trash", "junk")
|
||||
|
||||
|
||||
class SubscriptionOut(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
email: str
|
||||
list_id: str | None
|
||||
total: int
|
||||
unread: int
|
||||
last_30_days: int
|
||||
per_month: float
|
||||
first_at: dt.datetime
|
||||
last_at: dt.datetime
|
||||
last_subject: str
|
||||
one_click: bool
|
||||
has_mailto: bool
|
||||
has_web: bool
|
||||
unsubscribed_at: dt.datetime | None
|
||||
method: str | None
|
||||
still_sending: bool
|
||||
|
||||
|
||||
class SubscriptionListOut(BaseModel):
|
||||
subscriptions: list[SubscriptionOut] # one page
|
||||
total: int # senders in all
|
||||
pending: int # messages whose headers are still being read
|
||||
|
||||
|
||||
class KeyIn(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class UnsubscribeIn(KeyIn):
|
||||
# From the reading pane: this message's own header, wherever it is filed
|
||||
# (Spam included, which the page's groups leave out).
|
||||
message_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class UnsubscribeOut(BaseModel):
|
||||
status: str # done | open
|
||||
method: str # one_click | mailto | web
|
||||
url: str | None = None
|
||||
|
||||
|
||||
def _sender() -> ColumnElement[Any]:
|
||||
return func.lower(func.coalesce(Message.from_addr[0]["email"].astext, ""))
|
||||
|
||||
|
||||
def _key() -> ColumnElement[Any]:
|
||||
return case(
|
||||
(Message.list_id.isnot(None), literal("list:") + Message.list_id),
|
||||
else_=literal("from:") + _sender(),
|
||||
)
|
||||
|
||||
|
||||
def _bulk(user_id: uuid.UUID) -> list[ColumnElement[bool]]:
|
||||
return [
|
||||
Message.user_id == user_id,
|
||||
Message.list_unsubscribe.isnot(None),
|
||||
Message.list_unsubscribe != "",
|
||||
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_LEFT_OUT)),
|
||||
]
|
||||
|
||||
|
||||
async def _start_backfill(user_id: uuid.UUID) -> None:
|
||||
# One job per user at a time: arq refuses a second while the first exists.
|
||||
await enqueue_job("backfill_list_headers_job", str(user_id), _job_id=f"listhdr:{user_id}")
|
||||
|
||||
|
||||
@router.get("", response_model=SubscriptionListOut)
|
||||
async def list_subscriptions(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
order: str = Query("desc"), # desc: heard from most recently first | asc: longest ago
|
||||
) -> SubscriptionListOut:
|
||||
"""One page of senders, by when their latest mail came."""
|
||||
pending = await subs.pending_count(db, user.id)
|
||||
if pending:
|
||||
await _start_backfill(user.id)
|
||||
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
key = _key().label("key")
|
||||
seen = Message.flags.contains(["\\Seen"])
|
||||
groups = (
|
||||
select(
|
||||
key,
|
||||
func.count().label("total"),
|
||||
func.count().filter(~seen).label("unread"),
|
||||
func.count()
|
||||
.filter(Message.internal_date > now - dt.timedelta(days=30))
|
||||
.label("recent"),
|
||||
func.min(Message.internal_date).label("first_at"),
|
||||
func.max(Message.internal_date).label("last_at"),
|
||||
)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(*_bulk(user.id))
|
||||
.group_by(key)
|
||||
)
|
||||
total = await db.scalar(select(func.count()).select_from(groups.subquery())) or 0
|
||||
agg_rows = (
|
||||
await db.execute(
|
||||
groups.order_by(
|
||||
func.max(Message.internal_date).asc()
|
||||
if order == "asc"
|
||||
else func.max(Message.internal_date).desc(),
|
||||
key,
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
).all()
|
||||
page_keys = [r.key for r in agg_rows]
|
||||
if not page_keys:
|
||||
return SubscriptionListOut(subscriptions=[], total=total, pending=pending)
|
||||
|
||||
in_page = _key().in_(page_keys)
|
||||
latest_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
key,
|
||||
Message.from_addr,
|
||||
Message.subject,
|
||||
Message.list_id,
|
||||
Message.list_unsubscribe,
|
||||
Message.list_unsubscribe_post,
|
||||
)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(*_bulk(user.id), in_page)
|
||||
.distinct(key)
|
||||
.order_by(key, Message.internal_date.desc())
|
||||
)
|
||||
).all()
|
||||
latest = {r.key: r for r in latest_rows}
|
||||
states = {
|
||||
s.key: s
|
||||
for s in await db.scalars(
|
||||
select(SubscriptionState).where(
|
||||
SubscriptionState.user_id == user.id, SubscriptionState.key.in_(page_keys)
|
||||
)
|
||||
)
|
||||
}
|
||||
# Senders still mailing after the grace period, for the whole page at once.
|
||||
still_sending = set(
|
||||
await db.scalars(
|
||||
select(key)
|
||||
.select_from(Message)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.join(
|
||||
SubscriptionState,
|
||||
(SubscriptionState.user_id == user.id) & (SubscriptionState.key == _key()),
|
||||
)
|
||||
.where(
|
||||
*_bulk(user.id),
|
||||
in_page,
|
||||
Message.internal_date > SubscriptionState.unsubscribed_at + GRACE,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
)
|
||||
|
||||
out: list[SubscriptionOut] = []
|
||||
for row in agg_rows:
|
||||
last = latest.get(row.key)
|
||||
if last is None:
|
||||
continue
|
||||
sender = last.from_addr[0] if last.from_addr else {}
|
||||
targets = subs.unsubscribe_targets(last.list_unsubscribe or "")
|
||||
state = states.get(row.key)
|
||||
months = max(1.0, (row.last_at - row.first_at).days / 30.44)
|
||||
out.append(
|
||||
SubscriptionOut(
|
||||
key=row.key,
|
||||
name=sender.get("name") or sender.get("email", ""),
|
||||
email=sender.get("email", ""),
|
||||
list_id=last.list_id,
|
||||
total=row.total,
|
||||
unread=row.unread,
|
||||
last_30_days=row.recent,
|
||||
per_month=round(row.total / months, 1),
|
||||
first_at=row.first_at,
|
||||
last_at=row.last_at,
|
||||
last_subject=last.subject,
|
||||
one_click=bool(last.list_unsubscribe_post and targets.https),
|
||||
has_mailto=targets.mailto is not None,
|
||||
has_web=targets.https is not None,
|
||||
unsubscribed_at=state.unsubscribed_at if state else None,
|
||||
method=state.method if state else None,
|
||||
still_sending=row.key in still_sending,
|
||||
)
|
||||
)
|
||||
return SubscriptionListOut(subscriptions=out, total=total, pending=pending)
|
||||
|
||||
|
||||
@router.get("/threads", response_model=list[uuid.UUID])
|
||||
async def subscription_threads(
|
||||
user: CurrentUser, db: DbSession, key: str = Query(...), limit: int = Query(50, le=200)
|
||||
) -> list[uuid.UUID]:
|
||||
"""The group's conversations, newest first (for the reading pane)."""
|
||||
rows = await db.execute(
|
||||
select(Message.thread_id, func.max(Message.internal_date).label("at"))
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(*_bulk(user.id), _key() == key, Message.thread_id.isnot(None))
|
||||
.group_by(Message.thread_id)
|
||||
.order_by(func.max(Message.internal_date).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [r.thread_id for r in rows if r.thread_id is not None]
|
||||
|
||||
|
||||
async def _latest_with_header(db: AsyncSession, user_id: uuid.UUID, key: str) -> Message:
|
||||
message = await db.scalar(
|
||||
select(Message)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(*_bulk(user_id), _key() == key)
|
||||
.order_by(Message.internal_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if message is None:
|
||||
raise_api_error(404, "not_found", "no mail from this sender")
|
||||
return message
|
||||
|
||||
|
||||
async def _record(db: AsyncSession, user_id: uuid.UUID, key: str, method: str) -> None:
|
||||
stmt = insert(SubscriptionState).values(user_id=user_id, key=key, method=method)
|
||||
await db.execute(
|
||||
stmt.on_conflict_do_update(
|
||||
index_elements=["user_id", "key"],
|
||||
set_={"method": method, "unsubscribed_at": func.now()},
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _one_click(url: str) -> bool:
|
||||
try:
|
||||
resp = await guarded_request(
|
||||
"POST",
|
||||
url,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
content="List-Unsubscribe=One-Click",
|
||||
max_bytes=256 * 1024,
|
||||
)
|
||||
except Exception as exc: # SSRF refusal, network, TLS: any failure tries the next way
|
||||
logger.info("one-click unsubscribe failed: %s", exc)
|
||||
return False
|
||||
return 200 <= resp.status < 400
|
||||
|
||||
|
||||
async def _send_mailto(
|
||||
db: AsyncSession, user_id: uuid.UUID, message: Message, targets: subs.UnsubscribeTargets
|
||||
) -> bool:
|
||||
assert targets.mailto is not None
|
||||
body = DraftIn(
|
||||
account_id=message.account_id,
|
||||
kind="new",
|
||||
payload=DraftPayload(
|
||||
to=[targets.mailto], subject=targets.mailto_subject, text=targets.mailto_body
|
||||
),
|
||||
)
|
||||
try:
|
||||
draft = await upsert_draft(db, user_id=user_id, body=body, draft_id=None)
|
||||
except DraftError:
|
||||
return False
|
||||
outbox = await create_outbox(db, user_id=user_id, draft=draft)
|
||||
await db.commit()
|
||||
await enqueue_job(
|
||||
"send_job", str(outbox.id), _defer_by=dt.timedelta(seconds=get_settings().undo_seconds)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/unsubscribe", response_model=UnsubscribeOut)
|
||||
async def unsubscribe(body: UnsubscribeIn, user: CurrentUser, db: DbSession) -> UnsubscribeOut:
|
||||
"""Unsubscribe from a sender: one-click if offered, else by mail, else the web page."""
|
||||
if body.message_id is not None:
|
||||
found = await db.scalar(
|
||||
select(Message).where(
|
||||
Message.id == body.message_id,
|
||||
Message.user_id == user.id,
|
||||
Message.list_unsubscribe.isnot(None),
|
||||
Message.list_unsubscribe != "",
|
||||
)
|
||||
)
|
||||
if found is None:
|
||||
raise_api_error(404, "not_found", "this message offers no way to unsubscribe")
|
||||
message = found
|
||||
else:
|
||||
message = await _latest_with_header(db, user.id, body.key)
|
||||
targets = subs.unsubscribe_targets(message.list_unsubscribe or "")
|
||||
if message.list_unsubscribe_post and targets.https and await _one_click(targets.https):
|
||||
await _record(db, user.id, body.key, "one_click")
|
||||
return UnsubscribeOut(status="done", method="one_click")
|
||||
if targets.mailto and await _send_mailto(db, user.id, message, targets):
|
||||
await _record(db, user.id, body.key, "mailto")
|
||||
return UnsubscribeOut(status="done", method="mailto")
|
||||
if targets.https:
|
||||
# The sender wants it done on their page: the user opens it.
|
||||
await _record(db, user.id, body.key, "web")
|
||||
return UnsubscribeOut(status="open", method="web", url=targets.https)
|
||||
raise_api_error(409, "no_unsubscribe", "this sender offers no way to unsubscribe")
|
||||
|
||||
|
||||
@router.post("/trash")
|
||||
async def trash_all(body: KeyIn, user: CurrentUser, db: DbSession) -> dict[str, int]:
|
||||
"""Move every conversation from this sender to Trash.
|
||||
|
||||
Only conversations made up entirely of this sender's mail: one where the
|
||||
user replied, or someone else wrote, stays where it is.
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Message.thread_id, _key().label("key"), Mailbox.special_use)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(
|
||||
Message.user_id == user.id,
|
||||
Message.thread_id.in_(
|
||||
select(Message.thread_id)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(*_bulk(user.id), _key() == body.key)
|
||||
),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
own: dict[uuid.UUID, bool] = {}
|
||||
for thread_id, key, special in rows:
|
||||
if thread_id is None or special == "trash":
|
||||
continue
|
||||
own[thread_id] = own.get(thread_id, True) and key == body.key
|
||||
moved = [tid for tid, only_theirs in own.items() if only_theirs]
|
||||
for tid in moved:
|
||||
await enqueue_job("move_thread_to_folder_job", str(tid), "trash")
|
||||
return {"moved": len(moved)}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Templates: saved replies, inserted from the compose window's More menu."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
import nh3
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import MailTemplate
|
||||
|
||||
router = APIRouter(prefix="/templates", tags=["compose"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
MAX_TEMPLATES = 200
|
||||
MAX_BODY_BYTES = 256 * 1024
|
||||
|
||||
# What the compose editor writes. A template goes back into the editor as HTML,
|
||||
# so it is cleaned like any HTML the app renders: no scripts, no handlers.
|
||||
_TAGS = {
|
||||
"a", "b", "blockquote", "br", "code", "div", "em", "font", "h1", "h2", "h3",
|
||||
"hr", "i", "img", "li", "ol", "p", "pre", "s", "span", "strike", "strong",
|
||||
"sub", "sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul",
|
||||
} # fmt: skip
|
||||
_GENERIC = {"style", "class", "dir", "align"}
|
||||
_SPECIFIC = {
|
||||
"a": {"href", "title"},
|
||||
"img": {"src", "alt", "width", "height"},
|
||||
"font": {"color", "face", "size"},
|
||||
"td": {"colspan", "rowspan"},
|
||||
"th": {"colspan", "rowspan"},
|
||||
}
|
||||
# nh3 takes no wildcard: the generic attributes go onto every tag.
|
||||
_ATTRS = {tag: _GENERIC | _SPECIFIC.get(tag, set()) for tag in _TAGS}
|
||||
|
||||
|
||||
class TemplateOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
subject: str
|
||||
body_html: str
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class TemplateIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
subject: str = Field(default="", max_length=500)
|
||||
body_html: str = ""
|
||||
|
||||
|
||||
def _out(t: MailTemplate) -> TemplateOut:
|
||||
return TemplateOut(
|
||||
id=t.id, name=t.name, subject=t.subject, body_html=t.body_html, updated_at=t.updated_at
|
||||
)
|
||||
|
||||
|
||||
def clean_body(html: str) -> str:
|
||||
return nh3.clean(
|
||||
html,
|
||||
tags=_TAGS,
|
||||
attributes=_ATTRS,
|
||||
url_schemes={"http", "https", "mailto", "data"},
|
||||
link_rel="noopener noreferrer",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TemplateOut])
|
||||
async def list_templates(user: CurrentUser, db: DbSession) -> list[TemplateOut]:
|
||||
rows = await db.scalars(
|
||||
select(MailTemplate)
|
||||
.where(MailTemplate.user_id == user.id)
|
||||
.order_by(func.lower(MailTemplate.name))
|
||||
)
|
||||
return [_out(t) for t in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateOut)
|
||||
async def save_template(body: TemplateIn, user: CurrentUser, db: DbSession) -> TemplateOut:
|
||||
"""Save a template. Saving under a name that exists replaces that template."""
|
||||
name = " ".join(body.name.split())
|
||||
if not name:
|
||||
raise_api_error(400, "invalid", "a template needs a name")
|
||||
if len(body.body_html.encode()) > MAX_BODY_BYTES:
|
||||
raise_api_error(400, "too_large", "the template is too large")
|
||||
html = clean_body(body.body_html)
|
||||
subject = " ".join(body.subject.split())
|
||||
existing = await db.scalar(
|
||||
select(MailTemplate).where(
|
||||
MailTemplate.user_id == user.id, func.lower(MailTemplate.name) == name.lower()
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
existing.name, existing.subject, existing.body_html = name, subject, html
|
||||
existing.updated_at = dt.datetime.now(dt.UTC)
|
||||
saved = existing
|
||||
else:
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(MailTemplate).where(MailTemplate.user_id == user.id)
|
||||
)
|
||||
if (count or 0) >= MAX_TEMPLATES:
|
||||
raise_api_error(409, "too_many", f"at most {MAX_TEMPLATES} templates")
|
||||
saved = MailTemplate(user_id=user.id, name=name, subject=subject, body_html=html)
|
||||
db.add(saved)
|
||||
await db.commit()
|
||||
await db.refresh(saved)
|
||||
return _out(saved)
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_template(
|
||||
template_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, str]:
|
||||
row = await db.scalar(
|
||||
select(MailTemplate).where(MailTemplate.id == template_id, MailTemplate.user_id == user.id)
|
||||
)
|
||||
if row is None:
|
||||
raise_api_error(404, "not_found", "template not found")
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -4,13 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Collection
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import Text, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app import subscriptions as subs
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import (
|
||||
Address,
|
||||
@@ -19,10 +20,10 @@ from app.api.schemas import (
|
||||
ThreadListOut,
|
||||
ThreadSummary,
|
||||
)
|
||||
from app.api.settings import allowed_image_senders
|
||||
from app.api.settings import allowed_image_senders, blocks_trackers
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Mailbox, Message, Thread, User
|
||||
from app.models import BodyPart, Mailbox, Message, SubscriptionState, Thread, User
|
||||
|
||||
router = APIRouter(prefix="/threads", tags=["mail"])
|
||||
|
||||
@@ -50,6 +51,7 @@ def _summary(
|
||||
has_attachments=thread.has_attachments,
|
||||
pinned=thread.pinned,
|
||||
last_message_at=thread.last_message_at,
|
||||
snoozed_until=thread.snoozed_until,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,6 +76,7 @@ def _envelope(msg: Message, allowed_senders: Collection[str] = ()) -> MessageEnv
|
||||
images_allowed=bool(sender) and sender in allowed_senders,
|
||||
size_bytes=msg.size_bytes,
|
||||
snippet=msg.snippet,
|
||||
content_language=msg.content_language or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -130,6 +133,10 @@ async def list_threads(
|
||||
.where(Message.user_id == user.id, Mailbox.special_use == special)
|
||||
)
|
||||
conditions.append(Thread.id.in_(in_folder))
|
||||
if special == "inbox":
|
||||
conditions.append(Thread.snoozed_until.is_(None)) # back when it wakes
|
||||
elif view == "snoozed":
|
||||
conditions.append(Thread.snoozed_until.isnot(None))
|
||||
elif view != "unified":
|
||||
raise_api_error(400, "bad_view", "unsupported view")
|
||||
|
||||
@@ -144,11 +151,20 @@ async def list_threads(
|
||||
)
|
||||
) or 0
|
||||
|
||||
order_by = (
|
||||
(Thread.last_message_at.asc(), Thread.id.asc())
|
||||
if order == "asc"
|
||||
else (Thread.last_message_at.desc(), Thread.id.desc())
|
||||
# A thread woken from snooze sorts as if it had just arrived.
|
||||
sort_at = func.greatest(
|
||||
Thread.last_message_at, func.coalesce(Thread.unsnoozed_at, Thread.last_message_at)
|
||||
)
|
||||
if view == "snoozed":
|
||||
# Soonest back first, whatever the list order.
|
||||
order_by: tuple[ColumnElement[Any], ...] = (
|
||||
Thread.snoozed_until.asc(),
|
||||
Thread.id.asc(),
|
||||
)
|
||||
elif order == "asc":
|
||||
order_by = (sort_at.asc(), Thread.id.asc())
|
||||
else:
|
||||
order_by = (sort_at.desc(), Thread.id.desc())
|
||||
stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit)
|
||||
threads = list(await db.scalars(stmt))
|
||||
|
||||
@@ -177,6 +193,41 @@ async def list_threads(
|
||||
)
|
||||
|
||||
|
||||
async def _mark_subscriptions(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
messages: list[Message],
|
||||
envelopes: list[MessageEnvelope],
|
||||
) -> None:
|
||||
"""Tell the reading pane which messages can be unsubscribed from, and how."""
|
||||
keys: dict[uuid.UUID, str] = {}
|
||||
for m, env in zip(messages, envelopes, strict=True):
|
||||
if not m.list_unsubscribe:
|
||||
continue
|
||||
targets = subs.unsubscribe_targets(m.list_unsubscribe)
|
||||
if m.list_unsubscribe_post and targets.https:
|
||||
env.unsubscribe_via = "one_click"
|
||||
elif targets.mailto:
|
||||
env.unsubscribe_via = "mailto"
|
||||
elif targets.https:
|
||||
env.unsubscribe_via = "web"
|
||||
else:
|
||||
continue
|
||||
sender = m.from_addr[0].get("email", "") if m.from_addr else ""
|
||||
env.subscription_key = keys[m.id] = subs.group_key(m.list_id, sender)
|
||||
if keys:
|
||||
done = set(
|
||||
await db.scalars(
|
||||
select(SubscriptionState.key).where(
|
||||
SubscriptionState.user_id == user_id,
|
||||
SubscriptionState.key.in_(set(keys.values())),
|
||||
)
|
||||
)
|
||||
)
|
||||
for env in envelopes:
|
||||
env.unsubscribed = env.subscription_key in done
|
||||
|
||||
|
||||
def _visible_in(current_special: str | None) -> ColumnElement[bool]:
|
||||
"""Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam
|
||||
show only their own messages; every other view hides them."""
|
||||
@@ -281,6 +332,20 @@ async def get_thread(
|
||||
env = _envelope(m, allowed_senders)
|
||||
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
|
||||
envelopes.append(env)
|
||||
await _mark_subscriptions(db, user.id, ordered, envelopes)
|
||||
if not blocks_trackers(user) and ordered:
|
||||
# Trackers left in are remote images: offer "Load images" for them too.
|
||||
with_trackers = set(
|
||||
await db.scalars(
|
||||
select(BodyPart.message_id).where(
|
||||
BodyPart.message_id.in_([m.id for m in ordered]),
|
||||
BodyPart.kind == "html_sanitized",
|
||||
BodyPart.trackers_blocked > 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
for env in envelopes:
|
||||
env.has_remote_images = env.has_remote_images or env.id in with_trackers
|
||||
|
||||
order = "desc" if sort == "desc" else "asc"
|
||||
return ThreadDetail(
|
||||
@@ -289,4 +354,5 @@ async def get_thread(
|
||||
order=order,
|
||||
pinned=thread.pinned,
|
||||
messages=envelopes,
|
||||
snoozed_until=thread.snoozed_until,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""What email translation offers (app/translation.py), for the reading pane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import translation
|
||||
from app.identity.deps import CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/translation", tags=["mail"])
|
||||
|
||||
|
||||
class LanguageOut(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
|
||||
|
||||
class TranslationInfoOut(BaseModel):
|
||||
available: bool
|
||||
languages: list[LanguageOut]
|
||||
|
||||
|
||||
@router.get("/languages", response_model=TranslationInfoOut)
|
||||
async def translation_languages(user: CurrentUser) -> TranslationInfoOut:
|
||||
found = await translation.languages()
|
||||
return TranslationInfoOut(
|
||||
available=bool(found),
|
||||
languages=[LanguageOut(code=x.code, name=x.name) for x in found],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -71,6 +71,9 @@ class Settings(BaseSettings):
|
||||
# Spelling and grammar checking in the compose window (LanguageTool server,
|
||||
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
|
||||
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
|
||||
# Email translation: a LibreTranslate server (app/translation.py), e.g.
|
||||
# http://libretranslate:5000. Empty: translation is off.
|
||||
libretranslate_url: str = Field(default="", alias="LIBRETRANSLATE_URL")
|
||||
# New passwords are checked against known data breaches (Have I Been Pwned's
|
||||
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
|
||||
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.indexer.sanitize import sanitize_html
|
||||
from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.search.base import SearchDoc
|
||||
from app.subscriptions import read_list_headers
|
||||
from app.threading_.normalize import synthesize_message_id
|
||||
from app.threading_.threader import thread_message
|
||||
|
||||
@@ -167,6 +168,7 @@ async def persist_message(
|
||||
else:
|
||||
sha = rawstore.put_cached(user_id, remote.raw)
|
||||
parsed = await _parse_bounded(user_id, sha, remote.raw)
|
||||
list_headers = read_list_headers(remote.raw) # for the Subscriptions page
|
||||
|
||||
message_id_hdr = await _resolve_message_id(
|
||||
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
|
||||
@@ -207,6 +209,9 @@ async def persist_message(
|
||||
has_attachments=parsed.has_attachments,
|
||||
snippet=parsed.snippet,
|
||||
blob_sha256=sha,
|
||||
list_id=list_headers.list_id,
|
||||
list_unsubscribe=list_headers.unsubscribe,
|
||||
list_unsubscribe_post=list_headers.one_click,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
@@ -224,6 +229,7 @@ async def persist_message(
|
||||
kind="html_sanitized",
|
||||
content=result.html,
|
||||
sanitizer_v=result.sanitizer_v,
|
||||
trackers_blocked=result.trackers_blocked,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ import nh3
|
||||
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
|
||||
# v5: this server's own inline-image URLs quoted in a message load from here.
|
||||
# v6: links keep only http(s) and mailto (no data: pages).
|
||||
SANITIZER_VERSION = 6
|
||||
# v7: tracking pixels are removed (and counted), even when images are allowed.
|
||||
# v8: CSS sizes in other units (in, em, %, pt) no longer read as pixels — v7
|
||||
# took 0.31in signature logos for trackers.
|
||||
SANITIZER_VERSION = 8
|
||||
|
||||
# This server's inline-image URL, however it ended up written: "/api/…",
|
||||
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
|
||||
@@ -154,6 +157,94 @@ class SanitizeResult:
|
||||
html: str
|
||||
has_remote_images: bool
|
||||
sanitizer_v: int
|
||||
trackers_blocked: int = 0
|
||||
|
||||
|
||||
# --- Tracking pixels ---------------------------------------------------------------
|
||||
# A remote image that is invisible (1×1, or hidden by CSS) has no use but to tell
|
||||
# the sender the mail was opened, and by whom. A few well-known open-tracking URL
|
||||
# shapes are caught whatever their size. Matching runs on the raw HTML before nh3:
|
||||
# a miss only leaves an image in, and nh3 still cleans whatever remains.
|
||||
_IMG_TAG = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
|
||||
_ATTR = re.compile(r"""([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+)""")
|
||||
_CSS_HIDDEN = re.compile(
|
||||
r"display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d]*[1-9])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TRACKER_URL = re.compile(
|
||||
r"//[^/]*mailtrack\.io/"
|
||||
r"|/track/open\b"
|
||||
r"|/wf/open\b"
|
||||
r"|/open\.(?:php|aspx|gif)\b"
|
||||
r"|[?&]tracking[_-]?pixel=",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_PX = re.compile(r"\s*(\d+(?:\.\d*)?|\.\d+)\s*(px)?\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _px(value: str | None) -> float | None:
|
||||
"""A size in pixels ("1", "1px"), or None for anything else: other units
|
||||
(0.31in is a 30-pixel signature logo), auto, %, or malformed (".", "1.2.3")."""
|
||||
if value is None:
|
||||
return None
|
||||
match = _PX.match(value)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _tiny(value: str | None) -> bool:
|
||||
px = _px(value)
|
||||
return px is not None and px <= 2
|
||||
|
||||
|
||||
def _css(style: str) -> dict[str, str]:
|
||||
"""An inline style's declarations, lower-cased, without !important."""
|
||||
out: dict[str, str] = {}
|
||||
for decl in style.split(";"):
|
||||
name, _, value = decl.partition(":")
|
||||
value = value.lower().replace("!important", "").strip()
|
||||
if name.strip() and value:
|
||||
out[name.strip().lower()] = value
|
||||
return out
|
||||
|
||||
|
||||
def is_tracking_pixel(tag: str) -> bool:
|
||||
"""Whether an ``<img …>`` tag is a remote open-tracking pixel."""
|
||||
attrs = {name.lower(): value.strip("\"'") for name, value in _ATTR.findall(tag)}
|
||||
src = attrs.get("src", "").strip()
|
||||
if not src.lower().startswith(("http:", "https:", "//")):
|
||||
return False # inline (cid:/data:) images can't report anything
|
||||
if _TRACKER_URL.search(src):
|
||||
return True
|
||||
style = attrs.get("style", "")
|
||||
if _CSS_HIDDEN.search(style):
|
||||
return True
|
||||
# The size the browser draws: a style width/height overrides the attribute
|
||||
# (and "auto", % or another unit is no tiny size); a max-width/max-height
|
||||
# of a pixel or two caps it; height "auto" follows the width.
|
||||
css = _css(style)
|
||||
wide = _tiny(css["width"]) if "width" in css else _tiny(attrs.get("width"))
|
||||
tall = _tiny(css["height"]) if "height" in css else _tiny(attrs.get("height"))
|
||||
wide = wide or _tiny(css.get("max-width"))
|
||||
tall = tall or _tiny(css.get("max-height"))
|
||||
if css.get("height") == "auto":
|
||||
tall = wide
|
||||
return wide and tall
|
||||
|
||||
|
||||
def strip_tracking_pixels(html: str) -> tuple[str, int]:
|
||||
"""Remove tracking pixels from raw HTML; returns the HTML and how many."""
|
||||
count = 0
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
nonlocal count
|
||||
if is_tracking_pixel(match.group(0)):
|
||||
count += 1
|
||||
return ""
|
||||
return match.group(0)
|
||||
|
||||
return _IMG_TAG.sub(repl, html), count
|
||||
|
||||
|
||||
def _sanitize_style(value: str, allow_remote: bool) -> str | None:
|
||||
@@ -188,15 +279,24 @@ def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, boo
|
||||
|
||||
|
||||
def sanitize_html(
|
||||
html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False
|
||||
html: str,
|
||||
message_id: uuid.UUID,
|
||||
*,
|
||||
allow_remote_images: bool = False,
|
||||
block_trackers: bool = True,
|
||||
) -> SanitizeResult:
|
||||
"""Sanitize ``html`` for the message ``message_id``.
|
||||
|
||||
Returns the safe HTML, whether any remote images were blocked, and the
|
||||
sanitizer version. Pass ``allow_remote_images=True`` after the user approves
|
||||
a sender to render remote images and CSS backgrounds (SPEC §9.4).
|
||||
``block_trackers=False`` (Settings → Privacy) leaves tracking pixels in; they
|
||||
are remote images like any other then.
|
||||
"""
|
||||
state = {"has_remote": False}
|
||||
trackers = 0
|
||||
if block_trackers:
|
||||
html, trackers = strip_tracking_pixels(html)
|
||||
|
||||
def attribute_filter(tag: str, attr: str, value: str) -> str | None:
|
||||
if attr == "style":
|
||||
@@ -247,4 +347,5 @@ def sanitize_html(
|
||||
html=cleaned,
|
||||
has_remote_images=state["has_remote"],
|
||||
sanitizer_v=SANITIZER_VERSION,
|
||||
trackers_blocked=trackers,
|
||||
)
|
||||
|
||||
@@ -199,6 +199,12 @@ class Thread(Base):
|
||||
has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||
last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||
# Snooze: out of the Inbox until this time (NULL when not snoozed). A message
|
||||
# that arrives after ``snoozed_at`` wakes it early; ``unsnoozed_at`` (when it
|
||||
# last woke) puts it back at the top of the Inbox.
|
||||
snoozed_until: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
snoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
unsnoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
|
||||
|
||||
class Message(Base):
|
||||
@@ -263,6 +269,17 @@ class Message(Base):
|
||||
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
body_indexed: Mapped[str | None] = mapped_column(TSVECTOR)
|
||||
# Mailing-list headers, for the Subscriptions page. ``list_unsubscribe`` is
|
||||
# NULL until read (a backfill job fills older mail), '' when there is none.
|
||||
list_id: Mapped[str | None] = mapped_column(Text)
|
||||
list_unsubscribe: Mapped[str | None] = mapped_column(Text)
|
||||
# RFC 8058: the sender takes a one-click unsubscribe POST.
|
||||
list_unsubscribe_post: Mapped[bool] = mapped_column(
|
||||
nullable=False, server_default=text("false")
|
||||
)
|
||||
# The language of its text (app/translation.py), found the first time it is
|
||||
# shown: NULL not looked at yet, '' could not tell.
|
||||
content_language: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class BodyPart(Base):
|
||||
@@ -276,6 +293,8 @@ class BodyPart(Base):
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# SPEC §10.4: re-sanitize on render if the sanitizer version changed.
|
||||
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
# Tracking pixels taken out of this HTML (shown as "N trackers blocked").
|
||||
trackers_blocked: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
@@ -404,6 +423,78 @@ class Invitation(Base):
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionState(Base):
|
||||
"""What a user did about a bulk sender on the Subscriptions page."""
|
||||
|
||||
__tablename__ = "subscription_states"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
# The sender group: "list:<List-Id>" or "from:<address>".
|
||||
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
unsubscribed_at: Mapped[dt.datetime] = _now()
|
||||
method: Mapped[str] = mapped_column(Text, nullable=False) # one_click | mailto | web
|
||||
|
||||
|
||||
class MailTemplate(Base):
|
||||
"""A saved reply, inserted from the compose window."""
|
||||
|
||||
__tablename__ = "mail_templates"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
body_html: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
created_at: Mapped[dt.datetime] = _now()
|
||||
updated_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
class MessageTranslation(Base):
|
||||
"""A message's HTML translated into a language (app/translation.py), kept so
|
||||
it is translated once. ``images``: with remote images shown or not."""
|
||||
|
||||
__tablename__ = "message_translations"
|
||||
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
target: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
images: Mapped[bool] = mapped_column(primary_key=True)
|
||||
source: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
html: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
class CompletionPhrase(Base):
|
||||
"""Sentence completion (app/completion.py): after these words, the user
|
||||
usually writes this — seen ``count`` times in their sent mail."""
|
||||
|
||||
__tablename__ = "completion_phrases"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
context: Mapped[str] = mapped_column(Text, primary_key=True) # normalized words
|
||||
continuation: Mapped[str] = mapped_column(Text, primary_key=True) # as written
|
||||
count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
class CompletionIndexState(Base):
|
||||
"""When a user's completion index was last built."""
|
||||
|
||||
__tablename__ = "completion_index_state"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
built_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
"""A browser or installed app to tell about new mail (Web Push)."""
|
||||
|
||||
|
||||
@@ -21,7 +21,14 @@ QUEUE_INTERACTIVE = "interactive"
|
||||
QUEUE_BULK = "bulk"
|
||||
|
||||
# Jobs default to the interactive queue; only bulk backfills go to the bulk queue.
|
||||
_BULK_JOBS = frozenset({"sync_bootstrap_account", "dedupe_existing_job"})
|
||||
_BULK_JOBS = frozenset(
|
||||
{
|
||||
"sync_bootstrap_account",
|
||||
"dedupe_existing_job",
|
||||
"backfill_list_headers_job",
|
||||
"build_index_job",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def queue_for(job_name: str) -> str:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]))
|
||||
@@ -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
|
||||
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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -391,7 +391,7 @@ async def test_folder_counts(api: ApiCtx) -> None:
|
||||
# All three seeded inbox messages are unread; no drafts yet.
|
||||
resp = await api.client.get("/api/mailboxes/counts")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"inbox_unread": 3, "drafts": 0}
|
||||
assert resp.json() == {"inbox_unread": 3, "drafts": 0, "snoozed": 0}
|
||||
|
||||
# Marking one message \Seen lowers the unread count.
|
||||
msg = await api.session.get(Message, api.message_ids[0])
|
||||
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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("&", "&")
|
||||
|
||||
# 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
|
||||
@@ -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": []}
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.indexer.sanitize import sanitize_html
|
||||
|
||||
MID = uuid.uuid4()
|
||||
@@ -99,3 +101,72 @@ def test_script_content_still_stripped_with_style_allowed() -> None:
|
||||
result = sanitize_html(html, MID)
|
||||
assert "alert(1)" not in result.html
|
||||
assert ".a{color:red}" in result.html
|
||||
|
||||
|
||||
# --- Tracking pixels ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
[
|
||||
'<img src="https://t.example.com/o.gif?u=1" width="1" height="1">',
|
||||
'<img src="https://t.example.com/o.gif" width="1px" height="1px" border="0">',
|
||||
"<img src='https://t.example.com/x' style='width:1px;height:1px'>",
|
||||
'<img src="https://t.example.com/x" style="display:none">',
|
||||
'<img src="https://x.list-manage.com/track/open.php?u=1&id=2">',
|
||||
'<img src="https://u1.ct.sendgrid.net/wf/open?upn=abc" alt="">',
|
||||
'<img src="https://mailtrack.io/trace/mail/abc.png">',
|
||||
# Squeezed to a pixel by max-width, its height following.
|
||||
'<img src="https://x.example/p.heic" width="1" height="1" '
|
||||
'style="display:block; height:auto; max-width:1px !important; width:100%">',
|
||||
'<img src="https://x.example/p" width="0" height="0" style="border:0;width:0;height:0;">',
|
||||
],
|
||||
)
|
||||
def test_tracking_pixels_are_removed_and_counted(tag: str) -> None:
|
||||
html = f"<p>Hello</p>{tag}<p>Bye</p>"
|
||||
for allow in (False, True):
|
||||
result = sanitize_html(html, MID, allow_remote_images=allow)
|
||||
assert result.trackers_blocked == 1
|
||||
assert "<img" not in result.html
|
||||
assert "Hello" in result.html and "Bye" in result.html
|
||||
# A pixel alone is no reason to offer "Load images".
|
||||
assert sanitize_html(html, MID).has_remote_images is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
[
|
||||
'<img src="https://cdn.example.com/logo.png" width="180" height="47">',
|
||||
'<img src="https://cdn.example.com/photo.jpg">',
|
||||
'<img src="https://cdn.example.com/spacer.gif" width="1" height="40">',
|
||||
'<img src="cid:logo" width="1" height="1">',
|
||||
# Outlook signature logos: sizes in inches, not pixels.
|
||||
'<img width="30" height="30" style="width:0.3125in; height:0.3125in" '
|
||||
'src="https://pfx.tv/img/sig/logo.png">',
|
||||
'<img style="width:1.5em;height:1.5em" src="https://cdn.example.com/icon.png">',
|
||||
'<img style="width:1%;height:1%" src="https://cdn.example.com/bar.png">',
|
||||
'<img style="width:1pt;height:1pt" src="https://cdn.example.com/dot.png">',
|
||||
# Attributes say 1x1, the style draws it full width.
|
||||
'<img src="https://cdn.example.com/photo.jpg" width="1" height="1" '
|
||||
'style="width:100%;height:auto">',
|
||||
],
|
||||
)
|
||||
def test_real_images_are_kept(tag: str) -> None:
|
||||
result = sanitize_html(f"<p>x</p>{tag}", MID, allow_remote_images=True)
|
||||
assert result.trackers_blocked == 0
|
||||
assert "<img" in result.html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [".", "1.2.3", "..", "", "px", "1e3", "-1"])
|
||||
def test_malformed_sizes_never_break_sanitizing(size: str) -> None:
|
||||
tag = f'<img src="https://cdn.example.com/a.png" width="{size}" height="{size}">'
|
||||
result = sanitize_html(f"<p>x</p>{tag}", MID, allow_remote_images=True)
|
||||
assert result.trackers_blocked == 0
|
||||
css = f'<img src="https://cdn.example.com/a.png" style="width:{size}px;height:{size}px">'
|
||||
assert sanitize_html(css, MID, allow_remote_images=True).trackers_blocked == 0
|
||||
|
||||
|
||||
def test_tracker_blocking_can_be_switched_off() -> None:
|
||||
tag = '<img src="https://t.example.com/o.gif" width="1" height="1">'
|
||||
kept = sanitize_html(f"<p>x</p>{tag}", MID, allow_remote_images=True, block_trackers=False)
|
||||
assert kept.trackers_blocked == 0 and "t.example.com/o.gif" in kept.html
|
||||
|
||||
@@ -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)
|
||||
@@ -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> </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> </div><p> – </p></div>'
|
||||
)
|
||||
assert f"{KEPT}\ufeff</span>" in out
|
||||
assert "<div> </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> </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"]
|
||||
@@ -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 & więcej.</div>"
|
||||
"<div>I noticed that you applied to join the platform & 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 & 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(
|
||||
"&", "&"
|
||||
)
|
||||
assert '<span translate="no">+48 780 549 877</span>' in out
|
||||
@@ -27,6 +27,7 @@ from arq.worker import Worker
|
||||
from app.config import get_settings
|
||||
from app.indexer.gc import sweep_job
|
||||
from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, redis_settings
|
||||
from app.snooze import wake_due_job
|
||||
from app.sync.actions import autopurge_trash_spam
|
||||
from app.sync.coordinator import SyncCoordinator
|
||||
from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS
|
||||
@@ -34,7 +35,9 @@ from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS
|
||||
# Core scheduled jobs. Daily Trash/Spam retention purge at 03:17 (off-peak, and a
|
||||
# minute that won't collide with typical top-of-hour crons); then the clean-up of
|
||||
# stored files nothing uses any more (the purge's among them) at 03:47.
|
||||
# Snoozed conversations are checked every minute (cheap: a partial index).
|
||||
_CORE_CRON_JOBS = [
|
||||
cron(wake_due_job, second=5),
|
||||
cron(autopurge_trash_spam, hour=3, minute=17),
|
||||
cron(sweep_job, hour=3, minute=47, timeout=3600),
|
||||
]
|
||||
|
||||
@@ -14,10 +14,12 @@ from typing import Any
|
||||
|
||||
from arq import func
|
||||
|
||||
from app.completion import build_index_job
|
||||
from app.indexer.attachments import dedupe_existing_job
|
||||
from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job
|
||||
from app.outbox.jobs import file_sent_copy_job, send_job
|
||||
from app.push.service import push_new_mail_job
|
||||
from app.subscriptions import backfill_list_headers_job
|
||||
from app.sync.actions import (
|
||||
ACTION_MAX_TRIES,
|
||||
delete_thread_job,
|
||||
@@ -67,4 +69,11 @@ BULK_FUNCTIONS = [
|
||||
sync_bootstrap_account,
|
||||
# Resumable (each batch is saved), so a long first run may span timeouts.
|
||||
func(dedupe_existing_job, timeout=6 * 3600),
|
||||
# Batches are saved as they go, so a run cut short resumes where it stopped.
|
||||
# No stored result: its job id (one per user) must only block a second run
|
||||
# while one is queued or running — a kept result would refuse new scans for
|
||||
# an hour after each one.
|
||||
func(backfill_list_headers_job, timeout=6 * 3600, keep_result=0),
|
||||
# Sentence completion: one per user at a time, and none kept (as above).
|
||||
func(build_index_job, timeout=1800, keep_result=0),
|
||||
]
|
||||
|
||||
+30
-1
@@ -71,6 +71,8 @@ x-backend-env: &backend-env
|
||||
RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD}
|
||||
# Spelling and grammar check in the compose window. Empty turns it off.
|
||||
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}
|
||||
UNDO_SECONDS: ${UNDO_SECONDS:-10}
|
||||
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
|
||||
@@ -109,12 +111,13 @@ services:
|
||||
- |
|
||||
set -e
|
||||
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
|
||||
# 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/backups)" = "999" ] || chown -R 999:999 /mnt/backups
|
||||
[ "$$(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
|
||||
echo "[init] data directories ready"
|
||||
volumes:
|
||||
@@ -313,6 +316,32 @@ services:
|
||||
cpus: ${LANGUAGETOOL_CPUS:-1.0}
|
||||
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
|
||||
# (§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
|
||||
|
||||
@@ -158,6 +158,24 @@ services:
|
||||
retries: 12
|
||||
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:
|
||||
image: greenmail/standalone:2.0.1
|
||||
environment:
|
||||
@@ -170,6 +188,7 @@ services:
|
||||
- "127.0.0.1:3993:3993" # IMAPS
|
||||
|
||||
volumes:
|
||||
ltmodels:
|
||||
pgdata:
|
||||
meilidata:
|
||||
blobs:
|
||||
|
||||
@@ -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.
|
||||
Generated
+14
@@ -8,9 +8,11 @@
|
||||
"name": "webmail-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "~1.2.1",
|
||||
"@tanstack/react-query": "~5.51.0",
|
||||
"@tanstack/react-virtual": "~3.8.0",
|
||||
"docx-preview": "^0.4.0",
|
||||
"emoji-mart": "~5.6.0",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"qrcode.react": "~4.1.0",
|
||||
@@ -524,6 +526,12 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -3457,6 +3465,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
"gen:api": "openapi-typescript http://127.0.0.1:8000/openapi.json -o src/api/schema.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "~1.2.1",
|
||||
"@tanstack/react-query": "~5.51.0",
|
||||
"@tanstack/react-virtual": "~3.8.0",
|
||||
"docx-preview": "^0.4.0",
|
||||
"emoji-mart": "~5.6.0",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"qrcode.react": "~4.1.0",
|
||||
|
||||
+32
-4
@@ -1,11 +1,11 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { OfflineController } from "./components/Offline";
|
||||
import { PushController } from "./components/PushController";
|
||||
import { useT } from "./i18n";
|
||||
import { isOfflineError } from "./lib/api";
|
||||
import { detectLanguage, supportedLanguage, useLocale, useT } from "./i18n";
|
||||
import { api, isOfflineError } from "./lib/api";
|
||||
import { useMe } from "./lib/auth";
|
||||
import { connectWs } from "./lib/ws";
|
||||
import { PluginPage } from "./plugins/PluginPage";
|
||||
@@ -17,6 +17,33 @@ import { Register } from "./routes/Register";
|
||||
import { ResetPassword } from "./routes/ResetPassword";
|
||||
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 }) {
|
||||
const { data, isLoading, isError, error, refetch } = useMe();
|
||||
const t = useT();
|
||||
@@ -29,7 +56,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
}, [data, queryClient]);
|
||||
|
||||
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)) {
|
||||
// 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 (
|
||||
<>
|
||||
<LanguageSync />
|
||||
<OfflineController />
|
||||
<PushController />
|
||||
{children}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { useMe } from "../lib/auth";
|
||||
import type { AdminMail, AdminUser } from "../lib/types";
|
||||
import { useConfirm } from "../components/AppDialog";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
@@ -519,6 +520,7 @@ function InvitationEmailDialog({ onClose }: { onClose: () => void }) {
|
||||
* placeholders; previewed with sample names, and sent as a test. */
|
||||
function InvitationEmail() {
|
||||
const t = useT();
|
||||
const [confirm, confirmDialog] = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const tpl = useQuery({ queryKey: ["adminInviteTemplate"], queryFn: api.adminInviteTemplate });
|
||||
// Sender and subject are drafts until Save; null shows the server's value.
|
||||
@@ -566,6 +568,7 @@ function InvitationEmail() {
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{confirmDialog}
|
||||
<h2 className="text-lg font-semibold">{t("admin.inviteMail")}</h2>
|
||||
<p className="text-xs text-app-faint">{t("admin.inviteMailHelp")}</p>
|
||||
<form
|
||||
@@ -634,7 +637,14 @@ function InvitationEmail() {
|
||||
type="button"
|
||||
className="rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
|
||||
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")}
|
||||
</button>
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
>
|
||||
<Composer init={win.init} onClose={() => onClose(win.id)} fullWindow />
|
||||
<Composer init={win.init} snapshotKey={win.id} onClose={() => onClose(win.id)} fullWindow />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -162,6 +162,7 @@ export function ComposeDock({
|
||||
>
|
||||
<Composer
|
||||
init={win.init}
|
||||
snapshotKey={win.id}
|
||||
onTitle={(s) => noteTitle(win.id, s)}
|
||||
minimized={!open}
|
||||
expanded={open && win.expanded}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
@@ -16,11 +16,14 @@ import {
|
||||
putLocal,
|
||||
uploadFiles,
|
||||
} 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 { RecipientChips } from "./RecipientChips";
|
||||
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
||||
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
|
||||
|
||||
@@ -48,6 +51,9 @@ export interface ComposerInit {
|
||||
localState?: LocalState; // "scheduled" when reopened from Scheduled
|
||||
attachments?: AttachmentOut[]; // known already: no need to ask the server
|
||||
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
|
||||
@@ -161,6 +167,7 @@ const caret = (
|
||||
|
||||
export function Composer({
|
||||
init,
|
||||
snapshotKey,
|
||||
onClose,
|
||||
expanded = false,
|
||||
onToggleExpand,
|
||||
@@ -171,6 +178,9 @@ export function Composer({
|
||||
fullWindow = false,
|
||||
}: {
|
||||
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;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
@@ -211,9 +221,21 @@ export function Composer({
|
||||
const [spellLang, setSpellLang] = useState<string | null>(null);
|
||||
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
|
||||
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(() => {
|
||||
if (!moreOpen) setSpellMenuOpen(false);
|
||||
if (!moreOpen) {
|
||||
setSpellMenuOpen(false);
|
||||
setTemplatesOpen(false);
|
||||
}
|
||||
}, [moreOpen]);
|
||||
// What the engine can check — and whether this server has one at all.
|
||||
const spellLanguages = useQuery({
|
||||
@@ -250,6 +272,9 @@ export function Composer({
|
||||
onTitleRef.current?.(subject);
|
||||
}, [subject]);
|
||||
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).
|
||||
const localId = useRef<string | null>(init.localId ?? null);
|
||||
const discardedRef = useRef(false);
|
||||
@@ -258,7 +283,7 @@ export function Composer({
|
||||
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
|
||||
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
|
||||
const dirtyRef = useRef(false);
|
||||
const dirtyRef = useRef(Boolean(init.restoredDirty));
|
||||
const markDirty = () => {
|
||||
dirtyRef.current = true;
|
||||
};
|
||||
@@ -278,6 +303,41 @@ export function Composer({
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [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 fromName = account?.display_name || "";
|
||||
const fromEmail = account?.email ?? "";
|
||||
@@ -533,7 +593,11 @@ export function Composer({
|
||||
setLocalFiles([]);
|
||||
}
|
||||
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) {
|
||||
await deleteLocal(localId.current);
|
||||
localId.current = null;
|
||||
@@ -586,8 +650,44 @@ export function Composer({
|
||||
const saveLocalRef = useRef(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(
|
||||
() => () => {
|
||||
keepSnapshotRef.current(); // leaving the mail view (Settings…): back as it was
|
||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||
// 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
|
||||
@@ -773,6 +873,24 @@ export function Composer({
|
||||
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
|
||||
</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 && (
|
||||
<div
|
||||
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")}
|
||||
</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 && (() => {
|
||||
// The language in force: this message's choice, else Settings.
|
||||
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 { api, ApiError } from "../../lib/api";
|
||||
import { notify } from "../AppDialog";
|
||||
import { EmojiButton } from "./EmojiButton";
|
||||
import {
|
||||
GHOST,
|
||||
acceptGhost,
|
||||
caretContext,
|
||||
ghostEl,
|
||||
removeGhost,
|
||||
showGhost,
|
||||
typedIntoGhost,
|
||||
} from "./completion";
|
||||
import {
|
||||
canHighlight,
|
||||
clearPaint,
|
||||
@@ -94,9 +105,10 @@ function addQuoteToggle(el: HTMLElement, title: 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;
|
||||
copy.querySelectorAll(`.${QUOTE_TOGGLE}`).forEach((n) => n.remove());
|
||||
copy.querySelectorAll(`.${QUOTE_TOGGLE}, .${GHOST}`).forEach((n) => n.remove());
|
||||
return copy.innerHTML;
|
||||
}
|
||||
|
||||
@@ -247,6 +259,70 @@ export const RichTextEditor = forwardRef<
|
||||
const [spellPos, setSpellPos] = useState<{ left: number; top: number } | null>(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.
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
|
||||
@@ -274,6 +350,7 @@ export const RichTextEditor = forwardRef<
|
||||
replaceSignature: (html: string) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
removeGhost(el);
|
||||
const sig = el.querySelector(".wmc-signature");
|
||||
if (html) {
|
||||
if (sig) sig.innerHTML = html;
|
||||
@@ -285,6 +362,7 @@ export const RichTextEditor = forwardRef<
|
||||
insertSignature: (html: string) => {
|
||||
const el = ref.current;
|
||||
if (!el || !html) return;
|
||||
removeGhost(el);
|
||||
el.querySelector(".wmc-signature")?.remove();
|
||||
const spacerBefore = document.createElement("div");
|
||||
spacerBefore.innerHTML = "<br>";
|
||||
@@ -304,6 +382,7 @@ export const RichTextEditor = forwardRef<
|
||||
insertHtml: (html: string) => {
|
||||
const el = ref.current;
|
||||
if (!el || !html) return;
|
||||
removeGhost(el);
|
||||
el.focus();
|
||||
// Insert at the current caret; if that fails (no selection in the editor),
|
||||
// append before the signature/quoted block.
|
||||
@@ -430,6 +509,7 @@ export const RichTextEditor = forwardRef<
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string) => {
|
||||
dropGhost();
|
||||
ref.current?.focus();
|
||||
document.execCommand(command, false, value);
|
||||
emit();
|
||||
@@ -481,7 +561,7 @@ export const RichTextEditor = forwardRef<
|
||||
|
||||
const insertImage = (file: File) => {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
window.alert(t("compose.imageTooLarge"));
|
||||
notify(t("compose.imageTooLarge"), 5000);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
@@ -771,6 +851,23 @@ export const RichTextEditor = forwardRef<
|
||||
};
|
||||
|
||||
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
|
||||
// here: the mail view's own Escape would otherwise minimise the whole window.
|
||||
if (spellIssue) {
|
||||
@@ -926,6 +1023,15 @@ export const RichTextEditor = forwardRef<
|
||||
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 />
|
||||
<Btn
|
||||
onClick={() => exec("justifyLeft")}
|
||||
@@ -964,6 +1070,7 @@ export const RichTextEditor = forwardRef<
|
||||
onInput={(e) => {
|
||||
onInput(contentHtml(e.currentTarget));
|
||||
scheduleSpellcheck();
|
||||
scheduleCompletion();
|
||||
}}
|
||||
// Our own checker replaces the browser's, rather than doubling it up.
|
||||
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.
|
||||
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)$/;
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSavedThreads } from "../../lib/offline";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
import { Clock } from "../Offline";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
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" });
|
||||
}
|
||||
|
||||
// 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.
|
||||
function initials(p: Address | undefined): string {
|
||||
const name = (p?.name || p?.email || "?").trim();
|
||||
@@ -354,7 +366,7 @@ export function MessageList({
|
||||
unread ? "font-bold" : ""
|
||||
}`}
|
||||
>
|
||||
{timeLabel(thread.last_message_at)}
|
||||
<RowDate thread={thread} />
|
||||
</span>
|
||||
</span>
|
||||
{/* A narrow desktop list: the actions take the date's place on hover. */}
|
||||
@@ -505,7 +517,7 @@ export function MessageList({
|
||||
unread ? "font-bold" : ""
|
||||
}`}
|
||||
>
|
||||
{timeLabel(thread.last_message_at)}
|
||||
<RowDate thread={thread} />
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { translate } from "../../i18n";
|
||||
|
||||
// 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
|
||||
// clickable ✕ (used in the reading pane to drop a label from the thread).
|
||||
@@ -23,7 +25,7 @@ export function LabelChips({
|
||||
{onRemove && (
|
||||
<button
|
||||
className="text-app-faint hover:text-app-fg"
|
||||
title="Remove label"
|
||||
title={translate("labels.remove")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(l);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { api, apiFetch } from "../../lib/api";
|
||||
import type { AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
import { useIsMobile } from "../../lib/useIsMobile";
|
||||
import { getDateLocale } from "../../lib/timeFormat";
|
||||
import { loadBundle } from "../../plugins/bundleLoader";
|
||||
import { usePluginContext } from "../../plugins/context";
|
||||
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>;
|
||||
|
||||
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 (
|
||||
<div className="px-3 py-2">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 { useT } from "../../i18n";
|
||||
import { languageName, sameLanguage, splitLanguages, useLocale, useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
|
||||
import { fixInvisibleText } from "../../lib/readability";
|
||||
@@ -19,11 +19,20 @@ import { Attachments } from "./Attachments";
|
||||
export function MessageBody({
|
||||
message,
|
||||
onMailto,
|
||||
translateTo = null,
|
||||
onLanguage,
|
||||
onShowOriginal,
|
||||
}: {
|
||||
message: MessageEnvelope;
|
||||
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 { locale } = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
// Seed from the server's trusted-sender flag so an already-approved sender
|
||||
// loads images and shows no "blocked" prompt on reopen.
|
||||
@@ -42,18 +51,52 @@ export function MessageBody({
|
||||
const mobileRef = useRef(false);
|
||||
mobileRef.current = useIsMobile();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed],
|
||||
const { data, isLoading, isError, error, isPlaceholderData } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed, translateTo],
|
||||
queryFn: async () => {
|
||||
// ?images=1 asks the server to render remote images for this one request
|
||||
// (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" });
|
||||
if (resp.headers.has("X-WM-Offline") && !resp.ok) throw new Error("offline");
|
||||
if (!resp.ok) throw new Error("failed to load message");
|
||||
return resp.text();
|
||||
if (!resp.ok) {
|
||||
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 frame = frameRef.current;
|
||||
@@ -247,6 +290,14 @@ export function MessageBody({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{translating && (
|
||||
<div
|
||||
className="wm-progress"
|
||||
role="progressbar"
|
||||
aria-label={t("mail.translating")}
|
||||
title={t("mail.translating")}
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
<span>{t("mail.imagesBlocked")}</span>
|
||||
@@ -258,8 +309,37 @@ export function MessageBody({
|
||||
</button>
|
||||
</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>}
|
||||
{isError && (
|
||||
{isError && error.message !== "translate" && (
|
||||
<div className={`p-3 text-sm ${error.message === "offline" ? "text-app-faint" : "text-red-500"}`}>
|
||||
{error.message === "offline" ? t("offline.notAvailable") : t("mail.loadFailed")}
|
||||
</div>
|
||||
@@ -271,7 +351,7 @@ export function MessageBody({
|
||||
ref={frameRef}
|
||||
title={`message-${message.id}`}
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={dark ? withDarkPage(data) : data}
|
||||
srcDoc={dark ? withDarkPage(data.html) : data.html}
|
||||
onLoad={onLoad}
|
||||
style={{ height }}
|
||||
className={`block w-full rounded-lg border-0 ${dark ? "bg-[#212427]" : "bg-white"}`}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { setDarkMessages, useDarkMessages } from "../../lib/darkMessage";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -17,6 +18,7 @@ import { Icon } from "../Icon";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
import { clearPdfPasswordCache } from "./AttachmentPreview";
|
||||
import { LabelChips } from "../mail/LabelChips";
|
||||
import { useSubscriptionActions } from "../list/SubscriptionsList";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
|
||||
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
||||
@@ -54,13 +56,13 @@ function attributionFor(msg: MessageEnvelope): string {
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = d.toLocaleTimeString("en-US", {
|
||||
const time = d.toLocaleTimeString(getDateLocale(), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: getHour12(),
|
||||
});
|
||||
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:
|
||||
@@ -137,16 +139,16 @@ function hue(msg: MessageEnvelope): number {
|
||||
|
||||
function fmtRelative(iso: string): string {
|
||||
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 mins = Math.round(diff / 60000);
|
||||
const hrs = Math.round(diff / 3600000);
|
||||
const days = Math.round(diff / 86400000);
|
||||
let rel = "";
|
||||
if (mins < 1) rel = "just now";
|
||||
else if (mins < 60) rel = `${mins} min ago`;
|
||||
else if (hrs < 24) rel = `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
|
||||
else if (days < 30) rel = `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
if (mins < 1) rel = translate("time.justNow");
|
||||
else if (mins < 60) rel = translate("time.minAgo", { count: mins });
|
||||
else if (hrs < 24) rel = translate("time.hoursAgo", { count: hrs });
|
||||
else if (days < 30) rel = translate("time.daysAgo", { count: days });
|
||||
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 {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const LOCATION_LABEL: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
sent: "Sent",
|
||||
archive: "Archive",
|
||||
drafts: "Drafts",
|
||||
trash: "Trash",
|
||||
junk: "Spam",
|
||||
// A message's folder, named as in the sidebar (in the interface language).
|
||||
const LOCATION_LABEL: Record<string, MessageKey> = {
|
||||
inbox: "nav.inbox",
|
||||
sent: "nav.sent",
|
||||
archive: "nav.archive",
|
||||
drafts: "nav.drafts",
|
||||
trash: "nav.trash",
|
||||
junk: "nav.spam",
|
||||
};
|
||||
|
||||
function ActionsMenu({
|
||||
messageId,
|
||||
mailboxId,
|
||||
onReply,
|
||||
onPrint,
|
||||
onDelete,
|
||||
@@ -187,8 +222,13 @@ function ActionsMenu({
|
||||
messageLabels,
|
||||
onToggleLabel,
|
||||
onCreateLabel,
|
||||
translateLanguages,
|
||||
translateTo,
|
||||
onTranslateTo,
|
||||
}: {
|
||||
messageId: string;
|
||||
/** The message's folder: a move gives plugin items a fresh start. */
|
||||
mailboxId: string;
|
||||
onReply: (kind: string) => void;
|
||||
onPrint: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -197,10 +237,16 @@ function ActionsMenu({
|
||||
messageLabels: string[];
|
||||
onToggleLabel: (label: 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 { locale } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
const [translateOpen, setTranslateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -220,7 +266,7 @@ function ActionsMenu({
|
||||
};
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); }
|
||||
if (!open) { setLabelsOpen(false); setTranslateOpen(false); setCreating(false); setNewName(""); }
|
||||
}, [open]);
|
||||
|
||||
const item = (label: string, onClick: () => void, disabled = false) => (
|
||||
@@ -249,79 +295,121 @@ function ActionsMenu({
|
||||
{t("mail.actions")}
|
||||
<span className="text-[11px]">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<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">
|
||||
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
|
||||
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
|
||||
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
|
||||
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
|
||||
{item(t("mail.print"), onPrint)}
|
||||
<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
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-app-accent"
|
||||
checked={messageLabels.includes(l)}
|
||||
onChange={() => onToggleLabel(l)}
|
||||
/>
|
||||
<span className="truncate">{l}</span>
|
||||
</label>
|
||||
))}
|
||||
{allLabels.length === 0 && (
|
||||
<div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div>
|
||||
)}
|
||||
{creating ? (
|
||||
{/* Always rendered, hidden while closed: plugin items (below) load with
|
||||
the message, not when the menu opens — on a slow connection their
|
||||
fetch would otherwise hold them back for seconds. */}
|
||||
<div
|
||||
hidden={!open}
|
||||
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.reply"), () => onReply("reply"), replyDisabled)}
|
||||
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
|
||||
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
|
||||
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
|
||||
{item(t("mail.print"), onPrint)}
|
||||
{translateLanguages.length > 0 && (
|
||||
<>
|
||||
{/* Translate to…: any language the server has; ✓ the one shown. */}
|
||||
<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={() => setTranslateOpen((v) => !v)}
|
||||
>
|
||||
{t("mail.translateTo")}
|
||||
<span className="text-[11px] text-app-muted">{translateOpen ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{translateOpen && (
|
||||
<div className="max-h-56 overflow-auto border-y border-app-border bg-app-surface/40">
|
||||
{translateTo &&
|
||||
item(t("mail.showOriginal"), () => onTranslateTo(null))}
|
||||
{translateLanguages
|
||||
.map((code) => ({ code, name: languageName(code, locale) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, locale))
|
||||
.map(({ code, name }) => (
|
||||
<button
|
||||
key={code}
|
||||
className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
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
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
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"
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-app-accent"
|
||||
checked={messageLabels.includes(l)}
|
||||
onChange={() => onToggleLabel(l)}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{/* 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 name="message.actions.menu" props={{ messageId }} />
|
||||
<span className="truncate">{l}</span>
|
||||
</label>
|
||||
))}
|
||||
{allLabels.length === 0 && (
|
||||
<div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div>
|
||||
)}
|
||||
{creating ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<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 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 className="my-1 border-t border-app-border" />
|
||||
{item(t("mail.delete"), onDelete)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -374,7 +462,24 @@ function MessageItem({
|
||||
ownAddresses: Set<string>;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { locale } = useLocale();
|
||||
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) => (
|
||||
<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"
|
||||
@@ -435,19 +540,30 @@ function MessageItem({
|
||||
<div className="flex items-start gap-3 px-4 pb-2 pt-3">
|
||||
{avatar()}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* line 1: sender + email ············· date (whole row collapses) */}
|
||||
<button
|
||||
className="flex w-full items-baseline justify-between gap-2 text-left"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="min-w-0 truncate text-[15px]">
|
||||
{/* line 1: sender + email · Unsubscribe ··········· date (the row collapses) */}
|
||||
<div className="flex w-full items-baseline gap-2">
|
||||
<button className="min-w-0 truncate text-left text-[15px]" onClick={onToggle}>
|
||||
{senderName}
|
||||
{message.from_addr[0]?.email && (
|
||||
<span className="text-app-faint"> <{message.from_addr[0].email}></span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
|
||||
</button>
|
||||
</button>
|
||||
{message.subscription_key && message.unsubscribe_via && (
|
||||
<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 */}
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
@@ -485,8 +601,24 @@ function MessageItem({
|
||||
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
|
||||
</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
|
||||
messageId={message.id}
|
||||
mailboxId={message.mailbox_id}
|
||||
translateLanguages={translateCodes}
|
||||
translateTo={translateTo}
|
||||
onTranslateTo={setTranslateTo}
|
||||
onReply={onReply}
|
||||
onPrint={onPrint}
|
||||
onDelete={onDelete}
|
||||
@@ -533,7 +665,13 @@ function MessageItem({
|
||||
props={{ messageId: message.id, hasAttachments: message.has_attachments }}
|
||||
/>
|
||||
<div className="px-4 pb-3">
|
||||
<MessageBody message={message} onMailto={onMailto} />
|
||||
<MessageBody
|
||||
message={message}
|
||||
onMailto={onMailto}
|
||||
translateTo={translateTo}
|
||||
onLanguage={setLanguage}
|
||||
onShowOriginal={() => setTranslateTo(null)}
|
||||
/>
|
||||
</div>
|
||||
{showReplyBar && canReply && (
|
||||
<div className="flex gap-2 border-t border-app-border px-4 py-3">
|
||||
@@ -658,7 +796,8 @@ export function ThreadView({
|
||||
}, [mailboxes]);
|
||||
const locationFor = (msg: MessageEnvelope) => {
|
||||
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) =>
|
||||
(mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id;
|
||||
|
||||
+169
-3
@@ -1,6 +1,9 @@
|
||||
// English message catalog. Adding a locale = drop a sibling file with the same
|
||||
// keys and register it in i18n/index.tsx. Keys are flat, dotted namespaces.
|
||||
// Interpolate with {name} placeholders.
|
||||
// English message catalog — the main language and the reference: other
|
||||
// language files (pl.ts, …) use these keys, and any string a language lacks is
|
||||
// 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 = {
|
||||
"common.appName": "Ultimo Email",
|
||||
@@ -79,12 +82,74 @@ export const en = {
|
||||
"nav.sent": "Sent",
|
||||
"nav.spam": "Spam",
|
||||
"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.searchInTrash": "Search in Trash",
|
||||
"nav.searchInSpam": "Search in Spam",
|
||||
"search.save": "Save this search",
|
||||
"search.saved": "Saved searches",
|
||||
"search.saveNamePrompt": "Name this saved search:",
|
||||
"search.saveNameLabel": "Name",
|
||||
"nav.storageQuota": "Storage used",
|
||||
"nav.refresh": "Refresh all folders",
|
||||
|
||||
@@ -187,10 +252,14 @@ export const en = {
|
||||
"panel.noAttachments": "No attachments",
|
||||
"panel.download": "Download",
|
||||
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
|
||||
"mail.emptyAction": "Delete everything",
|
||||
"mail.movedToTrash": "Moved to Trash",
|
||||
"mail.loadFailed": "Couldn't load this message.",
|
||||
"mail.imagesBlocked": "Images from this sender are blocked.",
|
||||
"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.to": "to",
|
||||
"mail.from": "From",
|
||||
@@ -256,6 +325,24 @@ export const en = {
|
||||
"compose.fmtHighlight": "Highlight color",
|
||||
"compose.fmtImage": "Insert image",
|
||||
"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.linkOpen": "Open the link:",
|
||||
"compose.linkEdit": "Edit",
|
||||
@@ -498,6 +585,40 @@ export const en = {
|
||||
"settings.addToCalendar": "Add to calendar",
|
||||
"settings.alsoFile": "Also mark the message as read and file it",
|
||||
"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.signInTitle": "Administrator sign-in",
|
||||
@@ -530,6 +651,7 @@ export const en = {
|
||||
"admin.inviteDownload": "Download template",
|
||||
"admin.inviteReset": "Back to the built-in template",
|
||||
"admin.inviteResetConfirm": "Remove your template and use the built-in one again?",
|
||||
"admin.inviteResetAction": "Use built-in template",
|
||||
"admin.invitePlaceholders": "Placeholders:",
|
||||
"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.",
|
||||
@@ -595,6 +717,8 @@ export const en = {
|
||||
"settings.addAccount": "Add account",
|
||||
"settings.syncState": "State: {state}",
|
||||
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
||||
"settings.removeAccountTitle": "Remove account",
|
||||
"settings.removeAccountAction": "Remove account",
|
||||
"settings.removingAccount": "Removing account…",
|
||||
"settings.editAccount": "Edit account",
|
||||
"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.hide": "Don't show this again",
|
||||
"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;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
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
@@ -1,15 +1,82 @@
|
||||
// Minimal, dependency-free i18n. English today; add a locale by registering a
|
||||
// catalog with the same keys. Strings are never hard-coded in components — they
|
||||
// go through t().
|
||||
// Minimal, dependency-free i18n. One file per language (en.ts, pl.ts, …), each
|
||||
// with the same keys; register a new one in LANGUAGES below. Strings are never
|
||||
// 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 { 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> = {
|
||||
en,
|
||||
// pl, <- add here to enable Polish, etc.
|
||||
};
|
||||
/** The interface languages, each named in its own language for the picker. */
|
||||
export const LANGUAGES: { code: string; name: string; catalog: Catalog }[] = [
|
||||
{ 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 TFunc = (key: MessageKey, vars?: Vars) => string;
|
||||
@@ -30,29 +97,58 @@ function interpolate(template: string, vars?: Vars): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocale] = useState<string>(() => localStorage.getItem("locale") ?? "en");
|
||||
function makeT(locale: string): TFunc {
|
||||
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>(() => {
|
||||
const catalog = catalogs[locale] ?? en;
|
||||
// A count of exactly one picks the "<key>_one" form where there is one
|
||||
// ("1 message", not "1 messages").
|
||||
const lookup = (key: string): string | undefined =>
|
||||
(catalog as Record<string, string>)[key] ?? (en as Record<string, string>)[key];
|
||||
const t: TFunc = (key, vars) =>
|
||||
interpolate(
|
||||
(vars?.count === 1 ? lookup(`${key}_one`) : undefined) ?? lookup(key) ?? key,
|
||||
vars,
|
||||
);
|
||||
return {
|
||||
// The current t() for code outside React (lib helpers, toasts).
|
||||
let currentT: TFunc = makeT(initialLanguage());
|
||||
export function translate(key: MessageKey, vars?: Vars): string {
|
||||
return currentT(key, vars);
|
||||
}
|
||||
|
||||
function apply(locale: string): void {
|
||||
currentT = makeT(locale);
|
||||
setDateLanguage(locale);
|
||||
document.documentElement.lang = locale;
|
||||
try {
|
||||
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,
|
||||
setLocale: (l) => {
|
||||
localStorage.setItem("locale", l);
|
||||
setLocale(l);
|
||||
const code = supportedLanguage(l) ?? "en";
|
||||
apply(code);
|
||||
setLocaleState(code);
|
||||
},
|
||||
t,
|
||||
};
|
||||
}, [locale]);
|
||||
t: makeT(locale),
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
@@ -129,6 +129,23 @@ body {
|
||||
.wmc-editor:empty::before {
|
||||
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 {
|
||||
color: rgb(var(--app-accent));
|
||||
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
|
||||
three fit in a row; their name bar adapts to the tile's own width. */
|
||||
.wm-tile {
|
||||
|
||||
@@ -23,6 +23,9 @@ import type {
|
||||
RegistrationStatus,
|
||||
RemoteDraftOut,
|
||||
SavedSearch,
|
||||
MailTemplate,
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
SearchOut,
|
||||
SendResponse,
|
||||
ServerConfig,
|
||||
@@ -294,11 +297,22 @@ export const api = {
|
||||
| "invitation_file"
|
||||
| "spellcheck_enabled"
|
||||
| "spellcheck_language"
|
||||
| "shortcuts_enabled"
|
||||
| "block_trackers"
|
||||
| "completion_enabled"
|
||||
| "ui_language"
|
||||
>
|
||||
>,
|
||||
) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
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) =>
|
||||
apiFetch<SpellCheckOut>("/spellcheck", {
|
||||
method: "POST",
|
||||
@@ -356,6 +370,47 @@ export const api = {
|
||||
deleteSavedSearch: (id: string) =>
|
||||
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 ---
|
||||
suggestRecipients: (q: string) =>
|
||||
apiFetch<RecipientSuggestion[]>(`/recipients/suggest?q=${encodeURIComponent(q)}`),
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { api } from "./api";
|
||||
import { getDateLocale, getHour12 } from "./timeFormat";
|
||||
import type { Address, AttachmentOut, MessageEnvelope } from "./types";
|
||||
import { notify } from "../components/AppDialog";
|
||||
import { translate } from "../i18n";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
@@ -71,18 +73,18 @@ function metaRow(label: string, value: string): string {
|
||||
|
||||
function section(msg: MessageEnvelope, subject: string, body: string, atts: AttachmentOut[]): string {
|
||||
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)})`)
|
||||
.join(", ")}</td></tr>`
|
||||
: "";
|
||||
return `<section class="wmc-print-msg">
|
||||
<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">
|
||||
${metaRow("From", fmtList(msg.from_addr))}
|
||||
${metaRow("To", fmtList(msg.to_addr))}
|
||||
${metaRow("Cc", fmtList(msg.cc_addr))}
|
||||
${metaRow("Date", fmtDate(msg.date))}
|
||||
${metaRow(translate("mail.from"), fmtList(msg.from_addr))}
|
||||
${metaRow(translate("compose.to"), fmtList(msg.to_addr))}
|
||||
${metaRow(translate("compose.cc"), fmtList(msg.cc_addr))}
|
||||
${metaRow(translate("mail.dateLabel"), fmtDate(msg.date))}
|
||||
${attachLine}
|
||||
</table>
|
||||
</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.
|
||||
const win = window.open("", "_blank", "width=820,height=1000");
|
||||
if (!win) {
|
||||
window.alert("Please allow pop-ups for this site to print.");
|
||||
notify(translate("print.popupBlocked"), 6000);
|
||||
return;
|
||||
}
|
||||
const title = esc(subject || "(no subject)");
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -27,11 +27,19 @@ export function getHour12(): boolean {
|
||||
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
|
||||
* (day/month/year, European), 12-hour → en-US (month/day/year, American).
|
||||
* Locale for date formatting. In English it follows the time preference:
|
||||
* 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 {
|
||||
if (dateLanguage === "pl") return "pl-PL";
|
||||
return hour12 ? "en-US" : "en-GB";
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface ThreadSummary {
|
||||
has_attachments: boolean;
|
||||
pinned: boolean;
|
||||
last_message_at: string;
|
||||
/** While snoozed: when it comes back to the Inbox. */
|
||||
snoozed_until?: string | null;
|
||||
}
|
||||
|
||||
export interface ThreadListOut {
|
||||
@@ -126,6 +128,12 @@ export interface MessageEnvelope {
|
||||
size_bytes: number;
|
||||
snippet: 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 {
|
||||
@@ -134,6 +142,8 @@ export interface ThreadDetail {
|
||||
order: "asc" | "desc";
|
||||
pinned: boolean;
|
||||
messages: MessageEnvelope[];
|
||||
/** While snoozed: when it comes back to the Inbox. */
|
||||
snoozed_until?: string | null;
|
||||
}
|
||||
|
||||
export type SyncWindow = "all" | "3months" | "new";
|
||||
@@ -284,6 +294,14 @@ export interface UserSettings {
|
||||
spellcheck_enabled: boolean;
|
||||
/** "auto", or a LanguageTool code such as "pl-PL". */
|
||||
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. */
|
||||
@@ -310,9 +328,48 @@ export interface QuotaOut {
|
||||
|
||||
export interface FolderCounts {
|
||||
inbox_unread: number;
|
||||
snoozed?: 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Keyboard shortcuts (SPEC §9.4). Ignores keystrokes while typing in inputs,
|
||||
// except Escape. Supports the `g i` chord (go to inbox).
|
||||
// Keyboard shortcuts (SPEC §9.4; the list users see is lib/shortcuts.ts).
|
||||
// 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 {
|
||||
enabled?: boolean;
|
||||
onSearch?: () => void;
|
||||
onCompose?: () => void;
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onArchive?: () => void;
|
||||
onTrash?: () => void;
|
||||
onTogglePin?: () => void;
|
||||
onSnooze?: () => void;
|
||||
onMarkRead?: () => void;
|
||||
onMarkUnread?: () => void;
|
||||
onBack?: () => void;
|
||||
onGoInbox?: () => void;
|
||||
onGoSnoozed?: () => void;
|
||||
onGoDrafts?: () => void;
|
||||
onGoSent?: () => void;
|
||||
onGoSubscriptions?: () => void;
|
||||
onHelp?: () => void;
|
||||
onEscape?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
@@ -18,7 +31,7 @@ export interface Shortcuts {
|
||||
function isTyping(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
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 {
|
||||
@@ -33,49 +46,54 @@ export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
||||
s.onEscape?.();
|
||||
return;
|
||||
}
|
||||
if (isTyping(e.target)) return;
|
||||
if (s.enabled === false || isTyping(e.target)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const run = (fn: (() => void) | undefined) => {
|
||||
if (!fn) return;
|
||||
e.preventDefault();
|
||||
fn();
|
||||
};
|
||||
|
||||
if (pendingG.current) {
|
||||
pendingG.current = false;
|
||||
if (e.key === "i") {
|
||||
e.preventDefault();
|
||||
s.onGoInbox?.();
|
||||
const go: Record<string, (() => void) | undefined> = {
|
||||
i: s.onGoInbox,
|
||||
z: s.onGoSnoozed,
|
||||
d: s.onGoDrafts,
|
||||
t: s.onGoSent,
|
||||
b: s.onGoSubscriptions,
|
||||
};
|
||||
if (e.key in go) {
|
||||
run(go[e.key]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
s.onSearch?.();
|
||||
break;
|
||||
case "c":
|
||||
s.onCompose?.();
|
||||
break;
|
||||
case "r":
|
||||
s.onReply?.();
|
||||
break;
|
||||
case "a":
|
||||
s.onReplyAll?.();
|
||||
break;
|
||||
case "f":
|
||||
s.onForward?.();
|
||||
break;
|
||||
case "e":
|
||||
s.onArchive?.();
|
||||
break;
|
||||
case "j":
|
||||
s.onNext?.();
|
||||
break;
|
||||
case "k":
|
||||
s.onPrev?.();
|
||||
break;
|
||||
case "g":
|
||||
pendingG.current = true;
|
||||
window.setTimeout(() => (pendingG.current = false), 800);
|
||||
break;
|
||||
// Shift+letter arrives as the capital letter.
|
||||
const actions: Record<string, (() => void) | undefined> = {
|
||||
"/": s.onSearch,
|
||||
"?": s.onHelp,
|
||||
c: s.onCompose,
|
||||
r: s.onReply,
|
||||
a: s.onReplyAll,
|
||||
f: s.onForward,
|
||||
e: s.onArchive,
|
||||
"#": s.onTrash,
|
||||
s: s.onTogglePin,
|
||||
b: s.onSnooze,
|
||||
I: s.onMarkRead,
|
||||
U: s.onMarkUnread,
|
||||
u: s.onBack,
|
||||
j: s.onNext,
|
||||
k: s.onPrev,
|
||||
};
|
||||
if (e.key === "g") {
|
||||
pendingG.current = true;
|
||||
window.setTimeout(() => (pendingG.current = false), 1000);
|
||||
return;
|
||||
}
|
||||
if (e.key in actions) run(actions[e.key]);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
|
||||
@@ -38,6 +38,17 @@ import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
||||
import { RightPanel, type CalendarFocus, type PanelTab } from "../components/mail/RightPanel";
|
||||
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 type { MessageKey } from "../i18n/en";
|
||||
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".
|
||||
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
|
||||
{ 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:drafts", icon: "drafts", label: "nav.drafts" },
|
||||
{ view: "folder:sent", icon: "sent", label: "nav.sent" },
|
||||
{ view: "folder:junk", icon: "spam", label: "nav.spam" },
|
||||
{ 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
|
||||
@@ -168,8 +181,15 @@ export function MailLayout() {
|
||||
});
|
||||
// Every open compose window, oldest first — the dock lays them out left to
|
||||
// right and keeps each one mounted for its whole life.
|
||||
const [composeWins, setComposeWins] = useState<ComposeWin[]>([]);
|
||||
const composeSeq = useRef(0);
|
||||
// Brought back from before Settings (or a reload) — compose/restore.ts.
|
||||
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
|
||||
// way a thread is read there — not in a floating window.
|
||||
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
|
||||
// folder of ones sent offline (lib/scheduled.ts).
|
||||
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 deviceDrafts = localMessages.filter((m) => m.state === "draft");
|
||||
// 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 });
|
||||
},
|
||||
enabled: !isDrafts && !isScheduled && !searching,
|
||||
enabled: !isDrafts && !isScheduled && !isSubscriptions && !searching,
|
||||
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
||||
});
|
||||
const draftsQuery = useQuery({
|
||||
@@ -376,8 +459,12 @@ export function MailLayout() {
|
||||
pageThreads = hiddenIds.size || swipedIds.size ? all.filter((th) => !isHidden(th.id)) : all;
|
||||
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 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
|
||||
// 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.
|
||||
@@ -394,7 +481,7 @@ export function MailLayout() {
|
||||
if (searchLoaded <= wanted && searchPages < MAX_SEARCH_PAGES) void fetchMoreSearch();
|
||||
}, [searching, searchHasMore, searchFetchingMore, fetchMoreSearch, searchLoaded, searchPages, page, listOrder]);
|
||||
// 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.
|
||||
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
|
||||
@@ -439,11 +526,21 @@ export function MailLayout() {
|
||||
mutationFn: (id: string) => api.deleteSavedSearch(id),
|
||||
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();
|
||||
if (!q) return;
|
||||
const name = window.prompt(t("search.saveNamePrompt"), q);
|
||||
if (name && name.trim()) saveSearch.mutate(name.trim());
|
||||
const name = await askText({
|
||||
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) => {
|
||||
setTerm(s.query);
|
||||
@@ -626,6 +723,51 @@ export function MailLayout() {
|
||||
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
|
||||
// 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.
|
||||
const [emptying, setEmptying] = useState(false);
|
||||
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);
|
||||
try {
|
||||
await api.emptyFolder(currentFolder);
|
||||
@@ -910,14 +1058,29 @@ export function MailLayout() {
|
||||
};
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: settingsQuery.data?.shortcuts_enabled ?? true,
|
||||
onSearch: () => searchRef.current?.focus(),
|
||||
onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }),
|
||||
onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }),
|
||||
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
|
||||
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
|
||||
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"),
|
||||
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),
|
||||
onPrev: () => openRelative(-1),
|
||||
});
|
||||
@@ -998,7 +1161,7 @@ export function MailLayout() {
|
||||
}, [queryClient]);
|
||||
|
||||
// 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 (
|
||||
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
|
||||
@@ -1068,7 +1231,9 @@ export function MailLayout() {
|
||||
const badge =
|
||||
f.view === "folder:inbox"
|
||||
? 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
|
||||
: undefined;
|
||||
return (
|
||||
@@ -1079,7 +1244,15 @@ export function MailLayout() {
|
||||
title={collapsed ? t(f.label) : undefined}
|
||||
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 && badge ? (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
|
||||
@@ -1351,7 +1524,84 @@ export function MailLayout() {
|
||||
</button>
|
||||
)}
|
||||
</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
|
||||
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"
|
||||
@@ -1461,6 +1711,13 @@ export function MailLayout() {
|
||||
</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 && (
|
||||
// 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.
|
||||
@@ -1485,6 +1742,20 @@ export function MailLayout() {
|
||||
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() ? (
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
@@ -1545,10 +1816,10 @@ export function MailLayout() {
|
||||
>
|
||||
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
|
||||
</button>
|
||||
{!isDrafts && !isScheduled && total > 0 ? (
|
||||
{!isDrafts && !isScheduled && pagerTotal > 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{pageStart + 1}–{pageEnd} of {total}
|
||||
{pageStart + 1}–{pageEnd} of {pagerTotal}
|
||||
</span>
|
||||
<div className="-mr-1 flex items-center gap-0.5">
|
||||
<PagerButton
|
||||
@@ -1559,7 +1830,7 @@ export function MailLayout() {
|
||||
big={mobile}
|
||||
/>
|
||||
<PagerButton
|
||||
disabled={pageEnd >= total}
|
||||
disabled={pageEnd >= pagerTotal}
|
||||
onClick={() => setPage(page + 1)}
|
||||
label={t("pager.next")}
|
||||
dir="right"
|
||||
@@ -1614,6 +1885,15 @@ export function MailLayout() {
|
||||
onClose={closePaneDraft}
|
||||
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 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={paneBarClass}>
|
||||
@@ -1681,6 +1961,20 @@ export function MailLayout() {
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</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" : ""}`}>
|
||||
@@ -1746,6 +2040,13 @@ export function MailLayout() {
|
||||
<NotSavedOffline />
|
||||
</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
|
||||
// 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} />
|
||||
<Toast mobile={mobile} />
|
||||
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
|
||||
{confirmDialog}
|
||||
{promptDialog}
|
||||
{subBulk.dialog}
|
||||
{subBulk.finishOnPage.length > 0 && (
|
||||
<FinishOnPageDialog items={subBulk.finishOnPage} onClose={subBulk.closeFinish} />
|
||||
)}
|
||||
</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({
|
||||
active,
|
||||
onClick,
|
||||
|
||||
@@ -7,7 +7,8 @@ import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||
import { ListDividersOption } from "../../components/mail/ListDividersOption";
|
||||
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 type { CalendarOut, ReminderPref } from "../../lib/types";
|
||||
import { useMe } from "../../lib/auth";
|
||||
@@ -28,8 +29,19 @@ import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat"
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
import { usePluginSettingsSections } from "../../plugins/manifest";
|
||||
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];
|
||||
|
||||
export function Settings() {
|
||||
@@ -60,9 +72,11 @@ export function Settings() {
|
||||
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
||||
{!addingAccount && section === "profile" && <ProfileSection />}
|
||||
{!addingAccount && section === "security" && <SecuritySection />}
|
||||
{section === "privacy" && <PrivacySection />}
|
||||
{!addingAccount && section === "accounts" && <AccountsSection />}
|
||||
{section === "calendars" && <CalendarsSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
{section === "shortcuts" && <ShortcutsSection />}
|
||||
{section === "integrations" && <IntegrationsSection />}
|
||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
|
||||
per plugin, selected from the nav like any built-in section. */}
|
||||
@@ -516,6 +530,7 @@ function OfflineAccessSetting() {
|
||||
*/
|
||||
function AppPasswordsSetting() {
|
||||
const t = useT();
|
||||
const [confirm, confirmDialog] = useConfirm();
|
||||
const me = useMe().data;
|
||||
const handle = me?.handle ?? "";
|
||||
const mailHost = me?.mail_host ?? null;
|
||||
@@ -559,6 +574,7 @@ function AppPasswordsSetting() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{confirmDialog}
|
||||
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
|
||||
<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). */}
|
||||
@@ -643,8 +659,14 @@ function AppPasswordsSetting() {
|
||||
<button
|
||||
className="shrink-0 text-xs text-red-500 disabled:opacity-50"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t("appPasswords.confirmRevoke", { name: a.name }))) revoke.mutate(a.id);
|
||||
onClick={async () => {
|
||||
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")}
|
||||
@@ -865,6 +887,7 @@ function SecuritySection() {
|
||||
|
||||
function AccountsSection() {
|
||||
const t = useT();
|
||||
const [confirm, confirmDialog] = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
@@ -893,6 +916,7 @@ function AccountsSection() {
|
||||
|
||||
return (
|
||||
<section className="max-w-5xl space-y-3">
|
||||
{confirmDialog}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
|
||||
<button
|
||||
@@ -971,8 +995,15 @@ function AccountsSection() {
|
||||
{a.kind !== "native" && (
|
||||
<button
|
||||
className="text-xs text-red-500"
|
||||
onClick={() => {
|
||||
if (confirm(t("settings.confirmRemoveAccount"))) {
|
||||
onClick={async () => {
|
||||
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);
|
||||
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() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1333,6 +1419,7 @@ function AppearanceSection() {
|
||||
return (
|
||||
<section className="max-w-4xl space-y-3">
|
||||
<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">
|
||||
<span className="text-app-muted">{t("settings.theme")}</span>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
||||
@@ -1359,6 +1446,7 @@ function AppearanceSection() {
|
||||
</div>
|
||||
</div>
|
||||
<SpellcheckSetting />
|
||||
<CompletionSetting />
|
||||
<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>
|
||||
<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/";
|
||||
|
||||
function IntegrationsSection() {
|
||||
@@ -1423,14 +1572,14 @@ function IntegrationsSection() {
|
||||
}
|
||||
|
||||
const CAL_COLORS: [string, string][] = [
|
||||
["Blue", "#3b82f6"],
|
||||
["Green", "#22c55e"],
|
||||
["Red", "#ef4444"],
|
||||
["Orange", "#f97316"],
|
||||
["Purple", "#a855f7"],
|
||||
["Teal", "#14b8a6"],
|
||||
["Pink", "#ec4899"],
|
||||
["Gray", "#6b7280"],
|
||||
["color.blue", "#3b82f6"],
|
||||
["color.green", "#22c55e"],
|
||||
["color.red", "#ef4444"],
|
||||
["color.orange", "#f97316"],
|
||||
["color.purple", "#a855f7"],
|
||||
["color.teal", "#14b8a6"],
|
||||
["color.pink", "#ec4899"],
|
||||
["color.gray", "#6b7280"],
|
||||
];
|
||||
|
||||
function BellIcon({ className }: { className?: string }) {
|
||||
@@ -1456,14 +1605,14 @@ function ColorSelect({ value, onChange }: { value: string; onChange: (hex: strin
|
||||
const [open, setOpen] = useState(false);
|
||||
const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value)
|
||||
? CAL_COLORS
|
||||
: [["Custom", value], ...CAL_COLORS];
|
||||
: [[translate("color.custom"), value], ...CAL_COLORS];
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
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 }} />
|
||||
<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
|
||||
key={hex}
|
||||
type="button"
|
||||
title={name}
|
||||
title={name.startsWith("color.") ? translate(name as MessageKey) : name}
|
||||
onClick={() => {
|
||||
onChange(hex);
|
||||
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];
|
||||
|
||||
function reminderText(m: number): string {
|
||||
if (m === 0) return "At time of event";
|
||||
if (m % 1440 === 0) return `${m / 1440} day${m / 1440 > 1 ? "s" : ""} before`;
|
||||
if (m % 60 === 0) return `${m / 60} hour${m / 60 > 1 ? "s" : ""} before`;
|
||||
return `${m} minutes before`;
|
||||
if (m === 0) return translate("reminder.atTime");
|
||||
if (m % 1440 === 0) return translate("reminder.daysBefore", { count: m / 1440 });
|
||||
if (m % 60 === 0) return translate("reminder.hoursBefore", { count: m / 60 });
|
||||
return translate("reminder.minutesBefore", { count: m });
|
||||
}
|
||||
|
||||
function CalendarsSection() {
|
||||
|
||||
@@ -54,4 +54,28 @@ describe("antispam Actions-menu item", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
if (!messageId) return;
|
||||
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;
|
||||
try {
|
||||
data = await ctx.api.pluginFetch(`/context/${messageId}`);
|
||||
} catch {
|
||||
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 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 */
|
||||
}
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
const onLearned = () => void load();
|
||||
window.addEventListener("webmail:antispam-learned", onLearned);
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener("webmail:antispam-learned", onLearned);
|
||||
el.innerHTML = "";
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user