Files
hermes-recovery/scripts/shonuff-email-responder.py
T

302 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""
Sho'Nuff Email Responder — polls shonuff@germainebrown.com inbox for emails
FROM the Master (g@germainebrown.com), outputs JSON for the cron agent to
generate replies, and sends those replies via SMTP.
Two modes:
default (collect): Poll IMAP for new messages from Master, output JSON to stdout
--send: Send a reply via SMTP (reads body from stdin)
Examples:
# Collect new emails from the Master
python3 shonuff-email-responder.py
# Send a reply (body from stdin)
echo "Hey Germaine! Here's the answer..." | python3 shonuff-email-responder.py --send \
--to "g@germainebrown.com" \
--subject "Re: Question about servers" \
--in-reply-to "<abc123@mail.germainebrown.com>"
"""
import imaplib
import smtplib
import email
import json
import os
import sys
import argparse
import hashlib
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
from datetime import datetime, timezone
# ── Config ────────────────────────────────────────────────────────────────
IMAP_HOST = "mail.germainebrown.com"
IMAP_PORT = 993
IMAP_USER = "shonuff@germainebrown.com"
SMTP_HOST = "mail.germainebrown.com"
SMTP_PORT = 2525
SMTP_FROM = "shonuff@germainebrown.com"
BCC_ADDRESS = "g@germainebrown.com"
# Master's known email addresses (only reply to these)
MASTER_ADDRESSES = [
"g@germainebrown.com",
]
SEEN_FILE = "/root/.hermes/scripts/.shonuff-reply-processed.json"
PASS_FILE = "/root/.config/himalaya/shonuff.pass"
PASS_FILE_G = "/root/.config/himalaya/g-germainebrown.pass"
# ── Helpers ────────────────────────────────────────────────────────────────
def read_password():
"""Read shonuff password from file."""
if os.path.exists(PASS_FILE):
with open(PASS_FILE) as f:
return f.read().strip()
# Fallback
return "Catches.bullets1985"
def load_seen():
if os.path.exists(SEEN_FILE):
try:
return set(json.load(open(SEEN_FILE)))
except (json.JSONDecodeError, ValueError):
return set()
return set()
def save_seen(seen):
json.dump(list(seen), open(SEEN_FILE, "w"))
def safe_str(val):
if val is None:
return ""
if isinstance(val, str):
return val
try:
return str(val)
except Exception:
return ""
def get_text_body(msg):
"""Extract plain text from email, preferring text/plain."""
body = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
payload = part.get_payload(decode=True)
if payload:
body += payload.decode("utf-8", errors="replace")
else:
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode("utf-8", errors="replace")
return body.strip()
def is_from_master(sender):
"""Check if the sender is the Master (g@germainebrown.com)."""
sender_lower = sender.lower()
for addr in MASTER_ADDRESSES:
if addr.lower() in sender_lower:
return True
return False
def make_msg_hash(msg_id):
"""Create a stable hash for deduplication."""
return hashlib.md5(msg_id.encode()).hexdigest()
# ── Collect Mode ───────────────────────────────────────────────────────────
def collect():
"""Poll IMAP for new UNSEEN messages FROM the Master. Output JSON."""
seen = load_seen()
new_messages = []
password = read_password()
try:
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
conn.login(IMAP_USER, password)
conn.select("INBOX")
status, data = conn.search(None, "UNSEEN")
if status != "OK" or not data[0]:
conn.logout()
save_seen(seen)
return # silent — nothing new
for num in data[0].split():
status, msg_data = conn.fetch(num, "(RFC822)")
if status != "OK":
continue
raw = email.message_from_bytes(msg_data[0][1])
sender = safe_str(raw.get("From", "")).strip()
subj = safe_str(raw.get("Subject", "(no subject)"))
msg_id = safe_str(raw.get("Message-ID", ""))
date = safe_str(raw.get("Date", ""))
# Only process emails FROM the Master
if not is_from_master(sender):
# Mark as read so we don't keep re-checking it
conn.store(num, "+FLAGS", "\\Seen")
continue
# Skip already processed (dedup)
h = make_msg_hash(msg_id) if msg_id else make_msg_hash(f"{sender}-{subj}-{date}")
if h in seen:
conn.store(num, "+FLAGS", "\\Seen")
continue
seen.add(h)
body = get_text_body(raw)
# Truncate very long bodies
body_preview = body[:3000] if body else ""
new_messages.append({
"from": sender,
"subject": subj,
"date": date,
"body": body_preview,
"message_id": msg_id,
"uid": num.decode(),
})
# Mark as SEEN so we don't re-process
conn.store(num, "+FLAGS", "\\Seen")
conn.logout()
save_seen(seen)
except Exception as e:
print(json.dumps({"error": str(e)}))
return
if not new_messages:
return # silent — no new messages from Master
# Output JSON for the cron agent
output = {
"status": "new_messages",
"count": len(new_messages),
"messages": new_messages,
"instructions": (
"You are Sho'Nuff, Germaine's AI assistant. For each message above, "
"generate a helpful reply. Then for EACH reply, write the reply text "
"to a temp file and run the send command:\n\n"
"cat /tmp/shonuff-reply.txt | python3 /root/.hermes/scripts/shonuff-email-responder.py "
"--send --to 'FROM_ADDRESS' --subject 'Re: ORIGINAL_SUBJECT' "
"--in-reply-to 'MESSAGE_ID'\n\n"
"Send ALL replies before giving your final summary. Reply as Sho'Nuff — "
"be helpful, concise, and personable."
),
}
print(json.dumps(output, indent=2))
# ── Send Mode ──────────────────────────────────────────────────────────────
def send_reply(to_addr, subject, in_reply_to, body_text):
"""Send an email reply via SMTP."""
password = read_password()
# Build the message
msg = MIMEText(body_text, "plain", "utf-8")
msg["From"] = SMTP_FROM
msg["To"] = to_addr
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain="germainebrown.com")
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
msg["References"] = in_reply_to
try:
# Connect with STARTTLS on port 2525
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30)
server.ehlo()
server.starttls()
server.ehlo()
server.login(SMTP_FROM, password)
# Build recipients list (To + BCC)
recipients = [to_addr]
if BCC_ADDRESS and BCC_ADDRESS not in recipients:
recipients.append(BCC_ADDRESS)
server.sendmail(SMTP_FROM, recipients, msg.as_string())
server.quit()
result = {
"status": "sent",
"to": to_addr,
"subject": subject,
"bcc": BCC_ADDRESS,
"message_id": msg["Message-ID"],
}
print(json.dumps(result))
return True
except Exception as e:
result = {"status": "error", "error": str(e)}
print(json.dumps(result))
return False
# ── Main ───────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Sho'Nuff Email Responder — collect or send replies"
)
parser.add_argument(
"--send", action="store_true",
help="Send mode: read reply body from stdin and send via SMTP"
)
parser.add_argument("--to", help="Recipient email address (send mode)")
parser.add_argument("--subject", help="Email subject (send mode)")
parser.add_argument("--in-reply-to", help="Message-ID being replied to (send mode)")
args = parser.parse_args()
if args.send:
# ── Send Mode ──
if not args.to:
print(json.dumps({"error": "Missing --to argument for send mode"}))
sys.exit(1)
if not args.subject:
print(json.dumps({"error": "Missing --subject argument for send mode"}))
sys.exit(1)
# Read body from stdin
body_text = sys.stdin.read().strip()
if not body_text:
print(json.dumps({"error": "No body text provided on stdin"}))
sys.exit(1)
success = send_reply(
to_addr=args.to,
subject=args.subject,
in_reply_to=args.in_reply_to or "",
body_text=body_text,
)
sys.exit(0 if success else 1)
else:
# ── Collect Mode (default) ──
collect()
if __name__ == "__main__":
main()