diff --git a/App.tsx b/App.tsx
index fe627cc5..d7ec84ef 100644
--- a/App.tsx
+++ b/App.tsx
@@ -56,6 +56,7 @@ import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { ToastHost } from './src/components/ToastHost';
+import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails } from './src/api/email';
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
@@ -146,6 +147,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps
+
s.appIconUnreadBadge);
+ const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled));
+
+ useEffect(() => {
+ void setAppBadge(count);
+ }, [count]);
+
+ useEffect(() => () => { void setAppBadge(0); }, []);
+
+ return null;
+}
diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx
index 07225873..5ed92924 100644
--- a/src/components/settings/NotificationSettings.tsx
+++ b/src/components/settings/NotificationSettings.tsx
@@ -41,6 +41,7 @@ import {
isUnifiedPushSupported,
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
+import { canBadgeAppIcon } from '../../lib/app-badge';
type PushStatus =
| { kind: 'idle' }
@@ -79,6 +80,7 @@ export function NotificationSettings() {
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
+ const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
const supported = isPushSupported();
const upSupported = isUnifiedPushSupported();
@@ -465,6 +467,17 @@ export function NotificationSettings() {
>
update('emailNotificationsEnabled', v)} />
+ {canBadgeAppIcon() && (
+
+ update('appIconUnreadBadge', v)} />
+
+ )}
{Platform.OS === 'android' && (
{t(
diff --git a/src/lib/__tests__/app-badge.test.ts b/src/lib/__tests__/app-badge.test.ts
new file mode 100644
index 00000000..49a9d8ba
--- /dev/null
+++ b/src/lib/__tests__/app-badge.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('expo-notifications', () => ({
+ setBadgeCountAsync: vi.fn(async () => true),
+}));
+
+import { Platform } from 'react-native';
+import * as Notifications from 'expo-notifications';
+import { appBadgeCount, setAppBadge } from '../app-badge';
+import type { Mailbox } from '../../api/types';
+
+const setBadge = Notifications.setBadgeCountAsync as unknown as ReturnType;
+const platform = Platform as { OS: string };
+
+function mailbox(id: string, extra: Partial): Mailbox {
+ return {
+ id,
+ name: id,
+ totalEmails: 0,
+ unreadEmails: 0,
+ totalThreads: 0,
+ unreadThreads: 0,
+ ...extra,
+ } as Mailbox;
+}
+
+afterEach(() => {
+ platform.OS = 'android';
+ setBadge.mockClear();
+});
+
+describe('appBadgeCount', () => {
+ it("counts the own inbox's unread, not a shared inbox listed first", () => {
+ const mailboxes = [
+ mailbox('team:inbox', { role: 'inbox', isShared: true, unreadEmails: 40 }),
+ mailbox('inbox', { role: 'inbox', isShared: false, unreadEmails: 3 }),
+ mailbox('lists', { unreadEmails: 12 }),
+ ];
+ expect(appBadgeCount(mailboxes, true)).toBe(3);
+ });
+
+ it('is 0 when the setting is off or there is no inbox yet', () => {
+ expect(appBadgeCount([mailbox('inbox', { role: 'inbox', unreadEmails: 3 })], false)).toBe(0);
+ expect(appBadgeCount([], true)).toBe(0);
+ });
+});
+
+describe('setAppBadge', () => {
+ it('sets and clears the badge on iOS', async () => {
+ platform.OS = 'ios';
+ await setAppBadge(5);
+ await setAppBadge(0);
+ expect(setBadge.mock.calls).toEqual([[5], [0]]);
+ });
+
+ it('never touches the badge on Android, where a 0 cancels every notification', async () => {
+ await setAppBadge(5);
+ await setAppBadge(0);
+ expect(setBadge).not.toHaveBeenCalled();
+ });
+
+ it('swallows a failing native call', async () => {
+ platform.OS = 'ios';
+ setBadge.mockRejectedValueOnce(new Error('no permission'));
+ await expect(setAppBadge(2)).resolves.toBeUndefined();
+ });
+});
diff --git a/src/lib/app-badge.ts b/src/lib/app-badge.ts
new file mode 100644
index 00000000..fc9facf6
--- /dev/null
+++ b/src/lib/app-badge.ts
@@ -0,0 +1,43 @@
+import { Platform } from 'react-native';
+import * as Notifications from 'expo-notifications';
+import type { Mailbox } from '../api/types';
+
+/**
+ * The number on the app icon: the unread count of the active account's own
+ * inbox, like the webmail's tab/app-icon badge (`FaviconBadge`). `role ===
+ * 'inbox'` alone is not enough: shared and group inboxes sit in the same list,
+ * so the first match can be somebody else's. 0 (no badge) when the setting is
+ * off.
+ */
+export function appBadgeCount(mailboxes: readonly Mailbox[], enabled: boolean): number {
+ if (!enabled) return 0;
+ const inbox = mailboxes.find((m) => m.role === 'inbox' && !m.isShared);
+ return Math.max(0, inbox?.unreadEmails ?? 0);
+}
+
+/**
+ * Whether this platform can badge the app icon without side effects.
+ *
+ * iOS only. On Android expo-notifications badges through ShortcutBadger,
+ * which only a few launchers honour (Android 8+ launchers derive their badge
+ * from the notifications on display instead), and it implements a count of 0
+ * as `NotificationManager.cancelAll()`: clearing the badge would also dismiss
+ * every notification the app shows, including other accounts' new mail and
+ * calendar reminders.
+ */
+export function canBadgeAppIcon(): boolean {
+ return Platform.OS === 'ios';
+}
+
+/**
+ * Show `count` on the app icon (0 clears it). A silent no-op where the
+ * platform can't badge or the user denied badge permission.
+ */
+export async function setAppBadge(count: number): Promise {
+ if (!canBadgeAppIcon()) return;
+ try {
+ await Notifications.setBadgeCountAsync(Math.max(0, Math.floor(count)));
+ } catch {
+ // Badging is cosmetic; never let it surface as an error.
+ }
+}
diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts
index ea464bb8..ce9bd25e 100644
--- a/src/stores/settings-store.ts
+++ b/src/stores/settings-store.ts
@@ -255,6 +255,9 @@ interface PersistedSettings {
emailNotificationsEnabled: boolean;
calendarNotificationsEnabled: boolean;
calendarInvitationParsingEnabled: boolean;
+ // Inbox unread count on the app icon (iOS, see lib/app-badge). Same
+ // preference as the webmail's `faviconUnreadBadge` (tab and app icon).
+ appIconUnreadBadge: boolean;
// Sidebar apps
sidebarApps: SidebarApp[];
@@ -403,6 +406,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
emailNotificationsEnabled: true,
calendarNotificationsEnabled: true,
calendarInvitationParsingEnabled: true,
+ appIconUnreadBadge: true,
sidebarApps: [],
keepAppsLoaded: false,
@@ -610,6 +614,7 @@ export const SETTINGS_KEY_MAP: Partial>
exportLowercase: 'filenameLowercase',
exportStripDiacritics: 'filenameStripDiacritics',
filtersExpandedView: 'expandedFilterView',
+ appIconUnreadBadge: 'faviconUnreadBadge',
};
// Keys that describe this device rather than the user's preferences.