` 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 && (
+
+ )}
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 });