diff --git a/CHANGELOG.md b/CHANGELOG.md
index a71deba..560d709 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.1.27
+
+- Show a private Drafts destination in desktop and mobile navigation only when the signed-in user
+ has saved drafts.
+- List saved drafts with mailbox and search filtering, and reopen the exact new-message, reply, or
+ forward draft with its attachments and conversation context.
+- Keep the Drafts count synchronized when a composer creates, sends, or discards a draft.
+
## 0.1.26
- Expand the OAuth-protected MCP server with conversation, thread, and attachment retrieval plus
diff --git a/app/app.tsx b/app/app.tsx
index b89e94a..2af26d5 100644
--- a/app/app.tsx
+++ b/app/app.tsx
@@ -5,6 +5,8 @@ import { Toaster } from "@/components/ui/sonner";
import { getCurrentUser } from "@/features/auth/api";
import { LoginPage } from "@/features/auth/login-page";
import type { CurrentUser } from "@/features/auth/types";
+import { DraftsPage } from "@/features/drafts/drafts-page";
+import { useDrafts } from "@/features/drafts/use-drafts";
import { InboxPage } from "@/features/inbox/inbox-page";
import { listMailboxes } from "@/features/mailboxes/api";
import type { Mailbox } from "@/features/mailboxes/types";
@@ -22,6 +24,11 @@ import { useAppRoute } from "@/lib/use-app-route";
const ComposeDialog = React.lazy(() =>
import("@/features/compose/compose-dialog").then((module) => ({ default: module.ComposeDialog }))
);
+const DraftComposeDialog = React.lazy(() =>
+ import("@/features/drafts/draft-compose-dialog").then((module) => ({
+ default: module.DraftComposeDialog
+ }))
+);
export function App(): React.ReactElement {
const [setup, setSetup] = React.useState(null);
@@ -33,10 +40,17 @@ export function App(): React.ReactElement {
const [composeOpen, setComposeOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const { navigate, route } = useAppRoute(setup?.isComplete);
- const activeFolder: FolderId = route.kind === "settings" ? "settings" : route.folder;
+ const activeFolder: FolderId =
+ route.kind === "settings" ? "settings" : route.kind === "drafts" ? "drafts" : route.folder;
const selectedId = route.kind === "mail" ? route.messageId : null;
+ const selectedDraftId = route.kind === "drafts" ? route.draftId : null;
const settingsTab: SettingsTabId = route.kind === "settings" ? route.tab : "mailboxes";
const currentUserId = user?.id ?? null;
+ const draftState = useDrafts(currentUserId);
+ const selectedDraft =
+ selectedDraftId === null
+ ? null
+ : (draftState.drafts.find((draft) => draft.id === selectedDraftId) ?? null);
const contentMailboxes = React.useMemo(
() => mailboxes.filter((mailbox) => mailbox.accessLevel !== null),
[mailboxes]
@@ -121,7 +135,8 @@ export function App(): React.ReactElement {
<>
+ ) : activeFolder === "drafts" ? (
+ navigate({ kind: "drafts", draftId: null })}
+ onSelect={(draftId) => navigate({ kind: "drafts", draftId })}
+ />
) : (
void draftState.refresh().catch(() => undefined)}
onRefresh={() => void mailSync.refresh().catch(() => undefined)}
onMessageRouteChange={(folder, messageId) =>
navigate({ kind: "mail", folder, messageId })
@@ -190,6 +218,20 @@ export function App(): React.ReactElement {
mode="new"
open={composeOpen}
onOpenChange={setComposeOpen}
+ onDraftsChange={() => void draftState.refresh().catch(() => undefined)}
+ onSent={() => void mailSync.refresh().catch(() => undefined)}
+ />
+
+ ) : null}
+ {selectedDraft ? (
+
+ void draftState.refresh().catch(() => undefined)}
+ onOpenChange={(open) => {
+ if (!open) navigate({ kind: "drafts", draftId: null });
+ }}
onSent={() => void mailSync.refresh().catch(() => undefined)}
/>
diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx
index 1e1f1a9..de203a8 100644
--- a/app/components/layout/app-shell.tsx
+++ b/app/components/layout/app-shell.tsx
@@ -20,6 +20,7 @@ type AppShellProps = {
updateInProgress: boolean;
updateStatus: UpdateStatus | null;
unread: UnreadCounts;
+ draftCount: number;
onCompose: () => void;
onFolderChange: (folder: FolderId) => void;
onMailboxChange: (mailboxId: string) => void;
@@ -33,6 +34,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
void;
@@ -19,6 +20,7 @@ type MobileNavigationProps = {
export function MobileNavigation({
activeFolder,
+ draftCount,
unread,
user,
onFolderChange,
@@ -64,6 +66,7 @@ export function MobileNavigation({
Navigation
= {
inbox: Inbox,
sent: Send,
+ drafts: FilePenLine,
starred: Star,
archived: Archive,
trash: Trash2,
@@ -33,6 +44,7 @@ const icons: Record = {
export function Sidebar({
activeFolder,
+ draftCount = 0,
drawerAction,
unread,
user,
@@ -41,6 +53,13 @@ export function Sidebar({
variant = "desktop"
}: SidebarProps): React.ReactElement {
const isDrawer = variant === "drawer";
+ const navigationFolders: Array<(typeof mailFolders)[number] | typeof draftFolder> = [];
+ for (const folder of mailFolders) {
+ navigationFolders.push(folder);
+ if (folder.id === "sent" && (draftCount > 0 || activeFolder === "drafts")) {
+ navigationFolders.push(draftFolder);
+ }
+ }
return (
+ {selectedId && !selectedDraft && !isLoading ? (
+
+
+
+
+
+
Draft not found
+
+ It may have been sent or discarded in another session.
+
+
+
+ Back to drafts
+
+
+ ) : isLoading && drafts.length === 0 ? (
+
+
+
+ ) : visibleDrafts.length === 0 ? (
+ 0} />
+ ) : (
+
+ {visibleDrafts.map((draft) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function DraftListItem({
+ draft,
+ isActive,
+ onSelect
+}: {
+ draft: Draft;
+ isActive: boolean;
+ onSelect: (draftId: string) => void;
+}): React.ReactElement {
+ const recipients = draft.to.length > 0 ? draft.to.join(", ") : "No recipients";
+ const subject = draft.subject.trim() || "No subject";
+ const snippet = draft.text.trim().replace(/\s+/g, " ") || "No message content";
+
+ return (
+ {
+ if (isModifiedNavigation(event)) return;
+ event.preventDefault();
+ onSelect(draft.id);
+ }}
+ >
+
+ Draft
+ {recipients}
+
+
+
{subject}
+
+ —
+
+
{snippet}
+ {draft.attachments.length > 0 ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function EmptyDrafts({ filtered }: { filtered: boolean }): React.ReactElement {
+ return (
+
+
+
+
+
{filtered ? "No drafts match this view" : "No saved drafts"}
+
+ );
+}
+
+function isModifiedNavigation(event: React.MouseEvent): boolean {
+ return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
+}
diff --git a/app/features/drafts/types.ts b/app/features/drafts/types.ts
new file mode 100644
index 0000000..670f1b7
--- /dev/null
+++ b/app/features/drafts/types.ts
@@ -0,0 +1,28 @@
+export type DraftAttachment = {
+ id: string;
+ filename: string;
+ contentType: string;
+ sizeBytes: number;
+};
+
+export type Draft = {
+ id: string;
+ mailboxId: string | null;
+ replyToMessageId: string | null;
+ forwardOfMessageId: string | null;
+ from: string;
+ to: string[];
+ cc: string[];
+ bcc: string[];
+ subject: string;
+ text: string;
+ html: string;
+ version: number;
+ updatedAt: string;
+ attachments: DraftAttachment[];
+};
+
+export type DraftInput = Omit & {
+ id?: string;
+ version?: number;
+};
diff --git a/app/features/drafts/use-drafts.ts b/app/features/drafts/use-drafts.ts
new file mode 100644
index 0000000..8f8003f
--- /dev/null
+++ b/app/features/drafts/use-drafts.ts
@@ -0,0 +1,61 @@
+import * as React from "react";
+
+import { listDrafts } from "./api";
+import type { Draft } from "./types";
+
+const refreshIntervalMs = 10_000;
+
+export function useDrafts(userId: string | null): {
+ drafts: Draft[];
+ isLoading: boolean;
+ refresh: () => Promise;
+} {
+ const [drafts, setDrafts] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const currentUserId = React.useRef(userId);
+ currentUserId.current = userId;
+
+ const refresh = React.useCallback(async (): Promise => {
+ if (!userId) {
+ setDrafts([]);
+ setIsLoading(false);
+ return;
+ }
+
+ const nextDrafts = await listDrafts();
+ if (currentUserId.current === userId) {
+ setDrafts(nextDrafts);
+ setIsLoading(false);
+ }
+ }, [userId]);
+
+ React.useEffect(() => {
+ if (!userId) {
+ setDrafts([]);
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ void refresh().catch(() => {
+ if (currentUserId.current === userId) setIsLoading(false);
+ });
+
+ const refreshWhenVisible = (): void => {
+ if (document.visibilityState === "visible") void refresh().catch(() => undefined);
+ };
+ const interval = window.setInterval(
+ () => void refresh().catch(() => undefined),
+ refreshIntervalMs
+ );
+ window.addEventListener("focus", refreshWhenVisible);
+ document.addEventListener("visibilitychange", refreshWhenVisible);
+ return () => {
+ window.clearInterval(interval);
+ window.removeEventListener("focus", refreshWhenVisible);
+ document.removeEventListener("visibilitychange", refreshWhenVisible);
+ };
+ }, [refresh, userId]);
+
+ return { drafts, isLoading, refresh };
+}
diff --git a/app/features/inbox/inbox-page.tsx b/app/features/inbox/inbox-page.tsx
index 3950b87..2f5997c 100644
--- a/app/features/inbox/inbox-page.tsx
+++ b/app/features/inbox/inbox-page.tsx
@@ -17,6 +17,7 @@ type InboxPageProps = {
conversations: ConversationSummary[];
mailboxes: Mailbox[];
selectedId: string | null;
+ onDraftsChange?: () => void;
onRefresh: () => void;
onMessageRouteChange: (folder: MailFolderId, messageId: string | null) => void;
onSelect: (messageId: string) => void;
@@ -27,6 +28,7 @@ export function InboxPage({
conversations,
mailboxes,
selectedId,
+ onDraftsChange,
onRefresh,
onMessageRouteChange,
onSelect
@@ -166,6 +168,7 @@ export function InboxPage({
selectedId={readerSelectedId}
onAction={(action) => void handleAction(action)}
onBack={() => onMessageRouteChange(activeFolder, null)}
+ {...(onDraftsChange ? { onDraftsChange } : {})}
onSent={() => {
onRefresh();
if (selectedId) void loadThread(selectedId);
diff --git a/app/features/messages/message-detail.tsx b/app/features/messages/message-detail.tsx
index 9fe2a22..6bbcb24 100644
--- a/app/features/messages/message-detail.tsx
+++ b/app/features/messages/message-detail.tsx
@@ -19,6 +19,7 @@ type MessageDetailProps = {
selectedId: string | null;
onAction: (action: "read" | "unread" | "star" | "unstar" | "archive" | "trash") => void;
onBack: () => void;
+ onDraftsChange?: () => void;
onSent: () => void;
};
@@ -30,6 +31,7 @@ export function MessageDetail({
selectedId,
onAction,
onBack,
+ onDraftsChange,
onSent
}: MessageDetailProps): React.ReactElement {
const [composeMode, setComposeMode] = React.useState}
+ {...(onDraftsChange ? { onDraftsChange } : {})}
onOpenChange={(nextOpen) => {
if (!nextOpen) setComposeMode(null);
}}
diff --git a/app/features/messages/use-mail-sync.ts b/app/features/messages/use-mail-sync.ts
index b2afe25..281f025 100644
--- a/app/features/messages/use-mail-sync.ts
+++ b/app/features/messages/use-mail-sync.ts
@@ -46,7 +46,7 @@ export function useMailSync({ activeFolder, mailboxId, search, userId }: MailSyn
const [notificationResult, conversationResult] = await Promise.allSettled([
refreshNotifications(),
- activeFolder === "settings"
+ activeFolder === "settings" || activeFolder === "drafts"
? Promise.resolve(null)
: listConversations({
folder: activeFolder,
diff --git a/app/lib/routes.ts b/app/lib/routes.ts
index 4f72382..9a8ce72 100644
--- a/app/lib/routes.ts
+++ b/app/lib/routes.ts
@@ -7,7 +7,13 @@ export const mailFolders = [
{ id: "catchall", label: "Catch-all", path: "catch-all" }
] as const;
-export const folders = [...mailFolders, { id: "settings", label: "Settings" }] as const;
+export const draftFolder = { id: "drafts", label: "Drafts", path: "drafts" } as const;
+
+export const folders = [
+ ...mailFolders,
+ draftFolder,
+ { id: "settings", label: "Settings" }
+] as const;
export const settingsTabs = [
"mailboxes",
@@ -24,6 +30,7 @@ export type SettingsTabId = (typeof settingsTabs)[number];
export type AppRoute =
| { kind: "mail"; folder: MailFolderId; messageId: string | null }
+ | { kind: "drafts"; draftId: string | null }
| { kind: "settings"; tab: SettingsTabId };
const legacySettingsTabs: Record = {
@@ -48,6 +55,13 @@ export function readAppRoute(input: string | URL): AppRoute {
return { kind: "settings", tab: tab ?? "mailboxes" };
}
+ if (segments[0] === draftFolder.path) {
+ return {
+ kind: "drafts",
+ draftId: segments[1] ? decodePathSegment(segments[1]) : null
+ };
+ }
+
const folder = readMailFolder(segments[0]);
if (!folder) return { kind: "mail", folder: "inbox", messageId: null };
@@ -60,6 +74,10 @@ export function readAppRoute(input: string | URL): AppRoute {
export function appRoutePath(route: AppRoute): string {
if (route.kind === "settings") return `/settings/${route.tab}`;
+ if (route.kind === "drafts") {
+ const base = `/${draftFolder.path}`;
+ return route.draftId ? `${base}/${encodeURIComponent(route.draftId)}` : base;
+ }
const folder = mailFolders.find((item) => item.id === route.folder);
const base = `/${folder?.path ?? "inbox"}`;
return route.messageId ? `${base}/${encodeURIComponent(route.messageId)}` : base;
diff --git a/package.json b/package.json
index 9f26697..131b697 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hqbase",
- "version": "0.1.26",
+ "version": "0.1.27",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
diff --git a/test/unit/app/compose/compose-state.test.ts b/test/unit/app/compose/compose-state.test.ts
index d05574b..60e3fb6 100644
--- a/test/unit/app/compose/compose-state.test.ts
+++ b/test/unit/app/compose/compose-state.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
+ findDraftForComposer,
forwardedMessage,
normalizeDraftHtml,
readDraftRecovery,
@@ -8,6 +9,7 @@ import {
serializeDraft,
splitRecipients
} from "@/features/compose/compose-state";
+import type { Draft } from "@/features/drafts/types";
afterEach(() => vi.unstubAllGlobals());
@@ -102,6 +104,29 @@ describe("composer state", () => {
expect(readDraftRecovery("key", new Date(300).toISOString())).toBeNull();
});
+ it("reopens the exact selected draft when several new-message drafts exist", () => {
+ const draft = (id: string): Draft => ({
+ id,
+ mailboxId: "mbx_1",
+ replyToMessageId: null,
+ forwardOfMessageId: null,
+ from: "support@example.com",
+ to: [],
+ cc: [],
+ bcc: [],
+ subject: id,
+ text: "",
+ html: "",
+ version: 1,
+ updatedAt: "2026-07-29T14:00:00.000Z",
+ attachments: []
+ });
+ const drafts = [draft("draft-one"), draft("draft-two")];
+
+ expect(findDraftForComposer(drafts, "draft-two", null, null)?.id).toBe("draft-two");
+ expect(findDraftForComposer(drafts, "missing", null, null)).toBeNull();
+ });
+
it("uses the exact address that received the message as the reply identity", () => {
const identity = replySendingIdentity(
{
diff --git a/test/unit/app/compose/use-draft-autosave.test.tsx b/test/unit/app/compose/use-draft-autosave.test.tsx
index 57027f3..6b6c0d8 100644
--- a/test/unit/app/compose/use-draft-autosave.test.tsx
+++ b/test/unit/app/compose/use-draft-autosave.test.tsx
@@ -1,8 +1,7 @@
// @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 type { Draft } from "@/features/drafts/types";
import { flushHookEffects, renderHook } from "../render-hook";
const mocks = vi.hoisted(() => ({
@@ -10,8 +9,8 @@ const mocks = vi.hoisted(() => ({
updateDraft: vi.fn()
}));
-vi.mock("@/features/compose/api", async (importOriginal) => ({
- ...(await importOriginal()),
+vi.mock("@/features/drafts/api", async (importOriginal) => ({
+ ...(await importOriginal()),
updateDraft: mocks.updateDraft
}));
vi.mock("sonner", () => ({
diff --git a/test/unit/app/layout/mail-shell.test.tsx b/test/unit/app/layout/mail-shell.test.tsx
index 30c5cf8..bf5096b 100644
--- a/test/unit/app/layout/mail-shell.test.tsx
+++ b/test/unit/app/layout/mail-shell.test.tsx
@@ -5,6 +5,7 @@ import { Sidebar } from "@/components/layout/sidebar";
import { TopBar } from "@/components/layout/top-bar";
import { LoginPage } from "@/features/auth/login-page";
import { ComposeWindow } from "@/features/compose/compose-window";
+import { DraftsPage } from "@/features/drafts/drafts-page";
import { InboxPage } from "@/features/inbox/inbox-page";
import { McpConnectionDetails } from "@/features/mcp/connection-dialog";
@@ -21,6 +22,7 @@ describe("mail shell", () => {
const html = renderToStaticMarkup(
{
const html = renderToStaticMarkup(
undefined}
@@ -124,6 +127,89 @@ describe("mail shell", () => {
expect(html.match(/border-t pt-2/g)).toHaveLength(2);
});
+ it("shows Drafts with its private count only when drafts exist or the route is active", () => {
+ const withoutDrafts = renderToStaticMarkup(
+ undefined}
+ onSignedOut={() => undefined}
+ />
+ );
+ const withDrafts = renderToStaticMarkup(
+ undefined}
+ onSignedOut={() => undefined}
+ />
+ );
+ const activeEmptyDrafts = renderToStaticMarkup(
+ undefined}
+ onSignedOut={() => undefined}
+ />
+ );
+
+ expect(withoutDrafts).not.toContain('href="/drafts"');
+ expect(withDrafts).toContain('href="/drafts"');
+ expect(withDrafts).toContain("3 drafts");
+ expect(activeEmptyDrafts).toContain('href="/drafts"');
+ expect(activeEmptyDrafts).toContain('aria-current="page"');
+ });
+
+ it("lists private drafts as reopenable rows with filtering and attachment state", () => {
+ const html = renderToStaticMarkup(
+ Here is the requested summary.",
+ version: 2,
+ updatedAt: "2026-07-29T14:00:00.000Z",
+ attachments: [
+ {
+ id: "attachment-1",
+ filename: "summary.pdf",
+ contentType: "application/pdf",
+ sizeBytes: 100
+ }
+ ]
+ }
+ ]}
+ isLoading={false}
+ mailboxId="all"
+ search=""
+ selectedId={null}
+ onBack={() => undefined}
+ onSelect={() => undefined}
+ />
+ );
+
+ expect(html).toContain("Drafts");
+ expect(html).toContain("support@example.com");
+ expect(html).toContain("Quarterly follow-up");
+ expect(html).toContain("Here is the requested summary.");
+ expect(html).toContain('href="/drafts/draft%2Fone"');
+ expect(html).toContain("1 attachment");
+ });
+
it("combines the compact folder label and conversation count in one list header", () => {
const html = renderToStaticMarkup(
{
}
});
+ it("round-trips the Drafts folder and a selected private draft", () => {
+ expect(readAppRoute("/drafts")).toEqual({ kind: "drafts", draftId: null });
+ const route: AppRoute = { kind: "drafts", draftId: "draft/one" };
+ expect(appRoutePath(route)).toBe("/drafts/draft%2Fone");
+ expect(readAppRoute(appRoutePath(route))).toEqual(route);
+ });
+
it("gives every Settings page a canonical route", () => {
for (const tab of settingsTabs) {
const path = `/settings/${tab}`;