← 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);
});