From d355e45b776892380ddafd7a91596fc7c153d2de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:44:10 +0100 Subject: [PATCH] fix: stop Send now from dropping a group identity's delayed mail back into Drafts --- lib/__tests__/jmap-send-threading.test.ts | 20 +++++++++++++++++++- lib/jmap/client.ts | 19 ++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/__tests__/jmap-send-threading.test.ts b/lib/__tests__/jmap-send-threading.test.ts index 66bb83fa5..7bdcdd5a8 100644 --- a/lib/__tests__/jmap-send-threading.test.ts +++ b/lib/__tests__/jmap-send-threading.test.ts @@ -271,7 +271,10 @@ describe('JMAPClient.rescheduleEmailSubmission (#1308)', () => { return { methodResponses: [[method, { list: [{ id: 'identity-1', email: 'user@example.com' }] }, '0']] }; } if (method === 'EmailSubmission/get') { - return { methodResponses: [[method, { list: [{ id: 'sub-old', sendAt: originalSendAt, envelope: { rcptTo: [{ email: 'rcpt@example.com' }] } }] }, '0']] }; + // Only the properties asked for, as the servers answer. + const wanted = (args.properties as string[] | undefined) ?? ['identityId', 'sendAt', 'envelope']; + const record: Record = { id: 'sub-old', identityId: 'identity-1', sendAt: originalSendAt, envelope: { rcptTo: [{ email: 'rcpt@example.com' }] } }; + return { methodResponses: [[method, { list: [Object.fromEntries(Object.entries(record).filter(([key]) => key === 'id' || wanted.includes(key)))] }, '0']] }; } if (method === 'EmailSubmission/set' && args.update) { return opts.cancel === 'fail' @@ -317,6 +320,21 @@ describe('JMAPClient.rescheduleEmailSubmission (#1308)', () => { expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-new-1' }); }); + // The undo toast's "Send now" passes the composer's identity, an id in the + // login's own account. A group identity's message is submitted in the group + // account, where that id names nothing (Stalwart 1.0: "Identity not found") + // or another identity, and the message dropped out of the schedule. + it('submits the replacement with the identity of the original submission', async () => { + const client = createClient(); + const { calls, submissionSets } = scriptServer(client, { creates: ['ok'] }); + + await client.rescheduleEmailSubmission('sub-old', 'email-1', 'own-identity-of-group-address', inTenMinutes()); + + const create = submissionSets().find(c => c.args.create)?.args.create as Record; + expect(create.replacement.identityId).toBe('identity-1'); + expect(calls.find(c => c.method === 'Identity/get')?.args.ids).toEqual(['identity-1']); + }); + it('changes nothing when the original cannot be canceled', async () => { const client = createClient(); const { submissionSets } = scriptServer(client, { cancel: 'fail', creates: ['ok'] }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 8c290f7bd..ec6749199 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6078,15 +6078,15 @@ export class JMAPClient implements IJMAPClient { return submission?.sendAt; } - private async getEmailSubmissionForReschedule(submissionId: string, accountId?: string): Promise<{ envelope?: { rcptTo?: Array<{ email: string }> }; sendAt?: string } | undefined> { + private async getEmailSubmissionForReschedule(submissionId: string, accountId?: string): Promise<{ identityId?: string; envelope?: { rcptTo?: Array<{ email: string }> }; sendAt?: string } | undefined> { const response = await this.request([ ['EmailSubmission/get', { accountId: accountId ?? await this.resolveSubmissionAccountId(submissionId), ids: [submissionId], - properties: ['envelope', 'sendAt'], + properties: ['identityId', 'envelope', 'sendAt'], }, '0'], ]); - return response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> }; sendAt?: string } | undefined; + return response.methodResponses?.[0]?.[1]?.list?.[0] as { identityId?: string; envelope?: { rcptTo?: Array<{ email: string }> }; sendAt?: string } | undefined; } /** @@ -11058,6 +11058,12 @@ export class JMAPClient implements IJMAPClient { const mailboxes = await this.getMailboxes(submissionAccountId); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); + const original = await this.getEmailSubmissionForReschedule(submissionId, submissionAccountId); + // The original's identity is one of the submission account's. The caller's + // may be the composer's, from the login's own account: for a group + // identity's message that id names nothing in the group account, or + // another identity. + const submissionIdentityId = original?.identityId || identityId; // The identity and the Email live beside the submission, so look both up in // the owning account. getIdentities() only knows the primary account's // identities; a shared identity missing there would silently put the login @@ -11065,14 +11071,13 @@ export class JMAPClient implements IJMAPClient { let identity: Identity | undefined; try { const identityResponse = await this.request([ - ['Identity/get', { accountId: submissionAccountId, ids: [identityId], properties: ['id', 'email'] }, '0'], + ['Identity/get', { accountId: submissionAccountId, ids: [submissionIdentityId], properties: ['id', 'email'] }, '0'], ]); identity = ((identityResponse.methodResponses?.[0]?.[1]?.list ?? []) as Identity[]) - .find(item => item.id === identityId); + .find(item => item.id === submissionIdentityId); } catch (error) { console.error('Failed to get identity for reschedule:', error); } - const original = await this.getEmailSubmissionForReschedule(submissionId, submissionAccountId); const existingEnvelope = original?.envelope; const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId, submissionAccountId); const envelopeRecipients = existingEnvelope?.rcptTo?.length @@ -11091,7 +11096,7 @@ export class JMAPClient implements IJMAPClient { return this.request([ ['EmailSubmission/set', { accountId: submissionAccountId, - create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } }, + create: { replacement: { emailId, identityId: submissionIdentityId, ...(envelope ? { envelope } : {}) } }, ...(draftsMailbox && sentMailbox ? { onSuccessUpdateEmail: { '#replacement': {