Files
cayce-ops/runtime/mail.mjs
T
cayce b81f7f3625
ci / lint-test-leaks (pull_request) Canceled after 0s
containment: every third-party send becomes a draft (outbox), identity file versioned
Problem: Cayce publishes straight to relays with no approval gate —
cayce-initiative publish_note, core.mjs digest/opinion/reflection/events,
mention auto-replies, social promo (Facebook/Instagram via sfeed), and
mail — while the operator believed approvals existed (they never did;
ADR-0005 Telegram queue was never implemented).

Mechanism (outbox.mjs, deployed to all runtimes):
- CAYCE_PUBLISH_MODE=draft (default): gateOutbound() stages EVERY outbound
  artifact into outbound_drafts (entity DB) with identity_path +
  identity_hash (sha256 of prompts/cayce-identity.md) before anything
  else happens. Fail-closed: if staging fails, the event is discarded,
  never published.
- CAYCE_PUBLISH_MODE=live: original behavior, unchanged (reversible with
  one line per service .env).
- Chokepoint: the nostr daemon /publish endpoint gates ALL callers
  (initiative publish_note, core.mjs ×4, banner kind 0); the daemon own
  mention-reply path gates too, storing original_event for context;
  startup profile publish gates as well. DM-to-supervisor (/dm) is
  internal and stays live.

Identity (point 8b):
- prompts/cayce-identity.md: the 5 hardcoded voice blocks extracted
  VERBATIM (verified byte-identical by runtime test) with block markers.
- core.mjs / editorials.mjs now load their voice blocks from this file
  (loadIdentityBlock); identity hash recorded on every draft.
- runtime/ holds the versioned copies of the previously-unversioned
  runtime scripts + deploy.sh installer.

No timers enabled or disabled. No production data modified.
2026-10-31 16:44:57 +00:00

164 lines
6.8 KiB
JavaScript

// cayce-mail — daemon de correo de la entidad (entidadnodal@gmail.com)
// Recibe: aprobaciones de afiliados, respuestas de merchants/grants → clasifica,
// registra en DB y avisa al supervisor por Nostr. Envia: outreach SMTP.
// Poll cada 5 min · estado en state.json · credenciales en .env (600)
import { ImapFlow } from 'imapflow'
import nodemailer from 'nodemailer'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import pg from 'pg'
import { gateOutbound } from './outbox.mjs'
const HOME = '/home/ubuntu/cayce-mail'
const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
const PGURL = readFileSync('/srv/cayce-front/.env', 'utf8').match(/^PGURL=(.+)$/m)[1]
const SUPERVISOR = readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)[1].trim()
const NOSTR = 'http://127.0.0.1:3011'
const pgc = new pg.Pool({ connectionString: PGURL })
const POLL_MS = 5 * 60 * 1000
const statePath = `${HOME}/state.json`
let state = { lastUid: 0, processed: 0, notified: 0 }
if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf8')) } } catch { } }
const save = () => writeFileSync(statePath, JSON.stringify(state, null, 1), { mode: 0o600 })
const log = (m) => console.log(`[cayce-mail ${new Date().toISOString().slice(5, 16)}] ${m}`)
async function dm(text) {
try {
await fetch(`${NOSTR}/dm`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to_hex: SUPERVISOR, content: text.slice(0, 900) }),
signal: AbortSignal.timeout(20000),
})
state.notified++
} catch (e) { console.error('dm:', String(e).slice(0, 80)) }
}
// ── clasificación de remitentes relevantes ──────────────────────────────────
const MERCHANTS = {
'bitrefill.com': 'Bitrefill',
'travala.com': 'Travala',
'trezor.io': 'Trezor',
'ledger.com': 'Ledger',
'bitbox.swiss': 'BitBox',
'coinkite.com': 'Coinkite',
'swan.com': 'Swan',
'bitaxe': 'Bitaxe',
'opensats.org': 'OpenSats',
'hrf.org': 'HRF',
'foundationdevices.com': 'Foundation',
'relai.ch': 'Relai',
'blink.sv': 'Blink',
}
function classify(from = '', subject = '') {
const f = from.toLowerCase(), s = subject.toLowerCase()
const merchant = Object.entries(MERCHANTS).find(([d]) => f.includes(d))
if (!merchant) return null
let kind = 'info'
if (/approv|accepted|welcome to|partner|afiliado aprobado/.test(s)) kind = 'APPROVAL'
else if (/reject|denied|not approved|declined/.test(s)) kind = 'REJECTION'
else if (/statement|receipt|transaction|invoice|payout|commission/.test(s)) kind = 'money'
else if (/verify|confirm|action required|complete your/.test(s)) kind = 'ACTION-REQUIRED'
return { merchant: merchant[1], kind }
}
async function processMessage(msg) {
const from = msg.envelope.from?.[0]?.address || ''
const subject = msg.envelope.subject || '(sin asunto)'
const c = classify(from, subject)
if (!c) return
log(`${c.kind} · ${c.merchant} · "${subject.slice(0, 60)}" <${from}>`)
// Registrar en actions_log (auditoría)
await pgc.query(
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_received', $1, 'ok')`,
[{ from, subject, merchant: c.merchant, kind: c.kind, uid: msg.uid }]
).catch(e => console.error('db:', String(e).slice(0, 100)))
// Notificar al supervisor solo lo importante (no estados de cuenta)
if (c.kind === 'money') return
const emoji = c.kind === 'APPROVAL' ? '✅' : c.kind === 'REJECTION' ? '❌' : c.kind === 'ACTION-REQUIRED' ? '⚠' : '✉'
await dm(`${emoji} MAIL ${c.kind} — ${c.merchant}\n"${subject.slice(0, 80)}"\nDe: ${from}\nRevisar bandeja entidadnodal@gmail.com`)
}
async function pollInbox() {
const client = new ImapFlow({
host: 'imap.gmail.com', port: 993, secure: true,
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
logger: false,
})
try {
await client.connect()
const lock = await client.getMailboxLock('INBOX')
try {
// primera pasada: marcar el punto de partida en el último mensaje existente
if (!state.lastUid) {
let last = 0
for await (const m of client.fetch({ all: true }, { uid: true }, { uid: true })) last = m.uid
state.lastUid = last
save()
log(`baseline: uid ${last} · ${client.mailbox.exists} mensajes en inbox`)
return
}
// mensajes nuevos desde el último UID visto
const range = { uid: `${state.lastUid + 1}:*` }
for await (const msg of client.fetch(range, { envelope: true, uid: true }, { uid: true })) {
if (msg.uid <= state.lastUid) continue
await processMessage(msg)
state.processed++
if (msg.uid > state.lastUid) state.lastUid = msg.uid
}
save()
} finally {
lock.release()
}
await client.logout()
} catch (e) {
console.error('[cayce-mail] poll error:', String(e).slice(0, 160))
try { await client.logout() } catch { }
}
}
// ── SMTP saliente para outreach ─────────────────────────────────────────────
export async function sendMail(to, subject, body) {
// contención (2026-10-31): el mail saliente también pasa por el outbox
const g = await gateOutbound(null, {
channel: 'mail', type: 'smtp_send', content: `To: ${to}\nSubject: ${subject}\n\n${body}`,
meta: { to, subject },
publishFn: () => _sendMailLive(to, subject, body),
})
if (g.staged) return g
}
async function _sendMailLive(to, subject, body) {
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com', port: 465, secure: true,
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
})
const info = await transporter.sendMail({
from: `"Cayce Co" <${ENV.MAIL_USER}>`,
to, subject, text: body,
})
await pgc.query(
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_sent', $1, 'ok')`,
[{ to, subject, messageId: info.messageId }]
).catch(() => { })
log(`sent → ${to} · "${subject.slice(0, 50)}"`)
return info
}
// CLI: node mail.mjs --send-to x@y --subject "..." --body-file z.txt
const args = process.argv.slice(2)
if (args.includes('--send-to')) {
const to = args[args.indexOf('--send-to') + 1]
const subject = args[args.indexOf('--subject') + 1] || '(sin asunto)'
const bodyFile = args[args.indexOf('--body-file') + 1]
const body = bodyFile ? readFileSync(bodyFile, 'utf8') : args[args.indexOf('--body') + 1] || ''
sendMail(to, subject, body).then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
} else {
log(`daemon on · poll cada ${POLL_MS / 60000} min · cuenta ${ENV.MAIL_USER}`)
pollInbox()
setInterval(pollInbox, POLL_MS)
}