diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index 65c7f27a..a61ffdc4 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -218,6 +218,48 @@ describe('pushBackgroundTask notifications', () => { ); }); + it('shows a generic notification when the lookup fails, not none (#1041)', async () => { + (secureFetch as ReturnType).mockImplementation(async (url: string) => { + if (url.endsWith('/.well-known/jmap')) return { ok: false, status: 503 }; + throw new Error('unexpected'); + }); + await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' }); + + expect(showNotification).toHaveBeenCalledTimes(1); + expect((showNotification.mock.calls[0] as unknown[])[0]).toMatchObject({ + title: 'New email', + body: 'alice', + emailId: '', + threadId: '', + groupKey: `bulwark-mail:${LOCAL}`, + }); + }); + + it('takes a method error for a failed lookup, not for "nothing new" (#1041)', async () => { + (secureFetch as ReturnType).mockImplementation(async (url: string) => { + if (url.endsWith('/.well-known/jmap')) { + return { ok: true, json: async () => ({ apiUrl: 'https://mail.example.com/jmap/', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' } }) }; + } + return { ok: true, json: async () => ({ methodResponses: [['error', { type: 'serverFail' }, '0']] }) }; + }); + await pushBackgroundTask({ kind: 'jmap-state-change', accountLabel: 'alice', changed: '{"jmap-primary":{"EmailDelivery":"s"}}' }); + + expect(showNotification).toHaveBeenCalledTimes(1); + expect((showNotification.mock.calls[0] as unknown[])[0]).toMatchObject({ title: 'New email', emailId: '' }); + }); + + it('stays quiet when the lookup worked and found nothing new', async () => { + (secureFetch as ReturnType).mockImplementation(async (url: string) => { + if (url.endsWith('/.well-known/jmap')) { + return { ok: true, json: async () => ({ apiUrl: 'https://mail.example.com/jmap/', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' } }) }; + } + return { ok: true, json: async () => ({ methodResponses: [['Email/get', { list: [{ id: 'm1', keywords: { $seen: true } }] }, '0']] }) }; + }); + await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' }); + + expect(showNotification).not.toHaveBeenCalled(); + }); + it("says a message has no subject in the app's language", async () => { await AsyncStorage.setItem('webmail:locale:v1', JSON.stringify({ override: 'de' })); (secureFetch as ReturnType).mockImplementation(async (url: string) => ({ diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 673ec0e3..cecf9d2f 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -331,6 +331,31 @@ async function openDetachedSession(accountId: string): Promise, string]>, + method: string, +): Record { + const [name, body] = responses[0] ?? []; + if (name !== method) { + const type = name === 'error' && typeof body?.type === 'string' ? body.type : name ?? 'no response'; + throw new PushLookupError(`${method} failed: ${type}`); + } + return body ?? {}; +} + async function jmapPost( session: DetachedSession, methodCalls: JMAPMethodCall[], @@ -353,8 +378,7 @@ async function detachedGetEmails( const responses = await jmapPost(session, [ ['Email/get', { accountId, ids, properties: EMAIL_PROPERTIES }, '0'], ]); - const [name, body] = responses[0] ?? []; - if (name !== 'Email/get') return []; + const body = methodResult(responses, 'Email/get'); return (body.list as Email[]) ?? []; } @@ -366,8 +390,7 @@ async function detachedNewestUnreadInboxIds( const mailboxResponses = await jmapPost(session, [ ['Mailbox/get', { accountId, properties: ['id', 'role'] }, '0'], ]); - const [mbName, mbBody] = mailboxResponses[0] ?? []; - if (mbName !== 'Mailbox/get') return []; + const mbBody = methodResult(mailboxResponses, 'Mailbox/get'); const inbox = ((mbBody.list as Mailbox[]) ?? []).find((m) => m.role === 'inbox'); if (!inbox) return []; const queryResponses = await jmapPost(session, [ @@ -382,8 +405,7 @@ async function detachedNewestUnreadInboxIds( '0', ], ]); - const [qName, qBody] = queryResponses[0] ?? []; - if (qName !== 'Email/query') return []; + const qBody = methodResult(queryResponses, 'Email/query'); return (qBody.ids as string[]) ?? []; } @@ -441,6 +463,8 @@ export async function pushBackgroundTask(data: unknown): Promise { accountId, err instanceof Error ? err.message : err, ); + // Something arrived; say so even though the lookup couldn't say what. + if (!match.ambiguous) await showLookupFailedNotification(accountId, payload).catch(() => undefined); } } } catch (error) { @@ -612,6 +636,29 @@ async function showPlainNotification(payload: RelayPushData): Promise { }); } +/** + * New mail in `accountId` that couldn't be looked up (webmail #1041): the + * account's name and no message, so a tap opens the app as it is. One per + * account; the next failure replaces it rather than stacking up. + */ +async function showLookupFailedNotification(accountId: string, payload: RelayPushData): Promise { + const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined; + if (!native?.showNotification) return; + const locale = await notificationLocale(); + const label = payload.accountLabel ?? accountId.split('@')[0] ?? accountId; + await native.showNotification({ + notificationId: `mail-unknown:${accountId}`, + title: translate(locale, 'notifications.new_email', 'New email'), + body: label, + initials: getEmailInitials(label, ''), + bgColorHex: hslToHex(generateEmailAvatarColor(label, '')), + emailId: '', + threadId: '', + groupKey: `bulwark-mail:${accountId}`, + groupTitle: label, + }); +} + function hslToHex(hsl: string): string { const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/); if (!match) return '#2563eb';