feat(mail): withhold new Gmail consents on hosted unless the company is allowlisted (#2320)

Every Gmail consent shows "Google hasn't verified this app" until the
restricted-scope review closes, and a prospect bounced on it today. Jakob's
call: remove the connector in the meantime rather than explain the screen.

New consents are gated by GOOGLE_MAIL_CONNECT_COMPANY_IDS on hosted: unset
means nobody (the default from this deploy on), `*` means everybody (set once
Google approves), a comma list means those companies (the reviewer's demo
company, the company the video is recorded in). Enforced in /oauth/start
(403 connect_disabled) and mirrored as connectEnabled on /connections, so the
settings page drops its connect button and the inbox start card falls back to
plain upload. Existing mailboxes stay listed, keep being searched and can be
disconnected. Self-hosted installs run their own Google app and are never
gated.


Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 17:51:45 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 3e5670f978
commit c0eda46354
8 changed files with 223 additions and 26 deletions
@@ -670,6 +670,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// the mailboxes we search, WhatsApp for photographed receipts, and the
// forwarding address that works with nothing connected at all.
const [mailConnections, setMailConnections] = useState<InboxMailConnection[]>([])
const [mailConnectEnabled, setMailConnectEnabled] = useState(false)
const [whatsapp, setWhatsapp] = useState<{ linked: boolean; phoneMasked?: string; verifiedAt?: string | null } | null>(null)
const [sourcesOpen, setSourcesOpen] = useState(false)
// Received-mail history (#2181): read when its panel is first opened, so
@@ -701,8 +702,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
try {
const res = await fetch('/api/extensions/ext/mail/connections')
if (!res.ok) return
const json = (await res.json()) as { data?: { connections?: InboxMailConnection[] } }
const json = (await res.json()) as {
data?: { connections?: InboxMailConnection[]; connectEnabled?: boolean }
}
setMailConnections(json.data?.connections ?? [])
// While Google's scope review keeps new consents withheld, the start
// card must not send people to a settings page with no connect button.
setMailConnectEnabled(json.data?.connectEnabled === true)
} catch {
// The extension may not be enabled at all; stay quiet.
}
@@ -1932,11 +1938,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
dense
title={tStart('inbox_title')}
body={tStart('inbox_body')}
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
secondary={{
label: tStart('inbox_secondary'),
onClick: () => fileInputRef.current?.click(),
}}
primary={
mailConnectEnabled
? { label: tStart('inbox_primary'), href: '/settings/mail' }
: { label: tStart('inbox_secondary'), onClick: () => fileInputRef.current?.click() }
}
secondary={
mailConnectEnabled
? { label: tStart('inbox_secondary'), onClick: () => fileInputRef.current?.click() }
: undefined
}
onDismiss={handleDismissOnboarding}
dismissLabel={tStart('inbox_dismiss')}
/>
@@ -2111,11 +2122,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
floatIcons
title={tStart('inbox_title')}
body={tStart('inbox_body')}
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
secondary={{
label: tStart('inbox_secondary'),
onClick: () => fileInputRef.current?.click(),
}}
primary={
mailConnectEnabled
? { label: tStart('inbox_primary'), href: '/settings/mail' }
: { label: tStart('inbox_secondary'), onClick: () => fileInputRef.current?.click() }
}
secondary={
mailConnectEnabled
? { label: tStart('inbox_secondary'), onClick: () => fileInputRef.current?.click() }
: undefined
}
onDismiss={handleDismissOnboarding}
dismissLabel={tStart('inbox_dismiss')}
/>
@@ -33,6 +33,9 @@ export function MailConnectionsPanel() {
const t = useTranslations('mail')
const [connections, setConnections] = useState<MailConnection[]>([])
const [configured, setConfigured] = useState(true)
// False while Google's scope review keeps new consents withheld on hosted:
// the connect button is simply absent, existing mailboxes stay listed.
const [connectEnabled, setConnectEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [connecting, setConnecting] = useState(false)
const [pendingDisconnect, setPendingDisconnect] = useState<MailConnection | null>(null)
@@ -45,10 +48,11 @@ export function MailConnectionsPanel() {
const response = await fetch(`${BASE}/connections`)
if (!response.ok) return
const body = (await response.json()) as {
data: { connections: MailConnection[]; configured: boolean }
data: { connections: MailConnection[]; configured: boolean; connectEnabled?: boolean }
}
setConnections(body.data.connections)
setConfigured(body.data.configured)
setConnectEnabled(body.data.connectEnabled === true)
} finally {
setLoading(false)
}
@@ -179,19 +183,23 @@ export function MailConnectionsPanel() {
</SettingsGroup>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={connect} disabled={connecting || !configured}>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<GoogleMark className="mr-2 h-4 w-4" />
)}
{t('connect')}
</Button>
{!configured ? <SettingsRowNote>{t('not_configured')}</SettingsRowNote> : null}
</div>
{connectEnabled ? (
<div className="flex flex-wrap items-center gap-3">
<Button onClick={connect} disabled={connecting || !configured}>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<GoogleMark className="mr-2 h-4 w-4" />
)}
{t('connect')}
</Button>
{!configured ? <SettingsRowNote>{t('not_configured')}</SettingsRowNote> : null}
</div>
) : null}
<p className="max-w-[62ch] text-xs text-muted-foreground">{t('promise')}</p>
{connectEnabled || connections.length > 0 ? (
<p className="max-w-[62ch] text-xs text-muted-foreground">{t('promise')}</p>
) : null}
<ConfirmDialog
open={pendingDisconnect !== null}