302 lines
9.7 KiB
Python
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()
|