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):
|
class UpdateAccountRequest(BaseModel):
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
color: 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):
|
class AccountDetailOut(BaseModel):
|
||||||
@@ -70,5 +75,6 @@ class AccountDetailOut(BaseModel):
|
|||||||
color: str
|
color: str
|
||||||
state: str
|
state: str
|
||||||
state_detail: str
|
state_detail: str
|
||||||
|
signature: str
|
||||||
imap_config: dict[str, object]
|
imap_config: dict[str, object]
|
||||||
smtp_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.models import Account
|
||||||
from app.queue import get_arq_pool
|
from app.queue import get_arq_pool
|
||||||
from app.redis_client import get_redis
|
from app.redis_client import get_redis
|
||||||
|
from app.security.crypto import encrypt_secret
|
||||||
|
|
||||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||||
|
|
||||||
@@ -75,12 +76,27 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
|||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
AccountOut(
|
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
|
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)])
|
@router.post("/discover", response_model=DiscoverResponse, dependencies=[Depends(require_csrf)])
|
||||||
async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse:
|
async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse:
|
||||||
await _rate_limit_discover(user.id)
|
await _rate_limit_discover(user.id)
|
||||||
@@ -135,6 +151,25 @@ async def create_new_account(
|
|||||||
return _detail(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(
|
@router.patch(
|
||||||
"/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)]
|
"/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)]
|
||||||
)
|
)
|
||||||
@@ -150,7 +185,51 @@ async def update_account(
|
|||||||
account.display_name = body.display_name
|
account.display_name = body.display_name
|
||||||
if body.color is not None:
|
if body.color is not None:
|
||||||
account.color = body.color
|
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()
|
await db.commit()
|
||||||
|
|
||||||
|
if conn_change:
|
||||||
|
pool = await get_arq_pool()
|
||||||
|
await pool.enqueue_job("sync_bootstrap_account", str(account.id))
|
||||||
|
|
||||||
return _detail(account)
|
return _detail(account)
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +250,7 @@ def _detail(account: Account) -> AccountDetailOut:
|
|||||||
color=account.color,
|
color=account.color,
|
||||||
state=account.state,
|
state=account.state,
|
||||||
state_detail=account.state_detail,
|
state_detail=account.state_detail,
|
||||||
|
signature=account.signature,
|
||||||
imap_config=account.imap_config,
|
imap_config=account.imap_config,
|
||||||
smtp_config=account.smtp_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.db import get_session
|
||||||
from app.identity.deps import CurrentUser, require_csrf
|
from app.identity.deps import CurrentUser, require_csrf
|
||||||
from app.indexer import blobstore
|
from app.indexer import blobstore
|
||||||
from app.models import ComposeAttachment, Draft
|
from app.models import BodyPart, ComposeAttachment, Draft, Mailbox, Message
|
||||||
from app.outbox.schemas import DraftIn, DraftOut, SendRequest, SendResponse
|
from app.outbox.schemas import (
|
||||||
|
DraftIn,
|
||||||
|
DraftOut,
|
||||||
|
RemoteDraftOut,
|
||||||
|
SendRequest,
|
||||||
|
SendResponse,
|
||||||
|
)
|
||||||
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
|
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
|
||||||
from app.queue import get_arq_pool
|
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]
|
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)
|
@router.post("/drafts", response_model=DraftOut)
|
||||||
async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut:
|
async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut:
|
||||||
try:
|
try:
|
||||||
@@ -125,13 +229,29 @@ async def update_draft(
|
|||||||
return _draft_out(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}")
|
@router.delete("/drafts/{draft_id}")
|
||||||
async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
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))
|
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
|
||||||
if draft is None:
|
if draft is None:
|
||||||
raise_api_error(404, "not_found", "draft not found")
|
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.delete(draft)
|
||||||
await db.commit()
|
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"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class MessageEnvelope(BaseModel):
|
|||||||
from_addr: list[Address]
|
from_addr: list[Address]
|
||||||
to_addr: list[Address]
|
to_addr: list[Address]
|
||||||
cc_addr: list[Address]
|
cc_addr: list[Address]
|
||||||
|
bcc_addr: list[Address]
|
||||||
date: dt.datetime
|
date: dt.datetime
|
||||||
flags: list[str]
|
flags: list[str]
|
||||||
is_unread: bool
|
is_unread: bool
|
||||||
@@ -79,6 +80,7 @@ class AccountOut(BaseModel):
|
|||||||
display_name: str
|
display_name: str
|
||||||
color: str
|
color: str
|
||||||
state: str
|
state: str
|
||||||
|
signature: str = ""
|
||||||
|
|
||||||
|
|
||||||
class SearchThreadHit(BaseModel):
|
class SearchThreadHit(BaseModel):
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.identity.deps import CurrentUser, require_csrf
|
from app.identity.deps import CurrentUser, require_csrf
|
||||||
from app.models import User
|
from app.models import Message, User
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ class SettingsPatch(BaseModel):
|
|||||||
tz: str | None = None
|
tz: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaOut(BaseModel):
|
||||||
|
used_bytes: int
|
||||||
|
quota_bytes: int
|
||||||
|
|
||||||
|
|
||||||
def allowed_image_senders(user: User) -> set[str]:
|
def allowed_image_senders(user: User) -> set[str]:
|
||||||
value = user.settings.get("allowed_image_senders", [])
|
value = user.settings.get("allowed_image_senders", [])
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
@@ -59,6 +65,15 @@ async def get_settings_endpoint(user: CurrentUser) -> SettingsOut:
|
|||||||
return _settings_out(user)
|
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)])
|
@router.patch("", response_model=SettingsOut, dependencies=[Depends(require_csrf)])
|
||||||
async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut:
|
async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut:
|
||||||
updates = dict(user.settings)
|
updates = dict(user.settings)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ def _envelope(msg: Message) -> MessageEnvelope:
|
|||||||
from_addr=_addresses(msg.from_addr),
|
from_addr=_addresses(msg.from_addr),
|
||||||
to_addr=_addresses(msg.to_addr),
|
to_addr=_addresses(msg.to_addr),
|
||||||
cc_addr=_addresses(msg.cc_addr),
|
cc_addr=_addresses(msg.cc_addr),
|
||||||
|
bcc_addr=_addresses(msg.bcc_addr),
|
||||||
date=msg.date_hdr or msg.internal_date,
|
date=msg.date_hdr or msg.internal_date,
|
||||||
flags=list(msg.flags),
|
flags=list(msg.flags),
|
||||||
is_unread="\\Seen" not in msg.flags,
|
is_unread="\\Seen" not in msg.flags,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ async def persist_message(
|
|||||||
from_addr=parsed.from_addr,
|
from_addr=parsed.from_addr,
|
||||||
to_addr=parsed.to_addr,
|
to_addr=parsed.to_addr,
|
||||||
cc_addr=parsed.cc_addr,
|
cc_addr=parsed.cc_addr,
|
||||||
|
bcc_addr=parsed.bcc_addr,
|
||||||
date_hdr=parsed.date,
|
date_hdr=parsed.date,
|
||||||
internal_date=remote.internal_date or datetime.now(UTC),
|
internal_date=remote.internal_date or datetime.now(UTC),
|
||||||
flags=sorted(remote.flags),
|
flags=sorted(remote.flags),
|
||||||
@@ -192,7 +193,10 @@ async def persist_message(
|
|||||||
)
|
)
|
||||||
|
|
||||||
message.has_remote_images = has_remote_images
|
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:
|
if mailbox_special_use not in _NO_RECIPIENT_HARVEST:
|
||||||
await _harvest_recipient(session, message, parsed)
|
await _harvest_recipient(session, message, parsed)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ParsedEmail:
|
|||||||
from_addr: list[dict[str, str]]
|
from_addr: list[dict[str, str]]
|
||||||
to_addr: list[dict[str, str]]
|
to_addr: list[dict[str, str]]
|
||||||
cc_addr: list[dict[str, str]]
|
cc_addr: list[dict[str, str]]
|
||||||
|
bcc_addr: list[dict[str, str]]
|
||||||
date: datetime | None
|
date: datetime | None
|
||||||
text_body: str
|
text_body: str
|
||||||
html_body: str | None
|
html_body: str | None
|
||||||
@@ -140,6 +141,7 @@ def parse_message(raw: bytes) -> ParsedEmail:
|
|||||||
from_addr=_addresses(msg, "From"),
|
from_addr=_addresses(msg, "From"),
|
||||||
to_addr=_addresses(msg, "To"),
|
to_addr=_addresses(msg, "To"),
|
||||||
cc_addr=_addresses(msg, "Cc"),
|
cc_addr=_addresses(msg, "Cc"),
|
||||||
|
bcc_addr=_addresses(msg, "Bcc"),
|
||||||
date=date,
|
date=date,
|
||||||
text_body=text_body or "",
|
text_body=text_body or "",
|
||||||
html_body=html_body,
|
html_body=html_body,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import datetime as dt
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -54,6 +55,11 @@ class User(Base):
|
|||||||
settings: Mapped[dict[str, object]] = mapped_column(
|
settings: Mapped[dict[str, object]] = mapped_column(
|
||||||
JSONB, nullable=False, server_default=text("'{}'")
|
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()
|
created_at: Mapped[dt.datetime] = _now()
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +74,8 @@ class Account(Base):
|
|||||||
email: Mapped[str] = mapped_column(CITEXT, nullable=False)
|
email: Mapped[str] = mapped_column(CITEXT, nullable=False)
|
||||||
display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||||
color: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'#888888'"))
|
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)
|
imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
||||||
smtp_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)
|
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(
|
cc_addr: Mapped[list[dict[str, str]]] = mapped_column(
|
||||||
JSONB, nullable=False, server_default=text("'[]'")
|
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))
|
date_hdr: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||||
internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
flags: Mapped[list[str]] = mapped_column(
|
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'"))
|
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'reply'"))
|
||||||
payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
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()
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -19,17 +20,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
from app.events import get_event_bus
|
from app.events import get_event_bus
|
||||||
from app.indexer import blobstore
|
from app.indexer.indexer import persist_message
|
||||||
from app.models import (
|
from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox
|
||||||
Account,
|
from app.outbox.attachments import load_attachments
|
||||||
ComposeAttachment,
|
|
||||||
Draft,
|
|
||||||
KnownRecipient,
|
|
||||||
Mailbox,
|
|
||||||
Message,
|
|
||||||
Outbox,
|
|
||||||
)
|
|
||||||
from app.outbox.mime_build import build_mime
|
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
|
from app.sync.provider_factory import build_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -56,33 +52,56 @@ async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails:
|
|||||||
await session.execute(stmt)
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
async def _load_attachments(
|
async def _persist_sent_copy(
|
||||||
session: AsyncSession, user_id: uuid.UUID, draft: Draft
|
user_id: uuid.UUID, account: Account, raw: bytes, uid: int
|
||||||
) -> list[tuple[str, str, bytes]]:
|
) -> None:
|
||||||
"""Resolve a draft's compose-attachment ids into (filename, mime, data) tuples."""
|
"""Store the just-sent message in the DB (Sent mailbox) and notify the UI.
|
||||||
raw_ids = draft.payload.get("attachment_ids") or []
|
|
||||||
if not isinstance(raw_ids, list) or not raw_ids:
|
No-op without a UIDPLUS append UID (uid <= 0); in that case a future Sent sync
|
||||||
return []
|
would pick it up. persist_message dedupes on (mailbox_id, uid).
|
||||||
ids: list[uuid.UUID] = []
|
"""
|
||||||
for value in raw_ids:
|
if uid <= 0:
|
||||||
try:
|
return
|
||||||
ids.append(uuid.UUID(str(value)))
|
async with session_scope() as session:
|
||||||
except ValueError:
|
sent_mb = await session.scalar(
|
||||||
continue
|
select(Mailbox).where(
|
||||||
if not ids:
|
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
|
||||||
return []
|
)
|
||||||
rows = await session.scalars(
|
|
||||||
select(ComposeAttachment).where(
|
|
||||||
ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id
|
|
||||||
)
|
)
|
||||||
|
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}
|
if thread_id is not None:
|
||||||
out: list[tuple[str, str, bytes]] = []
|
await bus.publish(
|
||||||
for i in ids:
|
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
|
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"
|
outbox.error = "account missing"
|
||||||
return
|
return
|
||||||
user_id = outbox.user_id
|
user_id = outbox.user_id
|
||||||
attachments = await _load_attachments(session, user_id, draft)
|
attachments = await load_attachments(session, user_id, draft)
|
||||||
raw, envelope_from, envelope_to = build_mime(account, draft, reply_to, attachments)
|
transmit_raw, sent_raw, envelope_from, envelope_to = build_mime(
|
||||||
|
account, draft, reply_to, attachments
|
||||||
|
)
|
||||||
sent_path = await session.scalar(
|
sent_path = await session.scalar(
|
||||||
select(Mailbox.path).where(
|
select(Mailbox.path).where(
|
||||||
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
|
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)
|
gmail = _is_gmail(account)
|
||||||
account_obj = account
|
account_obj = account
|
||||||
|
|
||||||
provider = build_provider(account_obj)
|
provider = build_provider(account_obj)
|
||||||
|
appended_uid = 0
|
||||||
try:
|
try:
|
||||||
await provider.send(raw, envelope_from, envelope_to)
|
await provider.send(transmit_raw, envelope_from, envelope_to)
|
||||||
if sent_path and not gmail:
|
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
|
except Exception as exc: # noqa: BLE001 - record and notify, keep the draft
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
ob = await session.get(Outbox, oid)
|
ob = await session.get(Outbox, oid)
|
||||||
@@ -139,6 +171,10 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
|
|||||||
finally:
|
finally:
|
||||||
await provider.close()
|
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:
|
async with session_scope() as session:
|
||||||
ob = await session.get(Outbox, oid)
|
ob = await session.get(Outbox, oid)
|
||||||
if ob is not None:
|
if ob is not None:
|
||||||
|
|||||||
@@ -49,8 +49,13 @@ def build_mime(
|
|||||||
draft: Draft,
|
draft: Draft,
|
||||||
reply_to: Message | None,
|
reply_to: Message | None,
|
||||||
attachments: list[tuple[str, str, bytes]] | None = None,
|
attachments: list[tuple[str, str, bytes]] | None = None,
|
||||||
) -> tuple[bytes, str, list[str]]:
|
) -> tuple[bytes, bytes, str, list[str]]:
|
||||||
"""Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``.
|
"""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
|
``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the
|
||||||
compose Attach action; each is added as a file attachment part.
|
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)]
|
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
|
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):
|
class SendRequest(BaseModel):
|
||||||
draft_id: uuid.UUID
|
draft_id: uuid.UUID
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -274,3 +276,73 @@ async def live_loop(
|
|||||||
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
||||||
finally:
|
finally:
|
||||||
await provider.close()
|
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.db import session_scope
|
||||||
from app.models import Account
|
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__)
|
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)
|
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:
|
async def start_supervisor(ctx: dict[str, Any]) -> None:
|
||||||
"""Launch a live loop per live account (called from the worker on_startup)."""
|
"""Launch a live loop per live account (called from the worker on_startup)."""
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
account_ids = list(await session.scalars(select(Account.id).where(Account.state == "live")))
|
account_ids = list(await session.scalars(select(Account.id).where(Account.state == "live")))
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
ctx["supervisor_stop"] = stop
|
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))
|
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"])]
|
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 test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
async def fake_test(**_kw: object) -> None:
|
async def fake_test(**_kw: object) -> None:
|
||||||
return 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"
|
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:
|
async def test_thread_detail_messages_ascending(api: ApiCtx) -> None:
|
||||||
# find the kickoff thread (2 messages)
|
# find the kickoff thread (2 messages)
|
||||||
resp = await api.client.get("/api/threads")
|
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": "",
|
"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 env_from == "me@example.com"
|
||||||
assert set(env_to) == {"bob@example.com", "c@example.com", "secret@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["From"] == "Me <me@example.com>"
|
||||||
assert msg["To"] == "bob@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 msg["In-Reply-To"] == "<orig@x.com>"
|
||||||
assert "<root@x.com>" in msg["References"] and "<orig@x.com>" in msg["References"]
|
assert "<root@x.com>" in msg["References"] and "<orig@x.com>" in msg["References"]
|
||||||
# multipart/alternative with an auto-generated text part
|
# multipart/alternative with an auto-generated text part
|
||||||
@@ -96,7 +100,7 @@ def test_build_mime_includes_attachments() -> None:
|
|||||||
kind="new",
|
kind="new",
|
||||||
payload={"to": ["bob@example.com"], "subject": "Files", "html": "<p>hi</p>", "text": ""},
|
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")]
|
account, draft, None, [("report.pdf", "application/pdf", b"%PDF-1.4 data")]
|
||||||
)
|
)
|
||||||
msg = email.message_from_bytes(raw, policy=default_policy)
|
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
|
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:
|
async def test_upload_and_list_compose_attachment(ctx: Ctx) -> None:
|
||||||
resp = await ctx.client.post(
|
resp = await ctx.client.post(
|
||||||
"/api/attachments",
|
"/api/attachments",
|
||||||
@@ -332,6 +409,17 @@ async def test_send_job_success(ctx: Ctx, monkeypatch) -> None: # type: ignore[
|
|||||||
ctx.session.expunge_all()
|
ctx.session.expunge_all()
|
||||||
async with session_scope() as verify:
|
async with session_scope() as verify:
|
||||||
assert await verify.get(Draft, draft_id) is None
|
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(
|
kr = await ctx.session.scalar(
|
||||||
select(KnownRecipient).where(
|
select(KnownRecipient).where(
|
||||||
KnownRecipient.user_id == ctx.user_id, KnownRecipient.email == "bob@example.com"
|
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 typing import Any
|
||||||
|
|
||||||
from app.config import get_settings
|
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.outbox.jobs import send_job
|
||||||
from app.queue import redis_settings as arq_redis_settings
|
from app.queue import redis_settings as arq_redis_settings
|
||||||
from app.sync.actions import (
|
from app.sync.actions import (
|
||||||
@@ -18,7 +19,7 @@ from app.sync.actions import (
|
|||||||
move_thread_to_folder_job,
|
move_thread_to_folder_job,
|
||||||
push_flags_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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ class WorkerSettings:
|
|||||||
move_thread_to_folder_job,
|
move_thread_to_folder_job,
|
||||||
delete_thread_job,
|
delete_thread_job,
|
||||||
send_job,
|
send_job,
|
||||||
|
push_draft_to_imap_job,
|
||||||
|
remove_imap_draft_job,
|
||||||
|
refresh_account_job,
|
||||||
]
|
]
|
||||||
on_startup = on_startup
|
on_startup = on_startup
|
||||||
on_shutdown = on_shutdown
|
on_shutdown = on_shutdown
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
import { RecipientChips } from "./RecipientChips";
|
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
|
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 [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
|
||||||
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
|
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
|
||||||
const draftId = useRef<string | null>(init.draftId ?? 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 undoTimer = useRef<number | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const editorRef = useRef<RichTextEditorHandle>(null);
|
||||||
|
|
||||||
const account = accounts.find((a) => a.id === accountId);
|
const account = accounts.find((a) => a.id === accountId);
|
||||||
const fromName = account?.display_name || "";
|
const fromName = account?.display_name || "";
|
||||||
const fromEmail = account?.email ?? "";
|
const fromEmail = account?.email ?? "";
|
||||||
|
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.
|
// Fetch the original message's sanitized body to quote in a reply/forward.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,24 +222,24 @@ export function Composer({
|
|||||||
const removeAttachment = (id: string) =>
|
const removeAttachment = (id: string) =>
|
||||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
|
||||||
// The editor's seed content: an empty line to type into, then (for replies)
|
// The editor's seed content. New messages get the account signature (2 blank
|
||||||
// the attribution line and the quoted original inside a blockquote.
|
// lines above it); replies keep the quoted original in a marked block so the
|
||||||
const initialHtml = useMemo(() => {
|
// signature can later be inserted above it.
|
||||||
|
const initialHtml = (() => {
|
||||||
if (init.html) return init.html;
|
if (init.html) return init.html;
|
||||||
if (init.text) return textToHtml(init.text);
|
if (init.text) return textToHtml(init.text);
|
||||||
if (init.quote) {
|
if (init.quote) {
|
||||||
return (
|
return (
|
||||||
`<div><br></div>` +
|
`<div><br></div>` +
|
||||||
|
`<div class="wmc-quoted">` +
|
||||||
`<div>${escapeHtml(init.quote.attribution)}</div>` +
|
`<div>${escapeHtml(init.quote.attribution)}</div>` +
|
||||||
`<blockquote>${quoteHtml}</blockquote>`
|
`<blockquote>${quoteHtml}</blockquote></div>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return `<div><br></div>`;
|
return `<div><br></div><div><br></div><div class="wmc-signature">${signature}</div>`;
|
||||||
// Only the quote resolving should (re)compute this; it mounts the editor once.
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [quoteResolved]);
|
|
||||||
|
|
||||||
const bodyReady = quoteResolved;
|
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
|
||||||
|
|
||||||
const payload = (): DraftInput => {
|
const payload = (): DraftInput => {
|
||||||
const html = bodyHtml || initialHtml;
|
const html = bodyHtml || initialHtml;
|
||||||
@@ -269,12 +283,18 @@ export function Composer({
|
|||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||||
|
// On close, mirror the draft to the IMAP Drafts folder so it can be
|
||||||
|
// 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 () => {
|
const send = async () => {
|
||||||
setStatus("sending");
|
setStatus("sending");
|
||||||
|
sentRef.current = true; // don't also mirror to Drafts on close
|
||||||
try {
|
try {
|
||||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
||||||
else await api.updateDraft(draftId.current, payload());
|
else await api.updateDraft(draftId.current, payload());
|
||||||
@@ -282,6 +302,7 @@ export function Composer({
|
|||||||
setUndo({ outboxId: res.outbox_id });
|
setUndo({ outboxId: res.outbox_id });
|
||||||
undoTimer.current = window.setTimeout(onClose, 10000);
|
undoTimer.current = window.setTimeout(onClose, 10000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
sentRef.current = false;
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
if (!(e instanceof ApiError)) throw e;
|
if (!(e instanceof ApiError)) throw e;
|
||||||
}
|
}
|
||||||
@@ -295,6 +316,7 @@ export function Composer({
|
|||||||
/* too late */
|
/* too late */
|
||||||
}
|
}
|
||||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||||
|
sentRef.current = false; // back to a draft; mirror it on close
|
||||||
setUndo(null);
|
setUndo(null);
|
||||||
setStatus("");
|
setStatus("");
|
||||||
};
|
};
|
||||||
@@ -403,6 +425,16 @@ export function Composer({
|
|||||||
>
|
>
|
||||||
{t("compose.cc")} / {t("compose.bcc")}
|
{t("compose.cc")} / {t("compose.bcc")}
|
||||||
</button>
|
</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
|
<button
|
||||||
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
|
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -540,7 +572,12 @@ export function Composer({
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{bodyReady ? (
|
{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>
|
<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";
|
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
|
// 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.
|
// of truth (uncontrolled) so the caret never jumps; we read innerHTML on input.
|
||||||
// Formatting uses document.execCommand — deprecated but universally supported and
|
// Formatting uses document.execCommand — deprecated but universally supported and
|
||||||
@@ -125,17 +133,19 @@ const ICONS = {
|
|||||||
),
|
),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function RichTextEditor({
|
export const RichTextEditor = forwardRef<
|
||||||
initialHtml,
|
RichTextEditorHandle,
|
||||||
onInput,
|
{
|
||||||
onReady,
|
initialHtml: string;
|
||||||
showToolbar = true,
|
onInput: (html: string) => void;
|
||||||
}: {
|
onReady?: (el: HTMLDivElement) => void;
|
||||||
initialHtml: string;
|
showToolbar?: boolean;
|
||||||
onInput: (html: string) => void;
|
minHeight?: string; // Tailwind min-h-* class for the editable area
|
||||||
onReady?: (el: HTMLDivElement) => void;
|
}
|
||||||
showToolbar?: boolean;
|
>(function RichTextEditor(
|
||||||
}) {
|
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]" },
|
||||||
|
handleRef,
|
||||||
|
) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const savedRange = useRef<Range | null>(null);
|
const savedRange = useRef<Range | null>(null);
|
||||||
@@ -153,6 +163,39 @@ export function RichTextEditor({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Remember the caret/selection so native pickers (color/file) that steal focus
|
||||||
// can restore where formatting should apply.
|
// can restore where formatting should apply.
|
||||||
const saveSelection = useCallback(() => {
|
const saveSelection = useCallback(() => {
|
||||||
@@ -419,8 +462,8 @@ export function RichTextEditor({
|
|||||||
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
|
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
|
||||||
onKeyUp={refreshActive}
|
onKeyUp={refreshActive}
|
||||||
onMouseUp={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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import type { DraftOut } from "../../lib/types";
|
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
|
||||||
|
|
||||||
function timeLabel(iso: string): string {
|
function timeLabel(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -9,41 +9,102 @@ function timeLabel(iso: string): string {
|
|||||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local drafts live in the `drafts` table (not an IMAP mailbox), so the Drafts
|
function DraftRow({
|
||||||
// folder lists them here rather than through the thread list.
|
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({
|
export function DraftsList({
|
||||||
drafts,
|
drafts,
|
||||||
|
remoteDrafts,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onOpenRemote,
|
||||||
}: {
|
}: {
|
||||||
drafts: DraftOut[];
|
drafts: DraftOut[];
|
||||||
|
remoteDrafts: RemoteDraftOut[];
|
||||||
onOpen: (draft: DraftOut) => void;
|
onOpen: (draft: DraftOut) => void;
|
||||||
|
onOpenRemote: (draft: RemoteDraftOut) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
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>;
|
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 (
|
return (
|
||||||
<div className="h-full overflow-y-auto" data-testid="drafts-list">
|
<div className="h-full overflow-y-auto" data-testid="drafts-list">
|
||||||
{drafts.map((draft) => {
|
{rows.map((row) => (
|
||||||
const recipients = draft.payload.to.join(", ") || t("compose.noRecipient");
|
<DraftRow
|
||||||
const preview = draft.payload.text.trim();
|
key={row.key}
|
||||||
return (
|
recipients={row.recipients}
|
||||||
<button
|
subject={row.subject}
|
||||||
key={draft.id}
|
preview={row.preview}
|
||||||
onClick={() => onOpen(draft)}
|
time={timeLabel(row.date)}
|
||||||
className="flex w-full flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left hover:bg-app-hover"
|
badge={row.badge}
|
||||||
>
|
onClick={row.onClick}
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,18 +61,13 @@ export function MessageList({
|
|||||||
height: item.size,
|
height: item.size,
|
||||||
transform: `translateY(${item.start}px)`,
|
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
|
selected
|
||||||
? "bg-app-active"
|
? "bg-app-active"
|
||||||
: "hover:bg-app-hover"
|
: "hover:bg-app-hover"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<span className="flex shrink-0 gap-0.5">
|
||||||
{thread.account_ids.map((id) => (
|
{thread.account_ids.map((id) => (
|
||||||
<span
|
<span
|
||||||
@@ -95,11 +90,11 @@ export function MessageList({
|
|||||||
)}
|
)}
|
||||||
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
|
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
|
||||||
</div>
|
</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.subject}
|
||||||
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
|
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ function MessageItem({
|
|||||||
{message.cc_addr.length > 0 && (
|
{message.cc_addr.length > 0 && (
|
||||||
<span className="text-app-faint"> · cc: {formatList(message.cc_addr)}</span>
|
<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>
|
||||||
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
|
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -193,7 +196,7 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
<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>
|
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
||||||
<button
|
<button
|
||||||
className="shrink-0 text-xs text-app-accent hover:underline"
|
className="shrink-0 text-xs text-app-accent hover:underline"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const en = {
|
|||||||
"common.saving": "Saving…",
|
"common.saving": "Saving…",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
"common.remove": "Remove",
|
"common.remove": "Remove",
|
||||||
|
"common.edit": "Edit",
|
||||||
"common.close": "Close",
|
"common.close": "Close",
|
||||||
"common.back": "Back",
|
"common.back": "Back",
|
||||||
"common.next": "Next",
|
"common.next": "Next",
|
||||||
@@ -45,6 +46,8 @@ export const en = {
|
|||||||
"nav.spam": "Spam",
|
"nav.spam": "Spam",
|
||||||
"nav.trash": "Trash",
|
"nav.trash": "Trash",
|
||||||
"nav.search": "Search mail…",
|
"nav.search": "Search mail…",
|
||||||
|
"nav.storageQuota": "Storage used",
|
||||||
|
"nav.refresh": "Refresh all folders",
|
||||||
|
|
||||||
"mail.noConversations": "No conversations.",
|
"mail.noConversations": "No conversations.",
|
||||||
"mail.selectConversation": "Select a conversation.",
|
"mail.selectConversation": "Select a conversation.",
|
||||||
@@ -57,6 +60,8 @@ export const en = {
|
|||||||
"filter.pinned": "Pinned",
|
"filter.pinned": "Pinned",
|
||||||
"filter.sortOldest": "Sort oldest first",
|
"filter.sortOldest": "Sort oldest first",
|
||||||
"filter.sortNewest": "Sort newest first",
|
"filter.sortNewest": "Sort newest first",
|
||||||
|
"pager.prev": "Previous page",
|
||||||
|
"pager.next": "Next page",
|
||||||
"mail.reply": "Reply",
|
"mail.reply": "Reply",
|
||||||
"mail.replyAll": "Reply all",
|
"mail.replyAll": "Reply all",
|
||||||
"mail.forward": "Forward",
|
"mail.forward": "Forward",
|
||||||
@@ -93,6 +98,7 @@ export const en = {
|
|||||||
"compose.savingDraft": "Saving draft…",
|
"compose.savingDraft": "Saving draft…",
|
||||||
"compose.draftSaved": "Draft saved",
|
"compose.draftSaved": "Draft saved",
|
||||||
"compose.noDrafts": "No drafts.",
|
"compose.noDrafts": "No drafts.",
|
||||||
|
"compose.otherClient": "other client",
|
||||||
"compose.noRecipient": "(no recipient)",
|
"compose.noRecipient": "(no recipient)",
|
||||||
"compose.noSubject": "(no subject)",
|
"compose.noSubject": "(no subject)",
|
||||||
"compose.from": "From",
|
"compose.from": "From",
|
||||||
@@ -118,6 +124,7 @@ export const en = {
|
|||||||
"compose.fmtColor": "Text color",
|
"compose.fmtColor": "Text color",
|
||||||
"compose.fmtHighlight": "Highlight color",
|
"compose.fmtHighlight": "Highlight color",
|
||||||
"compose.fmtImage": "Insert image",
|
"compose.fmtImage": "Insert image",
|
||||||
|
"compose.insertSignature": "Insert signature",
|
||||||
"compose.linkPrompt": "Link URL:",
|
"compose.linkPrompt": "Link URL:",
|
||||||
"compose.imageTooLarge": "Image is too large (max 5 MB).",
|
"compose.imageTooLarge": "Image is too large (max 5 MB).",
|
||||||
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
|
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
|
||||||
@@ -172,6 +179,13 @@ export const en = {
|
|||||||
"settings.addAccount": "Add account",
|
"settings.addAccount": "Add account",
|
||||||
"settings.syncState": "State: {state}",
|
"settings.syncState": "State: {state}",
|
||||||
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
||||||
|
"settings.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",
|
"settings.theme": "Theme",
|
||||||
"theme.light": "Light",
|
"theme.light": "Light",
|
||||||
"theme.dark": "Dark",
|
"theme.dark": "Dark",
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import type {
|
|||||||
DraftInput,
|
DraftInput,
|
||||||
DraftOut,
|
DraftOut,
|
||||||
MailboxOut,
|
MailboxOut,
|
||||||
|
QuotaOut,
|
||||||
RecipientSuggestion,
|
RecipientSuggestion,
|
||||||
|
RemoteDraftOut,
|
||||||
SearchOut,
|
SearchOut,
|
||||||
SendResponse,
|
SendResponse,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
@@ -85,6 +87,7 @@ export const api = {
|
|||||||
|
|
||||||
accounts: () => apiFetch<AccountOut[]>("/accounts"),
|
accounts: () => apiFetch<AccountOut[]>("/accounts"),
|
||||||
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
|
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
|
||||||
|
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
|
||||||
|
|
||||||
threads: (view = "unified", cursor?: string) => {
|
threads: (view = "unified", cursor?: string) => {
|
||||||
const params = new URLSearchParams({ view });
|
const params = new URLSearchParams({ view });
|
||||||
@@ -118,6 +121,18 @@ export const api = {
|
|||||||
smtp: ServerConfig;
|
smtp: ServerConfig;
|
||||||
password: string;
|
password: string;
|
||||||
}) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }),
|
}) => 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" }),
|
deleteAccount: (id: string) => apiFetch<void>(`/accounts/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// --- identity ---
|
// --- identity ---
|
||||||
@@ -140,6 +155,7 @@ export const api = {
|
|||||||
settings: () => apiFetch<UserSettings>("/settings"),
|
settings: () => apiFetch<UserSettings>("/settings"),
|
||||||
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
|
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
|
||||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||||
|
|
||||||
// --- compose ---
|
// --- compose ---
|
||||||
suggestRecipients: (q: string) =>
|
suggestRecipients: (q: string) =>
|
||||||
@@ -149,6 +165,8 @@ export const api = {
|
|||||||
updateDraft: (id: string, input: DraftInput) =>
|
updateDraft: (id: string, input: DraftInput) =>
|
||||||
apiFetch<DraftOut>(`/drafts/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
apiFetch<DraftOut>(`/drafts/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||||
deleteDraft: (id: string) => apiFetch<void>(`/drafts/${id}`, { method: "DELETE" }),
|
deleteDraft: (id: string) => apiFetch<void>(`/drafts/${id}`, { method: "DELETE" }),
|
||||||
|
publishDraft: (id: string) =>
|
||||||
|
apiFetch<{ status: string }>(`/drafts/${id}/publish`, { method: "POST" }),
|
||||||
send: (draftId: string) =>
|
send: (draftId: string) =>
|
||||||
apiFetch<SendResponse>("/send", { method: "POST", body: JSON.stringify({ draft_id: draftId }) }),
|
apiFetch<SendResponse>("/send", { method: "POST", body: JSON.stringify({ draft_id: draftId }) }),
|
||||||
undoSend: (outboxId: string) =>
|
undoSend: (outboxId: string) =>
|
||||||
@@ -179,6 +197,9 @@ export const api = {
|
|||||||
deleteThreadPermanent: (id: string) =>
|
deleteThreadPermanent: (id: string) =>
|
||||||
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
|
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
|
||||||
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
|
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
|
||||||
|
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
|
||||||
|
adoptDraft: (messageId: string) =>
|
||||||
|
apiFetch<DraftOut>(`/drafts/adopt/${messageId}`, { method: "POST" }),
|
||||||
|
|
||||||
// --- attachments ---
|
// --- attachments ---
|
||||||
messageAttachments: (messageId: string) =>
|
messageAttachments: (messageId: string) =>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface MessageEnvelope {
|
|||||||
from_addr: Address[];
|
from_addr: Address[];
|
||||||
to_addr: Address[];
|
to_addr: Address[];
|
||||||
cc_addr: Address[];
|
cc_addr: Address[];
|
||||||
|
bcc_addr: Address[];
|
||||||
date: string;
|
date: string;
|
||||||
flags: string[];
|
flags: string[];
|
||||||
is_unread: boolean;
|
is_unread: boolean;
|
||||||
@@ -71,6 +72,7 @@ export interface AccountOut {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
color: string;
|
color: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
signature: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MailboxOut {
|
export interface MailboxOut {
|
||||||
@@ -143,6 +145,11 @@ export interface UserSettings {
|
|||||||
allowed_image_senders: string[];
|
allowed_image_senders: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuotaOut {
|
||||||
|
used_bytes: number;
|
||||||
|
quota_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
// --- M1: compose -------------------------------------------------------------
|
// --- M1: compose -------------------------------------------------------------
|
||||||
|
|
||||||
export interface RecipientSuggestion {
|
export interface RecipientSuggestion {
|
||||||
@@ -182,3 +189,15 @@ export interface SendResponse {
|
|||||||
outbox_id: string;
|
outbox_id: string;
|
||||||
undo_until: 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).
|
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
|
||||||
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||||
if (typeof payload.thread_id === "string") {
|
if (typeof payload.thread_id === "string") {
|
||||||
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
|
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ import { ThreadView } from "../components/thread/ThreadView";
|
|||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import type { MessageKey } from "../i18n/en";
|
import type { MessageKey } from "../i18n/en";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { ThreadSummary } from "../lib/types";
|
import type { QuotaOut, ThreadSummary } from "../lib/types";
|
||||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
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".
|
// Unified per-folder views. "Spam" maps to the IMAP special-use "junk".
|
||||||
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
|
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
|
||||||
{ view: "folder:inbox", icon: "inbox", label: "nav.inbox" },
|
{ view: "folder:inbox", icon: "inbox", label: "nav.inbox" },
|
||||||
@@ -34,7 +45,11 @@ export function MailLayout() {
|
|||||||
const [view, setView] = useState<string>("folder:inbox");
|
const [view, setView] = useState<string>("folder:inbox");
|
||||||
const [term, setTerm] = useState("");
|
const [term, setTerm] = useState("");
|
||||||
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
|
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 [composing, setComposing] = useState<ComposerInit | null>(null);
|
||||||
const [composerExpanded, setComposerExpanded] = useState(false);
|
const [composerExpanded, setComposerExpanded] = useState(false);
|
||||||
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
|
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);
|
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.sidebar", String(sidebarW)), [sidebarW]);
|
||||||
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
|
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
|
||||||
|
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
||||||
|
|
||||||
const isDrafts = view === "folder:drafts";
|
const isDrafts = view === "folder:drafts";
|
||||||
// Which special-use folder the reading pane is showing (drives the toolbar).
|
// Which special-use folder the reading pane is showing (drives the toolbar).
|
||||||
@@ -63,6 +79,11 @@ export function MailLayout() {
|
|||||||
queryFn: api.listDrafts,
|
queryFn: api.listDrafts,
|
||||||
enabled: isDrafts,
|
enabled: isDrafts,
|
||||||
});
|
});
|
||||||
|
const remoteDraftsQuery = useQuery({
|
||||||
|
queryKey: ["drafts-remote"],
|
||||||
|
queryFn: api.remoteDrafts,
|
||||||
|
enabled: isDrafts,
|
||||||
|
});
|
||||||
const searchQuery = useQuery({
|
const searchQuery = useQuery({
|
||||||
queryKey: ["search", term],
|
queryKey: ["search", term],
|
||||||
queryFn: () => api.search(term),
|
queryFn: () => api.search(term),
|
||||||
@@ -70,6 +91,7 @@ export function MailLayout() {
|
|||||||
});
|
});
|
||||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||||
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
||||||
|
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
||||||
|
|
||||||
const allThreads: ThreadSummary[] = term.trim()
|
const allThreads: ThreadSummary[] = term.trim()
|
||||||
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
|
? (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.
|
// The backend returns threads newest-first; reverse for an oldest-first view.
|
||||||
const threads = listOrder === "newest" ? filtered : [...filtered].reverse();
|
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);
|
const threadsRef = useRef<ThreadSummary[]>(threads);
|
||||||
threadsRef.current = threads;
|
threadsRef.current = threads;
|
||||||
|
|
||||||
@@ -101,9 +133,27 @@ export function MailLayout() {
|
|||||||
|
|
||||||
const afterMove = () => {
|
const afterMove = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||||
navigate("/mail");
|
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 () => {
|
const archive = async () => {
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
await api.archiveThread(threadId);
|
await api.archiveThread(threadId);
|
||||||
@@ -230,6 +280,14 @@ export function MailLayout() {
|
|||||||
>
|
>
|
||||||
{t("common.signOut")}
|
{t("common.signOut")}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -240,7 +298,7 @@ export function MailLayout() {
|
|||||||
style={{ width: listW }}
|
style={{ width: listW }}
|
||||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
|
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
|
<input
|
||||||
ref={searchRef}
|
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"
|
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>
|
</div>
|
||||||
{!isDrafts && (
|
{!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")}>
|
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
|
||||||
{t("filter.all")}
|
{t("filter.all")}
|
||||||
</FilterTab>
|
</FilterTab>
|
||||||
@@ -261,7 +319,7 @@ export function MailLayout() {
|
|||||||
{t("filter.pinned")}
|
{t("filter.pinned")}
|
||||||
</FilterTab>
|
</FilterTab>
|
||||||
<button
|
<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")}
|
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
|
||||||
aria-label={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"))}
|
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
|
||||||
@@ -274,13 +332,56 @@ export function MailLayout() {
|
|||||||
{isDrafts && !term.trim() ? (
|
{isDrafts && !term.trim() ? (
|
||||||
<DraftsList
|
<DraftsList
|
||||||
drafts={draftsQuery.data ?? []}
|
drafts={draftsQuery.data ?? []}
|
||||||
|
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||||||
onOpen={(draft) => {
|
onOpen={(draft) => {
|
||||||
setComposing(draftToInit(draft));
|
setComposing(draftToInit(draft));
|
||||||
setComposerExpanded(true);
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +392,7 @@ export function MailLayout() {
|
|||||||
<main className="relative min-w-0 flex-1 bg-app-surface">
|
<main className="relative min-w-0 flex-1 bg-app-surface">
|
||||||
{threadId && detailQuery.data ? (
|
{threadId && detailQuery.data ? (
|
||||||
<div className="flex h-full flex-col">
|
<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" ? (
|
{currentFolder === "archive" ? (
|
||||||
<>
|
<>
|
||||||
<ToolbarButton onClick={restore}>
|
<ToolbarButton onClick={restore}>
|
||||||
@@ -369,6 +470,7 @@ export function MailLayout() {
|
|||||||
setComposing(null);
|
setComposing(null);
|
||||||
setComposerExpanded(false);
|
setComposerExpanded(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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() {
|
function SortIcon() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
import { useMe } from "../../lib/auth";
|
import { useMe } from "../../lib/auth";
|
||||||
@@ -53,7 +54,7 @@ function ProfileSection() {
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const { data } = useMe();
|
const { data } = useMe();
|
||||||
return (
|
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>
|
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
||||||
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
||||||
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
|
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
|
||||||
@@ -114,7 +115,7 @@ function SecuritySection() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="max-w-md space-y-6">
|
<section className="max-w-4xl space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
|
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
@@ -217,13 +218,21 @@ function AccountsSection() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||||
|
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => api.deleteAccount(id),
|
mutationFn: (id: string) => api.deleteAccount(id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setEditing(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="max-w-lg space-y-3">
|
<section className="max-w-5xl space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
|
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
|
||||||
<button
|
<button
|
||||||
@@ -235,20 +244,44 @@ function AccountsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
||||||
{(accounts.data ?? []).map((a) => (
|
{(accounts.data ?? []).map((a) => (
|
||||||
<li key={a.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
<li key={a.id} className="px-3 py-2 text-sm">
|
||||||
<span className="flex items-center gap-2">
|
{editing?.id === a.id && editing.mode === "account" ? (
|
||||||
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
|
<AccountEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
|
||||||
<span>{a.email}</span>
|
) : editing?.id === a.id && editing.mode === "signature" ? (
|
||||||
<span className="text-xs text-app-faint">{t("settings.syncState", { state: a.state })}</span>
|
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
|
||||||
</span>
|
) : (
|
||||||
<button
|
<div className="flex items-center justify-between">
|
||||||
className="text-xs text-red-500"
|
<span className="flex items-center gap-2">
|
||||||
onClick={() => {
|
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
|
||||||
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
|
<span>{a.email}</span>
|
||||||
}}
|
<span className="text-xs text-app-faint">
|
||||||
>
|
{t("settings.syncState", { state: a.state })}
|
||||||
{t("common.remove")}
|
</span>
|
||||||
</button>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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() {
|
function AppearanceSection() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -267,7 +537,7 @@ function AppearanceSection() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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>
|
<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">
|
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||||
<span className="text-app-muted">{t("settings.theme")}</span>
|
<span className="text-app-muted">{t("settings.theme")}</span>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
|
|||||||
from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }],
|
from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }],
|
||||||
to_addr: [],
|
to_addr: [],
|
||||||
cc_addr: [],
|
cc_addr: [],
|
||||||
|
bcc_addr: [],
|
||||||
date: new Date(2026, 0, 1, 12, minutes).toISOString(),
|
date: new Date(2026, 0, 1, 12, minutes).toISOString(),
|
||||||
flags: unread ? [] : ["\\Seen"],
|
flags: unread ? [] : ["\\Seen"],
|
||||||
is_unread: unread,
|
is_unread: unread,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Reference in New Issue
Block a user