feat(hq): gmail send with token-death handling and email log (HQ-1 task 11)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 8c90b09595
commit 94734d04bf

@ -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<GmailDeps>): Router {
const r = Router()
const companySettings = (): Record<string, string> => {
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 {

@ -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')
}

@ -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<string> {
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<SendResult> {
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 }
}

@ -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' }
}

@ -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')
})
})
Loading…
Cancel
Save