fix: add support for email attachments in sendEmail functionality and update related components

This commit is contained in:
Linus Rath
2026-03-18 18:51:20 +01:00
parent bb72ac92ae
commit bcdde9f454
4 changed files with 74 additions and 41 deletions
+2 -1
View File
@@ -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
+23
View File
@@ -55,6 +55,7 @@ interface EmailComposerProps {
fromEmail?: string;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
}) => void | Promise<void>;
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,
});
}
+46 -37
View File
@@ -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<void> {
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<string, unknown> = {
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<string, unknown> = {
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');
+3 -3
View File
@@ -61,7 +61,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string) => Promise<void>;
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<void>;
sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
@@ -388,10 +388,10 @@ export const useEmailStore = create<EmailStore>((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) {