fix: enable push notifications when another user shares a folder with you #1050

This commit is contained in:
Linus Rath
2026-10-17 19:17:59 +02:00
parent cbf917372c
commit f8ef54dbea
3 changed files with 198 additions and 4 deletions
+85
View File
@@ -484,6 +484,91 @@ describe('enableWebPush delivery filter (emailPush)', () => {
});
});
// Stalwart refuses emailPush entries for accounts that only shared a folder
// with the user; only the own account and groups the user belongs to pass.
describe('enableWebPush with a folder shared by another user (#1050)', () => {
const GROUP_ACCOUNT_ID = 'acct-group';
const FORBIDDEN = 'PushSubscription/set create failed: {"type":"forbidden","description":"No access to one of the accounts in the emailPush map."}';
const pushParams = (client: IJMAPClient, call: number) =>
(client.createPushSubscription as ReturnType<typeof vi.fn>).mock.calls[call][0];
// Behaves like Stalwart: any map naming the sharing account is refused.
function refuseSharedAccount(client: IJMAPClient) {
vi.mocked(client.createPushSubscription).mockImplementation(async (params) => {
if (params.emailPush && SHARED_ACCOUNT_ID in params.emailPush) throw new Error(FORBIDDEN);
return 'push-new';
});
vi.mocked(client.updatePushSubscription).mockImplementation(async (_id, patch) =>
!(patch.emailPush && SHARED_ACCOUNT_ID in patch.emailPush));
}
it('still creates the subscription, filtered for the own account only', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([], { emailPushCapability: true });
refuseSharedAccount(client);
installFetch({});
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-new');
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code-123');
expect(client.createPushSubscription).toHaveBeenCalledTimes(2);
expect(pushParams(client, 1).emailPush).toEqual({ [ACCOUNT_ID]: EXPECTED_EMAIL_PUSH[ACCOUNT_ID] });
});
it('keeps the filter for a group account the user belongs to', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([], { emailPushCapability: true });
const base = await client.getAllMailboxes();
vi.mocked(client.getAllMailboxes).mockResolvedValue([
...base,
{ ...base[0], id: `${GROUP_ACCOUNT_ID}:mb-group-inbox`, originalId: 'mb-group-inbox', accountId: GROUP_ACCOUNT_ID },
]);
// What Stalwart's session says: create rights on owned accounts (own and
// group), none on an account that only shared a folder.
(client as { getAccountCapability?: unknown }).getAccountCapability = vi.fn(
(urn: string, accountId: string) => (urn === 'urn:ietf:params:jmap:calendars'
? { mayCreateCalendar: accountId !== SHARED_ACCOUNT_ID }
: undefined),
);
refuseSharedAccount(client);
installFetch({});
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(client.createPushSubscription).toHaveBeenCalledTimes(2);
expect(Object.keys(pushParams(client, 1).emailPush).sort()).toEqual([GROUP_ACCOUNT_ID, ACCOUNT_ID].sort());
});
it('does not retry a create that failed for another reason', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([], { emailPushCapability: true });
vi.mocked(client.createPushSubscription).mockRejectedValue(new Error('PushSubscription/set create failed: {"type":"overQuota"}'));
installFetch({});
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow(/overQuota/);
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
});
it('refreshes an existing subscription with the accounts the server accepts', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
refuseSharedAccount(client);
installFetch({});
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-old');
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(client.destroyed).toEqual([]);
expect(client.updatePushSubscription).toHaveBeenLastCalledWith(
'push-old',
expect.objectContaining({ emailPush: { [ACCOUNT_ID]: EXPECTED_EMAIL_PUSH[ACCOUNT_ID] } }),
);
});
});
// Inbox-only mode swaps the "not in Junk" mailbox condition for a positive
// "in Inbox" one, so mail a Sieve rule files into any other folder never
// reaches the push subscription.
+2
View File
@@ -88,6 +88,8 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown>;
hasAccountCapability(capability: string, accountId?: string): boolean;
/** The value of an account capability (primary account by default). */
getAccountCapability?(capability: string, accountId?: string): unknown;
/** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */
getFileNameRules?(accountId?: string): FileNameRules | null;
getMaxSizeUpload(): number;
+111 -4
View File
@@ -202,6 +202,99 @@ function sameEmailPush(
return normalizeEmailPush(a) === normalizeEmailPush(b);
}
function pickAccounts(
config: Record<string, EmailPushConfig>,
accountIds: readonly string[],
): Record<string, EmailPushConfig> {
const out: Record<string, EmailPushConfig> = {};
for (const id of accountIds) if (config[id]) out[id] = config[id];
return out;
}
// A stored filter that the fallback below narrowed down still counts as up to
// date: same entries for the accounts it covers, and it covers the primary.
// Without this every resync would retry the full map and get refused again.
function coveredByEmailPush(
stored: Record<string, EmailPushConfig> | null | undefined,
desired: Record<string, EmailPushConfig>,
primary: string,
): boolean {
if (!stored || !stored[primary]) return false;
const keys = Object.keys(stored);
if (!keys.every((id) => desired[id])) return false;
return sameEmailPush(stored, pickAccounts(desired, keys));
}
function isForbiddenError(err: unknown): boolean {
return err instanceof Error && err.message.includes('"type":"forbidden"');
}
/**
* Smaller emailPush maps to retry with after the server refused the full one.
*
* Stalwart only accepts emailPush entries for the user's own account and the
* groups they belong to. An entry for an account that merely shared a folder
* with them fails the whole PushSubscription/set with `forbidden` (#1050).
* Those accounts never push to this subscription anyway - Stalwart fans out
* to member accounts only - so leaving their entries out loses nothing. The
* session lists both kinds alike, so first keep the accounts that look owned
* (see OWNERSHIP_FLAGS), then fall back to the primary account alone. The
* native app does the same (repos/react-native emailPushFallbacks).
*/
function narrowedEmailPushConfigs(
client: IJMAPClient,
config: Record<string, EmailPushConfig>,
): Record<string, EmailPushConfig>[] {
const primary = client.getAccountId();
const others = Object.keys(config).filter((id) => id !== primary);
if (others.length === 0) return [];
const candidates: Record<string, EmailPushConfig>[] = [];
const owned = others.filter((id) => looksOwned(client, id));
if (owned.length > 0 && owned.length < others.length) {
candidates.push(pickAccounts(config, [primary, ...owned]));
}
candidates.push(pickAccounts(config, [primary]));
return candidates;
}
// Stalwart marks the accounts a user owns - their own and the groups they
// belong to - through the create flags of its per-account collection
// capabilities: true there, false on accounts shared with them by ACL.
const OWNERSHIP_FLAGS: ReadonlyArray<readonly [string, string]> = [
['urn:ietf:params:jmap:calendars', 'mayCreateCalendar'],
['urn:ietf:params:jmap:contacts', 'mayCreateAddressBook'],
['urn:ietf:params:jmap:filenode', 'mayCreateTopLevelFileNode'],
];
function looksOwned(client: IJMAPClient, accountId: string): boolean {
return OWNERSHIP_FLAGS.some(([urn, flag]) => {
const capability = client.getAccountCapability?.(urn, accountId) as Record<string, unknown> | undefined;
return capability?.[flag] === true;
});
}
async function createPushSubscriptionWithEmailPush(
client: IJMAPClient,
params: Omit<Parameters<IJMAPClient['createPushSubscription']>[0], 'emailPush'>,
emailPush: Record<string, EmailPushConfig> | null,
): Promise<string> {
try {
return await client.createPushSubscription({ ...params, ...(emailPush ? { emailPush } : {}) });
} catch (err) {
if (!emailPush || !isForbiddenError(err)) throw err;
let lastError = err;
for (const narrowed of narrowedEmailPushConfigs(client, emailPush)) {
try {
return await client.createPushSubscription({ ...params, emailPush: narrowed });
} catch (retryErr) {
if (!isForbiddenError(retryErr)) throw retryErr;
lastError = retryErr;
}
}
throw lastError;
}
}
export interface EnableWebPushParams {
client: IJMAPClient;
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
@@ -473,7 +566,22 @@ async function refreshSubscriptionExpires(
};
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush;
return await client.updatePushSubscription(sub.id, patch);
if (await client.updatePushSubscription(sub.id, patch)) return true;
if (!patch.emailPush) return false;
// The server may refuse entries for accounts that only shared a folder
// with us (#1050). Retry with the accounts it accepted last time, then
// with the same smaller maps a fresh create would fall back to.
const desired = patch.emailPush;
const candidates: Record<string, EmailPushConfig>[] = [];
if (coveredByEmailPush(sub.emailPush, desired, client.getAccountId())) {
candidates.push(pickAccounts(desired, Object.keys(sub.emailPush ?? {})));
} else {
candidates.push(...narrowedEmailPushConfigs(client, desired));
}
for (const narrowed of candidates) {
if (await client.updatePushSubscription(sub.id, { ...patch, emailPush: narrowed })) return true;
}
return false;
} catch {
return false;
}
@@ -595,13 +703,12 @@ export async function enableWebPush(
}
}
const serverAssignedId = await params.client.createPushSubscription({
const serverAssignedId = await createPushSubscriptionWithEmailPush(params.client, {
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
...(emailPush ? { emailPush } : {}),
});
}, emailPush);
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);