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.
80 lines
3.1 KiB
TypeScript
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' }
|
|
}
|