diff --git a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx index 1903fb7e..aee07617 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx @@ -73,6 +73,28 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st ); }; +// Inline-image preview with a managed object URL (created/revoked with the file). +const InlineThumb = ({ file, onRemove }: { file: File; onRemove: () => void }) => { + const [url, setUrl] = useState(''); + useEffect(() => { + const u = URL.createObjectURL(file); + setUrl(u); + return () => URL.revokeObjectURL(u); + }, [file]); + return ( +
+ {url && {file.name}} + +
+ ); +}; + export const ComposeModal = () => { const client = useClient(); const queryClient = useQueryClient(); @@ -80,9 +102,12 @@ export const ComposeModal = () => { const [to, setTo] = useState(''); const [cc, setCc] = useState(''); const [showCc, setShowCc] = useState(false); + const [bcc, setBcc] = useState(''); + const [showBcc, setShowBcc] = useState(false); const [subject, setSubject] = useState(''); const [body, setBody] = useState(''); const [files, setFiles] = useState([]); + const [inlineImages, setInlineImages] = useState([]); const [dragging, setDragging] = useState(false); const [sending, setSending] = useState(false); const seeded = useRef(null); @@ -94,29 +119,43 @@ export const ComposeModal = () => { setTo(draft.to ?? ''); setCc(draft.cc ?? ''); setShowCc(!!draft.cc); + setBcc(''); + setShowBcc(false); setSubject(draft.subject ?? ''); setBody(draft.body ?? ''); setFiles([]); + setInlineImages([]); } if (!draft) seeded.current = null; }, [draft]); - const addFiles = (incoming: FileList | File[]) => { - const list = Array.from(incoming).map((f) => - // Clipboard images often come nameless — give them a sensible filename. - f.name ? f : new File([f], `pasted-${Date.now()}.${(f.type.split('/')[1] || 'png')}`, { type: f.type }), - ); + // Clipboard images often come nameless — give them a sensible filename. + const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type })); + + // Attach button: everything goes as a regular attachment. + const addAttachments = (incoming: FileList | File[]) => { + const list = Array.from(incoming).map(named); if (list.length) setFiles((prev) => [...prev, ...list]); }; + // Paste / drop: images embed inline in the body, everything else is a regular attachment. + const routeFiles = (incoming: FileList | File[]) => { + const list = Array.from(incoming).map(named); + const imgs = list.filter((f) => f.type.startsWith('image/')); + const rest = list.filter((f) => !f.type.startsWith('image/')); + if (imgs.length) setInlineImages((prev) => [...prev, ...imgs]); + if (rest.length) setFiles((prev) => [...prev, ...rest]); + }; const onPaste = (ev: React.ClipboardEvent) => { - const items = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file'); - const pasted = items.map((it) => it.getAsFile()).filter((f): f is File => !!f); - if (pasted.length) addFiles(pasted); + const pasted = Array.from(ev.clipboardData?.items ?? []) + .filter((it) => it.kind === 'file') + .map((it) => it.getAsFile()) + .filter((f): f is File => !!f); + if (pasted.length) routeFiles(pasted); }; const onDrop = (ev: React.DragEvent) => { ev.preventDefault(); setDragging(false); - if (ev.dataTransfer?.files?.length) addFiles(ev.dataTransfer.files); + if (ev.dataTransfer?.files?.length) routeFiles(ev.dataTransfer.files); }; const close = () => setDraft(null); @@ -131,10 +170,18 @@ export const ComposeModal = () => { const fd = new FormData(); fd.append('to', to); if (cc.trim()) fd.append('cc', cc); + if (bcc.trim()) fd.append('bcc', bcc); fd.append('subject', subject); fd.append('body', body); if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo); files.forEach((f) => fd.append('files', f)); + // Inline images: the backend gives each a cid `inline-` in this order; the html references them. + if (inlineImages.length) { + const esc = body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); + const imgs = inlineImages.map((_, i) => `
`).join(''); + fd.append('html', `${esc}${esc ? '
' : ''}${imgs}`); + inlineImages.forEach((f) => fd.append('inline', f)); + } await client.post('/email/send', fd); toast.success('Email sent'); // Gmail files the sent copy in Sent, so refresh the list to surface it. @@ -167,7 +214,7 @@ export const ComposeModal = () => { > {dragging && (
- Drop files to attach + Drop images to inline · other files attach
)}
@@ -179,11 +226,18 @@ export const ComposeModal = () => {
- {!showCc && ( - - )} +
+ {!showCc && ( + + )} + {!showBcc && ( + + )} +
{showCc && (
@@ -193,6 +247,14 @@ export const ComposeModal = () => {
)} + {showBcc && ( +
+ Bcc +
+ +
+
+ )} setSubject(ev.target.value)} @@ -206,6 +268,14 @@ export const ComposeModal = () => { className="flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:opacity-40" /> + {inlineImages.length > 0 && ( +
+ {inlineImages.map((f, i) => ( + setInlineImages((prev) => prev.filter((_, idx) => idx !== i))} /> + ))} +
+ )} + {files.length > 0 && (
{files.map((f, i) => ( @@ -232,7 +302,7 @@ export const ComposeModal = () => { multiple className="hidden" onChange={(ev) => { - if (ev.target.files?.length) addFiles(ev.target.files); + if (ev.target.files?.length) addAttachments(ev.target.files); ev.target.value = ''; }} /> diff --git a/src/servers/api/email/email.ts b/src/servers/api/email/email.ts index 2731a434..29759df7 100644 --- a/src/servers/api/email/email.ts +++ b/src/servers/api/email/email.ts @@ -35,12 +35,23 @@ emailRouter.post('/send', async (ctx) => { const to = str(form.to).trim(); if (!to) throw errors.BAD_REQUEST('At least one recipient is required'); - const rawFiles = form.files; - const files = (Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []).filter((f): f is File => f instanceof File); + const toFiles = (raw: unknown) => (Array.isArray(raw) ? raw : raw ? [raw] : []).filter((f): f is File => f instanceof File); const attachments = await Promise.all( - files.map(async (f) => ({ filename: f.name, content: Buffer.from(await f.arrayBuffer()), contentType: f.type || undefined })), + toFiles(form.files).map(async (f) => ({ filename: f.name, content: Buffer.from(await f.arrayBuffer()), contentType: f.type || undefined })), ); + // Inline images: cid `inline-` matches the `` the composer put in the html. + const inline = await Promise.all( + toFiles(form.inline).map(async (f, i) => ({ + filename: f.name, + content: Buffer.from(await f.arrayBuffer()), + contentType: f.type || undefined, + cid: `inline-${i}`, + contentDisposition: 'inline' as const, + })), + ); + const allAttachments = [...attachments, ...inline]; + const html = str(form.html).trim(); const inReplyTo = str(form.inReplyTo).trim(); const { transport, from } = await getSmtpTransport(user.id); await transport.sendMail({ @@ -50,7 +61,8 @@ emailRouter.post('/send', async (ctx) => { bcc: str(form.bcc).trim() || undefined, subject: str(form.subject).trim() || '(no subject)', text: str(form.body), - ...(attachments.length ? { attachments } : {}), + ...(html ? { html } : {}), + ...(allAttachments.length ? { attachments: allAttachments } : {}), ...(inReplyTo ? { headers: { 'In-Reply-To': inReplyTo, References: inReplyTo } } : {}), }); return ctx.json({ ok: true });