From 94734d04bf91733420187b081bc91197e0ce3b4e Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 01:32:01 +0530 Subject: [PATCH] feat(hq): gmail send with token-death handling and email log (HQ-1 task 11) Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 67 ++++++++++++++-- apps/hq/src/crypto.ts | 33 ++++++++ apps/hq/src/gmail.ts | 158 +++++++++++++++++++++++++++++++++++++ apps/hq/src/repos-email.ts | 80 +++++++++++++++++++ apps/hq/test/gmail.test.ts | 24 ++++++ 5 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 apps/hq/src/crypto.ts create mode 100644 apps/hq/src/gmail.ts create mode 100644 apps/hq/src/repos-email.ts create mode 100644 apps/hq/test/gmail.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index ac4e6fd..6e8a90a 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -20,11 +20,26 @@ import { } from './repos-payments' import { documentHtml } from './templates' import { renderPdf } from './pdf' +import { emailStatus } from './repos-email' +import { sendDocumentEmail, type GmailDeps } from './gmail' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id -export function apiRouter(db: DB): Router { +export function apiRouter(db: DB, gmailDeps?: Partial): Router { const r = Router() + const companySettings = (): Record => { + const rows = db.prepare( + `SELECT key, value FROM setting WHERE key LIKE 'company.%'`, + ).all() as { key: string; value: string }[] + return Object.fromEntries(rows.map((row) => [row.key, row.value])) + } + // Env read at request time so gmail-connect can run without a restart. + const gmail = (): GmailDeps => ({ + f: gmailDeps?.f ?? fetch, + clientId: gmailDeps?.clientId ?? process.env['GOOGLE_CLIENT_ID'] ?? '', + clientSecret: gmailDeps?.clientSecret ?? process.env['GOOGLE_CLIENT_SECRET'] ?? '', + keyHex: gmailDeps?.keyHex ?? process.env['HQ_SECRET_KEY'] ?? '', + }) r.get('/health', (_req, res) => { res.json({ ok: true, service: 'sims-hq', version: '0.1.0' }) }) @@ -178,10 +193,7 @@ export function apiRouter(db: DB): Router { if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } const client = getClient(db, document.clientId) if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } - const rows = db.prepare( - `SELECT key, value FROM setting WHERE key LIKE 'company.%'`, - ).all() as { key: string; value: string }[] - const company = Object.fromEntries(rows.map((r2) => [r2.key, r2.value])) + const company = companySettings() void (async () => { try { const pdf = await renderPdf(documentHtml(document, client, company)) @@ -219,6 +231,51 @@ export function apiRouter(db: DB): Router { return createCreditNote(db, staffId(res), id, lines) })) + // ---------- email ---------- + r.get('/email/status', requireAuth, (_req, res) => { + res.json({ ok: true, ...emailStatus(db) }) + }) + r.post('/documents/:id/send', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + const document = getDocument(db, id) + if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + // Guard before any work: a dead/missing account never attempts a send. + const status = emailStatus(db) + if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return } + if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return } + if (document.docNo === null) { + res.status(400).json({ ok: false, error: 'Only issued documents can be emailed' }); return + } + const client = getClient(db, document.clientId) + if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + const body = req.body as { to?: string; subject?: string; body?: string } + const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email + if (to === undefined || to === '') { + res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' }) + return + } + const company = companySettings() + const companyName = company['company.name'] ?? '' + const subject = body.subject ?? `${document.docType} ${document.docNo} — ${companyName}` + const bodyText = body.body ?? + `Dear ${client.name},\n\nPlease find attached ${document.docType} ${document.docNo}.\n\nRegards,\n${companyName}` + void (async () => { + try { + const pdf = await renderPdf(documentHtml(document, client, company)) + const out = await sendDocumentEmail(db, gmail(), { + documentId: id, to, subject, bodyText, pdf, userId: staffId(res), + }) + if (!out.ok) { + res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error }) + return + } + res.json({ ok: true, document: getDocument(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + })() + }) + // ---------- payments ---------- r.post('/payments', requireAuth, (req, res) => { try { diff --git a/apps/hq/src/crypto.ts b/apps/hq/src/crypto.ts new file mode 100644 index 0000000..806e5ed --- /dev/null +++ b/apps/hq/src/crypto.ts @@ -0,0 +1,33 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' + +/** + * AES-256-GCM helpers for the Gmail refresh token at rest. The key is + * HQ_SECRET_KEY (64 hex chars = 32 bytes); payload format is + * `iv.tag.ciphertext` hex-joined by `.` so it stores in one TEXT column. + * Never log the plaintext or the key. + */ + +function keyFromHex(keyHex: string): Buffer { + const key = Buffer.from(keyHex, 'hex') + if (key.length !== 32 || keyHex.length !== 64) { + throw new Error('Encryption key must be 64 hex chars (32 bytes)') + } + return key +} + +export function encrypt(plain: string, keyHex: string): string { + const iv = randomBytes(12) // 96-bit nonce, the GCM-recommended size + const cipher = createCipheriv('aes-256-gcm', keyFromHex(keyHex), iv) + const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]) + return [iv.toString('hex'), cipher.getAuthTag().toString('hex'), ciphertext.toString('hex')].join('.') +} + +export function decrypt(payload: string, keyHex: string): string { + const [ivHex, tagHex, ctHex] = payload.split('.') + if (ivHex === undefined || tagHex === undefined || ctHex === undefined) { + throw new Error('Malformed encrypted payload (expected iv.tag.ciphertext)') + } + const decipher = createDecipheriv('aes-256-gcm', keyFromHex(keyHex), Buffer.from(ivHex, 'hex')) + decipher.setAuthTag(Buffer.from(tagHex, 'hex')) + return Buffer.concat([decipher.update(Buffer.from(ctHex, 'hex')), decipher.final()]).toString('utf8') +} diff --git a/apps/hq/src/gmail.ts b/apps/hq/src/gmail.ts new file mode 100644 index 0000000..5b3485c --- /dev/null +++ b/apps/hq/src/gmail.ts @@ -0,0 +1,158 @@ +import { uuidv7 } from '@sims/domain' +import { decrypt } from './crypto' +import type { DB } from './db' +import { getDocument, markStatus } from './repos-documents' +import { getAccount, logEmail, markAccountDead } from './repos-email' + +/** + * Gmail send via REST (no SDK): refresh-token → access-token exchange, raw + * MIME build, users.me/messages.send. fetch is injected (Fetcher) so tests + * stub the network. The one non-retryable failure — `invalid_grant`, Google + * revoked or expired the refresh token — surfaces as TokenDeadError and + * flips the account to 'dead' so the dashboard banner shows immediately. + */ + +export type Fetcher = typeof fetch + +const TOKEN_URL = 'https://oauth2.googleapis.com/token' +const SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send' + +/** Refresh token revoked/expired — re-run gmail-connect to fix. */ +export class TokenDeadError extends Error { + constructor() { super('Gmail refresh token is dead (invalid_grant)') } +} + +export async function getAccessToken( + refreshToken: string, clientId: string, clientSecret: string, f: Fetcher, +): Promise { + const res = await f(TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', refresh_token: refreshToken, + client_id: clientId, client_secret: clientSecret, + }).toString(), + }) + const json = await res.json() as { access_token?: string; error?: string } + if (json.error === 'invalid_grant') throw new TokenDeadError() + if (!res.ok || typeof json.access_token !== 'string') { + throw new Error(`Gmail token exchange failed: ${json.error ?? `HTTP ${res.status}`}`) + } + return json.access_token +} + +export interface MimeInput { + from: string; to: string; subject: string; bodyText: string + attachment?: { filename: string; contentType: string; data: Buffer } +} + +/** RFC 2822 message (multipart when attaching), base64url-encoded for Gmail's `raw`. */ +export function buildMime(m: MimeInput): string { + const boundary = 'sims-hq-boundary-7f3a9c' + const headers = [ + `From: ${m.from}`, + `To: ${m.to}`, + `Subject: ${m.subject}`, + 'MIME-Version: 1.0', + ] + const body = m.attachment === undefined + ? [ + ...headers, + 'Content-Type: text/plain; charset="UTF-8"', + '', + m.bodyText, + ] + : [ + ...headers, + `Content-Type: multipart/mixed; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset="UTF-8"', + '', + m.bodyText, + '', + `--${boundary}`, + `Content-Type: ${m.attachment.contentType}; name="${m.attachment.filename}"`, + 'Content-Transfer-Encoding: base64', + `Content-Disposition: attachment; filename="${m.attachment.filename}"`, + '', + m.attachment.data.toString('base64'), + `--${boundary}--`, + ] + return Buffer.from(body.join('\r\n'), 'utf8').toString('base64url') +} + +export interface GmailDeps { f: Fetcher; clientId: string; clientSecret: string; keyHex: string } + +export interface SendDocumentInput { + documentId: string; to: string; subject: string; bodyText: string + pdf: Buffer; userId: string +} + +export type SendResult = { ok: true } | { ok: false; error: string } + +export async function sendDocumentEmail( + db: DB, deps: GmailDeps, args: SendDocumentInput, +): Promise { + const doc = getDocument(db, args.documentId) + if (doc === null) throw new Error('Document not found') + // Only issued documents leave the building — a draft has no legal number. + if (doc.docNo === null) throw new Error('Only issued documents can be emailed') + const account = getAccount(db) + if (account === null) throw new Error('Gmail is not connected — run gmail-connect on the server') + + const fail = (error: string): SendResult => { + logEmail(db, { documentId: args.documentId, to: args.to, subject: args.subject, status: 'failed', error }) + return { ok: false, error } + } + if (account.status === 'dead') return fail('gmail-token-dead') + + let accessToken: string + try { + const refreshToken = decrypt(account.refreshTokenEnc, deps.keyHex) + accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f) + } catch (err) { + if (err instanceof TokenDeadError) { + markAccountDead(db) + return fail('gmail-token-dead') + } + return fail(err instanceof Error ? err.message : String(err)) + } + + const raw = buildMime({ + from: account.address, to: args.to, subject: args.subject, bodyText: args.bodyText, + attachment: { + filename: `${doc.docNo.replaceAll('/', '-')}.pdf`, // '/' is not legal in a filename + contentType: 'application/pdf', data: args.pdf, + }, + }) + let gmailMessageId: string | undefined + try { + const res = await deps.f(SEND_URL, { + method: 'POST', + headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ raw }), + }) + const json = await res.json().catch(() => ({})) as { id?: string } + if (!res.ok) return fail(`Gmail send failed: HTTP ${res.status}`) + gmailMessageId = json.id + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)) + } + + logEmail(db, { + documentId: args.documentId, to: args.to, subject: args.subject, status: 'sent', + ...(gmailMessageId !== undefined ? { gmailMessageId } : {}), + }) + if (doc.status === 'draft') { + // markStatus appends the 'sent' document_event and the audit row. + markStatus(db, args.userId, args.documentId, 'sent') + } else { + // Re-send of an already-sent/accepted doc: trace the event, status unchanged. + db.prepare( + `INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`, + ).run(uuidv7(), args.documentId, new Date().toISOString(), 'sent', + JSON.stringify({ to: args.to, resend: true })) + } + return { ok: true } +} diff --git a/apps/hq/src/repos-email.ts b/apps/hq/src/repos-email.ts new file mode 100644 index 0000000..2941e83 --- /dev/null +++ b/apps/hq/src/repos-email.ts @@ -0,0 +1,80 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' + +/** + * Email account + send log — plain functions over the handle (D12 pattern). + * One sending identity: saveAccount replaces any existing row. The refresh + * token is stored AES-256-GCM encrypted (crypto.ts) and never appears in + * audit rows or logs. + */ + +export interface EmailAccount { + id: string; address: string; refreshTokenEnc: string + status: 'active' | 'dead'; updatedAt: string +} + +interface AccountRow { + id: string; address: string; refresh_token_enc: string; status: string; updated_at: string +} + +function toAccount(r: AccountRow): EmailAccount { + return { + id: r.id, address: r.address, refreshTokenEnc: r.refresh_token_enc, + status: r.status as EmailAccount['status'], updatedAt: r.updated_at, + } +} + +export function getAccount(db: DB): EmailAccount | null { + const row = db.prepare(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`) + .get() as AccountRow | undefined + return row === undefined ? null : toAccount(row) +} + +export function saveAccount(db: DB, address: string, refreshTokenEnc: string): EmailAccount { + return db.transaction(() => { + db.prepare(`DELETE FROM email_account`).run() // single sending identity + const id = uuidv7() + db.prepare( + `INSERT INTO email_account (id, address, refresh_token_enc, status, updated_at) + VALUES (?, ?, ?, 'active', ?)`, + ).run(id, address, refreshTokenEnc, new Date().toISOString()) + // Audit carries the address only — never the (even encrypted) token. + writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' }) + return getAccount(db)! + })() +} + +/** Refresh token revoked/expired (invalid_grant) — flips the dashboard banner. */ +export function markAccountDead(db: DB): void { + const account = getAccount(db) + if (account === null || account.status === 'dead') return + db.prepare(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`) + .run(new Date().toISOString(), account.id) + writeAudit(db, 'system', 'update', 'email_account', account.id, + { status: account.status }, { status: 'dead' }) +} + +export interface EmailLogInput { + documentId?: string; to: string; subject: string + status: 'sent' | 'failed'; gmailMessageId?: string; error?: string +} + +/** Append-only send trace — the email_log table is itself the record. */ +export function logEmail(db: DB, e: EmailLogInput): void { + db.prepare( + `INSERT INTO email_log (id, document_id, to_addr, subject, status, gmail_message_id, error, at_wall) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + uuidv7(), e.documentId ?? null, e.to, e.subject, e.status, + e.gmailMessageId ?? null, e.error ?? null, new Date().toISOString(), + ) +} + +export interface EmailStatus { connected: boolean; address?: string; dead: boolean } + +export function emailStatus(db: DB): EmailStatus { + const account = getAccount(db) + if (account === null) return { connected: false, dead: false } + return { connected: true, address: account.address, dead: account.status === 'dead' } +} diff --git a/apps/hq/test/gmail.test.ts b/apps/hq/test/gmail.test.ts new file mode 100644 index 0000000..6201897 --- /dev/null +++ b/apps/hq/test/gmail.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { encrypt, decrypt } from '../src/crypto' +import { getAccessToken, buildMime, TokenDeadError } from '../src/gmail' + +const KEY = '11'.repeat(32) + +describe('gmail plumbing', () => { + it('crypto round-trips', () => { + expect(decrypt(encrypt('refresh-token-x', KEY), KEY)).toBe('refresh-token-x') + }) + it('exchanges refresh token; surfaces invalid_grant as TokenDeadError', async () => { + const okFetch = (async () => new Response(JSON.stringify({ access_token: 'at-1' }), { status: 200 })) as typeof fetch + expect(await getAccessToken('rt', 'cid', 'sec', okFetch)).toBe('at-1') + const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch + await expect(getAccessToken('rt', 'cid', 'sec', deadFetch)).rejects.toBeInstanceOf(TokenDeadError) + }) + it('builds a MIME message with PDF attachment', () => { + const raw = buildMime({ from: 'a@b.c', to: 'x@y.z', subject: 'INV INV/26-27-0001', + bodyText: 'Please find attached.', attachment: { filename: 'inv.pdf', contentType: 'application/pdf', data: Buffer.from('%PDF-') } }) + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString() + expect(decoded).toContain('Subject: INV INV/26-27-0001') + expect(decoded).toContain('application/pdf') + }) +})