mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: subscribe to EmailDelivery only, use the relay's email ids and route pushes per account (#45)
This commit is contained in:
@@ -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
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user