From bcdde9f45445db10b3f95f9a2c658b0f4d339159 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 18:51:20 +0100 Subject: [PATCH] fix: add support for email attachments in sendEmail functionality and update related components --- app/[locale]/page.tsx | 3 +- components/email/email-composer.tsx | 23 ++++++++ lib/jmap/client.ts | 83 ++++++++++++++++------------- stores/email-store.ts | 6 +-- 4 files changed, 74 insertions(+), 41 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index f593f4ea..90be1459 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -437,11 +437,12 @@ export default function Home() { fromEmail?: string; fromName?: string; identityId?: string; + attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; }) => { if (!client) return; try { - await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody); + await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments); setShowComposer(false); // Refresh the current mailbox to update the UI diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 01e1bbbd..b3bd2393 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -55,6 +55,7 @@ interface EmailComposerProps { fromEmail?: string; fromName?: string; identityId?: string; + attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; }) => void | Promise; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; @@ -664,6 +665,22 @@ export function EmailComposer({ finalBody = body + '\n\n-- \n' + currentIdentity.textSignature; } + // Append quoted original text for the plain text part in reply/forward + if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { + const originalText = replyTo.body || ''; + if (originalText) { + const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ''; + const fromAddr = replyTo.from?.[0]; + const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown'); + + if (mode === 'forward') { + finalBody += `\n\n---------- ${t('prefix.forward')} ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`; + } else { + finalBody += `\n\nOn ${date}, ${fromStr} wrote:\n> ${originalText.split('\n').join('\n> ')}`; + } + } + } + // Build HTML signature block (prefer htmlSignature, fall back to escaped textSignature) const buildSignatureHtml = (): string => { if (currentIdentity?.htmlSignature) { @@ -794,6 +811,11 @@ export function EmailComposer({ await sendRawEmail(client, payload, currentIdentity.id); } else { // Standard JMAP send path + // Collect uploaded attachment blobIds for the send request + const uploadedAttachments = attachments + .filter(att => att.blobId && !att.uploading && !att.error) + .map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size })); + await onSend?.({ to: toAddresses, cc: ccAddresses, @@ -805,6 +827,7 @@ export function EmailComposer({ fromEmail, fromName: currentIdentity?.name || undefined, identityId: currentIdentity?.id, + attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, }); } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index afc91070..4cc69117 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1405,9 +1405,10 @@ export class JMAPClient { fromEmail?: string, draftId?: string, fromName?: string, - htmlBody?: string + htmlBody?: string, + attachments?: Array<{ blobId: string; name: string; type: string; size: number }> ): Promise { - const emailId = draftId || `draft-${Date.now()}`; + const emailId = `send-${Date.now()}`; const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { @@ -1430,48 +1431,56 @@ export class JMAPClient { } } + // Always create a new email with the final body content + const emailCreate: Record = { + from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }], + to: to.map(email => ({ email })), + cc: cc?.map(email => ({ email })), + bcc: bcc?.map(email => ({ email })), + subject, + keywords: { "$seen": true }, + mailboxIds: { [sentMailbox.id]: true }, + }; + + if (htmlBody) { + // Send as multipart/alternative with both text and HTML + emailCreate.bodyValues = { + "text": { value: body }, + "html": { value: htmlBody }, + }; + emailCreate.textBody = [{ partId: "text" }]; + emailCreate.htmlBody = [{ partId: "html" }]; + } else { + emailCreate.bodyValues = { "1": { value: body } }; + emailCreate.textBody = [{ partId: "1" }]; + } + + if (attachments?.length) { + emailCreate.attachments = attachments.map(att => ({ + blobId: att.blobId, + type: att.type, + name: att.name, + disposition: "attachment", + })); + } + const methodCalls: JMAPMethodCall[] = []; if (draftId) { + // Destroy the old draft and create a new email with the final body methodCalls.push(["Email/set", { accountId: this.accountId, - update: { - [draftId]: { - "keywords/$draft": false, - "keywords/$seen": true, - mailboxIds: { [sentMailbox.id]: true }, - }, - }, + destroy: [draftId], }, "0"]); + methodCalls.push(["Email/set", { + accountId: this.accountId, + create: { [emailId]: emailCreate }, + }, "1"]); methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, - create: { "1": { emailId: draftId, identityId: finalIdentityId } }, - }, "1"]); + create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, + }, "2"]); } else { - // Build email body parts - include HTML if available - const emailCreate: Record = { - from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }], - to: to.map(email => ({ email })), - cc: cc?.map(email => ({ email })), - bcc: bcc?.map(email => ({ email })), - subject, - keywords: { "$seen": true }, - mailboxIds: { [sentMailbox.id]: true }, - }; - - if (htmlBody) { - // Send as multipart/alternative with both text and HTML - emailCreate.bodyValues = { - "text": { value: body }, - "html": { value: htmlBody }, - }; - emailCreate.textBody = [{ partId: "text" }]; - emailCreate.htmlBody = [{ partId: "html" }]; - } else { - emailCreate.bodyValues = { "1": { value: body } }; - emailCreate.textBody = [{ partId: "1" }]; - } - methodCalls.push(["Email/set", { accountId: this.accountId, create: { [emailId]: emailCreate }, @@ -1491,8 +1500,8 @@ export class JMAPClient { throw new Error(result.description || `Failed to send email: ${result.type}`); } - if (result.notCreated || result.notUpdated) { - const errors = result.notCreated || result.notUpdated; + if (result.notCreated) { + const errors = result.notCreated; const firstError = Object.values(errors)[0] as { description?: string; type?: string }; console.error('Email send error:', firstError); throw new Error(firstError?.description || firstError?.type || 'Failed to send email'); diff --git a/stores/email-store.ts b/stores/email-store.ts index eb02f9e9..33ed6d8e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -61,7 +61,7 @@ interface EmailStore { loadMoreEmails: (client: JMAPClient) => Promise; fetchEmailContent: (client: JMAPClient, emailId: string) => Promise; fetchQuota: (client: JMAPClient) => Promise; - sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string) => Promise; + sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise; sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise; deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise; @@ -388,10 +388,10 @@ export const useEmailStore = create((set, get) => ({ } }, - sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody) => { + sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments) => { set({ isLoading: true, error: null }); try { - await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody); + await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments); // Refresh handled by UI layer for immediate feedback set({ isLoading: false }); } catch (error) {