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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
3e5670f978
commit
c0eda46354
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user