diff --git a/frontend/src/components/ComposeModal.jsx b/frontend/src/components/ComposeModal.jsx index bb36db3..5a8563d 100644 --- a/frontend/src/components/ComposeModal.jsx +++ b/frontend/src/components/ComposeModal.jsx @@ -1,166 +1,696 @@ import { + AlignCenter, + AlignLeft, + AlignRight, + ArrowRight, Bold, + ChevronDown, + Clock, + Image, Italic, Link, List, + ListOrdered, + Lock, + Minus, + MoreHorizontal, + Paperclip, + PenLine, + Quote, Send, + Smile, + Strikethrough, + Trash2, Underline, X, + Maximize2, + Minimize2, + ExternalLink, + RemoveFormatting, + IndentIncrease, + IndentDecrease, } from "lucide-react"; import AgentSpark from "./AgentSpark"; -import { useEffect, useRef, useState } from "react"; -import { sendEmail } from "../api"; +import RecipientField, { recipientsToPayload } from "./compose/RecipientField"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { listMailAliases, listOrgMembers, sendEmail } from "../api"; -function parseAddresses(value) { - return value - .split(/[,\n;]/) - .map((address) => address.trim()) - .filter(Boolean); -} +const EMOJI_SAMPLES = ["😀", "😊", "👍", "🙏", "✅", "🎉", "📎", "📅", "💡", "🔥", "❤️", "😅"]; +const DRAFT_KEY_PREFIX = "agmail_draft_"; function plainTextFromHtml(value) { - const document = new DOMParser().parseFromString(value, "text/html"); - return document.body.textContent || ""; + const doc = new DOMParser().parseFromString(value, "text/html"); + return (doc.body.textContent || "").replace(/\u00a0/g, " ").trim(); } -export default function ComposeModal({ account, onClose, onSent }) { - const [format, setFormat] = useState("plain"); - const [showCopies, setShowCopies] = useState(false); - const [body, setBody] = useState(""); - const [aiPrompt, setAiPrompt] = useState(""); - const [status, setStatus] = useState({ sending: false, error: "" }); - const bodyRef = useRef(null); +function formatFileSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }) { + const warnings = []; + const body = plainTextFromHtml(bodyHtml); + const allRecipients = [...to, ...cc, ...bcc]; + const external = allRecipients.filter((r) => r.external); + if (/\battach(ed|ment|ing)\b/i.test(body) && attachments.length === 0) { + warnings.push("You mentioned an attachment, but no file is attached."); + } + if (external.length) { + warnings.push(`${external.length} recipient${external.length > 1 ? "s are" : " is"} outside your organization.`); + } + if (allRecipients.some((r) => r.invalid)) { + warnings.push("One or more recipient addresses look invalid."); + } + const mentionMatch = body.match(/\b(?:hi|hey|dear)\s+([A-Z][a-z]+)\b/); + if (mentionMatch) { + const name = mentionMatch[1].toLowerCase(); + const inRecipients = allRecipients.some((r) => r.name?.toLowerCase().includes(name)); + if (!inRecipients) { + warnings.push(`This email mentions ${mentionMatch[1]}, who isn't a recipient.`); + } + } + if (/(api[_-]?key|secret|password|token)\s*[:=]/i.test(body)) { + warnings.push("This message may contain sensitive credentials."); + } + if (orgDomain && allRecipients.filter((r) => r.external).length >= 10) { + warnings.push(`You're sending to ${allRecipients.length} recipients.`); + } + return warnings; +} + +function MenuDropdown({ label, items, align = "left", className = "" }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); useEffect(() => { - function closeOnEscape(event) { - if (event.key === "Escape" && !status.sending) onClose(); + function close(event) { + if (!ref.current?.contains(event.target)) setOpen(false); } - document.addEventListener("keydown", closeOnEscape); - return () => document.removeEventListener("keydown", closeOnEscape); - }, [onClose, status.sending]); + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, []); - function helpWrite() { - const request = aiPrompt.trim(); - if (!request) return; - const draft = format === "html" - ? `

Hi,

${request}

Best,
${account.first_name}

` - : `Hi,\n\n${request}\n\nBest,\n${account.first_name}`; - setBody(draft); - setAiPrompt(""); + return ( +
+ + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +export default function ComposeModal({ + account, + windowId, + stackIndex = 0, + onClose, + onSent, + onMinimizeChange, + initialMinimized = false, +}) { + const editorRef = useRef(null); + const fileInputRef = useRef(null); + const imageInputRef = useRef(null); + const draftTimerRef = useRef(null); + + const [minimized, setMinimized] = useState(initialMinimized); + const [expanded, setExpanded] = useState(false); + const [detached, setDetached] = useState(false); + const [showCc, setShowCc] = useState(false); + const [showBcc, setShowBcc] = useState(false); + const [to, setTo] = useState([]); + const [cc, setCc] = useState([]); + const [bcc, setBcc] = useState([]); + const [subject, setSubject] = useState(""); + const [bodyHtml, setBodyHtml] = useState(""); + const [attachments, setAttachments] = useState([]); + const [fromAddress, setFromAddress] = useState({ email: account.email, label: "Default", primary: true }); + const [fromOptions, setFromOptions] = useState([]); + const [contacts, setContacts] = useState([]); + const [draftStatus, setDraftStatus] = useState(""); + const [notice, setNotice] = useState(""); + const [warnings, setWarnings] = useState([]); + const [showEmoji, setShowEmoji] = useState(false); + const [status, setStatus] = useState({ sending: false, error: "" }); + + const orgDomain = account.company_domain || account.email.split("@")[1]; + const draftKey = `${DRAFT_KEY_PREFIX}${windowId || account.id}`; + const draftHydratedRef = useRef(false); + + const signatures = useMemo(() => [ + { + id: "personal", + label: `${account.first_name} — Personal`, + html: `

—
${account.first_name} ${account.last_name}

`, + }, + { + id: "work", + label: `${account.first_name} — ${orgDomain || "Work"}`, + html: `

—
${account.first_name} ${account.last_name}
${fromAddress.email}

`, + }, + { id: "none", label: "No signature", html: "" }, + ], [account, fromAddress.email, orgDomain]); + + const saveDraft = useCallback(() => { + const payload = { to, cc, bcc, subject, bodyHtml, updatedAt: Date.now() }; + const empty = !subject && !bodyHtml && !to.length && !cc.length && !bcc.length; + if (empty) { + localStorage.removeItem(draftKey); + setDraftStatus(""); + return; + } + setDraftStatus("Saving…"); + localStorage.setItem(draftKey, JSON.stringify(payload)); + window.setTimeout(() => { + setDraftStatus("Saved to Drafts"); + window.setTimeout(() => setDraftStatus(""), 2400); + }, 420); + }, [to, cc, bcc, subject, bodyHtml, draftKey]); + + useEffect(() => { + if (draftHydratedRef.current) return; + draftHydratedRef.current = true; + try { + const raw = localStorage.getItem(draftKey); + if (!raw) return; + const draft = JSON.parse(raw); + setTo(draft.to || []); + setCc(draft.cc || []); + setBcc(draft.bcc || []); + setSubject(draft.subject || ""); + setBodyHtml(draft.bodyHtml || ""); + setShowCc(Boolean(draft.cc?.length)); + setShowBcc(Boolean(draft.bcc?.length)); + if (editorRef.current && draft.bodyHtml) { + editorRef.current.innerHTML = draft.bodyHtml; + } + } catch { + /* ignore corrupt drafts */ + } + }, [draftKey]); + + useEffect(() => { + const token = sessionStorage.getItem("agmail_access_token"); + if (!token) return; + listOrgMembers(token) + .then((members) => { + setContacts( + (members || []).map((member) => ({ + name: `${member.first_name} ${member.last_name}`.trim(), + email: member.email, + })), + ); + }) + .catch(() => {}); + listMailAliases(token) + .then((aliases) => { + const options = [ + { email: account.email, label: "Default", primary: true }, + ...(aliases || []).map((alias) => ({ + email: alias.alias_address, + label: alias.forwards_to || alias.alias_address, + primary: false, + })), + ].filter((item) => item.email); + setFromOptions(options); + }) + .catch(() => { + setFromOptions([{ email: account.email, label: "Default", primary: true }]); + }); + }, [account.email]); + + useEffect(() => { + if (draftTimerRef.current) window.clearTimeout(draftTimerRef.current); + draftTimerRef.current = window.setTimeout(saveDraft, 1400); + return () => { + if (draftTimerRef.current) window.clearTimeout(draftTimerRef.current); + }; + }, [to, cc, bcc, subject, bodyHtml, saveDraft]); + + useEffect(() => { + setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain })); + }, [bodyHtml, to, cc, bcc, attachments, orgDomain]); + + useEffect(() => { + onMinimizeChange?.(minimized); + }, [minimized, onMinimizeChange]); + + + function flashNotice(message) { + setNotice(message); + window.setTimeout(() => setNotice(""), 2800); } - function applyHtml(openingTag, closingTag) { - const editor = bodyRef.current; - if (!editor || format !== "html") return; - const start = editor.selectionStart; - const end = editor.selectionEnd; - const selected = body.slice(start, end) || "text"; - setBody(`${body.slice(0, start)}${openingTag}${selected}${closingTag}${body.slice(end)}`); - window.requestAnimationFrame(() => editor.focus()); + function syncBodyFromEditor() { + const html = editorRef.current?.innerHTML || ""; + setBodyHtml(html === "
" ? "" : html); } - async function submit(event) { - event.preventDefault(); - const form = new FormData(event.currentTarget); + function execFormat(command, value = null) { + editorRef.current?.focus(); + document.execCommand(command, false, value); + syncBodyFromEditor(); + } + + function insertLink() { + const url = window.prompt("Enter link URL", "https://"); + if (!url) return; + execFormat("createLink", url); + } + + function insertEmoji(emoji) { + editorRef.current?.focus(); + document.execCommand("insertText", false, emoji); + syncBodyFromEditor(); + setShowEmoji(false); + } + + function insertSignature(signature) { + if (!signature.html) return; + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + const block = `${editor.innerHTML ? "
" : ""}${signature.html}`; + document.execCommand("insertHTML", false, block); + syncBodyFromEditor(); + flashNotice(`Inserted signature: ${signature.label}`); + } + + function addFiles(fileList, asInline = false) { + const files = Array.from(fileList || []); + if (!files.length) return; + setAttachments((current) => [ + ...current, + ...files.map((file) => ({ + id: `${file.name}-${file.size}-${Date.now()}`, + name: file.name, + size: file.size, + type: file.type, + inline: asInline, + preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : null, + })), + ]); + } + + function removeAttachment(id) { + setAttachments((current) => { + const target = current.find((item) => item.id === id); + if (target?.preview) URL.revokeObjectURL(target.preview); + return current.filter((item) => item.id !== id); + }); + } + + function suggestSubject() { + const body = plainTextFromHtml(bodyHtml); + if (!body) { + flashNotice("Write a message first to get subject suggestions."); + return; + } + const firstLine = body.split("\n").find((line) => line.trim().length > 8)?.trim() || "Follow up"; + const shortened = firstLine.length > 58 ? `${firstLine.slice(0, 55)}…` : firstLine; + setSubject(shortened); + flashNotice("Subject suggestion applied."); + } + + function discardDraft() { + localStorage.removeItem(draftKey); + setTo([]); + setCc([]); + setBcc([]); + setSubject(""); + setBodyHtml(""); + if (editorRef.current) editorRef.current.innerHTML = ""; + setAttachments([]); + setDraftStatus(""); + onClose(); + } + + const submitSend = useCallback(async () => { + if (!to.length) { + setStatus({ sending: false, error: "Add at least one recipient." }); + return; + } setStatus({ sending: true, error: "" }); try { await sendEmail( { - recipients: parseAddresses(form.get("to")), - cc: parseAddresses(form.get("cc") || ""), - bcc: parseAddresses(form.get("bcc") || ""), - subject: form.get("subject"), - body_text: format === "plain" ? body : plainTextFromHtml(body), - body_html: format === "html" ? body : null, + recipients: recipientsToPayload(to), + cc: recipientsToPayload(cc), + bcc: recipientsToPayload(bcc), + subject, + body_text: plainTextFromHtml(bodyHtml), + body_html: bodyHtml || null, }, sessionStorage.getItem("agmail_access_token"), ); + localStorage.removeItem(draftKey); + flashNotice("Message sent. Undo available for 5 seconds."); onSent(); } catch (error) { setStatus({ sending: false, error: error.message }); } + }, [to, cc, bcc, subject, bodyHtml, draftKey, onSent]); + + useEffect(() => { + function onKeyDown(event) { + const mod = event.metaKey || event.ctrlKey; + if (mod && event.key === "Enter" && !status.sending) { + event.preventDefault(); + submitSend(); + } + if (mod && event.key === "k") { + event.preventDefault(); + insertLink(); + } + if (mod && event.shiftKey && event.key.toLowerCase() === "c") { + event.preventDefault(); + setShowCc(true); + } + if (mod && event.shiftKey && event.key.toLowerCase() === "b") { + event.preventDefault(); + setShowBcc(true); + } + if (event.key === "Escape" && !status.sending) { + onClose(); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [status.sending, submitSend, onClose]); + + function togglePopOut() { + if (detached) { + setDetached(false); + return; + } + setDetached(true); + setExpanded(false); + flashNotice("Composer popped out — still linked to your mailbox."); } - return ( -
-
-
-
- -

New message

From {account.email}
-
- -
+ const shellClass = [ + "compose-window", + minimized ? "minimized" : "", + expanded ? "expanded" : "", + detached ? "detached" : "", + ].filter(Boolean).join(" "); -
-
- - - + const offset = stackIndex * 28; + + return ( +
+
+
+ +
+

New message

+ + From: {fromAddress.email} + + } + items={fromOptions.map((option) => ({ + id: option.email, + label: option.email, + hint: option.primary ? "Default" : option.label, + active: option.email === fromAddress.email, + onClick: () => setFromAddress(option), + }))} + />
- {showCopies && ( -
- - +
+
+ + + + +
+
+ + {!minimized && ( +
+
+ +
+ {!showCc && ( + + )} + {!showBcc && ( + + )} +
+
+ + {showCc && ( + + )} + {showBcc && ( + + )} + +
+ setSubject(event.target.value)} + placeholder="Subject" + maxLength={998} + /> + {bodyHtml && !subject && ( + + )} +
+ + {attachments.length > 0 && ( +
+ {attachments.map((file) => ( +
+ {file.inline ? "🖼" : "📄"} +
+ {file.name} + {file.type || "File"} · {formatFileSize(file.size)} +
+
+ + + +
+
+ ))}
)} - -
- Message format -
- - -
-
- -