From 47e2e397ca14cf9df49a02df7a994ec94a85af88 Mon Sep 17 00:00:00 2001 From: olegberman Date: Sat, 22 Aug 2026 07:36:42 -0400 Subject: [PATCH 1/3] feat: support receive-only workspaces --- app/app.tsx | 9 ++- app/components/layout/app-shell.tsx | 8 +- app/components/layout/top-bar.tsx | 4 +- app/features/compose/compose-state.ts | 12 ++- .../domains/connect-domain-dialog.tsx | 22 +++++- app/features/domains/domain-settings.tsx | 75 +++++++++++++++++-- app/features/domains/domain-table.tsx | 44 +++++++---- .../default-from-mailbox-control.tsx | 3 +- app/features/mailboxes/types.ts | 1 + app/features/messages/message-detail.tsx | 10 ++- app/features/setup/api.ts | 1 + app/features/setup/setup-domain-screen.tsx | 18 ++++- app/features/setup/setup-preview-fixtures.tsx | 4 + .../setup/setup-workspace-screens.tsx | 54 +++++++------ app/features/setup/types.ts | 10 ++- app/features/setup/use-setup-cloudflare.ts | 15 +++- app/features/setup/use-setup-flow.ts | 7 +- test/integration/worker/auth.test.ts | 5 +- .../worker/receive-only-mailboxes.test.ts | 57 ++++++++++++++ test/unit/app/compose/compose-state.test.ts | 34 +++++++++ .../app/messages/conversation-reader.test.tsx | 26 ++++++- .../settings/settings-presentation.test.tsx | 53 ++++++++++++- test/unit/app/setup/setup-ui.test.tsx | 1 + .../features/setup/api-validation.test.ts | 41 +++++++++- .../setup/cloudflare-setup-api.test.ts | 32 ++++++++ worker/features/domains/routes.ts | 6 +- worker/features/mailboxes/address-queries.ts | 2 +- worker/features/mailboxes/queries.ts | 16 ++-- worker/features/mailboxes/service.ts | 4 +- worker/features/mailboxes/types.ts | 2 + worker/features/preferences/service.ts | 6 +- worker/features/setup/cloudflare.ts | 8 +- worker/features/setup/service.ts | 29 ++++--- worker/features/setup/types.ts | 1 + worker/features/setup/validation.ts | 28 ++++++- 35 files changed, 539 insertions(+), 109 deletions(-) create mode 100644 test/integration/worker/receive-only-mailboxes.test.ts diff --git a/app/app.tsx b/app/app.tsx index 7979b658..96f55b33 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -12,6 +12,7 @@ import { TemporaryPasswordSetupPage } from "@/features/auth/password-setup-page"; import type { CurrentUser } from "@/features/auth/types"; +import { sendingIdentities } from "@/features/compose/compose-state"; import { DraftsPage } from "@/features/drafts/drafts-page"; import { useDrafts } from "@/features/drafts/use-drafts"; import { InboxPage } from "@/features/inbox/inbox-page"; @@ -71,6 +72,10 @@ export function App(): React.ReactElement { () => mailboxes.filter((mailbox) => mailbox.accessLevel !== null), [mailboxes] ); + const canSend = React.useMemo( + () => sendingIdentities(contentMailboxes).length > 0, + [contentMailboxes] + ); const canManageUpdates = !user?.passwordSetupRequired && (user?.role === "owner" || user?.role === "admin"); const updateMonitor = useUpdateMonitor(canManageUpdates); @@ -204,9 +209,7 @@ export function App(): React.ReactElement { onOpenUpdates={() => { navigate({ kind: "settings", tab: "updates" }); }} - onCompose={() => { - setComposeOpen(true); - }} + {...(canSend ? { onCompose: () => setComposeOpen(true) } : {})} onFolderChange={(folder) => { navigate( folder === "settings" diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index c0c1726c..b8e057b2 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -25,7 +25,7 @@ type AppShellProps = { updateStatus: UpdateStatus | null; unread: UnreadCounts; draftCount: number; - onCompose: () => void; + onCompose?: (() => void) | undefined; onFolderChange: (folder: FolderId) => void; onSettingsTabChange?: ((tab: import("@/lib/routes").SettingsTabId) => void) | undefined; onMailboxChange: (mailboxId: string) => void; @@ -60,7 +60,7 @@ export function AppShell(props: AppShellProps): React.ReactElement { sidebarCollapsed={sidebarCollapsed} unread={props.unread} user={props.user} - onCompose={props.onCompose} + {...(props.onCompose ? { onCompose: props.onCompose } : {})} onFolderChange={props.onFolderChange} onSettingsTabChange={props.onSettingsTabChange} onSignedOut={props.onSignedOut} @@ -88,7 +88,7 @@ export function AppShell(props: AppShellProps): React.ReactElement { mailboxId={props.mailboxId} unread={props.unread} user={props.user} - onCompose={props.onCompose} + {...(props.onCompose ? { onCompose: props.onCompose } : {})} onFolderChange={props.onFolderChange} onSettingsTabChange={props.onSettingsTabChange} onSignedOut={props.onSignedOut} @@ -157,7 +157,7 @@ function ShellContent({ unread={unread} user={user} sidebarCollapsed={sidebarCollapsed} - onCompose={onCompose} + {...(onCompose ? { onCompose } : {})} onFolderChange={onFolderChange} onMailboxChange={onMailboxChange} onSearchChange={onSearchChange} diff --git a/app/components/layout/top-bar.tsx b/app/components/layout/top-bar.tsx index 05d08bfa..db19958e 100644 --- a/app/components/layout/top-bar.tsx +++ b/app/components/layout/top-bar.tsx @@ -28,7 +28,7 @@ type TopBarProps = { mailboxId: string; search: string; unread: UnreadCounts; - onCompose: () => void; + onCompose?: (() => void) | undefined; onFolderChange: (folder: FolderId) => void; onMailboxChange: (mailboxId: string) => void; onSearchChange: (search: string) => void; @@ -81,7 +81,7 @@ export function TopBar({ mailboxes={mailboxes} unread={unread} user={user} - onCompose={onCompose} + {...(onCompose ? { onCompose } : {})} onFolderChange={onFolderChange} onMailboxChange={onMailboxChange} onSettingsTabChange={onSettingsTabChange} diff --git a/app/features/compose/compose-state.ts b/app/features/compose/compose-state.ts index 6b0fcd41..7d5bd183 100644 --- a/app/features/compose/compose-state.ts +++ b/app/features/compose/compose-state.ts @@ -81,8 +81,8 @@ export function defaultSendingIdentity( ): SendingIdentity | null { const mailbox = mailboxes.find((candidate) => candidate.id === defaultFromMailboxId); const primaryAddress = - mailbox?.addresses.find((address) => address.isPrimary && address.sendEnabled)?.address ?? - (mailbox?.addresses.length === 0 ? mailbox.address : null); + mailbox?.addresses.find((address) => address.isPrimary && address.sendAvailable)?.address ?? + null; return ( identities.find( (identity) => @@ -100,11 +100,9 @@ export function sendingIdentities(mailboxes: Mailbox[]): SendingIdentity[] { mailbox.isActive && (mailbox.accessLevel === "agent" || mailbox.accessLevel === "manager") ) .flatMap((mailbox) => - mailbox.addresses?.length - ? mailbox.addresses - .filter((address) => address.sendEnabled) - .map((address) => ({ mailboxId: mailbox.id, address: address.address })) - : [{ mailboxId: mailbox.id, address: mailbox.address }] + mailbox.addresses + .filter((address) => address.sendAvailable) + .map((address) => ({ mailboxId: mailbox.id, address: address.address })) ); } diff --git a/app/features/domains/connect-domain-dialog.tsx b/app/features/domains/connect-domain-dialog.tsx index 40763422..4af6c45e 100644 --- a/app/features/domains/connect-domain-dialog.tsx +++ b/app/features/domains/connect-domain-dialog.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { PiPlus } from "react-icons/pi"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogClose, @@ -12,7 +13,7 @@ import { DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Select, SelectContent, @@ -44,6 +45,7 @@ export function ConnectDomainDialog({ const [zones, setZones] = React.useState([]); const [zoneId, setZoneId] = React.useState(""); const [name, setName] = React.useState(""); + const [enableSending, setEnableSending] = React.useState(true); const [pending, setPending] = React.useState(false); const loadZones = React.useCallback(async () => { @@ -82,7 +84,7 @@ export function ConnectDomainDialog({ event.preventDefault(); setPending(true); try { - await provisionDomain({ zoneId, name, enableSending: true }); + await provisionDomain({ zoneId, name, enableSending }); reset(); onConnected(); toast.success("Domain connected."); @@ -95,6 +97,7 @@ export function ConnectDomainDialog({ function reset() { setName(""); + setEnableSending(true); setZoneId(""); setZones([]); } @@ -140,6 +143,21 @@ export function ConnectDomainDialog({ + + setEnableSending(checked === true)} + /> +
+ + Enable outbound sending + + + Requires Workers Paid. Clear this option for receive-only mail. + +
+
diff --git a/app/features/domains/domain-settings.tsx b/app/features/domains/domain-settings.tsx index 836b5859..e99336d2 100644 --- a/app/features/domains/domain-settings.tsx +++ b/app/features/domains/domain-settings.tsx @@ -6,7 +6,13 @@ import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { CloudflareAuthorizationDialog } from "@/features/settings/cloudflare-authorization-dialog"; import { SettingsSection } from "@/features/settings/settings-section"; -import { changePortal, listDomains, revokeCloudflareAuthorization, updateDomain } from "./api"; +import { + changePortal, + listDomains, + provisionDomain, + revokeCloudflareAuthorization, + updateDomain +} from "./api"; import { ConnectDomainDialog } from "./connect-domain-dialog"; import { DomainTable } from "./domain-table"; import type { MailDomain } from "./types"; @@ -15,7 +21,8 @@ const PENDING_OPERATION_KEY = "hqb_cloudflare_operation_v1"; type PendingCloudflareOperation = | { action: "connect" } - | { action: "portal"; hostname: string; zoneId: string }; + | { action: "portal"; hostname: string; zoneId: string } + | { action: "sending"; domainId: string; name: string; zoneId: string }; export function DomainSettings({ portalHostname, @@ -55,7 +62,7 @@ export function DomainSettings({ const pending = readPendingOperation(); if (pending?.action === "connect") { setConnectOpen(true); - } else if (pending?.action === "portal") { + } else if (pending?.action === "portal" || pending?.action === "sending") { setAuthorizationOperation(pending); } else { toast.error("Sign in again, then restart the Cloudflare change."); @@ -87,6 +94,28 @@ export function DomainSettings({ } setChangePending(true); + if (pending.action === "sending") { + setPendingDomainId(pending.domainId); + void provisionDomain({ zoneId: pending.zoneId, name: pending.name, enableSending: true }) + .then(({ domain }) => { + if (domain.sendingStatus !== "ready") { + throw new Error("Cloudflare has not reported Email Sending as ready."); + } + refresh(); + onChanged(); + toast.success(`Sending enabled for ${domain.name}.`); + }) + .catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : "Cloudflare change failed."); + }) + .finally(() => { + sessionStorage.removeItem(PENDING_OPERATION_KEY); + setChangePending(false); + setPendingDomainId(null); + }); + return; + } + void changePortal({ zoneId: pending.zoneId, hostname: pending.hostname }) .then(() => { onChanged(); @@ -99,7 +128,7 @@ export function DomainSettings({ sessionStorage.removeItem(PENDING_OPERATION_KEY); setChangePending(false); }); - }, [onChanged]); + }, [onChanged, refresh]); function portal(event: React.FormEvent) { event.preventDefault(); @@ -124,6 +153,19 @@ export function DomainSettings({ } } + function enableSending(domain: MailDomain) { + if (!domain.zoneId) { + toast.error("Reconnect this domain to its Cloudflare zone before enabling sending."); + return; + } + setAuthorizationOperation({ + action: "sending", + domainId: domain.id, + name: domain.name, + zoneId: domain.zoneId + }); + } + return ( void toggleDomain(domain)} /> @@ -187,8 +230,15 @@ export function DomainSettings({ { if (authorizationOperation) { sessionStorage.setItem(PENDING_OPERATION_KEY, JSON.stringify(authorizationOperation)); @@ -215,6 +265,19 @@ function readPendingOperation(): PendingCloudflareOperation | null { ) { return { action: "portal", hostname: value.hostname, zoneId: value.zoneId }; } + if ( + value?.action === "sending" && + typeof value.domainId === "string" && + typeof value.name === "string" && + typeof value.zoneId === "string" + ) { + return { + action: "sending", + domainId: value.domainId, + name: value.name, + zoneId: value.zoneId + }; + } } catch { // Ignore malformed, non-secret browser draft state. } diff --git a/app/features/domains/domain-table.tsx b/app/features/domains/domain-table.tsx index 2dbea2d0..5869b449 100644 --- a/app/features/domains/domain-table.tsx +++ b/app/features/domains/domain-table.tsx @@ -15,10 +15,12 @@ import type { MailDomain } from "./types"; export function DomainTable({ domains, pendingDomainId, + onEnableSending, onToggle }: { domains: MailDomain[]; pendingDomainId: string | null; + onEnableSending: (domain: MailDomain) => void; onToggle: (domain: MailDomain) => void; }): React.ReactElement { return ( @@ -67,20 +69,34 @@ export function DomainTable({ - +
+ {domain.isEnabled && domain.sendingStatus !== "ready" && domain.zoneId ? ( + + ) : null} + +
))} diff --git a/app/features/mailboxes/default-from-mailbox-control.tsx b/app/features/mailboxes/default-from-mailbox-control.tsx index 9515262f..c6320b21 100644 --- a/app/features/mailboxes/default-from-mailbox-control.tsx +++ b/app/features/mailboxes/default-from-mailbox-control.tsx @@ -79,7 +79,6 @@ function defaultFromMailboxOptions(mailboxes: Mailbox[]): Mailbox[] { (mailbox) => mailbox.isActive && (mailbox.accessLevel === "agent" || mailbox.accessLevel === "manager") && - (mailbox.addresses.length === 0 || - mailbox.addresses.some((address) => address.isPrimary && address.sendEnabled)) + mailbox.addresses.some((address) => address.isPrimary && address.sendAvailable) ); } diff --git a/app/features/mailboxes/types.ts b/app/features/mailboxes/types.ts index 23b0e5b0..29d6b124 100644 --- a/app/features/mailboxes/types.ts +++ b/app/features/mailboxes/types.ts @@ -9,6 +9,7 @@ export type Mailbox = { displayName: string; receiveEnabled: boolean; sendEnabled: boolean; + sendAvailable: boolean; isPrimary: boolean; }>; displayName: string; diff --git a/app/features/messages/message-detail.tsx b/app/features/messages/message-detail.tsx index 59df1563..fd7ec051 100644 --- a/app/features/messages/message-detail.tsx +++ b/app/features/messages/message-detail.tsx @@ -11,7 +11,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { PullToRefresh } from "@/components/ui/pull-to-refresh"; -import type { ComposeMode } from "@/features/compose/compose-state"; +import { type ComposeMode, sendingIdentities } from "@/features/compose/compose-state"; import type { Mailbox } from "@/features/mailboxes/types"; import type { MailFolderId } from "@/lib/routes"; import { ConversationMessages } from "./conversation-messages"; @@ -70,6 +70,7 @@ export function MessageDetail({ onSent }: MessageDetailProps): React.ReactElement { const [composeState, setComposeState] = React.useState(null); + const canSend = sendingIdentities(mailboxes).length > 0; if (isLoading) { return ; @@ -181,7 +182,12 @@ export function MessageDetail({ setComposeState({ message, mode })} + {...(canSend + ? { + onCompose: (message: MessageDetailType, mode: ThreadComposeMode) => + setComposeState({ message, mode }) + } + : {})} /> {composeState ? (
diff --git a/app/features/setup/api.ts b/app/features/setup/api.ts index eefcffb2..d8559285 100644 --- a/app/features/setup/api.ts +++ b/app/features/setup/api.ts @@ -26,6 +26,7 @@ export async function listCloudflareZones(): Promise { } export async function inspectCloudflareDomain(input: { + requireSending?: boolean; workerName: string; zoneId: string; }): Promise { diff --git a/app/features/setup/setup-domain-screen.tsx b/app/features/setup/setup-domain-screen.tsx index c6ad01d8..615aff12 100644 --- a/app/features/setup/setup-domain-screen.tsx +++ b/app/features/setup/setup-domain-screen.tsx @@ -27,6 +27,7 @@ export function DomainStep(props: { appHostname: string; appSubdomain: string; connectionError: string | null; + enableSending: boolean; errors: DomainErrors; isLoading: boolean; onBack: () => void; @@ -38,6 +39,7 @@ export function DomainStep(props: { selectedZoneIds: string[]; selectedZones: CloudflareZone[]; setAppSubdomain: (value: string) => void; + setEnableSending: (value: boolean) => void; setPortalZoneId: (value: string) => void; zones: CloudflareZone[]; }): React.ReactElement { @@ -108,6 +110,20 @@ export function DomainStep(props: {
+ + props.setEnableSending(checked === true)} + /> +
+ Enable outbound sending + + Requires Workers Paid. Clear this option to create a receive-only workspace. + +
+
+ undefined} @@ -130,6 +131,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode { selectedZoneIds={input.selectedZoneIds} selectedZones={input.selectedZones} setAppSubdomain={input.setAppSubdomain} + setEnableSending={() => undefined} setPortalZoneId={input.setPortalZoneId} zones={zones} /> @@ -164,6 +166,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode { errors={{ rows: input.mailboxes.map(() => ({})) }} isPending={input.state === "submitting"} mailboxes={input.mailboxes} + sendingEnabled onAdd={() => input.setMailboxes((current) => [...current, { address: "", displayName: "" }])} onBack={() => undefined} onComplete={() => undefined} @@ -227,6 +230,7 @@ function readinessFailureFixture(): ConfiguredDomain[] { status: { zone, workerName: "hqbase-preview", + sendingRequired: true, routing: { enabled: true, status: "active", diff --git a/app/features/setup/setup-workspace-screens.tsx b/app/features/setup/setup-workspace-screens.tsx index c6cbc746..19713c92 100644 --- a/app/features/setup/setup-workspace-screens.tsx +++ b/app/features/setup/setup-workspace-screens.tsx @@ -142,6 +142,7 @@ export function MailboxStep({ errors, isPending, mailboxes, + sendingEnabled, onAdd, onBack, onComplete, @@ -154,6 +155,7 @@ export function MailboxStep({ errors: MailboxErrors; isPending: boolean; mailboxes: MailboxDraft[]; + sendingEnabled: boolean; onAdd: () => void; onBack: () => void; onComplete: () => void; @@ -248,29 +250,35 @@ export function MailboxStep({ Add mailbox - - Default From mailbox - - - New messages and forwards start from this mailbox. Replies use the mailbox that received - the original message. - - + {sendingEnabled ? ( + + Default From mailbox + + + New messages and forwards start from this mailbox. Replies use the mailbox that received + the original message. + + + ) : ( +

+ These mailboxes can receive mail. Enable sending later in Settings → Domains. +

+ )} {errors.form ? {errors.form} : null} {submitError ? ( diff --git a/app/features/setup/types.ts b/app/features/setup/types.ts index dbd0ff12..dda7a658 100644 --- a/app/features/setup/types.ts +++ b/app/features/setup/types.ts @@ -14,9 +14,14 @@ export type BootstrapSetupInput = { ownerPassword: string; primaryDomain: string; portalHostname: string; - emailDomains: Array<{ name: string; zoneId: string; accountId: string | null }>; + emailDomains: Array<{ + name: string; + zoneId: string; + accountId: string | null; + sendingStatus: "ready" | "disabled"; + }>; checklistAcknowledged: boolean; - defaultFromMailboxAddress: string; + defaultFromMailboxAddress: string | null; mailboxes: Array<{ address: string; displayName: string; @@ -41,6 +46,7 @@ export type CloudflareAccessStatus = { export type CloudflareDomainStatus = { zone: CloudflareZone; workerName: string; + sendingRequired: boolean; routing: { enabled: boolean; status: string | null; diff --git a/app/features/setup/use-setup-cloudflare.ts b/app/features/setup/use-setup-cloudflare.ts index 2586b91e..ba183817 100644 --- a/app/features/setup/use-setup-cloudflare.ts +++ b/app/features/setup/use-setup-cloudflare.ts @@ -23,6 +23,7 @@ export function useSetupCloudflare(callbacks: { const [portalZoneId, setPortalZoneId] = React.useState(""); const workerName = React.useMemo(() => inferWorkerName(), []); const [appSubdomain, setAppSubdomain] = React.useState("hqbase"); + const [enableSending, setEnableSending] = React.useState(true); const [domainAttempted, setDomainAttempted] = React.useState(false); const [connectionError, setConnectionError] = React.useState(null); const [results, setResults] = React.useState([]); @@ -37,7 +38,8 @@ export function useSetupCloudflare(callbacks: { ...selectedZoneIds.slice().sort(), portalZoneId, appHostname, - workerName + workerName, + enableSending ? "send" : "receive-only" ].join(":"); const domainConnected = Boolean( configuredKey === currentConnectionKey && @@ -96,7 +98,7 @@ export function useSetupCloudflare(callbacks: { const result = await configureCloudflareDomain({ ...(isPortal ? { appHostname } : {}), attachCustomDomain: isPortal, - enableSending: true, + enableSending, workerName: workerName.trim(), zoneId: zone.id }); @@ -155,6 +157,7 @@ export function useSetupCloudflare(callbacks: { domain: { appHostname, appSubdomain, + enableSending, connectionError, errors: domainErrors, isLoading, @@ -167,10 +170,16 @@ export function useSetupCloudflare(callbacks: { onConnect: () => void handleDomainConnect(), onToggleZone: toggleZone, setAppSubdomain: (value: string) => update(() => setAppSubdomain(value)), + setEnableSending: (value: boolean) => update(() => setEnableSending(value)), setPortalZoneId: (value: string) => update(() => setPortalZoneId(value)) }, domainConnected, - emailDomains: selectedZones.map(({ accountId, id, name }) => ({ accountId, name, zoneId: id })), + emailDomains: selectedZones.map(({ accountId, id, name }) => ({ + accountId, + name, + sendingStatus: enableSending ? ("ready" as const) : ("disabled" as const), + zoneId: id + })), primaryDomain, portalHostname: appHostname, requireConnection(message = "Connect the domains before continuing.") { diff --git a/app/features/setup/use-setup-flow.ts b/app/features/setup/use-setup-flow.ts index 11cda210..8d181b2f 100644 --- a/app/features/setup/use-setup-flow.ts +++ b/app/features/setup/use-setup-flow.ts @@ -126,7 +126,11 @@ export function useSetupFlow(onComplete: () => void) { const input: BootstrapSetupInput = { checklistAcknowledged: true, - defaultFromMailboxAddress, + defaultFromMailboxAddress: cloudflare.emailDomains.some( + (domain) => domain.sendingStatus === "ready" + ) + ? defaultFromMailboxAddress + : null, mailboxes, ownerEmail, ownerName, @@ -192,6 +196,7 @@ export function useSetupFlow(onComplete: () => void) { errors: mailboxErrors, isPending, mailboxes, + sendingEnabled: cloudflare.emailDomains.some((domain) => domain.sendingStatus === "ready"), submitError, onAdd: addMailbox, onBack: () => setActiveStep(OWNER_STEP), diff --git a/test/integration/worker/auth.test.ts b/test/integration/worker/auth.test.ts index 92a8c312..b3927d3c 100644 --- a/test/integration/worker/auth.test.ts +++ b/test/integration/worker/auth.test.ts @@ -226,8 +226,9 @@ describe("Better Auth schema", () => { const timestamp = new Date().toISOString(); await env.DB.batch([ env.DB.prepare( - `INSERT INTO mail_domains (id, name, created_at, updated_at) - VALUES ('domain_preferences', 'preferences.example', ?, ?)` + `INSERT INTO mail_domains + (id, name, receiving_status, sending_status, dns_status, created_at, updated_at) + VALUES ('domain_preferences', 'preferences.example', 'ready', 'ready', 'ready', ?, ?)` ).bind(timestamp, timestamp), env.DB.prepare( `INSERT INTO mailboxes diff --git a/test/integration/worker/receive-only-mailboxes.test.ts b/test/integration/worker/receive-only-mailboxes.test.ts new file mode 100644 index 00000000..dde578b2 --- /dev/null +++ b/test/integration/worker/receive-only-mailboxes.test.ts @@ -0,0 +1,57 @@ +import { env } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { upsertMailDomain } from "../../../worker/features/domains/queries"; +import { findAddressIdentity } from "../../../worker/features/mailboxes/address-queries"; +import { listMailboxes } from "../../../worker/features/mailboxes/queries"; +import { createMailbox } from "../../../worker/features/mailboxes/service"; +import { applyCurrentMigrations } from "./current-migrations"; + +describe("receive-only mailbox identities", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + }); + + beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM mailbox_addresses"), + env.DB.prepare("DELETE FROM mailboxes"), + env.DB.prepare("DELETE FROM mail_domains") + ]); + }); + + it("keeps receiving available while hiding sending until the domain is ready", async () => { + await upsertMailDomain(env.DB, { + name: "example.com", + receivingStatus: "ready", + sendingStatus: "disabled", + dnsStatus: "ready" + }); + await createMailbox(env.DB, { + address: "support@example.com", + displayName: "Support" + }); + + expect((await listMailboxes(env.DB))[0]?.addresses[0]).toMatchObject({ + receiveEnabled: true, + sendEnabled: true, + sendAvailable: false + }); + await expect( + findAddressIdentity(env.DB, "support@example.com", "receive") + ).resolves.not.toBeNull(); + await expect(findAddressIdentity(env.DB, "support@example.com", "send")).resolves.toBeNull(); + + await upsertMailDomain(env.DB, { + name: "example.com", + receivingStatus: "ready", + sendingStatus: "ready", + dnsStatus: "ready" + }); + + expect((await listMailboxes(env.DB))[0]?.addresses[0]?.sendAvailable).toBe(true); + await expect( + findAddressIdentity(env.DB, "support@example.com", "send") + ).resolves.not.toBeNull(); + }); +}); diff --git a/test/unit/app/compose/compose-state.test.ts b/test/unit/app/compose/compose-state.test.ts index 5115fdf6..b9d120d6 100644 --- a/test/unit/app/compose/compose-state.test.ts +++ b/test/unit/app/compose/compose-state.test.ts @@ -46,6 +46,7 @@ describe("composer state", () => { displayName: "Support", receiveEnabled: true, sendEnabled: true, + sendAvailable: true, isPrimary: true }, { @@ -56,6 +57,7 @@ describe("composer state", () => { displayName: "Support", receiveEnabled: true, sendEnabled: false, + sendAvailable: false, isPrimary: false } ] @@ -77,6 +79,7 @@ describe("composer state", () => { displayName: "Sales", receiveEnabled: true, sendEnabled: true, + sendAvailable: true, isPrimary: true } ] @@ -88,6 +91,35 @@ describe("composer state", () => { ]); }); + it("does not expose an address when its domain cannot send", () => { + expect( + sendingIdentities([ + { + id: "mbx_1", + address: "support@example.com", + displayName: "Support", + isActive: true, + accessLevel: "manager", + createdAt: "now", + updatedAt: "now", + addresses: [ + { + id: "addr_1", + mailboxId: "mbx_1", + mailDomainId: "dom_1", + address: "support@example.com", + displayName: "Support", + receiveEnabled: true, + sendEnabled: true, + sendAvailable: false, + isPrimary: true + } + ] + } + ]) + ).toEqual([]); + }); + it("uses crash recovery only when it is newer than the server draft", () => { vi.stubGlobal("localStorage", { getItem: () => @@ -219,6 +251,7 @@ describe("composer state", () => { displayName: "Support", receiveEnabled: true, sendEnabled: true, + sendAvailable: true, isPrimary: true } ] @@ -240,6 +273,7 @@ describe("composer state", () => { displayName: "Privacy", receiveEnabled: true, sendEnabled: true, + sendAvailable: true, isPrimary: true } ] diff --git a/test/unit/app/messages/conversation-reader.test.tsx b/test/unit/app/messages/conversation-reader.test.tsx index d380b16f..7e925799 100644 --- a/test/unit/app/messages/conversation-reader.test.tsx +++ b/test/unit/app/messages/conversation-reader.test.tsx @@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { InboxPage } from "@/features/inbox/inbox-page"; +import type { Mailbox } from "@/features/mailboxes/types"; import { ConversationMessages } from "@/features/messages/conversation-messages"; import { MessageDetail } from "@/features/messages/message-detail"; import { MessageListItem } from "@/features/messages/message-list-item"; @@ -63,12 +64,35 @@ const conversation: ConversationSummary = { unreadCount: 1 }; +const sendableMailbox: Mailbox = { + id: "mbx_1", + address: "support@example.com", + addresses: [ + { + id: "addr_1", + mailboxId: "mbx_1", + mailDomainId: "dom_1", + address: "support@example.com", + displayName: "Support", + receiveEnabled: true, + sendEnabled: true, + sendAvailable: true, + isPrimary: true + } + ], + displayName: "Support", + isActive: true, + accessLevel: "manager", + createdAt: "2026-07-27T14:00:00.000Z", + updatedAt: "2026-07-27T14:00:00.000Z" +}; + describe("conversation reader", () => { it("renders Reply and Forward under the last message", () => { const html = renderToStaticMarkup( undefined} diff --git a/test/unit/app/settings/settings-presentation.test.tsx b/test/unit/app/settings/settings-presentation.test.tsx index a7cf97a1..79c33f4a 100644 --- a/test/unit/app/settings/settings-presentation.test.tsx +++ b/test/unit/app/settings/settings-presentation.test.tsx @@ -26,7 +26,19 @@ const setup = { const mailbox: Mailbox = { id: "mailbox-1", address: "support@example.com", - addresses: [], + addresses: [ + { + id: "address-1", + mailboxId: "mailbox-1", + mailDomainId: "domain-1", + address: "support@example.com", + displayName: "Support", + receiveEnabled: true, + sendEnabled: true, + sendAvailable: true, + isPrimary: true + } + ], displayName: "Support", isActive: true, accessLevel: "manager", @@ -38,6 +50,19 @@ const secondDomainMailbox: Mailbox = { ...mailbox, id: "mailbox-2", address: "privacy@example.net", + addresses: [ + { + id: "address-2", + mailboxId: "mailbox-2", + mailDomainId: "domain-2", + address: "privacy@example.net", + displayName: "Privacy", + receiveEnabled: true, + sendEnabled: true, + sendAvailable: true, + isPrimary: true + } + ], displayName: "Privacy" }; @@ -229,6 +254,25 @@ describe("settings presentation", () => { expect(html).toContain('aria-label="Filter mailboxes by domain"'); }); + it("does not offer a default From mailbox for a receive-only domain", () => { + const receiveOnlyMailbox: Mailbox = { + ...mailbox, + addresses: mailbox.addresses.map((address) => ({ ...address, sendAvailable: false })) + }; + const html = renderToStaticMarkup( + undefined} + onDefaultFromMailboxChange={() => undefined} + /> + ); + + expect(html).not.toContain("Default From mailbox"); + }); + it("only shows one bulk action after mailbox selection", () => { expect( renderToStaticMarkup( undefined} />) @@ -259,7 +303,12 @@ describe("settings presentation", () => { it("renders connected domains in the compact settings table", () => { const html = renderToStaticMarkup( - undefined} /> + undefined} + onToggle={() => undefined} + /> ); expect(html).toContain(">Domain<"); diff --git a/test/unit/app/setup/setup-ui.test.tsx b/test/unit/app/setup/setup-ui.test.tsx index 43a0cde3..21182a34 100644 --- a/test/unit/app/setup/setup-ui.test.tsx +++ b/test/unit/app/setup/setup-ui.test.tsx @@ -121,6 +121,7 @@ describe("setup UI", () => { errors={{ rows: mailboxes.map(() => ({})) }} isPending={false} mailboxes={mailboxes} + sendingEnabled onAdd={() => undefined} onBack={() => undefined} onComplete={() => undefined} diff --git a/test/unit/worker/features/setup/api-validation.test.ts b/test/unit/worker/features/setup/api-validation.test.ts index 6f8fe9e6..df5b25fa 100644 --- a/test/unit/worker/features/setup/api-validation.test.ts +++ b/test/unit/worker/features/setup/api-validation.test.ts @@ -54,7 +54,10 @@ describe("setup API validation", () => { ownerName: "Owner", ownerEmail: "owner@example.com", ownerPassword: "password123", - emailDomains: [{ name: "support.example" }, { name: "example.com" }], + emailDomains: [ + { name: "support.example", sendingStatus: "ready" }, + { name: "example.com", sendingStatus: "ready" } + ], checklistAcknowledged: true, defaultFromMailboxAddress: "hello@example.com", mailboxes: [{ address: "hello@example.com", displayName: "Hello" }] @@ -90,7 +93,41 @@ describe("setup API validation", () => { defaultFromMailboxAddress: "privacy@example.com", mailboxes: [{ address: "hello@example.com", displayName: "Hello" }] }) - ).toThrow("Choose one of the setup mailboxes as the default From mailbox."); + ).toThrow("Choose a mailbox on a send-enabled domain as the default From mailbox."); + }); + + it("accepts receive-only setup without a default From mailbox", () => { + expect( + bootstrapSetupSchema.parse({ + ownerName: "Owner", + ownerEmail: "owner@gmail.com", + ownerPassword: "password123", + emailDomains: [{ name: "example.com", sendingStatus: "disabled" }], + checklistAcknowledged: true, + defaultFromMailboxAddress: null, + mailboxes: [{ address: "hello@example.com", displayName: "Hello" }] + }) + ).toMatchObject({ defaultFromMailboxAddress: null }); + }); + + it("requires a send-enabled default From mailbox when any domain can send", () => { + expect(() => + bootstrapSetupSchema.parse({ + ownerName: "Owner", + ownerEmail: "owner@gmail.com", + ownerPassword: "password123", + emailDomains: [ + { name: "receive.example", sendingStatus: "disabled" }, + { name: "send.example", sendingStatus: "ready" } + ], + checklistAcknowledged: true, + defaultFromMailboxAddress: "hello@receive.example", + mailboxes: [ + { address: "hello@receive.example", displayName: "Hello" }, + { address: "support@send.example", displayName: "Support" } + ] + }) + ).toThrow("Choose a mailbox on a send-enabled domain as the default From mailbox."); }); it("rejects credentials in Cloudflare zone listing input", () => { diff --git a/test/unit/worker/features/setup/cloudflare-setup-api.test.ts b/test/unit/worker/features/setup/cloudflare-setup-api.test.ts index 404bf9a6..07fd777a 100644 --- a/test/unit/worker/features/setup/cloudflare-setup-api.test.ts +++ b/test/unit/worker/features/setup/cloudflare-setup-api.test.ts @@ -234,6 +234,38 @@ describe("Cloudflare setup API", () => { ).toBe(false); }); + it("finishes receive-only setup without changing Email Sending", async () => { + const fetchMock = vi.fn((input, init) => { + const url = fetchInputUrl(input); + const method = init?.method ?? "GET"; + if (url === `${API_BASE}/zones/zone-1/email/sending/subdomains`) { + return Promise.resolve(jsonResponse({ result: [] })); + } + return Promise.resolve(cloudflareSetupResponse(url, method)); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await configureCloudflareDomain({ + apiToken: "token-123", + enableSending: false, + workerName: "hqbase", + zoneId: "zone-1" + }); + + expect(result.status).toMatchObject({ ready: true, sendingRequired: false }); + expect(result.steps.find((step) => step.id === "sending")).toMatchObject({ + message: "Skipped. This workspace receives mail but cannot send it.", + status: "skipped" + }); + expect( + fetchMock.mock.calls.some( + ([input, init]) => + fetchInputUrl(input) === `${API_BASE}/zones/zone-1/email/sending/subdomains` && + init?.method === "POST" + ) + ).toBe(false); + }); + it("accepts the current Email Routing DNS record response", async () => { const fetchMock = vi.fn((input, init) => { const url = fetchInputUrl(input); diff --git a/worker/features/domains/routes.ts b/worker/features/domains/routes.ts index fa00d19e..32edbf2f 100644 --- a/worker/features/domains/routes.ts +++ b/worker/features/domains/routes.ts @@ -123,7 +123,11 @@ domainRoutes.post("/provision", async (c) => { accountId: result.status.zone.accountId, receivingStatus: result.status.routing.enabled && result.status.catchAll.enabled ? "ready" : "degraded", - sendingStatus: result.status.sending.enabled ? "ready" : "degraded", + sendingStatus: input.enableSending + ? result.status.sending.enabled + ? "ready" + : "degraded" + : "disabled", dnsStatus: result.status.routing.dnsReady ? "ready" : "degraded" }); await recordAudit(c.env.DB, { diff --git a/worker/features/mailboxes/address-queries.ts b/worker/features/mailboxes/address-queries.ts index 41d33b8e..69c3aff3 100644 --- a/worker/features/mailboxes/address-queries.ts +++ b/worker/features/mailboxes/address-queries.ts @@ -22,7 +22,7 @@ export async function findAddressIdentity( >( db, sql`SELECT a.id, a.mailbox_id, a.mail_domain_id, a.address, a.display_name, - a.receive_enabled, a.send_enabled, a.is_primary, + a.receive_enabled, a.send_enabled, a.is_primary, d.sending_status, m.address AS mailbox_address, m.display_name AS mailbox_display_name, m.is_active, m.created_at, m.updated_at FROM mailbox_addresses a diff --git a/worker/features/mailboxes/queries.ts b/worker/features/mailboxes/queries.ts index 759d5ac1..2df36f41 100644 --- a/worker/features/mailboxes/queries.ts +++ b/worker/features/mailboxes/queries.ts @@ -37,6 +37,7 @@ export function mapMailboxAddress(row: MailboxAddressRow): MailboxAddress { displayName: row.display_name, receiveEnabled: row.receive_enabled === 1, sendEnabled: row.send_enabled === 1, + sendAvailable: row.send_enabled === 1 && row.sending_status === "ready", isPrimary: row.is_primary === 1 }; } @@ -49,9 +50,10 @@ export async function addressMap( if (mailboxIds.length === 0) return mapped; const rows = await getRows( db, - sql`SELECT id, mailbox_id, mail_domain_id, address, display_name, - receive_enabled, send_enabled, is_primary - FROM mailbox_addresses + sql`SELECT a.id, a.mailbox_id, a.mail_domain_id, a.address, a.display_name, + a.receive_enabled, a.send_enabled, a.is_primary, d.sending_status + FROM mailbox_addresses a + JOIN mail_domains d ON d.id = a.mail_domain_id WHERE mailbox_id IN (${sql.join( mailboxIds.map((mailboxId) => sql`${mailboxId}`), sql`, ` @@ -158,7 +160,8 @@ export async function findMailboxById(db: D1Database, id: string): Promise { const timestamp = nowIso(); const id = newId("mbx"); @@ -201,6 +204,7 @@ export async function insertMailbox( displayName: input.displayName, receiveEnabled: true, sendEnabled: true, + sendAvailable: sendingReady, isPrimary: true } ], @@ -215,7 +219,8 @@ export async function insertMailboxAddress( db: D1Database, mailboxId: string, mailDomainId: string, - input: CreateMailboxAddressInput + input: CreateMailboxAddressInput, + sendingReady: boolean ): Promise { const id = newId("addr"); const timestamp = nowIso(); @@ -243,6 +248,7 @@ export async function insertMailboxAddress( displayName: input.displayName, receiveEnabled: input.receiveEnabled !== false, sendEnabled: input.sendEnabled !== false, + sendAvailable: input.sendEnabled !== false && sendingReady, isPrimary: false }; } diff --git a/worker/features/mailboxes/service.ts b/worker/features/mailboxes/service.ts index a8658010..1117f203 100644 --- a/worker/features/mailboxes/service.ts +++ b/worker/features/mailboxes/service.ts @@ -29,7 +29,7 @@ export async function createMailbox(db: D1Database, input: CreateMailboxInput): throw new AppError("MAILBOX_EXISTS", "A mailbox with this address already exists.", 409); } - return insertMailbox(db, input, domain.id); + return insertMailbox(db, input, domain.id, domain.sendingStatus === "ready"); } export async function createMailboxAddress( @@ -46,7 +46,7 @@ export async function createMailboxAddress( if (await findMailboxByAddress(db, input.address)) { throw new AppError("MAILBOX_ADDRESS_EXISTS", "This email address is already in use.", 409); } - return insertMailboxAddress(db, mailboxId, domain.id, input); + return insertMailboxAddress(db, mailboxId, domain.id, input, domain.sendingStatus === "ready"); } export async function removeMailboxAddress( diff --git a/worker/features/mailboxes/types.ts b/worker/features/mailboxes/types.ts index dbc9acca..d4909cf8 100644 --- a/worker/features/mailboxes/types.ts +++ b/worker/features/mailboxes/types.ts @@ -16,6 +16,7 @@ export type MailboxAddress = { displayName: string; receiveEnabled: boolean; sendEnabled: boolean; + sendAvailable: boolean; isPrimary: boolean; }; @@ -27,6 +28,7 @@ export type MailboxAddressRow = { display_name: string; receive_enabled: number; send_enabled: number; + sending_status: "pending" | "ready" | "degraded" | "disabled"; is_primary: number; }; diff --git a/worker/features/preferences/service.ts b/worker/features/preferences/service.ts index 96280df4..f33b04b1 100644 --- a/worker/features/preferences/service.ts +++ b/worker/features/preferences/service.ts @@ -14,9 +14,9 @@ export async function updateDefaultFromMailbox( ): Promise { await requireMailboxAccess(db, input.userId, input.role, input.mailboxId, "agent"); const mailbox = await findMailboxById(db, input.mailboxId); - const primaryCanSend = - mailbox?.addresses.length === 0 || - mailbox?.addresses.some((address) => address.isPrimary && address.sendEnabled); + const primaryCanSend = mailbox?.addresses.some( + (address) => address.isPrimary && address.sendAvailable + ); if (!mailbox?.isActive || !primaryCanSend) { throw new AppError( "MAILBOX_NOT_SENDABLE", diff --git a/worker/features/setup/cloudflare.ts b/worker/features/setup/cloudflare.ts index 61a9d6d2..f43e0c6b 100644 --- a/worker/features/setup/cloudflare.ts +++ b/worker/features/setup/cloudflare.ts @@ -18,6 +18,7 @@ type CloudflareInput = { apiToken: string }; type CloudflareZoneInput = CloudflareInput & { zoneId: string; workerName?: string | undefined; + requireSending?: boolean | undefined; }; type CloudflareConfigureInput = CloudflareZoneInput & { @@ -114,6 +115,7 @@ export async function inspectCloudflareDomain( input: CloudflareZoneInput ): Promise { const workerName = normalizeWorkerName(input.workerName); + const sendingRequired = input.requireSending ?? true; const zone = mapZone( await cloudflareRequestResult(input.apiToken, `/zones/${input.zoneId}`, cloudflareZoneSchema) ); @@ -130,13 +132,14 @@ export async function inspectCloudflareDomain( routing.dnsReady && catchAll.enabled && catchAll.configuredForWorker && - sending.enabled; + (!sendingRequired || sending.enabled); return { catchAll, ready, routing, sending, + sendingRequired, workerName, zone }; @@ -229,7 +232,7 @@ export async function configureCloudflareDomain( steps.push({ id: "sending", label: "Enable Email Sending", - message: "Skipped by setup option.", + message: "Skipped. This workspace receives mail but cannot send it.", status: "skipped" }); } @@ -237,6 +240,7 @@ export async function configureCloudflareDomain( return { status: await inspectCloudflareDomain({ apiToken: input.apiToken, + requireSending: input.enableSending, workerName, zoneId: zone.id }), diff --git a/worker/features/setup/service.ts b/worker/features/setup/service.ts index b8d72b13..d75a166a 100644 --- a/worker/features/setup/service.ts +++ b/worker/features/setup/service.ts @@ -27,10 +27,11 @@ type BootstrapInput = { name: string; zoneId?: string | null | undefined; accountId?: string | null | undefined; + sendingStatus: "ready" | "disabled"; }> | undefined; checklistAcknowledged: boolean; - defaultFromMailboxAddress: string; + defaultFromMailboxAddress: string | null; mailboxes: Array<{ address: string; displayName: string; @@ -50,7 +51,9 @@ export async function bootstrapSetup( throw new AppError("SETUP_OWNER_EXISTS", "An owner user already exists.", 409); } - const domains = input.emailDomains ?? [{ name: input.primaryDomain ?? "" }]; + const domains = input.emailDomains ?? [ + { name: input.primaryDomain ?? "", sendingStatus: "ready" as const } + ]; if (!domains[0]?.name) throw new AppError("DOMAIN_REQUIRED", "Choose an email domain.", 400); assertLoginEmailOutsideDomains( input.ownerEmail, @@ -61,7 +64,7 @@ export async function bootstrapSetup( await upsertMailDomain(env.DB, { ...domain, receivingStatus: "ready", - sendingStatus: "ready", + sendingStatus: domain.sendingStatus, dnsStatus: "ready" }); } @@ -89,17 +92,19 @@ export async function bootstrapSetup( for (const mailbox of input.mailboxes) { mailboxes.push(await createMailbox(env.DB, mailbox)); } - const defaultFromMailbox = mailboxes.find( - (mailbox) => mailbox.address === input.defaultFromMailboxAddress - ); - if (!defaultFromMailbox) { - throw new AppError( - "DEFAULT_FROM_MAILBOX_REQUIRED", - "Choose one of the setup mailboxes as the default From mailbox.", - 400 + if (input.defaultFromMailboxAddress) { + const defaultFromMailbox = mailboxes.find( + (mailbox) => mailbox.address === input.defaultFromMailboxAddress ); + if (!defaultFromMailbox) { + throw new AppError( + "DEFAULT_FROM_MAILBOX_REQUIRED", + "Choose one of the setup mailboxes as the default From mailbox.", + 400 + ); + } + await setDefaultFromMailboxId(env.DB, owner.id, defaultFromMailbox.id); } - await setDefaultFromMailboxId(env.DB, owner.id, defaultFromMailbox.id); await completeSetupIfReady(env.DB); diff --git a/worker/features/setup/types.ts b/worker/features/setup/types.ts index 57642552..09c5bd36 100644 --- a/worker/features/setup/types.ts +++ b/worker/features/setup/types.ts @@ -70,6 +70,7 @@ export type CloudflareSendingStatus = { export type CloudflareDomainStatus = { zone: CloudflareZone; workerName: string; + sendingRequired: boolean; routing: CloudflareRoutingStatus; catchAll: CloudflareCatchAllStatus; sending: CloudflareSendingStatus; diff --git a/worker/features/setup/validation.ts b/worker/features/setup/validation.ts index 7f7127cb..c1f6bf4f 100644 --- a/worker/features/setup/validation.ts +++ b/worker/features/setup/validation.ts @@ -27,14 +27,15 @@ export const bootstrapSetupSchema = z z.object({ name: domainSchema, zoneId: z.string().trim().min(1).max(64).nullable().optional(), - accountId: z.string().trim().min(1).max(64).nullable().optional() + accountId: z.string().trim().min(1).max(64).nullable().optional(), + sendingStatus: z.enum(["ready", "disabled"]) }) ) .min(1) .max(50) .optional(), checklistAcknowledged: z.literal(true), - defaultFromMailboxAddress: emailAddressSchema, + defaultFromMailboxAddress: emailAddressSchema.nullable(), mailboxes: z.array(createMailboxSchema).min(1).max(20) }) .superRefine((input, context) => { @@ -75,10 +76,28 @@ export const bootstrapSetupSchema = z } seen.add(mailbox.address); } - if (!input.mailboxes.some((mailbox) => mailbox.address === input.defaultFromMailboxAddress)) { + const sendingDomains = new Set( + input.emailDomains + ?.filter((domain) => domain.sendingStatus === "ready") + .map((domain) => domain.name) ?? domains + ); + const defaultFromMailbox = input.mailboxes.find( + (mailbox) => mailbox.address === input.defaultFromMailboxAddress + ); + const defaultFromDomain = defaultFromMailbox?.address.split("@")[1]; + if ( + sendingDomains.size > 0 && + (!defaultFromMailbox || !defaultFromDomain || !sendingDomains.has(defaultFromDomain)) + ) { context.addIssue({ code: "custom", - message: "Choose one of the setup mailboxes as the default From mailbox.", + message: "Choose a mailbox on a send-enabled domain as the default From mailbox.", + path: ["defaultFromMailboxAddress"] + }); + } else if (sendingDomains.size === 0 && input.defaultFromMailboxAddress !== null) { + context.addIssue({ + code: "custom", + message: "Receive-only setup must not choose a default From mailbox.", path: ["defaultFromMailboxAddress"] }); } @@ -90,6 +109,7 @@ export const listCloudflareZonesSchema = z.object({}).strict(); export const inspectCloudflareDomainSchema = z.object({ workerName: z.string().trim().min(1).max(63).optional(), + requireSending: z.boolean().optional(), zoneId: z.string().trim().min(1).max(64) }); From 2d0b7e5832ea1bec7b258337659f3736c0096cd8 Mon Sep 17 00:00:00 2001 From: olegberman Date: Sat, 22 Aug 2026 07:50:09 -0400 Subject: [PATCH 2/3] Enforce receive-only sending boundaries --- .../worker/receive-only-mailboxes.test.ts | 12 +++- test/unit/app/setup/setup-ui.test.tsx | 23 ++++++++ .../features/setup/bootstrap-service.test.ts | 56 +++++++++++++++++++ worker/features/mailboxes/address-queries.ts | 1 + worker/features/mailboxes/queries.ts | 6 +- worker/features/mailboxes/types.ts | 1 + worker/features/preferences/service.ts | 2 +- worker/features/setup/service.ts | 23 ++++++++ 8 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 test/unit/worker/features/setup/bootstrap-service.test.ts diff --git a/test/integration/worker/receive-only-mailboxes.test.ts b/test/integration/worker/receive-only-mailboxes.test.ts index dde578b2..a266288a 100644 --- a/test/integration/worker/receive-only-mailboxes.test.ts +++ b/test/integration/worker/receive-only-mailboxes.test.ts @@ -1,7 +1,10 @@ import { env } from "cloudflare:test"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { upsertMailDomain } from "../../../worker/features/domains/queries"; +import { + updateMailDomainSettings, + upsertMailDomain +} from "../../../worker/features/domains/queries"; import { findAddressIdentity } from "../../../worker/features/mailboxes/address-queries"; import { listMailboxes } from "../../../worker/features/mailboxes/queries"; import { createMailbox } from "../../../worker/features/mailboxes/service"; @@ -21,7 +24,7 @@ describe("receive-only mailbox identities", () => { }); it("keeps receiving available while hiding sending until the domain is ready", async () => { - await upsertMailDomain(env.DB, { + const domain = await upsertMailDomain(env.DB, { name: "example.com", receivingStatus: "ready", sendingStatus: "disabled", @@ -53,5 +56,10 @@ describe("receive-only mailbox identities", () => { await expect( findAddressIdentity(env.DB, "support@example.com", "send") ).resolves.not.toBeNull(); + + await updateMailDomainSettings(env.DB, domain.id, { isEnabled: false }); + + expect((await listMailboxes(env.DB))[0]?.addresses[0]?.sendAvailable).toBe(false); + await expect(findAddressIdentity(env.DB, "support@example.com", "send")).resolves.toBeNull(); }); }); diff --git a/test/unit/app/setup/setup-ui.test.tsx b/test/unit/app/setup/setup-ui.test.tsx index 21182a34..8a10e639 100644 --- a/test/unit/app/setup/setup-ui.test.tsx +++ b/test/unit/app/setup/setup-ui.test.tsx @@ -143,4 +143,27 @@ describe("setup UI", () => { expect(html).not.toContain(">Review<"); expect(html).not.toContain(">Mailbox 1<"); }); + + it("shows receive-only guidance without a default From control", () => { + const html = renderToStaticMarkup( + undefined} + onBack={() => undefined} + onComplete={() => undefined} + onRemove={() => undefined} + onSetDefaultFromMailboxAddress={() => undefined} + onUpdate={() => undefined} + submitError={null} + /> + ); + + expect(html).not.toContain("Default From mailbox"); + expect(html).toContain("These mailboxes can receive mail"); + expect(html).toContain("Enable sending later in Settings"); + }); }); diff --git a/test/unit/worker/features/setup/bootstrap-service.test.ts b/test/unit/worker/features/setup/bootstrap-service.test.ts new file mode 100644 index 00000000..a59582fd --- /dev/null +++ b/test/unit/worker/features/setup/bootstrap-service.test.ts @@ -0,0 +1,56 @@ +import type { WorkerEnv } from "@worker/lib/env"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createMailbox: vi.fn(), + getSetupStatus: vi.fn(), + setChecklistAcknowledged: vi.fn(), + setDefaultFromMailboxId: vi.fn(), + setPrimaryDomain: vi.fn(), + setSetupComplete: vi.fn(), + signUpOwnerUser: vi.fn(), + upsertMailDomain: vi.fn(), + upsertWorkspaceHost: vi.fn() +})); + +vi.mock("@worker/auth/user-actions", () => ({ signUpOwnerUser: mocks.signUpOwnerUser })); +vi.mock("@worker/features/domains/queries", () => ({ upsertMailDomain: mocks.upsertMailDomain })); +vi.mock("@worker/features/mailboxes/service", () => ({ createMailbox: mocks.createMailbox })); +vi.mock("@worker/features/preferences/queries", () => ({ + setDefaultFromMailboxId: mocks.setDefaultFromMailboxId +})); +vi.mock("@worker/features/setup/queries", () => ({ + getSetupStatus: mocks.getSetupStatus, + setChecklistAcknowledged: mocks.setChecklistAcknowledged, + setPrimaryDomain: mocks.setPrimaryDomain, + setSetupComplete: mocks.setSetupComplete, + upsertWorkspaceHost: mocks.upsertWorkspaceHost +})); + +import { bootstrapSetup } from "@worker/features/setup/service"; + +describe("setup bootstrap service validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSetupStatus.mockResolvedValue({ isComplete: false, userCount: 0 }); + }); + + it("rejects a missing default From mailbox before creating sending-enabled setup records", async () => { + const env = { DB: {} as D1Database } as WorkerEnv; + + await expect( + bootstrapSetup(env, new Request("https://hqbase.test/api/setup/bootstrap"), { + checklistAcknowledged: true, + defaultFromMailboxAddress: null, + emailDomains: [{ name: "example.com", sendingStatus: "ready" }], + mailboxes: [{ address: "support@example.com", displayName: "Support" }], + ownerEmail: "owner@gmail.com", + ownerName: "Owner", + ownerPassword: "password123" + }) + ).rejects.toMatchObject({ code: "DEFAULT_FROM_MAILBOX_REQUIRED", status: 400 }); + + expect(mocks.upsertMailDomain).not.toHaveBeenCalled(); + expect(mocks.signUpOwnerUser).not.toHaveBeenCalled(); + }); +}); diff --git a/worker/features/mailboxes/address-queries.ts b/worker/features/mailboxes/address-queries.ts index 69c3aff3..481e5d9c 100644 --- a/worker/features/mailboxes/address-queries.ts +++ b/worker/features/mailboxes/address-queries.ts @@ -23,6 +23,7 @@ export async function findAddressIdentity( db, sql`SELECT a.id, a.mailbox_id, a.mail_domain_id, a.address, a.display_name, a.receive_enabled, a.send_enabled, a.is_primary, d.sending_status, + d.is_enabled AS domain_is_enabled, m.address AS mailbox_address, m.display_name AS mailbox_display_name, m.is_active, m.created_at, m.updated_at FROM mailbox_addresses a diff --git a/worker/features/mailboxes/queries.ts b/worker/features/mailboxes/queries.ts index 2df36f41..80c506ad 100644 --- a/worker/features/mailboxes/queries.ts +++ b/worker/features/mailboxes/queries.ts @@ -37,7 +37,8 @@ export function mapMailboxAddress(row: MailboxAddressRow): MailboxAddress { displayName: row.display_name, receiveEnabled: row.receive_enabled === 1, sendEnabled: row.send_enabled === 1, - sendAvailable: row.send_enabled === 1 && row.sending_status === "ready", + sendAvailable: + row.send_enabled === 1 && row.domain_is_enabled === 1 && row.sending_status === "ready", isPrimary: row.is_primary === 1 }; } @@ -51,7 +52,8 @@ export async function addressMap( const rows = await getRows( db, sql`SELECT a.id, a.mailbox_id, a.mail_domain_id, a.address, a.display_name, - a.receive_enabled, a.send_enabled, a.is_primary, d.sending_status + a.receive_enabled, a.send_enabled, a.is_primary, d.sending_status, + d.is_enabled AS domain_is_enabled FROM mailbox_addresses a JOIN mail_domains d ON d.id = a.mail_domain_id WHERE mailbox_id IN (${sql.join( diff --git a/worker/features/mailboxes/types.ts b/worker/features/mailboxes/types.ts index d4909cf8..87fa0ea2 100644 --- a/worker/features/mailboxes/types.ts +++ b/worker/features/mailboxes/types.ts @@ -29,6 +29,7 @@ export type MailboxAddressRow = { receive_enabled: number; send_enabled: number; sending_status: "pending" | "ready" | "degraded" | "disabled"; + domain_is_enabled: number; is_primary: number; }; diff --git a/worker/features/preferences/service.ts b/worker/features/preferences/service.ts index f33b04b1..2dcdd5b5 100644 --- a/worker/features/preferences/service.ts +++ b/worker/features/preferences/service.ts @@ -20,7 +20,7 @@ export async function updateDefaultFromMailbox( if (!mailbox?.isActive || !primaryCanSend) { throw new AppError( "MAILBOX_NOT_SENDABLE", - "Choose an active mailbox with a send-enabled primary address.", + "Choose an active mailbox with a primary address that can send.", 400 ); } diff --git a/worker/features/setup/service.ts b/worker/features/setup/service.ts index d75a166a..04b33661 100644 --- a/worker/features/setup/service.ts +++ b/worker/features/setup/service.ts @@ -55,6 +55,29 @@ export async function bootstrapSetup( { name: input.primaryDomain ?? "", sendingStatus: "ready" as const } ]; if (!domains[0]?.name) throw new AppError("DOMAIN_REQUIRED", "Choose an email domain.", 400); + const sendingDomains = new Set( + domains.filter((domain) => domain.sendingStatus === "ready").map((domain) => domain.name) + ); + const defaultFromDomain = input.defaultFromMailboxAddress?.split("@")[1]; + if ( + sendingDomains.size > 0 && + (!input.defaultFromMailboxAddress || + !defaultFromDomain || + !sendingDomains.has(defaultFromDomain)) + ) { + throw new AppError( + "DEFAULT_FROM_MAILBOX_REQUIRED", + "Choose one of the setup mailboxes on a send-enabled domain as the default From mailbox.", + 400 + ); + } + if (sendingDomains.size === 0 && input.defaultFromMailboxAddress !== null) { + throw new AppError( + "DEFAULT_FROM_MAILBOX_NOT_ALLOWED", + "Receive-only setup must not choose a default From mailbox.", + 400 + ); + } assertLoginEmailOutsideDomains( input.ownerEmail, domains.map((domain) => domain.name) From 7e72ecf6f7624b2f1bf36e3808c9e1f18cee8775 Mon Sep 17 00:00:00 2001 From: olegberman Date: Sat, 22 Aug 2026 07:51:04 -0400 Subject: [PATCH 3/3] Make setup preview sending state explicit --- app/features/setup/setup-preview-fixtures.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/features/setup/setup-preview-fixtures.tsx b/app/features/setup/setup-preview-fixtures.tsx index f704be54..e6a141fd 100644 --- a/app/features/setup/setup-preview-fixtures.tsx +++ b/app/features/setup/setup-preview-fixtures.tsx @@ -115,7 +115,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode { connectionError={ readinessError ? "Cloudflare needs attention on one or more checks below." : null } - enableSending + enableSending={true} errors={{}} isLoading={false} onBack={() => undefined} @@ -166,7 +166,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode { errors={{ rows: input.mailboxes.map(() => ({})) }} isPending={input.state === "submitting"} mailboxes={input.mailboxes} - sendingEnabled + sendingEnabled={true} onAdd={() => input.setMailboxes((current) => [...current, { address: "", displayName: "" }])} onBack={() => undefined} onComplete={() => undefined}