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
+230 -38
View File
@@ -2,8 +2,22 @@ import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { api, ApiError, isOfflineError } from "../../lib/api";
import { isOffline, useOffline } from "../../lib/offline";
import {
deleteFiles,
deleteLocal,
holdOpen,
type LocalFile,
type LocalMessage,
type LocalState,
newLocalId,
putFile,
putLocal,
uploadFiles,
} from "../../lib/scheduled";
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
import { showOfflineNotice } from "../../lib/offlineNotice";
import { RecipientChips } from "./RecipientChips";
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
import { hasUndoHost, offerUndo } from "./undoSend";
@@ -17,6 +31,7 @@ export interface ComposerInit {
kind: string; // reply | reply_all | forward | new
to?: string;
cc?: string;
bcc?: string;
subject?: string;
text?: string;
html?: string; // initial body when reopening an existing draft
@@ -25,6 +40,11 @@ export interface ComposerInit {
// A reply/forward quotes the original: this attribution line sits above the
// quoted message body (fetched from the original message's sanitized HTML).
quote?: { attribution: string; messageId: string };
// A message kept on this device (written offline; lib/scheduled.ts).
localId?: string;
localState?: LocalState; // "scheduled" when reopened from Scheduled
attachments?: AttachmentOut[]; // known already: no need to ask the server
localFiles?: LocalFile[];
}
// Build the composer seed from a saved draft (used to reopen it in place or in a
@@ -45,6 +65,29 @@ export function draftToInit(draft: DraftOut): ComposerInit {
};
}
// Reopen a message kept on this device (a draft written offline, or one in
// Scheduled).
export function localToInit(msg: LocalMessage): ComposerInit {
const p = msg.input.payload;
return {
accountId: msg.input.account_id,
threadId: msg.input.thread_id ?? null,
replyToMessageId: msg.input.reply_to_message_id ?? null,
kind: msg.input.kind,
to: p.to.join(", "),
cc: p.cc.join(", "),
bcc: p.bcc.join(", "),
subject: p.subject,
text: p.text,
html: p.html || undefined,
draftId: msg.serverDraftId ?? undefined,
localId: msg.id,
localState: msg.state === "draft" ? "draft" : "scheduled", // "failed"/"sending" wait to be sent too
attachments: msg.attachments,
localFiles: msg.files,
};
}
function splitList(value: string): string[] {
return value
.split(",")
@@ -80,11 +123,13 @@ function HeaderButton({
onClick,
title,
active = false,
className = "",
children,
}: {
onClick?: () => void;
title: string;
active?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
@@ -93,7 +138,7 @@ function HeaderButton({
title={title}
aria-label={title}
onClick={onClick}
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
className={`${className} flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
}`}
>
@@ -150,8 +195,8 @@ export function Composer({
const [accountId, setAccountId] = useState(init.accountId);
const [to, setTo] = useState(init.to ?? "");
const [cc, setCc] = useState(init.cc ?? "");
const [bcc, setBcc] = useState("");
const [showCc, setShowCc] = useState(Boolean(init.cc));
const [bcc, setBcc] = useState(init.bcc ?? "");
const [showCc, setShowCc] = useState(Boolean(init.cc || init.bcc));
const [subject, setSubject] = useState(init.subject ?? "");
const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
const [quoteHtml, setQuoteHtml] = useState("");
@@ -176,9 +221,12 @@ export function Composer({
});
const moreRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLDivElement>(null);
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
const [attachments, setAttachments] = useState<AttachmentOut[]>(init.attachments ?? []);
// Files attached while offline, kept on this device until they can be uploaded.
const [localFiles, setLocalFiles] = useState<LocalFile[]>(init.localFiles ?? []);
const [attachError, setAttachError] = useState("");
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
const [status, setStatus] = useState<"" | "saving" | "saved" | "savedLocal" | "sending" | "error">("");
const offline = useOffline();
const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
const [undoLeft, setUndoLeft] = useState(0);
// Tick the undo countdown while the window is open.
@@ -199,6 +247,11 @@ export function Composer({
onTitleRef.current?.(subject);
}, [subject]);
const draftId = useRef<string | null>(init.draftId ?? null);
// This message's copy on this device, while there is one (lib/scheduled.ts).
const localId = useRef<string | null>(init.localId ?? null);
const discardedRef = useRef(false);
// Kept as what it was opened as: a scheduled message edited offline stays scheduled.
const localState = useRef<LocalState>(init.localState ?? "draft");
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
// Whether the user actually edited anything. Merely opening a draft to view it
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
@@ -249,6 +302,8 @@ export function Composer({
(async () => {
try {
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" });
// Offline, a message not saved here answers 503: quote nothing rather than that.
if (!resp.ok) throw new Error(`quote: ${resp.status}`);
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
} catch {
@@ -265,7 +320,7 @@ export function Composer({
// Resolve attachment metadata when reopening a draft that already has some.
useEffect(() => {
const ids = init.attachmentIds ?? [];
if (ids.length === 0) return;
if (ids.length === 0 || init.attachments) return;
let cancelled = false;
(async () => {
try {
@@ -290,6 +345,12 @@ export function Composer({
continue;
}
try {
if (isOffline()) {
// Kept on this device, uploaded once the server is reachable.
const kept = await putFile(file);
setLocalFiles((prev) => [...prev, kept]);
continue;
}
const att = await api.uploadComposeAttachment(file);
setAttachments((prev) => [...prev, att]);
} catch (e) {
@@ -303,6 +364,12 @@ export function Composer({
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
const removeLocalFile = (id: string) => {
markDirty();
setLocalFiles((prev) => prev.filter((f) => f.id !== id));
void deleteFiles([id]);
};
// Drag & drop: files dropped on the writing area insert inline (images) or
// attach (other types); dropped anywhere above it, everything attaches.
const editorAreaRef = useRef<HTMLDivElement>(null);
@@ -393,7 +460,7 @@ export function Composer({
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
const payload = (): DraftInput => {
const payload = (atts: AttachmentOut[] = attachments): DraftInput => {
const html = bodyHtml || initialHtml;
return {
account_id: accountId,
@@ -407,31 +474,94 @@ export function Composer({
subject,
html,
text: htmlToText(html),
attachment_ids: attachments.map((a) => a.id),
attachment_ids: atts.map((a) => a.id),
},
};
};
useEffect(() => {
if (undo) return;
const handle = window.setTimeout(async () => {
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
if (!to && !subject && !bodyHtml) return;
setStatus("saving");
// Keep this message on the device (offline): as a draft, or for Scheduled.
const saveLocal = async (state: LocalState) => {
localId.current ??= newLocalId();
const msg: LocalMessage = {
id: localId.current,
state,
input: payload(),
attachments,
files: localFiles,
serverDraftId: draftId.current,
updatedAt: Date.now(),
};
await putLocal(msg);
};
// While a copy is kept here, other tabs' hand-over to the server leaves it be.
const [heldId, setHeldId] = useState(localId.current);
useEffect(() => (heldId ? holdOpen(heldId) : undefined), [heldId]);
// Save to the server; a copy kept on this device goes there first (its files
// uploaded), then is dropped. Returns the attachments now on the server.
const saveToServer = async (): Promise<AttachmentOut[]> => {
let atts = attachments;
if (localFiles.length) {
const uploaded = await uploadFiles(localFiles);
await deleteFiles(localFiles.map((f) => f.id));
atts = [...attachments, ...uploaded];
setAttachments(atts);
setLocalFiles([]);
}
if (draftId.current) await api.updateDraft(draftId.current, payload(atts));
else draftId.current = (await api.createDraft(payload(atts))).id;
if (localId.current) {
await deleteLocal(localId.current);
localId.current = null;
setHeldId(null);
}
localState.current = "draft"; // on the server now: a draft until Send
return atts;
};
const autosave = async () => {
setStatus("saving");
try {
if (isOffline()) throw new ApiError(0, "offline", "offline");
await saveToServer();
setStatus("saved");
} catch (e) {
if (!isOfflineError(e)) {
setStatus("error");
return;
}
try {
if (draftId.current) {
await api.updateDraft(draftId.current, payload());
} else {
draftId.current = (await api.createDraft(payload())).id;
}
setStatus("saved");
await saveLocal(localState.current);
setHeldId(localId.current);
setStatus("savedLocal");
} catch {
setStatus("error");
}
}
};
useEffect(() => {
if (undo) return;
const handle = window.setTimeout(() => {
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
if (!to && !subject && !bodyHtml) return;
void autosave();
}, 2000);
return () => window.clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments]);
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments, localFiles]);
// Back online with a draft kept here: hand it to the server now. (A
// scheduled one is sent by the scheduled sender once this window closes.)
useEffect(() => {
if (!offline && localId.current && !undo && localState.current === "draft") void autosave();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [offline]);
// The latest way to save locally, for the unmount below (its closure is old).
const saveLocalRef = useRef(saveLocal);
saveLocalRef.current = saveLocal;
useEffect(
() => () => {
@@ -439,25 +569,57 @@ export function Composer({
// On close, mirror the draft to the IMAP Drafts folder so it can be
// continued on any mail client — but only if the user actually edited it
// (skip if it was sent, discarded, or merely opened to view).
if (draftId.current && !sentRef.current && dirtyRef.current) {
if (localId.current && localState.current === "scheduled" && !sentRef.current && !discardedRef.current) {
// A scheduled message looked at (or edited offline) and closed: still
// scheduled — save any change, then let the sender have it.
const save = dirtyRef.current ? saveLocalRef.current("scheduled") : Promise.resolve();
void save.finally(() => window.dispatchEvent(new Event("webmail:send-scheduled")));
return;
}
if (sentRef.current || discardedRef.current || !dirtyRef.current) return;
if (isOffline()) {
// Closed offline: keep it here as a draft (the server gets it later).
void saveLocalRef.current("draft").catch(() => {});
return;
}
if (draftId.current && !localId.current) {
void api.publishDraft(draftId.current).catch(() => {});
}
},
[],
);
// Offline (or the connection dropped while sending): into Scheduled, to go
// out on its own once the server is reachable again.
const schedule = async () => {
try {
await saveLocal("scheduled");
sentRef.current = true;
showOfflineNotice("scheduled", 5000);
onClose();
} catch {
sentRef.current = false;
setStatus("error");
}
};
const send = async () => {
setStatus("sending");
sentRef.current = true; // don't also mirror to Drafts on close
if (isOffline()) {
await schedule();
return;
}
try {
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
const res = await api.send(draftId.current);
// Drive the countdown + auto-close off the server's undo deadline.
const until = new Date(res.undo_until).getTime();
await saveToServer();
const sentDraft = draftId.current!;
const res = await api.send(sentDraft);
// Drive the countdown + auto-close off the server's undo window, as a
// duration: this computer's clock may be seconds off the server's.
const until = Date.now() + res.undo_seconds * 1000;
// With a toast to carry the undo offer, the window closes right away.
if (hasUndoHost()) {
offerUndo({ outboxId: res.outbox_id, draftId: draftId.current, until });
offerUndo({ outboxId: res.outbox_id, draftId: sentDraft, until });
onClose();
return;
}
@@ -466,6 +628,10 @@ export function Composer({
setUndoLeft(Math.ceil(remaining / 1000));
undoTimer.current = window.setTimeout(onClose, remaining);
} catch (e) {
if (isOfflineError(e)) {
await schedule();
return;
}
sentRef.current = false;
setStatus("error");
if (!(e instanceof ApiError)) throw e;
@@ -486,7 +652,14 @@ export function Composer({
};
const discard = async () => {
if (draftId.current) {
discardedRef.current = true;
if (localId.current) {
await deleteLocal(localId.current).catch(() => {});
localId.current = null;
} else if (localFiles.length) {
await deleteFiles(localFiles.map((f) => f.id)).catch(() => {});
}
if (draftId.current && !isOffline()) {
try {
await api.deleteDraft(draftId.current);
} catch {
@@ -500,7 +673,7 @@ export function Composer({
// X button: if there's a draft worth keeping, ask before closing; otherwise
// (nothing typed, no draft) just close.
const requestClose = () => {
if (!dirtyRef.current && !draftId.current) {
if (!dirtyRef.current && !draftId.current && !localId.current) {
onClose();
return;
}
@@ -513,10 +686,7 @@ export function Composer({
const saveDraftAndClose = async () => {
setShowClosePrompt(false);
try {
if (dirtyRef.current) {
if (draftId.current) await api.updateDraft(draftId.current, payload());
else draftId.current = (await api.createDraft(payload())).id;
}
if (dirtyRef.current) await autosave();
} catch {
/* the close-time IMAP mirror is best-effort too */
}
@@ -639,7 +809,7 @@ export function Composer({
</svg>
{/* Phone width: the icon alone. */}
<span className="max-md:hidden">
{status === "sending" ? t("compose.sending") : t("compose.send")}
{status === "sending" ? t("compose.sending") : offline ? t("compose.sendLater") : t("compose.send")}
</span>
</button>
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
@@ -758,6 +928,7 @@ export function Composer({
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
{!minimized && status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
{!minimized && status === "savedLocal" && <span className="mr-1">{t("compose.savedOnDevice")}</span>}
{!minimized && status === "error" && (
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
)}
@@ -793,7 +964,7 @@ export function Composer({
{!fullWindow && (
// No second browser window to pop out into on a phone.
<div className="contents max-md:hidden">
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
<HeaderButton onClick={popOut} title={t("compose.popOut")} className="wm-online-only">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
</svg>
@@ -898,7 +1069,7 @@ export function Composer({
}}
/>
{(attachments.length > 0 || attachError) && (
{(attachments.length > 0 || localFiles.length > 0 || attachError) && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => (
<span
@@ -920,6 +1091,27 @@ export function Composer({
</button>
</span>
))}
{localFiles.map((f) => (
<span
key={f.id}
title={t("compose.attachOnDevice")}
className="flex items-center gap-1.5 rounded border border-dashed border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
<span className="max-w-[12rem] truncate">{f.name}</span>
<span className="text-app-faint">{formatBytes(f.size)}</span>
<button
type="button"
onClick={() => removeLocalFile(f.id)}
className="text-app-faint hover:text-app-fg"
aria-label={`remove ${f.name}`}
>
✕
</button>
</span>
))}
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
</div>
)}