diff --git a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx index 009e123e..1903fb7e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { X, Loader2, Send } from 'lucide-react'; +import { X, Loader2, Send, Paperclip } from 'lucide-react'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { useClient } from 'hooks/useClient'; @@ -82,8 +82,11 @@ export const ComposeModal = () => { const [showCc, setShowCc] = useState(false); const [subject, setSubject] = useState(''); const [body, setBody] = useState(''); + const [files, setFiles] = useState([]); + const [dragging, setDragging] = useState(false); const [sending, setSending] = useState(false); const seeded = useRef(null); + const fileInput = useRef(null); useEffect(() => { if (draft && draft !== seeded.current) { @@ -93,10 +96,29 @@ export const ComposeModal = () => { setShowCc(!!draft.cc); setSubject(draft.subject ?? ''); setBody(draft.body ?? ''); + setFiles([]); } 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 }), + ); + if (list.length) setFiles((prev) => [...prev, ...list]); + }; + 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 onDrop = (ev: React.DragEvent) => { + ev.preventDefault(); + setDragging(false); + if (ev.dataTransfer?.files?.length) addFiles(ev.dataTransfer.files); + }; + const close = () => setDraft(null); const send = async () => { @@ -106,7 +128,14 @@ export const ComposeModal = () => { } setSending(true); try { - await client.post('/email/send', { to, cc: cc || undefined, subject, body, inReplyTo: draft?.inReplyTo }); + const fd = new FormData(); + fd.append('to', to); + if (cc.trim()) fd.append('cc', cc); + fd.append('subject', subject); + fd.append('body', body); + if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo); + files.forEach((f) => fd.append('files', 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. queryClient.invalidateQueries({ queryKey: ['email-messages'] }); @@ -118,16 +147,31 @@ export const ComposeModal = () => { } }; + const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`); + if (!draft) return null; return ( !o && close()}> - -
+ { + ev.preventDefault(); + setDragging(true); + }} + onDragLeave={(ev) => { + if (ev.currentTarget === ev.target) setDragging(false); + }} + > + {dragging && ( +
+ Drop files to attach +
+ )} +
{/^re:/i.test(subject) ? 'Reply' : 'New message'} -
@@ -162,7 +206,43 @@ export const ComposeModal = () => { className="flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:opacity-40" /> + {files.length > 0 && ( +
+ {files.map((f, i) => ( + + + {f.name} + {fmtSize(f.size)} + + + ))} +
+ )} +
+ { + if (ev.target.files?.length) addFiles(ev.target.files); + ev.target.value = ''; + }} + /> + diff --git a/src/servers/api/email/email.ts b/src/servers/api/email/email.ts index 22d62e56..2731a434 100644 --- a/src/servers/api/email/email.ts +++ b/src/servers/api/email/email.ts @@ -26,20 +26,32 @@ async function getSmtpTransport(userId: number): Promise<{ transport: ReturnType return { transport, from: acct.email }; } -// POST /send — compose/reply. Gmail auto-files the sent copy in "Sent", so IMAP sync picks it up. +// POST /send — compose/reply (multipart; `files` are attachments). Gmail auto-files the sent copy in +// "Sent", so IMAP sync picks it up. emailRouter.post('/send', async (ctx) => { const user = ctx.get('user'); - const b = await ctx.req.json<{ to: string; cc?: string; bcc?: string; subject?: string; body?: string; inReplyTo?: string }>(); - if (!b.to?.trim()) throw errors.BAD_REQUEST('At least one recipient is required'); + const form = await ctx.req.parseBody({ all: true }); + const str = (v: unknown) => (typeof v === 'string' ? v : ''); + 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 attachments = await Promise.all( + files.map(async (f) => ({ filename: f.name, content: Buffer.from(await f.arrayBuffer()), contentType: f.type || undefined })), + ); + + const inReplyTo = str(form.inReplyTo).trim(); const { transport, from } = await getSmtpTransport(user.id); await transport.sendMail({ from, - to: b.to, - cc: b.cc?.trim() || undefined, - bcc: b.bcc?.trim() || undefined, - subject: b.subject?.trim() || '(no subject)', - text: b.body ?? '', - ...(b.inReplyTo ? { headers: { 'In-Reply-To': b.inReplyTo, References: b.inReplyTo } } : {}), + to, + cc: str(form.cc).trim() || undefined, + bcc: str(form.bcc).trim() || undefined, + subject: str(form.subject).trim() || '(no subject)', + text: str(form.body), + ...(attachments.length ? { attachments } : {}), + ...(inReplyTo ? { headers: { 'In-Reply-To': inReplyTo, References: inReplyTo } } : {}), }); return ctx.json({ ok: true }); });