← back to Claude Mail

poller.js

98 lines

'use strict';
// claude-mail poller — one pass: connect IMAP, process UNSEEN mail, reply, mark seen.
// Run on an interval by launchd (com.steve.claude-mail). Idempotent: only UNSEEN
// messages are touched, and every processed message is marked \Seen so it is never
// handled twice.
const { ImapFlow } = require('imapflow');
const { simpleParser } = require('mailparser');
const { cfg, gate, stripQuote, runClaude, sendMail, saveImageAttachments } = require('./lib');
const { recordSuccess, recordFailure } = require('./health');

const log = (...a) => console.log(new Date().toISOString(), ...a);

async function main() {
  const client = new ImapFlow({
    host: cfg.imapHost, port: cfg.imapPort, secure: true,
    auth: { user: cfg.user, pass: cfg.pass }, logger: false,
  });
  await client.connect();
  const lock = await client.getMailboxLock('INBOX');
  try {
    const unseen = await client.search({ seen: false });
    if (!unseen || unseen.length === 0) { log('no new mail'); return; }
    log(`found ${unseen.length} unseen`);

    for (const uid of unseen) {
      let parsed;
      try {
        const { content } = await client.download(uid, undefined, { uid: true });
        parsed = await simpleParser(content);
      } catch (e) { log(`uid ${uid}: download/parse failed: ${e.message}`); continue; }

      const from = parsed.from?.value?.[0]?.address || '(unknown)';
      const subject = parsed.subject || '(no subject)';
      const decision = gate(parsed);

      if (!decision.ok) {
        // Silent ignore: no reply to strangers / bad phrase / spoofed mail.
        log(`IGNORE uid ${uid} from=${from} subj="${subject}" reason="${decision.reason}"`);
        await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
        continue;
      }

      // Persist any image attachments so the claude run can view them.
      const images = saveImageAttachments(parsed, `uid${uid}`);
      log(`ACCEPT uid ${uid} from=${from} subj="${subject}" images=${images.length} -> running claude`);
      const body = stripQuote(parsed.text || parsed.html?.replace(/<[^>]+>/g, ' ') || '');
      const prompt = (body || subject || '').trim();

      let reply;
      if (!prompt && images.length === 0) {
        // Nothing to act on (empty body + empty subject + no images) — don't invoke
        // claude on an empty prompt; reply asking for content.
        reply = 'I got your message but it had no readable body or subject to act on. '
          + 'Reply with what you\'d like me to do (keep the phrase in the subject).\n\n— Claude (agentabrams)';
      } else {
        // Attachment-only mail still gets a prompt so Claude knows to describe it.
        const effectivePrompt = prompt
          || 'This email had no text — please look at the attached file(s) (image or PDF) and describe what you see.';
        const res = await runClaude(effectivePrompt, images);
        reply = res.ok ? res.text
          : `I hit a problem running that locally:\n\n${res.text}\n\n— Claude (agentabrams)`;
      }

      const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject}`;
      const msgId = parsed.messageId;
      try {
        const info = await sendMail({
          to: from,
          subject: replySubject,
          text: reply,
          inReplyTo: msgId,
          references: msgId,
        });
        log(`REPLIED uid ${uid} -> ${from} id=${info.messageId}`);
      } catch (e) {
        log(`REPLY FAILED uid ${uid} -> ${from}: ${e.message}`);
        // leave UNSEEN so the next pass retries the reply
        continue;
      }
      await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
    }
  } finally {
    lock.release();
    await client.logout().catch(() => {});
  }
}

main()
  .then(async () => { await recordSuccess(); process.exit(0); })
  .catch(async (e) => {
    log('FATAL', e.message);
    // Self-heal/alert: a poll that throws (e.g. auth rejected, server
    // unreachable) increments a consecutive-failure counter that pages Steve
    // over mailbox-independent channels once it crosses the threshold.
    try { await recordFailure(e.message); } catch (he) { log('health.recordFailure errored:', he.message); }
    process.exit(1);
  });