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) {
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 identityReplyTo: EmailAddress[] | undefined;
@@ -1819,8 +1823,8 @@ export class JMAPClient implements IJMAPClient {
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
keywords: { "$seen": true, "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
};
if (htmlBody) {
@@ -1847,6 +1851,17 @@ export class JMAPClient implements IJMAPClient {
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) {
// Destroy the old draft and create a new email with the final body
methodCalls.push(["Email/set", {
@@ -1860,6 +1875,7 @@ export class JMAPClient implements IJMAPClient {
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail,
}, "2"]);
} else {
methodCalls.push(["Email/set", {
@@ -1869,6 +1885,7 @@ export class JMAPClient implements IJMAPClient {
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail,
}, "1"]);
}
@@ -1915,6 +1932,10 @@ export class JMAPClient implements IJMAPClient {
if (!sentMailbox) {
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;
if (!finalIdentityId) {
@@ -2015,8 +2036,8 @@ export class JMAPClient implements IJMAPClient {
from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }],
to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }],
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
keywords: { "$seen": true, "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
bodyStructure: {
type: 'multipart/alternative',
subParts: [
@@ -2038,6 +2059,13 @@ export class JMAPClient implements IJMAPClient {
["EmailSubmission/set", {
accountId: this.accountId,
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
"keywords/$draft": null,
},
},
}, "1"],
];
@@ -2076,6 +2104,10 @@ export class JMAPClient implements IJMAPClient {
if (!sentMailbox) {
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
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 }],
to: toAddresses,
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
keywords: { "$seen": true, "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
bodyStructure: {
type: 'multipart/alternative',
subParts: [
@@ -2200,6 +2232,13 @@ export class JMAPClient implements IJMAPClient {
["EmailSubmission/set", {
accountId: this.accountId,
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
"keywords/$draft": null,
},
},
}, "1"],
];
@@ -2233,6 +2272,10 @@ export class JMAPClient implements IJMAPClient {
if (!sentMailbox) {
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 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 }],
to: toAddresses,
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
keywords: { "$seen": true, "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
bodyStructure: {
type: 'multipart/alternative',
subParts: [
@@ -2336,6 +2379,13 @@ export class JMAPClient implements IJMAPClient {
["EmailSubmission/set", {
accountId: this.accountId,
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
"keywords/$draft": null,
},
},
}, "1"],
];
@@ -4747,21 +4797,23 @@ export class JMAPClient implements IJMAPClient {
blob: Blob,
identityId: string,
sentMailboxId: string,
_draftMailboxId?: string,
draftMailboxId?: string,
): Promise<void> {
// Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
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][] = [
['Email/import', {
accountId: this.accountId,
emails: {
'raw-import': {
blobId,
mailboxIds: { [sentMailboxId]: true },
keywords: { '$seen': true },
mailboxIds: { [importMailboxId]: true },
keywords: draftMailboxId ? { '$seen': true, '$draft': true } : { '$seen': true },
},
},
}, '0'],
@@ -4773,6 +4825,15 @@ export class JMAPClient implements IJMAPClient {
identityId,
},
},
...(draftMailboxId ? {
onSuccessUpdateEmail: {
'#raw-submit': {
[`mailboxIds/${draftMailboxId}`]: null,
[`mailboxIds/${sentMailboxId}`]: true,
'keywords/$draft': null,
},
},
} : {}),
}, '1'],
];
+2 -1
View File
@@ -453,7 +453,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
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 });
} catch (error) {
set({