diff --git a/bun.lockb b/bun.lockb index e560d2f..2045680 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 10c5f52..6d7d167 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@tiptap/react": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", "@types/file-saver": "^2.0.7", + "@types/mailparser": "3.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -61,10 +62,12 @@ "docx": "^9.6.1", "embla-carousel-react": "^8.6.0", "file-saver": "^2.0.5", + "imapflow": "1.0.171", "input-otp": "^1.4.2", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", "lucide-react": "^0.575.0", + "mailparser": "3.7.1", "react": "^19.2.0", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 65e7e89..da0c777 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,7 +1,7 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useAuth } from "@/lib/auth"; import { Button } from "@/components/ui/button"; -import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard, Calendar as CalendarIcon, CheckSquare, MessageSquare, Activity, DollarSign, FolderOpen, Files as FilesIcon, Settings as SettingsIcon, ClipboardList, UserPlus, User as UserIcon } from "lucide-react"; +import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard, Calendar as CalendarIcon, CheckSquare, MessageSquare, Activity, DollarSign, FolderOpen, Files as FilesIcon, Settings as SettingsIcon, ClipboardList, UserPlus, User as UserIcon, Inbox as InboxIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; import { HeaderTimer } from "@/components/timer/header-timer"; @@ -24,6 +24,7 @@ const NAV: NavItem[] = [ { to: "/cases", label: "Cases", icon: Briefcase }, { to: "/tasks", label: "Tasks", icon: CheckSquare }, { to: "/messages", label: "Messages", icon: MessageSquare }, + { to: "/inbox", label: "Inbox", icon: InboxIcon }, { to: "/status", label: "Status Updates", icon: Activity }, { to: "/collections", label: "Collections", icon: DollarSign }, { to: "/documents", label: "Documents", icon: FolderOpen }, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index fb097a5..026bfc4 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as StatusIndexRouteImport } from './routes/status.index' import { Route as SettingsIndexRouteImport } from './routes/settings.index' import { Route as MessagesIndexRouteImport } from './routes/messages.index' import { Route as InvoicesIndexRouteImport } from './routes/invoices.index' +import { Route as InboxIndexRouteImport } from './routes/inbox.index' import { Route as FormsIndexRouteImport } from './routes/forms.index' import { Route as FilesIndexRouteImport } from './routes/files.index' import { Route as DocumentsIndexRouteImport } from './routes/documents.index' @@ -30,8 +31,10 @@ import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflo import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow' import { Route as SettingsSmtpRouteImport } from './routes/settings.smtp' import { Route as SettingsProfileRouteImport } from './routes/settings.profile' +import { Route as SettingsImapRouteImport } from './routes/settings.imap' import { Route as SettingsFeesRouteImport } from './routes/settings.fees' import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId' +import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap' import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId' import { Route as CollectionsCollectionIdRouteImport } from './routes/collections.$collectionId' import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId' @@ -88,6 +91,11 @@ const InvoicesIndexRoute = InvoicesIndexRouteImport.update({ path: '/invoices/', getParentRoute: () => rootRouteImport, } as any) +const InboxIndexRoute = InboxIndexRouteImport.update({ + id: '/inbox/', + path: '/inbox/', + getParentRoute: () => rootRouteImport, +} as any) const FormsIndexRoute = FormsIndexRouteImport.update({ id: '/forms/', path: '/forms/', @@ -148,6 +156,11 @@ const SettingsProfileRoute = SettingsProfileRouteImport.update({ path: '/profile', getParentRoute: () => SettingsRoute, } as any) +const SettingsImapRoute = SettingsImapRouteImport.update({ + id: '/imap', + path: '/imap', + getParentRoute: () => SettingsRoute, +} as any) const SettingsFeesRoute = SettingsFeesRouteImport.update({ id: '/fees', path: '/fees', @@ -158,6 +171,11 @@ const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({ path: '/invoices/$invoiceId', getParentRoute: () => rootRouteImport, } as any) +const HooksPollImapRoute = HooksPollImapRouteImport.update({ + id: '/hooks/poll-imap', + path: '/hooks/poll-imap', + getParentRoute: () => rootRouteImport, +} as any) const ContactsContactIdRoute = ContactsContactIdRouteImport.update({ id: '/contacts/$contactId', path: '/contacts/$contactId', @@ -221,8 +239,10 @@ export interface FileRoutesByFullPath { '/clients/$clientId': typeof ClientsClientIdRoute '/collections/$collectionId': typeof CollectionsCollectionIdRoute '/contacts/$contactId': typeof ContactsContactIdRoute + '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/imap': typeof SettingsImapRoute '/settings/profile': typeof SettingsProfileRoute '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute @@ -235,6 +255,7 @@ export interface FileRoutesByFullPath { '/documents/': typeof DocumentsIndexRoute '/files/': typeof FilesIndexRoute '/forms/': typeof FormsIndexRoute + '/inbox/': typeof InboxIndexRoute '/invoices/': typeof InvoicesIndexRoute '/messages/': typeof MessagesIndexRoute '/settings/': typeof SettingsIndexRoute @@ -255,8 +276,10 @@ export interface FileRoutesByTo { '/clients/$clientId': typeof ClientsClientIdRoute '/collections/$collectionId': typeof CollectionsCollectionIdRoute '/contacts/$contactId': typeof ContactsContactIdRoute + '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/imap': typeof SettingsImapRoute '/settings/profile': typeof SettingsProfileRoute '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute @@ -269,6 +292,7 @@ export interface FileRoutesByTo { '/documents': typeof DocumentsIndexRoute '/files': typeof FilesIndexRoute '/forms': typeof FormsIndexRoute + '/inbox': typeof InboxIndexRoute '/invoices': typeof InvoicesIndexRoute '/messages': typeof MessagesIndexRoute '/settings': typeof SettingsIndexRoute @@ -291,8 +315,10 @@ export interface FileRoutesById { '/clients/$clientId': typeof ClientsClientIdRoute '/collections/$collectionId': typeof CollectionsCollectionIdRoute '/contacts/$contactId': typeof ContactsContactIdRoute + '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/imap': typeof SettingsImapRoute '/settings/profile': typeof SettingsProfileRoute '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute @@ -305,6 +331,7 @@ export interface FileRoutesById { '/documents/': typeof DocumentsIndexRoute '/files/': typeof FilesIndexRoute '/forms/': typeof FormsIndexRoute + '/inbox/': typeof InboxIndexRoute '/invoices/': typeof InvoicesIndexRoute '/messages/': typeof MessagesIndexRoute '/settings/': typeof SettingsIndexRoute @@ -328,8 +355,10 @@ export interface FileRouteTypes { | '/clients/$clientId' | '/collections/$collectionId' | '/contacts/$contactId' + | '/hooks/poll-imap' | '/invoices/$invoiceId' | '/settings/fees' + | '/settings/imap' | '/settings/profile' | '/settings/smtp' | '/settings/workflow' @@ -342,6 +371,7 @@ export interface FileRouteTypes { | '/documents/' | '/files/' | '/forms/' + | '/inbox/' | '/invoices/' | '/messages/' | '/settings/' @@ -362,8 +392,10 @@ export interface FileRouteTypes { | '/clients/$clientId' | '/collections/$collectionId' | '/contacts/$contactId' + | '/hooks/poll-imap' | '/invoices/$invoiceId' | '/settings/fees' + | '/settings/imap' | '/settings/profile' | '/settings/smtp' | '/settings/workflow' @@ -376,6 +408,7 @@ export interface FileRouteTypes { | '/documents' | '/files' | '/forms' + | '/inbox' | '/invoices' | '/messages' | '/settings' @@ -397,8 +430,10 @@ export interface FileRouteTypes { | '/clients/$clientId' | '/collections/$collectionId' | '/contacts/$contactId' + | '/hooks/poll-imap' | '/invoices/$invoiceId' | '/settings/fees' + | '/settings/imap' | '/settings/profile' | '/settings/smtp' | '/settings/workflow' @@ -411,6 +446,7 @@ export interface FileRouteTypes { | '/documents/' | '/files/' | '/forms/' + | '/inbox/' | '/invoices/' | '/messages/' | '/settings/' @@ -433,6 +469,7 @@ export interface RootRouteChildren { ClientsClientIdRoute: typeof ClientsClientIdRoute CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute ContactsContactIdRoute: typeof ContactsContactIdRoute + HooksPollImapRoute: typeof HooksPollImapRoute InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute CalendarIndexRoute: typeof CalendarIndexRoute CasesIndexRoute: typeof CasesIndexRoute @@ -442,6 +479,7 @@ export interface RootRouteChildren { DocumentsIndexRoute: typeof DocumentsIndexRoute FilesIndexRoute: typeof FilesIndexRoute FormsIndexRoute: typeof FormsIndexRoute + InboxIndexRoute: typeof InboxIndexRoute InvoicesIndexRoute: typeof InvoicesIndexRoute MessagesIndexRoute: typeof MessagesIndexRoute StatusIndexRoute: typeof StatusIndexRoute @@ -517,6 +555,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InvoicesIndexRouteImport parentRoute: typeof rootRouteImport } + '/inbox/': { + id: '/inbox/' + path: '/inbox' + fullPath: '/inbox/' + preLoaderRoute: typeof InboxIndexRouteImport + parentRoute: typeof rootRouteImport + } '/forms/': { id: '/forms/' path: '/forms' @@ -601,6 +646,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProfileRouteImport parentRoute: typeof SettingsRoute } + '/settings/imap': { + id: '/settings/imap' + path: '/imap' + fullPath: '/settings/imap' + preLoaderRoute: typeof SettingsImapRouteImport + parentRoute: typeof SettingsRoute + } '/settings/fees': { id: '/settings/fees' path: '/fees' @@ -615,6 +667,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InvoicesInvoiceIdRouteImport parentRoute: typeof rootRouteImport } + '/hooks/poll-imap': { + id: '/hooks/poll-imap' + path: '/hooks/poll-imap' + fullPath: '/hooks/poll-imap' + preLoaderRoute: typeof HooksPollImapRouteImport + parentRoute: typeof rootRouteImport + } '/contacts/$contactId': { id: '/contacts/$contactId' path: '/contacts/$contactId' @@ -690,6 +749,7 @@ declare module '@tanstack/react-router' { interface SettingsRouteChildren { SettingsFeesRoute: typeof SettingsFeesRoute + SettingsImapRoute: typeof SettingsImapRoute SettingsProfileRoute: typeof SettingsProfileRoute SettingsSmtpRoute: typeof SettingsSmtpRoute SettingsWorkflowRoute: typeof SettingsWorkflowRoute @@ -699,6 +759,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsFeesRoute: SettingsFeesRoute, + SettingsImapRoute: SettingsImapRoute, SettingsProfileRoute: SettingsProfileRoute, SettingsSmtpRoute: SettingsSmtpRoute, SettingsWorkflowRoute: SettingsWorkflowRoute, @@ -721,6 +782,7 @@ const rootRouteChildren: RootRouteChildren = { ClientsClientIdRoute: ClientsClientIdRoute, CollectionsCollectionIdRoute: CollectionsCollectionIdRoute, ContactsContactIdRoute: ContactsContactIdRoute, + HooksPollImapRoute: HooksPollImapRoute, InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute, CalendarIndexRoute: CalendarIndexRoute, CasesIndexRoute: CasesIndexRoute, @@ -730,6 +792,7 @@ const rootRouteChildren: RootRouteChildren = { DocumentsIndexRoute: DocumentsIndexRoute, FilesIndexRoute: FilesIndexRoute, FormsIndexRoute: FormsIndexRoute, + InboxIndexRoute: InboxIndexRoute, InvoicesIndexRoute: InvoicesIndexRoute, MessagesIndexRoute: MessagesIndexRoute, StatusIndexRoute: StatusIndexRoute, diff --git a/src/routes/hooks/poll-imap.ts b/src/routes/hooks/poll-imap.ts new file mode 100644 index 0000000..3a99242 --- /dev/null +++ b/src/routes/hooks/poll-imap.ts @@ -0,0 +1,157 @@ +// Server route that connects to the configured IMAP mailbox, fetches new +// messages since the last polled UID, and stores them in incoming_emails. +// Triggered by pg_cron and by the "Poll now" button in settings. +import { createFileRoute } from "@tanstack/react-router"; +import { supabaseAdmin } from "@/integrations/supabase/client.server"; +import { ImapFlow } from "imapflow"; +import { simpleParser } from "mailparser"; + +const MAX_MESSAGES_PER_RUN = 50; + +export const Route = createFileRoute("/hooks/poll-imap")({ + server: { + handlers: { + POST: async () => { + try { + const { data: settings, error: setErr } = await supabaseAdmin + .from("imap_settings") + .select("*") + .eq("enabled", true) + .order("updated_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (setErr) return json({ error: setErr.message }, 500); + if (!settings) return json({ error: "No IMAP settings configured" }, 400); + + const password = process.env.IMAP_PASSWORD; + if (!password) { + return json({ error: "IMAP_PASSWORD secret is not set" }, 400); + } + + const client = new ImapFlow({ + host: settings.host, + port: settings.port, + secure: !!settings.secure, + auth: { user: settings.username, pass: password }, + logger: false, + }); + + let imported = 0; + let highestUid = settings.last_uid ?? 0; + let errorMessage: string | null = null; + + try { + await client.connect(); + const lock = await client.getMailboxLock(settings.folder ?? "INBOX"); + + try { + const sinceUid = (settings.last_uid ?? 0) + 1; + const range = `${sinceUid}:*`; + + for await (const msg of client.fetch( + range, + { uid: true, source: true, envelope: true, size: true }, + { uid: true }, + )) { + if (imported >= MAX_MESSAGES_PER_RUN) break; + if (msg.uid <= (settings.last_uid ?? 0)) continue; + + let parsed; + try { + parsed = await simpleParser(msg.source as Buffer); + } catch (e) { + console.error("Failed to parse message uid", msg.uid, e); + continue; + } + + const fromAddr = parsed.from?.value?.[0]?.address ?? null; + const fromName = parsed.from?.value?.[0]?.name ?? null; + const toAddrs = + Array.isArray(parsed.to) + ? parsed.to.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean)) + : (parsed.to?.value?.map((v: any) => v.address).filter(Boolean) ?? []); + const ccAddrs = + Array.isArray(parsed.cc) + ? parsed.cc.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean)) + : (parsed.cc?.value?.map((v: any) => v.address).filter(Boolean) ?? []); + + const text = parsed.text ?? null; + const snippet = text + ? text.replace(/\s+/g, " ").trim().slice(0, 280) + : null; + const attachments = parsed.attachments ?? []; + + const insertRow = { + message_id: parsed.messageId ?? null, + imap_uid: msg.uid, + received_at: (parsed.date ?? new Date()).toISOString(), + from_address: fromAddr, + from_name: fromName, + to_addresses: toAddrs, + cc_addresses: ccAddrs, + subject: parsed.subject ?? null, + body_text: text, + body_html: parsed.html || null, + snippet, + has_attachments: attachments.length > 0, + attachment_count: attachments.length, + raw_size_bytes: msg.size ?? null, + }; + + // Upsert by message_id to avoid duplicates if mailbox is re-polled + const { error: insErr } = await supabaseAdmin + .from("incoming_emails") + .upsert(insertRow, { onConflict: "message_id", ignoreDuplicates: true }); + + if (insErr && !insErr.message.includes("duplicate")) { + console.error("Insert failed for uid", msg.uid, insErr.message); + } else { + imported++; + } + + if (msg.uid > highestUid) highestUid = msg.uid; + } + } finally { + lock.release(); + } + await client.logout(); + } catch (e) { + errorMessage = e instanceof Error ? e.message : String(e); + console.error("IMAP poll error:", errorMessage); + try { + await client.close(); + } catch (_) { + /* ignore */ + } + } + + await supabaseAdmin + .from("imap_settings") + .update({ + last_uid: highestUid, + last_polled_at: new Date().toISOString(), + last_error: errorMessage, + }) + .eq("id", settings.id); + + if (errorMessage) { + return json({ ok: false, imported, error: errorMessage }, 500); + } + return json({ ok: true, imported, last_uid: highestUid }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error("poll-imap fatal", message); + return json({ error: message }, 500); + } + }, + }, + }, +}); + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/routes/inbox.index.tsx b/src/routes/inbox.index.tsx new file mode 100644 index 0000000..6baef28 --- /dev/null +++ b/src/routes/inbox.index.tsx @@ -0,0 +1,299 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { ProtectedLayout } from "@/components/protected-layout"; +import { PageContainer, PageHeader } from "@/components/app-shell"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Loader2, Inbox as InboxIcon, RefreshCcw, Search, Mail, MailOpen, Archive, ArchiveRestore, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useAuth } from "@/lib/auth"; + +export const Route = createFileRoute("/inbox/")({ + component: InboxPage, +}); + +interface IncomingEmail { + id: string; + received_at: string; + from_address: string | null; + from_name: string | null; + to_addresses: string[]; + subject: string | null; + snippet: string | null; + body_text: string | null; + body_html: string | null; + is_read: boolean; + is_archived: boolean; + has_attachments: boolean; + attachment_count: number; +} + +function InboxPage() { + const { isAdmin } = useAuth(); + const [emails, setEmails] = useState([]); + const [loading, setLoading] = useState(true); + const [polling, setPolling] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + + const load = async () => { + setLoading(true); + let query = supabase + .from("incoming_emails") + .select("id, received_at, from_address, from_name, to_addresses, subject, snippet, body_text, body_html, is_read, is_archived, has_attachments, attachment_count") + .order("received_at", { ascending: false }) + .limit(200); + query = showArchived ? query.eq("is_archived", true) : query.eq("is_archived", false); + const { data, error } = await query; + if (error) { + toast.error("Failed to load inbox", { description: error.message }); + } else { + setEmails((data as IncomingEmail[]) ?? []); + } + setLoading(false); + }; + + useEffect(() => { + load(); + }, [showArchived]); + + const pollNow = async () => { + setPolling(true); + try { + const res = await fetch("/hooks/poll-imap", { method: "POST" }); + const body = await res.json(); + if (!res.ok || body.error) { + toast.error("Poll failed", { description: body.error }); + } else { + toast.success(`Imported ${body.imported ?? 0} new email(s)`); + load(); + } + } catch (e) { + toast.error("Poll failed", { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setPolling(false); + } + }; + + const markRead = async (id: string, isRead: boolean) => { + await supabase.from("incoming_emails").update({ is_read: isRead }).eq("id", id); + setEmails((prev) => + prev.map((e) => (e.id === id ? { ...e, is_read: isRead } : e)), + ); + }; + + const toggleArchive = async (id: string, archive: boolean) => { + await supabase.from("incoming_emails").update({ is_archived: archive }).eq("id", id); + setEmails((prev) => prev.filter((e) => e.id !== id)); + if (selectedId === id) setSelectedId(null); + toast.success(archive ? "Archived" : "Restored"); + }; + + const deleteEmail = async (id: string) => { + if (!confirm("Delete this email permanently?")) return; + const { error } = await supabase.from("incoming_emails").delete().eq("id", id); + if (error) { + toast.error("Delete failed", { description: error.message }); + return; + } + setEmails((prev) => prev.filter((e) => e.id !== id)); + if (selectedId === id) setSelectedId(null); + }; + + const filtered = emails.filter((e) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + (e.subject ?? "").toLowerCase().includes(q) || + (e.from_address ?? "").toLowerCase().includes(q) || + (e.from_name ?? "").toLowerCase().includes(q) || + (e.snippet ?? "").toLowerCase().includes(q) + ); + }); + + const selected = emails.find((e) => e.id === selectedId) ?? null; + const unreadCount = emails.filter((e) => !e.is_read).length; + + return ( + + + + {polling ? ( + + ) : ( + + )} + Check for new mail + + } + /> + +
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( + + + + {showArchived ? "No archived emails." : "No emails yet. "} + {!showArchived && ( + <>Configure IMAP in Settings → Email (IMAP) and click "Check for new mail". + )} + + + ) : ( +
+ +
+ {filtered.map((email) => ( + + ))} +
+
+ + + {selected ? ( + +
+
+

+ {selected.subject || "(no subject)"} +

+
+ From: + {selected.from_name + ? `${selected.from_name} <${selected.from_address}>` + : selected.from_address} + +
+
+ To: {selected.to_addresses.join(", ")} +
+
+ {new Date(selected.received_at).toLocaleString()} +
+
+
+ + + {isAdmin && ( + + )} +
+
+
+ {selected.body_html ? ( +