- Replace hand-rolled MIME parsing with mailparser's simpleParser - Use HTML body in prompts for better encoding/structure fidelity - Replace IMAP IDLE (unreliable on Loopia) with 45s polling loop - Track highest UID on startup; only fetch uid > last_uid each poll to avoid re-transferring old messages - Add node: prefix to built-in imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
3.2 KiB
JavaScript
126 lines
3.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import { ImapFlow } from 'imapflow';
|
|
import { simpleParser } from 'mailparser';
|
|
import { readFileSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { inspect } from 'node:util';
|
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
|
const CONFIG_PATH = process.env.MAIL_BUDDY_CONFIG ?? '/home/devilholk/.secrets/claude-mail-buddy.conf';
|
|
|
|
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.from?.value?.[0]?.address ?? 'unknown';
|
|
const subject = message.subject ?? '(no subject)';
|
|
const body = message.html ?? message.text ?? '(no body)';
|
|
|
|
return [
|
|
`[mail-buddy] You have received an email from ${from} with the subject "${subject}".`,
|
|
`Reply using ccc according to MEMORY:email_protocol.md`,
|
|
``,
|
|
`--- Message body ---`,
|
|
body,
|
|
`--- End of message ---`,
|
|
].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 raw = await client.fetchOne(uid, { source: true });
|
|
if (!raw?.source) {
|
|
return;
|
|
}
|
|
|
|
const msg = await simpleParser(raw.source);
|
|
const sender = msg.from?.value?.[0]?.address ?? '';
|
|
|
|
if (!sender.toLowerCase().includes(config.allowed_sender.toLowerCase())) {
|
|
console.log(`Ignoring message from ${sender}`);
|
|
return;
|
|
}
|
|
|
|
const prompt = make_prompt(msg);
|
|
send_to_claude(prompt, config);
|
|
}
|
|
|
|
async function process_new_messages(client, config, last_uid) {
|
|
const search = last_uid
|
|
? { uid: `${last_uid + 1}:*` }
|
|
: { unseen: true };
|
|
|
|
const uids = await client.search(search, { uid: true });
|
|
let new_last_uid = last_uid;
|
|
|
|
for (const uid of uids) {
|
|
if (uid <= last_uid) {
|
|
continue;
|
|
}
|
|
await handle_message(client, uid, config);
|
|
await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
|
|
if (uid > new_last_uid) {
|
|
new_last_uid = uid;
|
|
}
|
|
}
|
|
|
|
return new_last_uid;
|
|
}
|
|
|
|
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 {
|
|
// Get highest existing UID without processing old messages
|
|
const all_uids = await client.search({ all: true }, { uid: true });
|
|
let last_uid = all_uids.length > 0 ? Math.max(...all_uids) : 0;
|
|
console.log(`Starting from UID ${last_uid}`);
|
|
|
|
const POLL_INTERVAL = 45_000;
|
|
console.log(`Polling every ${POLL_INTERVAL / 1000}s...`);
|
|
|
|
while (true) {
|
|
await sleep(POLL_INTERVAL);
|
|
last_uid = await process_new_messages(client, config, last_uid);
|
|
}
|
|
} finally {
|
|
lock.release();
|
|
await client.logout();
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|