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
+9
View File
@@ -32,6 +32,15 @@ RECEIPT_HUNT_MODEL_ID=
RECEIPT_HUNT_MIN_CONFIDENCE=
RECEIPT_HUNT_COMPANY_IDS=
# Gmail connect gate (hosted only; self-hosted installs run their own Google
# app and are never gated). Who may start a NEW Gmail consent: unset means
# nobody (the default while Google's restricted-scope review is open, so
# prospects never meet the "Google hasn't verified this app" screen), `*`
# means everybody (set once the scope is approved), or a comma-separated list
# of company ids (the reviewer's demo company). Existing connections keep
# working either way.
GOOGLE_MAIL_CONNECT_COMPANY_IDS=
# Session timeouts are opt-in per user (user_preferences.auto_logout, toggled
# in Settings > Security): users who have not opted in stay signed in for the
# full Supabase session lifetime. The variables below set the limits that
+1
View File
@@ -1595,6 +1595,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Gmail consent requests gmail.readonly alone and the mailbox address is read from Gmail's profile endpoint instead of asking for openid+email: Google's restricted-scope review (2026-08-31) bounced the submission on a string mismatch between the authorization URL and the console's Data Access list, and one scope in both places is the only shape that cannot drift. Rejected: declaring openid and userinfo.email in the console as well (two more scopes to justify and demonstrate for an address the granted scope already returns). Added app_metadata.mfa_exempt_until (ISO timestamp, service-role only, honoured only while in the future) so Google's reviewers can log in to the demo account without a second factor, which they treat as an authentication blocker. Time-boxed rather than a boolean after Superagent's P1 on the first shape: an exemption that expires on its own cannot be forgotten on an account. Never set it on a customer account.
[2026-09-05] Fortnox VAT-inclusive invoices (VATIncluded: true) now map their rows net of VAT (lib/providers/fortnox/mapper.ts netOfVat, preferring TotalExcludingVAT / PriceExcludingVAT when the payload carries them), and the migrated-row completion pass refuses a row set whose net or VAT disagrees with the header the same payload established by more than 1 kr (rowsMismatch, reported, never stored). Why: the first production run of the completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the gross with 25 % on top, beside a correct header; the mapper had always read row Total as net, and the pass's only cross-check was the invoice total, which the header satisfied. Rows that contradict their own header are worse than no rows: the invoice page shows both, and for an open invoice the booking engine sums the rows. Rejected: comparing against the stored header (it may itself be the pre-#1745 default) and a wider tolerance (öresavrundning is at most 0.50 kr; the real disagreements are kronor).
[2026-09-05] Fortnox header-level Freight and AdministrationFee become synthetic rows in the sales mapper (lib/providers/fortnox/mapper.ts headerChargeLines), free-text rows (no quantity, no amount) land as line_type 'text' and no longer count as a stated 0 % rate in the migration's VAT resolver. Why: Fortnox keeps both charges outside InvoiceRows while Total and TotalVAT include them, so the rows summed to less than the header by exactly the charge, and after #2302 the rows-versus-header check refused those invoices (Profilio 14 of 384); the *VAT fields are amounts, not rates, and the charge is gross when VATIncluded, all verified on live payloads (invoices 295 and 242) rather than the spec, whose endpoint answered 429 all day. Text rows with VAT 0 beside 25 % rows had made roughly half of the Loftux and Clearstoq registers "mixed" with a null header rate. Rejected: dropping the charge into the first priced row (it is its own line on the customer's invoice, often on 3520) and trusting Net for the header (Net excludes the charges; gross minus TotalVAT is the net the rows must reach).
[2026-09-05] New Gmail consents are withheld on hosted unless the company is on GOOGLE_MAIL_CONNECT_COMPANY_IDS (unset = nobody, `*` = everybody, list = those companies; self-hosted never gated), enforced in the mail extension's /oauth/start and mirrored as connectEnabled on /connections so the connect button and the inbox "Koppla brevlådor" card disappear rather than explain. Why: every consent shows "Google hasn't verified this app" until the restricted-scope review closes, and a prospect (Lumaro AB, 2026-09-05) bounced on it; Jakob chose removal over an in-app explanation. Why an allowlist and not a plain off switch: Google's reviewer must be able to press the button on prod in the demo company, and the recording needs it too. Existing connections keep hunting and can be disconnected. Rejected: flipping the console back to Testing (kills every existing grant after 7 days and Google's email forbids it) and hiding the whole Brevlådor page (the four connected companies still need their list and disconnect).
[2026-09-05] SIE preview now says what the import does to the company's chart (accounts new to THIS company vs already present, planChartChanges in lib/import/account-sync.ts) and shows the fiscal-year verdict (match / create / conflict) from precheckFiscalPeriod, the same read-only check ensureFiscalPeriod now consumes, so preview and import cannot drift. Why: the card counted matches against the BAS reference ("150 mappade" on a 41-account company) and said "matchas mot din kontoplan", which a consultant read as "the file's chart replaces mine"; the fiscal-year overlap was only refused after the mapping step. Rejected: an "import chart of accounts" toggle (the chart already imports unconditionally, a toggle would have no off position); filtering to accounts with nonzero IB/UB/saldo (inactive accounts are harmless chart rows and later re-imports reference them); blocking Continue on a conflict (the import refuses anyway with the same text, and a hard block leaves nothing to do but re-upload; display only, no import logic changed). New wizard strings go through the import namespace in messages/sv.json + en.json even though the surrounding wizard copy is inline Swedish: the Definition of Done asks for both, and the wizard is not on the stays-Swedish list; the conflict text itself is the engine's refusal and stays verbatim.
[2026-09-05] The SIE fiscal-year precheck also carries the BFL 3 kap. shape rules (18 months, mid-month start, month-end finish) as a fourth verdict 'invalid', and the chart plan counts mapped targets only (unmapped sources stay in "Ej mappade"). Why: the skeptic pass on #2307 refuted the first cut twice: "Skapas vid import" was shown for a 19-month #RAR the import then refused, and the chart card kept listing accounts under "Läggs till" after "Skapa saknade konton" while the result said 0 created. Moving the shape rules into the precheck keeps ensureFiscalPeriod a consumer of one verdict (same query order, same texts); counting mapped targets only makes the create button move accounts from "Ej mappade" to "Finns redan" with no recompute. Rejected: recomputing the plan in the browser from the refreshed chart (would need the server-side plan helper in the client bundle, and the arithmetic is one addition).
[2026-09-05] Parties: the register's legal name becomes the displayed name of a party and of its supplier or customer row when those still carry the memo the pipeline read ("Webhallen Oktober" becomes "Webhallen Sverige AB"); an all-capitals registry name is shown in title case with legal forms kept, the fact keeps the register's spelling. A name a person typed on the row is never touched. And a party confirmed under the pre-2026-09-04 ledger key keeps its vouchers through legacyLedgerKey: without it every such party came back as a fresh suggestion after the key change.
@@ -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}
@@ -0,0 +1,113 @@
/**
* New Gmail consents are withheld on hosted while Google's scope review is
* open, unless the company is allowlisted. Existing mailboxes are unaffected.
*/
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
vi.mock('@/lib/mail-search/service', () => ({ registerMailSearchService: vi.fn() }))
vi.mock('../lib/search-service', () => ({ GmailSearchService: class GmailSearchService {} }))
vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: vi.fn(() => ({})) }))
vi.mock('../lib/google-oauth', () => ({
buildAuthorizationUrl: vi.fn(() => 'https://accounts.google.com/o/oauth2/v2/auth?x=1'),
exchangeCodeForTokens: vi.fn(),
getGoogleOAuthEnv: vi.fn(() => ({})),
isGoogleMailConfigured: vi.fn(() => true),
}))
vi.mock('../lib/connections', () => ({
disconnect: vi.fn(),
listConnections: vi.fn(),
saveConnection: vi.fn(),
}))
vi.mock('../lib/gmail-client', () => ({ getMailboxAddress: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), createServiceClient: vi.fn() }))
import { mailExtension } from '../index'
import { isMailConnectEnabled } from '../lib/connect-gate'
import { listConnections } from '../lib/connections'
const COMPANY = 'company-1'
const route = (method: 'GET' | 'POST', path: string) =>
mailExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
const ctx = {
userId: 'user-1',
companyId: COMPANY,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
} as unknown as Parameters<ReturnType<typeof route>['handler']>[1]
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('MAIL_TOKEN_ENCRYPTION_KEY', '00'.repeat(32))
;(listConnections as Mock).mockResolvedValue([])
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('isMailConnectEnabled', () => {
it('withholds every company when the allowlist is unset', () => {
expect(isMailConnectEnabled(COMPANY, undefined)).toBe(false)
expect(isMailConnectEnabled(COMPANY, '')).toBe(false)
expect(isMailConnectEnabled(COMPANY, ' , ')).toBe(false)
})
it('opens for everybody on a star, and for listed companies only otherwise', () => {
expect(isMailConnectEnabled(COMPANY, '*')).toBe(true)
expect(isMailConnectEnabled(COMPANY, `other, ${COMPANY} `)).toBe(true)
expect(isMailConnectEnabled(COMPANY, 'other')).toBe(false)
})
it('never gates a self-hosted install, which runs its own Google app', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
expect(isMailConnectEnabled(COMPANY, undefined)).toBe(true)
})
})
describe('POST /oauth/start', () => {
it('refuses to start a consent for a company that is not allowlisted', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', '')
const res = await route('POST', '/oauth/start').handler(
new Request('https://app.example/api/extensions/ext/mail/oauth/start', { method: 'POST' }),
ctx,
)
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'connect_disabled' })
})
it('starts the consent for an allowlisted company', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', COMPANY)
const res = await route('POST', '/oauth/start').handler(
new Request('https://app.example/api/extensions/ext/mail/oauth/start', { method: 'POST' }),
ctx,
)
expect(res.status).toBe(200)
expect((await res.json()).url).toContain('accounts.google.com')
})
})
describe('GET /connections', () => {
it('still lists existing mailboxes and says whether a new consent may start', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', '')
;(listConnections as Mock).mockResolvedValue([{ id: 'c1', provider: 'gmail', status: 'active' }])
const res = await route('GET', '/connections').handler(
new Request('https://app.example/api/extensions/ext/mail/connections'),
ctx,
)
const body = await res.json()
expect(body.data.connections).toHaveLength(1)
expect(body.data.configured).toBe(true)
expect(body.data.connectEnabled).toBe(false)
})
it('reports connectEnabled once the scope is approved and the star is set', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', '*')
const res = await route('GET', '/connections').handler(
new Request('https://app.example/api/extensions/ext/mail/connections'),
ctx,
)
expect((await res.json()).data.connectEnabled).toBe(true)
})
})
+12 -1
View File
@@ -13,6 +13,7 @@ import {
} from './lib/google-oauth'
import { disconnect, listConnections, saveConnection } from './lib/connections'
import { resolveCallbackOrigin } from './lib/callback-origin'
import { isMailConnectEnabled } from './lib/connect-gate'
import { requireFlowInitiator } from '@/lib/auth/oauth-flow-binding'
// Registered as soon as the extension loads, so the receipt hunt can search
@@ -46,6 +47,8 @@ export const mailExtension: Extension = {
handler: async (request, ctx) => {
if (!ctx) return jsonError('Missing context', 500)
if (!isGoogleMailConfigured()) return jsonError('provider_not_configured', 400)
// Withheld while Google's scope review is open; see connect-gate.ts.
if (!isMailConnectEnabled(ctx.companyId)) return jsonError('connect_disabled', 403)
try {
const url = new URL(request.url)
const origin = resolveCallbackOrigin(url.origin)
@@ -138,7 +141,15 @@ export const mailExtension: Extension = {
handler: async (_request, ctx) => {
if (!ctx) return jsonError('Missing context', 500)
const connections = await listConnections(createServiceClientNoCookies(), ctx.companyId)
return NextResponse.json({ data: { connections, configured: isGoogleMailConfigured() } })
return NextResponse.json({
data: {
connections,
configured: isGoogleMailConfigured(),
// Whether this company may start a NEW consent right now. Listing
// and disconnecting existing mailboxes never depend on it.
connectEnabled: isMailConnectEnabled(ctx.companyId),
},
})
},
},
@@ -0,0 +1,39 @@
/**
* Who may start a new Gmail consent.
*
* While Google's restricted-scope review is open, every consent on the hosted
* app shows "Google hasn't verified this app", and prospects bounce off it
* (Lumaro AB, 2026-09-05). The connect button is therefore withheld on hosted
* unless the company is on `GOOGLE_MAIL_CONNECT_COMPANY_IDS`:
*
* unset or empty nobody can start a consent (the default while in review)
* `*` everybody (set this once Google has approved the scope)
* `id1,id2` only those companies (the reviewer's demo company, the
* company the demo video is recorded in)
*
* Existing connections are untouched: the hunt keeps searching mailboxes that
* were connected before, and disconnecting still works. Self-hosted installs
* run their own Google app with their own verification status, so the gate
* does not apply there.
*/
import { isSelfHosted } from '@/lib/env/public-flags'
export const MAIL_CONNECT_ALLOWLIST_ENV = 'GOOGLE_MAIL_CONNECT_COMPANY_IDS'
function allowlist(raw: string | undefined): string[] {
if (!raw) return []
return raw
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0)
}
export function isMailConnectEnabled(
companyId: string,
raw: string | undefined = process.env[MAIL_CONNECT_ALLOWLIST_ENV],
): boolean {
if (isSelfHosted()) return true
const ids = allowlist(raw)
if (ids.includes('*')) return true
return ids.includes(companyId)
}
+1 -1
View File
@@ -4,7 +4,7 @@
"exportName": "mailExtension",
"entryPoint": "@/extensions/general/mail",
"requiredEnvVars": ["GOOGLE_MAIL_CLIENT_ID", "GOOGLE_MAIL_CLIENT_SECRET"],
"optionalEnvVars": ["MAIL_TOKEN_ENCRYPTION_KEY"],
"optionalEnvVars": ["MAIL_TOKEN_ENCRYPTION_KEY", "GOOGLE_MAIL_CONNECT_COMPANY_IDS"],
"npmDependencies": [],
"definition": {
"name": "Brevlådor",