src/compose/ComposePanel.tsx
import { ScheduleTime } from "./ScheduleTime";
import { followUpPreset } from "../../shared/follow-up-presets";
import { ATTACHMENT_BYTES, ATTACHMENT_COUNT, attachmentSize } from "../../shared/attachments";
import { RichBody } from "./RichBody";
import { useContext, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { Account, Mail, DraftAttachment } from "../../shared/types";
import { emptyComposePreferences, type ComposePreferences } from "../../shared/mail-features";
import { MailPrivacy, PrivacyContext, Redact } from "../ui/Privacy";
import { accountLabel } from "../mail-ui";
import { Dialog } from "../ui/Dialog";
import { Dropdown } from "../ui/Dropdown";
import type { Api } from "../api";
import { ReaderContent } from "../mail/MessageContent";
import { mailBodyKey } from "../mail-body";
import { changeComposeAccount, ComposePreferenceControls } from "./ComposePreferences";
import type { Compose, DraftProgress } from "./draft-session";
import { Paperclip, Trash } from "../ui/icons";
import { RecipientInput } from "./RecipientInput";
export type { Compose } from "./draft-session";
export const unknownSendNotice =
"Send status unknown. This message may already have been sent. Sending again may send a duplicate.";
export async function sendCompose(
draft: Compose,
{ lock, prepare, send, complete, reload, setDraft, setBusy, setNotice, confirm, uncertain }: {
lock: { current: boolean };
prepare: () => Promise<void>;
send: (draft: Compose) => Promise<{ ok?: boolean; queued?: boolean; scheduled?: boolean; warning?: string }>;
uncertain?: () => void;
complete: () => Promise<void>;
reload: () => Promise<unknown>;
setDraft: Dispatch<SetStateAction<Compose | null>>;
setBusy: (busy: boolean) => void;
setNotice: (notice: string) => void;
confirm: (message: string) => boolean;
},
) {
if (lock.current) return;
if (
draft.sendUnknown &&
!confirm("This message may already have been sent. Sending again may send a duplicate. Send again?")
)
return;
lock.current = true;
setBusy(true);
try {
try {
await prepare();
} catch {
setNotice("Draft save could not be confirmed. No send was attempted. Keep this window open and check the draft save status.");
return;
}
let result: { warning?: string; queued?: boolean; scheduled?: boolean };
try {
const accepted = await send(draft);
if (accepted.ok !== true && accepted.queued !== true) throw new Error("Unconfirmed send");
result = accepted;
} catch {
uncertain?.();
setDraft((current) => current ? { ...current, sendUnknown: true } : current);
setNotice(unknownSendNotice);
return;
}
if (result.queued) {
setDraft(null);
setNotice(result.scheduled ? "Email scheduled on this device. Open Scheduled from Commands to review it." : "Email queued on this device. Use Undo in the Outbox before submission.");
try { await reload(); } catch { setNotice("Email queued. Inbox refresh failed. Review the Outbox before sending again."); }
return;
}
let sentNotice = result.warning || "Email sent.";
try {
await complete();
} catch {
sentNotice += "\nSaved draft cleanup could not be confirmed. A recovered copy may remain. Do not resend.";
}
setDraft(null);
setNotice(sentNotice);
try {
await reload();
} catch {
setNotice(`${sentNotice}\nInbox refresh failed. Refresh the inbox separately.`);
}
} finally {
lock.current = false;
setBusy(false);
}
}
export function ComposePanel({
draft,
setDraft,
accounts,
busy,
onClose,
onDiscard,
onRetry,
onSaveCopy,
progress,
onSend,
preferences = emptyComposePreferences,
onUndoDelay,
onAttachmentTask,
source,
api,
}: {
draft: Compose;
source?: Mail;
api?: Api;
setDraft: (value: Compose) => void;
accounts: Account[];
busy: boolean;
onClose: () => void;
onDiscard: () => void;
onRetry: () => void;
onSaveCopy: () => void;
progress: DraftProgress;
onSend: (scheduleAt?: number) => void;
preferences?: ComposePreferences;
onUndoDelay?: (seconds: NonNullable<ComposePreferences["undoSendSeconds"]>) => void;
onAttachmentTask?: (task: Promise<void>) => void;
}) {
const { on: privacy } = useContext(PrivacyContext);
const [scheduleAt, setScheduleAt] = useState<number | undefined>();
const sourceKey = useMemo(() => mailBodyKey(source), [source]);
const footerActions = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const row = footerActions.current;
const app = row?.closest<HTMLElement>(".app");
if (!row || !app) return;
const toolbar = app.querySelector<HTMLElement>(".app-toolbar");
const measure = () => {
const height = `${Math.max(42, Math.ceil(row.getBoundingClientRect().height + 12))}px`;
if (app.style.getPropertyValue("--app-footer-height") !== height) app.style.setProperty("--app-footer-height", height);
if (toolbar) app.style.setProperty("--compose-top", `${Math.ceil(toolbar.getBoundingClientRect().bottom)}px`);
};
measure();
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(measure);
observer?.observe(row);
if (toolbar) observer?.observe(toolbar);
window.addEventListener("resize", measure);
return () => {
observer?.disconnect(); window.removeEventListener("resize", measure);
app.style.removeProperty("--app-footer-height"); app.style.removeProperty("--compose-top");
};
}, []);
const [attachmentError, setAttachmentError] = useState("");
const [loadingFiles, setLoadingFiles] = useState(false);
const filesBusy = useRef(false);
const [dragging, setDragging] = useState(false);
const [showBcc, setShowBcc] = useState(false);
const [showCc, setShowCc] = useState(false);
const selectedBytes = (draft.attachments || []).reduce((sum, file) => sum + attachmentSize(file), 0);
const ccVisible = showCc || Boolean(draft.cc?.some((address) => address.trim()));
const addFiles = async (files: File[]) => {
if (!files.length || busy || filesBusy.current) return;
if (files.some((file) => file.size > ATTACHMENT_BYTES || !file.name || file.name.length > 255 || /[\u0000-\u001f\u007f/\\:]/.test(file.name) || file.name === "." || file.name === "..") || files.reduce((sum, file) => sum + file.size, 0) + selectedBytes > ATTACHMENT_BYTES || files.length + (draft.attachments?.length || 0) > ATTACHMENT_COUNT) {
setAttachmentError("Choose up to 20 files with safe filenames, at most 25 MiB each and 25 MiB total."); return;
}
filesBusy.current = true; setLoadingFiles(true); setAttachmentError("");
const task = (async () => { try {
const attachments: DraftAttachment[] = [];
for (const file of files) {
if (api) {
const query = new URLSearchParams({ filename: file.name, contentType: file.type || "application/octet-stream" });
attachments.push(await api<DraftAttachment>(`/accounts/${encodeURIComponent(draft.accountId)}/attachments?${query}`, "POST", file));
} else {
// Standalone callers without a service can still use the legacy small form.
if (file.size > 524288) throw new Error("Attachment upload service is unavailable");
attachments.push(await new Promise<DraftAttachment>((resolve, reject) => {
const reader = new FileReader(); reader.onerror = () => reject(new Error());
reader.onload = () => resolve({ filename: file.name, contentType: file.type || "application/octet-stream", content: String(reader.result).split(",")[1] });
reader.readAsDataURL(file);
}));
}
}
setDraft({ ...draft, attachments: [...(draft.attachments || []), ...attachments] });
} catch { setAttachmentError("Attachment could not be read. Choose it again."); throw new Error("Attachment read failed"); }
finally { filesBusy.current = false; setLoadingFiles(false); } })();
onAttachmentTask?.(task); await task.catch(() => {});
};
return (
<Dialog
toolbarAccess
actions={<button type="button" className="compose-discard" title="Delete draft" aria-label="Delete draft" disabled={busy || loadingFiles} onClick={() => {
if (confirm(draft.sendUnknown
? "Discard this saved draft? This message may already have been sent."
: "Discard this draft from this device?")) onDiscard();
}}><Trash size={15} color="currentColor" /><span>Delete draft</span></button>}
title={source || draft.inReplyTo ? "Reply" : "New message"}
className={source ? "compose-dialog compose-with-context" : "compose-dialog"}
onClose={() => { if (!busy && !loadingFiles) onClose(); }}
>
<div className={source ? "compose-layout compose-reply-layout" : "compose-layout"}>
{source && <MailPrivacy mail={source}><aside className="compose-context" aria-label="Original email" tabIndex={0}>
<span className="compose-context-label">Replying to</span>
<h3><Redact text={source.subject || "No subject"} /></h3>
<p><Redact text={source.sender} /> · {new Date(source.date).toLocaleDateString()}</p>
<div className="compose-context-body"><ReaderContent api={api} key={sourceKey} mail={{ ...source, body: source.body || source.preview || "No message text in cache." }}
loadContent={api ? (id) => api(`/messages/${encodeURIComponent(id)}/content`, "POST") : undefined}
loadImages={(id) => api ? api(`/messages/${encodeURIComponent(id)}/images`, "POST") : Promise.reject(new Error("Images unavailable"))} /></div>
</aside></MailPrivacy>}
<form
className={`compose-form${dragging ? " dragging-files" : ""}`}
onDragOver={(event) => {
if (event.dataTransfer.types.includes("Files")) { event.preventDefault(); event.dataTransfer.dropEffect = busy || loadingFiles ? "none" : "copy"; setDragging(!busy && !loadingFiles); }
}}
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
onDrop={(event) => { event.preventDefault(); setDragging(false); void addFiles(Array.from(event.dataTransfer.files)); }}
onSubmit={(e) => {
e.preventDefault();
if (!e.currentTarget.reportValidity()) return;
if (!draft.body) { e.currentTarget.querySelector<HTMLElement>('[aria-label="Message body"]')?.focus(); return; }
onSend(scheduleAt);
}}
>
<div className="compose-scroll">
{draft.invitation && <p className="compose-invitation-note" role="status">Review {draft.invitation.method === "REPLY" ? "your calendar response" : draft.invitation.method === "CANCEL" ? "this cancellation" : "this invitation"}. Calendar details and recipients are fixed. Nothing is sent until you press Send.</p>}
<div className="compose-envelope">
<div className="compose-from-row">
<div className="compose-from-field">
<span>From</span>
<Dropdown
label="From"
value={draft.accountId}
disabled={!!draft.invitation || busy || loadingFiles || progress.phase === "saving" || progress.phase === "error"}
options={accounts.map((account, index) => ({
value: account.id,
label: privacy ? accountLabel(account, index, true) : account.name ? `${account.name} · ${account.email}` : account.email,
}))}
onChange={(accountId) => {
if (accountId !== draft.accountId && draft.attachments?.some(file => file.id)) { setAttachmentError("Remove uploaded attachments before changing From. Choose the files again for the new account."); return; }
try { setDraft(changeComposeAccount(draft, accountId, preferences)); }
catch { setAttachmentError("Unable to update the signature without changing your message. Your text is unchanged."); }
}}
/>
</div>
</div>
<div className="compose-to-row">
<RecipientInput label="To" required multiple value={draft.to.join(", ")} accountId={draft.accountId} accounts={accounts} api={api}
disabled={!!draft.invitation || busy || loadingFiles}
onChange={(to) => setDraft({ ...draft, to: to.split(",").map(value => value.trimStart()) })} />
{!ccVisible && <button className="compose-cc-toggle" type="button" disabled={!!draft.invitation || busy || loadingFiles}
aria-label="Add Cc recipients" aria-expanded={false} onClick={() => setShowCc(true)}>Cc</button>}
{!showBcc && !draft.bcc?.length && <button className="compose-cc-toggle" type="button" disabled={!!draft.invitation || busy || loadingFiles} aria-label="Add Bcc recipients" aria-expanded={false} onClick={() => setShowBcc(true)}>Bcc</button>}
</div>
{(showBcc || !!draft.bcc?.length) && <RecipientInput label="Bcc" multiple autoFocus={showBcc} value={(draft.bcc || []).join(", ")}
accountId={draft.accountId} accounts={accounts} api={api} disabled={!!draft.invitation || busy || loadingFiles}
onChange={(bcc) => setDraft({ ...draft, bcc: bcc.split(",").map(value => value.trimStart()) })} />}
{ccVisible && <RecipientInput label="Cc" multiple autoFocus={showCc} value={(draft.cc || []).join(", ")}
accountId={draft.accountId} accounts={accounts} api={api} disabled={!!draft.invitation || busy || loadingFiles}
onChange={(cc) => setDraft({ ...draft, cc: cc.split(",").map(value => value.trimStart()) })} />}
<label className="compose-subject">
<span>Subject</span>
<input
disabled={busy || loadingFiles}
value={draft.subject}
maxLength={998}
onChange={(e) => setDraft({ ...draft, subject: e.target.value })}
/>
</label>
</div>
<div className="compose-toolbar">
<label className="compose-file-action" aria-disabled={busy || loadingFiles}>
<Paperclip size={15} color="currentColor" /><span>Attach files</span>
<input aria-label="Attach files" aria-describedby="compose-attachment-limits" type="file" multiple disabled={busy || loadingFiles} onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void addFiles(files);
}} />
</label>
<span id="compose-attachment-limits" className="compose-attachment-limits">Up to 20 files · 25 MiB each · 25 MiB total; provider message limits include encoding</span>
<span className="compose-attachment-limits compose-attachment-size" role="status">{draft.attachments?.length || 0} {draft.attachments?.length === 1 ? "file" : "files"} · {selectedBytes} bytes selected</span>
</div>
<RichBody draft={draft} disabled={busy || loadingFiles} onChange={setDraft} />
<div className="compose-extras">
{(draft.attachments || []).map((file, index) => <p className="compose-attachment" key={index}><Redact text={file.filename} /> ({attachmentSize(file)} bytes) <button type="button" disabled={busy || loadingFiles} aria-label={`Remove attachment ${index + 1}`} onClick={() => setDraft({ ...draft, attachments: draft.attachments?.filter((_, i) => i !== index) })}>Remove</button></p>)}
{attachmentError && <p role="alert">{attachmentError}</p>}
{loadingFiles && <p role="status">Uploading attachments…</p>}
<ComposePreferenceControls draft={draft} preferences={preferences} busy={busy || loadingFiles} onChange={setDraft} />
{onUndoDelay && <div className="compose-undo-delay"><span>Undo delay</span><Dropdown label="Undo send delay"
value={String(preferences.undoSendSeconds ?? 10)} disabled={busy || loadingFiles}
options={[{ value: "0", label: "Off" }, ...[5, 10, 30].map(seconds => ({ value: String(seconds), label: `${seconds} seconds` }))]}
onChange={value => onUndoDelay(Number(value) as NonNullable<ComposePreferences["undoSendSeconds"]>)} />
<span>Cancel before submission. No recall after acceptance.</span></div>}
</div>
{draft.sendUnknown && (
<p className="dialog-copy" role="status">{unknownSendNotice}</p>
)}
</div>
<div className="compose-footer">
{scheduleAt !== undefined && <ScheduleTime at={scheduleAt} onChange={setScheduleAt} disabled={busy || loadingFiles} />}
{progress.phase === "error" && (
<div className="draft-recovery-actions">
<button type="button" disabled={busy || loadingFiles} onClick={onRetry}>Retry save</button>
<button type="button" disabled={busy || loadingFiles} onClick={onSaveCopy}>Save separate copy</button>
</div>
)}
<div className="compose-actions" ref={footerActions}>
<button type="button" disabled={busy || loadingFiles} onClick={onClose}>Keep draft</button>
<p className={`compose-save-status${progress.phase === "error" ? " save-error" : ""}`} role="status">{progress.message}</p>
<button type="button" disabled={busy || loadingFiles} onClick={() => setScheduleAt(scheduleAt === undefined ? followUpPreset("tomorrow", Date.now()) : undefined)}>{scheduleAt === undefined ? "Send later" : "Cancel scheduling"}</button>
<button className="button primary-button compose-send" disabled={busy || loadingFiles || progress.phase === "error"} type="submit">
{busy ? "Please wait" : scheduleAt === undefined ? "Send" : "Schedule send"}
</button>
</div>
</div>
</form>
</div>
</Dialog>
);
}