mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Follow-ups to the review of the push, keyword and identity work: - A send that names no sender, like the List-Unsubscribe mailto: of the unified view, fell back to the first identity in server order, which on Stalwart 1.0 is a group's. It now takes the login's own identity, with addresses compared regardless of case. - Re-register creates the replacement subscription before it destroys the old one, so at Stalwart's limit (15 per account by default) it failed with `overQuota`. It now frees the old record's place and tries once more. - On 1.0 the push settings note goes by the emailpush capability alone and ignores refusals remembered under 0.16. On 0.16 a group the user belongs to is recognised by its identity, so a member without calendars, contacts or files keeps the group's junk filter and is no longer told the group sends no notifications. - Plugin imports can no longer set Stalwart 1.0's attachment keywords, and `keywords.discover` leaves them out. - Batch mark-as-read showed messages the server refused as read. It now updates only what was written and reports the rest, and thread expansion and category-tab moves keep what a partly refused batch did write. - Refused read and star changes from shortcuts, the list, the viewer and Pro tabs show "Failed to update email" instead of an unhandled rejection. The automatic mark-as-read on opening stays quiet. - A tag whose id predates the 128-byte limit can have its colour changed again. - The state-regression guard notes that a multi-node 1.0 cluster can trip it harmlessly, at the cost of one extra full fetch.
208 lines
8.5 KiB
TypeScript
208 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { JMAPClient } from '../jmap/client';
|
|
|
|
// #1009: a From override is asked for as the envelope MAIL FROM, so the
|
|
// Return-Path doesn't reveal the identity's address where the server allows it.
|
|
|
|
type MethodCall = [string, Record<string, unknown>, string];
|
|
|
|
function createClient(username = 'user@example.com'): JMAPClient {
|
|
const client = new JMAPClient('https://jmap.example.com', username, 'pass');
|
|
Object.assign(client, {
|
|
apiUrl: 'https://jmap.example.com/api',
|
|
accountId: 'account-1',
|
|
username,
|
|
});
|
|
return client;
|
|
}
|
|
|
|
function enableDelayedSend(client: JMAPClient) {
|
|
Object.assign(client, {
|
|
capabilities: {
|
|
'urn:ietf:params:jmap:core': {},
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': {},
|
|
},
|
|
session: {
|
|
primaryAccounts: {
|
|
'urn:ietf:params:jmap:mail': 'account-1',
|
|
'urn:ietf:params:jmap:submission': 'account-1',
|
|
},
|
|
accounts: {
|
|
'account-1': {
|
|
accountCapabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const IDENTITIES = [
|
|
{ id: 'identity-1', email: 'user@example.com', mayDelete: false },
|
|
{ id: 'identity-2', email: 'Sales@example.com', mayDelete: true, replyTo: [{ email: 'team@example.com' }] },
|
|
];
|
|
|
|
/**
|
|
* Answers the requests sendEmail makes; `refuseMailFrom` makes every
|
|
* EmailSubmission/set whose envelope asks for another MAIL FROM than the
|
|
* identity's fail the way Stalwart does. `overQuotaOnce` refuses the first
|
|
* submission the way Stalwart 1.0 does at its `maxSubmissions` limit - before
|
|
* it looks at the envelope.
|
|
*/
|
|
function mockServer(options: { refuseMailFrom?: boolean; overQuotaOnce?: boolean; identities?: typeof IDENTITIES } = {}) {
|
|
const submissions: Array<Record<string, unknown>> = [];
|
|
const identities = options.identities ?? IDENTITIES;
|
|
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]) => {
|
|
if (name === 'Mailbox/get') {
|
|
return [name, { list: [{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' }, { id: 'mb-sent', name: 'Sent', role: 'sent' }] }, callId];
|
|
}
|
|
if (name === 'Identity/get') return [name, { list: identities }, callId];
|
|
if (name === 'Email/set') {
|
|
const created = Object.keys((args.create ?? {}) as object);
|
|
return [name, { created: Object.fromEntries(created.map((id) => [id, { id: 'email-9' }])) }, callId];
|
|
}
|
|
if (name === 'EmailSubmission/set') {
|
|
const create = (args.create as Record<string, Record<string, unknown>>)['1'];
|
|
submissions.push(create);
|
|
if (options.overQuotaOnce && submissions.length === 1) {
|
|
return [name, { notCreated: { '1': { type: 'overQuota', description: 'There are too many email submissions, please delete some before adding a new one.' } } }, callId];
|
|
}
|
|
const identity = identities.find((i) => i.id === create.identityId);
|
|
const mailFrom = (create.envelope as { mailFrom?: { email: string } } | undefined)?.mailFrom?.email;
|
|
if (options.refuseMailFrom && mailFrom && mailFrom.toLowerCase() !== identity?.email.toLowerCase()) {
|
|
return [name, { notCreated: { '1': { type: 'forbiddenFrom', description: 'Envelope mailFrom does not match identity email address.' } } }, callId];
|
|
}
|
|
return [name, { created: { '1': { id: `sub-${submissions.length}` } } }, 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 submissions;
|
|
}
|
|
|
|
function send(client: JMAPClient, fromEmail: string, envelopeMailFrom?: string, delayedUntil?: string) {
|
|
return client.sendEmail(
|
|
['Recipient <rcpt@example.net>'],
|
|
'Subject',
|
|
'body',
|
|
undefined, undefined, 'identity-1', fromEmail,
|
|
undefined, undefined, undefined, undefined,
|
|
undefined, undefined,
|
|
delayedUntil,
|
|
envelopeMailFrom,
|
|
);
|
|
}
|
|
|
|
describe('JMAPClient.sendEmail envelope MAIL FROM (#1009)', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('asks for the From override as MAIL FROM', async () => {
|
|
const submissions = mockServer();
|
|
|
|
const result = await send(createClient(), 'alias@example.com', 'alias@example.com');
|
|
|
|
expect(submissions).toEqual([{
|
|
emailId: expect.stringMatching(/^#send-/),
|
|
identityId: 'identity-1',
|
|
envelope: { mailFrom: { email: 'alias@example.com' }, rcptTo: [{ email: 'rcpt@example.net' }] },
|
|
}]);
|
|
expect(result).toMatchObject({ emailId: 'email-9', emailSubmissionId: 'sub-1' });
|
|
});
|
|
|
|
it("submits again with the identity's address when the server refuses the override", async () => {
|
|
const submissions = mockServer({ refuseMailFrom: true });
|
|
|
|
const result = await send(createClient(), 'alias@example.com', 'alias@example.com');
|
|
|
|
expect(submissions).toHaveLength(2);
|
|
expect(submissions[1]).toEqual({ emailId: 'email-9', identityId: 'identity-1' });
|
|
expect(result).toMatchObject({ emailId: 'email-9', emailSubmissionId: 'sub-2' });
|
|
});
|
|
|
|
it("makes room at a full quota, then falls back to the identity's address on Stalwart 1.0", async () => {
|
|
const submissions = mockServer({ refuseMailFrom: true, overQuotaOnce: true });
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
const result = await send(createClient(), 'alias@example.com', 'alias@example.com');
|
|
|
|
const mailFroms = submissions.map((s) => (s.envelope as { mailFrom?: { email: string } } | undefined)?.mailFrom?.email ?? 'identity');
|
|
expect(mailFroms).toEqual(['alias@example.com', 'alias@example.com', 'identity']);
|
|
expect(submissions[1].emailId).toBe('email-9');
|
|
expect(result).toMatchObject({ emailId: 'email-9', emailSubmissionId: 'sub-3' });
|
|
});
|
|
|
|
it('sends through the identity that owns the override address', async () => {
|
|
const submissions = mockServer({ refuseMailFrom: true });
|
|
|
|
await send(createClient(), 'sales@example.com', 'sales@example.com');
|
|
|
|
expect(submissions).toEqual([{ emailId: expect.stringMatching(/^#send-/), identityId: 'identity-2' }]);
|
|
});
|
|
|
|
it("keeps the identity's address as MAIL FROM of a sub-addressed scheduled send", async () => {
|
|
const client = createClient();
|
|
enableDelayedSend(client);
|
|
const submissions = mockServer({ refuseMailFrom: true });
|
|
|
|
await send(client, 'user+shop@example.com', undefined, new Date(Date.now() + 60_000).toISOString());
|
|
|
|
expect(submissions).toHaveLength(1);
|
|
expect(submissions[0].envelope).toMatchObject({ mailFrom: { email: 'user@example.com' } });
|
|
});
|
|
});
|
|
|
|
// A send that names neither identity nor From - the List-Unsubscribe mailto:
|
|
// path of the unified view - goes out from the login's own identity.
|
|
describe('JMAPClient.sendEmail without a named sender', () => {
|
|
// Identity/get order for a member of team@: Stalwart 1.0 lists the group's
|
|
// identity first, 0.16.23 the member's own.
|
|
const GROUP = { id: 'identity-team', email: 'team@example.org', mayDelete: false };
|
|
const OWN = { id: 'identity-own', email: 'usera@example.org', mayDelete: false };
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const sendUnnamed = (client: JMAPClient) =>
|
|
client.sendEmail(['list-unsubscribe@lists.example'], 'unsubscribe', '', undefined, undefined, undefined, undefined);
|
|
|
|
it.each([
|
|
['Stalwart 1.0', [GROUP, OWN]],
|
|
['Stalwart 0.16', [OWN, GROUP]],
|
|
])("sends from the login's own identity on %s, whatever case the login was typed in", async (_server, identities) => {
|
|
const submissions = mockServer({ identities });
|
|
|
|
await sendUnnamed(createClient('UserA@Example.org'));
|
|
|
|
expect(submissions[0].identityId).toBe('identity-own');
|
|
});
|
|
|
|
it.each([
|
|
['Stalwart 1.0', [GROUP, OWN]],
|
|
['Stalwart 0.16', [OWN, GROUP]],
|
|
])('matches a From address regardless of case on %s', async (_server, identities) => {
|
|
const submissions = mockServer({ identities });
|
|
|
|
await createClient('usera@example.org').sendEmail(
|
|
['rcpt@example.net'], 'Subject', 'body', undefined, undefined, undefined, 'Team@Example.org',
|
|
);
|
|
|
|
expect(submissions[0].identityId).toBe('identity-team');
|
|
});
|
|
});
|