Two-way draft sync, signatures, sent/bcc fixes, and mail-list UI

Backend
- Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll
  (Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX.
- Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr.
- Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge);
  inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID.
- Per-user storage quota (5 GiB default) and manual "sync all folders" action.
- Account editing (display/color/server/password) and per-account HTML signature.
- Account view scoped to that account's inbox; new migrations 0006–0009.

Frontend
- Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh.
- Aligned + widened mail-list margins; reading-pane bars fixed height.
- Rich-text composer: color palette, highlight, image; attachments; expand/pop-out.
- Signatures: auto-insert for new mail, replace on From change, reply insert.
- Restore/Delete actions per folder; drafts view merges local + remote, date-sorted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 15:27:09 +02:00
co-authored by Claude Opus 4.8
parent a5ec0d21cb
commit 3951df9755
39 changed files with 1917 additions and 139 deletions
+6
View File
@@ -61,6 +61,11 @@ class CreateAccountRequest(BaseModel):
class UpdateAccountRequest(BaseModel):
display_name: str | None = None
color: str | None = None
signature: str | None = None
# Connection/credential changes require the password (re-validated + re-encrypted).
imap: ServerConfigIn | None = None
smtp: ServerConfigIn | None = None
password: str | None = None
class AccountDetailOut(BaseModel):
@@ -70,5 +75,6 @@ class AccountDetailOut(BaseModel):
color: str
state: str
state_detail: str
signature: str
imap_config: dict[str, object]
smtp_config: dict[str, object]
+81 -1
View File
@@ -32,6 +32,7 @@ from app.identity.deps import CurrentUser, require_csrf
from app.models import Account
from app.queue import get_arq_pool
from app.redis_client import get_redis
from app.security.crypto import encrypt_secret
router = APIRouter(prefix="/accounts", tags=["accounts"])
@@ -75,12 +76,27 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
)
return [
AccountOut(
id=a.id, email=a.email, display_name=a.display_name, color=a.color, state=a.state
id=a.id,
email=a.email,
display_name=a.display_name,
color=a.color,
state=a.state,
signature=a.signature,
)
for a in accounts
]
@router.post("/sync", dependencies=[Depends(require_csrf)])
async def sync_all_accounts(user: CurrentUser, db: DbSession) -> dict[str, str]:
"""Poll every folder of each of the user's accounts (manual Refresh)."""
account_ids = list(await db.scalars(select(Account.id).where(Account.user_id == user.id)))
pool = await get_arq_pool()
for account_id in account_ids:
await pool.enqueue_job("refresh_account_job", str(account_id))
return {"status": "queued"}
@router.post("/discover", response_model=DiscoverResponse, dependencies=[Depends(require_csrf)])
async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse:
await _rate_limit_discover(user.id)
@@ -135,6 +151,25 @@ async def create_new_account(
return _detail(account)
@router.get("/{account_id}", response_model=AccountDetailOut)
async def get_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> AccountDetailOut:
account = await db.scalar(
select(Account).where(Account.id == account_id, Account.user_id == user.id)
)
if account is None:
raise_api_error(404, "not_found", "account not found")
return _detail(account)
def _server_from_config(cfg: dict[str, object]) -> ServerConfig:
return ServerConfig(
str(cfg["host"]),
int(str(cfg["port"])),
str(cfg.get("tls", "ssl")),
str(cfg.get("auth", "password")),
)
@router.patch(
"/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)]
)
@@ -150,7 +185,51 @@ async def update_account(
account.display_name = body.display_name
if body.color is not None:
account.color = body.color
if body.signature is not None:
account.signature = body.signature
# Connection settings and/or password: re-validate against the servers, then
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
# stored secret is only decrypted in the worker, never here).
conn_change = body.imap is not None or body.smtp is not None or body.password is not None
if conn_change:
if not body.password:
raise_api_error(
400,
"password_required",
"the mailbox password is required to change server settings",
)
imap = (
_to_server_config(body.imap) if body.imap else _server_from_config(account.imap_config)
)
smtp = (
_to_server_config(body.smtp) if body.smtp else _server_from_config(account.smtp_config)
)
try:
await test_account(imap=imap, smtp=smtp, username=account.email, password=body.password)
except AccountTestError as exc:
raise_api_error(400, exc.code, exc.detail)
account.imap_config = {
"host": imap.host,
"port": imap.port,
"tls": imap.tls,
"auth": imap.auth,
}
account.smtp_config = {
"host": smtp.host,
"port": smtp.port,
"tls": smtp.tls,
"auth": smtp.auth,
}
account.secret_ct = encrypt_secret(body.password.encode("utf-8"))
account.state = "backfilling"
await db.commit()
if conn_change:
pool = await get_arq_pool()
await pool.enqueue_job("sync_bootstrap_account", str(account.id))
return _detail(account)
@@ -171,6 +250,7 @@ def _detail(account: Account) -> AccountDetailOut:
color=account.color,
state=account.state,
state_detail=account.state_detail,
signature=account.signature,
imap_config=account.imap_config,
smtp_config=account.smtp_config,
)
+122 -2
View File
@@ -16,8 +16,14 @@ from app.config import get_settings
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.indexer import blobstore
from app.models import ComposeAttachment, Draft
from app.outbox.schemas import DraftIn, DraftOut, SendRequest, SendResponse
from app.models import BodyPart, ComposeAttachment, Draft, Mailbox, Message
from app.outbox.schemas import (
DraftIn,
DraftOut,
RemoteDraftOut,
SendRequest,
SendResponse,
)
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
from app.queue import get_arq_pool
@@ -102,6 +108,104 @@ async def list_drafts(user: CurrentUser, db: DbSession) -> list[DraftOut]:
return [_draft_out(d) for d in drafts]
def _fmt_addr(addr: dict[str, str]) -> str:
email = addr.get("email", "")
name = addr.get("name", "")
return f"{name} <{email}>" if name else email
async def _own_draft_uids(db: AsyncSession, user_id: uuid.UUID) -> set[tuple[uuid.UUID, int]]:
"""(account_id, imap_uid) of drafts we mirrored — excluded from the remote list."""
rows = await db.execute(
select(Draft.account_id, Draft.imap_uid).where(
Draft.user_id == user_id, Draft.imap_uid.isnot(None)
)
)
return {(r.account_id, r.imap_uid) for r in rows}
@router.get("/drafts/remote", response_model=list[RemoteDraftOut])
async def list_remote_drafts(user: CurrentUser, db: DbSession) -> list[RemoteDraftOut]:
"""Drafts sitting in the IMAP Drafts folder that we haven't already adopted."""
own = await _own_draft_uids(db, user.id)
messages = await db.scalars(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Mailbox.special_use == "drafts")
.order_by(Message.internal_date.desc())
)
out: list[RemoteDraftOut] = []
for m in messages:
if (m.account_id, m.uid) in own:
continue
out.append(
RemoteDraftOut(
message_id=m.id,
account_id=m.account_id,
imap_uid=m.uid,
subject=m.subject,
to=[_fmt_addr(a) for a in m.to_addr],
cc=[_fmt_addr(a) for a in m.cc_addr],
bcc=[_fmt_addr(a) for a in m.bcc_addr],
snippet=m.snippet,
updated_at=(m.date_hdr or m.internal_date).isoformat(),
)
)
return out
@router.post("/drafts/adopt/{message_id}", response_model=DraftOut)
async def adopt_remote_draft(
message_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> DraftOut:
"""Turn a Drafts-folder message into an editable local draft (linked by UID)."""
msg = await db.scalar(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.id == message_id,
Message.user_id == user.id,
Mailbox.special_use == "drafts",
)
)
if msg is None:
raise_api_error(404, "not_found", "draft message not found")
# Idempotent: if already adopted (a local draft links this UID), return it.
existing = await db.scalar(
select(Draft).where(
Draft.user_id == user.id,
Draft.account_id == msg.account_id,
Draft.imap_uid == msg.uid,
)
)
if existing is not None:
return _draft_out(existing)
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == msg.id)))
html = next((p.content for p in parts if p.kind == "html_sanitized"), "")
text = next((p.content for p in parts if p.kind == "text"), "")
payload = {
"to": [_fmt_addr(a) for a in msg.to_addr],
"cc": [_fmt_addr(a) for a in msg.cc_addr],
"bcc": [_fmt_addr(a) for a in msg.bcc_addr],
"subject": msg.subject,
"html": html,
"text": text,
"attachment_ids": [],
}
draft = Draft(
user_id=user.id,
account_id=msg.account_id,
kind="new",
payload=payload,
imap_uid=msg.uid,
)
db.add(draft)
await db.commit()
return _draft_out(draft)
@router.post("/drafts", response_model=DraftOut)
async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut:
try:
@@ -125,13 +229,29 @@ async def update_draft(
return _draft_out(draft)
@router.post("/drafts/{draft_id}/publish")
async def publish_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
"""Mirror the draft to the account's IMAP Drafts folder (continue on any client)."""
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
if draft is None:
raise_api_error(404, "not_found", "draft not found")
pool = await get_arq_pool()
await pool.enqueue_job("push_draft_to_imap_job", str(draft_id))
return {"status": "queued"}
@router.delete("/drafts/{draft_id}")
async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
if draft is None:
raise_api_error(404, "not_found", "draft not found")
account_id, imap_uid = draft.account_id, draft.imap_uid
await db.delete(draft)
await db.commit()
# Remove the mirrored copy from the server Drafts folder too.
if imap_uid:
pool = await get_arq_pool()
await pool.enqueue_job("remove_imap_draft_job", str(account_id), imap_uid)
return {"status": "deleted"}
+2
View File
@@ -47,6 +47,7 @@ class MessageEnvelope(BaseModel):
from_addr: list[Address]
to_addr: list[Address]
cc_addr: list[Address]
bcc_addr: list[Address]
date: dt.datetime
flags: list[str]
is_unread: bool
@@ -79,6 +80,7 @@ class AccountOut(BaseModel):
display_name: str
color: str
state: str
signature: str = ""
class SearchThreadHit(BaseModel):
+16 -1
View File
@@ -6,11 +6,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import User
from app.models import Message, User
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -30,6 +31,11 @@ class SettingsPatch(BaseModel):
tz: str | None = None
class QuotaOut(BaseModel):
used_bytes: int
quota_bytes: int
def allowed_image_senders(user: User) -> set[str]:
value = user.settings.get("allowed_image_senders", [])
if isinstance(value, list):
@@ -59,6 +65,15 @@ async def get_settings_endpoint(user: CurrentUser) -> SettingsOut:
return _settings_out(user)
@router.get("/quota", response_model=QuotaOut)
async def get_quota(user: CurrentUser, db: DbSession) -> QuotaOut:
"""Storage quota and current usage (sum of stored message sizes)."""
used = await db.scalar(
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
)
return QuotaOut(used_bytes=int(used or 0), quota_bytes=user.quota_bytes)
@router.patch("", response_model=SettingsOut, dependencies=[Depends(require_csrf)])
async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut:
updates = dict(user.settings)
+1
View File
@@ -56,6 +56,7 @@ def _envelope(msg: Message) -> MessageEnvelope:
from_addr=_addresses(msg.from_addr),
to_addr=_addresses(msg.to_addr),
cc_addr=_addresses(msg.cc_addr),
bcc_addr=_addresses(msg.bcc_addr),
date=msg.date_hdr or msg.internal_date,
flags=list(msg.flags),
is_unread="\\Seen" not in msg.flags,
+5 -1
View File
@@ -150,6 +150,7 @@ async def persist_message(
from_addr=parsed.from_addr,
to_addr=parsed.to_addr,
cc_addr=parsed.cc_addr,
bcc_addr=parsed.bcc_addr,
date_hdr=parsed.date,
internal_date=remote.internal_date or datetime.now(UTC),
flags=sorted(remote.flags),
@@ -192,7 +193,10 @@ async def persist_message(
)
message.has_remote_images = has_remote_images
await thread_message(session, message)
# Drafts are not conversation messages: keep them out of threads so they don't
# pollute the reading pane or thread list. They surface only via the drafts UI.
if mailbox_special_use != "drafts":
await thread_message(session, message)
if mailbox_special_use not in _NO_RECIPIENT_HARVEST:
await _harvest_recipient(session, message, parsed)
+2
View File
@@ -37,6 +37,7 @@ class ParsedEmail:
from_addr: list[dict[str, str]]
to_addr: list[dict[str, str]]
cc_addr: list[dict[str, str]]
bcc_addr: list[dict[str, str]]
date: datetime | None
text_body: str
html_body: str | None
@@ -140,6 +141,7 @@ def parse_message(raw: bytes) -> ParsedEmail:
from_addr=_addresses(msg, "From"),
to_addr=_addresses(msg, "To"),
cc_addr=_addresses(msg, "Cc"),
bcc_addr=_addresses(msg, "Bcc"),
date=date,
text_body=text_body or "",
html_body=html_body,
+14
View File
@@ -10,6 +10,7 @@ import datetime as dt
import uuid
from sqlalchemy import (
BigInteger,
ForeignKey,
Index,
Integer,
@@ -54,6 +55,11 @@ class User(Base):
settings: Mapped[dict[str, object]] = mapped_column(
JSONB, nullable=False, server_default=text("'{}'")
)
# Storage quota in bytes; per-user so an admin can override it later.
# Default 5 GiB (5 * 1024^3).
quota_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("5368709120")
)
created_at: Mapped[dt.datetime] = _now()
@@ -68,6 +74,8 @@ class Account(Base):
email: Mapped[str] = mapped_column(CITEXT, nullable=False)
display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
color: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'#888888'"))
# Per-account HTML signature, inserted into new messages (and via compose menu).
signature: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False)
@@ -158,6 +166,9 @@ class Message(Base):
cc_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
bcc_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
date_hdr: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
flags: Mapped[list[str]] = mapped_column(
@@ -233,6 +244,9 @@ class Draft(Base):
)
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'reply'"))
payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
# UID of the mirrored copy in the account's IMAP Drafts folder (so the draft
# is visible/continuable on other mail clients). Replaced on each edit.
imap_uid: Mapped[int | None] = mapped_column(Integer)
updated_at: Mapped[dt.datetime] = _now()
+40
View File
@@ -0,0 +1,40 @@
"""Resolve a draft's compose-attachment ids into raw (filename, mime, data) tuples."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.indexer import blobstore
from app.models import ComposeAttachment, Draft
async def load_attachments(
session: AsyncSession, user_id: uuid.UUID, draft: Draft
) -> list[tuple[str, str, bytes]]:
"""Return each of the draft's compose attachments as ``(filename, mime, data)``."""
raw_ids = draft.payload.get("attachment_ids") or []
if not isinstance(raw_ids, list) or not raw_ids:
return []
ids: list[uuid.UUID] = []
for value in raw_ids:
try:
ids.append(uuid.UUID(str(value)))
except ValueError:
continue
if not ids:
return []
rows = await session.scalars(
select(ComposeAttachment).where(
ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id
)
)
by_id = {r.id: r for r in rows}
out: list[tuple[str, str, bytes]] = []
for i in ids:
att = by_id.get(i)
if att is not None:
out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256)))
return out
+120
View File
@@ -0,0 +1,120 @@
"""Mirror local drafts to the account's IMAP Drafts folder (SPEC §6.5 extension).
Drafts are stored server-side as ``\\Draft`` messages so they can be continued on
any standard mail client. IMAP messages are immutable, so each edit APPENDs a new
copy and expunges the previous one (tracked by ``drafts.imap_uid``). Runs in the
worker (credential decryption is worker-only, §10.2).
"""
from __future__ import annotations
import contextlib
import logging
import uuid
from typing import Any
from sqlalchemy import select
from app.db import session_scope
from app.models import Account, Draft, Mailbox, Message
from app.outbox.attachments import load_attachments
from app.outbox.mime_build import build_mime
from app.sync.provider_factory import build_provider
logger = logging.getLogger(__name__)
_DRAFT_FLAGS = frozenset({"\\Draft", "\\Seen"})
async def _ensure_drafts_folder(provider: Any, account: Account, user_id: uuid.UUID) -> str:
"""Return the account's Drafts folder path, creating one if it is missing."""
path = "Drafts"
await provider.create_mailbox(path)
remote = await provider.list_mailboxes()
uidvalidity = next((r.uidvalidity for r in remote if r.path == path), 0)
async with session_scope() as session:
existing = await session.scalar(
select(Mailbox).where(
Mailbox.account_id == account.id, Mailbox.special_use == "drafts"
)
)
if existing is not None:
return existing.path
session.add(
Mailbox(
account_id=account.id,
user_id=user_id,
path=path,
special_use="drafts",
uidvalidity=uidvalidity,
)
)
return path
async def push_draft_to_imap_job(ctx: dict[str, Any], draft_id: str) -> None:
"""APPEND the draft to the Drafts folder and expunge the previous copy."""
did = uuid.UUID(draft_id)
async with session_scope() as session:
draft = await session.get(Draft, did)
if draft is None:
return
account = await session.get(Account, draft.account_id)
if account is None:
return
user_id = draft.user_id
old_uid = draft.imap_uid
reply_to = (
await session.get(Message, draft.reply_to_message_id)
if draft.reply_to_message_id
else None
)
attachments = await load_attachments(session, user_id, draft)
_, sent_raw, _, _ = build_mime(account, draft, reply_to, attachments)
drafts_mb = await session.scalar(
select(Mailbox).where(
Mailbox.account_id == account.id, Mailbox.special_use == "drafts"
)
)
drafts_path = drafts_mb.path if drafts_mb else None
account_obj = account
provider = build_provider(account_obj)
new_uid = 0
try:
if drafts_path is None:
drafts_path = await _ensure_drafts_folder(provider, account_obj, user_id)
new_uid = await provider.append(drafts_path, sent_raw, _DRAFT_FLAGS)
if old_uid:
with contextlib.suppress(Exception):
await provider.delete_messages(drafts_path, [old_uid])
finally:
await provider.close()
if new_uid:
async with session_scope() as session:
draft = await session.get(Draft, did)
if draft is not None:
draft.imap_uid = new_uid
async def remove_imap_draft_job(ctx: dict[str, Any], account_id: str, uid: int) -> None:
"""Expunge a draft copy from the account's Drafts folder (on discard)."""
aid = uuid.UUID(account_id)
async with session_scope() as session:
account = await session.get(Account, aid)
if account is None:
return
drafts_mb = await session.scalar(
select(Mailbox).where(Mailbox.account_id == aid, Mailbox.special_use == "drafts")
)
drafts_path = drafts_mb.path if drafts_mb else None
account_obj = account
if drafts_path is None:
return
provider = build_provider(account_obj)
try:
with contextlib.suppress(Exception):
await provider.delete_messages(drafts_path, [int(uid)])
finally:
await provider.close()
+75 -39
View File
@@ -8,6 +8,7 @@ to Sent (skipped for Gmail, which self-appends), harvest recipients into
from __future__ import annotations
import contextlib
import logging
import uuid
from datetime import UTC, datetime
@@ -19,17 +20,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import session_scope
from app.events import get_event_bus
from app.indexer import blobstore
from app.models import (
Account,
ComposeAttachment,
Draft,
KnownRecipient,
Mailbox,
Message,
Outbox,
)
from app.indexer.indexer import persist_message
from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox
from app.outbox.attachments import load_attachments
from app.outbox.mime_build import build_mime
from app.providers.base import RemoteMessage
from app.search.factory import get_search_backend
from app.sync.provider_factory import build_provider
logger = logging.getLogger(__name__)
@@ -56,33 +52,56 @@ async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails:
await session.execute(stmt)
async def _load_attachments(
session: AsyncSession, user_id: uuid.UUID, draft: Draft
) -> list[tuple[str, str, bytes]]:
"""Resolve a draft's compose-attachment ids into (filename, mime, data) tuples."""
raw_ids = draft.payload.get("attachment_ids") or []
if not isinstance(raw_ids, list) or not raw_ids:
return []
ids: list[uuid.UUID] = []
for value in raw_ids:
try:
ids.append(uuid.UUID(str(value)))
except ValueError:
continue
if not ids:
return []
rows = await session.scalars(
select(ComposeAttachment).where(
ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id
async def _persist_sent_copy(
user_id: uuid.UUID, account: Account, raw: bytes, uid: int
) -> None:
"""Store the just-sent message in the DB (Sent mailbox) and notify the UI.
No-op without a UIDPLUS append UID (uid <= 0); in that case a future Sent sync
would pick it up. persist_message dedupes on (mailbox_id, uid).
"""
if uid <= 0:
return
async with session_scope() as session:
sent_mb = await session.scalar(
select(Mailbox).where(
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
)
)
if sent_mb is None:
return
remote = RemoteMessage(
uid=uid, raw=raw, flags=frozenset({"\\Seen"}), internal_date=datetime.now(UTC)
)
result = await persist_message(
session,
user_id=user_id,
account_id=account.id,
mailbox_id=sent_mb.id,
mailbox_special_use="sent",
remote=remote,
)
if result is None:
return
message_id = result.message.id
thread_id = result.message.thread_id
search_doc = result.search_doc
await get_search_backend().index(search_doc)
bus = get_event_bus()
await bus.publish(
"message.new",
{
"user_id": str(user_id),
"account_id": str(account.id),
"message_id": str(message_id),
"thread_id": str(thread_id) if thread_id else None,
},
)
by_id = {r.id: r for r in rows}
out: list[tuple[str, str, bytes]] = []
for i in ids:
att = by_id.get(i)
if att is not None:
out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256)))
return out
if thread_id is not None:
await bus.publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
)
async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
@@ -109,21 +128,34 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
outbox.error = "account missing"
return
user_id = outbox.user_id
attachments = await _load_attachments(session, user_id, draft)
raw, envelope_from, envelope_to = build_mime(account, draft, reply_to, attachments)
attachments = await load_attachments(session, user_id, draft)
transmit_raw, sent_raw, envelope_from, envelope_to = build_mime(
account, draft, reply_to, attachments
)
sent_path = await session.scalar(
select(Mailbox.path).where(
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
)
)
drafts_path = await session.scalar(
select(Mailbox.path).where(
Mailbox.account_id == account.id, Mailbox.special_use == "drafts"
)
)
draft_imap_uid = draft.imap_uid
gmail = _is_gmail(account)
account_obj = account
provider = build_provider(account_obj)
appended_uid = 0
try:
await provider.send(raw, envelope_from, envelope_to)
await provider.send(transmit_raw, envelope_from, envelope_to)
if sent_path and not gmail:
await provider.append(sent_path, raw, frozenset({"\\Seen"}))
appended_uid = await provider.append(sent_path, sent_raw, frozenset({"\\Seen"}))
# Remove the mirrored Drafts-folder copy now that it has been sent.
if drafts_path and draft_imap_uid:
with contextlib.suppress(Exception):
await provider.delete_messages(drafts_path, [draft_imap_uid])
except Exception as exc: # noqa: BLE001 - record and notify, keep the draft
async with session_scope() as session:
ob = await session.get(Outbox, oid)
@@ -139,6 +171,10 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
finally:
await provider.close()
# Persist the sent copy (with Bcc) ourselves so it shows in Sent and threads
# immediately; the periodic Sent poll would also catch it (dedup by UID).
await _persist_sent_copy(user_id, account_obj, sent_raw, appended_uid)
async with session_scope() as session:
ob = await session.get(Outbox, oid)
if ob is not None:
+14 -3
View File
@@ -49,8 +49,13 @@ def build_mime(
draft: Draft,
reply_to: Message | None,
attachments: list[tuple[str, str, bytes]] | None = None,
) -> tuple[bytes, str, list[str]]:
"""Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``.
) -> tuple[bytes, bytes, str, list[str]]:
"""Return ``(transmit_raw, sent_raw, envelope_from, envelope_to)`` for ``draft``.
``transmit_raw`` is sent to the SMTP recipients and carries NO ``Bcc`` header.
``sent_raw`` is the copy filed in Sent; it additionally includes a ``Bcc``
header so the sender keeps a record of blind recipients (standard client
behavior). They are identical when there is no Bcc.
``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the
compose Attach action; each is added as a file attachment part.
@@ -99,4 +104,10 @@ def build_mime(
)
envelope_to = [_email_only(a) for a in (to + cc + bcc)]
return msg.as_bytes(), account.email, envelope_to
transmit_raw = msg.as_bytes() # no Bcc header -> safe to transmit
if bcc:
msg["Bcc"] = ", ".join(bcc)
sent_raw = msg.as_bytes()
else:
sent_raw = transmit_raw
return transmit_raw, sent_raw, account.email, envelope_to
+14
View File
@@ -35,6 +35,20 @@ class DraftOut(BaseModel):
updated_at: str
class RemoteDraftOut(BaseModel):
"""A draft found in the account's IMAP Drafts folder (created on another client)."""
message_id: uuid.UUID
account_id: uuid.UUID
imap_uid: int
subject: str
to: list[str]
cc: list[str]
bcc: list[str]
snippet: str
updated_at: str
class SendRequest(BaseModel):
draft_id: uuid.UUID
+72
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import uuid
from typing import Any
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -274,3 +276,73 @@ async def live_loop(
await apply_delta(provider, account_obj, mailbox_obj, delta)
finally:
await provider.close()
async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> None:
"""One CONDSTORE poll pass over an account's special-use folders.
``include_inbox`` also polls INBOX (used by the manual Refresh); the periodic
secondary loop leaves INBOX to its dedicated IDLE loop. Custom (non special-use)
folders are not polled. Drafts are polled so drafts made in other clients appear
(persisted un-threaded).
"""
from app.db import session_scope
async with session_scope() as session:
account = await _load_account(session, account_id)
account_obj = account
stmt = select(Mailbox.id).where(
Mailbox.account_id == account_id, Mailbox.special_use.isnot(None)
)
if not include_inbox:
stmt = stmt.where(Mailbox.special_use != "inbox")
mailbox_ids = list(await session.scalars(stmt))
if not mailbox_ids:
return
provider = build_provider(account_obj)
try:
for mailbox_id in mailbox_ids:
async with session_scope() as session:
mailbox = await session.get(Mailbox, mailbox_id)
mailbox_obj = mailbox
if mailbox_obj is None:
continue
try:
delta = await provider.poll(
mailbox_obj.path,
last_seen_uid=mailbox_obj.last_seen_uid,
highestmodseq=mailbox_obj.highestmodseq,
)
await apply_delta(provider, account_obj, mailbox_obj, delta)
except Exception:
logger.exception("poll failed: account=%s mailbox=%s", account_id, mailbox_obj.path)
finally:
await provider.close()
async def _poll_secondary_once(account_id: uuid.UUID) -> None:
await poll_folders_once(account_id, include_inbox=False)
async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None:
"""Poll every special-use folder (incl. INBOX) once — the manual Refresh action."""
await poll_folders_once(uuid.UUID(account_id), include_inbox=True)
async def secondary_poll_loop(account_id: uuid.UUID, stop: asyncio.Event | None = None) -> None:
"""Periodically CONDSTORE-poll non-INBOX folders (Sent/Drafts/Archive/Trash/Junk).
INBOX has its own IDLE loop (real-time). This keeps the other folders in step
with server-side changes (e.g. Gmail-filed sent mail) and changes made by other
clients. CONDSTORE/QRESYNC makes each pass a cheap MODSEQ delta.
"""
stop = stop or asyncio.Event()
interval = get_settings().poll_interval
while not stop.is_set():
# Wait first: the initial state was already covered by the backfill.
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=interval)
if stop.is_set():
break
await _poll_secondary_once(account_id)
+18 -2
View File
@@ -20,7 +20,7 @@ from sqlalchemy import select
from app.db import session_scope
from app.models import Account
from app.sync.engine import live_loop
from app.sync.engine import live_loop, secondary_poll_loop
logger = logging.getLogger(__name__)
@@ -39,13 +39,29 @@ async def _run_account(account_id: uuid.UUID, stop: asyncio.Event) -> None:
await asyncio.wait_for(stop.wait(), timeout=_RECONNECT_BACKOFF)
async def _run_secondary(account_id: uuid.UUID, stop: asyncio.Event) -> None:
while not stop.is_set():
try:
await secondary_poll_loop(account_id, stop)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("secondary poll loop for %s crashed; retrying", account_id)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=_RECONNECT_BACKOFF)
async def start_supervisor(ctx: dict[str, Any]) -> None:
"""Launch a live loop per live account (called from the worker on_startup)."""
async with session_scope() as session:
account_ids = list(await session.scalars(select(Account.id).where(Account.state == "live")))
stop = asyncio.Event()
ctx["supervisor_stop"] = stop
ctx["supervisor_tasks"] = [asyncio.create_task(_run_account(aid, stop)) for aid in account_ids]
tasks: list[asyncio.Task[None]] = []
for aid in account_ids:
tasks.append(asyncio.create_task(_run_account(aid, stop))) # INBOX IDLE
tasks.append(asyncio.create_task(_run_secondary(aid, stop))) # other folders poll
ctx["supervisor_tasks"] = tasks
logger.info("live supervisor started for %d account(s)", len(account_ids))
@@ -0,0 +1,30 @@
"""add users.quota_bytes
Per-user storage quota (bytes) so an administrator can define/override it later.
Defaults to 5 GiB for existing and new users.
Revision ID: 0006
Revises: 0005
Create Date: 2026-07-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0006"
down_revision: str | None = "0005"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE users ADD COLUMN quota_bytes bigint NOT NULL DEFAULT 5368709120"
)
def downgrade() -> None:
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS quota_bytes")
@@ -0,0 +1,29 @@
"""add messages.bcc_addr
The Sent copy of an outgoing message includes a Bcc header (standard client
behavior) so the sender keeps a record of blind recipients; store it structured
alongside to/cc for display.
Revision ID: 0007
Revises: 0006
Create Date: 2026-07-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0007"
down_revision: str | None = "0006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE messages ADD COLUMN bcc_addr jsonb NOT NULL DEFAULT '[]'")
def downgrade() -> None:
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS bcc_addr")
@@ -0,0 +1,29 @@
"""add drafts.imap_uid
Drafts are mirrored to the account's IMAP Drafts folder (as \\Draft messages) so
they can be continued on any standard mail client. This stores the UID of the
current server copy so it can be replaced/removed.
Revision ID: 0008
Revises: 0007
Create Date: 2026-07-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0008"
down_revision: str | None = "0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE drafts ADD COLUMN imap_uid integer")
def downgrade() -> None:
op.execute("ALTER TABLE drafts DROP COLUMN IF EXISTS imap_uid")
@@ -0,0 +1,28 @@
"""add accounts.signature
Per-account HTML signature, configured in Settings → Accounts and inserted into
outgoing messages by the composer.
Revision ID: 0009
Revises: 0008
Create Date: 2026-07-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0009"
down_revision: str | None = "0008"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE accounts ADD COLUMN signature text NOT NULL DEFAULT ''")
def downgrade() -> None:
op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS signature")
+108
View File
@@ -146,6 +146,114 @@ async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # t
assert enqueued == [("sync_bootstrap_account", body["id"])]
async def _make_account(ctx: Ctx, monkeypatch) -> str: # type: ignore[no-untyped-def]
async def fake_test(**_kw: object) -> None:
return None
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
resp = await ctx.client.post(
"/api/accounts",
json={**GOOD_CFG, "email": "me@example.com", "display_name": "Me", "password": "secret"},
headers=_hdr(),
)
assert resp.status_code == 200, resp.text
return str(resp.json()["id"])
async def test_sync_all_enqueues_refresh_per_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
resp = await ctx.client.post("/api/accounts/sync", headers=_hdr())
assert resp.status_code == 200
assert enqueued == [("refresh_account_job", aid)]
async def test_update_display_and_color_no_resync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
resp = await ctx.client.patch(
f"/api/accounts/{aid}",
json={"display_name": "New Name", "color": "#00ff00"},
headers=_hdr(),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["display_name"] == "New Name" and body["color"] == "#00ff00"
async def test_update_signature(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
resp = await ctx.client.patch(
f"/api/accounts/{aid}", json={"signature": "<p>Best,<br>Me</p>"}, headers=_hdr()
)
assert resp.status_code == 200
assert resp.json()["signature"] == "<p>Best,<br>Me</p>"
# The accounts list also carries the signature (used by the composer).
listing = await ctx.client.get("/api/accounts")
acct = next(a for a in listing.json() if a["id"] == aid)
assert acct["signature"] == "<p>Best,<br>Me</p>"
async def test_update_password_reencrypts_and_resyncs(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
acct = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(aid)))
assert acct is not None
await ctx.session.refresh(acct)
old_secret = bytes(acct.secret_ct)
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
async def fake_test(**_kw: object) -> None:
return None
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
resp = await ctx.client.patch(
f"/api/accounts/{aid}", json={"password": "newsecret"}, headers=_hdr()
)
assert resp.status_code == 200, resp.text
assert resp.json()["state"] == "backfilling"
await ctx.session.refresh(acct)
assert bytes(acct.secret_ct) != old_secret # re-encrypted
assert enqueued == [("sync_bootstrap_account", aid)]
async def test_update_server_change_requires_password(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
resp = await ctx.client.patch(
f"/api/accounts/{aid}",
json={"imap": {"host": "imap2.example.com", "port": 993, "tls": "ssl"}},
headers=_hdr(),
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "password_required"
async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
async def fake_test(**_kw: object) -> None:
return None
+8
View File
@@ -234,6 +234,14 @@ async def test_attachments_list_and_zip(api: ApiCtx) -> None:
assert archive.read("notes.txt") == b"hello attachment"
async def test_quota_reports_usage_and_default(api: ApiCtx) -> None:
resp = await api.client.get("/api/settings/quota")
assert resp.status_code == 200
body = resp.json()
assert body["quota_bytes"] == 5 * 1024**3 # default 5 GiB
assert body["used_bytes"] > 0 # seeded messages have a size
async def test_thread_detail_messages_ascending(api: ApiCtx) -> None:
# find the kickoff thread (2 messages)
resp = await api.client.get("/api/threads")
+188
View File
@@ -0,0 +1,188 @@
"""Mirroring local drafts to the IMAP Drafts folder (continue on any client)."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import pytest_asyncio
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
from app.models import Account, Draft, Mailbox, User
from app.outbox import draft_sync
@dataclass
class Ctx:
session: AsyncSession
user_id: uuid.UUID
account_id: uuid.UUID
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"drf{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
display_name="Me",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
session.add(
Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts")
)
await session.commit()
try:
yield Ctx(session=session, user_id=user.id, account_id=account.id)
finally:
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
class FakeProvider:
def __init__(self) -> None:
self.appended: list[tuple[str, frozenset[str]]] = []
self.deleted: list[tuple[str, list[int]]] = []
self._uid = 41
async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int:
self.appended.append((mailbox, flags))
self._uid += 1
return self._uid
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
self.deleted.append((mailbox, list(uids)))
async def close(self) -> None:
return None
async def _make_draft(ctx: Ctx) -> uuid.UUID:
draft = Draft(
user_id=ctx.user_id,
account_id=ctx.account_id,
kind="new",
payload={"to": ["bob@example.com"], "subject": "Hi", "html": "<p>hi</p>", "text": ""},
)
ctx.session.add(draft)
await ctx.session.commit()
return draft.id
async def test_push_draft_appends_with_draft_flag_and_replaces(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
did = await _make_draft(ctx)
fake = FakeProvider()
monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake)
await draft_sync.push_draft_to_imap_job({}, str(did))
# appended once to DRAFTS with the \Draft flag; UID recorded on the draft
assert len(fake.appended) == 1
assert fake.appended[0][0] == "DRAFTS"
assert "\\Draft" in fake.appended[0][1]
assert fake.deleted == [] # nothing to replace yet
async with session_scope() as s:
assert (await s.get(Draft, did)).imap_uid == 42 # type: ignore[union-attr]
# A second push (edit) appends a new copy and expunges the previous UID.
await draft_sync.push_draft_to_imap_job({}, str(did))
assert len(fake.appended) == 2
assert fake.deleted == [("DRAFTS", [42])]
async with session_scope() as s:
assert (await s.get(Draft, did)).imap_uid == 43 # type: ignore[union-attr]
async def test_draft_messages_are_not_threaded(ctx: Ctx) -> None:
from datetime import UTC, datetime
from email.message import EmailMessage
from app.indexer.indexer import persist_message
from app.providers.base import RemoteMessage
m = EmailMessage()
m["Subject"] = "A draft reply"
m["From"] = "me@example.com"
m["To"] = "bob@example.com"
m["Message-ID"] = "<d1@x.com>"
m["In-Reply-To"] = "<orig@x.com>" # would normally thread it
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content("body")
drafts_mb = await ctx.session.scalar(
select(Mailbox).where(
Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts"
)
)
assert drafts_mb is not None
remote = RemoteMessage(
uid=71, raw=m.as_bytes(), flags=frozenset({"\\Draft"}), internal_date=datetime.now(UTC)
)
result = await persist_message(
ctx.session,
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=drafts_mb.id,
mailbox_special_use="drafts",
remote=remote,
)
await ctx.session.commit()
assert result is not None
assert result.message.thread_id is None # drafts stay out of conversations
async def test_remove_imap_draft_expunges(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
fake = FakeProvider()
monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake)
await draft_sync.remove_imap_draft_job({}, str(ctx.account_id), 7)
assert fake.deleted == [("DRAFTS", [7])]
async def test_no_drafts_mailbox_creates_one(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
# Drop the drafts mailbox so the job has to create it.
await ctx.session.execute(
delete(Mailbox).where(Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts")
)
await ctx.session.commit()
did = await _make_draft(ctx)
created: list[str] = []
class CreatingProvider(FakeProvider):
async def create_mailbox(self, path: str) -> None:
created.append(path)
async def list_mailboxes(self): # type: ignore[no-untyped-def]
from app.providers.base import RemoteMailbox
return [RemoteMailbox(path="Drafts", special_use="drafts", uidvalidity=1)]
fake = CreatingProvider()
monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake)
await draft_sync.push_draft_to_imap_job({}, str(did))
assert created == ["Drafts"]
async with session_scope() as s:
mb = await s.scalar(
select(Mailbox).where(
Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts"
)
)
assert mb is not None and mb.path == "Drafts"
+92 -4
View File
@@ -65,14 +65,18 @@ def test_build_mime_sets_threading_and_alternative() -> None:
"text": "",
},
)
raw, env_from, env_to = build_mime(account, draft, parent)
transmit_raw, sent_raw, env_from, env_to = build_mime(account, draft, parent)
assert env_from == "me@example.com"
assert set(env_to) == {"bob@example.com", "c@example.com", "secret@example.com"}
msg = email.message_from_bytes(raw, policy=default_policy)
msg = email.message_from_bytes(transmit_raw, policy=default_policy)
assert msg["From"] == "Me <me@example.com>"
assert msg["To"] == "bob@example.com"
assert "secret@example.com" not in (raw.decode()) # bcc not in headers
assert msg["Bcc"] is None # transmitted copy carries no Bcc
assert "secret@example.com" not in transmit_raw.decode() # bcc not transmitted
# ...but the Sent copy records the Bcc so the sender keeps the blind recipient.
sent_msg = email.message_from_bytes(sent_raw, policy=default_policy)
assert sent_msg["Bcc"] == "secret@example.com"
assert msg["In-Reply-To"] == "<orig@x.com>"
assert "<root@x.com>" in msg["References"] and "<orig@x.com>" in msg["References"]
# multipart/alternative with an auto-generated text part
@@ -96,7 +100,7 @@ def test_build_mime_includes_attachments() -> None:
kind="new",
payload={"to": ["bob@example.com"], "subject": "Files", "html": "<p>hi</p>", "text": ""},
)
raw, _from, _to = build_mime(
raw, _sent, _from, _to = build_mime(
account, draft, None, [("report.pdf", "application/pdf", b"%PDF-1.4 data")]
)
msg = email.message_from_bytes(raw, policy=default_policy)
@@ -219,6 +223,79 @@ async def test_draft_crud(ctx: Ctx) -> None:
assert deleted.status_code == 200
async def test_remote_drafts_listed_and_adopted(ctx: Ctx) -> None:
from datetime import UTC, datetime
from app.models import BodyPart
# A "foreign" draft (as if created in another client) in the Drafts folder.
drafts_mb = Mailbox(
account_id=ctx.account_id, user_id=ctx.user_id, path="DRAFTS", special_use="drafts"
)
ctx.session.add(drafts_mb)
await ctx.session.flush()
msg = Message(
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=drafts_mb.id,
uid=94,
subject="Home draft",
to_addr=[{"name": "", "email": "bob@example.com"}],
cc_addr=[],
bcc_addr=[],
internal_date=datetime.now(UTC),
blob_sha256="x" * 64,
snippet="hello there",
)
ctx.session.add(msg)
await ctx.session.flush()
ctx.session.add(BodyPart(message_id=msg.id, kind="html_sanitized", content="<p>hello</p>"))
ctx.session.add(BodyPart(message_id=msg.id, kind="text", content="hello there"))
await ctx.session.commit()
# It shows in the remote drafts list.
listing = await ctx.client.get("/api/drafts/remote")
assert listing.status_code == 200
assert any(i["message_id"] == str(msg.id) and i["imap_uid"] == 94 for i in listing.json())
# Adopting turns it into an editable local draft (linked by UID).
adopted = await ctx.client.post(f"/api/drafts/adopt/{msg.id}", headers=_hdr())
assert adopted.status_code == 200, adopted.text
draft = adopted.json()
assert draft["payload"]["subject"] == "Home draft"
assert draft["payload"]["to"] == ["bob@example.com"]
assert draft["payload"]["html"] == "<p>hello</p>"
# Now it's excluded from the remote list (we own that UID)...
listing2 = await ctx.client.get("/api/drafts/remote")
assert all(i["message_id"] != str(msg.id) for i in listing2.json())
# ...and adopting again returns the same local draft (idempotent).
again = await ctx.client.post(f"/api/drafts/adopt/{msg.id}", headers=_hdr())
assert again.json()["id"] == draft["id"]
async def test_publish_draft_enqueues_imap_mirror(ctx: Ctx) -> None:
did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"]
ctx.enqueued.clear()
resp = await ctx.client.post(f"/api/drafts/{did}/publish", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued[-1][0] == ("push_draft_to_imap_job", did)
async def test_delete_draft_expunges_mirror_when_present(ctx: Ctx) -> None:
did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"]
# Simulate a previously mirrored copy (imap_uid set).
draft = await ctx.session.get(Draft, uuid.UUID(did))
assert draft is not None
draft.imap_uid = 88
await ctx.session.commit()
ctx.enqueued.clear()
resp = await ctx.client.request("DELETE", f"/api/drafts/{did}", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued[-1][0] == ("remove_imap_draft_job", str(ctx.account_id), 88)
async def test_upload_and_list_compose_attachment(ctx: Ctx) -> None:
resp = await ctx.client.post(
"/api/attachments",
@@ -332,6 +409,17 @@ async def test_send_job_success(ctx: Ctx, monkeypatch) -> None: # type: ignore[
ctx.session.expunge_all()
async with session_scope() as verify:
assert await verify.get(Draft, draft_id) is None
# The sent copy is persisted into the Sent mailbox (append UID 99).
sent_mb = await verify.scalar(
select(Mailbox).where(
Mailbox.account_id == ctx.account_id, Mailbox.special_use == "sent"
)
)
assert sent_mb is not None
sent_msg = await verify.scalar(
select(Message).where(Message.mailbox_id == sent_mb.id, Message.uid == 99)
)
assert sent_msg is not None and sent_msg.subject == "Re: Hi"
kr = await ctx.session.scalar(
select(KnownRecipient).where(
KnownRecipient.user_id == ctx.user_id, KnownRecipient.email == "bob@example.com"
+99
View File
@@ -0,0 +1,99 @@
"""Secondary (non-INBOX) folder polling — multi-folder CONDSTORE sync (SPEC §6.3)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from email.message import EmailMessage
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
from app.models import Account, Mailbox, Message, User
from app.providers.base import MailboxDelta, RemoteMessage
from app.sync import engine as sync_engine
def _raw(subject: str, mid: str) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "me@example.com"
m["To"] = "bob@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content("body")
return m.as_bytes()
async def test_secondary_poll_syncs_special_folders_not_inbox(monkeypatch) -> None: # type: ignore[no-untyped-def]
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"sec{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
sent = Mailbox(account_id=account.id, user_id=user.id, path="SENT", special_use="sent")
custom = Mailbox(account_id=account.id, user_id=user.id, path="Notes", special_use=None)
session.add_all([inbox, sent, custom])
await session.commit()
polled: list[str] = []
class FakeProvider:
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
polled.append(mailbox)
if mailbox == "SENT":
return MailboxDelta(
[
RemoteMessage(
uid=5,
raw=_raw("Sent one", "<s1@x.com>"),
flags=frozenset({"\\Seen"}),
internal_date=datetime.now(UTC),
)
],
[],
[],
None,
None,
)
return MailboxDelta([], [], [], None, None)
async def close(self) -> None:
return None
monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider())
try:
await sync_engine._poll_secondary_once(account.id)
# Only the non-INBOX special-use folder is polled (SENT); INBOX and the
# custom folder (no special_use) are skipped.
assert polled == ["SENT"]
# The delta was applied: the sent message is now in the DB under SENT.
session.expunge_all()
n = await session.scalar(
select(func.count()).select_from(Message).where(Message.mailbox_id == sent.id)
)
assert n == 1
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
+5 -1
View File
@@ -10,6 +10,7 @@ import uuid
from typing import Any
from app.config import get_settings
from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job
from app.outbox.jobs import send_job
from app.queue import redis_settings as arq_redis_settings
from app.sync.actions import (
@@ -18,7 +19,7 @@ from app.sync.actions import (
move_thread_to_folder_job,
push_flags_job,
)
from app.sync.engine import bootstrap_and_backfill
from app.sync.engine import bootstrap_and_backfill, refresh_account_job
logger = logging.getLogger(__name__)
@@ -60,6 +61,9 @@ class WorkerSettings:
move_thread_to_folder_job,
delete_thread_job,
send_job,
push_draft_to_imap_job,
remove_imap_draft_job,
refresh_account_job,
]
on_startup = on_startup
on_shutdown = on_shutdown
+49 -12
View File
@@ -1,12 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
import { Icon } from "../Icon";
import { RecipientChips } from "./RecipientChips";
import { RichTextEditor } from "./RichTextEditor";
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
@@ -142,12 +142,26 @@ export function Composer({
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
const draftId = useRef<string | null>(init.draftId ?? null);
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
const undoTimer = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<RichTextEditorHandle>(null);
const account = accounts.find((a) => a.id === accountId);
const fromName = account?.display_name || "";
const fromEmail = account?.email ?? "";
const signature = account?.signature ?? "";
// When the From account changes, swap the signature block for the new one.
const firstAccount = useRef(true);
useEffect(() => {
if (firstAccount.current) {
firstAccount.current = false;
return;
}
editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId]);
// Fetch the original message's sanitized body to quote in a reply/forward.
useEffect(() => {
@@ -208,24 +222,24 @@ export function Composer({
const removeAttachment = (id: string) =>
setAttachments((prev) => prev.filter((a) => a.id !== id));
// The editor's seed content: an empty line to type into, then (for replies)
// the attribution line and the quoted original inside a blockquote.
const initialHtml = useMemo(() => {
// The editor's seed content. New messages get the account signature (2 blank
// lines above it); replies keep the quoted original in a marked block so the
// signature can later be inserted above it.
const initialHtml = (() => {
if (init.html) return init.html;
if (init.text) return textToHtml(init.text);
if (init.quote) {
return (
`<div><br></div>` +
`<div class="wmc-quoted">` +
`<div>${escapeHtml(init.quote.attribution)}</div>` +
`<blockquote>${quoteHtml}</blockquote>`
`<blockquote>${quoteHtml}</blockquote></div>`
);
}
return `<div><br></div>`;
// Only the quote resolving should (re)compute this; it mounts the editor once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quoteResolved]);
return `<div><br></div><div><br></div><div class="wmc-signature">${signature}</div>`;
})();
const bodyReady = quoteResolved;
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
const payload = (): DraftInput => {
const html = bodyHtml || initialHtml;
@@ -269,12 +283,18 @@ export function Composer({
useEffect(
() => () => {
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 (skip if it was sent or discarded).
if (draftId.current && !sentRef.current) {
void api.publishDraft(draftId.current).catch(() => {});
}
},
[],
);
const send = async () => {
setStatus("sending");
sentRef.current = true; // don't also mirror to Drafts on close
try {
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
@@ -282,6 +302,7 @@ export function Composer({
setUndo({ outboxId: res.outbox_id });
undoTimer.current = window.setTimeout(onClose, 10000);
} catch (e) {
sentRef.current = false;
setStatus("error");
if (!(e instanceof ApiError)) throw e;
}
@@ -295,6 +316,7 @@ export function Composer({
/* too late */
}
if (undoTimer.current) window.clearTimeout(undoTimer.current);
sentRef.current = false; // back to a draft; mirror it on close
setUndo(null);
setStatus("");
};
@@ -403,6 +425,16 @@ export function Composer({
>
{t("compose.cc")} / {t("compose.bcc")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover disabled:opacity-40"
disabled={!signature}
onClick={() => {
setMoreOpen(false);
editorRef.current?.insertSignature(signature);
}}
>
{t("compose.insertSignature")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
@@ -540,7 +572,12 @@ export function Composer({
{/* Body */}
{bodyReady ? (
<RichTextEditor initialHtml={initialHtml} onInput={setBodyHtml} showToolbar={showToolbar} />
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={setBodyHtml}
showToolbar={showToolbar}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
@@ -1,7 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useT } from "../../i18n";
// Imperative handle for the composer to manage the signature block.
export interface RichTextEditorHandle {
// Replace the current signature block's content (remove it if html is empty).
replaceSignature: (html: string) => void;
// Insert the signature below the body but above quoted content (reply menu).
insertSignature: (html: string) => void;
}
// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source
// of truth (uncontrolled) so the caret never jumps; we read innerHTML on input.
// Formatting uses document.execCommand — deprecated but universally supported and
@@ -125,17 +133,19 @@ const ICONS = {
),
} as const;
export function RichTextEditor({
initialHtml,
onInput,
onReady,
showToolbar = true,
}: {
initialHtml: string;
onInput: (html: string) => void;
onReady?: (el: HTMLDivElement) => void;
showToolbar?: boolean;
}) {
export const RichTextEditor = forwardRef<
RichTextEditorHandle,
{
initialHtml: string;
onInput: (html: string) => void;
onReady?: (el: HTMLDivElement) => void;
showToolbar?: boolean;
minHeight?: string; // Tailwind min-h-* class for the editable area
}
>(function RichTextEditor(
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]" },
handleRef,
) {
const t = useT();
const ref = useRef<HTMLDivElement>(null);
const savedRange = useRef<Range | null>(null);
@@ -153,6 +163,39 @@ export function RichTextEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useImperativeHandle(handleRef, () => ({
replaceSignature: (html: string) => {
const el = ref.current;
if (!el) return;
const sig = el.querySelector(".wmc-signature");
if (html) {
if (sig) sig.innerHTML = html;
} else if (sig) {
sig.remove();
}
onInput(el.innerHTML);
},
insertSignature: (html: string) => {
const el = ref.current;
if (!el || !html) return;
el.querySelector(".wmc-signature")?.remove();
const spacerBefore = document.createElement("div");
spacerBefore.innerHTML = "<br>";
const block = document.createElement("div");
block.className = "wmc-signature";
block.innerHTML = html;
const spacerAfter = document.createElement("div");
spacerAfter.innerHTML = "<br>";
const quoted = el.querySelector(".wmc-quoted");
const anchor = quoted ?? null;
// ...content, blank line, signature, blank line, [quoted]
el.insertBefore(spacerBefore, anchor);
el.insertBefore(block, anchor);
el.insertBefore(spacerAfter, anchor);
onInput(el.innerHTML);
},
}));
// Remember the caret/selection so native pickers (color/file) that steal focus
// can restore where formatting should apply.
const saveSelection = useCallback(() => {
@@ -419,8 +462,8 @@ export function RichTextEditor({
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
className="wmc-editor min-h-[250px] flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none"
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
</div>
);
}
});
+85 -24
View File
@@ -1,5 +1,5 @@
import { useT } from "../../i18n";
import type { DraftOut } from "../../lib/types";
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
function timeLabel(iso: string): string {
const d = new Date(iso);
@@ -9,41 +9,102 @@ function timeLabel(iso: string): string {
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
// Local drafts live in the `drafts` table (not an IMAP mailbox), so the Drafts
// folder lists them here rather than through the thread list.
function DraftRow({
recipients,
subject,
preview,
time,
badge,
onClick,
}: {
recipients: string;
subject: string;
preview: string;
time: string;
badge?: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="flex w-full flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left hover:bg-app-hover"
>
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
{badge && (
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[10px] text-app-muted">
{badge}
</span>
)}
<span className="shrink-0 text-[11px] text-app-faint">{time}</span>
</div>
<div className="truncate text-[13px] font-medium text-app-fg">{subject}</div>
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
</button>
);
}
// The Drafts folder lists local drafts (the `drafts` table) plus drafts found in
// the account's IMAP Drafts folder that were created on another client.
export function DraftsList({
drafts,
remoteDrafts,
onOpen,
onOpenRemote,
}: {
drafts: DraftOut[];
remoteDrafts: RemoteDraftOut[];
onOpen: (draft: DraftOut) => void;
onOpenRemote: (draft: RemoteDraftOut) => void;
}) {
const t = useT();
if (drafts.length === 0) {
if (drafts.length === 0 && remoteDrafts.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("compose.noDrafts")}</div>;
}
// Merge local + remote drafts into one list sorted by date (newest first).
type Row = {
key: string;
date: string;
recipients: string;
subject: string;
preview: string;
badge?: string;
onClick: () => void;
};
const rows: Row[] = [
...drafts.map((d) => ({
key: `l-${d.id}`,
date: d.updated_at,
recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
subject: d.payload.subject || t("compose.noSubject"),
preview: d.payload.text.trim(),
onClick: () => onOpen(d),
})),
...remoteDrafts.map((d) => ({
key: `r-${d.message_id}`,
date: d.updated_at,
recipients: d.to.join(", ") || t("compose.noRecipient"),
subject: d.subject || t("compose.noSubject"),
preview: d.snippet.trim(),
badge: t("compose.otherClient"),
onClick: () => onOpenRemote(d),
})),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return (
<div className="h-full overflow-y-auto" data-testid="drafts-list">
{drafts.map((draft) => {
const recipients = draft.payload.to.join(", ") || t("compose.noRecipient");
const preview = draft.payload.text.trim();
return (
<button
key={draft.id}
onClick={() => onOpen(draft)}
className="flex w-full flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left hover:bg-app-hover"
>
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(draft.updated_at)}</span>
</div>
<div className="truncate text-[13px] font-medium text-app-fg">
{draft.payload.subject || t("compose.noSubject")}
</div>
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
</button>
);
})}
{rows.map((row) => (
<DraftRow
key={row.key}
recipients={row.recipients}
subject={row.subject}
preview={row.preview}
time={timeLabel(row.date)}
badge={row.badge}
onClick={row.onClick}
/>
))}
</div>
);
}
+3 -8
View File
@@ -61,18 +61,13 @@ export function MessageList({
height: item.size,
transform: `translateY(${item.start}px)`,
}}
className={`flex flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left ${
className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${
selected
? "bg-app-active"
: "hover:bg-app-hover"
}`}
>
<div className="flex items-center gap-2">
{unread ? (
<span className="h-2 w-2 shrink-0 rounded-full bg-app-accent" />
) : (
<span className="h-2 w-2 shrink-0" />
)}
<span className="flex shrink-0 gap-0.5">
{thread.account_ids.map((id) => (
<span
@@ -95,11 +90,11 @@ export function MessageList({
)}
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
</div>
<div className={`truncate pl-4 text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
<div className={`truncate text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
{thread.subject}
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
</div>
<div className="truncate pl-4 text-xs text-app-faint">{thread.snippet}</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</button>
);
})}
@@ -120,6 +120,9 @@ function MessageItem({
{message.cc_addr.length > 0 && (
<span className="text-app-faint"> · cc: {formatList(message.cc_addr)}</span>
)}
{message.bcc_addr.length > 0 && (
<span className="text-app-faint"> · bcc: {formatList(message.bcc_addr)}</span>
)}
</span>
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
</span>
@@ -193,7 +196,7 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread
return (
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
<div className="sticky top-0 z-10 flex h-[44px] items-center justify-between border-b border-app-border bg-app-surface px-4">
<div className="sticky top-0 z-10 flex h-[44px] shrink-0 items-center justify-between border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<button
className="shrink-0 text-xs text-app-accent hover:underline"
+14
View File
@@ -10,6 +10,7 @@ export const en = {
"common.saving": "Saving…",
"common.delete": "Delete",
"common.remove": "Remove",
"common.edit": "Edit",
"common.close": "Close",
"common.back": "Back",
"common.next": "Next",
@@ -45,6 +46,8 @@ export const en = {
"nav.spam": "Spam",
"nav.trash": "Trash",
"nav.search": "Search mail…",
"nav.storageQuota": "Storage used",
"nav.refresh": "Refresh all folders",
"mail.noConversations": "No conversations.",
"mail.selectConversation": "Select a conversation.",
@@ -57,6 +60,8 @@ export const en = {
"filter.pinned": "Pinned",
"filter.sortOldest": "Sort oldest first",
"filter.sortNewest": "Sort newest first",
"pager.prev": "Previous page",
"pager.next": "Next page",
"mail.reply": "Reply",
"mail.replyAll": "Reply all",
"mail.forward": "Forward",
@@ -93,6 +98,7 @@ export const en = {
"compose.savingDraft": "Saving draft…",
"compose.draftSaved": "Draft saved",
"compose.noDrafts": "No drafts.",
"compose.otherClient": "other client",
"compose.noRecipient": "(no recipient)",
"compose.noSubject": "(no subject)",
"compose.from": "From",
@@ -118,6 +124,7 @@ export const en = {
"compose.fmtColor": "Text color",
"compose.fmtHighlight": "Highlight color",
"compose.fmtImage": "Insert image",
"compose.insertSignature": "Insert signature",
"compose.linkPrompt": "Link URL:",
"compose.imageTooLarge": "Image is too large (max 5 MB).",
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
@@ -172,6 +179,13 @@ export const en = {
"settings.addAccount": "Add account",
"settings.syncState": "State: {state}",
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
"settings.editAccount": "Edit account",
"settings.color": "Color",
"settings.serverSettings": "Change server settings / password",
"settings.serverSettingsHint": "Enter your mailbox password to apply server or password changes.",
"settings.signature": "Signature",
"settings.configureAccount": "Configure account",
"settings.editSignature": "Edit signature",
"settings.theme": "Theme",
"theme.light": "Light",
"theme.dark": "Dark",
+21
View File
@@ -9,7 +9,9 @@ import type {
DraftInput,
DraftOut,
MailboxOut,
QuotaOut,
RecipientSuggestion,
RemoteDraftOut,
SearchOut,
SendResponse,
ServerConfig,
@@ -85,6 +87,7 @@ export const api = {
accounts: () => apiFetch<AccountOut[]>("/accounts"),
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
threads: (view = "unified", cursor?: string) => {
const params = new URLSearchParams({ view });
@@ -118,6 +121,18 @@ export const api = {
smtp: ServerConfig;
password: string;
}) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }),
accountDetail: (id: string) => apiFetch<AccountDetail>(`/accounts/${id}`),
updateAccount: (
id: string,
patch: {
display_name?: string;
color?: string;
signature?: string;
imap?: ServerConfig;
smtp?: ServerConfig;
password?: string;
},
) => apiFetch<AccountDetail>(`/accounts/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteAccount: (id: string) => apiFetch<void>(`/accounts/${id}`, { method: "DELETE" }),
// --- identity ---
@@ -140,6 +155,7 @@ export const api = {
settings: () => apiFetch<UserSettings>("/settings"),
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"),
// --- compose ---
suggestRecipients: (q: string) =>
@@ -149,6 +165,8 @@ export const api = {
updateDraft: (id: string, input: DraftInput) =>
apiFetch<DraftOut>(`/drafts/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteDraft: (id: string) => apiFetch<void>(`/drafts/${id}`, { method: "DELETE" }),
publishDraft: (id: string) =>
apiFetch<{ status: string }>(`/drafts/${id}/publish`, { method: "POST" }),
send: (draftId: string) =>
apiFetch<SendResponse>("/send", { method: "POST", body: JSON.stringify({ draft_id: draftId }) }),
undoSend: (outboxId: string) =>
@@ -179,6 +197,9 @@ export const api = {
deleteThreadPermanent: (id: string) =>
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
adoptDraft: (messageId: string) =>
apiFetch<DraftOut>(`/drafts/adopt/${messageId}`, { method: "POST" }),
// --- attachments ---
messageAttachments: (messageId: string) =>
+19
View File
@@ -48,6 +48,7 @@ export interface MessageEnvelope {
from_addr: Address[];
to_addr: Address[];
cc_addr: Address[];
bcc_addr: Address[];
date: string;
flags: string[];
is_unread: boolean;
@@ -71,6 +72,7 @@ export interface AccountOut {
display_name: string;
color: string;
state: string;
signature: string;
}
export interface MailboxOut {
@@ -143,6 +145,11 @@ export interface UserSettings {
allowed_image_senders: string[];
}
export interface QuotaOut {
used_bytes: number;
quota_bytes: number;
}
// --- M1: compose -------------------------------------------------------------
export interface RecipientSuggestion {
@@ -182,3 +189,15 @@ export interface SendResponse {
outbox_id: string;
undo_until: string;
}
export interface RemoteDraftOut {
message_id: string;
account_id: string;
imap_uid: number;
subject: string;
to: string[];
cc: string[];
bcc: string[];
snippet: string;
updated_at: string;
}
+2
View File
@@ -45,6 +45,8 @@ export function connectWs(queryClient: QueryClient): () => void {
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
if (typeof payload.thread_id === "string") {
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
}
+155 -7
View File
@@ -12,9 +12,20 @@ import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import type { ThreadSummary } from "../lib/types";
import type { QuotaOut, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
function quotaLabel(q: QuotaOut): string {
const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0;
const pctStr = pct >= 10 ? String(Math.round(pct)) : String(Math.round(pct * 10) / 10);
const gb = q.quota_bytes / 1024 ** 3;
const gbStr = Number.isInteger(gb) ? String(gb) : gb.toFixed(1);
return `${pctStr}% of ${gbStr} GB`;
}
// Threads shown per page in the mail list.
const PAGE_SIZE = 50;
// 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" },
@@ -34,7 +45,11 @@ export function MailLayout() {
const [view, setView] = useState<string>("folder:inbox");
const [term, setTerm] = useState("");
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
const [listOrder, setListOrder] = useState<"newest" | "oldest">("newest");
const [listOrder, setListOrder] = useState<"newest" | "oldest">(() =>
localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest",
);
const [page, setPage] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [composing, setComposing] = useState<ComposerInit | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
@@ -48,6 +63,7 @@ export function MailLayout() {
const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
const isDrafts = view === "folder:drafts";
// Which special-use folder the reading pane is showing (drives the toolbar).
@@ -63,6 +79,11 @@ export function MailLayout() {
queryFn: api.listDrafts,
enabled: isDrafts,
});
const remoteDraftsQuery = useQuery({
queryKey: ["drafts-remote"],
queryFn: api.remoteDrafts,
enabled: isDrafts,
});
const searchQuery = useQuery({
queryKey: ["search", term],
queryFn: () => api.search(term),
@@ -70,6 +91,7 @@ export function MailLayout() {
});
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const allThreads: ThreadSummary[] = term.trim()
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
@@ -80,6 +102,16 @@ export function MailLayout() {
// The backend returns threads newest-first; reverse for an oldest-first view.
const threads = listOrder === "newest" ? filtered : [...filtered].reverse();
// Client-side pagination of the (filtered, ordered) list.
const total = threads.length;
const maxPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1);
const curPage = Math.min(page, maxPage);
const pageStart = curPage * PAGE_SIZE;
const pageEnd = Math.min(pageStart + PAGE_SIZE, total);
const pageThreads = threads.slice(pageStart, pageEnd);
// Reset to the first page whenever the underlying list identity changes.
useEffect(() => setPage(0), [view, filter, term, listOrder]);
const threadsRef = useRef<ThreadSummary[]>(threads);
threadsRef.current = threads;
@@ -101,9 +133,27 @@ export function MailLayout() {
const afterMove = () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
navigate("/mail");
};
// Trigger a server-side sync of all folders; refresh the views once the worker
// has had a moment to poll (live WebSocket events also update them).
const doRefresh = async () => {
setRefreshing(true);
try {
await api.syncNow();
} catch {
/* ignore */
}
window.setTimeout(() => {
for (const key of ["threads", "drafts", "drafts-remote", "quota"]) {
queryClient.invalidateQueries({ queryKey: [key] });
}
setRefreshing(false);
}, 2500);
};
const archive = async () => {
if (!threadId) return;
await api.archiveThread(threadId);
@@ -230,6 +280,14 @@ export function MailLayout() {
>
{t("common.signOut")}
</button>
{quotaQuery.data && (
<div
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-xs text-app-faint"
title={t("nav.storageQuota")}
>
{quotaLabel(quotaQuery.data)}
</div>
)}
</div>
</aside>
@@ -240,7 +298,7 @@ export function MailLayout() {
style={{ width: listW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
>
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-2.5">
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
<input
ref={searchRef}
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
@@ -250,7 +308,7 @@ export function MailLayout() {
/>
</div>
{!isDrafts && (
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border px-3 text-sm">
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
@@ -261,7 +319,7 @@ export function MailLayout() {
{t("filter.pinned")}
</FilterTab>
<button
className="ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
className="-mr-1.5 ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
@@ -274,13 +332,56 @@ export function MailLayout() {
{isDrafts && !term.trim() ? (
<DraftsList
drafts={draftsQuery.data ?? []}
remoteDrafts={remoteDraftsQuery.data ?? []}
onOpen={(draft) => {
setComposing(draftToInit(draft));
setComposerExpanded(true);
}}
onOpenRemote={async (remote) => {
// Adopt the server draft into an editable local draft, then open it.
const draft = await api.adoptDraft(remote.message_id);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
setComposing(draftToInit(draft));
setComposerExpanded(true);
}}
/>
) : (
<MessageList threads={threads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
<MessageList threads={pageThreads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted">
<button
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
title={t("nav.refresh")}
aria-label={t("nav.refresh")}
onClick={doRefresh}
disabled={refreshing}
>
<RefreshIcon spinning={refreshing} />
</button>
{!isDrafts && total > 0 ? (
<div className="flex items-center gap-3">
<span>
{pageStart + 1}–{pageEnd} of {total}
</span>
<div className="-mr-1 flex items-center gap-0.5">
<PagerButton
disabled={curPage === 0}
onClick={() => setPage(curPage - 1)}
label={t("pager.prev")}
dir="left"
/>
<PagerButton
disabled={pageEnd >= total}
onClick={() => setPage(curPage + 1)}
label={t("pager.next")}
dir="right"
/>
</div>
</div>
) : (
<span />
)}
</div>
</div>
@@ -291,7 +392,7 @@ export function MailLayout() {
<main className="relative min-w-0 flex-1 bg-app-surface">
{threadId && detailQuery.data ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
{currentFolder === "archive" ? (
<>
<ToolbarButton onClick={restore}>
@@ -369,6 +470,7 @@ export function MailLayout() {
setComposing(null);
setComposerExpanded(false);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
}}
/>
</div>
@@ -401,6 +503,52 @@ function FilterTab({
);
}
function PagerButton({
disabled,
onClick,
label,
dir,
}: {
disabled: boolean;
onClick: () => void;
label: string;
dir: "left" | "right";
}) {
return (
<button
disabled={disabled}
onClick={onClick}
title={label}
aria-label={label}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:pointer-events-none disabled:text-app-faint disabled:opacity-40"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d={dir === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"} />
</svg>
</button>
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={spinning ? "animate-spin" : ""}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2v3h-3" />
</svg>
);
}
function SortIcon() {
return (
<svg
+289 -19
View File
@@ -1,8 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { useMe } from "../../lib/auth";
@@ -53,7 +54,7 @@ function ProfileSection() {
const t = useT();
const { data } = useMe();
return (
<section className="max-w-md space-y-3">
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
@@ -114,7 +115,7 @@ function SecuritySection() {
});
return (
<section className="max-w-md space-y-6">
<section className="max-w-4xl space-y-6">
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
{enabled ? (
@@ -217,13 +218,21 @@ function AccountsSection() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
null,
);
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
});
const close = () => {
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["accounts"] });
};
return (
<section className="max-w-lg space-y-3">
<section className="max-w-5xl space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
<button
@@ -235,20 +244,44 @@ function AccountsSection() {
</div>
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{(accounts.data ?? []).map((a) => (
<li key={a.id} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
<span className="text-xs text-app-faint">{t("settings.syncState", { state: a.state })}</span>
</span>
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
}}
>
{t("common.remove")}
</button>
<li key={a.id} className="px-3 py-2 text-sm">
{editing?.id === a.id && editing.mode === "account" ? (
<AccountEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
) : editing?.id === a.id && editing.mode === "signature" ? (
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
) : (
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
<span className="text-xs text-app-faint">
{t("settings.syncState", { state: a.state })}
</span>
</span>
<span className="flex items-center gap-3">
<button
className="text-xs text-app-accent"
onClick={() => setEditing({ id: a.id, mode: "signature" })}
>
{t("settings.editSignature")}
</button>
<button
className="text-xs text-app-accent"
onClick={() => setEditing({ id: a.id, mode: "account" })}
>
{t("settings.configureAccount")}
</button>
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
}}
>
{t("common.remove")}
</button>
</span>
</div>
)}
</li>
))}
</ul>
@@ -256,6 +289,243 @@ function AccountsSection() {
);
}
function AccountEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId) });
const [displayName, setDisplayName] = useState("");
const [color, setColor] = useState("#888888");
const [showServer, setShowServer] = useState(false);
const [imapHost, setImapHost] = useState("");
const [imapPort, setImapPort] = useState("993");
const [imapTls, setImapTls] = useState("ssl");
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState("465");
const [smtpTls, setSmtpTls] = useState("ssl");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
const d = detail.data;
if (!d) return;
setDisplayName(d.display_name);
setColor(d.color);
setImapHost(String(d.imap_config.host ?? ""));
setImapPort(String(d.imap_config.port ?? "993"));
setImapTls(String(d.imap_config.tls ?? "ssl"));
setSmtpHost(String(d.smtp_config.host ?? ""));
setSmtpPort(String(d.smtp_config.port ?? "465"));
setSmtpTls(String(d.smtp_config.tls ?? "ssl"));
}, [detail.data]);
const save = useMutation({
mutationFn: () => {
const patch: Parameters<typeof api.updateAccount>[1] = {
display_name: displayName,
color,
};
if (showServer) {
patch.imap = { host: imapHost, port: Number(imapPort), tls: imapTls, auth: "password" };
patch.smtp = { host: smtpHost, port: Number(smtpPort), tls: smtpTls, auth: "password" };
patch.password = password;
}
return api.updateAccount(accountId, patch);
},
onSuccess: onSaved,
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
const canSave = !save.isPending && (!showServer || password.length > 0);
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
className="h-8 w-8 shrink-0 cursor-pointer rounded border border-app-border bg-transparent p-0.5"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label={t("settings.color")}
/>
<input
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1"
placeholder={t("wizard.displayName")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
{!showServer ? (
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
{t("settings.serverSettings")}
</button>
) : (
<div className="space-y-2 rounded border border-app-border p-2">
<p className="text-xs text-app-faint">{t("settings.serverSettingsHint")}</p>
<ServerRow
label={t("wizard.imapServer")}
host={imapHost}
port={imapPort}
tls={imapTls}
onHost={setImapHost}
onPort={setImapPort}
onTls={setImapTls}
/>
<ServerRow
label={t("wizard.smtpServer")}
host={smtpHost}
port={smtpPort}
tls={smtpTls}
onHost={setSmtpHost}
onPort={setSmtpPort}
onTls={setSmtpTls}
/>
<input
className="w-full rounded border border-app-border bg-app-surface px-2 py-1"
type="password"
placeholder={t("wizard.accountPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-2">
<button
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
disabled={!canSave}
onClick={() => {
setError("");
save.mutate();
}}
>
{save.isPending ? t("common.saving") : t("common.save")}
</button>
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
{t("common.cancel")}
</button>
</div>
</div>
);
}
function SignatureEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const detail = useQuery({
queryKey: ["account", accountId],
queryFn: () => api.accountDetail(accountId),
});
const [signature, setSignature] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (detail.data) setSignature(detail.data.signature);
}, [detail.data]);
const save = useMutation({
mutationFn: () => api.updateAccount(accountId, { signature }),
onSuccess: onSaved,
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
return (
<div className="space-y-2">
<div className="text-xs text-app-muted">
{t("settings.signature")} — {detail.data.email}
</div>
<div className="flex flex-col overflow-hidden rounded border border-app-border">
<RichTextEditor
initialHtml={detail.data.signature}
onInput={setSignature}
minHeight="min-h-[160px]"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-2">
<button
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
disabled={save.isPending}
onClick={() => {
setError("");
save.mutate();
}}
>
{save.isPending ? t("common.saving") : t("common.save")}
</button>
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
{t("common.cancel")}
</button>
</div>
</div>
);
}
function ServerRow({
label,
host,
port,
tls,
onHost,
onPort,
onTls,
}: {
label: string;
host: string;
port: string;
tls: string;
onHost: (v: string) => void;
onPort: (v: string) => void;
onTls: (v: string) => void;
}) {
const t = useT();
return (
<div className="flex items-center gap-1.5">
<span className="w-24 shrink-0 text-xs text-app-muted">{label}</span>
<input
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.host")}
value={host}
onChange={(e) => onHost(e.target.value)}
/>
<input
className="w-16 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.port")}
value={port}
onChange={(e) => onPort(e.target.value)}
/>
<select
className="rounded border border-app-border bg-app-surface px-1 py-1 text-sm"
value={tls}
onChange={(e) => onTls(e.target.value)}
aria-label={t("wizard.security")}
>
<option value="ssl">SSL</option>
<option value="starttls">STARTTLS</option>
</select>
</div>
);
}
function AppearanceSection() {
const t = useT();
const queryClient = useQueryClient();
@@ -267,7 +537,7 @@ function AppearanceSection() {
});
return (
<section className="max-w-md space-y-3">
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
<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>
+1
View File
@@ -17,6 +17,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }],
to_addr: [],
cc_addr: [],
bcc_addr: [],
date: new Date(2026, 0, 1, 12, minutes).toISOString(),
flags: unread ? [] : ["\\Seen"],
is_unread: unread,
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB