fix: subscribe to EmailDelivery only, use the relay's email ids and route pushes per account (#45)

This commit is contained in:
Linus Rath
2026-08-29 17:53:53 +02:00
parent 7f956d60f7
commit 00faceb25f
9 changed files with 1066 additions and 187 deletions
+15 -4
View File
@@ -44,6 +44,7 @@ import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
@@ -101,6 +102,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<View style={{ flex: 1, backgroundColor: c.background }}>
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -294,12 +296,21 @@ export default function App() {
});
}, [haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
// Foreground FCM messages: the Kotlin service skips the headless task while
// the app is visible, so feed the relay's StateChange straight into the
// stores. SSE normally beats it, but this covers the window where the SSE
// socket is down and the poll fallback has not fired yet.
React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => {
console.log('[push] fcm message', payload.title);
const raw = payload?.data?.changed;
if (!raw) return;
try {
const changed = JSON.parse(raw) as Record<string, Record<string, string>>;
if (!changed || typeof changed !== 'object') return;
void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed });
} catch {
// malformed payload - ignore
}
});
return unsubscribe;
}, []);
@@ -66,6 +66,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey")
?: accountId?.let { "bulwark-mail:$it" }
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
?: accountId ?: "Bulwark Mail"
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -74,8 +78,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject, accountId,
emailId, threadId, subject, accountId, groupKey,
)
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
promise.resolve(null)
}
}
@@ -96,6 +101,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
threadId: String?,
subject: String?,
accountId: String?,
groupKey: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -122,11 +128,64 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pending)
if (groupKey != null) {
builder.setGroup(groupKey)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notificationId.hashCode(), builder.build())
}
// One summary per account group so several deliveries collapse into a
// single "N new messages" entry (Android renders the "+N more" itself
// from the children). Tapping the summary opens the app on the inbox;
// the per-message children carry the deep link.
private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) {
val ctx = reactApplicationContext
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val children = manager.activeNotifications.filter {
it.groupKey?.endsWith(groupKey) == true &&
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
}
val count = maxOf(children.size, 1)
val inbox = NotificationCompat.InboxStyle()
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
val extras = sbn.notification.extras
val line = listOfNotNull(
extras.getCharSequence(android.app.Notification.EXTRA_TITLE),
extras.getCharSequence(android.app.Notification.EXTRA_TEXT),
).joinToString(" ")
if (line.isNotBlank()) inbox.addLine(line)
}
inbox.setBigContentTitle(groupTitle)
inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages")
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
groupKey.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_email)
.setContentTitle(groupTitle)
.setContentText(if (count == 1) "1 new message" else "$count new messages")
.setStyle(inbox)
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
.setGroup(groupKey)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setAutoCancel(true)
.setContentIntent(pending)
.build()
manager.notify(groupKey, groupKey.hashCode(), summary)
}
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
val size = 192
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+6 -2
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { PushSubscription, StateChange } from './types';
import type { EmailPushConfig, PushSubscription, StateChange } from './types';
export type StateChangeHandler = (change: StateChange) => void;
@@ -27,6 +27,9 @@ export async function createPushSubscription(params: {
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
// want and accept whatever Stalwart returns.
expires?: string;
// draft-ietf-jmap-emailpush delivery filter, only when the server advertises
// urn:ietf:params:jmap:emailpush (see serverSupportsEmailPush).
emailPush?: Record<string, EmailPushConfig>;
}): Promise<string> {
const created: Record<string, unknown> = {
deviceClientId: params.deviceClientId,
@@ -34,6 +37,7 @@ export async function createPushSubscription(params: {
types: params.types,
};
if (params.expires) created.expires = params.expires;
if (params.emailPush) created.emailPush = params.emailPush;
const res = await jmapClient.request(
[
@@ -63,7 +67,7 @@ export async function createPushSubscription(params: {
*/
export async function updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[] },
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
): Promise<boolean> {
const res = await jmapClient.request(
[
+3 -1
View File
@@ -573,7 +573,9 @@ export interface FileNode {
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
// Last modification time (UTCDate). Older builds asked for a non-existent
// `updated` property, which the server silently left undefined (#700).
modified?: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// principals capability and the properties are requested explicitly. A node
// is shared-out when `shareWith` has entries; `myRights` describes what the
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'jmap-primary', loadAccount: vi.fn(async () => true) },
}));
vi.mock('../../api/email', () => ({
getEmails: vi.fn(async () => []),
getMailboxes: vi.fn(async () => []),
queryEmails: vi.fn(async () => ({ ids: [] })),
}));
import {
matchAccountsForPush,
parseRelayPushData,
selectNotifiableEmails,
} from '../push-background-task';
import type { Email } from '../../api/types';
describe('parseRelayPushData', () => {
it('decodes the relay FCM data payload (all values are strings)', () => {
const parsed = parseRelayPushData({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'a1',
emailIds: JSON.stringify(['m1', 'm2']),
changed: JSON.stringify({ a1: { EmailDelivery: 's1' } }),
});
expect(parsed).toEqual({
kind: 'jmap-email-push',
accountLabel: 'alice',
jmapAccountId: 'a1',
emailIds: ['m1', 'm2'],
changed: { a1: { EmailDelivery: 's1' } },
});
});
it('falls back to the first key of `changed` for the account id', () => {
const parsed = parseRelayPushData({
kind: 'jmap-state-change',
emailIds: '[]',
changed: JSON.stringify({ a9: { Email: 'x' } }),
});
expect(parsed.jmapAccountId).toBe('a9');
expect(parsed.emailIds).toEqual([]);
});
it('tolerates garbage', () => {
expect(parseRelayPushData(null).emailIds).toEqual([]);
expect(parseRelayPushData({ emailIds: '{not json' }).emailIds).toEqual([]);
expect(parseRelayPushData({ kind: 'weird' }).kind).toBeNull();
});
});
describe('matchAccountsForPush', () => {
const accounts = ['alice@mail.example.com', 'bob@mail.example.com'];
const registry = [
{ id: 'alice@mail.example.com', username: 'alice' },
{ id: 'bob@mail.example.com', username: 'bob' },
];
it('matches on the recorded JMAP account id first', () => {
const payload = parseRelayPushData({ accountId: 'jb', accountLabel: 'alice' });
expect(matchAccountsForPush(payload, accounts, { 'bob@mail.example.com': 'jb' }, registry))
.toEqual(['bob@mail.example.com']);
});
it('falls back to the relay accountLabel (username)', () => {
const payload = parseRelayPushData({ accountId: 'unknown', accountLabel: 'alice' });
expect(matchAccountsForPush(payload, accounts, {}, registry)).toEqual(['alice@mail.example.com']);
});
it('checks every account when nothing matches', () => {
const payload = parseRelayPushData({ accountId: 'unknown', accountLabel: 'carol' });
expect(matchAccountsForPush(payload, accounts, {}, registry)).toEqual(accounts);
});
});
describe('selectNotifiableEmails', () => {
const email = (id: string, keywords: Record<string, boolean> = {}): Email =>
({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email);
it('drops read, junk and already-notified messages', () => {
const out = selectNotifiableEmails(
[email('a'), email('b', { $seen: true }), email('c', { $junk: true }), email('d')],
['d'],
);
expect(out.map((e) => e.id)).toEqual(['a']);
});
});
+166 -2
View File
@@ -24,7 +24,20 @@ vi.mock('react-native', () => {
});
vi.mock('../../api/jmap-client', () => ({
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
jmapClient: {
username: 'user@example.com',
serverUrl: 'https://mail.example.com',
accountId: 'jmap-primary',
currentSession: { capabilities: { 'urn:ietf:params:jmap:core': {} } },
},
}));
vi.mock('../../api/email', () => ({
getMailboxes: vi.fn(async () => [
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
]),
getSharedMailboxes: vi.fn(async () => []),
}));
vi.mock('../../api/push', () => ({
@@ -36,12 +49,22 @@ vi.mock('../../api/push', () => ({
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
import {
setupPushNotifications,
deviceClientIdKey,
isValidRelayUrl,
readPushJmapAccountIds,
PushSetupError,
teardownPushNotificationsForAccount,
} from '../push-notifications';
import {
listPushSubscriptions,
createPushSubscription,
destroyPushSubscription,
updatePushSubscription,
} from '../../api/push';
import { jmapClient } from '../../api/jmap-client';
import { NativeModules } from 'react-native';
import { generateAccountId } from '../account-utils';
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
@@ -76,6 +99,8 @@ function installFetch(states: Record<string, RelayState>): void {
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
const updateMock = updatePushSubscription as ReturnType<typeof vi.fn>;
const SUB_KEY = 'push:subscriptionId:v2:' + ACCOUNT_ID;
function sub(id: string, deviceClientId: string) {
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
@@ -147,3 +172,142 @@ describe('setupPushNotifications leftover reaping', () => {
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
});
});
describe('setupPushNotifications subscription shape', () => {
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
(jmapClient as { currentSession: unknown }).currentSession = {
capabilities: { 'urn:ietf:params:jmap:core': {} },
};
listMock.mockResolvedValue([]);
updateMock.mockResolvedValue(true);
installFetch({});
});
it('subscribes to EmailDelivery only', async () => {
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(createMock).toHaveBeenCalledTimes(1);
expect(createMock.mock.calls[0][0].types).toEqual(['EmailDelivery']);
expect(createMock.mock.calls[0][0].emailPush).toBeUndefined();
});
it('records the JMAP account id so pushes can be routed per account', async () => {
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(await readPushJmapAccountIds()).toEqual({ [ACCOUNT_ID]: 'jmap-primary' });
});
it('adds a junk-excluding emailPush filter when the server advertises emailpush', async () => {
(jmapClient as { currentSession: unknown }).currentSession = {
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
};
await setupPushNotifications({ relayBaseUrl: RELAY });
const emailPush = createMock.mock.calls[0][0].emailPush;
expect(emailPush['jmap-primary']).toEqual({
filter: {
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['junk'] }],
},
properties: ['id', 'threadId'],
urgency: 'high',
});
});
it('patches types on an existing subscription that still listens to Email/Mailbox', async () => {
await AsyncStorage.setItem(SUB_KEY, 'existing');
listMock.mockResolvedValue([
{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['Email', 'EmailDelivery', 'Mailbox'] },
]);
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
expect(result.subscriptionId).toBe('existing');
expect(createMock).not.toHaveBeenCalled();
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock.mock.calls[0][1].types).toEqual(['EmailDelivery']);
});
it('leaves a healthy subscription alone', async () => {
await AsyncStorage.setItem(SUB_KEY, 'existing');
listMock.mockResolvedValue([
{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['EmailDelivery'] },
]);
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(updateMock).not.toHaveBeenCalled();
expect(createMock).not.toHaveBeenCalled();
});
it('forceRecreate destroys the recorded subscription and creates a new one', async () => {
await AsyncStorage.setItem(SUB_KEY, 'existing');
listMock.mockResolvedValue([
{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['EmailDelivery'] },
]);
const result = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true });
expect(destroyMock.mock.calls.map((c) => c[0])).toContain('existing');
expect(createMock).toHaveBeenCalledTimes(1);
expect(result.subscriptionId).toBe('new-server-id');
});
it('rejects plain-http relay URLs', async () => {
await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' });
expect(createMock).not.toHaveBeenCalled();
});
it('surfaces the relay error body and phase when registration fails', async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 400,
text: async () => JSON.stringify({ error: 'Invalid fcmToken' }),
})) as unknown as typeof fetch;
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
expect(err).toBeInstanceOf(PushSetupError);
expect(err.phase).toBe('relay');
expect(err.message).toContain('Invalid fcmToken');
});
it('tags a Firebase token failure with the token phase', async () => {
const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
native.getToken.mockRejectedValueOnce(new Error('SERVICE_NOT_AVAILABLE'));
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
expect(err.phase).toBe('token');
expect(err.message).toContain('SERVICE_NOT_AVAILABLE');
});
});
describe('teardownPushNotificationsForAccount', () => {
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
installFetch({});
});
it('destroys every server subscription for this device but keeps the FCM token', async () => {
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
await AsyncStorage.setItem(SUB_KEY, 'recorded');
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
listMock.mockResolvedValue([
sub('recorded', OUR_DCID),
sub('untracked', OUR_DCID),
sub('foreign', 'ffffffffffffffffffffffffffffffff'),
]);
await teardownPushNotificationsForAccount(ACCOUNT_ID);
const destroyed = destroyMock.mock.calls.map((c) => c[0]);
expect(destroyed).toContain('recorded');
expect(destroyed).toContain('untracked');
expect(destroyed).not.toContain('foreign');
const native = (NativeModules as { BulwarkFcm: { deleteToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
expect(native.deleteToken).not.toHaveBeenCalled();
expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull();
});
});
describe('isValidRelayUrl', () => {
it('requires https except for loopback development hosts', () => {
expect(isValidRelayUrl('https://relay.example.com')).toBe(true);
expect(isValidRelayUrl('https://relay.example.com/')).toBe(true);
expect(isValidRelayUrl('http://relay.example.com')).toBe(false);
expect(isValidRelayUrl('http://localhost:3003')).toBe(true);
expect(isValidRelayUrl('http://10.0.2.2:3003')).toBe(true);
expect(isValidRelayUrl('relay.example.com')).toBe(false);
expect(isValidRelayUrl('')).toBe(false);
});
});
+224 -79
View File
@@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules } from 'react-native';
import { jmapClient } from '../api/jmap-client';
import { getEmails, getMailboxes, queryEmails } from '../api/email';
import type { Email } from '../api/types';
import {
generateEmailAvatarColor,
getEmailInitials,
@@ -9,10 +10,11 @@ import {
getFaviconUrl,
} from './avatar-utils';
import {
deviceClientIdKey,
lastNotifiedKey,
migrateLegacyPushKeys,
notifiedIdsKey,
readPushAccountIds,
readPushJmapAccountIds,
} from './push-notifications';
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
@@ -22,9 +24,17 @@ const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
// Mirrors the persist name in `stores/account-store.ts`. We use it to find
// the user's active account at the end of the headless task so we can leave
// the jmapClient singleton in a state consistent with what the UI expects
// when the app resumes.
// when the app resumes, and to map the relay's `accountLabel` (= username)
// back to a local account when the JMAP account id is not known yet.
const ACCOUNT_REGISTRY_KEY = 'account-registry';
// How many recently-notified ids to remember per account. Enough to cover a
// burst of deliveries without growing the AsyncStorage record unbounded.
const NOTIFIED_IDS_LIMIT = 200;
// Legacy state-change payloads carry no ids; look at this many newest unread
// inbox messages and notify the ones we have not shown yet.
const LEGACY_QUERY_LIMIT = 5;
interface PushPersistedSettings {
emailNotificationsEnabled?: boolean;
}
@@ -51,54 +61,145 @@ interface ShowNotificationOptions {
threadId: string;
subject?: string;
accountId: string;
// Android notification group: one per account so the tray bundles
// several deliveries under a "+N more" summary instead of stacking them.
groupKey: string;
groupTitle: string;
}
interface BulwarkFcmNative {
showNotification(opts: ShowNotificationOptions): Promise<void>;
}
// The relay forwards each JMAP push to FCM tagged with the deviceClientId it
// received the push on (the URL slot from createPushSubscription). The exact
// key the relay uses isn't part of any client-controlled contract, so rather
// than guessing a field name we scan all string values in the data payload
// and look for one that matches a deviceClientId we've registered locally.
// Returns null if nothing matches (e.g. legacy relay payload format) so the
// caller falls back to iterating every known account.
async function identifyAccountFromFcmData(
data: unknown,
accountIds: string[],
): Promise<string | null> {
if (!data || typeof data !== 'object') return null;
const reverseMap = new Map<string, string>();
for (const accountId of accountIds) {
const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
if (dcid) reverseMap.set(dcid, accountId);
}
if (reverseMap.size === 0) return null;
for (const value of Object.values(data as Record<string, unknown>)) {
if (typeof value === 'string') {
const match = reverseMap.get(value);
if (match) return match;
}
}
return null;
/**
* What the relay puts in the FCM data payload (repos/relay/src/fcm.ts). All
* values are strings; `emailIds` and `changed` are JSON-encoded.
*/
export interface RelayPushData {
kind: 'jmap-email-push' | 'jmap-state-change' | null;
accountLabel: string | null;
// JMAP primary account id the push was generated for.
jmapAccountId: string | null;
emailIds: string[];
changed: Record<string, Record<string, string>> | null;
}
// Read the persisted active account directly from AsyncStorage (Zustand's
// persist middleware stores the JSON-serialised state under its `name`).
async function readActiveAccountId(): Promise<string | null> {
export function parseRelayPushData(data: unknown): RelayPushData {
const out: RelayPushData = {
kind: null,
accountLabel: null,
jmapAccountId: null,
emailIds: [],
changed: null,
};
if (!data || typeof data !== 'object') return out;
const d = data as Record<string, unknown>;
if (d.kind === 'jmap-email-push' || d.kind === 'jmap-state-change') out.kind = d.kind;
if (typeof d.accountLabel === 'string' && d.accountLabel) out.accountLabel = d.accountLabel;
if (typeof d.accountId === 'string' && d.accountId) out.jmapAccountId = d.accountId;
if (typeof d.emailIds === 'string') {
try {
const parsed = JSON.parse(d.emailIds) as unknown;
if (Array.isArray(parsed)) {
out.emailIds = parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
}
} catch {
// malformed - treat as no ids
}
} else if (Array.isArray(d.emailIds)) {
out.emailIds = d.emailIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
}
if (typeof d.changed === 'string') {
try {
const parsed = JSON.parse(d.changed) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
out.changed = parsed as Record<string, Record<string, string>>;
}
} catch {
// ignore
}
} else if (d.changed && typeof d.changed === 'object') {
out.changed = d.changed as Record<string, Record<string, string>>;
}
// Older relays only sent `changed`; the account id is its first key.
if (!out.jmapAccountId && out.changed) {
out.jmapAccountId = Object.keys(out.changed)[0] ?? null;
}
return out;
}
interface RegistryAccount {
id: string;
username?: string;
}
async function readAccountRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> {
try {
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
return parsed.state?.activeAccountId ?? null;
if (!raw) return { accounts: [], activeAccountId: null };
const parsed = JSON.parse(raw) as {
state?: { accounts?: RegistryAccount[]; activeAccountId?: string | null };
};
return {
accounts: Array.isArray(parsed.state?.accounts) ? parsed.state!.accounts! : [],
activeAccountId: parsed.state?.activeAccountId ?? null,
};
} catch {
return null;
return { accounts: [], activeAccountId: null };
}
}
/**
* Pick the local account(s) a relay payload belongs to. Primary key is the
* JMAP account id recorded at setup; the relay's `accountLabel` (= username)
* is a weaker fallback. When neither matches - a payload from a build that
* predates the id map - every registered account is checked.
*/
export function matchAccountsForPush(
payload: RelayPushData,
pushAccountIds: string[],
jmapAccountIds: Record<string, string>,
registry: RegistryAccount[],
): string[] {
if (payload.jmapAccountId) {
const byJmapId = pushAccountIds.filter((id) => jmapAccountIds[id] === payload.jmapAccountId);
if (byJmapId.length > 0) return byJmapId;
}
if (payload.accountLabel) {
const label = payload.accountLabel.toLowerCase();
const byLabel = pushAccountIds.filter((id) => {
const entry = registry.find((a) => a.id === id);
const username = entry?.username?.toLowerCase();
return username === label || id.toLowerCase().startsWith(`${label}@`);
});
if (byLabel.length > 0) return byLabel;
}
return pushAccountIds;
}
async function readNotifiedIds(accountId: string): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem(notifiedIdsKey(accountId));
if (raw) {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) return parsed.filter((s): s is string => typeof s === 'string');
}
} catch {
// fall through
}
// Pre-ring builds stored a single id; honour it so the upgrade does not
// re-notify the newest message.
const legacy = await AsyncStorage.getItem(lastNotifiedKey(accountId));
return legacy ? [legacy] : [];
}
async function rememberNotifiedIds(accountId: string, ids: string[]): Promise<void> {
const current = await readNotifiedIds(accountId);
const next = [...ids, ...current.filter((id) => !ids.includes(id))].slice(0, NOTIFIED_IDS_LIMIT);
await AsyncStorage.setItem(notifiedIdsKey(accountId), JSON.stringify(next));
if (ids.length > 0) await AsyncStorage.setItem(lastNotifiedKey(accountId), ids[0]);
}
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
// short-lived headless JS runtime - keep it fast, catch all errors, and always
// resolve so the native service can release its wake lock.
@@ -112,14 +213,16 @@ export async function pushBackgroundTask(data: unknown): Promise<void> {
const accountIds = await readPushAccountIds();
if (accountIds.length === 0) return;
activeAccountId = await readActiveAccountId();
const registry = await readAccountRegistry();
activeAccountId = registry.activeAccountId;
const matched = await identifyAccountFromFcmData(data, accountIds);
const accountsToCheck = matched ? [matched] : accountIds;
const payload = parseRelayPushData(data);
const jmapAccountIds = await readPushJmapAccountIds();
const accountsToCheck = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry.accounts);
for (const accountId of accountsToCheck) {
try {
await processAccountForPush(accountId);
await processAccountForPush(accountId, payload);
} catch (err) {
console.warn(
'[push] background check failed for account',
@@ -144,55 +247,97 @@ export async function pushBackgroundTask(data: unknown): Promise<void> {
}
}
async function processAccountForPush(accountId: string): Promise<void> {
/** Messages worth a notification: unread, not junk, not shown before. */
export function selectNotifiableEmails(emails: Email[], alreadyNotified: readonly string[]): Email[] {
const seen = new Set(alreadyNotified);
return emails.filter((email) => {
if (!email?.id || seen.has(email.id)) return false;
const keywords = email.keywords ?? {};
if (keywords.$seen || keywords.$junk) return false;
return true;
});
}
async function processAccountForPush(accountId: string, payload: RelayPushData): Promise<void> {
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
const alreadyNotified = await readNotifiedIds(accountId);
let candidates: Email[];
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
if (payload.emailIds.length > 0) {
// EmailPush (or a relay that forwards ids): fetch exactly the delivered
// messages. The push may concern a shared account the user has access
// to, in which case the ids live under that JMAP account.
const fresh = payload.emailIds.filter((id) => !alreadyNotified.includes(id));
if (fresh.length === 0) return;
let accountOverride: string | undefined;
try {
if (payload.jmapAccountId && payload.jmapAccountId !== jmapClient.accountId) {
accountOverride = payload.jmapAccountId;
}
} catch {
accountOverride = undefined;
}
candidates = await getEmails(fresh, accountOverride);
} else {
// Legacy `jmap-state-change` payload without ids: look at the newest
// unread inbox messages and notify the ones not shown before. The ring
// of notified ids (rather than a single "last" id) is what keeps a
// message read elsewhere from surfacing the next older one.
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: LEGACY_QUERY_LIMIT,
});
const fresh = ids.filter((id) => !alreadyNotified.includes(id));
if (fresh.length === 0) return;
candidates = await getEmails(fresh);
}
const emailId = ids[0];
const lastKey = lastNotifiedKey(accountId);
const previouslyNotified = await AsyncStorage.getItem(lastKey);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const toNotify = selectNotifiableEmails(candidates, alreadyNotified);
if (toNotify.length === 0) return;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
});
const groupKey = `bulwark-mail:${accountId}`;
const groupTitle = payload.accountLabel ?? accountId.split('@')[0] ?? accountId;
await AsyncStorage.setItem(lastKey, emailId);
// Oldest first so the newest ends up on top of the tray.
const ordered = [...toNotify].sort(
(a, b) => new Date(a.receivedAt ?? 0).getTime() - new Date(b.receivedAt ?? 0).getTime(),
);
for (const email of ordered) {
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
await native.showNotification({
notificationId: `mail:${email.id}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
groupKey,
groupTitle,
});
}
await rememberNotifiedIds(accountId, ordered.map((e) => e.id).reverse());
}
function hslToHex(hsl: string): string {
+500 -98
View File
@@ -7,7 +7,9 @@ import {
updatePushSubscription,
verifyPushSubscription,
} from '../api/push';
import { getMailboxes, getSharedMailboxes } from '../api/email';
import { jmapClient } from '../api/jmap-client';
import type { EmailPushConfig, Mailbox } from '../api/types';
import { generateAccountId } from './account-utils';
// Persist identifiers across launches so we reuse the same JMAP subscription
@@ -15,9 +17,17 @@ import { generateAccountId } from './account-utils';
// relay can distinguish per-account pushes via the URL slot it forwards.
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
// Local account id (username@host) → JMAP primary account id. The relay tags
// every forwarded push with the JMAP account id it came from, which is the
// only reliable key for routing a payload to the right local account.
const PUSH_JMAP_ACCOUNT_IDS_KEY = 'push:jmapAccountIds:v1';
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
// Ring of recently notified message ids per account (replaces the single
// lastNotified id, which could not tell "already shown" from "older mail").
const NOTIFIED_IDS_PREFIX = 'push:notifiedIds:v1:';
const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:';
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
// setupPushNotifications / pushBackgroundTask call, then deleted.
@@ -38,6 +48,14 @@ export function lastNotifiedKey(accountId: string): string {
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
}
export function notifiedIdsKey(accountId: string): string {
return NOTIFIED_IDS_PREFIX + accountId;
}
function promptDismissedKey(accountId: string): string {
return PROMPT_DISMISSED_PREFIX + accountId;
}
export async function readPushAccountIds(): Promise<string[]> {
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
if (!raw) return [];
@@ -59,6 +77,34 @@ async function writePushAccountIds(ids: string[]): Promise<void> {
}
}
/** Local account id → JMAP primary account id, for every account with push. */
export async function readPushJmapAccountIds(): Promise<Record<string, string>> {
const raw = await AsyncStorage.getItem(PUSH_JMAP_ACCOUNT_IDS_KEY);
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') out[k] = v;
}
return out;
} catch {
return {};
}
}
async function writePushJmapAccountId(accountId: string, jmapAccountId: string | null): Promise<void> {
const map = await readPushJmapAccountIds();
if (jmapAccountId) map[accountId] = jmapAccountId;
else delete map[accountId];
if (Object.keys(map).length === 0) {
await AsyncStorage.removeItem(PUSH_JMAP_ACCOUNT_IDS_KEY);
} else {
await AsyncStorage.setItem(PUSH_JMAP_ACCOUNT_IDS_KEY, JSON.stringify(map));
}
}
// One-shot migration from the pre-multi-account schema. If the legacy
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
// setup: reuse the legacy deviceClientId and JMAP subscription id under
@@ -99,9 +145,39 @@ export async function migrateLegacyPushKeys(): Promise<void> {
// Power users can override this from the settings screen.
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
// Types the mobile app wants StateChange pings for. Submission is excluded -
// outgoing mail state changes don't belong in a user-visible push.
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
/**
* A relay must be reachable over TLS: the registration carries the FCM token
* and the JMAP server posts push bodies to it. Plain http is only allowed for
* loopback / the Android emulator host so a local relay can be developed
* against.
*/
export function isValidRelayUrl(value: string): boolean {
const trimmed = value.trim();
const m = /^(https?):\/\/([^/?#:]+)(?::\d{1,5})?(?:[/?#].*)?$/i.exec(trimmed);
if (!m) return false;
if (m[1].toLowerCase() === 'https') return true;
const host = m[2].toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '10.0.2.2';
}
// Only `EmailDelivery` state-changes when new mail is actually delivered.
// `Email` fires for any mutation (sending, drafting, moving, marking read,
// deleting) and `Mailbox` fires for mailbox edits - both produced spurious
// system notifications, so we keep them out of the push subscription.
// In-app sync uses the separate SSE channel and is unaffected.
export const PUSH_TYPES = ['EmailDelivery'] as const;
// draft-ietf-jmap-emailpush (Stalwart >= 0.16.16). `EmailDelivery` alone
// still fires for every ingested message - including spam the server files
// straight into Junk - because the server can't know which folders a client
// cares about. With `emailPush` the server evaluates a per-account filter
// against each new message before pushing and stays silent on a miss, so
// junk-filed mail never wakes the device. Older servers don't advertise the
// capability and get the plain EmailDelivery subscription as before.
export const EMAIL_PUSH_CAPABILITY = 'urn:ietf:params:jmap:emailpush';
// Only ids: the relay stays content-blind and the headless task dedupes on them.
const EMAIL_PUSH_PROPERTIES = ['id', 'threadId'];
// Maximum expires we ask the server for. Stalwart (and other JMAP servers)
// may clamp this down; whatever they return is what we get. Without this,
@@ -117,6 +193,79 @@ function expiresFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
export function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean {
if (!a || a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((t, i) => t === sortedB[i]);
}
export function serverSupportsEmailPush(): boolean {
try {
return EMAIL_PUSH_CAPABILITY in (jmapClient.currentSession?.capabilities ?? {});
} catch {
return false;
}
}
/**
* The delivery filter we want on every account the subscription fans out to:
* skip anything the spam filter tagged `$junk` and anything that lives only in
* a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are
* ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades
* to keyword-only filtering rather than letting everything through.
*/
export async function buildEmailPushConfig(): Promise<Record<string, EmailPushConfig>> {
const primary = jmapClient.accountId;
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const own = await getMailboxes().catch(() => [] as Mailbox[]);
const shared = await getSharedMailboxes().catch(() => [] as Mailbox[]);
for (const m of [...own, ...shared]) {
const accountId = m.accountId || primary;
const junk = junkByAccount.get(accountId) ?? [];
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
// the server only knows the original.
if (m.role === 'junk') junk.push(m.originalId ?? m.id);
junkByAccount.set(accountId, junk);
}
const config: Record<string, EmailPushConfig> = {};
for (const [accountId, junkIds] of junkByAccount) {
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
if (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() });
config[accountId] = {
// Always the operator form: that's how the server echoes it back, so a
// stored config compares equal to a freshly built one.
filter: { operator: 'AND', conditions },
properties: [...EMAIL_PUSH_PROPERTIES],
urgency: 'high',
};
}
return config;
}
function normalizeEmailPush(value: unknown): string {
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys);
if (v && typeof v === 'object') {
return Object.keys(v as Record<string, unknown>).sort().reduce<Record<string, unknown>>((acc, k) => {
acc[k] = sortKeys((v as Record<string, unknown>)[k]);
return acc;
}, {});
}
return v;
};
return JSON.stringify(sortKeys(value ?? null));
}
export function sameEmailPush(
a: Record<string, EmailPushConfig> | null | undefined,
b: Record<string, EmailPushConfig>,
): boolean {
if (!a) return false;
return normalizeEmailPush(a) === normalizeEmailPush(b);
}
type BulwarkFcmNative = {
getToken(): Promise<string>;
deleteToken(): Promise<void>;
@@ -127,10 +276,21 @@ function getNative(): BulwarkFcmNative | null {
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
}
/** True on platforms that have a push transport wired up (Android/FCM only). */
export function isPushSupported(): boolean {
return getNative() !== null;
}
export interface PushSetupParams {
// Optional - falls back to the hosted relay if omitted.
relayBaseUrl?: string;
accountLabel?: string;
// Destroy the recorded server-side subscription and create a brand-new one
// instead of refreshing the existing record's expiry. Stalwart binds the set
// of accounts a subscription fans out to at creation time, so a subscription
// that outlives a permission change keeps pushing for mailboxes the user can
// no longer read - recreating is the only client-side remedy (#841).
forceRecreate?: boolean;
}
export interface PushSetupResult {
@@ -138,6 +298,26 @@ export interface PushSetupResult {
verified: boolean;
}
/** Which step of the enable flow failed - lets the UI say what went wrong. */
export type PushSetupPhase =
| 'platform'
| 'permission'
| 'token'
| 'account'
| 'relay'
| 'jmap'
| 'verify';
export class PushSetupError extends Error {
readonly phase: PushSetupPhase;
constructor(phase: PushSetupPhase, message: string) {
super(message);
this.name = 'PushSetupError';
this.phase = phase;
}
}
function randomClientId(): string {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
@@ -174,6 +354,20 @@ export async function setStoredRelayBaseUrl(url: string | null): Promise<void> {
}
}
/** Whether this account has a JMAP subscription recorded on this device. */
export async function isPushEnabledForAccount(accountId: string): Promise<boolean> {
await migrateLegacyPushKeys();
return (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null;
}
export async function wasPushPromptDismissed(accountId: string): Promise<boolean> {
return (await AsyncStorage.getItem(promptDismissedKey(accountId))) !== null;
}
export async function dismissPushPrompt(accountId: string): Promise<void> {
await AsyncStorage.setItem(promptDismissedKey(accountId), String(Date.now()));
}
export async function requestNotificationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
if (Platform.Version < 33) return true;
@@ -193,55 +387,114 @@ export async function getFcmToken(): Promise<string | null> {
}
}
// Firebase rejects getToken() on devices without Google Play services (or
// with a broken Firebase configuration), and briefly right after a
// deleteToken(). Turn the raw native rejection into a phase-tagged error the
// settings pane can explain.
async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
let token: string | null = null;
try {
token = await native.getToken();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'token',
`Firebase could not issue a device token (${detail}). Push needs Google Play services on this device.`,
);
}
if (!token) {
throw new PushSetupError('token', 'Firebase returned an empty device token.');
}
return token;
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
async function readRelayError(res: Response): Promise<string> {
try {
const text = await res.text();
if (!text) return `HTTP ${res.status}`;
try {
const body = JSON.parse(text) as { error?: unknown };
if (typeof body.error === 'string') return `${body.error} (HTTP ${res.status})`;
} catch {
// not JSON
}
return `HTTP ${res.status}: ${text.slice(0, 200)}`;
} catch {
return `HTTP ${res.status}`;
}
}
async function registerWithRelay(params: {
relayBaseUrl: string;
subscriptionId: string;
fcmToken: string;
accountLabel?: string;
}): Promise<void> {
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
fcmToken: params.fcmToken,
accountLabel: params.accountLabel,
}),
});
let res: Response;
try {
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
fcmToken: params.fcmToken,
accountLabel: params.accountLabel,
}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError('relay', `Could not reach the push relay at ${params.relayBaseUrl} (${detail}).`);
}
if (!res.ok) {
throw new Error(`Relay register failed: ${res.status}`);
throw new PushSetupError('relay', `The push relay rejected the registration: ${await readRelayError(res)}`);
}
}
/** The relay's view of a subscription - see relayStatusFor. */
export type PushRelayStatus = 'active' | 'inactive' | 'unknown';
/**
* Ask the relay what it knows about a subscription. `inactive` means the relay
* recognises the record and it is provably dead - it has never forwarded a push
* and isn't freshly registered. `unknown` covers everything we cannot vouch for:
* the relay doesn't recognise the id, an older relay without this endpoint, or a
* network blip. Callers must treat `unknown` as "leave it alone", never as dead.
*/
async function relayStatusFor(
relayBaseUrl: string,
subscriptionId: string,
): Promise<PushRelayStatus> {
if (!relayBaseUrl || !subscriptionId) return 'unknown';
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
);
if (!res.ok) return 'unknown';
const body = (await res.json()) as { active?: unknown };
if (body.active === true) return 'active';
if (body.active === false) return 'inactive';
return 'unknown';
} catch {
return 'unknown';
}
}
/**
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
* the relay positively reports it inactive - a record it knows about that has
* never forwarded a push and isn't freshly registered. Every other outcome (the
* relay doesn't recognise the id, an older relay without this endpoint, a
* network blip, or a live subscription) returns false, so we never reap
* anything we can't confirm is dead. This is what lets setup clear its own
* abandoned attempts - and dead siblings left by reinstalls that regenerated
* the deviceClientId - without disturbing another live device or the PWA that
* shares the account.
* Returns true ONLY when the relay positively reports a subscription inactive,
* so we never reap anything we can't confirm is dead. This is what lets setup
* clear its own abandoned attempts - and dead siblings left by reinstalls that
* regenerated the deviceClientId - without disturbing another live device or
* the PWA that shares the account.
*/
async function relayReportsDead(
relayBaseUrl: string,
subscriptionId: string,
): Promise<boolean> {
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
);
if (!res.ok) return false;
const body = (await res.json()) as { active?: unknown };
return body.active === false;
} catch {
return false;
}
return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive';
}
async function pollVerificationCode(
@@ -254,18 +507,31 @@ async function pollVerificationCode(
// verify window even in the unlucky case.
const timeoutAt = Date.now() + 75_000;
let delay = 400;
let lastRelayError: string | null = null;
while (Date.now() < timeoutAt) {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
);
if (res.ok) {
const body = (await res.json()) as { verificationCode?: string | null };
if (body.verificationCode) return body.verificationCode;
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
);
if (res.ok) {
const body = (await res.json()) as { verificationCode?: string | null };
if (body.verificationCode) return body.verificationCode;
lastRelayError = null;
} else {
lastRelayError = await readRelayError(res);
}
} catch (err) {
lastRelayError = err instanceof Error ? err.message : String(err);
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 2000);
}
throw new Error('Timed out waiting for PushVerification from JMAP server');
throw new PushSetupError(
'verify',
lastRelayError
? `The relay never received the verification code from the mail server (last relay response: ${lastRelayError}).`
: 'The mail server did not send a verification code to the relay within 75 s. Check that the server can reach the relay URL.',
);
}
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
@@ -297,27 +563,39 @@ export function setupPushNotifications(
return run;
}
function logPhase(phase: string, detail?: string): void {
console.log(`[push] ${phase}${detail ? `: ${detail}` : ''}`);
}
async function setupPushNotificationsInner(
params: PushSetupParams,
): Promise<PushSetupResult> {
const native = getNative();
if (!native) throw new Error('Push notifications require Android');
if (!native) {
throw new PushSetupError('platform', 'Push notifications are only available on Android.');
}
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
if (!relayBaseUrl) throw new PushSetupError('relay', 'relayBaseUrl is required');
if (!isValidRelayUrl(relayBaseUrl)) {
throw new PushSetupError('relay', 'The relay URL must use https://.');
}
logPhase('permission');
const granted = await requestNotificationPermission();
if (!granted) throw new Error('Notification permission denied');
if (!granted) {
throw new PushSetupError('permission', 'Notification permission was not granted.');
}
const fcmToken = await native.getToken();
if (!fcmToken) throw new Error('FCM token unavailable');
logPhase('token');
const fcmToken = await getFcmTokenOrThrow(native);
// setupPushNotifications operates on the currently-loaded jmapClient. We
// need its username/serverUrl up-front so we can key per-account state.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (!username || !serverUrl) {
throw new Error('No account loaded - cannot set up push');
throw new PushSetupError('account', 'No account loaded - cannot set up push.');
}
const accountId = generateAccountId(username, serverUrl);
@@ -328,8 +606,9 @@ async function setupPushNotificationsInner(
// Register this account's device-client-id with the relay. Multiple
// accounts on the same device end up as separate registrations sharing
// one fcmToken - the relay forwards each push individually so the headless
// task can identify the source account via the FCM data payload.
// one fcmToken - the relay forwards each push individually and tags it
// with the JMAP account id so the headless task can route it.
logPhase('relay', relayBaseUrl);
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
@@ -339,19 +618,33 @@ async function setupPushNotificationsInner(
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
// With forceRecreate we skip the reuse and destroy it instead (#841).
logPhase('jmap');
const existingSubs = await listPushSubscriptions().catch(() => []);
const emailPush = serverSupportsEmailPush() ? await buildEmailPushConfig() : null;
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
let jmapAccountId: string | null = null;
try {
jmapAccountId = jmapClient.accountId;
} catch {
jmapAccountId = null;
}
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
const refreshed = await refreshSubscriptionExpires(match);
if (refreshed) {
await addPushAccountId(accountId);
return { subscriptionId: storedServerId, verified: true };
if (!params.forceRecreate) {
const refreshed = await refreshSubscriptionExpires(match, emailPush);
if (refreshed) {
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
logPhase('done', 'reused existing subscription');
return { subscriptionId: storedServerId, verified: true };
}
}
// Server rejected the refresh (likely the subscription was already
// deleted server-side) - drop the stale id and recreate below.
// deleted server-side) or the caller asked for a fresh record - drop
// the stale id and recreate below.
await destroyPushSubscription(storedServerId).catch(() => undefined);
}
await AsyncStorage.removeItem(subKey);
@@ -382,18 +675,33 @@ async function setupPushNotificationsInner(
}
}
const serverAssignedId = await createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
});
let serverAssignedId: string;
try {
serverAssignedId = await createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
...(emailPush ? { emailPush } : {}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError('jmap', `The mail server refused the push subscription: ${detail}`);
}
logPhase('verify');
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await verifyPushSubscription(serverAssignedId, verificationCode);
try {
await verifyPushSubscription(serverAssignedId, verificationCode);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError('verify', `The mail server rejected the verification code: ${detail}`);
}
await AsyncStorage.setItem(subKey, serverAssignedId);
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
logPhase('done', 'subscription verified');
return { subscriptionId: serverAssignedId, verified: true };
}
@@ -406,12 +714,25 @@ async function addPushAccountId(accountId: string): Promise<void> {
}
// Push the subscription's expires forward when it's getting close to the
// server's ceiling. Returns false if the server rejects the update, which the
// caller treats as "recreate".
// server's ceiling, and re-sync `types` / the delivery filter when they drift
// from what this client wants (a subscription created by an older build still
// listens to `Email`/`Mailbox`; a Junk mailbox id can change under us).
// Returns false if the server rejects the update, which the caller treats as
// "recreate".
async function refreshSubscriptionExpires(
sub: { id: string; expires?: string | null },
sub: {
id: string;
expires?: string | null;
types?: string[] | null;
emailPush?: Record<string, EmailPushConfig> | null;
},
// null when the server has no emailPush support - leave the property alone.
desiredEmailPush: Record<string, EmailPushConfig> | null,
): Promise<boolean> {
if (sub.expires) {
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
const emailPushNeedsUpdate =
desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush);
if (!typesNeedUpdate && !emailPushNeedsUpdate && sub.expires) {
const remainingMs = new Date(sub.expires).getTime() - Date.now();
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
@@ -420,9 +741,14 @@ async function refreshSubscriptionExpires(
}
}
try {
return await updatePushSubscription(sub.id, {
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
});
const patch: {
expires?: string;
types?: string[];
emailPush?: Record<string, EmailPushConfig>;
} = { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) };
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush;
return await updatePushSubscription(sub.id, patch);
} catch {
return false;
}
@@ -438,48 +764,59 @@ async function deregisterFromRelay(
).catch(() => undefined);
}
async function clearAccountPushKeys(accountId: string): Promise<void> {
await AsyncStorage.multiRemove([
subscriptionIdKey(accountId),
deviceClientIdKey(accountId),
lastNotifiedKey(accountId),
notifiedIdsKey(accountId),
]);
await writePushJmapAccountId(accountId, null);
}
/**
* Tear down push for a single account. Destroys that account's JMAP
* subscription (assumes the jmapClient is currently authenticated to that
* account; the active-account logout flow guarantees this) and tells the
* relay to drop its mapping. Other accounts' push setups are untouched.
* Tear down push for a single account. Destroys every JMAP subscription the
* server holds for this device (assumes the jmapClient is currently
* authenticated to that account; the active-account logout flow guarantees
* this) and tells the relay to drop its mapping. Other accounts' push setups
* are untouched.
*
* If no accounts have push left after removal, also deletes the FCM token
* so the device stops receiving FCM messages entirely. Never throws -
* callers treat teardown as best effort.
* The FCM token is deliberately left alive: the relay mapping is gone so
* nothing gets forwarded, and deleting the token makes the next getToken()
* fail for a while (native #45 "Disable then Enable" race). Only the
* logout-all path (`teardownPushNotifications`) kills the token.
*/
export async function teardownPushNotificationsForAccount(
accountId: string,
): Promise<void> {
await migrateLegacyPushKeys();
const subKey = subscriptionIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const dcidKey = deviceClientIdKey(accountId);
const storedDcid = await AsyncStorage.getItem(dcidKey);
const storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
const relayBaseUrl = await getStoredRelayBaseUrl();
if (storedSubId) {
await destroyPushSubscription(storedSubId).catch(() => undefined);
// Destroy every subscription the server holds for this device, not just the
// id we happen to have recorded - a destroy that lost its round-trip or a
// failed enable can leave a registration this client no longer tracks (#841).
const idsToDestroy = new Set<string>();
if (storedSubId) idsToDestroy.add(storedSubId);
if (storedDcid) {
const existing = await listPushSubscriptions().catch(() => []);
for (const s of existing) {
if (s.deviceClientId === storedDcid) idsToDestroy.add(s.id);
}
}
for (const id of idsToDestroy) {
await destroyPushSubscription(id).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
await clearAccountPushKeys(accountId);
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
await writePushAccountIds(remaining);
// If this was the last account with push, kill the FCM token so the device
// truly goes silent. Otherwise the token stays alive so the remaining
// accounts keep receiving pushes.
if (remaining.length === 0) {
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
}
}
}
/**
@@ -495,10 +832,8 @@ export async function teardownPushNotifications(): Promise<void> {
const relayBaseUrl = await getStoredRelayBaseUrl();
for (const accountId of accountIds) {
const subKey = subscriptionIdKey(accountId);
const dcidKey = deviceClientIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const storedDcid = await AsyncStorage.getItem(dcidKey);
const storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
if (storedSubId) {
// Will only succeed if the jmapClient happens to be authenticated to
@@ -509,10 +844,10 @@ export async function teardownPushNotifications(): Promise<void> {
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
await clearAccountPushKeys(accountId);
}
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
await AsyncStorage.multiRemove([PUSH_ACCOUNT_IDS_KEY, PUSH_JMAP_ACCOUNT_IDS_KEY]);
const native = getNative();
if (native) {
@@ -520,9 +855,76 @@ export async function teardownPushNotifications(): Promise<void> {
}
}
export interface PushDevice {
// The JMAP PushSubscription id - what you destroy to revoke it.
id: string;
// Client-chosen id the relay keys its endpoint mapping on.
deviceClientId: string;
expires: string | null;
types: string[] | null;
// True when this registration belongs to the device you're looking at.
isThisDevice: boolean;
relayStatus: PushRelayStatus;
}
/**
* Every push registration the JMAP server holds for this account, annotated
* with whether it is this device and what the relay makes of it.
*
* Stalwart hides a subscription's url and verified state from clients, so
* deviceClientId is the only handle we get. That's enough to spot our own
* registration and to ask the relay about the rest - but registrations made
* against a different relay, or by a non-Bulwark client, come back `unknown`
* rather than dead, and the UI must present them as revocable-but-unclassified.
*/
export async function listPushDevices(params: {
accountId: string;
relayBaseUrl?: string;
}): Promise<PushDevice[]> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
const thisDeviceClientId = await AsyncStorage.getItem(deviceClientIdKey(params.accountId));
const subs = await listPushSubscriptions();
return Promise.all(
subs.map(async (s) => ({
id: s.id,
deviceClientId: s.deviceClientId,
expires: s.expires ?? null,
types: s.types ?? null,
isThisDevice: thisDeviceClientId !== null && s.deviceClientId === thisDeviceClientId,
relayStatus: await relayStatusFor(relayBaseUrl, s.deviceClientId),
})),
);
}
/**
* Revoke one registration. Destroying the JMAP subscription stops the server
* fanning StateChanges to it; dropping the relay mapping stops the relay
* forwarding anything already in flight and frees the deviceClientId. Revoking
* this device runs the full local teardown so the UI doesn't keep claiming push
* is on.
*/
export async function revokePushDevice(params: {
accountId: string;
device: Pick<PushDevice, 'id' | 'deviceClientId' | 'isThisDevice'>;
relayBaseUrl?: string;
}): Promise<void> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (params.device.isThisDevice) {
await teardownPushNotificationsForAccount(params.accountId);
return;
}
await destroyPushSubscription(params.device.id);
if (relayBaseUrl && params.device.deviceClientId) {
await deregisterFromRelay(relayBaseUrl, params.device.deviceClientId);
}
}
export type FcmMessageListener = (payload: {
title: string;
body: string;
title?: string;
body?: string;
data: Record<string, string>;
}) => void;
+3
View File
@@ -16,6 +16,9 @@ export interface AccountEntry {
hasError: boolean;
errorMessage?: string;
isDefault: boolean;
// JMAP primary account id (from the session's primaryAccounts). Recorded
// when push is set up so a relay payload can be routed to this entry.
jmapAccountId?: string;
}
interface AccountState {