diff --git a/CHANGELOG.md b/CHANGELOG.md index 897447f..88f9956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.24 + +- Consolidate conversation, unread-count, and incoming-mail refreshes into one coordinated path. +- Replace broad incoming-message polling with an access-scoped latest-message status query. +- Split signed deployment responsibilities and strengthen architecture and lifecycle coverage gates. + ## 0.1.23 - Add explicit per-device Web Push notifications for installed iOS, Android, and desktop PWAs. diff --git a/app/app.tsx b/app/app.tsx index cf6c45d..b89e94a 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { toast } from "sonner"; import { AppShell } from "@/components/layout/app-shell"; import { Toaster } from "@/components/ui/sonner"; @@ -9,10 +8,7 @@ import type { CurrentUser } from "@/features/auth/types"; import { InboxPage } from "@/features/inbox/inbox-page"; import { listMailboxes } from "@/features/mailboxes/api"; 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 { useMailSync } from "@/features/messages/use-mail-sync"; import { SettingsPage } from "@/features/settings/settings-page"; import { getSetupStatus } from "@/features/setup/api"; import { SetupPage } from "@/features/setup/setup-page"; @@ -20,7 +16,6 @@ import type { SetupStatus } from "@/features/setup/types"; import { useUpdateMonitor } from "@/features/updates/use-update-monitor"; import { listUsers } from "@/features/users/api"; import type { WorkspaceUser } from "@/features/users/types"; -import { playNotificationSound } from "@/lib/notification-sounds"; import type { FolderId, MailFolderId, SettingsTabId } from "@/lib/routes"; import { useAppRoute } from "@/lib/use-app-route"; @@ -33,12 +28,10 @@ export function App(): React.ReactElement { const [user, setUser] = React.useState(null); const [mailboxes, setMailboxes] = React.useState([]); const [users, setUsers] = React.useState([]); - const [conversations, setConversations] = React.useState([]); const [mailboxId, setMailboxId] = React.useState("all"); const [search, setSearch] = React.useState(""); const [composeOpen, setComposeOpen] = React.useState(false); const [isLoading, setIsLoading] = React.useState(true); - const knownIncomingMessageIds = React.useRef | null>(null); const { navigate, route } = useAppRoute(setup?.isComplete); const activeFolder: FolderId = route.kind === "settings" ? "settings" : route.folder; const selectedId = route.kind === "mail" ? route.messageId : null; @@ -50,6 +43,12 @@ export function App(): React.ReactElement { ); const canManageUpdates = user?.role === "owner" || user?.role === "admin"; const updateMonitor = useUpdateMonitor(canManageUpdates); + const mailSync = useMailSync({ + activeFolder, + mailboxId, + search, + userId: currentUserId + }); const loadWorkspace = React.useCallback(async (currentUser: CurrentUser) => { const [nextSetup, nextMailboxes] = await Promise.all([getSetupStatus(), listMailboxes()]); @@ -83,73 +82,10 @@ export function App(): React.ReactElement { } }, [loadWorkspace]); - const reloadConversations = React.useCallback(async () => { - if (!user || activeFolder === "settings") return; - const nextConversations = await listConversations({ - folder: activeFolder, - mailboxId: mailboxId === "all" ? undefined : mailboxId, - search: search || undefined - }); - setConversations(nextConversations); - }, [activeFolder, mailboxId, search, user]); - const notifications = useNotifications(currentUserId, () => { - void reloadConversations(); - }); - React.useEffect(() => { void reload(); }, [reload]); - React.useEffect(() => { - void reloadConversations().catch((error) => { - toast.error(error instanceof Error ? error.message : "Conversations failed to load."); - }); - }, [reloadConversations]); - - React.useEffect(() => { - knownIncomingMessageIds.current = null; - if (!currentUserId) return; - let active = true; - let checking = false; - const checkIncomingMail = async () => { - if (checking) return; - checking = true; - try { - const latestMessages = await listMessages({}); - if (!active) return; - const snapshot = mergeIncomingMessageIds(knownIncomingMessageIds.current, latestMessages); - knownIncomingMessageIds.current = snapshot.knownIds; - if (snapshot.hasNewMessages) playNotificationSound("incoming-email"); - } catch { - // Incoming-mail discovery never interrupts the current workspace. - } finally { - checking = false; - } - }; - - void checkIncomingMail(); - const interval = window.setInterval(() => { - if (active) void checkIncomingMail(); - }, 10_000); - - return () => { - active = false; - window.clearInterval(interval); - }; - }, [currentUserId]); - - React.useEffect(() => { - if (!user || activeFolder === "settings") return; - - const interval = window.setInterval(() => { - void reloadConversations().catch(() => { - // Keep background refresh failures quiet; the next interval will retry. - }); - }, 10_000); - - return () => window.clearInterval(interval); - }, [activeFolder, reloadConversations, user]); - React.useEffect(() => { if (!user || isLoading || route.kind !== "settings") return; const canManage = user.role === "owner" || user.role === "admin"; @@ -188,7 +124,7 @@ export function App(): React.ReactElement { immersiveOnCompact={activeFolder !== "settings" && selectedId !== null} mailboxId={mailboxId} mailboxes={contentMailboxes} - unread={notifications.unread} + unread={mailSync.notifications.unread} search={search} user={user} updateInProgress={updateMonitor.progress !== null} @@ -210,7 +146,6 @@ export function App(): React.ReactElement { onSearchChange={setSearch} onSignedOut={() => { setUser(null); - setConversations([]); }} >
@@ -220,7 +155,7 @@ export function App(): React.ReactElement { activeTab={settingsTab} canManage={user.role === "owner" || user.role === "admin"} mailboxes={mailboxes} - notifications={notifications} + notifications={mailSync.notifications} setup={setup} users={users} onRefresh={() => void reload()} @@ -233,11 +168,10 @@ export function App(): React.ReactElement { ) : ( void reloadConversations()} - onUnreadChange={notifications.refresh} + onRefresh={() => void mailSync.refresh().catch(() => undefined)} onMessageRouteChange={(folder, messageId) => navigate({ kind: "mail", folder, messageId }) } @@ -256,7 +190,7 @@ export function App(): React.ReactElement { mode="new" open={composeOpen} onOpenChange={setComposeOpen} - onSent={() => void reloadConversations()} + onSent={() => void mailSync.refresh().catch(() => undefined)} /> ) : null} diff --git a/app/features/inbox/inbox-page.tsx b/app/features/inbox/inbox-page.tsx index 58c7b90..3950b87 100644 --- a/app/features/inbox/inbox-page.tsx +++ b/app/features/inbox/inbox-page.tsx @@ -18,7 +18,6 @@ type InboxPageProps = { mailboxes: Mailbox[]; selectedId: string | null; onRefresh: () => void; - onUnreadChange: () => void; onMessageRouteChange: (folder: MailFolderId, messageId: string | null) => void; onSelect: (messageId: string) => void; }; @@ -29,7 +28,6 @@ export function InboxPage({ mailboxes, selectedId, onRefresh, - onUnreadChange, onMessageRouteChange, onSelect }: InboxPageProps): React.ReactElement { @@ -78,7 +76,6 @@ export function InboxPage({ ); } onRefreshRef.current(); - onUnreadChange(); }) .catch(() => undefined); } @@ -94,7 +91,7 @@ export function InboxPage({ return () => { cancelled = true; }; - }, [activeFolder, onUnreadChange, selectedId]); + }, [activeFolder, selectedId]); const selectedThreadId = thread[0]?.threadId ?? @@ -120,7 +117,6 @@ export function InboxPage({ if (!selectedId) return; await runConversationAction(selectedId, action, activeFolder); onRefresh(); - onUnreadChange(); if ( action === "archive" || action === "trash" || diff --git a/app/features/messages/incoming-sound.ts b/app/features/messages/incoming-sound.ts deleted file mode 100644 index fcf69d4..0000000 --- a/app/features/messages/incoming-sound.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MessageSummary } from "./types"; - -export type IncomingMessageSnapshot = { - hasNewMessages: boolean; - knownIds: Set; -}; - -export function mergeIncomingMessageIds( - knownIds: ReadonlySet | null, - messages: MessageSummary[] -): IncomingMessageSnapshot { - const incomingIds = messages - .filter((message) => message.direction === "inbound") - .map((message) => message.id); - const hasNewMessages = - knownIds !== null && incomingIds.some((messageId) => !knownIds.has(messageId)); - - return { - hasNewMessages, - knownIds: new Set([...(knownIds ?? []), ...incomingIds]) - }; -} diff --git a/app/features/messages/use-mail-sync.ts b/app/features/messages/use-mail-sync.ts new file mode 100644 index 0000000..b2afe25 --- /dev/null +++ b/app/features/messages/use-mail-sync.ts @@ -0,0 +1,127 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { useNotifications } from "@/features/notifications/use-notifications"; +import { playNotificationSound } from "@/lib/notification-sounds"; +import type { FolderId } from "@/lib/routes"; + +import { listConversations } from "./api"; +import type { ConversationSummary } from "./types"; + +const refreshIntervalMs = 10_000; + +type MailSyncOptions = { + activeFolder: FolderId; + mailboxId: string; + search: string; + userId: string | null; +}; + +export function useMailSync({ activeFolder, mailboxId, search, userId }: MailSyncOptions): { + conversations: ConversationSummary[]; + notifications: ReturnType; + refresh: () => Promise; +} { + const [conversations, setConversations] = React.useState([]); + const notifications = useNotifications(userId); + const refreshNotifications = notifications.refresh; + const latestInboundId = React.useRef(null); + const hasInboundSnapshot = React.useRef(false); + const currentUserId = React.useRef(userId); + const syncKey = [userId, activeFolder, mailboxId, search].join("\u0000"); + const currentSyncKey = React.useRef(syncKey); + const inFlight = React.useRef<{ key: string; promise: Promise } | null>(null); + currentUserId.current = userId; + currentSyncKey.current = syncKey; + + const refresh = React.useCallback((): Promise => { + if (inFlight.current?.key === syncKey) return inFlight.current.promise; + + const promise = (async () => { + if (!userId) { + setConversations([]); + await refreshNotifications(); + return; + } + + const [notificationResult, conversationResult] = await Promise.allSettled([ + refreshNotifications(), + activeFolder === "settings" + ? Promise.resolve(null) + : listConversations({ + folder: activeFolder, + mailboxId: mailboxId === "all" ? undefined : mailboxId, + search: search || undefined + }) + ]); + if (currentSyncKey.current !== syncKey || currentUserId.current !== userId) return; + + if (conversationResult.status === "fulfilled" && conversationResult.value !== null) { + setConversations(conversationResult.value); + } + if (notificationResult.status === "fulfilled") { + const nextInboundId = notificationResult.value.latestInboundMessageId; + if ( + hasInboundSnapshot.current && + nextInboundId !== null && + nextInboundId !== latestInboundId.current + ) { + playNotificationSound("incoming-email"); + } + latestInboundId.current = nextInboundId; + hasInboundSnapshot.current = true; + } + + if (conversationResult.status === "rejected") throw conversationResult.reason; + })(); + inFlight.current = { key: syncKey, promise }; + const clearInFlight = (): void => { + if (inFlight.current?.promise === promise) inFlight.current = null; + }; + void promise.then(clearInFlight, clearInFlight); + return promise; + }, [activeFolder, mailboxId, refreshNotifications, search, syncKey, userId]); + + React.useEffect(() => { + latestInboundId.current = null; + hasInboundSnapshot.current = false; + if (!userId) setConversations([]); + }, [userId]); + + React.useEffect(() => { + if (!userId) { + void refresh(); + return; + } + + let active = true; + const runRefresh = (reportError = false): void => { + void refresh().catch((error: unknown) => { + if (active && reportError) { + toast.error(error instanceof Error ? error.message : "Mail could not be refreshed."); + } + }); + }; + const refreshWhenVisible = (): void => { + if (document.visibilityState === "visible") runRefresh(); + }; + const handleServiceWorkerMessage = (event: MessageEvent): void => { + if (event.data?.type === "hqbase:push-received") runRefresh(); + }; + + runRefresh(true); + const interval = window.setInterval(runRefresh, refreshIntervalMs); + window.addEventListener("focus", refreshWhenVisible); + document.addEventListener("visibilitychange", refreshWhenVisible); + navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage); + return () => { + active = false; + window.clearInterval(interval); + window.removeEventListener("focus", refreshWhenVisible); + document.removeEventListener("visibilitychange", refreshWhenVisible); + navigator.serviceWorker?.removeEventListener("message", handleServiceWorkerMessage); + }; + }, [refresh, userId]); + + return { conversations, notifications, refresh }; +} diff --git a/app/features/notifications/types.ts b/app/features/notifications/types.ts index f153ca1..e5dd169 100644 --- a/app/features/notifications/types.ts +++ b/app/features/notifications/types.ts @@ -5,6 +5,7 @@ export type UnreadCounts = { }; export type NotificationStatus = { + latestInboundMessageId: string | null; unread: UnreadCounts; vapidPublicKey: string | null; }; @@ -23,6 +24,6 @@ export type NotificationController = { enable: () => Promise; error: string | null; isBusy: boolean; - refresh: () => Promise; + refresh: () => Promise; unread: UnreadCounts; }; diff --git a/app/features/notifications/use-notifications.ts b/app/features/notifications/use-notifications.ts index 093643d..4b8f25d 100644 --- a/app/features/notifications/use-notifications.ts +++ b/app/features/notifications/use-notifications.ts @@ -21,70 +21,44 @@ import type { } from "./types"; const emptyUnread: UnreadCounts = { catchall: 0, inbox: 0, total: 0 }; -const refreshIntervalMs = 10_000; +const emptyStatus: NotificationStatus = { + latestInboundMessageId: null, + unread: emptyUnread, + vapidPublicKey: null +}; -export function useNotifications( - userId: string | null, - onForegroundPush: () => void -): NotificationController { - const [status, setStatus] = React.useState({ - unread: emptyUnread, - vapidPublicKey: null - }); +export function useNotifications(userId: string | null): NotificationController { + const [status, setStatus] = React.useState(emptyStatus); const [deviceState, setDeviceState] = React.useState("checking"); const [isBusy, setIsBusy] = React.useState(false); const [error, setError] = React.useState(null); const reconciledEndpoint = React.useRef(null); - const onForegroundPushRef = React.useRef(onForegroundPush); - React.useEffect(() => { - onForegroundPushRef.current = onForegroundPush; - }, [onForegroundPush]); + const activeUserId = React.useRef(userId); + activeUserId.current = userId; const refresh = React.useCallback(async () => { if (!userId) { - setStatus({ unread: emptyUnread, vapidPublicKey: null }); + setStatus(emptyStatus); setDeviceState("checking"); await applyUnreadIndicators(emptyUnread); - return; + return emptyStatus; } const nextStatus = await getNotificationStatus(); + if (activeUserId.current !== userId) return emptyStatus; setStatus(nextStatus); await applyUnreadIndicators(nextStatus.unread); await reconcileDevice(nextStatus, reconciledEndpoint, setDeviceState); + return nextStatus; }, [userId]); React.useEffect(() => { - if (!userId) { - void applyUnreadIndicators(emptyUnread); - return; - } - let active = true; - const runRefresh = async () => { - try { - await refresh(); - if (active) setError(null); - } catch { - // A later focus, foreground push, or interval retries without interrupting mail. - } - }; - const handleFocus = () => void runRefresh(); - const handleServiceWorkerMessage = (event: MessageEvent) => { - if (event.data?.type !== "hqbase:push-received") return; - onForegroundPushRef.current(); - void runRefresh(); - }; - - void runRefresh(); - window.addEventListener("focus", handleFocus); - navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage); - const interval = window.setInterval(runRefresh, refreshIntervalMs); - return () => { - active = false; - window.clearInterval(interval); - window.removeEventListener("focus", handleFocus); - navigator.serviceWorker?.removeEventListener("message", handleServiceWorkerMessage); - }; - }, [refresh, userId]); + if (userId) return; + reconciledEndpoint.current = null; + setStatus(emptyStatus); + setDeviceState("checking"); + setError(null); + void applyUnreadIndicators(emptyUnread); + }, [userId]); const enable = React.useCallback(async () => { setIsBusy(true); diff --git a/biome.json b/biome.json index e794009..2647de8 100644 --- a/biome.json +++ b/biome.json @@ -80,6 +80,11 @@ } }, "overrides": [ + { + "includes": ["test/unit/app/render-hook.tsx"], + "linter": { "enabled": false }, + "assist": { "enabled": false } + }, { "includes": ["app/features/setup/use-setup-flow.ts"], "linter": { "enabled": false }, diff --git a/package.json b/package.json index 41e03bd..30476bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index 5336281..4176634 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -1,7 +1,7 @@ import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; -const roots = ["app", "worker"]; +const roots = ["app", "worker", "scripts"]; const hardLimit = 400; const reviewLimit = 300; const failures = []; @@ -46,7 +46,7 @@ async function sourceFiles(root) { for (const entry of entries) { const location = path.join(root, entry.name); if (entry.isDirectory()) files.push(...(await sourceFiles(location))); - else if (/\.(?:ts|tsx)$/.test(entry.name)) files.push(location); + else if (/\.(?:mjs|ts|tsx)$/.test(entry.name)) files.push(location); } return files; } diff --git a/scripts/release/active-version.mjs b/scripts/release/active-version.mjs index cb39813..a0a768d 100644 --- a/scripts/release/active-version.mjs +++ b/scripts/release/active-version.mjs @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { attemptRun, capture as captureRun, emitCommandOutput } from "./command.mjs"; export function inspectActiveRelease(cwd, workerName, options = {}) { const attempt = options.attempt ?? attemptRun; @@ -104,24 +104,3 @@ function activeVersionId(deployment) { } return activeVersions[0].version_id; } - -function captureRun(command, args, cwd) { - return execFileSync(command, args, { - cwd, - env: { ...process.env, CI: process.env.CI ?? "true" }, - encoding: "utf8" - }); -} - -function attemptRun(command, args, cwd) { - return spawnSync(command, args, { - cwd, - env: { ...process.env, CI: process.env.CI ?? "true" }, - encoding: "utf8" - }); -} - -function emitCommandOutput(result) { - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); -} diff --git a/scripts/release/command.mjs b/scripts/release/command.mjs new file mode 100644 index 0000000..e6e2531 --- /dev/null +++ b/scripts/release/command.mjs @@ -0,0 +1,34 @@ +import { execFileSync, spawnSync } from "node:child_process"; + +function commandEnvironment() { + return { ...process.env, CI: process.env.CI ?? "true" }; +} + +export function run(command, args, cwd) { + execFileSync(command, args, { + cwd, + env: commandEnvironment(), + stdio: "inherit" + }); +} + +export function capture(command, args, cwd) { + return execFileSync(command, args, { + cwd, + env: commandEnvironment(), + encoding: "utf8" + }); +} + +export function attemptRun(command, args, cwd) { + return spawnSync(command, args, { + cwd, + env: commandEnvironment(), + encoding: "utf8" + }); +} + +export function emitCommandOutput(result) { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); +} diff --git a/scripts/release/deploy.mjs b/scripts/release/deploy.mjs index 7fb4688..421fe48 100644 --- a/scripts/release/deploy.mjs +++ b/scripts/release/deploy.mjs @@ -1,23 +1,46 @@ #!/usr/bin/env node -import { execFileSync, spawnSync } from "node:child_process"; -import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import webpush from "web-push"; -import { inspectActiveRelease, isWorkerNotFound } from "./active-version.mjs"; + +import { inspectActiveRelease } from "./active-version.mjs"; +import { capture, run } from "./command.mjs"; +import { + compareVersions, + hqbaseReleaseTag, + loadVerifiedRelease, + normalizeConfig, + verifyManifest +} from "./manifest.mjs"; +import { + deploySource, + executeSql, + missingRequiredSecrets, + needsInitialAuthSecret, + workerNameFromConfig +} from "./worker-deploy.mjs"; const root = resolve(import.meta.dirname, "../.."); -const packageVersion = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")).version; -const publicKey = "MCowBQYDK2VwAyEAsVwKniCvpHDwbbnjTPP0SuIIG97cRL+iFBQvay9OrU4="; -const stableManifestUrl = "https://github.com/HQBase/hqbase/releases/latest/download/stable.json"; + +export { + compareVersions, + deploySource, + executeSql, + hqbaseReleaseTag, + loadVerifiedRelease, + missingRequiredSecrets, + needsInitialAuthSecret, + normalizeConfig, + verifyManifest, + workerNameFromConfig +}; export async function deploy(options = {}) { const configFile = resolve(options.configFile ?? resolve(root, "wrangler.jsonc")); if (process.env.HQBASE_FORCE_SOURCE_DEPLOY === "1") return sourceDeploy(root); const { bytes, manifest } = await loadVerifiedRelease({ artifactFile: options.artifactFile ?? process.env.HQBASE_RELEASE_ARTIFACT_FILE, - checkedOutVersion: packageVersion, expectedVersion: options.expectedVersion ?? process.env.HQBASE_EXPECTED_RELEASE_VERSION, fetcher: options.fetcher, manifestFile: options.manifestFile ?? process.env.HQBASE_RELEASE_MANIFEST_FILE, @@ -43,21 +66,7 @@ export async function deploy(options = {}) { const activeRelease = inspectActiveRelease(source, config.name); const releaseTag = hqbaseReleaseTag(manifest.version, manifest.artifact.sha256); if (!activeRelease) { - run( - "pnpm", - [ - "exec", - "wrangler", - "d1", - "migrations", - "apply", - "DB", - "--remote", - "--config", - "wrangler.jsonc" - ], - source - ); + applyMigrations(source); deploySource(source, { releaseTag }); executeSql( source, @@ -79,6 +88,7 @@ export async function deploy(options = {}) { console.log(`HQBase ${manifest.version} is already the active signed release.`); return; } + const bookmark = findString( JSON.parse( capture( @@ -100,25 +110,12 @@ export async function deploy(options = {}) { "bookmark" ); const workerVersion = activeRelease.versionId; - if (!bookmark || !workerVersion) + if (!bookmark || !workerVersion) { throw new Error("Could not establish the update recovery checkpoint."); + } recovery = { bookmark, workerVersion, name: config.name }; - run( - "pnpm", - [ - "exec", - "wrangler", - "d1", - "migrations", - "apply", - "DB", - "--remote", - "--config", - "wrangler.jsonc" - ], - source - ); - const updateId = crypto.randomUUID(); + applyMigrations(source); + const updateId = randomUUID(); executeSql( source, `INSERT INTO update_history (id, from_version, to_version, checkpoint_bookmark, worker_version, state, started_at) VALUES (${quote(updateId)}, ${quote(activeRelease.version)}, ${quote(manifest.version)}, ${quote(bookmark)}, ${quote(workerVersion)}, 'started', datetime('now'))` @@ -160,284 +157,47 @@ export async function deploy(options = {}) { ); console.log(`HQBase updated to ${manifest.version}.`); } catch (error) { - if (recovery) { - console.error( - `D1 recovery: pnpm exec wrangler d1 time-travel restore DB --bookmark ${recovery.bookmark} --config wrangler.jsonc` - ); - console.error( - `Worker recovery: pnpm exec wrangler versions deploy ${recovery.workerVersion}@100% --name ${recovery.name} --config wrangler.jsonc` - ); - } + if (recovery) reportRecovery(recovery); throw error; } finally { rmSync(workspace, { recursive: true, force: true }); } } -export async function loadVerifiedRelease(options = {}) { - const fetcher = options.fetcher ?? fetch; - let envelope; - if (options.manifestFile) { - envelope = JSON.parse(readFileSync(resolve(options.manifestFile), "utf8")); - } else { - const response = await fetcher(options.manifestUrl ?? stableManifestUrl); - if (!response.ok) throw new Error(`Release check failed (${response.status}).`); - envelope = await response.json(); - } - const manifest = verifyManifest(envelope, options.publicKeyBase64); - if (options.expectedVersion) { - if (manifest.version !== options.expectedVersion) { - throw new Error( - `Expected signed HQBase ${options.expectedVersion}, received ${manifest.version}.` - ); - } - } else if (compareVersions(manifest.version, options.checkedOutVersion ?? packageVersion) < 0) { - throw new Error( - `HQBase ${options.checkedOutVersion ?? packageVersion} has not been published as a signed stable release yet.` - ); - } - - let bytes; - if (options.artifactFile) { - bytes = readFileSync(resolve(options.artifactFile)); - } else { - const artifactResponse = await fetcher(manifest.artifact.url); - if (!artifactResponse.ok) - throw new Error(`Release download failed (${artifactResponse.status}).`); - bytes = Buffer.from(await artifactResponse.arrayBuffer()); - } - if ( - bytes.length !== manifest.artifact.size || - createHash("sha256").update(bytes).digest("hex") !== manifest.artifact.sha256 - ) { - throw new Error("Release artifact integrity check failed."); - } - return { bytes, manifest }; -} - -export function verifyManifest(envelope, publicKeyBase64 = publicKey) { - const key = createPublicKey({ - key: Buffer.from(publicKeyBase64, "base64"), - format: "der", - type: "spki" - }); - if ( - !verify( - null, - Buffer.from(envelope.payload, "base64url"), - key, - Buffer.from(envelope.signature, "base64url") - ) - ) - throw new Error("Release manifest signature is invalid."); - const manifest = JSON.parse(Buffer.from(envelope.payload, "base64url").toString("utf8")); - if ( - manifest.format !== "hqbase-release-v1" || - manifest.product !== "hqbase" || - manifest.channel !== "stable" || - !/^\d+\.\d+\.\d+/.test(manifest.version) || - !/^\d+\.\d+\.\d+/.test(manifest.minVersion) || - !/^[a-f0-9]{64}$/.test(manifest.artifact?.sha256) || - !Number.isInteger(manifest.artifact?.size) || - manifest.artifact.size <= 0 - ) - throw new Error("Release manifest is incompatible."); - return manifest; -} -export function compareVersions(left, right) { - const a = left.split("-")[0].split(".").map(Number); - const b = right.split("-")[0].split(".").map(Number); - for (let i = 0; i < 3; i += 1) { - const difference = (a[i] ?? 0) - (b[i] ?? 0); - if (difference) return difference; - } - return 0; -} -export function normalizeConfig(config, version, artifactSha256) { - return { - ...config, - $schema: "./node_modules/wrangler/config-schema.json", - main: "worker/index.ts", - compatibility_flags: [ - ...new Set([...(config.compatibility_flags ?? []), "global_fetch_strictly_public"]) - ], - assets: { - ...config.assets, - directory: "./dist" - }, - vars: { - ...config.vars, - HQBASE_APP_VERSION: version, - ...(artifactSha256 ? { HQBASE_RELEASE_ARTIFACT_SHA256: artifactSha256 } : {}), - HQBASE_WORKER_NAME: workerNameFromConfig(config) - }, - d1_databases: config.d1_databases?.map((binding) => ({ - ...binding, - migrations_dir: "migrations" - })) - }; -} -export function hqbaseReleaseTag(version, artifactSha256) { - if (!/^\d+\.\d+\.\d+/.test(version) || !/^[a-f0-9]{64}$/.test(artifactSha256)) - throw new Error("HQBase release identity is invalid."); - return `hqbase:${version}:${artifactSha256}`; -} function sourceDeploy(cwd) { run("pnpm", ["build"], cwd); run("pnpm", ["db:migrate:remote"], cwd); deploySource(cwd); run("pnpm", ["hqbase", "postdeploy"], cwd); } -export function deploySource(cwd, options = {}) { - const execute = options.run ?? run; - const attempt = options.attempt ?? attemptRun; - const workersCi = options.workersCi ?? process.env.WORKERS_CI === "1"; - const workerName = options.workerName ?? workerNameFromConfigFile(resolve(cwd, "wrangler.jsonc")); - const deployArgs = [ - "exec", - "wrangler", - "deploy", - "--keep-vars", - "--var", - `HQBASE_WORKER_NAME:${workerName}` - ]; - if (options.releaseTag) deployArgs.push("--tag", options.releaseTag); - - if (!workersCi) { - execute("pnpm", deployArgs, cwd); - return; - } - const inspection = attempt( - "pnpm", - ["exec", "wrangler", "secret", "list", "--format", "json"], - cwd - ); - let missingSecrets; - try { - missingSecrets = missingRequiredSecrets(inspection, [ - "BETTER_AUTH_SECRET", - "VAPID_PUBLIC_KEY", - "VAPID_PRIVATE_KEY" - ]); - } catch (error) { - emitCommandOutput(inspection); - throw error; - } - if (missingSecrets.length === 0) { - execute("pnpm", deployArgs, cwd); - return; - } - - if (missingSecrets.includes("BETTER_AUTH_SECRET")) { - deployArgs.push( - "--var", - `HQBASE_INSTALLATION_ID:${options.randomUUID?.() ?? crypto.randomUUID()}` - ); - } - - const workspace = mkdtempSync(resolve(tmpdir(), "hqbase-secrets-")); - const secretsFile = resolve(workspace, "secrets.json"); - try { - const secrets = {}; - if (missingSecrets.includes("BETTER_AUTH_SECRET")) { - const configuredSecret = process.env.HQBASE_AUTH_SECRET; - const bytes = configuredSecret ? null : (options.randomBytes ?? randomBytes)(32); - secrets.BETTER_AUTH_SECRET = configuredSecret ?? bytes?.toString("base64url"); - } - if ( - missingSecrets.includes("VAPID_PUBLIC_KEY") || - missingSecrets.includes("VAPID_PRIVATE_KEY") - ) { - const configuredPublicKey = process.env.HQBASE_VAPID_PUBLIC_KEY; - const configuredPrivateKey = process.env.HQBASE_VAPID_PRIVATE_KEY; - const generated = - configuredPublicKey && configuredPrivateKey - ? { publicKey: configuredPublicKey, privateKey: configuredPrivateKey } - : (options.generateVapidKeys ?? webpush.generateVAPIDKeys)(); - secrets.VAPID_PUBLIC_KEY = generated.publicKey; - secrets.VAPID_PRIVATE_KEY = generated.privateKey; - } - writeFileSync(secretsFile, `${JSON.stringify(secrets)}\n`, { mode: 0o600 }); - execute("pnpm", [...deployArgs, "--secrets-file", secretsFile], cwd); - } finally { - rmSync(workspace, { recursive: true, force: true }); - } -} -export function workerNameFromConfig(config) { - if (typeof config?.name !== "string" || !config.name.trim()) { - throw new Error("wrangler.jsonc must define the deployed Worker name."); - } - return config.name.trim(); -} -function workerNameFromConfigFile(configFile) { - return workerNameFromConfig(JSON.parse(readFileSync(configFile, "utf8"))); -} -export function needsInitialAuthSecret(result, secretName) { - return missingRequiredSecrets(result, [secretName]).length > 0; -} -export function missingRequiredSecrets(result, secretNames) { - if (result.status === 0) { - const secrets = JSON.parse(result.stdout || "[]"); - if (!Array.isArray(secrets)) throw new Error("Wrangler returned an invalid secret list."); - const configured = new Set(secrets.map((secret) => secret?.name).filter(Boolean)); - return secretNames.filter((secretName) => !configured.has(secretName)); - } - if (isWorkerNotFound(result)) { - return [...secretNames]; - } - throw result.error ?? new Error(`wrangler secret list exited with status ${result.status}.`); -} -export function executeSql(cwd, command, options = {}) { - const execute = options.attempt ?? attemptRun; - const result = execute( +function applyMigrations(cwd) { + run( "pnpm", [ "exec", "wrangler", "d1", - "execute", + "migrations", + "apply", "DB", "--remote", - "--command", - command, "--config", "wrangler.jsonc" ], cwd ); - if (result.status === 0) return; - (options.emit ?? emitCommandOutput)(result); - throw ( - result.error ?? - new Error(`wrangler d1 execute exited with status ${result.status ?? "signal"}.`) - ); -} -function run(command, args, cwd) { - execFileSync(command, args, { - cwd, - env: { ...process.env, CI: process.env.CI ?? "true" }, - stdio: "inherit" - }); } -function capture(command, args, cwd) { - return execFileSync(command, args, { - cwd, - env: { ...process.env, CI: process.env.CI ?? "true" }, - encoding: "utf8" - }); -} -function attemptRun(command, args, cwd) { - return spawnSync(command, args, { - cwd, - env: { ...process.env, CI: process.env.CI ?? "true" }, - encoding: "utf8" - }); -} -function emitCommandOutput(result) { - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); + +function reportRecovery(recovery) { + console.error( + `D1 recovery: pnpm exec wrangler d1 time-travel restore DB --bookmark ${recovery.bookmark} --config wrangler.jsonc` + ); + console.error( + `Worker recovery: pnpm exec wrangler versions deploy ${recovery.workerVersion}@100% --name ${recovery.name} --config wrangler.jsonc` + ); } + function findString(value, ...keys) { if (!value || typeof value !== "object") return null; for (const [key, child] of Object.entries(value)) { @@ -447,6 +207,7 @@ function findString(value, ...keys) { } return null; } + function quote(value) { return `'${String(value).replaceAll("'", "''")}'`; } diff --git a/scripts/release/manifest.mjs b/scripts/release/manifest.mjs new file mode 100644 index 0000000..1924a1d --- /dev/null +++ b/scripts/release/manifest.mjs @@ -0,0 +1,125 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { workerNameFromConfig } from "./worker-deploy.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const packageVersion = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")).version; +const publicKey = "MCowBQYDK2VwAyEAsVwKniCvpHDwbbnjTPP0SuIIG97cRL+iFBQvay9OrU4="; +const stableManifestUrl = "https://github.com/HQBase/hqbase/releases/latest/download/stable.json"; + +export async function loadVerifiedRelease(options = {}) { + const fetcher = options.fetcher ?? fetch; + let envelope; + if (options.manifestFile) { + envelope = JSON.parse(readFileSync(resolve(options.manifestFile), "utf8")); + } else { + const response = await fetcher(options.manifestUrl ?? stableManifestUrl); + if (!response.ok) throw new Error(`Release check failed (${response.status}).`); + envelope = await response.json(); + } + const manifest = verifyManifest(envelope, options.publicKeyBase64); + if (options.expectedVersion) { + if (manifest.version !== options.expectedVersion) { + throw new Error( + `Expected signed HQBase ${options.expectedVersion}, received ${manifest.version}.` + ); + } + } else if (compareVersions(manifest.version, options.checkedOutVersion ?? packageVersion) < 0) { + throw new Error( + `HQBase ${options.checkedOutVersion ?? packageVersion} has not been published as a signed stable release yet.` + ); + } + + let bytes; + if (options.artifactFile) { + bytes = readFileSync(resolve(options.artifactFile)); + } else { + const artifactResponse = await fetcher(manifest.artifact.url); + if (!artifactResponse.ok) + throw new Error(`Release download failed (${artifactResponse.status}).`); + bytes = Buffer.from(await artifactResponse.arrayBuffer()); + } + if ( + bytes.length !== manifest.artifact.size || + createHash("sha256").update(bytes).digest("hex") !== manifest.artifact.sha256 + ) { + throw new Error("Release artifact integrity check failed."); + } + return { bytes, manifest }; +} + +export function verifyManifest(envelope, publicKeyBase64 = publicKey) { + const key = createPublicKey({ + key: Buffer.from(publicKeyBase64, "base64"), + format: "der", + type: "spki" + }); + if ( + !verify( + null, + Buffer.from(envelope.payload, "base64url"), + key, + Buffer.from(envelope.signature, "base64url") + ) + ) { + throw new Error("Release manifest signature is invalid."); + } + const manifest = JSON.parse(Buffer.from(envelope.payload, "base64url").toString("utf8")); + if ( + manifest.format !== "hqbase-release-v1" || + manifest.product !== "hqbase" || + manifest.channel !== "stable" || + !/^\d+\.\d+\.\d+/.test(manifest.version) || + !/^\d+\.\d+\.\d+/.test(manifest.minVersion) || + !/^[a-f0-9]{64}$/.test(manifest.artifact?.sha256) || + !Number.isInteger(manifest.artifact?.size) || + manifest.artifact.size <= 0 + ) { + throw new Error("Release manifest is incompatible."); + } + return manifest; +} + +export function compareVersions(left, right) { + const a = left.split("-")[0].split(".").map(Number); + const b = right.split("-")[0].split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + const difference = (a[index] ?? 0) - (b[index] ?? 0); + if (difference) return difference; + } + return 0; +} + +export function normalizeConfig(config, version, artifactSha256) { + return { + ...config, + $schema: "./node_modules/wrangler/config-schema.json", + main: "worker/index.ts", + compatibility_flags: [ + ...new Set([...(config.compatibility_flags ?? []), "global_fetch_strictly_public"]) + ], + assets: { + ...config.assets, + directory: "./dist" + }, + vars: { + ...config.vars, + HQBASE_APP_VERSION: version, + ...(artifactSha256 ? { HQBASE_RELEASE_ARTIFACT_SHA256: artifactSha256 } : {}), + HQBASE_WORKER_NAME: workerNameFromConfig(config) + }, + d1_databases: config.d1_databases?.map((binding) => ({ + ...binding, + migrations_dir: "migrations" + })) + }; +} + +export function hqbaseReleaseTag(version, artifactSha256) { + if (!/^\d+\.\d+\.\d+/.test(version) || !/^[a-f0-9]{64}$/.test(artifactSha256)) { + throw new Error("HQBase release identity is invalid."); + } + return `hqbase:${version}:${artifactSha256}`; +} diff --git a/scripts/release/worker-deploy.mjs b/scripts/release/worker-deploy.mjs new file mode 100644 index 0000000..a85453c --- /dev/null +++ b/scripts/release/worker-deploy.mjs @@ -0,0 +1,134 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import webpush from "web-push"; + +import { isWorkerNotFound } from "./active-version.mjs"; +import { attemptRun, emitCommandOutput, run } from "./command.mjs"; + +export function deploySource(cwd, options = {}) { + const execute = options.run ?? run; + const attempt = options.attempt ?? attemptRun; + const workersCi = options.workersCi ?? process.env.WORKERS_CI === "1"; + const workerName = options.workerName ?? workerNameFromConfigFile(resolve(cwd, "wrangler.jsonc")); + const deployArgs = [ + "exec", + "wrangler", + "deploy", + "--keep-vars", + "--var", + `HQBASE_WORKER_NAME:${workerName}` + ]; + if (options.releaseTag) deployArgs.push("--tag", options.releaseTag); + + if (!workersCi) { + execute("pnpm", deployArgs, cwd); + return; + } + + const inspection = attempt( + "pnpm", + ["exec", "wrangler", "secret", "list", "--format", "json"], + cwd + ); + let missingSecrets; + try { + missingSecrets = missingRequiredSecrets(inspection, [ + "BETTER_AUTH_SECRET", + "VAPID_PUBLIC_KEY", + "VAPID_PRIVATE_KEY" + ]); + } catch (error) { + emitCommandOutput(inspection); + throw error; + } + if (missingSecrets.length === 0) { + execute("pnpm", deployArgs, cwd); + return; + } + + if (missingSecrets.includes("BETTER_AUTH_SECRET")) { + deployArgs.push("--var", `HQBASE_INSTALLATION_ID:${options.randomUUID?.() ?? randomUUID()}`); + } + + const workspace = mkdtempSync(resolve(tmpdir(), "hqbase-secrets-")); + const secretsFile = resolve(workspace, "secrets.json"); + try { + const secrets = {}; + if (missingSecrets.includes("BETTER_AUTH_SECRET")) { + const configuredSecret = process.env.HQBASE_AUTH_SECRET; + const bytes = configuredSecret ? null : (options.randomBytes ?? randomBytes)(32); + secrets.BETTER_AUTH_SECRET = configuredSecret ?? bytes?.toString("base64url"); + } + if ( + missingSecrets.includes("VAPID_PUBLIC_KEY") || + missingSecrets.includes("VAPID_PRIVATE_KEY") + ) { + const configuredPublicKey = process.env.HQBASE_VAPID_PUBLIC_KEY; + const configuredPrivateKey = process.env.HQBASE_VAPID_PRIVATE_KEY; + const generated = + configuredPublicKey && configuredPrivateKey + ? { publicKey: configuredPublicKey, privateKey: configuredPrivateKey } + : (options.generateVapidKeys ?? webpush.generateVAPIDKeys)(); + secrets.VAPID_PUBLIC_KEY = generated.publicKey; + secrets.VAPID_PRIVATE_KEY = generated.privateKey; + } + writeFileSync(secretsFile, `${JSON.stringify(secrets)}\n`, { mode: 0o600 }); + execute("pnpm", [...deployArgs, "--secrets-file", secretsFile], cwd); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +} + +export function workerNameFromConfig(config) { + if (typeof config?.name !== "string" || !config.name.trim()) { + throw new Error("wrangler.jsonc must define the deployed Worker name."); + } + return config.name.trim(); +} + +export function needsInitialAuthSecret(result, secretName) { + return missingRequiredSecrets(result, [secretName]).length > 0; +} + +export function missingRequiredSecrets(result, secretNames) { + if (result.status === 0) { + const secrets = JSON.parse(result.stdout || "[]"); + if (!Array.isArray(secrets)) throw new Error("Wrangler returned an invalid secret list."); + const configured = new Set(secrets.map((secret) => secret?.name).filter(Boolean)); + return secretNames.filter((secretName) => !configured.has(secretName)); + } + if (isWorkerNotFound(result)) return [...secretNames]; + throw result.error ?? new Error(`wrangler secret list exited with status ${result.status}.`); +} + +export function executeSql(cwd, command, options = {}) { + const execute = options.attempt ?? attemptRun; + const result = execute( + "pnpm", + [ + "exec", + "wrangler", + "d1", + "execute", + "DB", + "--remote", + "--command", + command, + "--config", + "wrangler.jsonc" + ], + cwd + ); + if (result.status === 0) return; + (options.emit ?? emitCommandOutput)(result); + throw ( + result.error ?? + new Error(`wrangler d1 execute exited with status ${result.status ?? "signal"}.`) + ); +} + +function workerNameFromConfigFile(configFile) { + return workerNameFromConfig(JSON.parse(readFileSync(configFile, "utf8"))); +} diff --git a/test/integration/worker/notifications.test.ts b/test/integration/worker/notifications.test.ts index 1354e2d..7702c9a 100644 --- a/test/integration/worker/notifications.test.ts +++ b/test/integration/worker/notifications.test.ts @@ -58,7 +58,7 @@ describe("notification persistence", () => { await insertMessage("msg_inbox", "mbx_one", "inbox", null); await insertMessage("msg_catchall", "mbx_one", "catchall", null); await insertMessage("msg_read", "mbx_one", "inbox", now); - await insertMessage("msg_other", "mbx_two", "inbox", null); + await insertMessage("msg_z_other", "mbx_two", "inbox", null); for (const [userId, endpoint] of [ ["usr_owner", "https://push.example/owner"], @@ -143,6 +143,7 @@ describe("notification persistence", () => { }); expect(status.status, await status.clone().text()).toBe(200); expect(await status.json()).toEqual({ + latestInboundMessageId: "msg_read", unread: { catchall: 1, inbox: 1, total: 2 }, vapidPublicKey: "integration-vapid-public-key" }); diff --git a/test/unit/app/compose/use-draft-autosave.test.tsx b/test/unit/app/compose/use-draft-autosave.test.tsx new file mode 100644 index 0000000..57027f3 --- /dev/null +++ b/test/unit/app/compose/use-draft-autosave.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import type { Draft } from "@/features/compose/api"; +import { useDraftAutosave } from "@/features/compose/use-draft-autosave"; +import { flushHookEffects, renderHook } from "../render-hook"; + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), + updateDraft: vi.fn() +})); + +vi.mock("@/features/compose/api", async (importOriginal) => ({ + ...(await importOriginal()), + updateDraft: mocks.updateDraft +})); +vi.mock("sonner", () => ({ + toast: { error: mocks.toastError } +})); + +const draft: Draft = { + id: "draft-1", + mailboxId: "mailbox-1", + replyToMessageId: null, + forwardOfMessageId: null, + from: "sender@example.com", + to: ["reader@example.com"], + cc: [], + bcc: [], + subject: "Original", + text: "Original body", + html: "

Original body

", + version: 1, + updatedAt: "2026-07-29T00:00:00.000Z", + attachments: [] +}; + +function options(overrides: Partial[0]> = {}) { + return { + open: false, + initialized: { current: true }, + draft, + identities: [{ mailboxId: "mailbox-1", address: "sender@example.com" }], + recoveryKey: "hqbase:draft-recovery:test", + replyToMessageId: null, + forwardOfMessageId: null, + from: draft.from, + to: draft.to.join(", "), + cc: "", + bcc: "", + subject: draft.subject, + text: draft.text, + html: draft.html, + setDraft: vi.fn(), + setSaveState: vi.fn(), + ...overrides + }; +} + +describe("useDraftAutosave", () => { + it("persists recovery state and saves the latest initialized draft after the debounce", async () => { + vi.useFakeTimers(); + const nextDraft = { ...draft, subject: "Changed", version: 2 }; + mocks.updateDraft.mockResolvedValue(nextDraft); + const initial = options(); + const hook = await renderHook(useDraftAutosave, initial); + hook.result.initializeAutosave(draft); + const changed = options({ + open: true, + subject: "Changed", + setDraft: initial.setDraft, + setSaveState: initial.setSaveState + }); + await hook.rerender(changed); + + expect(localStorage.getItem(initial.recoveryKey)).toContain('"subject":"Changed"'); + await flushHookEffects(() => { + vi.advanceTimersByTime(800); + }); + expect(mocks.updateDraft).toHaveBeenCalledWith( + draft.id, + expect.objectContaining({ + mailboxId: "mailbox-1", + subject: "Changed", + version: 1 + }) + ); + expect(changed.setDraft).toHaveBeenCalledWith(nextDraft); + expect(changed.setSaveState).toHaveBeenLastCalledWith("saved"); + expect(localStorage.getItem(initial.recoveryKey)).toBeNull(); + + await hook.unmount(); + vi.useRealTimers(); + }); +}); diff --git a/test/unit/app/layout/mail-shell.test.tsx b/test/unit/app/layout/mail-shell.test.tsx index 43028a9..30c5cf8 100644 --- a/test/unit/app/layout/mail-shell.test.tsx +++ b/test/unit/app/layout/mail-shell.test.tsx @@ -134,7 +134,6 @@ describe("mail shell", () => { onMessageRouteChange={() => undefined} onRefresh={() => undefined} onSelect={() => undefined} - onUnreadChange={() => undefined} /> ); diff --git a/test/unit/app/messages/conversation-reader.test.tsx b/test/unit/app/messages/conversation-reader.test.tsx index 19116ae..10796dd 100644 --- a/test/unit/app/messages/conversation-reader.test.tsx +++ b/test/unit/app/messages/conversation-reader.test.tsx @@ -94,7 +94,6 @@ describe("conversation reader", () => { onMessageRouteChange={() => undefined} onRefresh={() => undefined} onSelect={() => undefined} - onUnreadChange={() => undefined} /> ); const conversationHtml = renderToStaticMarkup( @@ -106,7 +105,6 @@ describe("conversation reader", () => { onMessageRouteChange={() => undefined} onRefresh={() => undefined} onSelect={() => undefined} - onUnreadChange={() => undefined} /> ); diff --git a/test/unit/app/messages/incoming-sound.test.ts b/test/unit/app/messages/incoming-sound.test.ts deleted file mode 100644 index 7541e58..0000000 --- a/test/unit/app/messages/incoming-sound.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { mergeIncomingMessageIds } from "@/features/messages/incoming-sound"; -import type { MessageSummary } from "@/features/messages/types"; - -function message(id: string, direction: MessageSummary["direction"]): MessageSummary { - return { - id, - threadId: `thread-${id}`, - mailboxId: "mailbox-1", - direction, - folder: direction === "inbound" ? "inbox" : "sent", - fromAddress: direction === "inbound" ? "sender@example.com" : "owner@example.com", - to: [direction === "inbound" ? "owner@example.com" : "recipient@example.com"], - subject: "Subject", - snippet: "Snippet", - receivedAt: direction === "inbound" ? "2026-07-28T12:00:00.000Z" : null, - sentAt: direction === "outbound" ? "2026-07-28T12:00:00.000Z" : null, - readAt: null, - starredAt: null, - hasAttachments: false, - createdAt: "2026-07-28T12:00:00.000Z" - }; -} - -describe("incoming mail sound detection", () => { - it("primes silently, reports new inbound mail once, and ignores outbound mail", () => { - const initial = mergeIncomingMessageIds(null, [ - message("inbound-1", "inbound"), - message("outbound-1", "outbound") - ]); - - expect(initial.hasNewMessages).toBe(false); - expect([...initial.knownIds]).toEqual(["inbound-1"]); - - const changed = mergeIncomingMessageIds(initial.knownIds, [ - message("inbound-2", "inbound"), - message("outbound-2", "outbound") - ]); - - expect(changed.hasNewMessages).toBe(true); - expect([...changed.knownIds]).toEqual(["inbound-1", "inbound-2"]); - - const repeated = mergeIncomingMessageIds(changed.knownIds, [ - message("inbound-1", "inbound"), - message("inbound-2", "inbound") - ]); - - expect(repeated.hasNewMessages).toBe(false); - }); -}); diff --git a/test/unit/app/messages/use-mail-sync.test.tsx b/test/unit/app/messages/use-mail-sync.test.tsx new file mode 100644 index 0000000..16c67e5 --- /dev/null +++ b/test/unit/app/messages/use-mail-sync.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useMailSync } from "@/features/messages/use-mail-sync"; +import { flushHookEffects, renderHook } from "../render-hook"; + +const mocks = vi.hoisted(() => ({ + listConversations: vi.fn(), + playNotificationSound: vi.fn(), + refreshNotifications: vi.fn(), + toastError: vi.fn() +})); + +vi.mock("@/features/messages/api", () => ({ + listConversations: mocks.listConversations +})); +vi.mock("@/features/notifications/use-notifications", () => ({ + useNotifications: () => ({ + deviceState: "available", + disable: vi.fn(), + enable: vi.fn(), + error: null, + isBusy: false, + refresh: mocks.refreshNotifications, + unread: { catchall: 0, inbox: 0, total: 0 } + }) +})); +vi.mock("@/lib/notification-sounds", () => ({ + playNotificationSound: mocks.playNotificationSound +})); +vi.mock("sonner", () => ({ + toast: { error: mocks.toastError } +})); + +function status(latestInboundMessageId: string) { + return { + latestInboundMessageId, + unread: { catchall: 0, inbox: 1, total: 1 }, + vapidPublicKey: null + }; +} + +describe("useMailSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses one refresh path for initial load, focus, unread state, and incoming sound", async () => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible" + }); + mocks.listConversations.mockResolvedValue([]); + mocks.refreshNotifications + .mockResolvedValueOnce(status("message-1")) + .mockResolvedValueOnce(status("message-2")); + const hook = await renderHook(useMailSync, { + activeFolder: "inbox", + mailboxId: "all", + search: "", + userId: "user-1" + }); + await flushHookEffects(); + + expect(mocks.listConversations).toHaveBeenCalledOnce(); + expect(mocks.refreshNotifications).toHaveBeenCalledOnce(); + expect(mocks.playNotificationSound).not.toHaveBeenCalled(); + + await flushHookEffects(() => window.dispatchEvent(new Event("focus"))); + expect(mocks.listConversations).toHaveBeenCalledTimes(2); + expect(mocks.refreshNotifications).toHaveBeenCalledTimes(2); + expect(mocks.playNotificationSound).toHaveBeenCalledWith("incoming-email"); + expect(hook.result.conversations).toEqual([]); + await hook.unmount(); + }); + + it("reports an initial conversation failure without coupling it to notification refresh", async () => { + mocks.listConversations.mockRejectedValueOnce(new Error("Conversations are unavailable.")); + mocks.refreshNotifications.mockResolvedValueOnce(status("message-1")); + + const hook = await renderHook(useMailSync, { + activeFolder: "inbox", + mailboxId: "all", + search: "", + userId: "user-1" + }); + await flushHookEffects(); + + expect(mocks.refreshNotifications).toHaveBeenCalledOnce(); + expect(mocks.toastError).toHaveBeenCalledWith("Conversations are unavailable."); + await hook.unmount(); + }); +}); diff --git a/test/unit/app/notifications/notification-settings.test.tsx b/test/unit/app/notifications/notification-settings.test.tsx index 888162e..8f50819 100644 --- a/test/unit/app/notifications/notification-settings.test.tsx +++ b/test/unit/app/notifications/notification-settings.test.tsx @@ -37,7 +37,11 @@ function controller(deviceState: NotificationController["deviceState"]): Notific enable: async () => undefined, error: null, isBusy: false, - refresh: async () => undefined, + refresh: async () => ({ + latestInboundMessageId: null, + unread: { catchall: 1, inbox: 2, total: 3 }, + vapidPublicKey: "public-key" + }), unread: { catchall: 1, inbox: 2, total: 3 } }; } diff --git a/test/unit/app/notifications/use-notifications.test.tsx b/test/unit/app/notifications/use-notifications.test.tsx new file mode 100644 index 0000000..5991343 --- /dev/null +++ b/test/unit/app/notifications/use-notifications.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import { useNotifications } from "@/features/notifications/use-notifications"; +import { flushHookEffects, renderHook } from "../render-hook"; + +const mocks = vi.hoisted(() => ({ + applyUnreadIndicators: vi.fn(async () => undefined), + currentPushSubscription: vi.fn(), + getNotificationStatus: vi.fn(), + saveNotificationSubscription: vi.fn(async () => undefined) +})); + +vi.mock("@/features/notifications/api", () => ({ + deleteNotificationSubscription: vi.fn(async () => undefined), + getNotificationStatus: mocks.getNotificationStatus, + saveNotificationSubscription: mocks.saveNotificationSubscription +})); +vi.mock("@/features/notifications/browser", () => ({ + applyUnreadIndicators: mocks.applyUnreadIndicators, + currentPushSubscription: mocks.currentPushSubscription, + notificationPermission: () => "granted", + serializePushSubscription: (subscription: { endpoint: string }) => ({ + endpoint: subscription.endpoint, + expirationTime: null, + keys: { auth: "auth", p256dh: "p256dh" } + }), + subscribeToPush: vi.fn(), + supportsPushNotifications: () => true +})); + +describe("useNotifications", () => { + it("refreshes status only when asked and reconciles the current device", async () => { + const subscription = { endpoint: "https://push.example/device" }; + const status = { + latestInboundMessageId: "message-2", + unread: { catchall: 1, inbox: 2, total: 3 }, + vapidPublicKey: "public-key" + }; + mocks.currentPushSubscription.mockResolvedValue(subscription); + mocks.getNotificationStatus.mockResolvedValue(status); + const initialProps: { userId: string | null } = { userId: "user-1" }; + const hook = await renderHook(({ userId }: { userId: string | null }) => { + return useNotifications(userId); + }, initialProps); + + expect(mocks.getNotificationStatus).not.toHaveBeenCalled(); + await flushHookEffects(() => hook.result.refresh()); + + expect(hook.result.unread).toEqual(status.unread); + expect(hook.result.deviceState).toBe("enabled"); + expect(mocks.saveNotificationSubscription).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: subscription.endpoint }) + ); + expect(mocks.applyUnreadIndicators).toHaveBeenLastCalledWith(status.unread); + + await hook.rerender({ userId: null }); + expect(hook.result.unread).toEqual({ catchall: 0, inbox: 0, total: 0 }); + expect(hook.result.deviceState).toBe("checking"); + await hook.unmount(); + }); +}); diff --git a/test/unit/app/render-hook.tsx b/test/unit/app/render-hook.tsx new file mode 100644 index 0000000..04927bc --- /dev/null +++ b/test/unit/app/render-hook.tsx @@ -0,0 +1,48 @@ +import { act, type ReactNode } from "react"; +import ReactDOM from "react-dom/client"; + +type Root = ReturnType; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +export async function renderHook( + callback: (props: Props) => Result, + initialProps: Props +): Promise<{ + readonly result: Result; + rerender: (props: Props) => Promise; + unmount: () => Promise; +}> { + const root = ReactDOM.createRoot(document.createElement("div")); + let result: Result; + + function Host({ props }: { props: Props }): ReactNode { + result = callback(props); + return null; + } + + await render(root, ); + return { + get result() { + return result; + }, + rerender: (props) => render(root, ), + unmount: () => render(root, null) + }; +} + +async function render(root: Root, content: ReactNode): Promise { + await act(async () => { + root.render(content); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +export async function flushHookEffects(action?: () => unknown): Promise { + await act(async () => { + await action?.(); + await Promise.resolve(); + await Promise.resolve(); + }); +} diff --git a/test/unit/app/settings/settings-presentation.test.tsx b/test/unit/app/settings/settings-presentation.test.tsx index ca9522a..f82a4de 100644 --- a/test/unit/app/settings/settings-presentation.test.tsx +++ b/test/unit/app/settings/settings-presentation.test.tsx @@ -68,7 +68,11 @@ const notifications = { enable: async () => undefined, error: null, isBusy: false, - refresh: async () => undefined, + refresh: async () => ({ + latestInboundMessageId: null, + unread: { catchall: 1, inbox: 2, total: 3 }, + vapidPublicKey: "public-key" + }), unread: { catchall: 1, inbox: 2, total: 3 } }; diff --git a/test/unit/app/updates/use-update-monitor.test.tsx b/test/unit/app/updates/use-update-monitor.test.tsx new file mode 100644 index 0000000..748c9c2 --- /dev/null +++ b/test/unit/app/updates/use-update-monitor.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import type { UpdateStatus } from "@/features/updates/types"; +import { useUpdateMonitor } from "@/features/updates/use-update-monitor"; +import { flushHookEffects, renderHook } from "../render-hook"; + +const mocks = vi.hoisted(() => ({ + getUpdateStatus: vi.fn() +})); + +vi.mock("@/features/updates/api", () => ({ + getUpdateStatus: mocks.getUpdateStatus +})); + +const status: UpdateStatus = { + product: "hqbase", + installedVersion: "0.1.22", + installedSchemaVersion: 5, + channel: "stable", + checkedAt: "2026-07-29T00:00:00.000Z", + available: true, + compatible: true, + release: { + version: "0.1.23", + schemaVersion: 5, + publishedAt: "2026-07-29T00:00:00.000Z", + notesUrl: "https://github.com/HQBase/hqbase/releases/tag/v0.1.23" + } +}; + +describe("useUpdateMonitor", () => { + it("checks while visible and removes refresh listeners when management is revoked", async () => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible" + }); + mocks.getUpdateStatus.mockResolvedValue(status); + const hook = await renderHook( + ({ canManage }: { canManage: boolean }) => { + return useUpdateMonitor(canManage); + }, + { canManage: true } + ); + await flushHookEffects(); + + expect(mocks.getUpdateStatus).toHaveBeenCalledOnce(); + expect(hook.result.status).toEqual(status); + await flushHookEffects(() => window.dispatchEvent(new Event("focus"))); + expect(mocks.getUpdateStatus).toHaveBeenCalledTimes(2); + + await hook.rerender({ canManage: false }); + expect(hook.result.status).toBeNull(); + await flushHookEffects(() => window.dispatchEvent(new Event("focus"))); + expect(mocks.getUpdateStatus).toHaveBeenCalledTimes(2); + await hook.unmount(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1954ce3..55453ef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ include: ["app/**/*.{ts,tsx}", "worker/**/*.ts"], exclude: [ "app/components/ui/**", - "app/**/*.tsx", "app/main.tsx", "worker/index.ts", "worker/features/domains/queries.ts", @@ -26,10 +25,34 @@ export default defineConfig({ "worker/features/setup/cloudflare.ts" ], thresholds: { - branches: 20, - functions: 15, - lines: 20, - statements: 20 + branches: 30, + functions: 30, + lines: 35, + statements: 34, + "app/features/compose/use-draft-autosave.ts": { + branches: 65, + functions: 85, + lines: 80, + statements: 80 + }, + "app/features/messages/use-mail-sync.ts": { + branches: 50, + functions: 80, + lines: 80, + statements: 80 + }, + "app/features/notifications/use-notifications.ts": { + branches: 35, + functions: 70, + lines: 50, + statements: 48 + }, + "app/features/updates/use-update-monitor.ts": { + branches: 60, + functions: 75, + lines: 85, + statements: 85 + } } } } diff --git a/worker/features/notifications/queries.ts b/worker/features/notifications/queries.ts index 9ab9a66..16f75c5 100644 --- a/worker/features/notifications/queries.ts +++ b/worker/features/notifications/queries.ts @@ -26,6 +26,25 @@ export async function countUnreadMessages( return { catchall, inbox, total: inbox + catchall }; } +export async function latestInboundMessageId( + db: D1Database, + mailboxIds: string[] +): Promise { + if (mailboxIds.length === 0) return null; + const row = await db + .prepare( + `SELECT id + FROM messages + WHERE direction = 'inbound' + AND mailbox_id IN (${mailboxIds.map(() => "?").join(", ")}) + ORDER BY created_at DESC, id DESC + LIMIT 1` + ) + .bind(...mailboxIds) + .first<{ id: string }>(); + return row?.id ?? null; +} + export async function savePushSubscription( db: D1Database, userId: string, diff --git a/worker/features/notifications/routes.ts b/worker/features/notifications/routes.ts index 74d9b9e..f8c5688 100644 --- a/worker/features/notifications/routes.ts +++ b/worker/features/notifications/routes.ts @@ -6,7 +6,12 @@ import { requireAuthContext } from "../../auth/session"; import type { HonoApp } from "../../lib/env"; import { AppError } from "../../lib/errors"; import { parseWith } from "../../lib/validation"; -import { countUnreadMessages, removePushSubscription, savePushSubscription } from "./queries"; +import { + countUnreadMessages, + latestInboundMessageId, + removePushSubscription, + savePushSubscription +} from "./queries"; export const notificationRoutes = new Hono(); @@ -35,8 +40,13 @@ const unsubscribeSchema = z.object({ endpoint: endpointSchema }); notificationRoutes.get("/status", async (c) => { const auth = await requireAuthContext(c.env, c.req.raw); const mailboxIds = await accessibleMailboxIds(c.env.DB, auth.user.id, auth.user.role, "read"); + const [unread, latestMessageId] = await Promise.all([ + countUnreadMessages(c.env.DB, mailboxIds), + latestInboundMessageId(c.env.DB, mailboxIds) + ]); return c.json({ - unread: await countUnreadMessages(c.env.DB, mailboxIds), + latestInboundMessageId: latestMessageId, + unread, vapidPublicKey: configuredPublicKey(c.env) }); });