fix: preview the named message when the inbox lookups beside it fail #1041

Any failed method in the preview request answered 502, so when the
server named the delivered message and it was read fine, a failing
Inbox query still turned the notification into a generic "New mail".
The named message is now a complete preview on its own.

A response without the optional `total` was also treated as a failure;
it now counts the returned ids.
This commit is contained in:
Linus Rath
2026-10-02 10:32:22 +02:00
parent 86afe94204
commit 39efd8af3c
2 changed files with 40 additions and 11 deletions
+14 -10
View File
@@ -247,15 +247,17 @@ export async function GET(request: NextRequest) {
// Missing/failed method responses are preview failures, not proof that
// there is no unread mail. A non-2xx response lets the SW show its fallback.
for (const call of methodCalls as [string, unknown, string][]) {
const response = data.methodResponses.find(([method, , callId]) =>
method === call[0] && callId === call[2]);
const result = response?.[1];
if (!result || (call[0] === 'Email/query'
? !Array.isArray(result.ids) || typeof result.total !== 'number'
: !Array.isArray(result.list))) {
return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 });
}
// The message the server named, once read, is a complete preview on its
// own, whatever became of the Inbox lookups next to it.
const resultOf = (name: string, id: string) =>
data.methodResponses.find(([method, , callId]) => method === name && callId === id)?.[1];
const failed = (methodCalls as [string, unknown, string][]).some(([name, , id]) => {
const result = resultOf(name, id);
return !result || (name === 'Email/query' ? !Array.isArray(result.ids) : !Array.isArray(result.list));
});
const deliveredList = resultOf('Email/get', 'delivered')?.list;
if (failed && !(Array.isArray(deliveredList) && deliveredList.length > 0)) {
return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 });
}
type EmailLite = {
@@ -272,7 +274,9 @@ export async function GET(request: NextRequest) {
let unreadTotal = 0;
for (const [method, body, callId] of data.methodResponses) {
if (method === 'Email/query') {
unreadTotal = ((body as { total?: number }).total) ?? 0;
// A server that leaves out the optional total still lists the ids.
const query = body as { total?: number; ids?: unknown[] };
unreadTotal = typeof query.total === 'number' ? query.total : (query.ids?.length ?? 0);
}
if (method === 'Email/get') {
const list = (body as { list?: EmailLite[] }).list ?? [];
+26 -1
View File
@@ -137,7 +137,6 @@ describe('push preview JMAP failures', () => {
it.each([
[['error', { type: 'serverFail' }, 'eq'], ['error', { type: 'resultReference' }, 'eg']],
[['Email/query', { ids: ['e1'], total: 1 }, 'eq'], ['error', { type: 'serverFail' }, 'eg']],
[['Email/query', { ids: [] }, 'eq'], ['Email/get', { list: [] }, 'eg']],
])('does not report zero unread on an email method failure', async (query, get) => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
@@ -156,6 +155,32 @@ describe('push preview JMAP failures', () => {
] }));
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
});
// `total` is optional in a JMAP Email/query response.
it('counts the ids when the server leaves out the total', async () => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
['Email/query', { ids: [] }, 'eq'],
['Email/get', { list: [] }, 'eg'],
] }));
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
});
it('previews the named message even when the Inbox lookups fail', async () => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
['error', { type: 'serverFail' }, 'eq'],
['error', { type: 'resultReference' }, 'eg'],
['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'],
] }));
expect(await callRoute('a', 'd')).toEqual({
status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 },
});
});
});