fix: let the notification Delete skip the unlock when the mail goes to Trash #85

This commit is contained in:
Linus Rath
2026-10-31 13:47:05 +01:00
parent 149e5bcee5
commit 2fe8366938
2 changed files with 63 additions and 10 deletions
+39 -6
View File
@@ -218,6 +218,39 @@ describe('pushBackgroundTask notifications', () => {
); );
}); });
// #85: Delete only moves to Trash, so with a Trash it needs no unlock and
// the shade stays open; without one the unlock stays.
it.each([
['without unlocking when the account has a Trash', [{ id: 'tr', role: 'trash' }], false],
['behind the unlock when the account has no Trash', [{ id: 'in', role: 'inbox' }], true],
])('offers Delete %s', async (_label, mailboxes, needsUnlock) => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init?: { body?: 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' },
accounts: { 'jmap-primary': {} },
}),
};
}
const calls = JSON.parse(init?.body ?? '{}').methodCalls as Array<[string, Record<string, unknown>, string]>;
return {
ok: true,
json: async () => ({
methodResponses: calls.map(([name, , id]) => (name === 'Mailbox/get'
? [name, { list: mailboxes }, id]
: [name, { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hi', from: [{ email: 'bob@example.com' }] }] }, id])),
}),
};
});
await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' });
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', deleteNeedsUnlock: needsUnlock }));
});
it('shows a generic notification when the lookup fails, not none (#1041)', async () => { it('shows a generic notification when the lookup fails, not none (#1041)', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => { (secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
if (url.endsWith('/.well-known/jmap')) return { ok: false, status: 503 }; if (url.endsWith('/.well-known/jmap')) return { ok: false, status: 503 };
@@ -693,14 +726,14 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
await recorded(2 * DAY); await recorded(2 * DAY);
await push(); await push();
expect(showNotification).toHaveBeenCalled(); expect(showNotification).toHaveBeenCalled();
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']); expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires); expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
// Recorded now: the next pushes leave it alone for a day. // Recorded now: the next pushes leave it alone for a day.
server.calls = []; server.calls = [];
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]); await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
await push(); await push();
expect(server.calls).toEqual(['Email/get']); expect(server.calls).toEqual(['Email/get', 'Mailbox/get']);
}); });
it('extends it also when the push brings nothing new to show', async () => { it('extends it also when the push brings nothing new to show', async () => {
@@ -721,7 +754,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]); await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
await setup(); await setup();
await push(); await push();
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']); expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires); expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
// The server's copy has a week left: recorded, not written. // The server's copy has a week left: recorded, not written.
@@ -730,7 +763,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]); await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
await setup(); await setup();
await push(); await push();
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get']); expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get']);
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(serverExpires); expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(serverExpires);
} }
}); });
@@ -748,7 +781,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
await AsyncStorage.removeItem(`push:optedOut:v1:${LOCAL}`); await AsyncStorage.removeItem(`push:optedOut:v1:${LOCAL}`);
await setup(); await setup();
await push(); await push();
expect(server.calls).toEqual(['Email/get']); expect(server.calls).toEqual(['Email/get', 'Mailbox/get']);
} }
}); });
@@ -756,7 +789,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
await recorded(2 * DAY); await recorded(2 * DAY);
server.deviceClientId = 'dcid-someone-else'; server.deviceClientId = 'dcid-someone-else';
await push(); await push();
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get']); expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get']);
}); });
it('still notifies when the server cannot be asked about the subscription', async () => { it('still notifies when the server cannot be asked about the subscription', async () => {
+24 -4
View File
@@ -128,6 +128,11 @@ interface ShowNotificationOptions {
groupTitle: string; groupTitle: string;
markReadLabel?: string; markReadLabel?: string;
deleteLabel?: string; deleteLabel?: string;
// Whether Delete asks to unlock the phone first (#85). Delete only ever
// moves the message to Trash; with a Trash to move it to, nothing is lost
// and the action runs from the lock screen and the open shade. Without
// one, or when that is not known, the unlock stays.
deleteNeedsUnlock?: boolean;
replyLabel?: string; replyLabel?: string;
} }
@@ -374,16 +379,29 @@ async function jmapPost(
return body.methodResponses ?? []; return body.methodResponses ?? [];
} }
/**
* The messages to notify, and whether their account has a Trash for the
* notification's Delete to move them to (asked in the same request). An
* answer without the folders counts as no Trash.
*/
async function detachedGetEmails( async function detachedGetEmails(
session: DetachedSession, session: DetachedSession,
accountId: string, accountId: string,
ids: string[], ids: string[],
): Promise<Email[]> { ): Promise<{ emails: Email[]; hasTrash: boolean }> {
const responses = await jmapPost(session, [ const responses = await jmapPost(session, [
['Email/get', { accountId, ids, properties: EMAIL_PROPERTIES }, '0'], ['Email/get', { accountId, ids, properties: EMAIL_PROPERTIES }, '0'],
['Mailbox/get', { accountId, properties: ['id', 'role'] }, '1'],
]); ]);
const body = methodResult(responses, 'Email/get'); const body = methodResult(responses, 'Email/get');
return (body.list as Email[]) ?? []; return { emails: (body.list as Email[]) ?? [], hasTrash: answerHasTrash(responses) };
}
/** Whether the Mailbox/get answer (call id '1') lists a Trash. */
export function answerHasTrash(responses: Array<[string, Record<string, any>, string]>): boolean {
const answer = responses.find(([name, , id]) => name === 'Mailbox/get' && id === '1');
const list = answer?.[1]?.list;
return Array.isArray(list) && list.some((m: Mailbox) => m?.role === 'trash');
} }
async function detachedNewestUnreadInboxIds( async function detachedNewestUnreadInboxIds(
@@ -543,6 +561,7 @@ async function processAccountForPush(accountId: string, payload: RelayPushData,
async function notifyForPush(accountId: string, session: DetachedSession, payload: RelayPushData): Promise<void> { async function notifyForPush(accountId: string, session: DetachedSession, payload: RelayPushData): Promise<void> {
const alreadyNotified = await readNotifiedIds(accountId); const alreadyNotified = await readNotifiedIds(accountId);
let candidates: Email[]; let candidates: Email[];
let hasTrash = false;
let emailAccountId = session.jmapAccountId; let emailAccountId = session.jmapAccountId;
if (payload.emailIds.length > 0) { if (payload.emailIds.length > 0) {
@@ -552,7 +571,7 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
const fresh = payload.emailIds.filter((id) => !alreadyNotified.includes(id)); const fresh = payload.emailIds.filter((id) => !alreadyNotified.includes(id));
if (fresh.length === 0) return; if (fresh.length === 0) return;
emailAccountId = payload.jmapAccountId ?? session.jmapAccountId; emailAccountId = payload.jmapAccountId ?? session.jmapAccountId;
candidates = await detachedGetEmails(session, emailAccountId, fresh); ({ emails: candidates, hasTrash } = await detachedGetEmails(session, emailAccountId, fresh));
} else { } else {
// Legacy `jmap-state-change` payload without ids: look at the newest // Legacy `jmap-state-change` payload without ids: look at the newest
// unread inbox messages and notify the ones not shown before. The ring // unread inbox messages and notify the ones not shown before. The ring
@@ -561,7 +580,7 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
const ids = await detachedNewestUnreadInboxIds(session, LEGACY_QUERY_LIMIT); const ids = await detachedNewestUnreadInboxIds(session, LEGACY_QUERY_LIMIT);
const fresh = ids.filter((id) => !alreadyNotified.includes(id)); const fresh = ids.filter((id) => !alreadyNotified.includes(id));
if (fresh.length === 0) return; if (fresh.length === 0) return;
candidates = await detachedGetEmails(session, session.jmapAccountId, fresh); ({ emails: candidates, hasTrash } = await detachedGetEmails(session, session.jmapAccountId, fresh));
} }
const toNotify = selectNotifiableEmails(candidates, alreadyNotified); const toNotify = selectNotifiableEmails(candidates, alreadyNotified);
@@ -613,6 +632,7 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
groupTitle, groupTitle,
markReadLabel, markReadLabel,
deleteLabel, deleteLabel,
deleteNeedsUnlock: !hasTrash,
replyLabel, replyLabel,
}); });
} }