import http from 'node:http' import { encrypt } from '../src/crypto' import { openDb } from '../src/db' import { saveAccount } from '../src/repos-email' /** * One-time Gmail OAuth connect (loopback flow). Run on the HQ server: * * GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... HQ_SECRET_KEY=... \ * npx tsx apps/hq/scripts/gmail-connect.ts * * Prints a consent URL, waits once on http://localhost:5190/callback, * exchanges the code for a refresh token, encrypts it with HQ_SECRET_KEY * (AES-256-GCM, crypto.ts) and stores it via saveAccount. Re-run whenever * the token dies (invalid_grant → dashboard banner). Tokens are never logged. */ const CALLBACK_PORT = 5190 const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth' const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' const PROFILE_ENDPOINT = 'https://gmail.googleapis.com/gmail/v1/users/me/profile' // Two scopes: send (HQ-1) and readonly (HQ-2 bounce polling reads mailer-daemon DSNs). // The company mailbox is not yet connected in production, so requesting both now // costs no extra consent — the first connect grants them together. const SCOPE = [ 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.readonly', ].join(' ') /** Consent URL — offline access + forced consent so Google returns a refresh token. */ export function authUrl(clientId: string, redirectUri: string): string { const u = new URL(AUTH_ENDPOINT) u.searchParams.set('client_id', clientId) u.searchParams.set('redirect_uri', redirectUri) u.searchParams.set('response_type', 'code') u.searchParams.set('scope', SCOPE) u.searchParams.set('access_type', 'offline') u.searchParams.set('prompt', 'consent') return u.toString() } /** Listens once on the loopback callback and resolves with the auth code. */ function waitForCode(): Promise { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`) if (url.pathname !== '/callback') { res.writeHead(404).end(); return } const code = url.searchParams.get('code') const error = url.searchParams.get('error') res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) res.end(code !== null ? '

SiMS HQ: Gmail connected.

You can close this tab and return to the terminal.

' : `

SiMS HQ: Gmail connect failed.

${error ?? 'No code returned.'}

`) server.close() if (code !== null) resolve(code) else reject(new Error(`Google returned no code (${error ?? 'unknown error'})`)) }) server.on('error', reject) server.listen(CALLBACK_PORT) }) } async function exchangeCode( code: string, clientId: string, clientSecret: string, redirectUri: string, ): Promise<{ accessToken: string; refreshToken: string }> { const res = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, }).toString(), }) const json = await res.json() as { access_token?: string; refresh_token?: string; error?: string } if (!res.ok || typeof json.access_token !== 'string' || typeof json.refresh_token !== 'string') { // json.error is Google's error code (e.g. invalid_grant) — never a token. throw new Error(`Code exchange failed: ${json.error ?? `HTTP ${res.status}`}`) } return { accessToken: json.access_token, refreshToken: json.refresh_token } } async function fetchAddress(accessToken: string): Promise { const res = await fetch(PROFILE_ENDPOINT, { headers: { authorization: `Bearer ${accessToken}` } }) const json = await res.json() as { emailAddress?: string } if (!res.ok || typeof json.emailAddress !== 'string') { throw new Error(`Could not read the Gmail profile (HTTP ${res.status})`) } return json.emailAddress } async function main(): Promise { const clientId = process.env['GOOGLE_CLIENT_ID'] const clientSecret = process.env['GOOGLE_CLIENT_SECRET'] const keyHex = process.env['HQ_SECRET_KEY'] if (clientId === undefined || clientSecret === undefined || keyHex === undefined) { console.error('Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and HQ_SECRET_KEY, then re-run.') process.exitCode = 1 return } const redirectUri = `http://localhost:${CALLBACK_PORT}/callback` console.log('Open this URL in a browser signed in as the sending Gmail account:\n') console.log(` ${authUrl(clientId, redirectUri)}\n`) console.log(`Waiting for Google to redirect to ${redirectUri} ...`) const code = await waitForCode() const { accessToken, refreshToken } = await exchangeCode(code, clientId, clientSecret, redirectUri) const address = await fetchAddress(accessToken) const account = await saveAccount(openDb(process.env['HQ_DATA_DIR']), address, encrypt(refreshToken, keyHex)) console.log(`Connected ${account.address} — refresh token stored encrypted. HQ can now send email.`) } // Direct launch only (tsx / bundled); vitest imports authUrl instead. if (process.argv[1]?.endsWith('gmail-connect.ts') || process.argv[1]?.endsWith('gmail-connect.cjs')) { main().catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)) process.exitCode = 1 }) }