diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml
index c35b451..2344415 100644
--- a/.github/workflows/staging-e2e.yml
+++ b/.github/workflows/staging-e2e.yml
@@ -70,7 +70,13 @@ jobs:
echo "HQBASE_STAGING_AUTH_SECRET=$AUTH_SECRET" >> "$GITHUB_ENV"
AUTH_SECRET="$AUTH_SECRET" node --input-type=module -e '
import { writeFileSync } from "node:fs";
- writeFileSync("/tmp/hqbase-e2e-secrets.json", `${JSON.stringify({ BETTER_AUTH_SECRET: process.env.AUTH_SECRET })}\n`, { mode: 0o600 });
+ import webpush from "web-push";
+ const vapid = webpush.generateVAPIDKeys();
+ writeFileSync("/tmp/hqbase-e2e-secrets.json", `${JSON.stringify({
+ BETTER_AUTH_SECRET: process.env.AUTH_SECRET,
+ VAPID_PUBLIC_KEY: vapid.publicKey,
+ VAPID_PRIVATE_KEY: vapid.privateKey
+ })}\n`, { mode: 0o600 });
'
pnpm build
pnpm exec wrangler d1 migrations apply DB --remote --config "$config"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0030c6d..897447f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.1.23
+
+- Add explicit per-device Web Push notifications for installed iOS, Android, and desktop PWAs.
+- Show exact accessible Inbox and Catch-all unread totals in navigation, the document title, and
+ supported installed-app badges.
+- Generate customer-owned VAPID secrets during installation and updates while keeping notification
+ payloads free of mail metadata.
+
## 0.1.22
- Keep the mobile and installed PWA shell below the iPhone Dynamic Island while preserving the
diff --git a/app/app.tsx b/app/app.tsx
index d34fec9..cf6c45d 100644
--- a/app/app.tsx
+++ b/app/app.tsx
@@ -12,6 +12,7 @@ import type { Mailbox } from "@/features/mailboxes/types";
import { listConversations, listMessages } from "@/features/messages/api";
import { mergeIncomingMessageIds } from "@/features/messages/incoming-sound";
import type { ConversationSummary } from "@/features/messages/types";
+import { useNotifications } from "@/features/notifications/use-notifications";
import { SettingsPage } from "@/features/settings/settings-page";
import { getSetupStatus } from "@/features/setup/api";
import { SetupPage } from "@/features/setup/setup-page";
@@ -91,6 +92,9 @@ export function App(): React.ReactElement {
});
setConversations(nextConversations);
}, [activeFolder, mailboxId, search, user]);
+ const notifications = useNotifications(currentUserId, () => {
+ void reloadConversations();
+ });
React.useEffect(() => {
void reload();
@@ -184,6 +188,7 @@ export function App(): React.ReactElement {
immersiveOnCompact={activeFolder !== "settings" && selectedId !== null}
mailboxId={mailboxId}
mailboxes={contentMailboxes}
+ unread={notifications.unread}
search={search}
user={user}
updateInProgress={updateMonitor.progress !== null}
@@ -215,6 +220,7 @@ export function App(): React.ReactElement {
activeTab={settingsTab}
canManage={user.role === "owner" || user.role === "admin"}
mailboxes={mailboxes}
+ notifications={notifications}
setup={setup}
users={users}
onRefresh={() => void reload()}
@@ -231,6 +237,7 @@ export function App(): React.ReactElement {
mailboxes={contentMailboxes}
selectedId={selectedId}
onRefresh={() => void reloadConversations()}
+ onUnreadChange={notifications.refresh}
onMessageRouteChange={(folder, messageId) =>
navigate({ kind: "mail", folder, messageId })
}
diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx
index f7d24b4..1e1f1a9 100644
--- a/app/components/layout/app-shell.tsx
+++ b/app/components/layout/app-shell.tsx
@@ -1,6 +1,7 @@
import type * as React from "react";
import type { CurrentUser } from "@/features/auth/types";
import type { Mailbox } from "@/features/mailboxes/types";
+import type { UnreadCounts } from "@/features/notifications/types";
import type { UpdateStatus } from "@/features/updates/types";
import { UpdateBanner } from "@/features/updates/update-banner";
import { cn } from "@/lib/cn";
@@ -18,6 +19,7 @@ type AppShellProps = {
user: CurrentUser;
updateInProgress: boolean;
updateStatus: UpdateStatus | null;
+ unread: UnreadCounts;
onCompose: () => void;
onFolderChange: (folder: FolderId) => void;
onMailboxChange: (mailboxId: string) => void;
@@ -31,6 +33,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
void;
onSignedOut: () => void;
};
export function MobileNavigation({
activeFolder,
+ unread,
user,
onFolderChange,
onSignedOut
@@ -61,6 +64,7 @@ export function MobileNavigation({
Navigation
void;
onSignedOut: () => void;
variant?: "desktop" | "drawer";
@@ -32,6 +34,7 @@ const icons: Record = {
export function Sidebar({
activeFolder,
drawerAction,
+ unread,
user,
onFolderChange,
onSignedOut,
@@ -55,6 +58,8 @@ export function Sidebar({