commit ebea4de978b48d101831220957fb63d3652e9f9f Author: mikael-lovqvists-claude-agent Date: Mon Mar 16 23:31:31 2026 +0000 Initial commit — IMAP IDLE mail-to-Claude bridge Polls a dedicated mailbox via IMAP IDLE for push notifications, filters by allowed sender, and dispatches messages as prompts to Claude Code via claude-remote. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..5e56aec --- /dev/null +++ b/config.example.json @@ -0,0 +1,13 @@ +{ + "imap": { + "host": "mail.example.com", + "port": 993, + "secure": true, + "user": "claude-buddy@example.com", + "pass": "secret", + "mailbox": "INBOX" + }, + "allowed_sender": "mikael@example.com", + "claude_window_id": 25165857, + "claude_remote_path": "/workspace/claude-remote/claude-remote.js" +} diff --git a/mail-buddy.js b/mail-buddy.js new file mode 100644 index 0000000..23b75a9 --- /dev/null +++ b/mail-buddy.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import { ImapFlow } from 'imapflow'; +import { readFileSync } from 'fs'; +import { execFileSync } from 'child_process'; + +const CONFIG_PATH = process.env.MAIL_BUDDY_CONFIG ?? '/home/devilholk/.config/claude-mail-buddy/config.json'; + +function load_config() { + try { + return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); + } catch (err) { + console.error(`Failed to load config from ${CONFIG_PATH}: ${err.message}`); + process.exit(1); + } +} + +function make_prompt(message) { + const from = message.envelope.from?.[0]?.address ?? 'unknown'; + const subject = message.envelope.subject ?? '(no subject)'; + const body = message.text ?? '(no body)'; + + return [ + `[mail-buddy] New email from ${from}`, + `Subject: ${subject}`, + ``, + body.trim(), + ``, + `Please reply to this email using the send-email CCC action, addressed to ${from} with an appropriate subject.`, + ].join('\n'); +} + +function send_to_claude(text, config) { + const window_id = String(config.claude_window_id); + execFileSync('node', [config.claude_remote_path, '--window-id', window_id], { + input: text, + encoding: 'utf8', + stdio: ['pipe', 'inherit', 'inherit'], + }); +} + +async function handle_message(client, uid, config) { + const msg = await client.fetchOne(uid, { envelope: true, bodyStructure: true, source: true }); + if (!msg) { + return; + } + + // Fetch plain text part + let text = ''; + for await (const part of client.fetch(uid, { bodyParts: ['text/plain'] })) { + text = part.bodyParts?.get('text/plain')?.toString('utf8') ?? ''; + } + msg.text = text; + + const sender = msg.envelope.from?.[0]?.address ?? ''; + if (!sender.toLowerCase().includes(config.allowed_sender.toLowerCase())) { + console.log(`Ignoring message from ${sender}`); + return; + } + + console.log(`Dispatching message from ${sender}: ${msg.envelope.subject}`); + const prompt = make_prompt(msg); + send_to_claude(prompt, config); +} + +async function main() { + const config = load_config(); + + const client = new ImapFlow({ + host: config.imap.host, + port: config.imap.port ?? 993, + secure: config.imap.secure ?? true, + auth: { + user: config.imap.user, + pass: config.imap.pass, + }, + logger: false, + }); + + await client.connect(); + console.log(`Connected to ${config.imap.host}`); + + const lock = await client.getMailboxLock(config.imap.mailbox ?? 'INBOX'); + + try { + // Process any unseen messages already waiting + const unseen = await client.search({ unseen: true }); + for (const uid of unseen) { + await handle_message(client, uid, config); + } + + console.log('Waiting for new mail (IDLE)...'); + + // IDLE loop — re-enters IDLE after each notification + while (true) { + await client.idle(); + + const new_msgs = await client.search({ unseen: true }); + for (const uid of new_msgs) { + await handle_message(client, uid, config); + await client.messageFlagsAdd(uid, ['\\Seen']); + } + } + } finally { + lock.release(); + await client.logout(); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..4fe29de --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "claude-mail-buddy", + "version": "0.1.0", + "type": "module", + "dependencies": { + "imapflow": "^1.0.0" + } +} \ No newline at end of file