Initial resurrection kit — 81 scripts, 62 references, configs, systemd units, crons, Docker compose files, Caddy config, master README
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user