mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: send from the account's identity when the composer has no From address #1377
This commit is contained in:
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user