src/compose/ComposePanel.tsx
import { useContext, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { Account, Mail } 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 { changeComposeAccount, ComposePreferenceControls } from "./ComposePreferences";
import type { Compose, DraftProgress } from "./draft-session";
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 }: {
lock: { current: boolean };
prepare: () => Promise<void>;
send: (draft: Compose) => Promise<{ ok?: boolean; warning?: string }>;
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 };
try {
const accepted = await send(draft);
if (accepted.ok !== true) throw new Error("Unconfirmed send");
result = accepted;
} catch {
setDraft((current) => current ? { ...current, sendUnknown: true } : current);
setNotice(unknownSendNotice);
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,
onAttachmentTask,
source,
}: {
draft: Compose;
source?: Mail;
setDraft: (value: Compose) => void;
accounts: Account[];
busy: boolean;
onClose: () => void;
onDiscard: () => void;
onRetry: () => void;
onSaveCopy: () => void;
progress: DraftProgress;
onSend: () => void;
preferences?: ComposePreferences;
onAttachmentTask?: (task: Promise<void>) => void;
}) {
const { on: privacy } = useContext(PrivacyContext);
const sendingIndex = accounts.findIndex((account) => account.id === draft.accountId);
const sendingAccount = accounts[sendingIndex];
const [attachmentError, setAttachmentError] = useState("");
const [loadingFiles, setLoadingFiles] = useState(false);
const filesBusy = useRef(false);
const [dragging, setDragging] = useState(false);
const [showCc, setShowCc] = useState(false);
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 > 524288 || !file.name || file.name.length > 255 || /[\u0000-\u001f\u007f/\\:]/.test(file.name) || file.name === "." || file.name === "..") || files.reduce((sum, file) => sum + file.size, 0) + (draft.attachments || []).reduce((sum, file) => sum + file.content.length * 3 / 4, 0) > 1048576 || files.length + (draft.attachments?.length || 0) > 10) {
setAttachmentError("Choose up to 10 files with safe filenames, at most 512 KiB each and 1 MiB total."); return;
}
filesBusy.current = true; setLoadingFiles(true); setAttachmentError("");
const task = (async () => { try {
const attachments = await Promise.all(files.map((file) => new Promise<{ filename: string; contentType: string; content: string }>((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."); }
finally { filesBusy.current = false; setLoadingFiles(false); } })();
onAttachmentTask?.(task); await task;
};
return (
<Dialog
title={source || draft.inReplyTo ? "Reply" : "New message"}
className={source ? "compose-with-context" : undefined}
onClose={() => { if (!busy && !loadingFiles) onClose(); }}
>
<div className={source ? "compose-reply-layout" : undefined}>
{source && <MailPrivacy mail={source}><aside className="compose-context" aria-label="Original email">
<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"><Redact text={source.body || source.preview || "No message text in cache."} /></div>
</aside></MailPrivacy>}
{draft.invitation && <p 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>}
<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;
onSend();
}}
>
<div className="compose-envelope">
<label>
<span>From</span>
<select
value={draft.accountId}
disabled={!!draft.invitation || busy || loadingFiles || progress.phase === "saving" || progress.phase === "error"}
onChange={(e) =>
setDraft(changeComposeAccount(draft, e.target.value, preferences))
}
>
{accounts.map((a, index) => (
<option key={a.id} value={a.id}>
{privacy ? accountLabel(a, index, true) : a.email}
</option>
))}
</select>
</label>
<div className="compose-to-row">
<label>
<span>To</span>
<input
type="email"
required
disabled={!!draft.invitation || busy || loadingFiles}
maxLength={10000}
value={draft.to}
onChange={(e) => setDraft({ ...draft, to: e.target.value })}
/>
</label>
{!ccVisible && <button className="compose-cc-toggle" type="button" disabled={busy || loadingFiles}
aria-label="Add Cc recipients" aria-expanded={false} onClick={() => setShowCc(true)}>Cc</button>}
</div>
{ccVisible && <label><span>Cc</span>
<input type="email" multiple autoFocus={showCc} disabled={!!draft.invitation || busy || loadingFiles} maxLength={10000}
value={(draft.cc || []).join(", ")}
onChange={(e) => setDraft({ ...draft, cc: e.target.value.split(",") })} />
</label>}
<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>
<textarea
className="compose-body"
aria-label="Message body"
placeholder="Write your message"
required
disabled={busy || loadingFiles}
maxLength={1000000}
value={draft.body}
onChange={(e) => setDraft({ ...draft, body: e.target.value })}
/>
<div className="compose-extras">
<label className="compose-attachments"><span>Attach or drop files</span>
<input 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>
<p id="compose-attachment-limits" className="compose-attachment-limits">Up to 10 files · 512 KiB each · 1 MiB total</p>
{(draft.attachments || []).map((file, index) => <p className="compose-attachment" key={index}><Redact text={file.filename} /> <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">Reading attachments…</p>}
<ComposePreferenceControls draft={draft} preferences={preferences} busy={busy || loadingFiles} onChange={setDraft} />
</div>
{draft.sendUnknown && (
<p className="dialog-copy" role="status">{unknownSendNotice}</p>
)}
<div className="compose-footer">
<p className="compose-save-status" role="status">{progress.message}</p>
{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">
<button type="button" disabled={busy || loadingFiles} onClick={onClose}>Keep draft</button>
<button type="button" 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();
}}>Discard draft</button>
<button className="button compose-send" disabled={busy || loadingFiles || progress.phase === "error"} type="submit">
{busy ? "Please wait" : "Send"}
</button>
</div>
<p className="compose-format">Plain text · From: {sendingAccount ? <Redact text={privacy ? accountLabel(sendingAccount, sendingIndex, true) : sendingAccount.email} /> : "Sending account unavailable"}</p>
</div>
</form>
</div>
</Dialog>
);
}