#!/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 "" """ 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()