Switch to mailparser, UID watermarking, and 45s polling
- 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>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { readFileSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
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/.config/claude-mail-buddy/config.json';
|
||||
const CONFIG_PATH = process.env.MAIL_BUDDY_CONFIG ?? '/home/devilholk/.secrets/claude-mail-buddy.conf';
|
||||
|
||||
function load_config() {
|
||||
try {
|
||||
@@ -15,17 +18,17 @@ function load_config() {
|
||||
}
|
||||
|
||||
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)';
|
||||
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] New email from ${from}`,
|
||||
`Subject: ${subject}`,
|
||||
`[mail-buddy] You have received an email from ${from} with the subject "${subject}".`,
|
||||
`Reply using ccc according to MEMORY:email_protocol.md`,
|
||||
``,
|
||||
body.trim(),
|
||||
``,
|
||||
`Please reply to this email using the send-email CCC action, addressed to ${from} with an appropriate subject.`,
|
||||
`--- Message body ---`,
|
||||
body,
|
||||
`--- End of message ---`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -39,29 +42,45 @@ function send_to_claude(text, config) {
|
||||
}
|
||||
|
||||
async function handle_message(client, uid, config) {
|
||||
const msg = await client.fetchOne(uid, { envelope: true, bodyStructure: true, source: true });
|
||||
if (!msg) {
|
||||
const raw = await client.fetchOne(uid, { source: true });
|
||||
if (!raw?.source) {
|
||||
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 msg = await simpleParser(raw.source);
|
||||
const sender = msg.from?.value?.[0]?.address ?? '';
|
||||
|
||||
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 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();
|
||||
|
||||
@@ -82,23 +101,17 @@ async function main() {
|
||||
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);
|
||||
}
|
||||
// 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}`);
|
||||
|
||||
console.log('Waiting for new mail (IDLE)...');
|
||||
const POLL_INTERVAL = 45_000;
|
||||
console.log(`Polling every ${POLL_INTERVAL / 1000}s...`);
|
||||
|
||||
// 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']);
|
||||
}
|
||||
await sleep(POLL_INTERVAL);
|
||||
last_uid = await process_new_messages(client, config, last_uid);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.0.0"
|
||||
"imapflow": "^1.0.0",
|
||||
"mailparser": "^3.9.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user