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
parent
8c90b09595
commit
94734d04bf
@ -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…
Reference in New Issue