Merge branch 'fx/compose' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 04:20:07 +01:00
69 changed files with 5113 additions and 663 deletions
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: { accountId: 'me', request: vi.fn() },
}));
import { jmapClient } from '../jmap-client';
import { copyBlobs } from '../email';
const request = vi.mocked(jmapClient.request);
beforeEach(() => vi.clearAllMocks());
describe('copyBlobs', () => {
// Stalwart 0.16.27 and 1.0.0 answer unknownMethod without it (seen live).
it('names the blob capability, without which Stalwart refuses Blob/copy', async () => {
request.mockResolvedValueOnce({ methodResponses: [['Blob/copy', { copied: { b1: 'b9' } }, '0']] });
await expect(copyBlobs('team', ['b1'], 'me')).resolves.toEqual({ b1: 'b9' });
expect(request).toHaveBeenCalledWith(
[['Blob/copy', { fromAccountId: 'team', accountId: 'me', blobIds: ['b1'] }, '0']],
['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:blob'],
);
});
it('sends nothing within one account', async () => {
await expect(copyBlobs('me', ['b1'], 'me')).resolves.toEqual({ b1: 'b1' });
expect(request).not.toHaveBeenCalled();
});
it('fails when the server copied nothing', async () => {
request.mockResolvedValueOnce({
methodResponses: [['Blob/copy', { notCopied: { b1: { type: 'blobNotFound' } } }, '0']],
});
await expect(copyBlobs('team', ['b1'], 'me')).rejects.toThrow('blobNotFound');
});
});
+2 -2
View File
@@ -23,7 +23,7 @@ vi.mock('../jmap-client', () => ({
},
}));
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata';
const VENDOR = { supportsVendorNamespaces: true, supportsPrivate: true, namespaces: [], maxDepth: 8 };
@@ -52,7 +52,7 @@ function inboxWith(metadata: unknown) {
beforeEach(() => {
state.requests = [];
state.answer = null;
resetBulwarkMetadataPauses();
resetBulwarkMetadata();
});
describe('getBulwarkMetadata', () => {
@@ -40,7 +40,7 @@ vi.mock('../blob', () => ({
import { jmapClient } from '../jmap-client';
import {
cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail, sendReadReceipt,
cancelScheduledSend, listScheduledEmails, RescheduleLostError, rescheduleScheduledSend, sendEmail, sendReadReceipt,
} from '../email';
import { NoLongerScheduledError, SubmissionQuotaError } from '../jmap-result';
import { prunableSubmissionIds, pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
@@ -456,7 +456,9 @@ describe('rescheduling and "Send now"', () => {
expect(argsOf(server, 'EmailSubmission/set').map((a) => a.destroy).filter(Boolean)).toEqual([[original]]);
});
it('Stalwart 1.0: a full quota makes room without touching the pending original', async () => {
// The original is cancelled and its record removed first (#1308), so the
// replacement takes the room the original had.
it('Stalwart 1.0: a full account has room for the replacement once the original is cancelled', async () => {
const server = setup('1.0', { limit: 3 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' }]);
@@ -464,24 +466,42 @@ describe('rescheduling and "Send now"', () => {
const result = await rescheduleScheduledSend(scheduled(original), 2 * 3600);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(2);
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions().filter((s) => s.undoStatus === 'pending')).toEqual([
{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
]);
expect(server.submissions().some((s) => s.id === original)).toBe(false);
});
it('Stalwart 1.0: still full after making room, the original stays scheduled', async () => {
const server = setup('1.0', { limit: 1 });
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
it('Stalwart 1.0: an original that went out before the cancel is not sent a second time', async () => {
const server = setup('1.0');
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + 30_000, status: 'pending', emailId: 'mail-1' }]);
// Its HOLDFOR runs out while the lookup is under way.
server.afterEachRequest(() => server.deliver('acc-1', original));
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(SubmissionQuotaError);
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.create)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'final' }]);
});
/** A full account: the original, due soon, and two delivered records the prune can remove. */
function fullWithOriginal(dueIn: number) {
const server = setup('1.0', { limit: 3 });
it('Stalwart 1.0: an original cancelled on another device meanwhile is left as it is', async () => {
const server = setup('1.0');
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
server.afterEachRequest(() => server.cancel('acc-1', original));
const err = await rescheduleScheduledSend(scheduled(original), 2 * 3600).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: false });
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.create)).toBe(false);
});
/** An account at its limit once the original is gone: two delivered records the prune can remove. */
function fullAfterCancel(dueIn: number) {
const server = setup('1.0', { limit: 2 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + dueIn, status: 'pending', emailId: 'mail-1' }]);
server.seed('acc-1', [{ sendAt: now - HOUR, status: 'final' }, { sendAt: now - 2 * HOUR, status: 'final' }]);
@@ -489,59 +509,10 @@ describe('rescheduling and "Send now"', () => {
}
const isQuery = (calls: Array<[string, unknown, string]>) => calls[0][0] === 'EmailSubmission/query';
it('Stalwart 1.0: an original that went out while room was made is not sent a second time', async () => {
const { server, original } = fullWithOriginal(30_000);
// Its HOLDFOR runs out while the prune looks for records to remove.
server.afterEachRequest((calls) => {
if (isQuery(calls)) server.deliver('acc-1', original);
});
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
// The original was read again after the prune, and only the refused create was made.
expect(server.requests.at(-1)?.calls).toEqual([
['EmailSubmission/get', { accountId: 'acc-1', ids: [original], properties: ['id', 'undoStatus'] }, '0'],
]);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'final' }]);
});
it('Stalwart 1.0: an original cancelled on another device meanwhile is left as it is', async () => {
const { server, original } = fullWithOriginal(HOUR);
server.afterEachRequest((calls) => {
if (isQuery(calls)) server.cancel('acc-1', original);
});
const err = await rescheduleScheduledSend(scheduled(original), 2 * 3600).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: false });
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'canceled' }]);
});
it('Stalwart 1.0: when the original cannot be read again, nothing is resubmitted', async () => {
const { server, original } = fullWithOriginal(HOUR);
server.beforeEachRequest((calls) => {
const [name, args] = calls[0];
if (name === 'EmailSubmission/get' && (args as { properties?: string[] }).properties?.join() === 'id,undoStatus') {
throw new Error('Network request failed');
}
});
await expect(rescheduleScheduledSend(scheduled(original), 2 * 3600)).rejects.toThrow('Network request failed');
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]);
});
it('Stalwart 1.0: the retry takes the time spent making room off the hold', async () => {
let clock = Date.parse('2026-09-25T12:00:00Z');
vi.spyOn(Date, 'now').mockImplementation(() => clock);
const { server, original } = fullWithOriginal(HOUR);
const { server, original } = fullAfterCancel(HOUR);
const target = clock + 2 * HOUR;
// The prune takes five minutes.
server.afterEachRequest((calls) => {
@@ -561,7 +532,7 @@ describe('rescheduling and "Send now"', () => {
it('Stalwart 1.0: "Send now" that waited for room still goes out at once', async () => {
let clock = Date.parse('2026-09-25T12:00:00Z');
vi.spyOn(Date, 'now').mockImplementation(() => clock);
const { server, original } = fullWithOriginal(HOUR);
const { server, original } = fullAfterCancel(HOUR);
server.afterEachRequest((calls) => {
if (isQuery(calls)) clock += 5 * MINUTE;
});
@@ -573,6 +544,22 @@ describe('rescheduling and "Send now"', () => {
.map((a) => a.create.replacement.envelope.mailFrom.parameters.HOLDFOR);
expect(holds).toEqual(['1', '1']);
});
it('Stalwart 1.0: still full after making room, the original goes back to its old time', async () => {
const server = setup('1.0', { limit: 2 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' }]);
// Two other held messages: nothing the prune may remove.
server.seed('acc-1', [{ sendAt: now + 2 * HOUR, status: 'pending' }, { sendAt: now + 3 * HOUR, status: 'pending' }]);
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
// Neither the replacement nor the original found room: the message is
// back in Drafts rather than in Sent looking sent.
expect(err).toBeInstanceOf(RescheduleLostError);
expect((err as RescheduleLostError).reason).toBeInstanceOf(SubmissionQuotaError);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { drafts: true }, keywords: { $draft: true } });
});
});
describe('read receipts', () => {
@@ -36,7 +36,9 @@ vi.mock('../jmap-client', () => ({
}));
import { jmapClient } from '../jmap-client';
import { listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
import {
listScheduledEmails, RescheduleLostError, RescheduleRestoredError, rescheduleScheduledSend, sendEmail,
} from '../email';
import { RecipientsRefusedError, jmapErrorType } from '../jmap-result';
import { pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
import {
@@ -263,20 +265,24 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
const cancelled = (server: FakeSubmissionServer) => argsOf(server, 'EmailSubmission/set')
.filter((a) => a.update)
.flatMap((a) => Object.keys(a.update as object));
/** The replacements the server created, in order. */
const replacements = (server: FakeSubmissionServer) => server.requests
.flatMap(({ responses }) => responses)
.filter(([name, body]) => name === 'EmailSubmission/set' && body.created?.replacement)
.map(([, body]) => body.created.replacement.id as string);
beforeEach(() => {
// "Send now" holds the replacement for a second: the clock stands still.
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-26T10:00:00Z'));
});
// The original is cancelled before the replacement is made (#1308). A
// replacement that takes nobody puts the original back; when the throttle
// refuses that one too, the message goes back to Drafts and says so.
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.23', 7200],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: keeps the original when every recipient of the replacement is refused (hold %is)',
'Stalwart %s: moves the message back to Drafts when every recipient is refused again (hold %is)',
async (version, holdFor) => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const server = setup(version);
const scheduled = scheduledTo(server, 'bob@example.org');
// A throttle used up since the message was scheduled.
@@ -284,13 +290,32 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
const err = await rescheduleScheduledSend(scheduled, holdFor).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RecipientsRefusedError);
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
// Still goes at its old time; no cancel went out, and the dead
// replacement's record is gone.
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(err).toBeInstanceOf(RescheduleLostError);
const reason = (err as RescheduleLostError).reason;
expect(reason).toBeInstanceOf(RecipientsRefusedError);
expect((reason as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { drafts: true }, keywords: { $draft: true } });
// The dead records are gone, and nothing is pending.
expect(server.submissions()).toEqual([]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: puts the original back at its old time when the replacement took nobody but the original is taken again',
async (version) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'bob@example.org');
// Refused once, for the replacement; the throttle has room again for the original.
let refusals = 1;
server.refuseRecipients(() => (refusals-- > 0 ? RATE_LIMITED : undefined));
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleRestoredError);
const restored = err as RescheduleRestoredError;
expect(restored.reason).toBeInstanceOf(RecipientsRefusedError);
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
{ id: restored.restoredSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
@@ -305,7 +330,9 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
expect(create?.calls[1]).toEqual(
['EmailSubmission/get', { accountId: 'acc-1', ids: ['#replacement'], properties: ['id', 'deliveryStatus'] }, '1'],
);
expect(create?.using).toEqual(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
expect(create?.using).toEqual([
'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission',
]);
});
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
@@ -324,141 +351,51 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
},
);
it('leaves the refused replacement to the prune on a server without urn:stalwart:jmap', async () => {
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.6', 7200],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: goes ahead and names the recipient the replacement was refused while it took the others (hold %is)',
async (version, holdFor) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
const result = await rescheduleScheduledSend(scheduled, holdFor);
// The original is gone before the replacement is made, so the caller
// warns that bob does not get it.
expect(result.refusedRecipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(cancelled(server)).toEqual([scheduled.emailSubmissionId]);
expect(server.submissions()).toEqual([
{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
]);
},
);
it('leaves the dead records to the prune on a server without urn:stalwart:jmap', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
fake.stalwart = false;
const server = setup('1.0');
const scheduled = scheduledTo(server, 'bob@example.org');
server.refuseRecipients(() => RATE_LIMITED);
await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RecipientsRefusedError);
await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RescheduleLostError);
expect(server.submissions().map((s) => s.undoStatus)).toEqual(['pending', 'final']);
// The replacement's and the put-back original's, both final with nobody taken.
expect(server.submissions().map((s) => s.undoStatus)).toEqual(['final', 'final']);
});
// The replacement takes the others: cancelling the original would lose
// the message for the refused one, whom the original still reaches.
describe('when the replacement is refused a recipient the original was taken for', () => {
beforeEach(() => {
// "Send now" holds the replacement for a second, so the clock stands
// still for the withdraw to find it held.
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-26T10:00:00Z'));
});
it('files the replacement in Sent, for servers that pull a cancelled message back to Drafts', async () => {
const server = setup('1.0');
const scheduled = scheduledTo(server, 'bob@example.org');
server.emails.set('mail-1', { ...server.emails.get('mail-1')!, mailboxIds: { drafts: true }, keywords: { $draft: true } });
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.23', 7200],
['0.16.6', 0],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: keeps the original at its old time and withdraws the replacement (hold %is)',
async (version, holdFor) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
await rescheduleScheduledSend(scheduled, 7200);
const err = await rescheduleScheduledSend(scheduled, holdFor).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RecipientsRefusedError);
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
// Bob still gets it at the old time, and alice only once: the
// replacement she was taken for is cancelled, not just its record.
expect(cancelled(server)).toEqual(replacements(server));
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: names only whom the replacement would have lost when it took nobody',
async (version) => {
const server = setup(version, { refuseRecipient: relayOff });
const scheduled = scheduledTo(server, 'alice@example.org', 'nobody@example.net');
server.refuseRecipients((email) => relayOff(email) ?? throttledFor('alice@example.org')(email));
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// nobody@example.net never got it: the relay rule refused it back then.
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'alice@example.org', smtpReply: RATE_LIMITED }]);
// Nothing was queued for the replacement, so only its record goes.
expect(cancelled(server)).toEqual([]);
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
it('Stalwart 1.0 shapes: counts every recipient as taken when the server keeps no delivery status for the original', async () => {
const server = setup('1.0', { refuseRecipient: relayOff });
const scheduled = scheduledTo(server, 'alice@example.org', 'nobody@example.net');
vi.mocked(jmapClient.request).mockImplementationOnce(async (calls, using) => {
const res = await server.request(calls, using);
return {
methodResponses: res.methodResponses.map(([name, body, callId]) => (name === 'EmailSubmission/get'
? [name, { ...body, list: body.list.map((s: object) => ({ ...s, deliveryStatus: null })) }, callId]
: [name, body, callId])) as Array<[string, Record<string, any>, string]>,
};
});
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// Nothing says the relay rule refused nobody@example.net back then.
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
expect(cancelled(server)).toEqual(replacements(server));
expect(server.submissions().map((s) => [s.id, s.undoStatus])).toEqual([[scheduled.emailSubmissionId, 'pending']]);
});
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: goes ahead and names the refused recipient when "Send now" went out before it could be withdrawn',
async (version) => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
// Its second of hold is over before the withdraw arrives.
server.afterEachRequest(() => {
for (const id of replacements(server)) server.deliver('acc-1', id);
});
const result = await rescheduleScheduledSend(scheduled, 0);
// Alice has it from the replacement: keeping the original as well
// would send her a second copy.
expect(result.refusedRecipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(cancelled(server)).toEqual([result.emailSubmissionId, scheduled.emailSubmissionId]);
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'final' }]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: keeps the original when the withdraw went through and only its answer was lost',
async (version) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
let answered = false;
server.afterEachRequest((calls) => {
const [name, args] = calls[0];
if (name === 'EmailSubmission/set' && (args as { update?: object }).update && !answered) {
answered = true;
throw new Error('Network request failed');
}
});
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// Going ahead would cancel the original too: nobody would get it.
expect(err).toBeInstanceOf(RecipientsRefusedError);
const [replacement] = replacements(server);
expect(cancelled(server)).toEqual([replacement]);
// The canceled record is left to the prune.
expect(server.submissions().map((s) => [s.id, s.undoStatus])).toEqual([
[scheduled.emailSubmissionId, 'pending'],
[replacement, 'canceled'],
]);
},
);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { sent: true }, keywords: {} });
});
});
+121 -81
View File
@@ -17,8 +17,11 @@ vi.mock('../jmap-client', () => ({
}));
import { jmapClient } from '../jmap-client';
import { cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
import { ScheduleTooLateError } from '../jmap-result';
import {
cancelScheduledSend, listScheduledEmails, RescheduleLostError, RescheduleRestoredError, rescheduleScheduledSend,
sendEmail, SendUnconfirmedError,
} from '../email';
import { NoLongerScheduledError, ScheduleTooLateError } from '../jmap-result';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -34,12 +37,18 @@ const SCHEDULED = {
to: [{ email: 'to@example.com' }],
};
const ORIGINAL_AT = new Date(Date.now() + 3600_000).toISOString();
const LOOKUP = {
methodResponses: [
['EmailSubmission/get', {
list: [{ id: 'sub-1', envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] } }],
list: [{
id: 'sub-1',
sendAt: ORIGINAL_AT,
envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] },
}],
}, '0'],
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'to@example.com' }] }] }, '1'],
['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }, { id: 'mb-in', role: 'inbox' }] }, '2'],
],
};
const REPLACED = [['EmailSubmission/set', {
@@ -58,8 +67,17 @@ const setCalls = () => mockRequest.mock.calls
Object.keys(args.update ?? {}),
args.destroy ?? [],
]);
/** The replacement create of the n-th EmailSubmission/set create call. */
const createdArgs = (n = 0) => mockRequest.mock.calls
.map((c) => c[0][0])
.filter(([method, args]: [string, { create?: object }]) => method === 'EmailSubmission/set' && args.create)[n][1];
const REFUSED = (description: string) => set({ notCreated: { replacement: { type: 'forbiddenToSend', description } } });
describe('rescheduleScheduledSend', () => {
afterEach(() => {
mockRequest.mockReset();
});
it('keeps Cc, Bcc and the envelope sender of the held submission (B19)', async () => {
mockRequest
.mockResolvedValueOnce({
@@ -80,20 +98,18 @@ describe('rescheduleScheduledSend', () => {
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'to@example.com' }], cc: [{ email: 'cc@example.com' }] }] }, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
expect(result).toEqual({ emailSubmissionId: 'sub-2', sendAt: '2026-09-25T08:00:00Z' });
// What the server answered for each recipient comes with the envelope.
const [lookup] = mockRequest.mock.calls[0][0];
expect(lookup).toEqual([
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'deliveryStatus'] }, '0',
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'sendAt'] }, '0',
]);
const args = mockRequest.mock.calls[1][0][0][1];
expect(args.create.replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '7200' } },
rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }],
});
@@ -114,104 +130,112 @@ describe('rescheduleScheduledSend', () => {
}, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 60);
expect(mockRequest.mock.calls[1][0][0][1].create.replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'news@shop.example', parameters: { HOLDFOR: '60' } },
rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }],
});
});
it('creates the replacement before cancelling the original, then removes the canceled record', async () => {
// #1308: some servers refuse a second active submission for one Email, and
// some undo onSuccessUpdateEmail on cancel, pulling the message back out of
// Sent once the original went after its replacement.
it('cancels the original and removes its record before it creates the replacement, which files the message in Sent', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 7200);
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
expect(mockRequest.mock.calls[2][0][0][1].update).toEqual({ 'sub-1': { undoStatus: 'canceled' } });
expect(setCalls()).toEqual([[[], ['sub-1'], []], [[], [], ['sub-1']], [['replacement'], [], []]]);
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({ 'sub-1': { undoStatus: 'canceled' } });
expect(createdArgs().onSuccessUpdateEmail).toEqual({
'#replacement': { mailboxIds: { 'mb-sent': true }, 'keywords/$draft': null },
});
});
it('sends now as a 1-second hold, so the replacement can still be withdrawn', async () => {
it('sends now as a 1-second hold', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 0);
const replacement = mockRequest.mock.calls[1][0][0][1].create.replacement;
expect(replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '1' } },
rcptTo: [{ email: 'to@example.com' }],
});
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
});
it('leaves the original scheduled when the replacement is refused', async () => {
it('changes nothing when the original has already gone out', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(set({ notCreated: { replacement: { type: 'forbiddenToSend', description: 'Quota exceeded' } } }));
await expect(rescheduleScheduledSend(SCHEDULED, 7200)).rejects.toThrow('Quota exceeded');
// No cancel went out.
expect(setCalls()).toEqual([[['replacement'], [], []]]);
});
it('withdraws the replacement when the original has already gone out', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'cannotUnsend', description: 'Already sent' } } }))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] })
.mockResolvedValueOnce(set({ updated: { 'sub-2': null } }))
.mockResolvedValueOnce(DESTROYED('sub-2'));
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] });
await expect(rescheduleScheduledSend(SCHEDULED, 0)).rejects.toThrow('Failed to cancel the previous schedule: Already sent');
// The original went out, so its record stays for the prune; the
// withdrawn replacement's goes at once.
expect(setCalls()).toEqual([
[['replacement'], [], []], [[], ['sub-1'], []], [[], ['sub-2'], []], [[], [], ['sub-2']],
]);
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
const err = await rescheduleScheduledSend(SCHEDULED, 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
// No replacement: it would send the message a second time.
expect(setCalls()).toEqual([[[], ['sub-1'], []]]);
});
it('still reports the failure when the replacement cannot be withdrawn either', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
it('changes nothing and reports the failure when the cancel fails while the original is still due', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'cannotUnsend' } } }))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-2': { type: 'cannotUnsend' } } }));
await expect(rescheduleScheduledSend(SCHEDULED, 0)).rejects.toThrow('Failed to cancel the previous schedule');
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
it('keeps the replacement when a cancel whose answer got lost did go through', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockRejectedValueOnce(new Error('Network request failed'))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'canceled' }] }, '0']] });
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'pending' }] }, '0']] });
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
await expect(rescheduleScheduledSend(SCHEDULED, 7200)).rejects.toThrow('Network request failed');
expect(setCalls()).toEqual([[[], ['sub-1'], []]]);
});
expect(result.emailSubmissionId).toBe('sub-2');
// Unconfirmed, so the original's record is left for the prune.
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []]]);
it('puts the original back at its old time, under a new id, when the replacement is refused', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse(ORIGINAL_AT) - 1800_000);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-3' } } }));
const err = await rescheduleScheduledSend(SCHEDULED, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleRestoredError);
expect(err).toMatchObject({ restoredSubmissionId: 'sub-3', restoredSendAt: ORIGINAL_AT, message: 'Quota exceeded' });
// Half an hour was left of the original hold.
expect(createdArgs(1).create.replacement.envelope.mailFrom.parameters).toEqual({ HOLDFOR: '1800' });
vi.mocked(Date.now).mockRestore();
});
it('moves the message back to Drafts when neither the replacement nor the original can be made', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce({ methodResponses: [['Email/set', { updated: { 'e-1': null } }, '0']] });
const err = await rescheduleScheduledSend(SCHEDULED, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleLostError);
expect(err).toMatchObject({ inDrafts: true });
const [, restore] = mockRequest.mock.calls.at(-1)![0][0];
expect(restore.update).toEqual({
'e-1': { 'mailboxIds/mb-drafts': true, 'keywords/$draft': true, 'keywords/$seen': true, 'mailboxIds/mb-sent': null },
});
});
});
@@ -257,6 +281,22 @@ describe('sendEmail', () => {
.rejects.toThrow('Invalid envelope');
});
// #1019: an answer without the submission confirms nothing. The draft
// stays, and so does the copy, which may be the one that went out.
it('reports a send the server did not confirm as unconfirmed, and keeps the draft', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
],
});
await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb', undefined, {
draftsMailboxId: 'drafts-mb', draftId: 'old-draft',
})).rejects.toBeInstanceOf(SendUnconfirmedError);
// No destroy of the old draft version, nor of the copy.
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it('still fails when the message could not be created', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
@@ -374,24 +414,24 @@ describe('hold-limit rejections', () => {
expect(jmapClient.learnHoldLimit).not.toHaveBeenCalled();
});
it('turns a refused reschedule into ScheduleTooLateError', async () => {
it('turns a refused reschedule into ScheduleTooLateError, with the original put back', async () => {
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['EmailSubmission/get', { list: [{ id: 'sub-1', envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] } }] }, '0'],
['Email/get', { list: [] }, '1'],
],
})
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({
methodResponses: [['EmailSubmission/set', { notCreated: { replacement: HOLD_REJECTED } }, '0']],
});
})
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-3' } } }));
const err = await rescheduleScheduledSend(SCHEDULED, 5 * 24 * 3600).catch((e) => e);
expect(err).toBeInstanceOf(ScheduleTooLateError);
expect(err).toBeInstanceOf(RescheduleRestoredError);
expect((err as RescheduleRestoredError).reason).toBeInstanceOf(ScheduleTooLateError);
expect(jmapClient.learnHoldLimit).toHaveBeenCalledWith(172_800);
// The original stays scheduled.
expect(setCalls()).toEqual([[['replacement'], [], []]]);
// The original goes at its old time again, under a new id.
expect(setCalls()).toEqual([[[], ['sub-1'], []], [[], [], ['sub-1']], [['replacement'], [], []], [['replacement'], [], []]]);
mockRequest.mockReset();
});
});
@@ -473,9 +513,9 @@ describe('scheduled sends in shared accounts (webmail #874)', () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(set({ created: { replacement: { id: 's-new', sendAt: FUTURE } } }))
.mockResolvedValueOnce(set({ updated: { 's-grp': null } }))
.mockResolvedValueOnce(DESTROYED('s-grp'));
.mockResolvedValueOnce(DESTROYED('s-grp'))
.mockResolvedValueOnce(set({ created: { replacement: { id: 's-new', sendAt: FUTURE } } }));
await rescheduleScheduledSend({ ...SCHEDULED, emailSubmissionId: 's-grp', accountId: 'grp-1' }, 0);
expect(mockRequest.mock.calls.slice(2).map((c) => c[0][0][1].accountId)).toEqual(['grp-1', 'grp-1', 'grp-1', 'grp-1']);
});
@@ -389,7 +389,27 @@ export function createFakeSubmissionServer(options: {
if (emails.delete(id)) (body.destroyed ??= []).push(id);
else (body.notDestroyed ??= {})[id] = { type: 'notFound' };
}
// Pointer patches of mailboxes and keywords (a message back to Drafts).
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
const email = emails.get(id);
if (!email) {
(body.notUpdated ??= {})[id] = { type: 'notFound' };
continue;
}
for (const [path, value] of Object.entries(patch)) {
const [field, key] = path.split('/');
const target = field === 'mailboxIds' ? email.mailboxIds : field === 'keywords' ? email.keywords : null;
if (!target || !key) continue;
if (value === null) delete target[key];
else target[key] = true;
}
(body.updated ??= {})[id] = null;
}
responses.push(['Email/set', body, callId]);
} else if (name === 'Mailbox/get') {
responses.push(['Mailbox/get', {
accountId, state: 'm1', list: [{ id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }], notFound: [],
}, callId]);
} else if (name === 'Email/import') {
const body: Record<string, any> = { accountId };
for (const [creationId, item] of Object.entries(args.emails as Record<string, Record<string, any>>)) {
@@ -11,6 +11,10 @@ import path from 'path';
* and the gets whose server default could grow to include metadata ask for an
* explicit property list instead. Port of the webmail's
* lib/__tests__/jmap-metadata-requests.test.ts.
*
* Stalwart 1.0.0 (the release) serves Mailbox metadata, and templates are
* kept there (src/api/bulwark-metadata.ts, in requests of their own, only
* for accounts that advertise it). Everything else still never asks for it.
*/
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
@@ -239,18 +243,15 @@ describe('JMAP Metadata in the request-building sources', () => {
/(['"`])metadata(\/[^'"`\n]*)?\1/,
];
// Stalwart 1.0.0 serves Metadata. The one module that reads it (a group's
// shared tags) asks only accounts that advertise it, in a request of its
// own; its tests cover that.
const METADATA_READER = 'src/api/bulwark-metadata.ts';
// The one module that may: Bulwark's own entries on the Inbox, asked for
// in requests of their own.
const METADATA_MODULE = 'src/api/bulwark-metadata.ts';
it('never names a JMAP Metadata property, argument or capability in code', () => {
const files = [...sourceFiles('src'), 'App.tsx'];
const files = [...sourceFiles('src'), 'App.tsx'].filter((file) => file !== METADATA_MODULE);
expect(files).toContain('src/api/email.ts');
expect(files).toContain(METADATA_READER);
const hits: string[] = [];
for (const file of files) {
if (file === METADATA_READER) continue;
const code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
for (const pattern of FORBIDDEN) {
const match = code.match(pattern);
@@ -259,4 +260,9 @@ describe('JMAP Metadata in the request-building sources', () => {
}
expect(hits).toEqual([]);
});
it('keeps privateMetadata and ignoreMetadataOnlyChanges out of the metadata module too', () => {
const code = withoutComments(readFileSync(path.join(root, METADATA_MODULE), 'utf8'));
expect(code).not.toMatch(/privateMetadata|ignoreMetadataOnlyChanges/);
});
});
@@ -0,0 +1,207 @@
/**
* Live checks of the composer's sending paths against a real Stalwart, for
* the differences between 0.16 and 1.0: reschedule and "Send now" cancel
* the original first (#1308), Cancel send puts the message back in Drafts
* (native #84), Files and mail share blobs (#1179, Save to Files), Blob/copy
* needs the blob capability, and templates live in the Inbox metadata on
* 1.0.0 only.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
* `BULWARK_LIVE_PASS` are set.
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-phone-dark@example.org BULWARK_LIVE_PASS=… \
* npx vitest run src/api/__tests__/live-stalwart-compose.test.ts
*
* Everything it creates is removed again. Mail goes to the user's own
* address and nothing depends on delivery (SMTP is down on the test VM).
*
* Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0 (2026-10-29).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { JMAPClient } from '../jmap-client';
import {
destroyEmails, getFullEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, saveDraftVersion, sendEmail,
} from '../email';
import { uploadBytes } from '../blob';
import { deleteFileNodes } from '../files';
import { getIdentities, resolveSendAccountId } from '../identity';
import {
getBulwarkMetadata, MetadataTooLargeError, resetBulwarkMetadata, setBulwarkMetadata, supportsBulwarkMetadata,
} from '../bulwark-metadata';
import { CAPABILITIES } from '../types';
import { cancelScheduledToDrafts } from '../../lib/compose-scheduled';
import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files';
import { servedLoginOf } from '../../lib/draft-login';
import { useAccountStore } from '../../stores/account-store';
import { useTemplatesStore } from '../../stores/templates-store';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-phone-dark@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const live = SERVER && PASS ? describe : describe.skip;
const RUN = Date.now().toString(36);
let client: JMAPClient;
let singleton: JMAPClient;
/** Stalwart 1.0 advertises JMAP Metadata, 0.16 does not. */
const is10 = () => !!client.currentSession?.capabilities?.['urn:ietf:params:jmap:metadata'];
live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
let drafts = '';
let sent = '';
const tag = `bulwark-compose-${RUN}`;
const createdEmails: string[] = [];
const createdNodes: string[] = [];
beforeAll(async () => {
const mod = await import('../jmap-client');
singleton = mod.jmapClient as unknown as JMAPClient;
client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
singleton.restoreSnapshot(client.snapshot());
const boxes = await getMailboxes();
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
}, 60_000);
afterAll(async () => {
const res = await singleton.request([['Email/query', { accountId: singleton.accountId, filter: { subject: tag } }, '0']]);
const leftovers = [...new Set([...(res.methodResponses[0][1].ids as string[] ?? []), ...createdEmails])];
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
if (createdNodes.length) await deleteFileNodes(createdNodes).catch(() => undefined);
}, 60_000);
describe('scheduled sends', () => {
it('cancels the original before it schedules the replacement, then sends a cancelled one back to Drafts', async () => {
const [identity] = await getIdentities();
const result = await sendEmail(
{ from: [{ email: USER }], to: [{ email: USER }], subject: `later ${tag}`, textBody: 'live' },
identity.id, sent, 3600, { draftsMailboxId: drafts },
);
createdEmails.push(result.emailId!);
const first = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!;
expect(first).toBeTruthy();
const request = vi.spyOn(singleton, 'request');
const moved = await rescheduleScheduledSend(first, 7200);
// The cancel of the original goes out before the replacement's create.
const sets = request.mock.calls
.map(([calls]) => calls[0])
.filter(([name]) => name === 'EmailSubmission/set')
.map(([, args]) => (args.update ? 'cancel' : args.create ? 'create' : 'destroy'));
expect(sets.slice(0, 3)).toEqual(['cancel', 'destroy', 'create']);
request.mockRestore();
expect(moved.emailSubmissionId).toBeTruthy();
const listed = await listScheduledEmails();
expect(listed.some((s) => s.emailSubmissionId === first.emailSubmissionId)).toBe(false);
const second = listed.find((s) => s.emailSubmissionId === moved.emailSubmissionId)!;
expect(new Date(second.sendAt).getTime()).toBeGreaterThan(new Date(first.sendAt).getTime() + 3_000_000);
// The replacement filed it in Sent.
const [filed] = await getFullEmails([result.emailId!]);
expect(filed.mailboxIds[sent]).toBe(true);
// Cancel send: nothing goes out, and the message is a draft again.
const inDrafts = await cancelScheduledToDrafts(second, await getMailboxes(), singleton.accountId);
expect(inDrafts).toBe(true);
expect((await listScheduledEmails()).some((s) => s.emailId === result.emailId)).toBe(false);
const [restored] = await getFullEmails([result.emailId!]);
expect(restored.mailboxIds[drafts]).toBe(true);
expect(restored.mailboxIds[sent]).toBeUndefined();
expect(restored.keywords.$draft).toBe(true);
}, 90_000);
});
describe('Files and mail share blobs', () => {
it('saves an attachment to Files and attaches a file from Files, by blob', async () => {
const upload = await uploadBytes(new TextEncoder().encode(`hello ${RUN}`), 'text/plain');
// Save to Files: the attachment's blob becomes a file.
const node = await saveAttachmentToFiles({ blobId: upload.blobId, name: `${tag}.txt`, type: 'text/plain' }, null);
createdNodes.push(node.id);
expect(node.id).toBeTruthy();
const nodes = await singleton.request(
[['FileNode/get', { accountId: singleton.accountId, ids: [node.id] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.FILES],
);
const stored = nodes.methodResponses[0][1].list[0];
// Attach from Files: the file's blob goes into a draft as it is.
const part = await attachmentFromFileNode({ ...stored, nodeType: 'file', type: 'text/plain' });
const saved = await saveDraftVersion({
from: [{ email: USER }], to: [{ email: USER }], subject: `files ${tag}`, textBody: 'see attached',
attachments: [{ blobId: part.blobId, name: 'from-files.txt', type: 'text/plain', disposition: 'attachment' }],
}, drafts);
createdEmails.push(saved.id);
expect(saved.attachments.map((a) => a.name)).toContain('from-files.txt');
}, 60_000);
it('copies blobs with Blob/copy only when the blob capability is in `using`', async () => {
const upload = await uploadBytes(new TextEncoder().encode(`copy ${RUN}`), 'text/plain');
const call = ['Blob/copy', { fromAccountId: singleton.accountId, accountId: singleton.accountId, blobIds: [upload.blobId] }, '0'];
const without = await singleton.request([call as never], [CAPABILITIES.CORE]);
expect(without.methodResponses[0][0]).toBe('error');
const withBlob = await singleton.request([call as never], [CAPABILITIES.CORE, CAPABILITIES.BLOB]);
expect(withBlob.methodResponses[0][0]).toBe('Blob/copy');
}, 30_000);
});
describe('identities', () => {
it('sends the own address from the own account (this login is in no group)', async () => {
await expect(resolveSendAccountId(USER)).resolves.toBe(singleton.accountId);
expect(singleton.getSubmissionAccountIds()).toEqual([singleton.accountId]);
}, 30_000);
});
describe('templates in the Inbox metadata', () => {
it('keeps no metadata on 0.16 and asks for none', async () => {
if (is10()) return;
resetBulwarkMetadata();
const request = vi.spyOn(singleton, 'request');
expect(supportsBulwarkMetadata(singleton.accountId)).toBe(false);
await expect(getBulwarkMetadata(singleton.accountId)).resolves.toBeNull();
expect(request).not.toHaveBeenCalled();
request.mockRestore();
});
it('writes, reads and removes an entry on 1.0, and refuses one over 8 KB', async () => {
if (!is10()) return;
resetBulwarkMetadata();
const key = `template.live-${RUN}`;
await setBulwarkMetadata(singleton.accountId, key, { id: `live-${RUN}`, name: 'Live', updatedAt: new Date().toISOString() });
try {
expect((await getBulwarkMetadata(singleton.accountId))?.[key]).toMatchObject({ name: 'Live' });
await expect(setBulwarkMetadata(singleton.accountId, `template.big-${RUN}`, { body: 'x'.repeat(9000) }))
.rejects.toBeInstanceOf(MetadataTooLargeError);
} finally {
await setBulwarkMetadata(singleton.accountId, key, null);
}
expect((await getBulwarkMetadata(singleton.accountId))?.[key]).toBeUndefined();
}, 30_000);
it('syncs a template made on the phone into the account, and deletes it there', async () => {
if (!is10()) return;
resetBulwarkMetadata();
const before = await getBulwarkMetadata(singleton.accountId);
const hadMarker = !!before && 'templates' in before;
useAccountStore.setState({ activeAccountId: servedLoginOf(singleton) });
useTemplatesStore.setState({ hydrated: true, device: [], accounts: {}, deviceListOwner: null, templates: [] });
try {
await useTemplatesStore.getState().refresh({ force: true });
const made = useTemplatesStore.getState().addTemplate({ name: `Live ${RUN}`, subject: 's', body: 'b' });
await useTemplatesStore.getState().refresh({ force: true });
expect((await getBulwarkMetadata(singleton.accountId))?.[`template.${made.id}`]).toMatchObject({ name: `Live ${RUN}` });
useTemplatesStore.getState().deleteTemplate(made.id);
await useTemplatesStore.getState().refresh({ force: true });
const after = await getBulwarkMetadata(singleton.accountId);
expect(after?.[`template.${made.id}`]).toBeUndefined();
expect((after?.templates as { deleted?: Record<string, string> })?.deleted?.[made.id]).toBeTruthy();
} finally {
if (!hadMarker) await setBulwarkMetadata(singleton.accountId, 'templates', null).catch(() => undefined);
}
}, 60_000);
});
});
+3 -3
View File
@@ -48,7 +48,7 @@ import {
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
} from '../sieve';
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata';
import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags';
import { generateVCard, parseVCard } from '../../lib/vcard';
import type { ContactCard } from '../types';
@@ -358,7 +358,7 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
// is in no group, so this checks the shape on its own Inbox: written the
// way the webmail writes it, read the way the app reads it.
it('reads shared tags from Inbox metadata on 1.0 and asks nothing of 0.16', async () => {
resetBulwarkMetadataPauses();
resetBulwarkMetadata();
const accountId = client.accountId;
if (!is10()) {
expect(supportsBulwarkMetadata(accountId)).toBe(false);
@@ -378,7 +378,7 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
// One tag patched on its own, as the webmail saves a change.
await setTags({ label: 'Second', color: 'blue' }, `/tags/second-${RUN}`);
resetBulwarkMetadataPauses();
resetBulwarkMetadata();
const entries = await getBulwarkMetadata(accountId);
expect(parseSharedTags(entries?.[SHARED_TAGS_KEY])).toEqual([
{ id: `live-${RUN}`, label: 'Live check', color: 'teal' },
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// #1090: a message From a group identity is created, submitted and filed in
// the group's account. Stalwart lists the group's send-as identity among the
// member's own too, so the member's list alone sent it through the member's
// account and filed the Sent copy there.
const { client } = vi.hoisted(() => ({
client: {
accountId: 'me',
serverUrl: 'https://mail.example.com',
username: 'me@example.com',
request: vi.fn(),
getSubmissionAccountIds: vi.fn(() => ['me', 'team']),
currentSession: { accounts: { me: { name: 'me@example.com' }, team: { name: 'Team' } } } as Record<string, unknown>,
getAccountName: vi.fn((id: string) => id),
hasAccountCapability: vi.fn(() => false),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
vi.mock('../jmap-client', () => ({ jmapClient: client }));
import { resetSendIdentities, resolveSendAccountId, sendIdentityIn } from '../identity';
import { saveDraftVersion, sendEmail } from '../email';
import { sendTargetFor } from '../../lib/compose-send-account';
import type { Identity, Mailbox } from '../types';
const ME = { id: 'i-me', name: 'Me', email: 'me@example.com', mayDelete: false } as Identity;
// The member's list mirrors the group's identity, with the member's id for it.
const MIRRORED = { id: 'i-me-team', name: 'Team', email: 'team@example.com', mayDelete: false } as Identity;
const TEAM = { id: 'i-team', name: 'Team', email: 'team@example.com', mayDelete: false } as Identity;
function identitiesAnswer(calls: Array<[string, { accountId: string }, string]>) {
return {
methodResponses: calls.map(([name, args, callId]) => {
if (name !== 'Identity/get') throw new Error(`unexpected ${name}`);
const list = args.accountId === 'team' ? [TEAM] : [ME, MIRRORED];
return ['Identity/get', { accountId: args.accountId, list }, callId];
}),
};
}
function mailbox(role: string, id: string, accountId?: string): Mailbox {
return {
id: accountId ? `${accountId}:${id}` : id, originalId: accountId ? id : undefined, role, name: role,
accountId, isShared: !!accountId,
} as Mailbox;
}
const MAILBOXES = [
mailbox('drafts', 'own-drafts'), mailbox('sent', 'own-sent'),
mailbox('drafts', 'team-drafts', 'team'), mailbox('sent', 'team-sent', 'team'),
];
beforeEach(() => {
vi.clearAllMocks();
resetSendIdentities();
client.getSubmissionAccountIds.mockReturnValue(['me', 'team']);
client.request.mockImplementation(async (calls) => identitiesAnswer(calls));
});
describe('resolveSendAccountId', () => {
it("sends a group identity's message from the group account, not the member's mirror", async () => {
await expect(resolveSendAccountId('team@example.com')).resolves.toBe('team');
await expect(resolveSendAccountId('Team@Example.com')).resolves.toBe('team');
});
it('keeps the own address, and a sub-address of it, in the own account', async () => {
await expect(resolveSendAccountId('me@example.com')).resolves.toBe('me');
await expect(resolveSendAccountId('me+news@example.com')).resolves.toBe('me');
});
it('takes a sub-address of the group address for the group', async () => {
await expect(resolveSendAccountId('team+sales@example.com')).resolves.toBe('team');
});
it('asks nothing when there is no group or shared account', async () => {
client.getSubmissionAccountIds.mockReturnValue(['me']);
await expect(resolveSendAccountId('team@example.com')).resolves.toBe('me');
expect(client.request).not.toHaveBeenCalled();
});
it('reads the identities once for a burst of saves', async () => {
await resolveSendAccountId('team@example.com');
await resolveSendAccountId('team@example.com');
expect(client.request).toHaveBeenCalledTimes(1);
});
it("names the group account's own identity for the address", async () => {
await expect(sendIdentityIn('team', 'team@example.com')).resolves.toMatchObject({ id: 'i-team' });
});
});
describe('sendTargetFor', () => {
it("submits a group identity's message through the group's identity and files it in the group's folders", async () => {
await expect(sendTargetFor(MIRRORED, 'team@example.com', MAILBOXES)).resolves.toEqual({
accountId: 'team', identityId: 'i-team', draftsMailboxId: 'team-drafts', sentMailboxId: 'team-sent',
});
});
it("sends from the own account when the group's identities can't be read", async () => {
const accounts = client.currentSession.accounts as Record<string, { name: string }>;
accounts.team.name = 'team@example.com';
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
client.request.mockRejectedValue(new Error('Network request failed'));
try {
await expect(sendTargetFor(MIRRORED, 'team@example.com', MAILBOXES)).resolves.toEqual({
identityId: 'i-me-team', draftsMailboxId: 'own-drafts', sentMailboxId: 'own-sent',
});
} finally {
accounts.team.name = 'Team';
}
});
it('keeps an own message in the own folders', async () => {
await expect(sendTargetFor(ME, 'me@example.com', MAILBOXES)).resolves.toEqual({
identityId: 'i-me', draftsMailboxId: 'own-drafts', sentMailboxId: 'own-sent',
});
});
});
describe('a draft that moves between accounts', () => {
const OUTGOING = { from: [{ email: 'team@example.com' }], to: [{ email: 'you@example.net' }], subject: 'Hi', textBody: 'Hi' };
it("destroys the previous version in the account it was saved in, never one with the same id in the new one", async () => {
client.request.mockReset();
client.request
.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'd7' } } }, '0'],
['Email/get', { list: [{ id: 'd7', attachments: [] }] }, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: [['Email/set', { destroyed: ['d7'] }, '0']] });
// The own Drafts held version d7; the From is now the group's, whose
// account has a message d7 of its own.
await saveDraftVersion(OUTGOING, 'team-drafts', 'd7', 'team', 'me');
const [create] = client.request.mock.calls[0][0];
expect(create[1].accountId).toBe('team');
const [destroy] = client.request.mock.calls[1][0];
expect(destroy[1]).toMatchObject({ accountId: 'me', destroy: ['d7'] });
});
it('removes the old draft from its own account once the group send went out', async () => {
client.request.mockReset();
client.request
.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'e1' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's1' } } }, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: [['Email/set', { destroyed: ['d7'] }, '0']] });
await sendEmail(OUTGOING, 'i-team', 'team-sent', undefined, {
draftsMailboxId: 'team-drafts', draftId: 'd7', draftAccountId: 'me', accountId: 'team',
});
expect(client.request.mock.calls[0][0][0][1].accountId).toBe('team');
expect(client.request.mock.calls[0][0][1][1].accountId).toBe('team');
expect(client.request.mock.calls[1][0][0][1]).toMatchObject({ accountId: 'me', destroy: ['d7'] });
});
});
+146 -48
View File
@@ -1,35 +1,70 @@
// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata)
// of an account's Inbox: the shared tag definitions of a group account
// (namespace `org.bulwarkmail.tags`). Port of the reading half of the
// webmail's lib/jmap/bulwark-metadata.ts and client.getBulwarkMetadata.
// of an account's Inbox: personal templates in the user's own account, team
// templates in a group account (webmail lib/jmap/bulwark-metadata.ts).
//
// Stalwart 1.0.0 serves Metadata; 0.16 advertises none, and a pre-release
// 1.0 build advertised it but dropped the connection on a Mailbox/get that
// selects it (42751a6b). So this is the only module that asks for it: only
// for an account whose capabilities offer vendor namespaces on mailboxes,
// in a request of its own, and any failure pauses that account for ten
// minutes. Every other Mailbox/get keeps its explicit property list.
// What Stalwart does: 1.0.0 serves it (seen live 2026-10-29: Mailbox/get and
// Mailbox/set with `metadata/<namespace>` pointers on the Inbox, at most
// 8 KB per namespace, "Metadata value size … exceeds the maximum of 8192
// bytes" above that). Vendor namespaces need a dot, so every item gets one
// of its own: `org.bulwarkmail.<key>`. 0.16 has no metadata and refuses the
// capability in `using` (notRequest); an account that doesn't advertise it
// is never asked. A pre-release 1.0 build advertised it but dropped the
// connection on such a request, so the calls go in requests of their own
// and any failure pauses that account for ten minutes.
//
// This is the only module that names the Metadata capability or property
// (src/api/__tests__/jmap-metadata-requests.test.ts).
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { CAPABILITIES, type JMAPMethodCall } from './types';
export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
export const BULWARK_NAMESPACE = 'org.bulwarkmail';
/** Largest value Stalwart's default config takes in one namespace. */
export const MAX_METADATA_ENTRY_BYTES = 8192;
const PAUSE_AFTER_FAILURE_MS = 10 * 60_000;
const KEY_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY];
const PAUSE_MS = 10 * 60_000;
/**
* Whether the account's capabilities offer vendor-namespaced metadata on
* mailboxes. Says nothing about whether the server really implements it.
*/
export function advertisesMailboxMetadata(accountCapabilities: Record<string, unknown> | undefined): boolean {
const cap = accountCapabilities?.[METADATA_CAPABILITY] as
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
| undefined;
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
export class MetadataTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = 'MetadataTooLargeError';
}
}
/** Bulwark's entries of a `metadata` object, by key (`tags`, `template.<id>`). */
/** The server refused a metadata change; `type` is its SetError type. */
export class MetadataRefusedError extends Error {
constructor(readonly type: string, message: string) {
super(message);
this.name = 'MetadataRefusedError';
}
}
export class MetadataUnsupportedError extends Error {
constructor(message = 'This server keeps no metadata for Bulwark') {
super(message);
this.name = 'MetadataUnsupportedError';
}
}
/** `templates`, `template.<id>`: lowercase, digits, dashes, dot-separated. */
export function isValidMetadataKey(key: string): boolean {
return key.length <= 120 && KEY_RE.test(key);
}
function namespaceFor(key: string): string {
if (!isValidMetadataKey(key)) throw new Error(`Invalid metadata key: ${key}`);
return `${BULWARK_NAMESPACE}.${key}`;
}
/** The size Stalwart counts for a value: its JSON in UTF-8. */
export function metadataValueBytes(value: unknown): number {
return new TextEncoder().encode(JSON.stringify(value)).length;
}
/** Bulwark's entries of a metadata object, by key. */
export function bulwarkEntries(metadata: unknown): Record<string, unknown> {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {};
const prefix = `${BULWARK_NAMESPACE}.`;
@@ -40,52 +75,115 @@ export function bulwarkEntries(metadata: unknown): Record<string, unknown> {
return out;
}
// Accounts whose last read failed, by server and account, with when.
// Account ids repeat across servers and logins.
const keyOf = (accountId: string) => `${jmapClient.serverUrl ?? ''}\u0000${jmapClient.username ?? ''}\u0000${accountId}`;
const failedAt = new Map<string, number>();
const pauseKey = (accountId: string) => `${jmapClient.serverUrl ?? ''}|${accountId}`;
const inboxIds = new Map<string, string>();
/** Whether the app may ask this account for its Inbox metadata now. */
export function supportsBulwarkMetadata(accountId: string): boolean {
const failed = failedAt.get(pauseKey(accountId));
if (failed !== undefined && Date.now() - failed < PAUSE_AFTER_FAILURE_MS) return false;
const account = jmapClient.currentSession?.accounts?.[accountId] as
| { accountCapabilities?: Record<string, unknown> }
/** Forget paused accounts and Inbox ids (tests). */
export function resetBulwarkMetadata(): void {
failedAt.clear();
inboxIds.clear();
}
/** Whether the account advertises vendor metadata on mailboxes, whatever its last request did. */
export function advertisesBulwarkMetadata(accountId: string): boolean {
const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities as Record<string, unknown> | undefined;
const cap = caps?.[METADATA_CAPABILITY] as
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
| undefined;
return advertisesMailboxMetadata(account?.accountCapabilities);
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
}
/** Whether Bulwark may keep data in this account's Inbox metadata now. */
export function supportsBulwarkMetadata(accountId: string): boolean {
const at = failedAt.get(keyOf(accountId));
if (at !== undefined && Date.now() - at < PAUSE_MS) return false;
return advertisesBulwarkMetadata(accountId);
}
/**
* Bulwark's shared metadata entries of the account's Inbox, by key. Null
* when the account has none to offer (0.16) or the read failed; no request
* goes out for an account that doesn't advertise Metadata.
* Bulwark's metadata entries of the account's Inbox, by key. Null when the
* account has no usable metadata; a failure pauses the account.
*/
export async function getBulwarkMetadata(accountId: string): Promise<Record<string, unknown> | null> {
if (!supportsBulwarkMetadata(accountId)) return null;
const property = 'metadata';
try {
const res = await jmapClient.request([
['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
['Mailbox/get', {
accountId,
'#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' },
properties: ['id', property],
accountId, '#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' }, properties: ['id', 'metadata'],
}, 'g'],
], [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY]);
const get = res.methodResponses?.find((r) => r[0] === 'Mailbox/get');
] as JMAPMethodCall[], USING);
const get = res.methodResponses.find(([name]) => name === 'Mailbox/get');
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
// An answer without the property is a server that doesn't keep it,
// never "every entry was removed".
if (!inbox || !(property in inbox)) throw new Error('No Inbox metadata in the answer');
failedAt.delete(pauseKey(accountId));
return bulwarkEntries(inbox[property]);
if (!inbox || !('metadata' in inbox)) throw new MetadataUnsupportedError('No Inbox metadata in the answer');
inboxIds.set(keyOf(accountId), inbox.id as string);
failedAt.delete(keyOf(accountId));
return bulwarkEntries(inbox.metadata);
} catch (err) {
console.warn('[bulwark-metadata] unavailable for account', accountId, err);
failedAt.set(pauseKey(accountId), Date.now());
console.warn(`[metadata] unavailable for account ${accountId}:`, err);
failedAt.set(keyOf(accountId), Date.now());
return null;
}
}
/** Forget the failure pauses (sign-out, tests). */
export function resetBulwarkMetadataPauses(): void {
failedAt.clear();
/**
* Write one Bulwark entry of the account's Inbox metadata (null removes it),
* leaving every other entry as it is. Throws MetadataTooLargeError when the
* value is over the size the server takes, MetadataRefusedError for another
* refusal, MetadataUnsupportedError when the account has none.
*/
export async function setBulwarkMetadata(accountId: string, key: string, value: unknown): Promise<void> {
if (!supportsBulwarkMetadata(accountId)) throw new MetadataUnsupportedError();
if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`);
}
let inboxId = inboxIds.get(keyOf(accountId));
if (!inboxId) {
await getBulwarkMetadata(accountId);
inboxId = inboxIds.get(keyOf(accountId));
if (!inboxId) throw new MetadataUnsupportedError();
}
const res = await jmapClient.request(
[['Mailbox/set', { accountId, update: { [inboxId]: { [`metadata/${namespaceFor(key)}`]: value } } }, '0']],
USING,
);
const [name, body] = res.methodResponses[0] ?? [];
if (name !== 'Mailbox/set') {
throw new Error((body as { description?: string } | undefined)?.description || 'Failed to save metadata');
}
const refused = (body as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
if (refused) {
if (/exceeds the maximum/i.test(refused.description ?? '')) throw new MetadataTooLargeError(refused.description!);
throw new MetadataRefusedError(refused.type ?? 'unknown', refused.description || `Failed to save metadata (${refused.type ?? 'unknown'})`);
}
}
/**
* The group accounts of the session: shared accounts whose principal is a
* group (Stalwart uses the account id as the principal id). A mailbox
* another person shared is not a team. Empty when the server won't say.
*/
export async function groupAccounts(): Promise<Array<{ id: string; name: string }>> {
const session = jmapClient.currentSession;
if (!session || !jmapClient.hasCapability(CAPABILITIES.PRINCIPALS)) return [];
const own = jmapClient.accountId;
const shared = Object.entries(session.accounts ?? {})
.filter(([id, account]) => id !== own && account && account.isPersonal === false);
if (shared.length === 0) return [];
try {
const res = await jmapClient.request(
[['Principal/get', { accountId: own, ids: shared.map(([id]) => id), properties: ['id', 'type'] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
);
const [name, body] = res.methodResponses[0] ?? [];
if (name !== 'Principal/get') return [];
const types = new Map(((body as { list?: Array<{ id: string; type?: string }> }).list ?? []).map((p) => [p.id, p.type]));
return shared.filter(([id]) => types.get(id) === 'group').map(([id, account]) => ({ id, name: account.name }));
} catch {
return [];
}
}
+232 -130
View File
@@ -1650,6 +1650,18 @@ function submissionError(err: { description?: string; type?: string }, fallback:
return new ScheduleTooLateError(limit);
}
/**
* The send request came back without an EmailSubmission and without an
* error: nothing confirms the message left, and it may have (webmail
* SendUnconfirmedError, #1019). The draft is kept.
*/
export class SendUnconfirmedError extends Error {
constructor() {
super('Send confirmation was not received. Check Sent before sending again. Your draft has been kept.');
this.name = 'SendUnconfirmedError';
}
}
export interface SendEmailOptions {
/**
* Drafts folder id. When given, the message is created there with `$draft`
@@ -1660,6 +1672,12 @@ export interface SendEmailOptions {
draftsMailboxId?: string;
/** Previous draft version to destroy once the submission succeeded (#849). */
draftId?: string;
/**
* Account `draftId` lives in, when not the submitting one: a draft saved
* in the own Drafts and sent from a group identity, or the other way
* round. Email ids repeat across accounts.
*/
draftAccountId?: string;
/** Submitting account (shared/group account); defaults to the primary. */
accountId?: string;
}
@@ -1804,6 +1822,14 @@ export async function sendEmail(
}
}
// No submission came back and nothing said why: an answer cut short, or a
// server that skipped the call. Nothing confirms the message left, and it
// may have. Reporting it as sent dropped the draft and closed the composer
// (#1019); this keeps both, and the copy made for it too, since it may be
// the one that went out.
if (!failure && !emailSubmissionId) failure = new SendUnconfirmedError();
if (failure instanceof SendUnconfirmedError) throw failure;
if (failure) {
// Nothing went out. A message created before the submission was refused
// must not stay behind: nobody tracked that copy, so a retry left a
@@ -1855,11 +1881,12 @@ export async function sendEmail(
// rather than a failed send (#849). The submission record rides along, and
// nothing about it may turn into a warning.
if (opts?.draftId && emailSubmissionId) {
const draftAccountId = opts.draftAccountId ?? accountId;
try {
if (recordToDestroy) {
const cleanup = await jmapClient.request(
[
['Email/set', { accountId, destroy: [opts.draftId] }, '0'],
['Email/set', { accountId: draftAccountId, destroy: [opts.draftId] }, '0'],
submissionDestroyCall([recordToDestroy], accountId, '1'),
],
SUBMISSION_USING,
@@ -1867,7 +1894,7 @@ export async function sendEmail(
readSubmissionDestroy(cleanup, '1');
assertEmailsDestroyed(requireMethodResult(cleanup, '0', 'Email/set'));
} else {
await destroyEmails([opts.draftId], accountId);
await destroyEmails([opts.draftId], draftAccountId);
}
} catch (err) {
filingWarning = filingWarning ?? `old draft cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
@@ -1916,6 +1943,11 @@ export async function saveDraftVersion(
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
/**
* Account the previous version lives in, when not this one: the draft
* moves between the own Drafts and a group's when its From does (#1090).
*/
previousDraftAccountId?: string,
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
@@ -1948,9 +1980,10 @@ export async function saveDraftVersion(
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
if (previousDraftId && previousDraftId !== id) {
const previousAccountId = previousDraftAccountId ?? accountId;
if (previousDraftId && (previousDraftId !== id || previousAccountId !== accountId)) {
try {
await destroyEmails([previousDraftId], accountId);
await destroyEmails([previousDraftId], previousAccountId);
} catch (err) {
console.warn('[email] failed to destroy previous draft version:', err);
}
@@ -2124,21 +2157,53 @@ export async function cancelScheduledSend(emailSubmissionId: string, accountIdOv
}
}
/**
* A reschedule or "Send now" whose replacement the server refused, after the
* original had been cancelled: the original was put back at its old time
* under a new submission id, which the caller now holds instead (webmail
* RescheduleRestoredError). `reason` is why the replacement failed.
*/
export class RescheduleRestoredError extends Error {
constructor(
readonly reason: unknown,
readonly restoredSubmissionId: string,
readonly restoredSendAt?: string,
) {
super(reason instanceof Error ? reason.message : String(reason));
this.name = 'RescheduleRestoredError';
}
}
/**
* A reschedule or "Send now" that failed and could not put the original back
* either: the message is no longer scheduled. It was moved back to Drafts
* (`inDrafts`) so it does not sit in Sent looking sent. `reason` is why the
* replacement failed.
*/
export class RescheduleLostError extends Error {
constructor(readonly reason: unknown, readonly inDrafts: boolean) {
super(`Reschedule failed and the message is no longer scheduled: ${reason instanceof Error ? reason.message : String(reason)}`);
this.name = 'RescheduleLostError';
}
}
/**
* Change when a scheduled message goes out, or send it now with
* `holdForSeconds` = 0. A replacement submission for the same Email, with the
* held envelope and a fresh HOLDFOR, is created first, and the original is
* cancelled only once the replacement exists (webmail parity). Both used to
* go in one /set, whose create and update the server applies independently:
* a refused replacement still cancelled the original, so the message never
* went out, and a refused cancel (the original had just gone out) still let
* the replacement go, so it was sent twice. Now a refused replacement leaves
* the original alone, and a replacement whose original can't be cancelled is
* withdrawn again. Returns the new submission id and send time, and the
* recipients the server refused for the replacement when it took the others.
* When it refused the replacement a recipient the original had been taken
* for, the original is kept at its old time, and RecipientsRefusedError
* names whom the replacement would have lost.
* `holdForSeconds` = 0. The original is cancelled first and a replacement
* for the same Email, with the held envelope and a fresh HOLDFOR, created
* after it (#1308, webmail parity). The other order failed on servers that
* allow one active submission per Email and refuse the second create, and on
* servers that undo onSuccessUpdateEmail on cancel, which pulled the message
* back to Drafts once the original was cancelled after the replacement
* existed; the replacement now files it in Sent itself.
*
* A cancel that fails changes nothing: the original still goes out, or went
* out already (NoLongerScheduledError). A replacement the server refuses, or
* takes for no recipient, puts the original back at its old time under a new
* id (RescheduleRestoredError); when that fails too the message goes back to
* Drafts (RescheduleLostError). Returns the new submission id and send time,
* and the recipients the server refused for the replacement when it took
* the others.
*/
export async function rescheduleScheduledSend(
scheduled: {
@@ -2154,151 +2219,163 @@ export async function rescheduleScheduledSend(
recipients?: EmailAddress[],
): Promise<{ emailSubmissionId?: string; sendAt?: string; refusedRecipients?: RefusedRecipient[] }> {
const accountId = scheduled.accountId ?? jmapClient.accountId;
// "Send now" is a 1-second hold, so the replacement can still be withdrawn.
// "Send now" is a 1-second hold, as in the webmail.
const holdFor = Math.max(1, Math.ceil(holdForSeconds));
// Reuse the held submission's envelope: it names every recipient,
// Cc and Bcc included, and the envelope sender a catch-all From went
// out through. Rebuilding it from the Email's To dropped Cc and Bcc.
// The Email's To/Cc/Bcc are the fallback when the server has none. What
// the server answered for each recipient when it took the original comes
// along: whom a refusal of the replacement costs the message depends on it.
// The Email's To/Cc/Bcc are the fallback when the server has none. The
// account's Drafts and Sent come along for filing the replacement and for
// a message that ends up not scheduled at all.
const lookup = await jmapClient.request(
[
['EmailSubmission/get', {
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'deliveryStatus'],
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'sendAt'],
}, '0'],
['Email/get', { accountId, ids: [scheduled.emailId], properties: ['to', 'cc', 'bcc'] }, '1'],
['Mailbox/get', { accountId, ids: null, properties: ['id', 'role'] }, '2'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
deliveryStatus?: Record<string, { delivered?: string } | null> | null;
sendAt?: string;
}> | undefined)?.[0];
const envelope = original?.envelope;
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
const rcpt = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
const create = {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
envelope: {
mailFrom: {
email: envelope?.mailFrom?.email || scheduled.from?.[0]?.email,
parameters: { HOLDFOR: String(holdFor) },
},
rcptTo: rcpt,
},
const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
// Without an answer the replacement is made without filing, and a message
// that can't be put back stays where it is.
const [mailboxAnswer, mailboxBody] = lookup.methodResponses.find(([, , callId]) => callId === '2') ?? [];
const roles = mailboxAnswer === 'Mailbox/get'
? ((mailboxBody?.list ?? []) as Array<{ id: string; role?: string | null }>)
: [];
const draftsId = roles.find((m) => m.role === 'drafts')?.id;
const sentId = roles.find((m) => m.role === 'sent')?.id;
type Created = { id: string; sendAt?: string; delivery: { recipients: RefusedRecipient[]; all: boolean } };
// `until`: when the submission goes out (ms); undefined, at once.
const createSubmission = async (until: number | undefined): Promise<Created> => {
// HOLDFOR counts from when the server takes the request, so a retry
// after making room takes the time spent off it.
const holdSeconds = () => (until === undefined ? undefined : Math.max(1, Math.ceil((until - Date.now()) / 1000)));
const send = () => {
const seconds = holdSeconds();
return jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
replacement: {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
envelope: {
mailFrom: seconds === undefined
? { email: mailFrom }
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } },
rcptTo,
},
},
},
...(draftsId && sentId ? {
onSuccessUpdateEmail: {
'#replacement': { mailboxIds: { [sentId]: true }, [keywordPointer('$draft')]: null },
},
} : {}),
}, '0'],
deliveryStatusCall(accountId, 'replacement', '1'),
],
SUBMISSION_USING,
);
};
let res = await send();
// A full submission quota (Stalwart 1.0): make room and try once more.
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
res = await send();
}
// Only the submission call counts: the implied Email/set of
// onSuccessUpdateEmail can fail while the submission was created.
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId);
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission');
return {
id: created.id,
sendAt: created.sendAt,
delivery: refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1')),
};
};
// A submission that took no recipient goes nowhere; its record only counts
// against the limit. The recipients it refused, or null when it took some.
const withdrawIfDead = async (created: Created): Promise<RefusedRecipient[] | null> => {
if (!created.delivery.all) return null;
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([created.id], accountId);
return created.delivery.recipients;
};
// HOLDFOR counts from when the server takes the request.
const sendAt = Date.now() + holdFor * 1000;
const createReplacement = (seconds: number) => jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
replacement: {
...create,
envelope: { ...create.envelope, mailFrom: { ...create.envelope.mailFrom, parameters: { HOLDFOR: String(seconds) } } },
},
},
}, '0'],
deliveryStatusCall(accountId, 'replacement', '1'),
],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
let res = await createReplacement(holdFor);
// A full submission quota (Stalwart 1.0): make room and try once more. The
// original is still pending, so making room never touches it.
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
// Making room can take a while - a prune, a walk through the change log -
// and meanwhile the original may have gone out: its time came, or it was
// "Send now" of a message due any moment. A replacement would then send
// it a second time. A read that fails throws, and nothing changes.
const originalStatus = await readSubmissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (originalStatus !== 'pending') throw new NoLongerScheduledError(originalStatus === 'final');
// The time spent is taken off the hold; a moment already past goes out
// at once.
res = await createReplacement(Math.max(1, Math.ceil((sendAt - Date.now()) / 1000)));
}
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId);
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission');
const holdUntil = Date.now() + holdFor * 1000;
// The recipients are checked again for the replacement, and a throttle
// used up since the original was taken ("452 4.4.5 Rate limit exceeded")
// can refuse some or all of them. Cancelling the original would lose the
// message for a recipient it took and the replacement didn't, so the
// original is kept then - it still goes at its old time to everyone it
// took - and the replacement is withdrawn. A recipient the original was
// refused for as well loses nothing, so the reschedule goes ahead; without
// a delivery status of the original, every recipient counts as taken.
// Should the replacement not be withdrawn, it goes ahead too, and the
// caller names the refused recipients: keeping both would send the others
// two copies (webmail aa99f038).
const delivery = refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1'));
const refusedBefore = new Set(Object.entries(original?.deliveryStatus ?? {})
.filter(([, status]) => status?.delivered === 'no')
.map(([address]) => address.toLowerCase()));
const lost = delivery.recipients.filter((recipient) => !refusedBefore.has(recipient.email.toLowerCase()));
if (lost.length > 0 && await withdrawReplacement(created.id, accountId, delivery.all)) {
throw new RecipientsRefusedError(lost);
}
// Either cancel below also removes the record it canceled, so a reschedule
// leaves only the replacement behind.
// Cancel first. Should it fail, nothing has changed and the original still
// goes out - unless it has gone out already ("Send now" of a message due
// any moment), or was cancelled on another device. Either cancel also
// removes the record it canceled.
try {
await cancelScheduledSend(scheduled.emailSubmissionId, accountId);
} catch (err) {
// A cancel whose answer got lost may still have gone through. Otherwise
// the original is still due or has just gone out, and the replacement
// must not go as well.
if ((await submissionUndoStatus(scheduled.emailSubmissionId, accountId)) !== 'canceled') {
try {
await cancelScheduledSend(created.id, accountId);
} catch (withdrawErr) {
console.warn('[email] could not withdraw the replacement scheduled send:', withdrawErr);
}
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to cancel the previous schedule: ${reason}`);
const status = await submissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (status === 'final' || status === 'canceled') throw new NoLongerScheduledError(status === 'final');
throw err;
}
let replacement: Created;
try {
replacement = await createSubmission(holdUntil);
// The recipients are checked again for the replacement, and a throttle
// used up since the original was taken can refuse some or all of them.
// When it took nobody the reschedule failed; when it took some, the
// caller names the refused ones.
const refused = await withdrawIfDead(replacement);
if (refused) throw new RecipientsRefusedError(refused);
} catch (err) {
// Put the original back at its old time: HOLDFOR with the seconds left,
// at once when that time has passed meanwhile.
const originalTime = original?.sendAt ? Date.parse(original.sendAt) : NaN;
let restored: Created | undefined;
try {
restored = await createSubmission(Number.isFinite(originalTime) && originalTime > Date.now() ? originalTime : undefined);
} catch (restoreErr) {
console.warn('[email] could not put the original scheduled send back:', restoreErr);
}
if (restored && !(await withdrawIfDead(restored).catch(() => null))) {
throw new RescheduleRestoredError(err, restored.id, restored.sendAt ?? original?.sendAt);
}
// Neither went through: the message is not scheduled any more. It goes
// back to Drafts rather than sit in Sent looking sent.
let inDrafts = false;
if (draftsId) {
try {
await restoreEmailToDraft(scheduled.emailId, draftsId, sentId, accountId);
inDrafts = true;
} catch (draftErr) {
console.warn('[email] could not move the unscheduled message back to Drafts:', draftErr);
}
}
throw new RescheduleLostError(err, inDrafts);
}
return {
emailSubmissionId: created.id,
sendAt: created.sendAt,
...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}),
emailSubmissionId: replacement.id,
sendAt: replacement.sendAt,
...(replacement.delivery.recipients.length > 0 ? { refusedRecipients: replacement.delivery.recipients } : {}),
};
}
/**
* Withdraw the replacement of a reschedule that keeps its original instead;
* true once it can no longer go out. A replacement the server took for
* nobody (`tookNobody`) holds no mail, so only its record goes, on Stalwart
* (elsewhere the prune takes it once it is final). Any other is cancelled,
* which "Send now" may be too late for: its replacement is held for a second
* only. A cancel that failed counts when a read shows it went through with
* only its answer lost.
*/
async function withdrawReplacement(replacementId: string, accountId: string, tookNobody: boolean): Promise<boolean> {
if (tookNobody) {
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([replacementId], accountId);
return true;
}
try {
await cancelScheduledSend(replacementId, accountId);
return true;
} catch (err) {
if ((await submissionUndoStatus(replacementId, accountId)) === 'canceled') return true;
console.warn('[email] could not withdraw a replacement some recipients were refused for:', err);
return false;
}
}
/**
* A submission's `undoStatus`; undefined when the server no longer has the
* record. Throws when it can't be read.
@@ -2413,3 +2490,28 @@ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<str
}
return emailId;
}
/**
* Copy blobs into another account of the login (RFC 8620 §6.3): the blob of
* an attachment in a shared or group mailbox, filed in the user's Files, or
* a file attached to a message of another account. Resolves with the new
* blobId for each. Stalwart answers Blob/copy only with the blob capability
* in `using`. Same account: the ids as they are, nothing is sent.
*/
export async function copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise<Record<string, string>> {
if (blobIds.length === 0 || fromAccountId === toAccountId) {
return Object.fromEntries(blobIds.map((id) => [id, id]));
}
const res = await jmapClient.request(
[['Blob/copy', { fromAccountId, accountId: toAccountId, blobIds }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.BLOB],
);
const body = requireMethodResult(res, '0', 'Blob/copy');
const copied = (body.copied ?? {}) as Record<string, string>;
const missing = blobIds.find((id) => !copied[id]);
if (missing) {
const error = (body.notCopied ?? {})[missing] as { type?: string; description?: string } | undefined;
throw new Error(error?.description || error?.type || 'Blob/copy did not copy the blob');
}
return copied;
}
+111 -1
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client';
import { assertSetResult, requireMethodResult } from './jmap-result';
import { CAPABILITIES } from './types';
import type { Identity } from './types';
import type { Identity, JMAPMethodCall } from './types';
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
const USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION];
@@ -60,3 +60,113 @@ export async function deleteIdentity(id: string): Promise<void> {
);
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
}
/** An address the identity matches exactly. */
function identityEmailMatchesExactly(identityEmail: string | undefined, from: string): boolean {
return !!identityEmail && identityEmail.toLowerCase() === from;
}
/** An identity address `from` is a sub-address of (team+tag@ for team@, with the common "+" / "-" delimiters). */
function identityEmailIsSubAddressOf(identityEmail: string | undefined, from: string): boolean {
if (!identityEmail) return false;
const [idLocal, idDomain] = identityEmail.toLowerCase().split('@');
const [fromLocal, fromDomain] = from.split('@');
if (!idDomain || !fromDomain || idDomain !== fromDomain || idLocal === '*') return false;
return fromLocal.startsWith(`${idLocal}+`) || fromLocal.startsWith(`${idLocal}-`);
}
/** A wildcard identity for the domain of `from` (RFC 8621 §6, "*@example.com"). */
function identityEmailIsWildcardFor(identityEmail: string | undefined, from: string): boolean {
if (!identityEmail) return false;
const [idLocal, idDomain] = identityEmail.toLowerCase().split('@');
return idLocal === '*' && !!idDomain && idDomain === from.split('@')[1];
}
const IDENTITY_MATCHERS = [identityEmailMatchesExactly, identityEmailIsSubAddressOf, identityEmailIsWildcardFor];
// Draft autosave resolves on every save; identities rarely change.
let sendIdentitiesCache: { key: string; byAccount: Array<[string, Identity[]]>; expires: number } | null = null;
/** Forget the identities resolveSendAccountId read (tests, a switch of logins). */
export function resetSendIdentities(): void {
sendIdentitiesCache = null;
}
/**
* The identities of every account that can send, the delegated (group or
* shared) ones first and the login's own last. Null when there is no
* delegated account, or the identities can't be read.
*/
async function identitiesOfSendAccounts(): Promise<Array<[string, Identity[]]> | null> {
const own = jmapClient.accountId;
const delegated = jmapClient.getSubmissionAccountIds().filter((id) => id !== own);
if (delegated.length === 0) return null;
const candidates = [...delegated, own];
// The server and login are part of the key: account ids repeat across them.
const key = [jmapClient.serverUrl, jmapClient.username, ...candidates].join('\u0000');
if (sendIdentitiesCache && sendIdentitiesCache.key === key && sendIdentitiesCache.expires > Date.now()) {
return sendIdentitiesCache.byAccount;
}
try {
const res = await jmapClient.request(
candidates.map((accountId, i) => ['Identity/get', { accountId }, String(i)] as JMAPMethodCall),
USING,
);
const byAccount = candidates.map((accountId, i): [string, Identity[]] => {
const [name, body] = res.methodResponses.find(([, , callId]) => callId === String(i)) ?? [];
return [accountId, name === 'Identity/get' ? ((body?.list ?? []) as Identity[]) : []];
});
sendIdentitiesCache = { key, byAccount, expires: Date.now() + 60_000 };
return byAccount;
} catch (err) {
console.warn('[identity] could not read the identities to pick the sending account', err);
return null;
}
}
/**
* The account a message sent From `fromEmail` belongs to: where it is
* created, submitted and filed in Sent (webmail resolveSendAccountId,
* #1090). Stalwart lists a group's send-as identity among the member's own
* identities too, so going by the identity list alone sent the message
* through the member's account and filed its Sent copy there.
*
* Order: an account named exactly `fromEmail`; then an account that owns an
* identity for the address - exact, then sub-address, then wildcard, and a
* delegated (group/shared) account before the own one, since the own one
* may merely mirror the group's identity; else the own account. A
* sub-address of the member's own address stays theirs even when a shared
* catch-all account has a wildcard identity for the domain.
*/
export async function resolveSendAccountId(fromEmail?: string): Promise<string> {
const own = jmapClient.accountId;
const from = fromEmail?.trim().toLowerCase();
if (!from) return own;
const accounts = jmapClient.currentSession?.accounts ?? {};
const named = Object.keys(accounts).find((id) => accounts[id]?.name?.toLowerCase() === from);
if (named) return named;
const byAccount = await identitiesOfSendAccounts();
if (!byAccount) return own;
for (const matches of IDENTITY_MATCHERS) {
const hit = byAccount.find(([, list]) => list.some((identity) => matches(identity.email, from)));
if (hit) return hit[0];
}
return own;
}
/**
* The identity of `accountId` a message From `fromEmail` is submitted
* through: identity ids are per account, so the one the composer picked
* from the login's list names nothing (or another identity) in a group
* account. Undefined when the account has none for the address.
*/
export async function sendIdentityIn(accountId: string, fromEmail: string): Promise<Identity | undefined> {
const from = fromEmail.trim().toLowerCase();
const byAccount = await identitiesOfSendAccounts();
const list = byAccount?.find(([id]) => id === accountId)?.[1] ?? await getIdentities(accountId);
for (const matches of IDENTITY_MATCHERS) {
const hit = list.find((identity) => matches(identity.email, from));
if (hit) return hit;
}
return undefined;
}