`;
})();
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
const payload = (atts: AttachmentOut[] = attachments): DraftInput => {
const html = bodyHtml || initialHtml;
return {
account_id: accountId,
thread_id: init.threadId ?? null,
reply_to_message_id: init.replyToMessageId ?? null,
kind: init.kind,
payload: {
to: splitList(to),
cc: splitList(cc),
bcc: splitList(bcc),
subject,
html,
text: htmlToText(html),
attachment_ids: atts.map((a) => a.id),
},
};
};
// 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 => {
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 {
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, 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(
() => () => {
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 — but only if the user actually edited it
// (skip if it was sent, discarded, or merely opened to view).
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 {
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: sentDraft, until });
onClose();
return;
}
const remaining = Math.max(0, until - Date.now());
setUndo({ outboxId: res.outbox_id, until });
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;
}
};
const doUndo = async () => {
if (!undo) return;
try {
await api.undoSend(undo.outboxId);
} catch {
/* too late */
}
if (undoTimer.current) window.clearTimeout(undoTimer.current);
sentRef.current = false; // back to a draft; mirror it on close
setUndo(null);
setStatus("");
};
const discard = async () => {
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 {
/* ignore */
}
draftId.current = null;
}
onClose();
};
// 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 && !localId.current) {
onClose();
return;
}
// The prompt is an overlay on this window; in a collapsed title bar there is
// nowhere to draw it. Restore first, then ask.
if (minimized) onMinimize?.();
setShowClosePrompt(true);
};
const saveDraftAndClose = async () => {
setShowClosePrompt(false);
try {
if (dirtyRef.current) await autosave();
} catch {
/* the close-time IMAP mirror is best-effort too */
}
onClose(); // the unmount effect mirrors the (dirty) draft to the IMAP Drafts folder
};
const popOut = async () => {
// Persist first so the new window can load this exact draft.
try {
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
} catch {
return;
}
// A window a quarter of the screen area (half width × half height), centered.
const w = Math.round(window.screen.width / 2);
const h = Math.round(window.screen.height / 2);
const left = Math.round((window.screen.width - w) / 2);
const top = Math.round((window.screen.height - h) / 2);
window.open(
`/compose/${draftId.current}`,
`compose-${draftId.current}`,
`width=${w},height=${h},left=${left},top=${top}`,
);
onClose();
};
if (undo) {
return (
{minimized ? (
// Collapsed: the subject stands in for the action buttons, and the whole
// bar is the restore target.
) : (
<>
fileInputRef.current?.click()} title={t("compose.attach")}>
{/* Phone width: the paperclip alone, so the close button still fits. */}
{t("compose.attach")}
{
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
e.target.value = "";
}}
/>
setShowToolbar((v) => !v)}
title={t("compose.fmtBold")}
active={showToolbar}
>
A
{!minimized && status === "saving" && {t("compose.savingDraft")}}
{!minimized && status === "saved" && {t("compose.draftSaved")}}
{!minimized && status === "savedLocal" && {t("compose.savedOnDevice")}}
{!minimized && status === "error" && (
{t("compose.sendFailed")}
)}
{!fullWindow && onMinimize && (
)}
{!fullWindow && onToggleExpand && (
)}
{!fullWindow && (
// No second browser window to pop out into on a phone.
)}
{/* Hidden rather than unmounted while collapsed: the editor seeds its
contentEditable once, so dropping this subtree would lose the body. */}
)}
{/* Body — wrapped so a drop here is detected as the writing area. */}
{bodyReady ? (
{
markDirty();
setBodyHtml(html);
}}
showToolbar={showToolbar}
spellLanguage={spellLang}
// With a height to divide, the body takes what is left and
// scrolls. A floor here would instead push the window past that
// height — and `overflow-hidden` would clip the difference.
minHeight={sized ? "min-h-0" : undefined}
/>
) : (