diff --git a/components/calendar/__tests__/participant-availability.test.ts b/components/calendar/__tests__/participant-availability.test.ts index 99cdfe212..7f018ed2e 100644 --- a/components/calendar/__tests__/participant-availability.test.ts +++ b/components/calendar/__tests__/participant-availability.test.ts @@ -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'); + }); }); diff --git a/components/calendar/participant-availability.tsx b/components/calendar/participant-availability.tsx index 15f2507fa..63bba121f 100644 --- a/components/calendar/participant-availability.tsx +++ b/components/calendar/participant-availability.tsx @@ -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 | 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(); 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 (

{t("unavailable_on_server")} diff --git a/lib/__tests__/contact-media.test.ts b/lib/__tests__/contact-media.test.ts index 218eaddaa..b06a71069 100644 --- a/lib/__tests__/contact-media.test.ts +++ b/lib/__tests__/contact-media.test.ts @@ -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: [] }); diff --git a/lib/__tests__/jmap-blob-get.test.ts b/lib/__tests__/jmap-blob-get.test.ts index 547b89c0d..d19205c0e 100644 --- a/lib/__tests__/jmap-blob-get.test.ts +++ b/lib/__tests__/jmap-blob-get.test.ts @@ -59,8 +59,17 @@ describe('JMAPClient.getBlobsAsBase64', () => { return client; } - /** A 1.0 server holding `blobs` (id -> size); records every request. */ - function serve(blobs: Record, 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, + 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 }); diff --git a/lib/__tests__/jmap-keyword-account.test.ts b/lib/__tests__/jmap-keyword-account.test.ts index 2ccc452b8..5c4222175 100644 --- a/lib/__tests__/jmap-keyword-account.test.ts +++ b/lib/__tests__/jmap-keyword-account.test.ts @@ -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); + 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']); + }); }); diff --git a/lib/__tests__/jmap-submission-cleanup.test.ts b/lib/__tests__/jmap-submission-cleanup.test.ts index 09ea77451..8ff72b639 100644 --- a/lib/__tests__/jmap-submission-cleanup.test.ts +++ b/lib/__tests__/jmap-submission-cleanup.test.ts @@ -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', () => { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index acd062759..0b1bed879 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { @@ -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 { @@ -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 { 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(); 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 { + private async makeRoomForSubmission(accountId: string, options: { automatic?: boolean } = {}): Promise { 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;