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 = await 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 = await getAccount(db) if (account === null) throw new Error('Gmail is not connected — run gmail-connect on the server') const fail = async (error: string): Promise => { await 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) { await 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)) } await 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. await markStatus(db, args.userId, args.documentId, 'sent') } else { // Re-send of an already-sent/accepted doc: trace the event, status unchanged. await db.run( `INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`, uuidv7(), args.documentId, new Date().toISOString(), 'sent', JSON.stringify({ to: args.to, resend: true })) } return { ok: true } } export interface ReminderMailInput { to: string; subject: string; bodyText: string attachment?: { filename: string; data: Buffer } documentId?: string } /** * Send a templated reminder — attachment optional, no document-status side effects. * Mirrors sendDocumentEmail's account/token-death handling and logs every attempt, * so the dashboard banner and email_log stay consistent across both send paths. */ export async function sendReminderEmail( db: DB, deps: GmailDeps, args: ReminderMailInput, ): Promise { const logFail = async (error: string): Promise => { await logEmail(db, { ...(args.documentId !== undefined ? { documentId: args.documentId } : {}), to: args.to, subject: args.subject, status: 'failed', error, }) return { ok: false, error } } const account = await getAccount(db) if (account === null) return logFail('gmail-not-connected') if (account.status === 'dead') return logFail('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) { await markAccountDead(db); return logFail('gmail-token-dead') } return logFail(err instanceof Error ? err.message : String(err)) } const raw = buildMime({ from: account.address, to: args.to, subject: args.subject, bodyText: args.bodyText, ...(args.attachment !== undefined ? { attachment: { filename: args.attachment.filename, contentType: 'application/pdf', data: args.attachment.data } } : {}), }) 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 logFail(`Gmail send failed: HTTP ${res.status}`) gmailMessageId = json.id } catch (err) { return logFail(err instanceof Error ? err.message : String(err)) } await logEmail(db, { ...(args.documentId !== undefined ? { documentId: args.documentId } : {}), to: args.to, subject: args.subject, status: 'sent', ...(gmailMessageId !== undefined ? { gmailMessageId } : {}), }) return { ok: true } }