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 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
13
config.example.json
Normal file
13
config.example.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
112
mail-buddy.js
Normal file
112
mail-buddy.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
8
package.json
Normal file
8
package.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-mail-buddy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"imapflow": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user