Offline compose: write mail offline, Scheduled folder, undo countdown fix

Compose, Reply and Forward work offline. Drafts are kept on the device
(IndexedDB, lib/scheduled.ts), with files attached offline; Send becomes
"Schedule" and puts the message in a new Scheduled folder. When the server
is reachable again, scheduled messages are sent through the normal outbox
(Undo offered for a single one) and offline drafts become server drafts —
files uploaded first, each step recorded so nothing is created or sent
twice, under a Web Lock so two tabs never send the same message. Drafts
written offline show in Drafts as "On this device". Sign-out sends what
is scheduled, then wipes the device's copy.

Also: offline failures from keyboard shortcuts show "Not available
offline"; no recipient suggestions are asked for offline; a reply no
longer quotes an error page when the original is not saved.

The undo countdown counted to the server's deadline on the browser's
clock, so a few seconds of skew showed (18 s instead of 10). /send now
also returns undo_seconds, and the composer counts down from that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 21:11:41 +02:00
co-authored by Claude Opus 5.5
parent c9c24e7e2f
commit e1bda37188
14 changed files with 808 additions and 107 deletions
+5 -2
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import uuid
from datetime import timedelta
from datetime import UTC, datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, File, Query, UploadFile
@@ -300,7 +300,10 @@ async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendRespo
str(outbox.id),
_defer_by=timedelta(seconds=get_settings().undo_seconds),
)
return SendResponse(outbox_id=outbox.id, undo_until=outbox.send_after.isoformat())
left = max(0.0, (outbox.send_after - datetime.now(UTC)).total_seconds())
return SendResponse(
outbox_id=outbox.id, undo_until=outbox.send_after.isoformat(), undo_seconds=left
)
@router.post("/send/{outbox_id}/undo")
+3
View File
@@ -56,6 +56,9 @@ class SendRequest(BaseModel):
class SendResponse(BaseModel):
outbox_id: uuid.UUID
undo_until: str
# The same deadline as seconds from now: the browser counts down from this,
# since its clock may be seconds off the server's.
undo_seconds: float
class OutboxStateOut(BaseModel):
+2
View File
@@ -398,6 +398,8 @@ async def test_send_creates_outbox_and_defers(ctx: Ctx) -> None:
resp = await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr())
assert resp.status_code == 200
outbox_id = resp.json()["outbox_id"]
# The window as a duration too, so the browser's clock does not matter.
assert 0 < resp.json()["undo_seconds"] <= get_settings().undo_seconds
ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id))
assert ob is not None and ob.state == "undo_window"