Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions api/hqbase-mail-api-v1.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
},
{
"name": "Sending"
},
{
"name": "Events"
}
],
"paths": {
Expand Down Expand Up @@ -117,6 +120,89 @@
"operationId": "listMailboxes"
}
},
"/api/v1/events": {
"get": {
"summary": "Open change event WebSocket",
"description": "Opens an authenticated wake-only WebSocket. The server sends `changed` frames for messages, mailboxes, and permitted drafts. Frames contain no mail data or cursors. After a frame or reconnect, read the authoritative REST resources and change journals. OAuth tokens require `mail:read`; draft frames also require `mail:send`. The authorization decision at upgrade is an event-delivery lease for 10 minutes. Credential or session revocation does not close the current socket immediately. The server closes the socket when the lease ends, and reconnection must use current credentials and permissions.",
"security": [
{
"oauth2": ["mail:read"]
},
{
"cookieSession": []
}
],
"parameters": [
{
"name": "Upgrade",
"in": "header",
"required": true,
"description": "WebSocket upgrade request.",
"schema": {
"type": "string",
"const": "websocket"
}
}
],
"responses": {
"101": {
"description": "WebSocket established. Text frames use `{\"type\":\"changed\",\"topic\":\"messages|drafts|mailboxes\"}`."
},
"401": {
"description": "Missing or invalid authentication",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Insufficient OAuth scope or invalid browser origin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"405": {
"description": "Method not allowed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"426": {
"description": "WebSocket upgrade required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Event connection unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"tags": ["Events"],
"operationId": "openMailEvents"
}
},
"/api/v1/changes": {
"get": {
"summary": "Read message changes",
Expand Down
7 changes: 6 additions & 1 deletion api/hqbase-mail-api-v1.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"info": {
"_postman_id": "62c6dbf4-835d-4a3f-87df-77b7ddcf2db1",
"name": "HQBase Mail API v1",
"description": "Generated from api/hqbase-mail-api-v1.openapi.json. Set base_url, run Register public client, and use Postman's OAuth 2.0 Authorization Code flow with PKCE (S256). Auth URL: {{base_url}}/api/auth/oauth2/authorize. Token URL: {{base_url}}/api/auth/oauth2/token. Client ID: {{client_id}}. Scope: mail:read mail:write mail:send offline_access. Add authorization request parameter resource={{api_resource}}, then store the resulting token only in your local environment as access_token. Sending, replying, and forwarding are not idempotent.",
"description": "Generated from api/hqbase-mail-api-v1.openapi.json. Set base_url, run Register public client, and use Postman's OAuth 2.0 Authorization Code flow with PKCE (S256). Auth URL: {{base_url}}/api/auth/oauth2/authorize. Token URL: {{base_url}}/api/auth/oauth2/token. Client ID: {{client_id}}. Scope: mail:read mail:write mail:send offline_access. Add authorization request parameter resource={{api_resource}}, then store the resulting token only in your local environment as access_token. Postman v2.1 HTTP collections cannot contain WebSocket requests. To receive change wakes, create a separate WebSocket request to {{ws_base_url}}/api/v1/events and add Authorization: Bearer {{access_token}}. Sending, replying, and forwarding are not idempotent.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
Expand All @@ -21,6 +21,11 @@
"value": "https://mail.example.com",
"type": "string"
},
{
"key": "ws_base_url",
"value": "wss://mail.example.com",
"type": "string"
},
{
"key": "api_resource",
"value": "{{base_url}}/api/v1",
Expand Down
6 changes: 6 additions & 0 deletions api/hqbase-mail-api-v1.postman_environment.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"enabled": true,
"type": "default"
},
{
"key": "ws_base_url",
"value": "wss://mail.example.com",
"enabled": true,
"type": "default"
},
{
"key": "api_resource",
"value": "{{base_url}}/api/v1",
Expand Down
28 changes: 28 additions & 0 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import type { CurrentUser } from "@/features/auth/types";
import { DraftsPage } from "@/features/drafts/drafts-page";
import { useDrafts } from "@/features/drafts/use-drafts";
import { useMailEvents } from "@/features/events/use-mail-events";
import { InboxPage } from "@/features/inbox/inbox-page";
import { listMailboxes } from "@/features/mailboxes/api";
import type { Mailbox } from "@/features/mailboxes/types";
Expand Down Expand Up @@ -114,6 +115,32 @@ export function App(): React.ReactElement {
}
}, [loadWorkspace]);

const refreshWorkspace = React.useCallback(async () => {
if (!currentUserId) return;
const currentUser = await getCurrentUser();
setUser(currentUser);
if (!currentUser.passwordSetupRequired) await loadWorkspace(currentUser);
}, [currentUserId, loadWorkspace]);
const refreshRealtimeState = React.useCallback(async () => {
const results = await Promise.allSettled([
refreshWorkspace(),
mailSync.refresh(),
draftState.refresh()
]);
if (results.every((result) => result.status === "rejected")) {
const failure = results.find((result) => result.status === "rejected");
throw failure?.reason;
}
}, [draftState.refresh, mailSync.refresh, refreshWorkspace]);

const connectionStatus = useMailEvents(currentUserId, {
onDrafts: draftState.refresh,
onFallbackPoll: refreshRealtimeState,
onMailboxes: refreshRealtimeState,
onMessages: mailSync.refresh,
onReconnect: refreshRealtimeState
});

React.useEffect(() => {
if (publicAuthenticationPath) return;
void reload();
Expand Down Expand Up @@ -192,6 +219,7 @@ export function App(): React.ReactElement {
activeFolder={activeFolder}
activeSettingsTab={settingsTab}
canManage={user.role === "owner" || user.role === "admin"}
connectionStatus={connectionStatus}
draftCount={draftState.drafts.length}
mailboxId={mailboxId}
mailboxes={contentMailboxes}
Expand Down
6 changes: 6 additions & 0 deletions app/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from "react";
import type { CurrentUser } from "@/features/auth/types";
import type { MailConnectionStatus } from "@/features/events/types";
import type { Mailbox } from "@/features/mailboxes/types";
import type { UnreadCounts } from "@/features/notifications/types";
import type { UpdateStatus } from "@/features/updates/types";
Expand All @@ -15,6 +16,7 @@ type AppShellProps = {
activeFolder: FolderId;
activeSettingsTab?: import("@/lib/routes").SettingsTabId | undefined;
canManage?: boolean | undefined;
connectionStatus: MailConnectionStatus;
children: React.ReactNode;
mailboxId: string;
mailboxes: Mailbox[];
Expand Down Expand Up @@ -55,6 +57,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
activeFolder={props.activeFolder}
activeSettingsTab={props.activeSettingsTab}
canManage={props.canManage}
connectionStatus={props.connectionStatus}
draftCount={props.draftCount}
mailboxId={props.mailboxId}
sidebarCollapsed={sidebarCollapsed}
Expand Down Expand Up @@ -84,6 +87,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
activeFolder={props.activeFolder}
activeSettingsTab={props.activeSettingsTab}
canManage={props.canManage}
connectionStatus={props.connectionStatus}
draftCount={props.draftCount}
mailboxId={props.mailboxId}
unread={props.unread}
Expand Down Expand Up @@ -121,6 +125,7 @@ function ShellContent({
activeSettingsTab,
canManage,
children,
connectionStatus,
draftCount,
mailboxId,
mailboxes,
Expand Down Expand Up @@ -150,6 +155,7 @@ function ShellContent({
activeFolder={activeFolder}
activeSettingsTab={activeSettingsTab}
canManage={canManage}
connectionStatus={connectionStatus}
draftCount={draftCount}
mailboxId={mailboxId}
mailboxes={mailboxes}
Expand Down
4 changes: 4 additions & 0 deletions app/components/layout/mobile-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PiList } from "react-icons/pi";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import type { CurrentUser } from "@/features/auth/types";
import type { MailConnectionStatus } from "@/features/events/types";
import type { Mailbox } from "@/features/mailboxes/types";
import type { UnreadCounts } from "@/features/notifications/types";
import type { FolderId, SettingsTabId } from "@/lib/routes";
Expand All @@ -13,6 +14,7 @@ type MobileNavigationProps = {
activeFolder: FolderId;
activeSettingsTab?: SettingsTabId | undefined;
canManage?: boolean | undefined;
connectionStatus?: MailConnectionStatus | undefined;
draftCount: number;
mailboxId: string;
mailboxes: Mailbox[];
Expand All @@ -29,6 +31,7 @@ export function MobileNavigation({
activeFolder,
activeSettingsTab,
canManage,
connectionStatus,
draftCount,
mailboxId,
mailboxes,
Expand Down Expand Up @@ -93,6 +96,7 @@ export function MobileNavigation({
activeFolder={activeFolder}
activeSettingsTab={activeSettingsTab}
canManage={canManage}
connectionStatus={connectionStatus}
draftCount={draftCount}
mailboxId={mailboxId}
mailboxFilter={{
Expand Down
5 changes: 5 additions & 0 deletions app/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { PiSidebar, PiSidebarSimple } from "react-icons/pi";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { CurrentUser } from "@/features/auth/types";
import type { MailConnectionStatus } from "@/features/events/types";
import type { Mailbox } from "@/features/mailboxes/types";
import type { UnreadCounts } from "@/features/notifications/types";
import { cn } from "@/lib/cn";
import type { FolderId, SettingsTabId } from "@/lib/routes";
import { appRoutePath } from "@/lib/routes";
import { AccountMenu } from "./account-menu";
import { quickAccess } from "./sidebar/constants";
import { MailConnectionIndicator } from "./sidebar/mail-connection-indicator";
import { isModifiedNavigation } from "./sidebar/sidebar-helpers";
import { MailNav, SettingsNav } from "./sidebar/sidebar-nav";

Expand All @@ -32,6 +34,7 @@ type SidebarProps = {
sidebarCollapsed?: boolean;
activeSettingsTab?: SettingsTabId | undefined;
canManage?: boolean | undefined;
connectionStatus?: MailConnectionStatus | undefined;
onSettingsTabChange?: ((tab: SettingsTabId) => void) | undefined;
onToggleSidebar?: () => void;
};
Expand All @@ -50,6 +53,7 @@ export function Sidebar({
sidebarCollapsed = false,
activeSettingsTab,
canManage = false,
connectionStatus = "connecting",
onSettingsTabChange,
onToggleSidebar
}: SidebarProps): React.ReactElement {
Expand Down Expand Up @@ -137,6 +141,7 @@ export function Sidebar({
<span className="truncate text-sm font-semibold leading-none tracking-tight">
Mail
</span>
<MailConnectionIndicator status={connectionStatus} />
</div>
{onToggleSidebar ? (
<TooltipProvider delayDuration={250}>
Expand Down
47 changes: 47 additions & 0 deletions app/components/layout/sidebar/mail-connection-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type * as React from "react";

import type { MailConnectionStatus } from "@/features/events/types";
import { cn } from "@/lib/cn";

const statusPresentation: Record<MailConnectionStatus, { className: string; label: string }> = {
connecting: {
className: "bg-muted-foreground/45 motion-safe:animate-pulse",
label: "Connecting to live updates"
},
connected: {
className: "bg-emerald-500 ring-2 ring-emerald-500/15",
label: "Live updates connected"
},
fallback: {
className: "bg-amber-400 ring-2 ring-amber-400/15",
label: "Using fallback sync while live updates reconnect"
},
unavailable: {
className: "bg-red-500 ring-2 ring-red-500/15",
label: "Cannot connect to HQBase"
}
};

export function MailConnectionIndicator({
status
}: {
status: MailConnectionStatus;
}): React.ReactElement {
const presentation = statusPresentation[status];

return (
<span
aria-label={presentation.label}
aria-live="polite"
className="inline-flex size-4 shrink-0 items-center justify-center"
data-connection-status={status}
role="status"
title={presentation.label}
>
<span
aria-hidden="true"
className={cn("size-2 rounded-full transition-colors duration-200", presentation.className)}
/>
</span>
);
}
4 changes: 4 additions & 0 deletions app/components/layout/top-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SelectValue
} from "@/components/ui/select";
import type { CurrentUser } from "@/features/auth/types";
import type { MailConnectionStatus } from "@/features/events/types";
import type { Mailbox } from "@/features/mailboxes/types";
import type { UnreadCounts } from "@/features/notifications/types";
import { mailboxUnreadLabel } from "@/features/notifications/unread";
Expand All @@ -22,6 +23,7 @@ type TopBarProps = {
activeFolder: FolderId;
activeSettingsTab?: SettingsTabId | undefined;
canManage?: boolean | undefined;
connectionStatus?: MailConnectionStatus | undefined;
draftCount: number;
user: CurrentUser;
mailboxes: Mailbox[];
Expand All @@ -42,6 +44,7 @@ export function TopBar({
activeFolder,
activeSettingsTab,
canManage,
connectionStatus,
draftCount,
user,
mailboxes,
Expand Down Expand Up @@ -76,6 +79,7 @@ export function TopBar({
activeFolder={activeFolder}
activeSettingsTab={activeSettingsTab}
canManage={canManage}
connectionStatus={connectionStatus}
draftCount={draftCount}
mailboxId={mailboxId}
mailboxes={mailboxes}
Expand Down
Loading