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 { useNetworkStore } from './src/stores/network-store';
|
||||||
import { useUpdatesStore } from './src/stores/updates-store';
|
import { useUpdatesStore } from './src/stores/updates-store';
|
||||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||||
|
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
||||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||||
import { useOutboxStore } from './src/stores/outbox-store';
|
import { useOutboxStore } from './src/stores/outbox-store';
|
||||||
@@ -101,6 +102,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<OfflineCacheBanner />
|
<OfflineCacheBanner />
|
||||||
|
<PushOnboardingPrompt />
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
@@ -294,12 +296,21 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, [haveLiveSession]);
|
}, [haveLiveSession]);
|
||||||
|
|
||||||
// Listen for FCM messages so the app can refresh state even when woken
|
// Foreground FCM messages: the Kotlin service skips the headless task while
|
||||||
// from the background - the FirebaseMessagingService also posts the system
|
// the app is visible, so feed the relay's StateChange straight into the
|
||||||
// notification so the user sees it regardless of RN runtime state.
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
const unsubscribe = addMessageListener((payload) => {
|
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;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
val threadId = options.getString("threadId")
|
val threadId = options.getString("threadId")
|
||||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
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
|
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||||
// block waiting for the favicon request.
|
// block waiting for the favicon request.
|
||||||
@@ -74,8 +78,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
?: makeLetterAvatar(initials, bgColorHex)
|
?: makeLetterAvatar(initials, bgColorHex)
|
||||||
postNotification(
|
postNotification(
|
||||||
notificationId, title, body, largeIcon, bgColorHex,
|
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)
|
promise.resolve(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +101,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
threadId: String?,
|
threadId: String?,
|
||||||
subject: String?,
|
subject: String?,
|
||||||
accountId: String?,
|
accountId: String?,
|
||||||
|
groupKey: String?,
|
||||||
) {
|
) {
|
||||||
val ctx = reactApplicationContext
|
val ctx = reactApplicationContext
|
||||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||||
@@ -122,11 +128,64 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
|
if (groupKey != null) {
|
||||||
|
builder.setGroup(groupKey)
|
||||||
|
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||||
|
}
|
||||||
|
|
||||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
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 {
|
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
|
||||||
val size = 192
|
val size = 192
|
||||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
import { jmapClient } from './jmap-client';
|
import { jmapClient } from './jmap-client';
|
||||||
import { CAPABILITIES } from './types';
|
import { CAPABILITIES } from './types';
|
||||||
import type { PushSubscription, StateChange } from './types';
|
import type { EmailPushConfig, PushSubscription, StateChange } from './types';
|
||||||
|
|
||||||
export type StateChangeHandler = (change: StateChange) => void;
|
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
|
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
|
||||||
// want and accept whatever Stalwart returns.
|
// want and accept whatever Stalwart returns.
|
||||||
expires?: string;
|
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> {
|
}): Promise<string> {
|
||||||
const created: Record<string, unknown> = {
|
const created: Record<string, unknown> = {
|
||||||
deviceClientId: params.deviceClientId,
|
deviceClientId: params.deviceClientId,
|
||||||
@@ -34,6 +37,7 @@ export async function createPushSubscription(params: {
|
|||||||
types: params.types,
|
types: params.types,
|
||||||
};
|
};
|
||||||
if (params.expires) created.expires = params.expires;
|
if (params.expires) created.expires = params.expires;
|
||||||
|
if (params.emailPush) created.emailPush = params.emailPush;
|
||||||
|
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[
|
[
|
||||||
@@ -63,7 +67,7 @@ export async function createPushSubscription(params: {
|
|||||||
*/
|
*/
|
||||||
export async function updatePushSubscription(
|
export async function updatePushSubscription(
|
||||||
id: string,
|
id: string,
|
||||||
patch: { expires?: string; types?: string[] },
|
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[
|
[
|
||||||
|
|||||||
+3
-1
@@ -573,7 +573,9 @@ export interface FileNode {
|
|||||||
blobId?: string | null;
|
blobId?: string | null;
|
||||||
size?: number;
|
size?: number;
|
||||||
created?: string;
|
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
|
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
|
||||||
// principals capability and the properties are requested explicitly. A node
|
// principals capability and the properties are requested explicitly. A node
|
||||||
// is shared-out when `shareWith` has entries; `myRights` describes what the
|
// 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', () => ({
|
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', () => ({
|
vi.mock('../../api/push', () => ({
|
||||||
@@ -36,12 +49,22 @@ vi.mock('../../api/push', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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 {
|
import {
|
||||||
listPushSubscriptions,
|
listPushSubscriptions,
|
||||||
createPushSubscription,
|
createPushSubscription,
|
||||||
destroyPushSubscription,
|
destroyPushSubscription,
|
||||||
|
updatePushSubscription,
|
||||||
} from '../../api/push';
|
} from '../../api/push';
|
||||||
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
|
import { NativeModules } from 'react-native';
|
||||||
import { generateAccountId } from '../account-utils';
|
import { generateAccountId } from '../account-utils';
|
||||||
|
|
||||||
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
@@ -76,6 +99,8 @@ function installFetch(states: Record<string, RelayState>): void {
|
|||||||
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
|
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
|
||||||
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
|
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
|
||||||
const createMock = createPushSubscription 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) {
|
function sub(id: string, deviceClientId: string) {
|
||||||
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
|
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);
|
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 { NativeModules } from 'react-native';
|
||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient } from '../api/jmap-client';
|
||||||
import { getEmails, getMailboxes, queryEmails } from '../api/email';
|
import { getEmails, getMailboxes, queryEmails } from '../api/email';
|
||||||
|
import type { Email } from '../api/types';
|
||||||
import {
|
import {
|
||||||
generateEmailAvatarColor,
|
generateEmailAvatarColor,
|
||||||
getEmailInitials,
|
getEmailInitials,
|
||||||
@@ -9,10 +10,11 @@ import {
|
|||||||
getFaviconUrl,
|
getFaviconUrl,
|
||||||
} from './avatar-utils';
|
} from './avatar-utils';
|
||||||
import {
|
import {
|
||||||
deviceClientIdKey,
|
|
||||||
lastNotifiedKey,
|
lastNotifiedKey,
|
||||||
migrateLegacyPushKeys,
|
migrateLegacyPushKeys,
|
||||||
|
notifiedIdsKey,
|
||||||
readPushAccountIds,
|
readPushAccountIds,
|
||||||
|
readPushJmapAccountIds,
|
||||||
} from './push-notifications';
|
} from './push-notifications';
|
||||||
|
|
||||||
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
|
// 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
|
// 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 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
|
// 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';
|
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 {
|
interface PushPersistedSettings {
|
||||||
emailNotificationsEnabled?: boolean;
|
emailNotificationsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -51,54 +61,145 @@ interface ShowNotificationOptions {
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
accountId: 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 {
|
interface BulwarkFcmNative {
|
||||||
showNotification(opts: ShowNotificationOptions): Promise<void>;
|
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
|
* What the relay puts in the FCM data payload (repos/relay/src/fcm.ts). All
|
||||||
// key the relay uses isn't part of any client-controlled contract, so rather
|
* values are strings; `emailIds` and `changed` are JSON-encoded.
|
||||||
// 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.
|
export interface RelayPushData {
|
||||||
// Returns null if nothing matches (e.g. legacy relay payload format) so the
|
kind: 'jmap-email-push' | 'jmap-state-change' | null;
|
||||||
// caller falls back to iterating every known account.
|
accountLabel: string | null;
|
||||||
async function identifyAccountFromFcmData(
|
// JMAP primary account id the push was generated for.
|
||||||
data: unknown,
|
jmapAccountId: string | null;
|
||||||
accountIds: string[],
|
emailIds: string[];
|
||||||
): Promise<string | null> {
|
changed: Record<string, Record<string, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the persisted active account directly from AsyncStorage (Zustand's
|
export function parseRelayPushData(data: unknown): RelayPushData {
|
||||||
// persist middleware stores the JSON-serialised state under its `name`).
|
const out: RelayPushData = {
|
||||||
async function readActiveAccountId(): Promise<string | null> {
|
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 {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
|
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return { accounts: [], activeAccountId: null };
|
||||||
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
|
const parsed = JSON.parse(raw) as {
|
||||||
return parsed.state?.activeAccountId ?? null;
|
state?: { accounts?: RegistryAccount[]; activeAccountId?: string | null };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
accounts: Array.isArray(parsed.state?.accounts) ? parsed.state!.accounts! : [],
|
||||||
|
activeAccountId: parsed.state?.activeAccountId ?? null,
|
||||||
|
};
|
||||||
} catch {
|
} 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
|
// 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
|
// short-lived headless JS runtime - keep it fast, catch all errors, and always
|
||||||
// resolve so the native service can release its wake lock.
|
// 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();
|
const accountIds = await readPushAccountIds();
|
||||||
if (accountIds.length === 0) return;
|
if (accountIds.length === 0) return;
|
||||||
|
|
||||||
activeAccountId = await readActiveAccountId();
|
const registry = await readAccountRegistry();
|
||||||
|
activeAccountId = registry.activeAccountId;
|
||||||
|
|
||||||
const matched = await identifyAccountFromFcmData(data, accountIds);
|
const payload = parseRelayPushData(data);
|
||||||
const accountsToCheck = matched ? [matched] : accountIds;
|
const jmapAccountIds = await readPushJmapAccountIds();
|
||||||
|
const accountsToCheck = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry.accounts);
|
||||||
|
|
||||||
for (const accountId of accountsToCheck) {
|
for (const accountId of accountsToCheck) {
|
||||||
try {
|
try {
|
||||||
await processAccountForPush(accountId);
|
await processAccountForPush(accountId, payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[push] background check failed for account',
|
'[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);
|
const loaded = await jmapClient.loadAccount(accountId);
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
|
|
||||||
const mailboxes = await getMailboxes();
|
const alreadyNotified = await readNotifiedIds(accountId);
|
||||||
const inbox = mailboxes.find((m) => m.role === 'inbox');
|
let candidates: Email[];
|
||||||
if (!inbox) return;
|
|
||||||
|
|
||||||
const { ids } = await queryEmails(inbox.id, {
|
if (payload.emailIds.length > 0) {
|
||||||
filter: { notKeyword: '$seen' },
|
// EmailPush (or a relay that forwards ids): fetch exactly the delivered
|
||||||
limit: 1,
|
// messages. The push may concern a shared account the user has access
|
||||||
});
|
// to, in which case the ids live under that JMAP account.
|
||||||
if (ids.length === 0) return;
|
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 toNotify = selectNotifiableEmails(candidates, alreadyNotified);
|
||||||
const lastKey = lastNotifiedKey(accountId);
|
if (toNotify.length === 0) return;
|
||||||
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 native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
||||||
if (!native?.showNotification) return;
|
if (!native?.showNotification) return;
|
||||||
|
|
||||||
await native.showNotification({
|
const groupKey = `bulwark-mail:${accountId}`;
|
||||||
notificationId: `mail:${emailId}`,
|
const groupTitle = payload.accountLabel ?? accountId.split('@')[0] ?? accountId;
|
||||||
title,
|
|
||||||
body,
|
|
||||||
initials,
|
|
||||||
bgColorHex,
|
|
||||||
iconUrl,
|
|
||||||
emailId,
|
|
||||||
threadId: email.threadId,
|
|
||||||
subject: email.subject ?? undefined,
|
|
||||||
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 {
|
function hslToHex(hsl: string): string {
|
||||||
|
|||||||
+500
-98
@@ -7,7 +7,9 @@ import {
|
|||||||
updatePushSubscription,
|
updatePushSubscription,
|
||||||
verifyPushSubscription,
|
verifyPushSubscription,
|
||||||
} from '../api/push';
|
} from '../api/push';
|
||||||
|
import { getMailboxes, getSharedMailboxes } from '../api/email';
|
||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient } from '../api/jmap-client';
|
||||||
|
import type { EmailPushConfig, Mailbox } from '../api/types';
|
||||||
import { generateAccountId } from './account-utils';
|
import { generateAccountId } from './account-utils';
|
||||||
|
|
||||||
// Persist identifiers across launches so we reuse the same JMAP subscription
|
// 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.
|
// relay can distinguish per-account pushes via the URL slot it forwards.
|
||||||
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
|
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
|
||||||
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds: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 DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
|
||||||
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
|
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
|
||||||
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId: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
|
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
|
||||||
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
||||||
@@ -38,6 +48,14 @@ export function lastNotifiedKey(accountId: string): string {
|
|||||||
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
|
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[]> {
|
export async function readPushAccountIds(): Promise<string[]> {
|
||||||
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
||||||
if (!raw) return [];
|
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
|
// 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
|
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
|
||||||
// setup: reuse the legacy deviceClientId and JMAP subscription id under
|
// 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.
|
// Power users can override this from the settings screen.
|
||||||
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
|
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.
|
* A relay must be reachable over TLS: the registration carries the FCM token
|
||||||
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
|
* 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)
|
// 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,
|
// 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();
|
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 = {
|
type BulwarkFcmNative = {
|
||||||
getToken(): Promise<string>;
|
getToken(): Promise<string>;
|
||||||
deleteToken(): Promise<void>;
|
deleteToken(): Promise<void>;
|
||||||
@@ -127,10 +276,21 @@ function getNative(): BulwarkFcmNative | null {
|
|||||||
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? 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 {
|
export interface PushSetupParams {
|
||||||
// Optional - falls back to the hosted relay if omitted.
|
// Optional - falls back to the hosted relay if omitted.
|
||||||
relayBaseUrl?: string;
|
relayBaseUrl?: string;
|
||||||
accountLabel?: 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 {
|
export interface PushSetupResult {
|
||||||
@@ -138,6 +298,26 @@ export interface PushSetupResult {
|
|||||||
verified: boolean;
|
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 {
|
function randomClientId(): string {
|
||||||
const bytes = new Uint8Array(16);
|
const bytes = new Uint8Array(16);
|
||||||
if (globalThis.crypto?.getRandomValues) {
|
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> {
|
export async function requestNotificationPermission(): Promise<boolean> {
|
||||||
if (Platform.OS !== 'android') return true;
|
if (Platform.OS !== 'android') return true;
|
||||||
if (Platform.Version < 33) 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 {
|
function buildRelayUrl(base: string, suffix: string): string {
|
||||||
return base.replace(/\/+$/, '') + suffix;
|
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: {
|
async function registerWithRelay(params: {
|
||||||
relayBaseUrl: string;
|
relayBaseUrl: string;
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
fcmToken: string;
|
fcmToken: string;
|
||||||
accountLabel?: string;
|
accountLabel?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), {
|
let res: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'content-type': 'application/json' },
|
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
subscriptionId: params.subscriptionId,
|
headers: { 'content-type': 'application/json' },
|
||||||
fcmToken: params.fcmToken,
|
body: JSON.stringify({
|
||||||
accountLabel: params.accountLabel,
|
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) {
|
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
|
* Returns true ONLY when the relay positively reports a subscription inactive,
|
||||||
* the relay positively reports it inactive - a record it knows about that has
|
* so we never reap anything we can't confirm is dead. This is what lets setup
|
||||||
* never forwarded a push and isn't freshly registered. Every other outcome (the
|
* clear its own abandoned attempts - and dead siblings left by reinstalls that
|
||||||
* relay doesn't recognise the id, an older relay without this endpoint, a
|
* regenerated the deviceClientId - without disturbing another live device or
|
||||||
* network blip, or a live subscription) returns false, so we never reap
|
* the PWA that shares the account.
|
||||||
* 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(
|
async function relayReportsDead(
|
||||||
relayBaseUrl: string,
|
relayBaseUrl: string,
|
||||||
subscriptionId: string,
|
subscriptionId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollVerificationCode(
|
async function pollVerificationCode(
|
||||||
@@ -254,18 +507,31 @@ async function pollVerificationCode(
|
|||||||
// verify window even in the unlucky case.
|
// verify window even in the unlucky case.
|
||||||
const timeoutAt = Date.now() + 75_000;
|
const timeoutAt = Date.now() + 75_000;
|
||||||
let delay = 400;
|
let delay = 400;
|
||||||
|
let lastRelayError: string | null = null;
|
||||||
while (Date.now() < timeoutAt) {
|
while (Date.now() < timeoutAt) {
|
||||||
const res = await fetch(
|
try {
|
||||||
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
|
const res = await fetch(
|
||||||
);
|
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
|
||||||
if (res.ok) {
|
);
|
||||||
const body = (await res.json()) as { verificationCode?: string | null };
|
if (res.ok) {
|
||||||
if (body.verificationCode) return body.verificationCode;
|
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));
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
delay = Math.min(delay * 1.5, 2000);
|
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
|
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
|
||||||
@@ -297,27 +563,39 @@ export function setupPushNotifications(
|
|||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logPhase(phase: string, detail?: string): void {
|
||||||
|
console.log(`[push] ${phase}${detail ? `: ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function setupPushNotificationsInner(
|
async function setupPushNotificationsInner(
|
||||||
params: PushSetupParams,
|
params: PushSetupParams,
|
||||||
): Promise<PushSetupResult> {
|
): Promise<PushSetupResult> {
|
||||||
const native = getNative();
|
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(/\/+$/, '');
|
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();
|
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();
|
logPhase('token');
|
||||||
if (!fcmToken) throw new Error('FCM token unavailable');
|
const fcmToken = await getFcmTokenOrThrow(native);
|
||||||
|
|
||||||
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
||||||
// need its username/serverUrl up-front so we can key per-account state.
|
// need its username/serverUrl up-front so we can key per-account state.
|
||||||
const username = jmapClient.username;
|
const username = jmapClient.username;
|
||||||
const serverUrl = jmapClient.serverUrl;
|
const serverUrl = jmapClient.serverUrl;
|
||||||
if (!username || !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);
|
const accountId = generateAccountId(username, serverUrl);
|
||||||
|
|
||||||
@@ -328,8 +606,9 @@ async function setupPushNotificationsInner(
|
|||||||
|
|
||||||
// Register this account's device-client-id with the relay. Multiple
|
// Register this account's device-client-id with the relay. Multiple
|
||||||
// accounts on the same device end up as separate registrations sharing
|
// accounts on the same device end up as separate registrations sharing
|
||||||
// one fcmToken - the relay forwards each push individually so the headless
|
// one fcmToken - the relay forwards each push individually and tags it
|
||||||
// task can identify the source account via the FCM data payload.
|
// with the JMAP account id so the headless task can route it.
|
||||||
|
logPhase('relay', relayBaseUrl);
|
||||||
await registerWithRelay({
|
await registerWithRelay({
|
||||||
relayBaseUrl,
|
relayBaseUrl,
|
||||||
subscriptionId: deviceClientId,
|
subscriptionId: deviceClientId,
|
||||||
@@ -339,19 +618,33 @@ async function setupPushNotificationsInner(
|
|||||||
|
|
||||||
// Reuse the previous JMAP subscription when the server still has it, but
|
// 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.
|
// 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 existingSubs = await listPushSubscriptions().catch(() => []);
|
||||||
|
const emailPush = serverSupportsEmailPush() ? await buildEmailPushConfig() : null;
|
||||||
const subKey = subscriptionIdKey(accountId);
|
const subKey = subscriptionIdKey(accountId);
|
||||||
const storedServerId = await AsyncStorage.getItem(subKey);
|
const storedServerId = await AsyncStorage.getItem(subKey);
|
||||||
|
let jmapAccountId: string | null = null;
|
||||||
|
try {
|
||||||
|
jmapAccountId = jmapClient.accountId;
|
||||||
|
} catch {
|
||||||
|
jmapAccountId = null;
|
||||||
|
}
|
||||||
if (storedServerId) {
|
if (storedServerId) {
|
||||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||||
if (match) {
|
if (match) {
|
||||||
const refreshed = await refreshSubscriptionExpires(match);
|
if (!params.forceRecreate) {
|
||||||
if (refreshed) {
|
const refreshed = await refreshSubscriptionExpires(match, emailPush);
|
||||||
await addPushAccountId(accountId);
|
if (refreshed) {
|
||||||
return { subscriptionId: storedServerId, verified: true };
|
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
|
// 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 destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||||
}
|
}
|
||||||
await AsyncStorage.removeItem(subKey);
|
await AsyncStorage.removeItem(subKey);
|
||||||
@@ -382,18 +675,33 @@ async function setupPushNotificationsInner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverAssignedId = await createPushSubscription({
|
let serverAssignedId: string;
|
||||||
deviceClientId,
|
try {
|
||||||
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
serverAssignedId = await createPushSubscription({
|
||||||
types: [...PUSH_TYPES],
|
deviceClientId,
|
||||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
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);
|
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 AsyncStorage.setItem(subKey, serverAssignedId);
|
||||||
await addPushAccountId(accountId);
|
await addPushAccountId(accountId);
|
||||||
|
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||||
|
logPhase('done', 'subscription verified');
|
||||||
|
|
||||||
return { subscriptionId: serverAssignedId, verified: true };
|
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
|
// 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
|
// server's ceiling, and re-sync `types` / the delivery filter when they drift
|
||||||
// caller treats as "recreate".
|
// 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(
|
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> {
|
): 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 remainingMs = new Date(sub.expires).getTime() - Date.now();
|
||||||
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
|
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
|
||||||
@@ -420,9 +741,14 @@ async function refreshSubscriptionExpires(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await updatePushSubscription(sub.id, {
|
const patch: {
|
||||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -438,48 +764,59 @@ async function deregisterFromRelay(
|
|||||||
).catch(() => undefined);
|
).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
|
* Tear down push for a single account. Destroys every JMAP subscription the
|
||||||
* subscription (assumes the jmapClient is currently authenticated to that
|
* server holds for this device (assumes the jmapClient is currently
|
||||||
* account; the active-account logout flow guarantees this) and tells the
|
* authenticated to that account; the active-account logout flow guarantees
|
||||||
* relay to drop its mapping. Other accounts' push setups are untouched.
|
* 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
|
* The FCM token is deliberately left alive: the relay mapping is gone so
|
||||||
* so the device stops receiving FCM messages entirely. Never throws -
|
* nothing gets forwarded, and deleting the token makes the next getToken()
|
||||||
* callers treat teardown as best effort.
|
* fail for a while (native #45 "Disable then Enable" race). Only the
|
||||||
|
* logout-all path (`teardownPushNotifications`) kills the token.
|
||||||
*/
|
*/
|
||||||
export async function teardownPushNotificationsForAccount(
|
export async function teardownPushNotificationsForAccount(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await migrateLegacyPushKeys();
|
await migrateLegacyPushKeys();
|
||||||
|
|
||||||
const subKey = subscriptionIdKey(accountId);
|
const storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
|
||||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||||
const dcidKey = deviceClientIdKey(accountId);
|
|
||||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
|
||||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||||
|
|
||||||
if (storedSubId) {
|
// Destroy every subscription the server holds for this device, not just the
|
||||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
// 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) {
|
if (relayBaseUrl && storedDcid) {
|
||||||
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
|
await clearAccountPushKeys(accountId);
|
||||||
|
|
||||||
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
|
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
|
||||||
await writePushAccountIds(remaining);
|
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();
|
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||||
|
|
||||||
for (const accountId of accountIds) {
|
for (const accountId of accountIds) {
|
||||||
const subKey = subscriptionIdKey(accountId);
|
const storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
|
||||||
const dcidKey = deviceClientIdKey(accountId);
|
const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
|
||||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
|
||||||
|
|
||||||
if (storedSubId) {
|
if (storedSubId) {
|
||||||
// Will only succeed if the jmapClient happens to be authenticated to
|
// Will only succeed if the jmapClient happens to be authenticated to
|
||||||
@@ -509,10 +844,10 @@ export async function teardownPushNotifications(): Promise<void> {
|
|||||||
if (relayBaseUrl && storedDcid) {
|
if (relayBaseUrl && storedDcid) {
|
||||||
await deregisterFromRelay(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();
|
const native = getNative();
|
||||||
if (native) {
|
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: {
|
export type FcmMessageListener = (payload: {
|
||||||
title: string;
|
title?: string;
|
||||||
body: string;
|
body?: string;
|
||||||
data: Record<string, string>;
|
data: Record<string, string>;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export interface AccountEntry {
|
|||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isDefault: boolean;
|
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 {
|
interface AccountState {
|
||||||
|
|||||||
Reference in New Issue
Block a user