// 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) }