containment: every third-party send becomes a draft (outbox), identity file versioned
ci / lint-test-leaks (pull_request) Canceled after 0s
ci / lint-test-leaks (pull_request) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user