compose: bcc field, inline pasted/dropped images, attach button stays regular
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="group relative h-16 w-16 overflow-hidden rounded border">
|
||||||
|
{url && <img src={url} alt={file.name} className="h-full w-full object-cover" />}
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="absolute right-0.5 top-0.5 rounded-full bg-black/60 p-0.5 text-white opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ComposeModal = () => {
|
export const ComposeModal = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -80,9 +102,12 @@ export const ComposeModal = () => {
|
|||||||
const [to, setTo] = useState('');
|
const [to, setTo] = useState('');
|
||||||
const [cc, setCc] = useState('');
|
const [cc, setCc] = useState('');
|
||||||
const [showCc, setShowCc] = useState(false);
|
const [showCc, setShowCc] = useState(false);
|
||||||
|
const [bcc, setBcc] = useState('');
|
||||||
|
const [showBcc, setShowBcc] = useState(false);
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [inlineImages, setInlineImages] = useState<File[]>([]);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const seeded = useRef<ComposeDraft | null>(null);
|
const seeded = useRef<ComposeDraft | null>(null);
|
||||||
@@ -94,29 +119,43 @@ export const ComposeModal = () => {
|
|||||||
setTo(draft.to ?? '');
|
setTo(draft.to ?? '');
|
||||||
setCc(draft.cc ?? '');
|
setCc(draft.cc ?? '');
|
||||||
setShowCc(!!draft.cc);
|
setShowCc(!!draft.cc);
|
||||||
|
setBcc('');
|
||||||
|
setShowBcc(false);
|
||||||
setSubject(draft.subject ?? '');
|
setSubject(draft.subject ?? '');
|
||||||
setBody(draft.body ?? '');
|
setBody(draft.body ?? '');
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
setInlineImages([]);
|
||||||
}
|
}
|
||||||
if (!draft) seeded.current = null;
|
if (!draft) seeded.current = null;
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
const addFiles = (incoming: FileList | File[]) => {
|
|
||||||
const list = Array.from(incoming).map((f) =>
|
|
||||||
// Clipboard images often come nameless — give them a sensible filename.
|
// 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 }),
|
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]);
|
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 onPaste = (ev: React.ClipboardEvent) => {
|
||||||
const items = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file');
|
const pasted = Array.from(ev.clipboardData?.items ?? [])
|
||||||
const pasted = items.map((it) => it.getAsFile()).filter((f): f is File => !!f);
|
.filter((it) => it.kind === 'file')
|
||||||
if (pasted.length) addFiles(pasted);
|
.map((it) => it.getAsFile())
|
||||||
|
.filter((f): f is File => !!f);
|
||||||
|
if (pasted.length) routeFiles(pasted);
|
||||||
};
|
};
|
||||||
const onDrop = (ev: React.DragEvent) => {
|
const onDrop = (ev: React.DragEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
if (ev.dataTransfer?.files?.length) addFiles(ev.dataTransfer.files);
|
if (ev.dataTransfer?.files?.length) routeFiles(ev.dataTransfer.files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => setDraft(null);
|
const close = () => setDraft(null);
|
||||||
@@ -131,10 +170,18 @@ export const ComposeModal = () => {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('to', to);
|
fd.append('to', to);
|
||||||
if (cc.trim()) fd.append('cc', cc);
|
if (cc.trim()) fd.append('cc', cc);
|
||||||
|
if (bcc.trim()) fd.append('bcc', bcc);
|
||||||
fd.append('subject', subject);
|
fd.append('subject', subject);
|
||||||
fd.append('body', body);
|
fd.append('body', body);
|
||||||
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
|
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
|
||||||
files.forEach((f) => fd.append('files', f));
|
files.forEach((f) => fd.append('files', f));
|
||||||
|
// Inline images: the backend gives each a cid `inline-<i>` in this order; the html references them.
|
||||||
|
if (inlineImages.length) {
|
||||||
|
const esc = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||||
|
const imgs = inlineImages.map((_, i) => `<div><img src="cid:inline-${i}" style="max-width:100%" /></div>`).join('');
|
||||||
|
fd.append('html', `${esc}${esc ? '<br>' : ''}${imgs}`);
|
||||||
|
inlineImages.forEach((f) => fd.append('inline', f));
|
||||||
|
}
|
||||||
await client.post('/email/send', fd);
|
await client.post('/email/send', fd);
|
||||||
toast.success('Email sent');
|
toast.success('Email sent');
|
||||||
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
||||||
@@ -167,7 +214,7 @@ export const ComposeModal = () => {
|
|||||||
>
|
>
|
||||||
{dragging && (
|
{dragging && (
|
||||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md border-2 border-dashed border-duck-teal bg-background/80 text-sm font-medium">
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md border-2 border-dashed border-duck-teal bg-background/80 text-sm font-medium">
|
||||||
Drop files to attach
|
Drop images to inline · other files attach
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center border-b px-4 py-2.5 pr-10">
|
<div className="flex items-center border-b px-4 py-2.5 pr-10">
|
||||||
@@ -179,11 +226,18 @@ export const ComposeModal = () => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
|
<RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3">
|
||||||
{!showCc && (
|
{!showCc && (
|
||||||
<button onClick={() => setShowCc(true)} className="px-3 text-xs opacity-50 hover:opacity-100 cursor-pointer">
|
<button onClick={() => setShowCc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
|
||||||
Cc
|
Cc
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!showBcc && (
|
||||||
|
<button onClick={() => setShowBcc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
|
||||||
|
Bcc
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showCc && (
|
{showCc && (
|
||||||
<div className="flex items-center border-b">
|
<div className="flex items-center border-b">
|
||||||
@@ -193,6 +247,14 @@ export const ComposeModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showBcc && (
|
||||||
|
<div className="flex items-center border-b">
|
||||||
|
<span className="pl-4 text-xs opacity-50">Bcc</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<RecipientInput value={bcc} onChange={setBcc} placeholder="Bcc recipients" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(ev) => setSubject(ev.target.value)}
|
onChange={(ev) => 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"
|
className="flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:opacity-40"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{inlineImages.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 border-t px-4 py-2">
|
||||||
|
{inlineImages.map((f, i) => (
|
||||||
|
<InlineThumb key={`${f.name}-${i}`} file={f} onRemove={() => setInlineImages((prev) => prev.filter((_, idx) => idx !== i))} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
|
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
|
||||||
{files.map((f, i) => (
|
{files.map((f, i) => (
|
||||||
@@ -232,7 +302,7 @@ export const ComposeModal = () => {
|
|||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(ev) => {
|
onChange={(ev) => {
|
||||||
if (ev.target.files?.length) addFiles(ev.target.files);
|
if (ev.target.files?.length) addAttachments(ev.target.files);
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,12 +35,23 @@ emailRouter.post('/send', async (ctx) => {
|
|||||||
const to = str(form.to).trim();
|
const to = str(form.to).trim();
|
||||||
if (!to) throw errors.BAD_REQUEST('At least one recipient is required');
|
if (!to) throw errors.BAD_REQUEST('At least one recipient is required');
|
||||||
|
|
||||||
const rawFiles = form.files;
|
const toFiles = (raw: unknown) => (Array.isArray(raw) ? raw : raw ? [raw] : []).filter((f): f is File => f instanceof File);
|
||||||
const files = (Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []).filter((f): f is File => f instanceof File);
|
|
||||||
const attachments = await Promise.all(
|
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-<i>` matches the `<img src="cid:inline-i">` 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 inReplyTo = str(form.inReplyTo).trim();
|
||||||
const { transport, from } = await getSmtpTransport(user.id);
|
const { transport, from } = await getSmtpTransport(user.id);
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
@@ -50,7 +61,8 @@ emailRouter.post('/send', async (ctx) => {
|
|||||||
bcc: str(form.bcc).trim() || undefined,
|
bcc: str(form.bcc).trim() || undefined,
|
||||||
subject: str(form.subject).trim() || '(no subject)',
|
subject: str(form.subject).trim() || '(no subject)',
|
||||||
text: str(form.body),
|
text: str(form.body),
|
||||||
...(attachments.length ? { attachments } : {}),
|
...(html ? { html } : {}),
|
||||||
|
...(allAttachments.length ? { attachments: allAttachments } : {}),
|
||||||
...(inReplyTo ? { headers: { 'In-Reply-To': inReplyTo, References: inReplyTo } } : {}),
|
...(inReplyTo ? { headers: { 'In-Reply-To': inReplyTo, References: inReplyTo } } : {}),
|
||||||
});
|
});
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user