import type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email"; import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, cn, } from "@dbx-tools/ui-appkit/react"; import { EyeIcon, PaperclipIcon, PencilIcon, SendIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { EmailPreview } from "./email-approval-card.tsx"; import { joinAddresses, parseAddresses, type EmailDraft } from "./fields.ts"; // A standard, editable email compose form usable outside a chat bubble // (a settings page, a standalone "send" view, etc.). It shares the // address / attachment helpers (`./fields`) and the React Email body // renderer (`./email-body`) with the read-only `EmailPreview`, so the // two surfaces stay visually and semantically in sync. // // The component is presentational and self-contained: it owns the draft // as local state (seeded from `defaultValue`), emits changes via // `onChange`, and hands the assembled `EmailMessage` (plus the chosen // `From`, when a sender list is provided) to `onSend`. Wiring the actual // dispatch - and fetching the `senders` list from the plugin's // `GET /senders` route - is the caller's job. /** Props for {@link EmailComposeView}. */ export interface EmailComposeProps { /** Initial draft to seed the form (uncontrolled thereafter). */ defaultValue?: EmailDraft; /** Called on every edit with the current assembled message. */ onChange?: (message: EmailMessage) => void; /** Called when the user submits; receives the message and chosen `From`. */ onSend?: (message: EmailMessage, from?: string) => void | Promise; /** Called when the user cancels. Omit to hide the Cancel button. */ onCancel?: () => void; /** * Permitted `From` addresses (e.g. from the plugin's `GET /senders`). * When non-empty a `From` dropdown is shown and its value is passed to * `onSend`; omit for a server-resolved sender (no dropdown). */ senders?: string[]; /** Preselected `From`. Defaults to the first {@link senders} entry. */ defaultFrom?: string; /** Disable actions while a send is in flight. */ pending?: boolean; /** Disable all actions regardless of pending state. */ disabled?: boolean; /** Show the attachment control. Defaults to `true`. */ allowAttachments?: boolean; /** Card header label. Defaults to "New message". */ title?: string; /** Submit button label. Defaults to "Send". */ sendLabel?: string; } /** Read a `File` into a base64 {@link EmailAttachment}. */ async function fileToAttachment(file: File): Promise { 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); }); // A data URL is `data:;base64,`; keep only the payload. const content = dataUrl.slice(dataUrl.indexOf(",") + 1); return { filename: file.name, content, encoding: "base64", ...(file.type ? { contentType: file.type } : {}), }; } /** A labelled field row: label above its control. */ const Field = ({ label, htmlFor, children, }: { label: string; htmlFor?: string; children: ReactNode; }) => (
{children}
); /** * A standard, editable email compose form. Reuses the shared field and * body helpers so a drafted message renders the same here as in the * read-only approval preview. */ export const EmailComposeView = ({ defaultValue, onChange, onSend, onCancel, senders, defaultFrom, pending, disabled, allowAttachments = true, title = "New message", sendLabel = "Send", }: EmailComposeProps) => { const [to, setTo] = useState(joinAddresses(defaultValue?.to)); const [cc, setCc] = useState(joinAddresses(defaultValue?.cc)); const [bcc, setBcc] = useState(joinAddresses(defaultValue?.bcc)); const [subject, setSubject] = useState(defaultValue?.subject ?? ""); const [body, setBody] = useState(defaultValue?.body ?? ""); const [attachments, setAttachments] = useState( defaultValue?.attachments ?? [], ); const [from, setFrom] = useState(defaultFrom ?? senders?.[0] ?? ""); const [showPreview, setShowPreview] = useState(false); const fileInput = useRef(null); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const ids = useId(); const fieldId = (name: string) => `${ids}-${name}`; const hasSenderPicker = Boolean(senders && senders.length > 0); const blocked = Boolean(disabled) || Boolean(pending); const recipients = parseAddresses(to); const canSend = recipients.length > 0 && Boolean(onSend) && !blocked; // Assemble the current draft into a wire-format message, omitting empty // optional fields so the payload stays minimal. const buildMessage = useCallback((): EmailMessage => { const ccList = parseAddresses(cc); const bccList = parseAddresses(bcc); return { to: parseAddresses(to), subject, body, ...(ccList.length ? { cc: ccList } : {}), ...(bccList.length ? { bcc: bccList } : {}), ...(attachments.length ? { attachments } : {}), }; }, [to, cc, bcc, subject, body, attachments]); useEffect(() => { onChangeRef.current?.(buildMessage()); }, [buildMessage]); const addFiles = useCallback(async (files: FileList | null) => { if (!files || files.length === 0) return; const read = await Promise.all([...files].map(fileToAttachment)); setAttachments((prev) => [...prev, ...read]); }, []); const removeAttachment = useCallback((index: number) => { setAttachments((prev) => prev.filter((_, i) => i !== index)); }, []); const submit = useCallback(() => { if (!canSend) return; void onSend?.(buildMessage(), hasSenderPicker ? from : undefined); }, [canSend, onSend, buildMessage, hasSenderPicker, from]); return (
{title}
{hasSenderPicker && ( )} setTo(e.target.value)} placeholder="alice@example.com, bob@example.com" disabled={blocked} className="h-8" />
setCc(e.target.value)} placeholder="Optional" disabled={blocked} className="h-8" /> setBcc(e.target.value)} placeholder="Optional" disabled={blocked} className="h-8" />
setSubject(e.target.value)} placeholder="Subject" disabled={blocked} className="h-8" />
{showPreview ? ( ) : (