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