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:
@@ -146,6 +146,114 @@ async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # t
|
||||
assert enqueued == [("sync_bootstrap_account", body["id"])]
|
||||
|
||||
|
||||
async def _make_account(ctx: Ctx, monkeypatch) -> str: # type: ignore[no-untyped-def]
|
||||
async def fake_test(**_kw: object) -> None:
|
||||
return None
|
||||
|
||||
class FakePool:
|
||||
async def enqueue_job(self, name: str, arg: str) -> None:
|
||||
return None
|
||||
|
||||
async def fake_pool() -> FakePool:
|
||||
return FakePool()
|
||||
|
||||
monkeypatch.setattr(accounts_api, "test_account", fake_test)
|
||||
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
|
||||
resp = await ctx.client.post(
|
||||
"/api/accounts",
|
||||
json={**GOOD_CFG, "email": "me@example.com", "display_name": "Me", "password": "secret"},
|
||||
headers=_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return str(resp.json()["id"])
|
||||
|
||||
|
||||
async def test_sync_all_enqueues_refresh_per_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
aid = await _make_account(ctx, monkeypatch)
|
||||
|
||||
enqueued: list[tuple[str, str]] = []
|
||||
|
||||
class FakePool:
|
||||
async def enqueue_job(self, name: str, arg: str) -> None:
|
||||
enqueued.append((name, arg))
|
||||
|
||||
async def fake_pool() -> FakePool:
|
||||
return FakePool()
|
||||
|
||||
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
|
||||
resp = await ctx.client.post("/api/accounts/sync", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert enqueued == [("refresh_account_job", aid)]
|
||||
|
||||
|
||||
async def test_update_display_and_color_no_resync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
aid = await _make_account(ctx, monkeypatch)
|
||||
resp = await ctx.client.patch(
|
||||
f"/api/accounts/{aid}",
|
||||
json={"display_name": "New Name", "color": "#00ff00"},
|
||||
headers=_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["display_name"] == "New Name" and body["color"] == "#00ff00"
|
||||
|
||||
|
||||
async def test_update_signature(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
aid = await _make_account(ctx, monkeypatch)
|
||||
resp = await ctx.client.patch(
|
||||
f"/api/accounts/{aid}", json={"signature": "<p>Best,<br>Me</p>"}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["signature"] == "<p>Best,<br>Me</p>"
|
||||
# The accounts list also carries the signature (used by the composer).
|
||||
listing = await ctx.client.get("/api/accounts")
|
||||
acct = next(a for a in listing.json() if a["id"] == aid)
|
||||
assert acct["signature"] == "<p>Best,<br>Me</p>"
|
||||
|
||||
|
||||
async def test_update_password_reencrypts_and_resyncs(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
aid = await _make_account(ctx, monkeypatch)
|
||||
acct = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(aid)))
|
||||
assert acct is not None
|
||||
await ctx.session.refresh(acct)
|
||||
old_secret = bytes(acct.secret_ct)
|
||||
|
||||
enqueued: list[tuple[str, str]] = []
|
||||
|
||||
class FakePool:
|
||||
async def enqueue_job(self, name: str, arg: str) -> None:
|
||||
enqueued.append((name, arg))
|
||||
|
||||
async def fake_pool() -> FakePool:
|
||||
return FakePool()
|
||||
|
||||
async def fake_test(**_kw: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(accounts_api, "test_account", fake_test)
|
||||
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
|
||||
|
||||
resp = await ctx.client.patch(
|
||||
f"/api/accounts/{aid}", json={"password": "newsecret"}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["state"] == "backfilling"
|
||||
await ctx.session.refresh(acct)
|
||||
assert bytes(acct.secret_ct) != old_secret # re-encrypted
|
||||
assert enqueued == [("sync_bootstrap_account", aid)]
|
||||
|
||||
|
||||
async def test_update_server_change_requires_password(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
aid = await _make_account(ctx, monkeypatch)
|
||||
resp = await ctx.client.patch(
|
||||
f"/api/accounts/{aid}",
|
||||
json={"imap": {"host": "imap2.example.com", "port": 993, "tls": "ssl"}},
|
||||
headers=_hdr(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "password_required"
|
||||
|
||||
|
||||
async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
async def fake_test(**_kw: object) -> None:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user