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.
164 lines
6.8 KiB
JavaScript
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)
|
|
}
|