fix: show the new-mail notification when the connection kept from the foreground has died #94

This commit is contained in:
Linus Rath
2026-11-05 22:28:57 +01:00
parent 8d03f5c442
commit e3525a0583
5 changed files with 104 additions and 3 deletions
@@ -19,6 +19,7 @@ import {
handleNotificationAction,
matchAccountsForPush,
parseRelayPushData,
PUSH_REQUEST_TIMEOUT_MS,
pushBackgroundTask,
selectNotifiableEmails,
senderFaviconsAllowed,
@@ -268,6 +269,30 @@ describe('pushBackgroundTask notifications', () => {
});
});
it('shows the generic notification when a request never answers, as on a dead connection (#94)', async () => {
vi.useFakeTimers();
try {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => undefined));
let settled = false;
const task = pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' })
.then(() => { settled = true; });
await vi.advanceTimersByTimeAsync(PUSH_REQUEST_TIMEOUT_MS - 1);
expect(settled).toBe(false);
expect(showNotification).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await task;
expect(showNotification).toHaveBeenCalledTimes(1);
expect((showNotification.mock.calls[0] as unknown[])[0]).toMatchObject({ title: 'New email', body: 'alice', emailId: '' });
// The request was told to stop, so it doesn't hold the connection.
const init = (secureFetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as { signal: AbortSignal };
expect(init.signal.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('takes a method error for a failed lookup, not for "nothing new" (#1041)', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
if (url.endsWith('/.well-known/jmap')) {
+31 -2
View File
@@ -321,13 +321,42 @@ function rewriteApiUrl(session: JMAPSession, serverUrl: string): string {
return serverOrigin + session.apiUrl.slice(apiOrigin.length);
}
/**
* How long one request of the push task may go unanswered. React Native's
* HTTP client has no timeout of its own, so a request on a dead connection
* waited until the task's 30 s were up and ended without any notification
* (#94). This leaves time for the session and the lookup, and the failure
* still shows the generic one.
*/
export const PUSH_REQUEST_TIMEOUT_MS = 10_000;
/** secureFetch that gives up after PUSH_REQUEST_TIMEOUT_MS. */
async function fetchInTime(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new PushLookupError(`No answer within ${PUSH_REQUEST_TIMEOUT_MS} ms`));
}, PUSH_REQUEST_TIMEOUT_MS);
});
try {
return await Promise.race([
secureFetch(url, { ...init, signal: controller.signal, timeoutMs: PUSH_REQUEST_TIMEOUT_MS }),
deadline,
]);
} finally {
clearTimeout(timer);
}
}
async function openDetachedSession(accountId: string): Promise<DetachedSession | null> {
let creds = await jmapClient.getStoredCredentials(accountId);
if (!creds) return null;
creds = await freshStoredCredentials(accountId, creds);
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
const authHeader = authHeaderFor(creds);
const res = await secureFetch(`${baseUrl}/.well-known/jmap`, {
const res = await fetchInTime(`${baseUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader, Accept: 'application/json' },
});
if (!res.ok) throw new Error(`Session discovery failed: ${res.status}`);
@@ -370,7 +399,7 @@ async function jmapPost(
session: DetachedSession,
methodCalls: JMAPMethodCall[],
): Promise<Array<[string, Record<string, any>, string]>> {
const response = await secureFetch(session.apiUrl, {
const response = await fetchInTime(session.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: session.authHeader },
body: JSON.stringify({ using: [CAPABILITIES.CORE, CAPABILITIES.MAIL], methodCalls }),