mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep a push for another login's group account out of the login whose own id it carries
This commit is contained in:
@@ -11,7 +11,7 @@ let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", ca
|
|||||||
/// passes through the relay or Apple.
|
/// passes through the relay or Apple.
|
||||||
///
|
///
|
||||||
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
||||||
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl}
|
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl, label}
|
||||||
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
||||||
/// <credentialsKey> the account's stored credentials
|
/// <credentialsKey> the account's stored credentials
|
||||||
/// If anything is missing or the server can't be reached, the relay's
|
/// If anything is missing or the server can't be reached, the relay's
|
||||||
@@ -66,6 +66,13 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
pushLog.error("no shared account entry for \(accountId, privacy: .public)")
|
pushLog.error("no shared account entry for \(accountId, privacy: .public)")
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
// Found by the account id alone, which a group account of another login
|
||||||
|
// can share: the relay's label (the username) has to be this entry's.
|
||||||
|
if let label = push["accountLabel"] as? String, !label.isEmpty,
|
||||||
|
let owner = account["label"] as? String, owner.lowercased() != label.lowercased() {
|
||||||
|
pushLog.error("push label does not fit the shared account for \(accountId, privacy: .public)")
|
||||||
|
return content
|
||||||
|
}
|
||||||
let emailIds = push["emailIds"] as? [String] ?? []
|
let emailIds = push["emailIds"] as? [String] ?? []
|
||||||
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
|
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
|
||||||
let strings = settings["strings"] as? [String: String] ?? [:]
|
let strings = settings["strings"] as? [String: String] ?? [:]
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ describe('shareAccountWithExtension', () => {
|
|||||||
expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API });
|
expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names the username the relay labels the account\'s pushes with', async () => {
|
||||||
|
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c' }));
|
||||||
|
await shareAccountWithExtension('ada@mail.example.org', 'c', API, 'ada');
|
||||||
|
expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API, label: 'ada' });
|
||||||
|
});
|
||||||
|
|
||||||
it('shares neither of two logins with the same JMAP account id, so the extension shows its placeholder', async () => {
|
it('shares neither of two logins with the same JMAP account id, so the extension shows its placeholder', async () => {
|
||||||
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({
|
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({
|
||||||
'ada@mail.example.org': 'c',
|
'ada@mail.example.org': 'c',
|
||||||
@@ -88,6 +94,13 @@ describe('iosTapPayload', () => {
|
|||||||
expect(await iosTapPayload(push)).toBeNull();
|
expect(await iosTapPayload(push)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not open another login's group push in the login whose own id it carries", async () => {
|
||||||
|
// Bea's group account "c" on her server; Ada's own account is "c" on hers.
|
||||||
|
const push = { kind: 'jmap-email-push', accountId: 'c', emailIds: ['e1'] };
|
||||||
|
expect(await iosTapPayload({ ...push, accountLabel: 'bea' })).toBeNull();
|
||||||
|
expect((await iosTapPayload({ ...push, accountLabel: 'ada' }))?.accountId).toBe('ada@mail.example.org');
|
||||||
|
});
|
||||||
|
|
||||||
it('does nothing for a push without a message id', async () => {
|
it('does nothing for a push without a message id', async () => {
|
||||||
expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull();
|
expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,9 +68,20 @@ describe('matchAccountsForPush', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
it('matches on the recorded JMAP account id first', () => {
|
it('matches on the recorded JMAP account id first', () => {
|
||||||
const payload = parseRelayPushData({ accountId: 'jb', accountLabel: 'alice' });
|
const payload = parseRelayPushData({ accountId: 'jb', accountLabel: 'bob', emailIds: '["m1"]' });
|
||||||
expect(matchAccountsForPush(payload, accounts, { 'bob@mail.example.com': 'jb' }, registry))
|
expect(matchAccountsForPush(payload, accounts, { 'bob@mail.example.com': 'jb' }, registry))
|
||||||
.toEqual({ accountIds: ['bob@mail.example.com'], ambiguous: false });
|
.toEqual({ accountIds: ['bob@mail.example.com'], ambiguous: false });
|
||||||
|
// A payload without a label (an older relay) goes by the id alone.
|
||||||
|
const unlabelled = parseRelayPushData({ accountId: 'jb', emailIds: '["m1"]' });
|
||||||
|
expect(matchAccountsForPush(unlabelled, accounts, { 'bob@mail.example.com': 'jb' }, registry))
|
||||||
|
.toEqual({ accountIds: ['bob@mail.example.com'], ambiguous: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not take another login's group push for the one whose own id it carries", () => {
|
||||||
|
// Bob's group account "c" on his server; Alice's own account is "c" on hers.
|
||||||
|
const payload = parseRelayPushData({ accountId: 'c', accountLabel: 'bob', emailIds: '["m1"]' });
|
||||||
|
expect(matchAccountsForPush(payload, accounts, { 'alice@mail.example.com': 'c', 'bob@mail.example.com': 'b' }, registry))
|
||||||
|
.toMatchObject({ ambiguous: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the relay accountLabel (username)', () => {
|
it('falls back to the relay accountLabel (username)', () => {
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ export interface IosPushAccount {
|
|||||||
credentialsKey: string;
|
credentialsKey: string;
|
||||||
/** The JMAP API URL as the app uses it (rewritten to the server's host). */
|
/** The JMAP API URL as the app uses it (rewritten to the server's host). */
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
|
/**
|
||||||
|
* The username the account registered with the relay, which the relay
|
||||||
|
* sends back as the push's accountLabel. The extension leaves a push whose
|
||||||
|
* label is another's as the placeholder: it may be for another login's
|
||||||
|
* group account that has this account's id.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
|
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ export interface PushAccountMatch {
|
|||||||
* same few), and so do message ids. A push naming messages that still fits
|
* same few), and so do message ids. A push naming messages that still fits
|
||||||
* more than one login is `ambiguous`: each login would fetch those ids from
|
* more than one login is `ambiguous`: each login would fetch those ids from
|
||||||
* its own server and could show another server's message, so the caller
|
* its own server and could show another server's message, so the caller
|
||||||
* shows a plain notification instead. A push without ids makes each login
|
* shows a plain notification instead. So is one whose label fits none of the
|
||||||
|
* logins with its JMAP account id. A push without ids makes each login
|
||||||
* look at its own newest mail, which is never another login's.
|
* look at its own newest mail, which is never another login's.
|
||||||
*/
|
*/
|
||||||
export function matchAccountsForPush(
|
export function matchAccountsForPush(
|
||||||
@@ -237,17 +238,22 @@ export function matchAccountsForPush(
|
|||||||
? ids.filter((id) => accountMatchesPushLabel(id, payload.accountLabel!, registry.find((a) => a.id === id)?.username))
|
? ids.filter((id) => accountMatchesPushLabel(id, payload.accountLabel!, registry.find((a) => a.id === id)?.username))
|
||||||
: []);
|
: []);
|
||||||
let accountIds = pushAccountIds;
|
let accountIds = pushAccountIds;
|
||||||
|
let unsure = false;
|
||||||
const byJmapId = payload.jmapAccountId
|
const byJmapId = payload.jmapAccountId
|
||||||
? pushAccountIds.filter((id) => jmapAccountIds[id] === payload.jmapAccountId)
|
? pushAccountIds.filter((id) => jmapAccountIds[id] === payload.jmapAccountId)
|
||||||
: [];
|
: [];
|
||||||
if (byJmapId.length > 0) {
|
if (byJmapId.length > 0) {
|
||||||
const narrowed = byJmapId.length > 1 ? byLabel(byJmapId) : [];
|
// Also a single match has to fit the label: group accounts are subscribed
|
||||||
|
// too, and their ids come from the same short pool, so a push for another
|
||||||
|
// login's group can carry this login's own id.
|
||||||
|
const narrowed = byLabel(byJmapId);
|
||||||
accountIds = narrowed.length > 0 ? narrowed : byJmapId;
|
accountIds = narrowed.length > 0 ? narrowed : byJmapId;
|
||||||
|
unsure = !!payload.accountLabel && narrowed.length === 0;
|
||||||
} else {
|
} else {
|
||||||
const labelled = byLabel(pushAccountIds);
|
const labelled = byLabel(pushAccountIds);
|
||||||
if (labelled.length > 0) accountIds = labelled;
|
if (labelled.length > 0) accountIds = labelled;
|
||||||
}
|
}
|
||||||
return { accountIds, ambiguous: accountIds.length > 1 && payload.emailIds.length > 0 };
|
return { accountIds, ambiguous: (unsure || accountIds.length > 1) && payload.emailIds.length > 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readNotifiedIds(accountId: string): Promise<string[]> {
|
async function readNotifiedIds(accountId: string): Promise<string[]> {
|
||||||
|
|||||||
@@ -1010,12 +1010,15 @@ async function registerWithRelayApns(params: {
|
|||||||
* another login with push has the same id, neither gets an entry: the
|
* another login with push has the same id, neither gets an entry: the
|
||||||
* placeholder says there is new mail, where one login's credentials would
|
* placeholder says there is new mail, where one login's credentials would
|
||||||
* fetch and show a message of the other's server. Run again for the login
|
* fetch and show a message of the other's server. Run again for the login
|
||||||
* that is left once the other is gone (the next resync does).
|
* that is left once the other is gone (the next resync does). The entry also
|
||||||
|
* names the username, which the extension compares with the push's label: a
|
||||||
|
* group account of another login can have this login's id.
|
||||||
*/
|
*/
|
||||||
export async function shareAccountWithExtension(
|
export async function shareAccountWithExtension(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
jmapAccountId: string | null,
|
jmapAccountId: string | null,
|
||||||
apiUrl: string | undefined = jmapClient.currentSession?.apiUrl,
|
apiUrl: string | undefined = jmapClient.currentSession?.apiUrl,
|
||||||
|
username: string | null | undefined = jmapClient.username,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (Platform.OS !== 'ios' || !jmapAccountId) return;
|
if (Platform.OS !== 'ios' || !jmapAccountId) return;
|
||||||
const byLocal = await readPushJmapAccountIds();
|
const byLocal = await readPushJmapAccountIds();
|
||||||
@@ -1024,7 +1027,11 @@ export async function shareAccountWithExtension(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!apiUrl) return;
|
if (!apiUrl) return;
|
||||||
await writeIosPushAccount(jmapAccountId, { credentialsKey: credentialsKey(accountId), apiUrl }).catch(() => undefined);
|
await writeIosPushAccount(jmapAccountId, {
|
||||||
|
credentialsKey: credentialsKey(accountId),
|
||||||
|
apiUrl,
|
||||||
|
...(username ? { label: username } : {}),
|
||||||
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1943,7 +1950,7 @@ async function renewPushAccount(accountId: string, relayBaseUrl: string | null):
|
|||||||
} catch {
|
} catch {
|
||||||
jmapAccountId = null;
|
jmapAccountId = null;
|
||||||
}
|
}
|
||||||
await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl);
|
await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl, client.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2216,11 +2223,13 @@ export async function iosTapPayload(data: unknown): Promise<NotificationTapPaylo
|
|||||||
// setup recorded this JMAP account id. Ids repeat across servers: with
|
// setup recorded this JMAP account id. Ids repeat across servers: with
|
||||||
// more than one such login the relay's label (the username) picks one, and
|
// more than one such login the relay's label (the username) picks one, and
|
||||||
// when it can't, the app just opens rather than show another login's
|
// when it can't, the app just opens rather than show another login's
|
||||||
// message on a guess.
|
// message on a guess. A single login has to fit the label too: the push
|
||||||
|
// may be for another login's group account, whose id comes from the same
|
||||||
|
// short pool.
|
||||||
const byLocal = await readPushJmapAccountIds();
|
const byLocal = await readPushJmapAccountIds();
|
||||||
let candidates = Object.keys(byLocal).filter((local) => byLocal[local] === jmapAccountId);
|
let candidates = Object.keys(byLocal).filter((local) => byLocal[local] === jmapAccountId);
|
||||||
if (candidates.length > 1) {
|
const label = typeof d.accountLabel === 'string' ? d.accountLabel : '';
|
||||||
const label = typeof d.accountLabel === 'string' ? d.accountLabel : '';
|
if (candidates.length > 1 || (label && candidates.length === 1)) {
|
||||||
candidates = label ? candidates.filter((local) => accountMatchesPushLabel(local, label)) : [];
|
candidates = label ? candidates.filter((local) => accountMatchesPushLabel(local, label)) : [];
|
||||||
if (candidates.length !== 1) return null;
|
if (candidates.length !== 1) return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user