mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: add an inbox-only switch for push notifications (bulwarkmail/webmail#983)
This commit is contained in:
@@ -708,8 +708,10 @@ export default function App() {
|
|||||||
// content, since hydration hands back an equal but new object; the value
|
// content, since hydration hands back an equal but new object; the value
|
||||||
// seen first after hydration is the baseline, not a change.
|
// seen first after hydration is the baseline, not a change.
|
||||||
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
|
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
|
||||||
|
// The Inbox-only switch changes the same filter (webmail #983).
|
||||||
|
const pushInboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
|
||||||
const settingsHydrated = useSettingsStore((s) => s.hydrated);
|
const settingsHydrated = useSettingsStore((s) => s.hydrated);
|
||||||
const mutesKey = JSON.stringify(notificationMutes);
|
const mutesKey = JSON.stringify([notificationMutes, pushInboxOnly]);
|
||||||
const lastMutesKey = React.useRef<string | null>(null);
|
const lastMutesKey = React.useRef<string | null>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!settingsHydrated) return;
|
if (!settingsHydrated) return;
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ live('live Stalwart push subscriptions', () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
it('takes the Inbox-only filter and echoes it so it compares equal (bulwarkmail/webmail#983)', async () => {
|
||||||
|
const me = client.accountId;
|
||||||
|
const emailPush = await buildEmailPushConfig(undefined, true);
|
||||||
|
expect(emailPush[me].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: inbox }] });
|
||||||
|
const { id } = await create('inbox-only', { emailPush });
|
||||||
|
const listed = (await listPushSubscriptions()).find((s) => s.id === id);
|
||||||
|
expect(sameEmailPush(listed?.emailPush, emailPush), JSON.stringify(listed?.emailPush)).toBe(true);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
it('refuses push addresses that are not public: http and private IPs everywhere, local names on 1.0', async () => {
|
it('refuses push addresses that are not public: http and private IPs everywhere, local names on 1.0', async () => {
|
||||||
for (const url of ['http://notifications.relay.bulwarkmail.org/api/push/jmap/x', 'https://192.168.1.20/api/push/jmap/x', 'https://10.0.0.5:8443/api/push/jmap/x']) {
|
for (const url of ['http://notifications.relay.bulwarkmail.org/api/push/jmap/x', 'https://192.168.1.20/api/push/jmap/x', 'https://10.0.0.5:8443/api/push/jmap/x']) {
|
||||||
const err = await refusal(create('private-ip', { url }));
|
const err = await refusal(create('private-ip', { url }));
|
||||||
|
|||||||
@@ -94,11 +94,14 @@ export function NotificationSettings() {
|
|||||||
try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; }
|
try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; }
|
||||||
const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush();
|
const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush();
|
||||||
const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId);
|
const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId);
|
||||||
|
// The Inbox-only filter needs the server's emailPush filter too (webmail #983).
|
||||||
|
const inboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
|
||||||
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
||||||
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||||
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
|
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
|
||||||
|
|
||||||
const supported = isPushSupported();
|
const supported = isPushSupported();
|
||||||
|
const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush();
|
||||||
const upSupported = isUnifiedPushSupported();
|
const upSupported = isUnifiedPushSupported();
|
||||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||||
const [transport, setTransport] = useState<PushTransport>('fcm');
|
const [transport, setTransport] = useState<PushTransport>('fcm');
|
||||||
@@ -557,6 +560,18 @@ export function NotificationSettings() {
|
|||||||
>
|
>
|
||||||
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
{inboxOnlyAvailable && (
|
||||||
|
<SettingItem
|
||||||
|
label={t('settings.notifications.email.inbox_only', 'Inbox only')}
|
||||||
|
description={t('settings.notifications.email.inbox_only_desc', 'Only notify for mail that lands in the Inbox; skip mail a filter files into another folder')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={inboxOnly}
|
||||||
|
onChange={(v) => update('pushNotifyInboxOnly', v)}
|
||||||
|
accessibilityLabel={t('settings.notifications.email.inbox_only', 'Inbox only')}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
{canMuteAccount && (
|
{canMuteAccount && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('settings.notifications.email.mute_account', 'Mute this account')}
|
label={t('settings.notifications.email.mute_account', 'Mute this account')}
|
||||||
|
|||||||
@@ -92,6 +92,34 @@ describe('push filter folders', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('push filter for the Inbox only (bulwarkmail/webmail#983)', () => {
|
||||||
|
it("lets through only mail that lands in each account's Inbox", async () => {
|
||||||
|
provideLoadedMailboxes(async () => [
|
||||||
|
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
|
||||||
|
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
|
||||||
|
{ id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true },
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES, true);
|
||||||
|
|
||||||
|
expect(config['jmap-primary'].filter).toEqual({
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'inbox' }],
|
||||||
|
});
|
||||||
|
expect(config.team.filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'in' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silences an account whose Inbox is muted, and refuses to build without the own Inbox', async () => {
|
||||||
|
provideLoadedMailboxes(async () => [{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' }] as never);
|
||||||
|
const muted: NotificationMutes = { accounts: [], mailboxes: { 'jmap-primary/inbox': true } };
|
||||||
|
const config = await buildEmailPushConfig(muted, true);
|
||||||
|
expect(config['jmap-primary'].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }] });
|
||||||
|
|
||||||
|
provideLoadedMailboxes(async () => [{ id: 'junk', role: 'junk', accountId: 'jmap-primary' }] as never);
|
||||||
|
await expect(buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES, true)).rejects.toThrow(/No Inbox/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Muted accounts and folders (#78) are left out by the server-side filter.
|
// Muted accounts and folders (#78) are left out by the server-side filter.
|
||||||
describe('push filter with muted folders and accounts (#78)', () => {
|
describe('push filter with muted folders and accounts (#78)', () => {
|
||||||
const folders = [
|
const folders = [
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { generateAccountId } from './account-utils';
|
|||||||
import {
|
import {
|
||||||
EMPTY_NOTIFICATION_MUTES,
|
EMPTY_NOTIFICATION_MUTES,
|
||||||
isAccountMuted,
|
isAccountMuted,
|
||||||
|
isMailboxMuted,
|
||||||
mailboxOverridesForAccount,
|
mailboxOverridesForAccount,
|
||||||
type NotificationMutes,
|
type NotificationMutes,
|
||||||
} from './notification-mutes';
|
} from './notification-mutes';
|
||||||
@@ -349,13 +350,19 @@ function emailPushCapabilityIsExact(): boolean {
|
|||||||
* Muted accounts and folders (#78) are cut out of the same filter: a muted
|
* Muted accounts and folders (#78) are cut out of the same filter: a muted
|
||||||
* folder joins Junk in the "not only in" list; a muted account only lets
|
* folder joins Junk in the "not only in" list; a muted account only lets
|
||||||
* through mail in the folders the user unmuted again, or nothing at all.
|
* through mail in the folders the user unmuted again, or nothing at all.
|
||||||
|
*
|
||||||
|
* `inboxOnly` (webmail #983, Settings > Notifications > Inbox only) lets
|
||||||
|
* through only mail that lands in each account's Inbox; a muted Inbox, or an
|
||||||
|
* account without one, then never pushes.
|
||||||
*/
|
*/
|
||||||
export async function buildEmailPushConfig(
|
export async function buildEmailPushConfig(
|
||||||
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
|
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
|
||||||
|
inboxOnly = false,
|
||||||
): Promise<Record<string, EmailPushConfig>> {
|
): Promise<Record<string, EmailPushConfig>> {
|
||||||
const primary = jmapClient.accountId;
|
const primary = jmapClient.accountId;
|
||||||
const junkByAccount = new Map<string, string[]>([[primary, []]]);
|
const junkByAccount = new Map<string, string[]>([[primary, []]]);
|
||||||
const mailboxIdsByAccount = new Map<string, Set<string>>();
|
const mailboxIdsByAccount = new Map<string, Set<string>>();
|
||||||
|
const inboxByAccount = new Map<string, string>();
|
||||||
// The mail store loads the same folders at the same moment (sign-in,
|
// The mail store loads the same folders at the same moment (sign-in,
|
||||||
// start): reuse its list rather than a second Mailbox/get of our own.
|
// start): reuse its list rather than a second Mailbox/get of our own.
|
||||||
const username = jmapClient.username;
|
const username = jmapClient.username;
|
||||||
@@ -365,7 +372,8 @@ export async function buildEmailPushConfig(
|
|||||||
// and unmute everything, so let the real error surface; otherwise the
|
// and unmute everything, so let the real error surface; otherwise the
|
||||||
// filter can still degrade to keyword-only on a transient miss.
|
// filter can still degrade to keyword-only on a transient miss.
|
||||||
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
|
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
|
||||||
const all = loaded ?? (hasMutes
|
// In inbox-only mode a swallowed failure would read as "no Inbox" too.
|
||||||
|
const all = loaded ?? (hasMutes || inboxOnly
|
||||||
? [...(await getMailboxes()), ...(await getSharedMailboxes())]
|
? [...(await getMailboxes()), ...(await getSharedMailboxes())]
|
||||||
: [
|
: [
|
||||||
...(await getMailboxes().catch(() => [] as Mailbox[])),
|
...(await getMailboxes().catch(() => [] as Mailbox[])),
|
||||||
@@ -383,6 +391,7 @@ export async function buildEmailPushConfig(
|
|||||||
const junk = junkByAccount.get(accountId) ?? [];
|
const junk = junkByAccount.get(accountId) ?? [];
|
||||||
if (m.role === 'junk') junk.push(serverId);
|
if (m.role === 'junk') junk.push(serverId);
|
||||||
junkByAccount.set(accountId, junk);
|
junkByAccount.set(accountId, junk);
|
||||||
|
if (m.role === 'inbox') inboxByAccount.set(accountId, serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: Record<string, EmailPushConfig> = {};
|
const config: Record<string, EmailPushConfig> = {};
|
||||||
@@ -391,7 +400,15 @@ export async function buildEmailPushConfig(
|
|||||||
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
|
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
|
||||||
const overrides = mailboxOverridesForAccount(mutes, accountId);
|
const overrides = mailboxOverridesForAccount(mutes, accountId);
|
||||||
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
|
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
|
||||||
if (isAccountMuted(mutes, accountId)) {
|
if (inboxOnly) {
|
||||||
|
const inboxId = inboxByAccount.get(accountId);
|
||||||
|
// The own account always has an Inbox; missing it means the folder
|
||||||
|
// list is incomplete, and silencing the account would be worse.
|
||||||
|
if (!inboxId && accountId === primary) {
|
||||||
|
throw new Error(`No Inbox found for account ${accountId}; cannot build an inbox-only push filter`);
|
||||||
|
}
|
||||||
|
conditions.push(inboxId && !isMailboxMuted(mutes, accountId, inboxId) ? { inMailbox: inboxId } : NEVER_MATCH);
|
||||||
|
} else if (isAccountMuted(mutes, accountId)) {
|
||||||
const allowed = overrides.unmuted
|
const allowed = overrides.unmuted
|
||||||
.filter((id) => existing.has(id) && !junkIds.includes(id))
|
.filter((id) => existing.has(id) && !junkIds.includes(id))
|
||||||
.sort();
|
.sort();
|
||||||
@@ -1357,7 +1374,10 @@ async function setupPushNotificationsInner(
|
|||||||
// a group out.
|
// a group out.
|
||||||
const refusedBefore = emailPushCapabilityIsExact() ? [] : await readRefusedEmailPushAccounts(accountId);
|
const refusedBefore = emailPushCapabilityIsExact() ? [] : await readRefusedEmailPushAccounts(accountId);
|
||||||
const emailPush = serverSupportsEmailPush()
|
const emailPush = serverSupportsEmailPush()
|
||||||
? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore)
|
? withoutAccounts(
|
||||||
|
await buildEmailPushConfig(useSettingsStore.getState().notificationMutes, useSettingsStore.getState().pushNotifyInboxOnly),
|
||||||
|
refusedBefore,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
const subKey = subscriptionIdKey(accountId);
|
const subKey = subscriptionIdKey(accountId);
|
||||||
const storedServerId = await AsyncStorage.getItem(subKey);
|
const storedServerId = await AsyncStorage.getItem(subKey);
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ export interface PersistedSettings {
|
|||||||
// until closed); -1 = automatic: 5 s, or 20 s while a screen reader is
|
// until closed); -1 = automatic: 5 s, or 20 s while a screen reader is
|
||||||
// on (lib/toast-duration).
|
// on (lib/toast-duration).
|
||||||
toastDuration: number;
|
toastDuration: number;
|
||||||
|
// Push only for mail that lands in the Inbox (webmail #983, same key).
|
||||||
|
pushNotifyInboxOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||||
@@ -474,6 +476,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
|
|
||||||
// platform:
|
// platform:
|
||||||
toastDuration: -1,
|
toastDuration: -1,
|
||||||
|
pushNotifyInboxOnly: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SettingsState extends PersistedSettings {
|
export interface SettingsState extends PersistedSettings {
|
||||||
|
|||||||
Reference in New Issue
Block a user