From b78968971cfa3e37f55623e77762bc863f624ea0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:23:49 +0100 Subject: [PATCH] fix: send from the account's identity when the composer has no From address #1377 --- .../jmap-send-sender-address.test.ts | 79 +++++++++++++++++++ lib/jmap/client.ts | 23 ++++-- 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 lib/__tests__/jmap-send-sender-address.test.ts diff --git a/lib/__tests__/jmap-send-sender-address.test.ts b/lib/__tests__/jmap-send-sender-address.test.ts new file mode 100644 index 000000000..2ca669a39 --- /dev/null +++ b/lib/__tests__/jmap-send-sender-address.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// #1377: a composer that lost its identities (a short outage) sends no From +// address, and an OAuth login has no username to fall back on. The message +// went out with an empty From and an @localhost Message-ID, and the server +// refused it only after it was saved and submitted. + +type MethodCall = [string, Record, string]; + +function createClient(username: string): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', username, 'pass'); + Object.assign(client, { apiUrl: 'https://jmap.example.com/api', accountId: 'account-1' }); + return client; +} + +function mockServer(identities: Array<{ id: string; email: string; name?: string }>) { + const calls: string[] = []; + const created: Array> = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const { methodCalls } = JSON.parse((init as { body: string }).body) as { methodCalls: MethodCall[] }; + const methodResponses = methodCalls.map(([name, args, callId]) => { + calls.push(name); + if (name === 'Mailbox/get') { + return [name, { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }] }, callId]; + } + if (name === 'Identity/get') return [name, { list: identities }, callId]; + if (name === 'Email/set') { + const create = (args.create ?? {}) as Record>; + created.push(...Object.values(create)); + return [name, { created: Object.fromEntries(Object.keys(create).map((id) => [id, { id: 'email-9' }])) }, callId]; + } + if (name === 'EmailSubmission/set') return [name, { created: { '1': { id: 'sub-1' } } }, callId]; + return ['error', { type: 'unknownMethod' }, callId]; + }); + const payload = { methodResponses }; + return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(payload)), json: () => Promise.resolve(payload) } as Response; + }); + return { calls, created }; +} + +const send = (client: JMAPClient) => client.sendEmail(['friend@example.org'], 'Hi', 'Hello'); + +describe('sendEmail without a From address from the composer (#1377)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('sends from the identity the server lists when the login has no username', async () => { + const client = createClient(''); + const { created } = mockServer([{ id: 'identity-1', email: 'admin@kisaanu.org', name: 'Kisaanu Admin' }]); + + await send(client); + + expect(created[0].from).toEqual([{ name: 'Kisaanu Admin', email: 'admin@kisaanu.org' }]); + expect((created[0].messageId as string[])[0]).toMatch(/@kisaanu\.org$/); + }); + + it('saves and submits nothing when no address can be found', async () => { + const client = createClient(''); + const { calls } = mockServer([]); + + await expect(send(client)).rejects.toThrow(/sender address/); + expect(calls).not.toContain('Email/set'); + expect(calls).not.toContain('EmailSubmission/set'); + }); + + it('keeps the address the composer passed', async () => { + const client = createClient(''); + const { created } = mockServer([ + { id: 'identity-1', email: 'admin@kisaanu.org' }, + { id: 'identity-2', email: 'sales@kisaanu.org' }, + ]); + + await client.sendEmail(['friend@example.org'], 'Hi', 'Hello', undefined, undefined, 'identity-2', 'sales@kisaanu.org', undefined, 'Sales'); + + expect(created[0].from).toEqual([{ name: 'Sales', email: 'sales@kisaanu.org' }]); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 1dee46c38..3d76ace05 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4703,6 +4703,7 @@ export class JMAPClient implements IJMAPClient { let finalIdentityId = identityId; let identityReplyTo: EmailAddress[] | undefined; let identityEmail: string | undefined; + let identityName: string | undefined; { const identityResponse = await this.request([ ["Identity/get", { accountId: targetAccountId }, "0"] @@ -4738,25 +4739,33 @@ export class JMAPClient implements IJMAPClient { finalIdentityId = matchingIdentity?.id || identities[0].id; identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo; identityEmail = (matchingIdentity || identities[0]).email; + identityName = (matchingIdentity || identities[0]).name; } } } + // The header From. A composer that had no identity loaded (after a short + // outage, #1377) passes none, and an OAuth login has no username to fall + // back on: the identity picked above stands in. Without any address the + // server would refuse the message only after it was saved and submitted. + const concreteIdentityEmail = identityEmail && !identityEmail.includes('*') ? identityEmail : undefined; + const senderEmail = fromEmail || concreteIdentityEmail || this.username; + if (!parseRecipientString(senderEmail).email.includes('@')) { + throw new Error('No sender address could be found for this message. Reload the page and try again.'); + } // The MAIL FROM the server derives from the identity when the envelope is // omitted. A wildcard identity (RFC 8621 §6, "*@example.com") names no // concrete address, so the header From stands in for it. - const identityMailFrom = identityEmail && !identityEmail.includes('*') - ? identityEmail - : parseRecipientString(fromEmail || this.username).email; + const identityMailFrom = concreteIdentityEmail ?? parseRecipientString(senderEmail).email; // Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids // (no angle brackets). Stalwart may return them either way, so normalize. const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean); const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean); - const sanitizedFromName = sanitizeIdentityDisplayName(fromName); + const sanitizedFromName = sanitizeIdentityDisplayName(fromEmail ? fromName : fromName || identityName); // Always create a new email with the final body content const emailCreate: Record = { - from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }], + from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: senderEmail }], replyTo: identityReplyTo?.length ? identityReplyTo : undefined, to: to.map(parseRecipientString), // RFC 5322 §3.6.3: To/Cc carry an address-list (non-empty). Sending @@ -4765,7 +4774,7 @@ export class JMAPClient implements IJMAPClient { cc: cc?.length ? cc.map(parseRecipientString) : undefined, bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined, subject, - messageId: [generateMessageId(fromEmail || this.username)], + messageId: [generateMessageId(senderEmail)], inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined, references: normalizedReferences?.length ? normalizedReferences : undefined, keywords: { "$seen": true, "$draft": true }, @@ -4776,7 +4785,7 @@ export class JMAPClient implements IJMAPClient { // RFC 8098: ask the recipient's client to return a Message Disposition // Notification to our address. JMAP lets us set the raw header on create // via the "header::asText" property form. - emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username; + emailCreate["header:Disposition-Notification-To:asText"] = senderEmail; } Object.assign(emailCreate, messagePriorityHeaders(options?.priority));