mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: stop treating partial refusals as total ones in sends and exports
Four places took a narrow refusal from the server for a complete one: - vCard export: Stalwart answers Blob/get only in accounts the user is a member of, so for an address book shared with the user it came back `forbidden` and the export fell back to one photo download after the other. It checks the access to each blob by itself, whichever account the request names, so `getBlobsAsBase64` now asks again in the user's own account (checked live on 1.0 with a book shared by another user). - Sending at a full submission quota: when making room found nothing, no search ran for five minutes, for any send. Scheduled mail and undo-send holds can go once their message is out, so a send the user starts now always looks again; the pause stays for automatic read receipts. - Free/busy: a directory that could not be listed hid the attendees' free/busy behind "Free/busy isn't available on this server". Listing principals and looking up free/busy are separate permissions on 0.16 and 1.0, so the line now shows only for Stalwart 1.0 with directory queries off or a lookup refused with `forbidden`; otherwise the principals the server still returns are looked up. - Mark as read and keyword writes over several requests: a request that failed outright made the whole selection count as unchanged, and rows already written showed unread until the next refresh. The write now stops there and names only that batch and the ones after it.
This commit is contained in:
@@ -73,14 +73,27 @@ const OWN_PRINCIPAL_0_16: MethodResponse = ['Principal/get', { accountId: 'me',
|
||||
},
|
||||
}], notFound: [] }, '0'];
|
||||
|
||||
// 1.0 with directory queries on, for a role that may look up free/busy.
|
||||
const OWN_PRINCIPAL_1_0: MethodResponse = ['Principal/get', { accountId: 'me', state: 'n', list: [{
|
||||
id: 'me',
|
||||
capabilities: {
|
||||
'urn:ietf:params:jmap:principals': {},
|
||||
'urn:ietf:params:jmap:calendars': { accountId: 'me', mayGetAvailability: true, mayShareWith: true, calendarAddress: 'mailto:me@example.com' },
|
||||
},
|
||||
}], notFound: [] }, '0'];
|
||||
|
||||
const NOT_AUTHORIZED: MethodResponse = ['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0'];
|
||||
const DIRECTORY_QUERIES_OFF: MethodResponse = ['error', { type: 'forbidden', description: 'The administrator has disabled directory queries.' }, '0'];
|
||||
const RATE_LIMITED: MethodResponse = ['error', { type: 'rateLimit', description: 'Too many availability requests, please try again later' }, '0'];
|
||||
const UNRESOLVED_REFERENCE: MethodResponse = ['error', { type: 'invalidResultReference', description: 'Result reference to 0#Principal/query not found.' }, '1'];
|
||||
|
||||
interface ServerScript {
|
||||
ownPrincipal: MethodResponse;
|
||||
/** Answer to Principal/get of the user's own principal; throws for an HTTP failure. */
|
||||
ownPrincipal: MethodResponse | (() => MethodResponse);
|
||||
/** Answer to the paged Principal/query + Principal/get; throws for an HTTP failure. */
|
||||
directory?: () => MethodResponse[];
|
||||
/** Answer to Principal/get without ids (the principals the user knows); the own principal's otherwise. */
|
||||
known?: MethodResponse;
|
||||
/** Answer to one Principal/getAvailability call; throws for an HTTP failure. */
|
||||
availability?: (principalId: string, callId: string) => MethodResponse;
|
||||
}
|
||||
@@ -109,7 +122,10 @@ function makeClient(script: ServerScript) {
|
||||
.mockImplementation(async (calls: Call[]) => {
|
||||
requests.push(calls);
|
||||
const [method, args] = calls[0];
|
||||
if (method === 'Principal/get') return { methodResponses: [script.ownPrincipal] };
|
||||
if (method === 'Principal/get') {
|
||||
if (args.ids === null && script.known) return { methodResponses: [script.known] };
|
||||
return { methodResponses: [typeof script.ownPrincipal === 'function' ? script.ownPrincipal() : script.ownPrincipal] };
|
||||
}
|
||||
if (method === 'Principal/query') {
|
||||
return { methodResponses: script.directory ? script.directory() : [
|
||||
['Principal/query', { ids: DIRECTORY.map((p) => p.id) }, '0'],
|
||||
@@ -302,4 +318,43 @@ describe('ParticipantAvailability', () => {
|
||||
await waitFor(() => expect(screen.getAllByText('error')).toHaveLength(2), { timeout: 2000 });
|
||||
expect(screen.queryByText('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
// Listing the directory (Principal/query) and looking up free/busy
|
||||
// (Principal/getAvailability) are permissions of their own on 0.16 and 1.0,
|
||||
// and both refuse a missing one with "You are not authorized to perform this
|
||||
// action" before the method runs. Principal/get without ids still returns
|
||||
// everyone there (0.16; 1.0 with directory queries on).
|
||||
it.each([
|
||||
['0.16', OWN_PRINCIPAL_0_16],
|
||||
['1.0', OWN_PRINCIPAL_1_0],
|
||||
])('keeps checking attendees on Stalwart %s when the role may not list the directory', async (_version, ownPrincipal) => {
|
||||
const { client } = makeClient({
|
||||
ownPrincipal,
|
||||
directory: () => [NOT_AUTHORIZED, UNRESOLVED_REFERENCE],
|
||||
known: ['Principal/get', { accountId: 'me', state: 'n', list: DIRECTORY, notFound: [] }, '0'],
|
||||
availability: (id, callId) => ['Principal/getAvailability', {
|
||||
list: id === 'anna' ? [{ utcStart: '2026-09-01T10:30:00Z', utcEnd: '2026-09-01T11:30:00Z', busyStatus: 'confirmed' }] : [],
|
||||
}, callId],
|
||||
});
|
||||
renderFor(client);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('busy')).toBeInTheDocument(), { timeout: 2000 });
|
||||
// Not a complete listing, so the outsider can't be called "not a user".
|
||||
expect(screen.getByText('error')).toBeInTheDocument();
|
||||
expect(screen.queryByText('unavailable_on_server')).toBeNull();
|
||||
});
|
||||
|
||||
it('says free/busy is not available when Stalwart 1.0 lists no directory because directory queries are off', async () => {
|
||||
// The own principal could not be read, so the listing's refusal is what tells.
|
||||
const { client, requests } = makeClient({
|
||||
ownPrincipal: http500,
|
||||
directory: () => [DIRECTORY_QUERIES_OFF, UNRESOLVED_REFERENCE],
|
||||
availability: () => DIRECTORY_QUERIES_OFF,
|
||||
});
|
||||
renderFor(client);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('unavailable_on_server')).toBeInTheDocument(), { timeout: 2000 });
|
||||
expect(screen.queryByTestId('participant-availability')).toBeNull();
|
||||
expect(methodsOf(requests)).not.toContain('Principal/getAvailability');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +52,8 @@ export function availabilityFor(periods: BusyPeriod[], start: Date, end: Date):
|
||||
|
||||
/**
|
||||
* The status of every address for the window, or null when the server won't
|
||||
* answer free/busy for this user: it refused the directory listing or a
|
||||
* lookup with `forbidden`.
|
||||
* answer free/busy for this user: Stalwart 1.0 with directory queries off,
|
||||
* or a lookup refused with `forbidden`.
|
||||
*/
|
||||
async function lookUpStatuses(
|
||||
client: IJMAPClient,
|
||||
@@ -62,7 +62,12 @@ async function lookUpStatuses(
|
||||
end: Date,
|
||||
): Promise<Record<string, AvailabilityStatus> | null> {
|
||||
const directory = await loadPrincipalDirectory(client);
|
||||
if (directory.status === "restricted") return null;
|
||||
// With directory queries off, Stalwart 1.0 refuses availability lookups as
|
||||
// well. Any other refused listing is a role without the permission to list
|
||||
// principals, which is not the one for free/busy (0.16 and 1.0 check them
|
||||
// one by one): the principals Principal/get still returned are looked up,
|
||||
// and a lookup refused in turn says it.
|
||||
if (directory.status === "restricted" && directory.directoryQueriesOff) return null;
|
||||
const byEmail = new Map<string, Principal>();
|
||||
for (const p of directory.principals) {
|
||||
if (p.email) byEmail.set(p.email.toLowerCase(), p);
|
||||
@@ -157,8 +162,8 @@ export function ParticipantAvailability({ attendees, start, end }: ParticipantAv
|
||||
if (!wanted) return null;
|
||||
if (allowed === false) {
|
||||
// Stalwart 1.0 with directory queries off (its default), or a role without
|
||||
// the principal permissions: the lookups are refused, so explain the gap
|
||||
// instead of leaving the attendees without a word.
|
||||
// the permission to look up free/busy: the lookups are refused, so explain
|
||||
// the gap instead of leaving the attendees without a word.
|
||||
return (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground" role="status" data-testid="participant-availability-unavailable">
|
||||
{t("unavailable_on_server")}
|
||||
|
||||
@@ -350,6 +350,9 @@ describe('inlineContactMediaForExport', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// The batch names the card's account. Where that is an address book shared
|
||||
// with the user, Stalwart refuses Blob/get in it, and the client asks in the
|
||||
// user's own account instead (see jmap-blob-get.test.ts).
|
||||
it('asks each account and login for its own blobs', async () => {
|
||||
const own = exportClient({ data: new Map([['dbcA', 'QQ==']]), failed: [] });
|
||||
const other = exportClient({ data: new Map([['dbcB', 'Qg==']]), failed: [] });
|
||||
|
||||
@@ -59,8 +59,17 @@ describe('JMAPClient.getBlobsAsBase64', () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
/** A 1.0 server holding `blobs` (id -> size); records every request. */
|
||||
function serve(blobs: Record<string, number>, refuse?: (call: Call, index: number) => Response | undefined) {
|
||||
/**
|
||||
* A 1.0 server holding `blobs` (id -> size); records every request. Like
|
||||
* Stalwart (0.16 and 1.0), it answers Blob/get only in accounts the user is
|
||||
* a member of - own and group accounts, not one that merely shares an
|
||||
* address book - and then serves every blob the user may read.
|
||||
*/
|
||||
function serve(
|
||||
blobs: Record<string, number>,
|
||||
refuse?: (call: Call, index: number) => Response | undefined,
|
||||
memberOf: string[] = ['a', 'team'],
|
||||
) {
|
||||
const requests: Array<{ using: string[]; call: Call }> = [];
|
||||
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
@@ -69,6 +78,11 @@ describe('JMAPClient.getBlobsAsBase64', () => {
|
||||
const refused = refuse?.(call, requests.length - 1);
|
||||
if (refused) return refused;
|
||||
const [, args, callId] = call;
|
||||
if (!memberOf.includes(args.accountId)) {
|
||||
return jsonResponse({
|
||||
methodResponses: [['error', { type: 'forbidden', description: `You are not an owner of account ${args.accountId}` }, callId]],
|
||||
});
|
||||
}
|
||||
const found = args.ids.filter((id) => id in blobs);
|
||||
return jsonResponse({
|
||||
methodResponses: [['Blob/get', {
|
||||
@@ -87,15 +101,55 @@ describe('JMAPClient.getBlobsAsBase64', () => {
|
||||
const client = await connectedClient();
|
||||
const requests = serve({ dbcPhoto: 40_000, dbcLogo: 2_000 });
|
||||
|
||||
const batch = await client.getBlobsAsBase64(['dbcPhoto', 'dbcLogo', 'dbcPhoto'], 'shared-1');
|
||||
const batch = await client.getBlobsAsBase64(['dbcPhoto', 'dbcLogo', 'dbcPhoto'], 'team');
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core', BLOB]);
|
||||
expect(requests[0].call[0]).toBe('Blob/get');
|
||||
expect(requests[0].call[1]).toEqual({ accountId: 'shared-1', ids: ['dbcPhoto', 'dbcLogo'], properties: ['data:asBase64'] });
|
||||
expect(requests[0].call[1]).toEqual({ accountId: 'team', ids: ['dbcPhoto', 'dbcLogo'], properties: ['data:asBase64'] });
|
||||
expect(batch).toEqual({ data: new Map([['dbcPhoto', btoa('dbcPhoto')], ['dbcLogo', btoa('dbcLogo')]]), failed: [] });
|
||||
});
|
||||
|
||||
// Live: userc's Blob/get naming userb's account `e` (an address book shared
|
||||
// with userc) is refused on 1.0 and on 0.16.23 alike; on 1.0 the same blobId
|
||||
// asked for in userc's own account comes back. (0.16 keeps contact photos
|
||||
// as data: URIs, so an export never gets here there.)
|
||||
it('reads the photos of a shared address book through the own account on Stalwart 1.0 and 0.16', async () => {
|
||||
const client = await connectedClient();
|
||||
const requests = serve({ dbcPhoto: 40_000, dbcLogo: 2_000 });
|
||||
|
||||
const batch = await client.getBlobsAsBase64(['dbcPhoto', 'dbcLogo'], 'shared-1');
|
||||
|
||||
expect(requests.map(({ call }) => call[1].accountId)).toEqual(['shared-1', 'a']);
|
||||
expect(requests[1].call[1]).toEqual({ accountId: 'a', ids: ['dbcPhoto', 'dbcLogo'], properties: ['data:asBase64'] });
|
||||
expect(batch).toEqual({ data: new Map([['dbcPhoto', btoa('dbcPhoto')], ['dbcLogo', btoa('dbcLogo')]]), failed: [] });
|
||||
});
|
||||
|
||||
it('switches to the own account once when a shared address book has many photos', async () => {
|
||||
const client = await connectedClient();
|
||||
const requests = serve({ a1: 3 * MiB, a2: 2 * MiB, a3: 2 * MiB });
|
||||
|
||||
const batch = await client.getBlobsAsBase64(['a1', 'a2', 'a3'], 'shared-1');
|
||||
|
||||
expect(requests.map(({ call }) => [call[1].accountId, call[1].properties[0]])).toEqual([
|
||||
['shared-1', 'size'],
|
||||
['a', 'size'],
|
||||
['a', 'data:asBase64'],
|
||||
['a', 'data:asBase64'],
|
||||
]);
|
||||
expect([...batch!.data.keys()]).toEqual(['a1', 'a2', 'a3']);
|
||||
});
|
||||
|
||||
it('does not retry a refusal in the own account', async () => {
|
||||
const client = await connectedClient();
|
||||
const requests = serve({ a1: 10 }, undefined, []);
|
||||
|
||||
const batch = await client.getBlobsAsBase64(['a1']);
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(batch).toEqual({ data: new Map(), failed: ['a1'] });
|
||||
});
|
||||
|
||||
it('asks for the sizes first and fetches the data in requests of about 4 MiB', async () => {
|
||||
const client = await connectedClient();
|
||||
const requests = serve({ a: 3 * MiB, b: 2 * MiB, c: 2 * MiB, d: 5 * MiB, e: 1024 });
|
||||
|
||||
@@ -309,4 +309,33 @@ describe('keyword writes report what the server refused', () => {
|
||||
expect(jmapErrorType(error)).toBe('accountReadOnly');
|
||||
expect(jmapErrorFailedIds(error)).toEqual(['m3', 'm4']);
|
||||
});
|
||||
|
||||
// A request that failed outright - the connection dropped - used to count
|
||||
// the whole selection as unchanged, although the batches before it were
|
||||
// written, and those rows showed unread until the next refresh.
|
||||
it.each([
|
||||
['batchMarkAsRead', (c: JMAPClient, ids: string[]) => c.batchMarkAsRead(ids, true)],
|
||||
['batchUpdateKeywords', (c: JMAPClient, ids: string[]) => c.batchUpdateKeywords(ids, { 'keywords/$label:work': true })],
|
||||
])('%s stops at a failed request and names only what was not written', async (_name, write) => {
|
||||
const client = createClient();
|
||||
Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInSet: 2 } } });
|
||||
const requested: string[][] = [];
|
||||
const dropped = new TypeError('Failed to fetch');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const [call] = (JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] }).methodCalls;
|
||||
const ids = Object.keys((call[1].update ?? {}) as Record<string, unknown>);
|
||||
requested.push(ids);
|
||||
if (ids.includes('m3')) throw dropped;
|
||||
return new Response(JSON.stringify({
|
||||
methodResponses: [['Email/set', { accountId: call[1].accountId, newState: 's2', ...acceptAll(ids) }, call[2]]],
|
||||
}), { status: 200 });
|
||||
});
|
||||
|
||||
const error = await write(client, ['m1', 'm2', 'm3', 'm4', 'm5']).then(() => null, (e: unknown) => e);
|
||||
|
||||
expect(requested.filter((ids) => ids.includes('m5'))).toEqual([]);
|
||||
expect(requested[0]).toEqual(['m1', 'm2']);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(jmapErrorFailedIds(error)).toEqual(['m3', 'm4', 'm5']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -771,7 +771,7 @@ describe('overQuota on EmailSubmission/set create', () => {
|
||||
return server;
|
||||
}
|
||||
|
||||
it('does not search again for a few minutes after finding nothing that can go on Stalwart 1.0', async () => {
|
||||
it('searches again for every send the user starts on Stalwart 1.0', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const server = fullOfHeldSends();
|
||||
const client = createClient();
|
||||
@@ -780,17 +780,32 @@ describe('overQuota on EmailSubmission/set create', () => {
|
||||
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(searches()).toBe(2);
|
||||
|
||||
// Every further attempt - a send, an automatic read receipt - only
|
||||
// tries the create once more.
|
||||
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(searches()).toBe(2);
|
||||
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(4);
|
||||
|
||||
vi.setSystemTime(Date.now() + 5 * 60 * 1000);
|
||||
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(searches()).toBe(4);
|
||||
});
|
||||
|
||||
// A search that found nothing used to hold off the next one for five
|
||||
// minutes, whoever sent: a held send that went out within them could not
|
||||
// make room, and every send failed with "ask your administrator".
|
||||
it('makes room once a held send went out, minutes after a search found nothing, on Stalwart 1.0', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const server = fakeStalwart({ version: '1.0', quota: 2 });
|
||||
const undoHold = server.add({ accountId: 'account-1', id: 'undo-hold', sendAt: utcDate(Date.now() + 60 * 1000), queued: true });
|
||||
server.add({ accountId: 'account-1', id: 'scheduled', sendAt: utcDate(Date.now() + DAY), queued: true });
|
||||
const client = createClient();
|
||||
|
||||
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
|
||||
// Two minutes later the undo hold has run out and its message left.
|
||||
vi.setSystemTime(Date.now() + 2 * 60 * 1000);
|
||||
undoHold.queued = false;
|
||||
const result = await send(client);
|
||||
|
||||
expect(result.emailSubmissionId).toBeDefined();
|
||||
expect(server.destroyedIds()).toContain('undo-hold');
|
||||
expect(server.submissions.has('scheduled')).toBe(true);
|
||||
});
|
||||
|
||||
it('searches again right away when the search itself failed on Stalwart 1.0', async () => {
|
||||
let failures = 1;
|
||||
const server = fullOfHeldSends({
|
||||
@@ -966,6 +981,32 @@ describe('sendReadReceipt', () => {
|
||||
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(2);
|
||||
expect(server.emails.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not search again for automatic receipts for a few minutes after finding nothing that can go on Stalwart 1.0', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const server = fakeStalwart({ version: '1.0', quota: 1 });
|
||||
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + DAY), queued: true });
|
||||
const client = mdnClient();
|
||||
const searches = () => server.calls('EmailSubmission/query').length + server.calls('EmailSubmission/changes').length;
|
||||
const automatic = { ...receipt, automatic: true };
|
||||
|
||||
await expect(client.sendReadReceipt(automatic)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(searches()).toBe(2);
|
||||
|
||||
// Every further automatic receipt only tries the create once more ...
|
||||
await expect(client.sendReadReceipt(automatic)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(searches()).toBe(2);
|
||||
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(4);
|
||||
|
||||
// ... while one the user sends looks again,
|
||||
await expect(client.sendReadReceipt(receipt)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(searches()).toBe(4);
|
||||
|
||||
// and so do automatic ones after a few minutes.
|
||||
vi.setSystemTime(Date.now() + 5 * 60 * 1000);
|
||||
await expect(client.sendReadReceipt(automatic)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(searches()).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScheduledEmails', () => {
|
||||
|
||||
+53
-20
@@ -237,7 +237,7 @@ export class NoLongerScheduledError extends Error {
|
||||
const SUBMISSION_PRUNE_INTERVAL_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* How long an account where making room found nothing that could go is not
|
||||
* searched again; see makeRoomForSubmission.
|
||||
* searched again for an automatic read receipt; see makeRoomForSubmission.
|
||||
*/
|
||||
const SUBMISSION_ROOM_RECHECK_MS = 5 * 60 * 1000;
|
||||
/** Upper bound on the pages one prune or change-log walk reads. */
|
||||
@@ -2745,13 +2745,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const batches = batched(emailIds, this.getMaxObjectsInSet());
|
||||
const responses: JMAPResponse[] = [];
|
||||
let requestError: unknown;
|
||||
for (const batch of batches) {
|
||||
const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read ? true : null }]));
|
||||
responses.push(await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]));
|
||||
try {
|
||||
responses.push(await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]));
|
||||
} catch (error) {
|
||||
requestError = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.assertEmailSetBatchesSucceeded(responses, batches, read ? "mark as read" : "mark as unread");
|
||||
this.assertEmailSetBatchesSucceeded(responses, batches, read ? "mark as read" : "mark as unread", requestError);
|
||||
}
|
||||
|
||||
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
|
||||
@@ -2823,13 +2829,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
|
||||
const batches = batched(emailIds, this.getMaxObjectsInSet());
|
||||
const responses: JMAPResponse[] = [];
|
||||
let requestError: unknown;
|
||||
for (const batch of batches) {
|
||||
const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
|
||||
responses.push(await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||||
]));
|
||||
try {
|
||||
responses.push(await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||||
]));
|
||||
} catch (error) {
|
||||
requestError = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.assertEmailSetBatchesSucceeded(responses, batches, "update keywords");
|
||||
this.assertEmailSetBatchesSucceeded(responses, batches, "update keywords", requestError);
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<KeywordMigration> {
|
||||
@@ -2995,8 +3007,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
* message must not leave the rest of a selection unchanged. Throws for the
|
||||
* first refusal, naming what every batch left unchanged, so a caller can
|
||||
* show the rest as done.
|
||||
*
|
||||
* `requestError`: the request of the batch after the last response failed
|
||||
* outright (the connection dropped), and the write stopped there. That batch
|
||||
* and the ones after it count as unchanged, the ones before as written.
|
||||
*/
|
||||
private assertEmailSetBatchesSucceeded(responses: JMAPResponse[], batches: string[][], action: string): void {
|
||||
private assertEmailSetBatchesSucceeded(responses: JMAPResponse[], batches: string[][], action: string, requestError?: unknown): void {
|
||||
let failure: JmapError | null = null;
|
||||
const failedIds: string[] = [];
|
||||
for (let i = 0; i < responses.length; i++) {
|
||||
@@ -3007,6 +3023,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
failedIds.push(...(jmapErrorFailedIds(error) ?? batches[i]));
|
||||
}
|
||||
}
|
||||
if (responses.length < batches.length) {
|
||||
failure ??= (requestError instanceof Error ? requestError : new Error(`Failed to ${action}`)) as JmapError;
|
||||
failedIds.push(...batches.slice(responses.length).flat());
|
||||
}
|
||||
if (failure) {
|
||||
failure.jmapFailedIds = failedIds;
|
||||
throw failure;
|
||||
@@ -5230,7 +5250,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
let response = await submit();
|
||||
if (submissionCreateError(response, subId)?.type === 'overQuota') {
|
||||
await this.makeRoomForSubmission(targetAccountId);
|
||||
await this.makeRoomForSubmission(targetAccountId, { automatic: params.automatic });
|
||||
response = await submit();
|
||||
}
|
||||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||||
@@ -5287,17 +5307,27 @@ export class JMAPClient implements IJMAPClient {
|
||||
* Null when the session doesn't advertise the blob capability, which then
|
||||
* must stay out of `using`. Blobs the server doesn't find are left out; once
|
||||
* a request fails, the blobs not fetched yet are listed in `failed`.
|
||||
*
|
||||
* Stalwart answers Blob/get only in accounts the user is a member of: an
|
||||
* address book shared with the user is in an account that isn't one, and
|
||||
* the call comes back `forbidden` ("You are not an owner of account ...").
|
||||
* It checks the access to each blob by itself, though, whichever account the
|
||||
* request names, so the rest are asked for in the user's own account.
|
||||
*/
|
||||
async getBlobsAsBase64(blobIds: string[], accountId?: string): Promise<BlobBatch | null> {
|
||||
if (!this.hasCapability(BLOB_CAPABILITY)) return null;
|
||||
const account = accountId || this.accountId;
|
||||
let account = accountId || this.accountId;
|
||||
const ids = [...new Set(blobIds)];
|
||||
const data = new Map<string, string>();
|
||||
const blobGet = async (chunk: string[], property: "size" | "data:asBase64") => {
|
||||
const response = await this.request([
|
||||
const call = () => this.request([
|
||||
["Blob/get", { accountId: account, ids: chunk, properties: [property] }, "0"],
|
||||
], ["urn:ietf:params:jmap:core", BLOB_CAPABILITY]);
|
||||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||||
let [name, result] = (await call()).methodResponses?.[0] ?? [];
|
||||
if (name === "error" && result?.type === "forbidden" && account !== this.accountId) {
|
||||
account = this.accountId;
|
||||
[name, result] = (await call()).methodResponses?.[0] ?? [];
|
||||
}
|
||||
if (name !== "Blob/get") {
|
||||
throw jmapError(`Blob/get failed: ${result?.description || result?.type || "unknown"}`, result);
|
||||
}
|
||||
@@ -10475,14 +10505,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
* the meantime.
|
||||
*
|
||||
* At a full quota every attempt ends up here - each send, reschedule and
|
||||
* automatic read receipt. When a search found nothing that can go, the
|
||||
* account is not searched again for a few minutes: what is left is pending
|
||||
* or out of reach, and looking again right away would cost the prune, the
|
||||
* change-log walk and the gets once more for the same answer.
|
||||
* automatic read receipt. When a search found nothing that can go, it is
|
||||
* not repeated for automatic read receipts for a few minutes: what is left
|
||||
* is pending or out of reach, and looking again right away would cost the
|
||||
* prune, the change-log walk and the gets once more for the same answer.
|
||||
* A send the user starts always looks again: pending records - scheduled
|
||||
* mail, undo-send holds - can go once their message is out, and until the
|
||||
* next search every send would fail with "ask your administrator".
|
||||
*/
|
||||
private async makeRoomForSubmission(accountId: string): Promise<void> {
|
||||
private async makeRoomForSubmission(accountId: string, options: { automatic?: boolean } = {}): Promise<void> {
|
||||
const recheckAt = this.submissionRoomRecheckAt.get(accountId);
|
||||
if (recheckAt !== undefined && Date.now() < recheckAt) return;
|
||||
if (options.automatic && recheckAt !== undefined && Date.now() < recheckAt) return;
|
||||
const now = Date.now();
|
||||
this.submissionPrunedAt.set(accountId, now);
|
||||
let found = 0;
|
||||
|
||||
Reference in New Issue
Block a user