You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/gmail.ts

221 lines
8.3 KiB
TypeScript

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 = 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<SendResult> => {
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<SendResult> {
const logFail = async (error: string): Promise<SendResult> => {
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 }
}