fix: send from the account's identity when the composer has no From address #1377

This commit is contained in:
Linus Rath
2026-10-27 23:23:49 +01:00
parent 0e026cab2e
commit b78968971c
2 changed files with 95 additions and 7 deletions
@@ -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, unknown>, 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<Record<string, unknown>> = [];
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<string, Record<string, unknown>>;
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' }]);
});
});
+16 -7
View File
@@ -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<string, unknown> = {
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:<Name>: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));