fix: use onSuccessUpdateEmail to send before storing in Sent #188

This commit is contained in:
Linus Rath
2026-04-13 00:50:13 +02:00
parent 0d4b588fd0
commit fa0045e01b
2 changed files with 75 additions and 13 deletions
+73 -12
View File
@@ -1783,6 +1783,10 @@ export class JMAPClient implements IJMAPClient {
if (!sentMailbox) { if (!sentMailbox) {
throw new Error('No sent mailbox found'); throw new Error('No sent mailbox found');
} }
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
if (!draftsMailbox) {
throw new Error('No drafts mailbox found');
}
let finalIdentityId = identityId; let finalIdentityId = identityId;
let identityReplyTo: EmailAddress[] | undefined; let identityReplyTo: EmailAddress[] | undefined;
@@ -1819,8 +1823,8 @@ export class JMAPClient implements IJMAPClient {
cc: cc?.map(email => ({ email })), cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })),
subject, subject,
keywords: { "$seen": true }, keywords: { "$seen": true, "$draft": true },
mailboxIds: { [sentMailbox.id]: true }, mailboxIds: { [draftsMailbox.id]: true },
}; };
if (htmlBody) { if (htmlBody) {
@@ -1847,6 +1851,17 @@ export class JMAPClient implements IJMAPClient {
const methodCalls: JMAPMethodCall[] = []; const methodCalls: JMAPMethodCall[] = [];
// Use onSuccessUpdateEmail to move from Drafts to Sent after submission.
// This ensures SMTP send happens before the email lands in Sent, avoiding
// issues with servers that encrypt on append (e.g. Stalwart). See #188.
const onSuccessUpdateEmail = {
"#1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
"keywords/$draft": null,
},
};
if (draftId) { if (draftId) {
// Destroy the old draft and create a new email with the final body // Destroy the old draft and create a new email with the final body
methodCalls.push(["Email/set", { methodCalls.push(["Email/set", {
@@ -1860,6 +1875,7 @@ export class JMAPClient implements IJMAPClient {
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail,
}, "2"]); }, "2"]);
} else { } else {
methodCalls.push(["Email/set", { methodCalls.push(["Email/set", {
@@ -1869,6 +1885,7 @@ export class JMAPClient implements IJMAPClient {
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail,
}, "1"]); }, "1"]);
} }
@@ -1915,6 +1932,10 @@ export class JMAPClient implements IJMAPClient {
if (!sentMailbox) { if (!sentMailbox) {
throw new Error('No sent mailbox found'); throw new Error('No sent mailbox found');
} }
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
if (!draftsMailbox) {
throw new Error('No drafts mailbox found');
}
let finalIdentityId = opts.identityId; let finalIdentityId = opts.identityId;
if (!finalIdentityId) { if (!finalIdentityId) {
@@ -2015,8 +2036,8 @@ export class JMAPClient implements IJMAPClient {
from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }], from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }],
to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }], to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }],
subject, subject,
keywords: { "$seen": true }, keywords: { "$seen": true, "$draft": true },
mailboxIds: { [sentMailbox.id]: true }, mailboxIds: { [draftsMailbox.id]: true },
bodyStructure: { bodyStructure: {
type: 'multipart/alternative', type: 'multipart/alternative',
subParts: [ subParts: [
@@ -2038,6 +2059,13 @@ export class JMAPClient implements IJMAPClient {
["EmailSubmission/set", { ["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
"keywords/$draft": null,
},
},
}, "1"], }, "1"],
]; ];
@@ -2076,6 +2104,10 @@ export class JMAPClient implements IJMAPClient {
if (!sentMailbox) { if (!sentMailbox) {
throw new Error('No sent mailbox found'); throw new Error('No sent mailbox found');
} }
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
if (!draftsMailbox) {
throw new Error('No drafts mailbox found');
}
// Find the organizer participant // Find the organizer participant
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
@@ -2177,8 +2209,8 @@ export class JMAPClient implements IJMAPClient {
from: [{ name: organizerName || undefined, email: organizerEmail }], from: [{ name: organizerName || undefined, email: organizerEmail }],
to: toAddresses, to: toAddresses,
subject, subject,
keywords: { "$seen": true }, keywords: { "$seen": true, "$draft": true },
mailboxIds: { [sentMailbox.id]: true }, mailboxIds: { [draftsMailbox.id]: true },
bodyStructure: { bodyStructure: {
type: 'multipart/alternative', type: 'multipart/alternative',
subParts: [ subParts: [
@@ -2200,6 +2232,13 @@ export class JMAPClient implements IJMAPClient {
["EmailSubmission/set", { ["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "sub-1": { emailId: `#${emailId}`, identityId } }, create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
"keywords/$draft": null,
},
},
}, "1"], }, "1"],
]; ];
@@ -2233,6 +2272,10 @@ export class JMAPClient implements IJMAPClient {
if (!sentMailbox) { if (!sentMailbox) {
throw new Error('No sent mailbox found'); throw new Error('No sent mailbox found');
} }
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
if (!draftsMailbox) {
throw new Error('No drafts mailbox found');
}
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username; const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
@@ -2313,8 +2356,8 @@ export class JMAPClient implements IJMAPClient {
from: [{ name: organizerName || undefined, email: organizerEmail }], from: [{ name: organizerName || undefined, email: organizerEmail }],
to: toAddresses, to: toAddresses,
subject, subject,
keywords: { "$seen": true }, keywords: { "$seen": true, "$draft": true },
mailboxIds: { [sentMailbox.id]: true }, mailboxIds: { [draftsMailbox.id]: true },
bodyStructure: { bodyStructure: {
type: 'multipart/alternative', type: 'multipart/alternative',
subParts: [ subParts: [
@@ -2336,6 +2379,13 @@ export class JMAPClient implements IJMAPClient {
["EmailSubmission/set", { ["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "sub-1": { emailId: `#${emailId}`, identityId } }, create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
"keywords/$draft": null,
},
},
}, "1"], }, "1"],
]; ];
@@ -4747,21 +4797,23 @@ export class JMAPClient implements IJMAPClient {
blob: Blob, blob: Blob,
identityId: string, identityId: string,
sentMailboxId: string, sentMailboxId: string,
_draftMailboxId?: string, draftMailboxId?: string,
): Promise<void> { ): Promise<void> {
// Upload the raw message // Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file); const { blobId } = await this.uploadBlob(file);
// Import into Sent, mark as seen, and submit — all in one request // Import into Drafts first, then move to Sent after submission succeeds.
// This avoids encrypt-on-append affecting the SMTP send. See #188.
const importMailboxId = draftMailboxId || sentMailboxId;
const methodCalls: [string, Record<string, unknown>, string][] = [ const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', { ['Email/import', {
accountId: this.accountId, accountId: this.accountId,
emails: { emails: {
'raw-import': { 'raw-import': {
blobId, blobId,
mailboxIds: { [sentMailboxId]: true }, mailboxIds: { [importMailboxId]: true },
keywords: { '$seen': true }, keywords: draftMailboxId ? { '$seen': true, '$draft': true } : { '$seen': true },
}, },
}, },
}, '0'], }, '0'],
@@ -4773,6 +4825,15 @@ export class JMAPClient implements IJMAPClient {
identityId, identityId,
}, },
}, },
...(draftMailboxId ? {
onSuccessUpdateEmail: {
'#raw-submit': {
[`mailboxIds/${draftMailboxId}`]: null,
[`mailboxIds/${sentMailboxId}`]: true,
'keywords/$draft': null,
},
},
} : {}),
}, '1'], }, '1'],
]; ];
+2 -1
View File
@@ -453,7 +453,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const mailboxes = await client.getMailboxes(); const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) throw new Error('No sent mailbox found'); if (!sentMailbox) throw new Error('No sent mailbox found');
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id);
set({ isLoading: false }); set({ isLoading: false });
} catch (error) { } catch (error) {
set({ set({