diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index 4da1ef4..abeae66 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -1,583 +1,24 @@ "use client"; -import React, { useCallback, useEffect, useRef, useState, use } from "react"; -import Link from "next/link"; -import { - Send, - Paperclip, - Bold, - Italic, - Underline, - Strikethrough, - List, - ListOrdered, - Link as LinkIcon, - Undo2, - X, - Sparkles, - CheckCircle2, - Flag, -} from "lucide-react"; -import { useTranslations } from "../../../../i18n/provider"; -import { Button } from "../../../../components/ui/Button"; -import { notifyMailStateChanged } from "../../../../lib/useFolders"; -import { signatureToHtml } from "../../../../lib/signature"; -import { RecipientInput } from "../../../../components/RecipientInput"; -import { parseAddress, parseAddressList, prefixSubject } from "../../../../lib/recipients"; +import { use } from "react"; +import { Composer, type ComposerInitial } from "../../../../components/Composer"; -/** Entries from a query-string list, which may be several addresses. */ -function entriesFrom(value: string | undefined): string[] { - return value ? parseAddressList(value).map((p) => p.entry) : []; -} - -interface AttachedFile { - id: string; - name: string; - size: number; - type: string; -} - -export default function ComposePage({ - searchParams, -}: { - searchParams?: Promise<{ - replyTo?: string; - cc?: string; - subject?: string; - inReplyTo?: string; - mode?: string; - draft?: string; - }>; -}) { - const t = useTranslations(); +/** + * The full compose screen. The composer itself lives in components/Composer so + * the reply written under a conversation is the same code, not a copy of it. + */ +export default function ComposePage({ searchParams }: { searchParams?: Promise }) { const params = searchParams ? use(searchParams) : {}; - - // Recipients are lists of entries, not one string to be split later: a - // reply-all arrives with several people already in To and Cc. - const [to, setTo] = useState(() => entriesFrom(params?.replyTo)); - const [cc, setCc] = useState(() => entriesFrom(params?.cc)); - const [bcc, setBcc] = useState([]); - const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0); - const [subject, setSubject] = useState( - params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "", - ); - // The Message-ID being answered, so the reply threads under it. - const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || ""); - const [important, setImportant] = useState(false); - const [attachments, setAttachments] = useState([]); - const [sending, setSending] = useState(false); - const [undoSeconds, setUndoSeconds] = useState(null); - const [draftStatus, setDraftStatus] = useState(""); - const [discarding, setDiscarding] = useState(false); - const [sendError, setSendError] = useState(null); - // The UID of the draft already stored for this message. Sent back on each - // autosave so the server replaces it instead of leaving another copy. - const draftUidRef = useRef(0); - // Set once the writer has discarded the message. An autosave already on its - // way when that happens would otherwise land afterwards and bring the draft - // back, so every save checks this before and after it talks to the server. - const discardedRef = useRef(false); - - const editorRef = useRef(null); - // The body, mirrored into React state. - // - // It used to live only inside the contenteditable, read through the ref at - // save time. Nothing observed it, so the autosave effect - which depends on - // what it reads - never re-ran when the message itself was typed. The draft - // was written once, moments after the address was entered, and never again: - // that is why a draft left behind by a sent message was empty. - const [body, setBody] = useState(""); - - useEffect(() => { - const savedSig = localStorage.getItem("lm_user_signature"); - // signatureToHtml, not the raw value: the signature may be the plain text - // of the old field, whose newlines are whitespace once assigned as HTML - - // which is why every line break in a signature used to vanish here. - const signatureHtml = signatureToHtml(savedSig ?? ""); - if (editorRef.current && signatureHtml) { - const initial = `

--
${signatureHtml}`; - editorRef.current.innerHTML = initial; - setBody(initial); - } - }, []); - - // Reopening a draft loads what was written into the composer, and adopts its - // UID so carrying on writing replaces that draft instead of leaving the old - // copy behind and starting a second one. - // - // Opening a draft used to land in the reader, which offered to reply to the - // half-written message and gave no way to finish it. - const draftParam = params?.draft; - useEffect(() => { - const uid = Number(draftParam); - if (!Number.isFinite(uid) || uid <= 0) return; - - let cancelled = false; - void (async () => { - try { - const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" }); - if (!res.ok || cancelled) return; - const message = await res.json(); - if (cancelled) return; - - draftUidRef.current = uid; - setTo(message.to ?? []); - setCc(message.cc ?? []); - setImportant(Boolean(message.important)); - setInReplyTo(message.in_reply_to ?? ""); - if ((message.cc ?? []).length > 0) setShowCcBcc(true); - setSubject(message.subject ?? ""); - const html = message.html || message.text || ""; - if (editorRef.current) editorRef.current.innerHTML = html; - setBody(html); - } catch { - // A draft that cannot be loaded leaves an empty composer rather than - // a broken screen; the draft itself is untouched on the server. - } - })(); - return () => { - cancelled = true; - }; - }, [draftParam]); - - // Removes a stored draft for good. discard_draft is what keeps it out of - // Trash: an ordinary delete moves it there, and a draft sitting in Trash is - // not discarded. - const removeDraft = async (uid: number): Promise => { - try { - const res = await fetch("/api/v1/mail/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }), - }); - return res.ok; - } catch { - return false; - } - }; - - // Saves the draft for real. This used to be a setTimeout that set the words - // "draft saved automatically" and nothing else - no request, no storage - so - // closing the tab lost the message while the screen had just said otherwise. - const saveDraft = useCallback(async () => { - if (discardedRef.current) return; - const html = editorRef.current?.innerHTML ?? body; - if (to.length === 0 && !subject && !body.trim() && !html.trim()) return; - setDraftStatus(t("mail.draftSaving")); - try { - const res = await fetch("/api/v1/mail/draft", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - to, - cc, - bcc, - subject, - html, - important, - in_reply_to: inReplyTo, - replace_uid: draftUidRef.current, - }), - }); - if (res.ok) { - const data = await res.json().catch(() => ({})); - if (typeof data.uid === "number") draftUidRef.current = data.uid; - // Discarded while this save was in flight: what it just stored is a - // draft nobody wants, so it goes too. - if (discardedRef.current && draftUidRef.current > 0) { - void removeDraft(draftUidRef.current); - return; - } - } - setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed")); - } catch { - setDraftStatus(t("mail.draftSaveFailed")); - } - }, [to, cc, bcc, subject, body, important, inReplyTo, t]); - - // Debounced so a draft is written after a pause in typing rather than on - // every keystroke. Skipped once the message is on its way out. - useEffect(() => { - if (sending) return; - // The body alone is enough: a message typed before its recipient is still - // worth keeping, and requiring an address here is half of why drafts were - // being stored with nothing in them. - if (to.length === 0 && !subject && !body.trim()) return; - const timer = setTimeout(() => void saveDraft(), 2500); - return () => clearTimeout(timer); - }, [to, subject, cc, bcc, body, important, sending, saveDraft]); - - // Throws the message away: stops the autosave, removes the draft already - // stored for it, and only then leaves. Leaving first was the old behaviour - - // a link to the inbox - and left the draft in Drafts, as if nothing had - // been discarded. - const discardMessage = async () => { - discardedRef.current = true; - setDiscarding(true); - setSendError(null); - const uid = draftUidRef.current; - if (uid > 0 && !(await removeDraft(uid))) { - // Still there, so say so and let the autosave carry on rather than - // leaving the page with a draft the writer thinks is gone. - discardedRef.current = false; - setDiscarding(false); - setSendError(t("mail.discardFailed")); - return; - } - draftUidRef.current = 0; - notifyMailStateChanged(); - window.location.href = "/user/mail/inbox"; - }; - - const executeFormat = (command: string, value: string | undefined = undefined) => { - document.execCommand(command, false, value); - editorRef.current?.focus(); - // execCommand edits the DOM directly, so the mirrored body is re-read - // here rather than relying on an input event that may not be dispatched. - setBody(editorRef.current?.innerHTML ?? ""); - }; - - const handleFileUpload = (e: React.ChangeEvent) => { - if (!e.target.files) return; - const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({ - id: Math.random().toString(36).substring(2, 9), - name: f.name, - size: f.size, - type: f.type || "application/octet-stream", - })); - setAttachments((prev) => [...prev, ...newFiles]); - }; - - const removeAttachment = (id: string) => { - setAttachments((prev) => prev.filter((a) => a.id !== id)); - }; - - // Anything in a recipient field that is not an address. These stay on screen - // as red chips; sending with one would queue mail to a destination that - // cannot exist and report success. - const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid); - const hasRecipient = to.length + cc.length + bcc.length > 0; - - const handleSend = async (e: React.FormEvent) => { - e.preventDefault(); - if (!hasRecipient) { - setSendError(t("mail.noRecipients")); - return; - } - if (invalidRecipients.length > 0) { - setSendError(t("mail.fixRecipients")); - setShowCcBcc(true); - return; - } - setSendError(null); - setSending(true); - setUndoSeconds(10); - - const bodyHtml = editorRef.current?.innerHTML || body; - - const interval = setInterval(() => { - setUndoSeconds((prev) => { - if (prev === null || prev <= 1) { - clearInterval(interval); - void sendActualMessage(bodyHtml); - return null; - } - return prev - 1; - }); - }, 1000); - }; - - const sendActualMessage = async (htmlBody: string) => { - try { - const res = await fetch("/api/v1/mail/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - to, - cc, - bcc, - subject, - html: htmlBody, - important, - in_reply_to: inReplyTo, - // The draft this message grew out of, so the server can discard it. - // Without this the draft outlived the message it became and sat in - // Drafts as a duplicate of mail already sent. - draft_uid: draftUidRef.current, - }), - }); - if (!res.ok) { - // Reporting success on a refused send is how a message silently - // disappears: the composer used to navigate away regardless. - const data = await res.json().catch(() => ({})); - setSendError(data.message ?? t("errors.serverError")); - setSending(false); - return; - } - window.location.href = "/user/mail/sent"; - } catch { - setSendError(t("errors.serverError")); - setSending(false); - } - }; - - const handleUndo = () => { - setUndoSeconds(null); - setSending(false); - }; - - /** One row of the compose sheet: a fixed-width label beside the control. */ - const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]"; - const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400"; - const bareInput = - "min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none"; - const toolButton = - "flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"; - return ( -
-
-
-
-

{t("mail.compose")}

- {draftStatus && ( -
- - {draftStatus} -
- )} -
-
- - - -
-
- - {sendError && ( -
- {sendError} -
- )} - - {undoSeconds !== null && ( -
-
- - {/* The countdown is a placeholder inside the message, not text - bolted on after it. It used to be both: the bundle's own - "{seconds}" was never substituted and a second, hardcoded - count was appended, so this read "Undo Send ({seconds}s - remaining) (9s remaining)". */} - {t("mail.undoSend", { seconds: undoSeconds })} -
- -
- )} - - {/* One sheet, the fields stacked as rows inside it, rather than a stack - of separately outlined inputs: the message is one object. */} -
-
- - !parseAddress(e).valid)} - /> - -
- - {showCcBcc && ( - <> -
- - !parseAddress(e).valid)} - /> -
-
- - !parseAddress(e).valid)} - /> -
- - )} - -
- - setSubject(e.target.value)} - placeholder={t("ui.subject")} - required - disabled={sending} - className={bareInput} - /> -
- - {/* Formatting Toolbar */} -
- - - - - - - - - -
- -
setBody((e.target as HTMLDivElement).innerHTML)} - onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)} - aria-label={t("ui.messageBody")} - role="textbox" - aria-multiline="true" - className="min-h-[280px] px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none" - /> - - {/* Attachment Selector & Display */} -
- - - {attachments.map((file) => ( -
- - {file.name} - {(file.size / 1024).toFixed(1)} KB - -
- ))} - - {/* Says so plainly rather than letting someone attach a contract - and watch it silently not arrive: /mail/send is handed the file - names only, because there is no upload endpoint behind this - picker yet. */} - {attachments.length > 0 && ( - - {t("mail.attachmentsUnsupported")} - - )} -
-
- -
+ { + window.location.href = "/user/mail/sent"; + }} + onDiscarded={() => { + window.location.href = "/user/mail/inbox"; + }} + /> ); } diff --git a/services/webmail/src/components/Composer.tsx b/services/webmail/src/components/Composer.tsx new file mode 100644 index 0000000..00bfd81 --- /dev/null +++ b/services/webmail/src/components/Composer.tsx @@ -0,0 +1,603 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Send, + Paperclip, + Bold, + Italic, + Underline, + Strikethrough, + List, + ListOrdered, + Link as LinkIcon, + Undo2, + X, + Sparkles, + CheckCircle2, + Flag, +} from "lucide-react"; +import { useTranslations } from "../i18n/provider"; +import { Button } from "../components/ui/Button"; +import { notifyMailStateChanged } from "../lib/useFolders"; +import { signatureToHtml } from "../lib/signature"; +import { RecipientInput } from "../components/RecipientInput"; +import { parseAddress, parseAddressList, prefixSubject } from "../lib/recipients"; + +/** Entries from a query-string list, which may be several addresses. */ +function entriesFrom(value: string | undefined): string[] { + return value ? parseAddressList(value).map((p) => p.entry) : []; +} + +interface AttachedFile { + id: string; + name: string; + size: number; + type: string; +} + +export interface ComposerInitial { + replyTo?: string; + cc?: string; + subject?: string; + inReplyTo?: string; + mode?: string; + draft?: string; +} + +interface ComposerProps { + initial?: ComposerInitial; + /** + * "page" is the full compose screen. "inline" is the reply written under the + * conversation it answers: no title, no subject row (a reply keeps its + * subject) and a shorter editor, so the messages stay in view above it. + */ + variant?: "page" | "inline"; + onSent: () => void; + onDiscarded: () => void; +} + +export function Composer({ initial, variant = "page", onSent, onDiscarded }: ComposerProps) { + const t = useTranslations(); + const inline = variant === "inline"; + const params = initial ?? {}; + + // Recipients are lists of entries, not one string to be split later: a + // reply-all arrives with several people already in To and Cc. + const [to, setTo] = useState(() => entriesFrom(params?.replyTo)); + const [cc, setCc] = useState(() => entriesFrom(params?.cc)); + const [bcc, setBcc] = useState([]); + const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0); + const [subject, setSubject] = useState( + params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "", + ); + // The Message-ID being answered, so the reply threads under it. + const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || ""); + const [important, setImportant] = useState(false); + const [attachments, setAttachments] = useState([]); + const [sending, setSending] = useState(false); + const [undoSeconds, setUndoSeconds] = useState(null); + const [draftStatus, setDraftStatus] = useState(""); + const [discarding, setDiscarding] = useState(false); + const [sendError, setSendError] = useState(null); + // The UID of the draft already stored for this message. Sent back on each + // autosave so the server replaces it instead of leaving another copy. + const draftUidRef = useRef(0); + // Set once the writer has discarded the message. An autosave already on its + // way when that happens would otherwise land afterwards and bring the draft + // back, so every save checks this before and after it talks to the server. + const discardedRef = useRef(false); + + const editorRef = useRef(null); + // The body, mirrored into React state. + // + // It used to live only inside the contenteditable, read through the ref at + // save time. Nothing observed it, so the autosave effect - which depends on + // what it reads - never re-ran when the message itself was typed. The draft + // was written once, moments after the address was entered, and never again: + // that is why a draft left behind by a sent message was empty. + const [body, setBody] = useState(""); + + // An inline reply is opened to be written in, so the cursor starts in it. + useEffect(() => { + if (inline) editorRef.current?.focus(); + }, [inline]); + + useEffect(() => { + const savedSig = localStorage.getItem("lm_user_signature"); + // signatureToHtml, not the raw value: the signature may be the plain text + // of the old field, whose newlines are whitespace once assigned as HTML - + // which is why every line break in a signature used to vanish here. + const signatureHtml = signatureToHtml(savedSig ?? ""); + if (editorRef.current && signatureHtml) { + const initial = `

--
${signatureHtml}`; + editorRef.current.innerHTML = initial; + setBody(initial); + } + }, []); + + // Reopening a draft loads what was written into the composer, and adopts its + // UID so carrying on writing replaces that draft instead of leaving the old + // copy behind and starting a second one. + // + // Opening a draft used to land in the reader, which offered to reply to the + // half-written message and gave no way to finish it. + const draftParam = params?.draft; + useEffect(() => { + const uid = Number(draftParam); + if (!Number.isFinite(uid) || uid <= 0) return; + + let cancelled = false; + void (async () => { + try { + const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" }); + if (!res.ok || cancelled) return; + const message = await res.json(); + if (cancelled) return; + + draftUidRef.current = uid; + setTo(message.to ?? []); + setCc(message.cc ?? []); + setImportant(Boolean(message.important)); + setInReplyTo(message.in_reply_to ?? ""); + if ((message.cc ?? []).length > 0) setShowCcBcc(true); + setSubject(message.subject ?? ""); + const html = message.html || message.text || ""; + if (editorRef.current) editorRef.current.innerHTML = html; + setBody(html); + } catch { + // A draft that cannot be loaded leaves an empty composer rather than + // a broken screen; the draft itself is untouched on the server. + } + })(); + return () => { + cancelled = true; + }; + }, [draftParam]); + + // Removes a stored draft for good. discard_draft is what keeps it out of + // Trash: an ordinary delete moves it there, and a draft sitting in Trash is + // not discarded. + const removeDraft = async (uid: number): Promise => { + try { + const res = await fetch("/api/v1/mail/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }), + }); + return res.ok; + } catch { + return false; + } + }; + + // Saves the draft for real. This used to be a setTimeout that set the words + // "draft saved automatically" and nothing else - no request, no storage - so + // closing the tab lost the message while the screen had just said otherwise. + const saveDraft = useCallback(async () => { + if (discardedRef.current) return; + const html = editorRef.current?.innerHTML ?? body; + if (to.length === 0 && !subject && !body.trim() && !html.trim()) return; + setDraftStatus(t("mail.draftSaving")); + try { + const res = await fetch("/api/v1/mail/draft", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + to, + cc, + bcc, + subject, + html, + important, + in_reply_to: inReplyTo, + replace_uid: draftUidRef.current, + }), + }); + if (res.ok) { + const data = await res.json().catch(() => ({})); + if (typeof data.uid === "number") draftUidRef.current = data.uid; + // Discarded while this save was in flight: what it just stored is a + // draft nobody wants, so it goes too. + if (discardedRef.current && draftUidRef.current > 0) { + void removeDraft(draftUidRef.current); + return; + } + } + setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed")); + } catch { + setDraftStatus(t("mail.draftSaveFailed")); + } + }, [to, cc, bcc, subject, body, important, inReplyTo, t]); + + // Debounced so a draft is written after a pause in typing rather than on + // every keystroke. Skipped once the message is on its way out. + useEffect(() => { + if (sending) return; + // The body alone is enough: a message typed before its recipient is still + // worth keeping, and requiring an address here is half of why drafts were + // being stored with nothing in them. + if (to.length === 0 && !subject && !body.trim()) return; + const timer = setTimeout(() => void saveDraft(), 2500); + return () => clearTimeout(timer); + }, [to, subject, cc, bcc, body, important, sending, saveDraft]); + + // Throws the message away: stops the autosave, removes the draft already + // stored for it, and only then leaves. Leaving first was the old behaviour - + // a link to the inbox - and left the draft in Drafts, as if nothing had + // been discarded. + const discardMessage = async () => { + discardedRef.current = true; + setDiscarding(true); + setSendError(null); + const uid = draftUidRef.current; + if (uid > 0 && !(await removeDraft(uid))) { + // Still there, so say so and let the autosave carry on rather than + // leaving the page with a draft the writer thinks is gone. + discardedRef.current = false; + setDiscarding(false); + setSendError(t("mail.discardFailed")); + return; + } + draftUidRef.current = 0; + notifyMailStateChanged(); + onDiscarded(); + }; + + const executeFormat = (command: string, value: string | undefined = undefined) => { + document.execCommand(command, false, value); + editorRef.current?.focus(); + // execCommand edits the DOM directly, so the mirrored body is re-read + // here rather than relying on an input event that may not be dispatched. + setBody(editorRef.current?.innerHTML ?? ""); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + if (!e.target.files) return; + const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({ + id: Math.random().toString(36).substring(2, 9), + name: f.name, + size: f.size, + type: f.type || "application/octet-stream", + })); + setAttachments((prev) => [...prev, ...newFiles]); + }; + + const removeAttachment = (id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }; + + // Anything in a recipient field that is not an address. These stay on screen + // as red chips; sending with one would queue mail to a destination that + // cannot exist and report success. + const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid); + const hasRecipient = to.length + cc.length + bcc.length > 0; + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + if (!hasRecipient) { + setSendError(t("mail.noRecipients")); + return; + } + if (invalidRecipients.length > 0) { + setSendError(t("mail.fixRecipients")); + setShowCcBcc(true); + return; + } + setSendError(null); + setSending(true); + setUndoSeconds(10); + + const bodyHtml = editorRef.current?.innerHTML || body; + + const interval = setInterval(() => { + setUndoSeconds((prev) => { + if (prev === null || prev <= 1) { + clearInterval(interval); + void sendActualMessage(bodyHtml); + return null; + } + return prev - 1; + }); + }, 1000); + }; + + const sendActualMessage = async (htmlBody: string) => { + try { + const res = await fetch("/api/v1/mail/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + to, + cc, + bcc, + subject, + html: htmlBody, + important, + in_reply_to: inReplyTo, + // The draft this message grew out of, so the server can discard it. + // Without this the draft outlived the message it became and sat in + // Drafts as a duplicate of mail already sent. + draft_uid: draftUidRef.current, + }), + }); + if (!res.ok) { + // Reporting success on a refused send is how a message silently + // disappears: the composer used to navigate away regardless. + const data = await res.json().catch(() => ({})); + setSendError(data.message ?? t("errors.serverError")); + setSending(false); + return; + } + onSent(); + } catch { + setSendError(t("errors.serverError")); + setSending(false); + } + }; + + const handleUndo = () => { + setUndoSeconds(null); + setSending(false); + }; + + /** One row of the compose sheet: a fixed-width label beside the control. */ + const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]"; + const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400"; + const bareInput = + "min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none"; + const toolButton = + "flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"; + + return ( +
+
+
+
+ {!inline &&

{t("mail.compose")}

} + {draftStatus && ( +
+ + {draftStatus} +
+ )} +
+
+ + + +
+
+ + {sendError && ( +
+ {sendError} +
+ )} + + {undoSeconds !== null && ( +
+
+ + {/* The countdown is a placeholder inside the message, not text + bolted on after it. It used to be both: the bundle's own + "{seconds}" was never substituted and a second, hardcoded + count was appended, so this read "Undo Send ({seconds}s + remaining) (9s remaining)". */} + {t("mail.undoSend", { seconds: undoSeconds })} +
+ +
+ )} + + {/* One sheet, the fields stacked as rows inside it, rather than a stack + of separately outlined inputs: the message is one object. */} +
+
+ + !parseAddress(e).valid)} + /> + +
+ + {showCcBcc && ( + <> +
+ + !parseAddress(e).valid)} + /> +
+
+ + !parseAddress(e).valid)} + /> +
+ + )} + + {!inline && ( +
+ + setSubject(e.target.value)} + placeholder={t("ui.subject")} + required + disabled={sending} + className={bareInput} + /> +
+ )} + + {/* Formatting Toolbar */} +
+ + + + + + + + + +
+ +
setBody((e.target as HTMLDivElement).innerHTML)} + onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)} + aria-label={t("ui.messageBody")} + role="textbox" + aria-multiline="true" + className={`${inline ? "min-h-[150px]" : "min-h-[280px]"} px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none`} + /> + + {/* Attachment Selector & Display */} +
+ + + {attachments.map((file) => ( +
+ + {file.name} + {(file.size / 1024).toFixed(1)} KB + +
+ ))} + + {/* Says so plainly rather than letting someone attach a contract + and watch it silently not arrive: /mail/send is handed the file + names only, because there is no upload endpoint behind this + picker yet. */} + {attachments.length > 0 && ( + + {t("mail.attachmentsUnsupported")} + + )} +
+
+ +
+ ); +} diff --git a/services/webmail/src/compose-draft-integrity.test.ts b/services/webmail/src/compose-draft-integrity.test.ts index 0eb95f5..d89619f 100644 --- a/services/webmail/src/compose-draft-integrity.test.ts +++ b/services/webmail/src/compose-draft-integrity.test.ts @@ -18,7 +18,7 @@ const code = (path: string) => .replace(/\{\/\*[\s\S]*?\*\/\}/g, "") .replace(/^\s*\/\/.*$/gm, ""); -const COMPOSE = "src/app/(user)/user/compose/page.tsx"; +const COMPOSE = "src/components/Composer.tsx"; const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx"; describe("the draft a sent message leaves behind", () => {