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/repos-email.ts

80 lines
3.1 KiB
TypeScript

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 async function getAccount(db: DB): Promise<EmailAccount | null> {
const row = await db.get<AccountRow>(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`)
return row === undefined ? null : toAccount(row)
}
export async function saveAccount(db: DB, address: string, refreshTokenEnc: string): Promise<EmailAccount> {
return db.transaction(async () => {
await db.run(`DELETE FROM email_account`) // single sending identity
const id = uuidv7()
await db.run(
`INSERT INTO email_account (id, address, refresh_token_enc, status, updated_at)
VALUES (?, ?, ?, 'active', ?)`,
id, address, refreshTokenEnc, new Date().toISOString(),
)
// Audit carries the address only — never the (even encrypted) token.
await writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' })
return (await getAccount(db))!
})
}
/** Refresh token revoked/expired (invalid_grant) — flips the dashboard banner. */
export async function markAccountDead(db: DB): Promise<void> {
const account = await getAccount(db)
if (account === null || account.status === 'dead') return
await db.run(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`,
new Date().toISOString(), account.id)
await 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 async function logEmail(db: DB, e: EmailLogInput): Promise<void> {
await db.run(
`INSERT INTO email_log (id, document_id, to_addr, subject, status, gmail_message_id, error, at_wall)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
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 async function emailStatus(db: DB): Promise<EmailStatus> {
const account = await getAccount(db)
if (account === null) return { connected: false, dead: false }
return { connected: true, address: account.address, dead: account.status === 'dead' }
}