diff --git a/.gitignore b/.gitignore index 831aefc..3a329a1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ todo.txt /scripts/sync-db.js /scripts/update-env.js /scripts/update-env.js -/scripts/README.md \ No newline at end of file +/scripts/README.md +.vscode/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0967ef4..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/components/PanelPages/EmailingPlatformSection.tsx b/components/PanelPages/EmailingPlatformSection.tsx index bfcfe14..416e4b4 100644 --- a/components/PanelPages/EmailingPlatformSection.tsx +++ b/components/PanelPages/EmailingPlatformSection.tsx @@ -6,14 +6,23 @@ import { FiAlertCircle, FiArchive, FiCheckSquare, + FiCheck, + FiChevronDown, FiChevronsRight, FiCornerUpLeft, FiDownload, FiEdit3, FiFilter, + FiFileText, + FiImage, FiInbox, + FiLink, + FiPaperclip, + FiPrinter, FiKey, FiMail, + FiMaximize2, + FiMinimize2, FiMove, FiPlus, FiRefreshCw, @@ -22,17 +31,37 @@ import { FiUpload, FiUserPlus, FiTrash2, + FiType, FiUsers, FiX, } from "react-icons/fi"; import RowActionMenu, { RowActionMenuItem } from "@/components/ui/RowActionMenu"; import SuccessStatusModal from "@/components/ui/SuccessStatusModal"; import ConfirmActionModal from "@/components/ui/ConfirmActionModal"; +import ComposeRichEditor, { printComposeContent, type ComposeRichEditorHandle } from "@/components/email/ComposeRichEditor"; const inter = Inter({ subsets: ["latin"] }); const PAGE_SIZE = 50; const CONTACTS_PAGE_SIZE = 50; +/** Neutral scrollbar; overrides global purple `::-webkit-scrollbar` in app/globals.css for compose UI */ + +/** Basic validation for comma-separated recipient fields (Cc / Bcc). */ +function validateRecipientCsv(label: "Cc" | "Bcc", raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const parts = trimmed.split(",").map((entry) => entry.trim()).filter(Boolean); + const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + for (const part of parts) { + const addr = part.includes("<") ? (part.match(/<([^>]+)>/)?.[1] || part).trim() : part; + if (!emailOk.test(addr)) return `${label}: invalid address "${part}"`; + } + return null; +} + +const COMPOSE_NEUTRAL_SCROLLBAR = + "[scrollbar-width:thin] [scrollbar-color:rgb(203_213_225)_rgb(241_245_249)] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:rounded-full [&::-webkit-scrollbar-track]:bg-slate-100 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb:hover]:bg-slate-400"; + type ModuleKey = | "inbox" | "sent" @@ -296,6 +325,20 @@ const EmailingPlatformSection: React.FC = ({ const [showBcc, setShowBcc] = useState(false); const [composeSubject, setComposeSubject] = useState(""); const [composeBody, setComposeBody] = useState(""); + const [composeBodyHtml, setComposeBodyHtml] = useState(""); + const [composeAttachments, setComposeAttachments] = useState< + Array<{ id: string; filename: string; contentType: string; contentBase64: string }> + >([]); + const [composeTemplates, setComposeTemplates] = useState< + Array<{ id: string; name: string; subject: string | null; bodyHtml: string | null; bodyText: string | null }> + >([]); + const [composeTemplateMenuOpen, setComposeTemplateMenuOpen] = useState(false); + const [saveTemplateModalOpen, setSaveTemplateModalOpen] = useState(false); + const [saveTemplateName, setSaveTemplateName] = useState(""); + const [saveTemplateSaving, setSaveTemplateSaving] = useState(false); + const composeEditorRef = useRef(null); + const composeAttachInputRef = useRef(null); + const [composeFormattingToolbarOpen, setComposeFormattingToolbarOpen] = useState(false); const [composeAccountEmail, setComposeAccountEmail] = useState(""); const [composeThreadId, setComposeThreadId] = useState(""); const [composeInReplyTo, setComposeInReplyTo] = useState(""); @@ -303,6 +346,8 @@ const EmailingPlatformSection: React.FC = ({ const [sendingCompose, setSendingCompose] = useState(false); const [composeError, setComposeError] = useState(""); const [composeSuccess, setComposeSuccess] = useState(""); + const [sentToastMessage, setSentToastMessage] = useState(null); + const sentToastTimerRef = useRef | null>(null); const [composeExpanded, setComposeExpanded] = useState(false); const [composeActiveDraftKey, setComposeActiveDraftKey] = useState(""); const [inlineComposeMode, setInlineComposeMode] = useState(null); @@ -515,6 +560,21 @@ const EmailingPlatformSection: React.FC = ({ setIsEditContactModalOpen(false); }, [editingContact]); + const showSentToast = useCallback((message: string) => { + setSentToastMessage(message); + if (sentToastTimerRef.current) clearTimeout(sentToastTimerRef.current); + sentToastTimerRef.current = setTimeout(() => { + setSentToastMessage(null); + sentToastTimerRef.current = null; + }, 4500); + }, []); + + useEffect(() => { + return () => { + if (sentToastTimerRef.current) clearTimeout(sentToastTimerRef.current); + }; + }, []); + useEffect(() => { selectedMessageIdRef.current = selectedMessageId; }, [selectedMessageId]); @@ -967,6 +1027,22 @@ const EmailingPlatformSection: React.FC = ({ }, [composeAccountEmail, connectedAccounts, selectedAccounts]); const effectiveComposeDraftStorageKey = composeActiveDraftKey || composeDraftStorageKey; + useEffect(() => { + if (!isComposeOpen || !composeAccountEmail) return; + let cancelled = false; + void fetch(`/api/gmail/templates?accountEmail=${encodeURIComponent(composeAccountEmail)}`) + .then((r) => r.json()) + .then((payload) => { + if (cancelled) return; + const list = Array.isArray(payload?.templates) ? payload.templates : []; + setComposeTemplates(list); + }) + .catch(() => null); + return () => { + cancelled = true; + }; + }, [isComposeOpen, composeAccountEmail]); + const inlineDraftStorageKey = useMemo(() => { if (!inlineComposeMode || !selectedMessage) return ""; if (inlineComposeMode === "forward") return ""; @@ -1482,7 +1558,9 @@ const EmailingPlatformSection: React.FC = ({ const flushDraftsNow = useCallback(() => { if (!sendingCompose && isComposeOpen && effectiveComposeDraftStorageKey) { - const hasComposeContent = composeBody.trim(); + const hasComposeContent = + composeBody.trim() || + composeBodyHtml.replace(/<[^>]+>/g, "").replace(/ /gi, " ").trim(); if (hasComposeContent) { void saveLocalDraft( effectiveComposeDraftStorageKey, @@ -1494,6 +1572,7 @@ const EmailingPlatformSection: React.FC = ({ showBcc, subject: composeSubject, bodyText: composeBody, + bodyHtml: composeBodyHtml, accountEmail: composeAccountEmail, updatedAt: Date.now(), }, @@ -1527,6 +1606,7 @@ const EmailingPlatformSection: React.FC = ({ composeAccountEmail, composeBcc, composeBody, + composeBodyHtml, composeCc, composeSubject, composeTo, @@ -1573,7 +1653,9 @@ const EmailingPlatformSection: React.FC = ({ useEffect(() => { if (sendingCompose || !isComposeOpen || !effectiveComposeDraftStorageKey) return; - const hasContent = composeBody.trim(); + const hasContent = + composeBody.trim() || + composeBodyHtml.replace(/<[^>]+>/g, "").replace(/ /gi, " ").trim(); const timeout = window.setTimeout(() => { if (!hasContent) return; void saveLocalDraft(effectiveComposeDraftStorageKey, { @@ -1584,6 +1666,7 @@ const EmailingPlatformSection: React.FC = ({ showBcc, subject: composeSubject, bodyText: composeBody, + bodyHtml: composeBodyHtml, accountEmail: composeAccountEmail, updatedAt: Date.now(), }); @@ -1594,6 +1677,7 @@ const EmailingPlatformSection: React.FC = ({ composeAccountEmail, composeBcc, composeBody, + composeBodyHtml, composeCc, composeActiveDraftKey, composeDraftStorageKey, @@ -1670,6 +1754,9 @@ const EmailingPlatformSection: React.FC = ({ setShowBcc(Boolean(draftMessage.composeShowBcc)); setComposeSubject(draftMessage.subject || ""); setComposeBody(draftMessage.composeBodyText || ""); + setComposeBodyHtml(draftMessage.composeBodyHtml || ""); + setComposeAttachments([]); + setComposeFormattingToolbarOpen(false); setComposeAccountEmail(accountEmail); setComposeThreadId(draftMessage.threadId || ""); setComposeInReplyTo(draftMessage.messageIdHeader || ""); @@ -1904,7 +1991,7 @@ ${sourceText}`; }; const sendInlineCompose = async () => { - if (!selectedMessage || !inlineComposeTo.trim() || !inlineComposeSubject.trim() || inlineComposeSending) return; + if (!selectedMessage || !inlineComposeTo.trim() || inlineComposeSending) return; const intro = inlineComposeIntroText.trim(); const textBody = intro ? `${intro}\n\n${inlineComposeBodyText}` : inlineComposeBodyText || intro; if (!textBody.trim()) return; @@ -1941,7 +2028,7 @@ ${sourceText}`; () => null ); } - setInlineComposeSuccess("Sent."); + showSentToast("Message Sent"); setInlineComposeMode(null); await Promise.all([loadMessages(), loadMailboxCounts(), loadUnreadBadges()]); setDetailError(""); @@ -2005,6 +2092,10 @@ ${sourceText}`; setShowBcc(false); setComposeSubject(""); setComposeBody(""); + setComposeBodyHtml(""); + setComposeAttachments([]); + setComposeTemplateMenuOpen(false); + setComposeFormattingToolbarOpen(false); setComposeAccountEmail(defaultAccount); setComposeThreadId(""); setComposeInReplyTo(""); @@ -2017,7 +2108,15 @@ ${sourceText}`; }; const handleSendCompose = async () => { - if (!composeTo.trim() || !composeSubject.trim() || !composeBody.trim() || !composeAccountEmail || sendingCompose) return; + const strippedHtml = composeBodyHtml.replace(/<[^>]+>/g, "").replace(/ /gi, " ").trim(); + const hasBody = Boolean(composeBody.trim() || strippedHtml); + if (!composeTo.trim() || !hasBody || !composeAccountEmail || sendingCompose) return; + const ccErr = validateRecipientCsv("Cc", composeCc); + const bccErr = validateRecipientCsv("Bcc", composeBcc); + if (ccErr || bccErr) { + setComposeError(ccErr || bccErr || ""); + return; + } setSendingCompose(true); setComposeError(""); setComposeSuccess(""); @@ -2032,12 +2131,18 @@ ${sourceText}`; bcc: composeBcc.trim(), subject: composeSubject.trim(), body: composeBody.trim(), + bodyHtml: composeBodyHtml.trim(), threadId: composeThreadId || undefined, inReplyTo: composeInReplyTo || undefined, references: composeReferences || undefined, draftKey: effectiveComposeDraftStorageKey || undefined, providerAccountId: providerAccounts.find((entry) => entry.accountEmail === composeAccountEmail.toLowerCase())?.id || undefined, + attachments: composeAttachments.map((a) => ({ + filename: a.filename, + contentType: a.contentType, + contentBase64: a.contentBase64, + })), }), }); const payload = await response.json().catch(() => ({})); @@ -2056,8 +2161,10 @@ ${sourceText}`; setShowBcc(false); setComposeSubject(""); setComposeBody(""); + setComposeBodyHtml(""); + setComposeAttachments([]); setIsComposeOpen(false); - setComposeSuccess("Email sent."); + showSentToast("Message Sent"); if (activeModule === "sent" || activeModule === "drafts") { await Promise.all([loadMessages(), loadMailboxCounts(), loadUnreadBadges()]); } else { @@ -2070,6 +2177,96 @@ ${sourceText}`; } }; + const composeHasMeaningfulBody = useMemo(() => { + const stripped = composeBodyHtml.replace(/<[^>]+>/g, "").replace(/ /gi, " ").trim(); + return Boolean(composeBody.trim() || stripped); + }, [composeBody, composeBodyHtml]); + + const addComposeAttachmentsFromFiles = useCallback(async (fileList: FileList | null) => { + if (!fileList?.length) return; + const additions: Array<{ id: string; filename: string; contentType: string; contentBase64: string }> = []; + for (let i = 0; i < fileList.length; i++) { + const file = fileList.item(i); + if (!file) continue; + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + const comma = dataUrl.indexOf(","); + const contentBase64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl; + additions.push({ + id: `${Date.now()}-${i}-${file.name}`, + filename: file.name, + contentType: file.type || "application/octet-stream", + contentBase64, + }); + } + setComposeAttachments((prev) => [...prev, ...additions]); + }, []); + + const handleSaveComposeTemplate = async () => { + const name = saveTemplateName.trim(); + if (!name || !composeAccountEmail || saveTemplateSaving) return; + setSaveTemplateSaving(true); + setComposeError(""); + try { + const response = await fetch("/api/gmail/templates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountEmail: composeAccountEmail, + name, + subject: composeSubject.trim() || "", + bodyHtml: composeBodyHtml, + bodyText: composeBody, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.message || "Failed to save template."); + setSaveTemplateModalOpen(false); + setSaveTemplateName(""); + const listRes = await fetch( + `/api/gmail/templates?accountEmail=${encodeURIComponent(composeAccountEmail)}` + ); + const listPayload = await listRes.json().catch(() => ({})); + setComposeTemplates(Array.isArray(listPayload?.templates) ? listPayload.templates : []); + } catch (error) { + setComposeError(error instanceof Error ? error.message : "Failed to save template."); + } finally { + setSaveTemplateSaving(false); + } + }; + + const applyComposeTemplate = (templateId: string) => { + const template = composeTemplates.find((entry) => entry.id === templateId); + if (!template) return; + if (template.subject) setComposeSubject(template.subject); + if (template.bodyHtml && template.bodyHtml.trim()) { + setComposeBodyHtml(template.bodyHtml); + setComposeBody(template.bodyText || ""); + } else if (template.bodyText && template.bodyText.trim()) { + const raw = template.bodyText; + setComposeBody(raw); + const esc = (value: string) => + value.replace(/&/g, "&").replace(//g, ">"); + setComposeBodyHtml(`

${esc(raw).replace(/\n/g, "
")}

`); + } + setComposeTemplateMenuOpen(false); + }; + + const handlePrintCompose = () => { + const html = + composeBodyHtml.trim() || + `

${composeBody + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, "
")}

`; + printComposeContent(composeSubject || "(No Subject)", html); + }; + const messagesCountLabel = `${filteredMessages.length} Messages`; const activeModuleLabel = MODULES.find((item) => item.key === activeModule)?.label || "Mailbox"; const pageLabel = `Page ${currentPage}`; @@ -2757,7 +2954,7 @@ ${sourceText}`; setDetailError(""); }} className={`w-full text-left px-3 py-2.5 flex items-center gap-2 transition hover:bg-[#F4EDFF] ${ - isSelected ? "bg-[#EDE1FF]" : message.unread ? "font-semibold bg-[#FBFBFF]" : "" + isSelected ? "bg-[#EDE1FF]" : message.unread ? "bg-[#FBFBFF]" : "" }`} > ) : null} - + {message.isComposeDraft ? ( <> (Draft) @@ -2786,11 +2987,20 @@ ${sourceText}`; senderOrTo )} - - {message.subject || "(No Subject)"} - - {message.snippet || "No preview available."} + + + {message.subject || "(No Subject)"} + + + {" "} + - {message.snippet || "No preview available."} + - + {formatDate(message.timestamp, message.date)} @@ -2959,76 +3169,105 @@ ${sourceText}`; ))} {inlineComposeMode ? ( -
-
-

- {inlineComposeMode === "forward" - ? "Forward Message" - : inlineComposeMode === "replyAll" - ? "Reply All" - : "Reply"} -

-
-
-
- - {inlineComposeMode === "forward" ? "To" : "Replying To"} +
+
+
+
+
+ +
+
+

+ {inlineComposeMode === "forward" + ? "Forward" + : inlineComposeMode === "replyAll" + ? "Reply all" + : "Reply"} +

+

Compose below the thread

+
+
+ + Draft - setInlineComposeTo(event.target.value)} - className="w-full bg-transparent px-0 py-1.5 text-sm text-[#111827] outline-none" - placeholder="Recipient Email" - />
-
- Subject - setInlineComposeSubject(event.target.value)} - className="w-full bg-transparent px-0 py-1.5 text-sm text-[#111827] outline-none" - /> -
-
-