mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: enable push notifications when another user shares a folder with you #1050
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user