mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 00:06:39 +00:00
Merge branch 'fx/compose' into feat/stalwart-1.0
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user