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
@@ -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",