Files
hermes-recovery/scripts/boys-mail-monitor.py

411 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Boys' Email Monitor — polls garrison@iamgmb.com and greyson@iamgmb.com inboxes,
logs new emails, tracks sender frequency, and sends daily summary emails.
Usage:
python3 boys-mail-monitor.py collect
python3 boys-mail-monitor.py summary
python3 boys-mail-monitor.py test-summary
"""
import imaplib
import email
import json
import os
import smtplib
import sys
import logging
import random
from datetime import datetime, timezone, timedelta
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import parsedate_to_datetime
from html import escape
from pathlib import Path
# ── Sho'Nuff's signature ──────────────────────────
SHONUFF_TITLES = [
"Germaine's AI Ops Engineer",
"Germaine's Baddest AI Mofo Low Down Around This Town",
"Germaine's One-and-Only Digital Master",
"Germaine's Converse-Kicking Assistant",
"Keeper of Germaine's Teeth (Catches Bullets)",
"Germaine's AI Problem Child",
"Germaine's 24/7 Co-Pilot",
"Germaine's Digital Henchman",
"Germaine's Glow Up Coordinator",
"Germaine's Digital Sidekick",
"Germaine's AI Bodyguard",
"Germaine's Chaos Coordinator",
"Germaine's Full-Time Menace",
]
SHONUFF_CLOSINGS = [
"Who's the Master?",
"Kiss my Converse!",
"Am I the meanest?",
"Am I the prettiest?",
"Am I the baddest mofo low down around this town?",
"All right, Leroy. Who's the one-and-only Master?",
"YOU'LL... NEVER... USE... THIS... FOOT... AGAIN!",
"Catches bullets with his teeth?",
]
BASE_DIR = "/root/.hermes"
DATA_DIR = os.path.join(BASE_DIR, "data")
LOG_FILE = "/var/log/boys-mail-monitor.log"
MAIL_LOG = os.path.join(DATA_DIR, "boys-mail.json")
SENDERS_DB = os.path.join(DATA_DIR, "boys-senders.json")
PROCESSED_DB = os.path.join(DATA_DIR, "boys-processed.json")
BOYS_ACCOUNTS = {
"Garrison": {
"host": "heracles.mxrouting.net",
"port": 993,
"user": "garrison@iamgmb.com",
"pw_file": "/root/.config/himalaya/garrison-iamgmb.pass",
},
"Greyson": {
"host": "heracles.mxrouting.net",
"port": 993,
"user": "greyson@iamgmb.com",
"pw_file": "/root/.config/himalaya/greyson-iamgmb.pass",
},
}
SMTP_HOST = "mail.germainebrown.com"
SMTP_PORT = 2525
SMTP_USER = "shonuff@germainebrown.com"
SMTP_PASS_FILE = "/root/.config/himalaya/shonuff.pass"
SMTP_FROM = "shonuff@germainebrown.com"
SMTP_FROM_NAME = "Sho'Nuff Brown"
BCC_ADDR = "g@germainebrown.com"
RECIPIENTS = {
"Garrison": "tinamichelle1008@gmail.com",
"Greyson": "katherineeubank@gmail.com",
}
MOM_NAMES = {"Garrison": "Tina", "Greyson": "Kate"}
HIGH_VOLUME_THRESHOLD = 3
TRUSTED_IGNORE_SENDERS = {"shonuff@germainebrown.com"}
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()],
)
log = logging.getLogger("boys-mail")
def read_secret(path):
return Path(path).read_text().strip()
def load_json(path, default):
if os.path.exists(path):
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
log.warning("Corrupt JSON %s, reinitializing", path)
return default
def save_json_atomic(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)
def safe_str(val):
if val is None:
return ""
try:
return str(val)
except Exception:
return ""
def extract_sender_name(from_header):
if not from_header:
return "Unknown Sender"
name, addr = email.utils.parseaddr(from_header)
return name if name else addr or "Unknown Sender"
def extract_sender_email(from_header):
if not from_header:
return "unknown@unknown"
_, addr = email.utils.parseaddr(from_header)
return (addr or from_header).lower().strip()
def parse_email_date(date_str):
try:
dt = parsedate_to_datetime(date_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return datetime.now(timezone.utc)
def format_time_for_summary(email_date):
if isinstance(email_date, str):
email_date = parse_email_date(email_date)
et = email_date.astimezone(timezone(timedelta(hours=-4)))
return et.strftime("%I:%M %p").lstrip("0")
def format_date_header(date_obj):
et = date_obj.astimezone(timezone(timedelta(hours=-4)))
return et.strftime("%A, %B %d, %Y")
def message_uid_key(child_name, uid):
return f"{child_name}:{uid}"
def message_id_key(child_name, msg_id):
msg_id = (msg_id or "").strip()
return f"{child_name}:msgid:{msg_id}" if msg_id else ""
def poll_inbox(child_name, config, mail_log_data, senders_data, processed):
new_count = 0
try:
conn = imaplib.IMAP4_SSL(config["host"], config["port"])
conn.login(config["user"], read_secret(config["pw_file"]))
conn.select("INBOX")
status, data = conn.uid("SEARCH", None, "UNSEEN")
if status != "OK" or not data[0]:
conn.logout()
log.info("%s: No UNSEEN messages", child_name)
return 0
for uid_b in data[0].split():
uid = uid_b.decode()
uid_key = message_uid_key(child_name, uid)
if uid_key in processed:
continue
try:
status, msg_data = conn.uid("FETCH", uid, "(BODY.PEEK[])")
if status != "OK" or not msg_data or not isinstance(msg_data[0], tuple):
continue
raw = email.message_from_bytes(msg_data[0][1])
from_hdr = safe_str(raw.get("From", "")).strip()
sender_email = extract_sender_email(from_hdr)
msg_id = safe_str(raw.get("Message-ID", ""))
mid_key = message_id_key(child_name, msg_id)
if mid_key and mid_key in processed:
processed.add(uid_key)
continue
if sender_email in TRUSTED_IGNORE_SENDERS:
processed.add(uid_key)
if mid_key:
processed.add(mid_key)
log.info("%s: Ignored trusted sender %s uid=%s", child_name, sender_email, uid)
continue
subject = safe_str(raw.get("Subject", "(no subject)"))
date_str = safe_str(raw.get("Date", ""))
sender_name = extract_sender_name(from_hdr)
entry = {
"id": uid_key,
"child": child_name,
"from": from_hdr,
"from_name": sender_name,
"from_email": sender_email,
"subject": subject,
"date": date_str,
"timestamp": datetime.now(timezone.utc).isoformat(),
"uid": uid,
"message_id": msg_id,
}
mail_log_data.append(entry)
processed.add(uid_key)
if mid_key:
processed.add(mid_key)
if sender_email not in senders_data:
senders_data[sender_email] = {"name": sender_name, "first_seen": entry["timestamp"], "last_seen": entry["timestamp"], "count": 0, "child": child_name}
senders_data[sender_email]["last_seen"] = entry["timestamp"]
senders_data[sender_email]["count"] += 1
if sender_name != "Unknown Sender" and sender_name:
senders_data[sender_email]["name"] = sender_name
new_count += 1
log.info("%s: New email uid=%s from %s%s", child_name, uid, sender_email, subject[:60])
except Exception as e:
log.error("%s: Error processing UID %s: %s", child_name, uid, e)
conn.logout()
except Exception as e:
log.error("%s: IMAP connection error: %s", child_name, e)
return new_count
def do_collect():
log.info("=" * 50)
log.info("Boys' Mail Collection Run")
log.info("=" * 50)
mail_log = load_json(MAIL_LOG, [])
senders = load_json(SENDERS_DB, {})
processed = set(load_json(PROCESSED_DB, []))
total = 0
for child_name, config in BOYS_ACCOUNTS.items():
total += poll_inbox(child_name, config, mail_log, senders, processed)
cutoff_ts = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
mail_log = [e for e in mail_log if e.get("timestamp", "") >= cutoff_ts]
for info in senders.values():
if info.get("count", 0) >= HIGH_VOLUME_THRESHOLD and not info.get("flagged"):
info["flagged"] = True
save_json_atomic(MAIL_LOG, mail_log)
save_json_atomic(SENDERS_DB, senders)
save_json_atomic(PROCESSED_DB, sorted(processed))
log.info("Total new emails collected: %d", total)
log.info("Total tracked senders: %d", len(senders))
return total
def shonuff_title():
return random.choice(SHONUFF_TITLES)
def shonuff_closing():
return random.choice(SHONUFF_CLOSINGS)
def build_summary_html(child_name, today_emails, senders_for_child):
mom_name = MOM_NAMES.get(child_name, "Mom")
today = datetime.now(timezone.utc)
date_str = format_date_header(today)
if not today_emails:
emails_section = '<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:16px;margin:16px 0;text-align:center;"><p style="font-size:14px;color:#166534;margin:0;">✅ No new emails today</p></div>'
else:
rows = ""
for em in today_emails:
sender_display = em.get("from_name", em.get("from", "Unknown"))
subj = escape(em.get("subject", "(no subject)"))
t = format_time_for_summary(em.get("date", ""))
rows += f"<tr><td style='padding:8px 10px;border:1px solid #e5e7eb;'>{escape(sender_display)}</td><td style='padding:8px 10px;border:1px solid #e5e7eb;'>{subj}</td><td style='padding:8px 10px;border:1px solid #e5e7eb;white-space:nowrap;'>{t}</td></tr>"
emails_section = f"<h3 style='font-size:14px;font-weight:600;margin:20px 0 8px;'>📨 New Emails</h3><table style='width:100%;border-collapse:collapse;font-size:13px;'><tr style='background:#f3f4f6;'><th style='padding:8px 10px;border:1px solid #e5e7eb;text-align:left;'>From</th><th style='padding:8px 10px;border:1px solid #e5e7eb;text-align:left;'>Subject</th><th style='padding:8px 10px;border:1px solid #e5e7eb;text-align:left;'>Time</th></tr>{rows}</table>"
frequency_section = ""
if senders_for_child:
freq_rows = ""
for sender_email, info in sorted(senders_for_child.items(), key=lambda x: x[1].get("count", 0), reverse=True)[:10]:
count = info.get("count", 0)
note = "⚠️ High volume — potential subscription" if count >= HIGH_VOLUME_THRESHOLD else ""
bg = ' style="background:#fef3c7;"' if note else ""
freq_rows += f"<tr{bg}><td style='padding:8px 10px;border:1px solid #e5e7eb;'>{escape(info.get('name', sender_email))}<br><span style='font-size:11px;color:#999;'>{escape(sender_email)}</span></td><td style='padding:8px 10px;border:1px solid #e5e7eb;text-align:center;font-weight:600;'>{count}</td><td style='padding:8px 10px;border:1px solid #e5e7eb;font-size:12px;'>{note}</td></tr>"
frequency_section = f"<h3 style='font-size:14px;font-weight:600;margin:20px 0 8px;'>📊 This Week's Top Senders</h3><table style='width:100%;border-collapse:collapse;font-size:13px;'><tr style='background:#f3f4f6;'><th style='padding:8px 10px;border:1px solid #e5e7eb;text-align:left;'>Sender</th><th style='padding:8px 10px;border:1px solid #e5e7eb;text-align:center;'>Count</th><th style='padding:8px 10px;border:1px solid #e5e7eb;text-align:left;'>Note</th></tr>{freq_rows}</table>"
return f"""<!DOCTYPE html><html><head><meta charset='utf-8'></head><body style="font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;color:#1a1a1a;max-width:560px;margin:0 auto;padding:20px;"><div style='border-bottom:2px solid #dc2626;padding-bottom:12px;margin-bottom:20px;'><h2 style='margin:0;font-size:18px;font-weight:600;'>📬 {escape(child_name)}'s Email Summary</h2><p style='color:#666;font-size:12px;margin:4px 0 0;'>{escape(date_str)}</p></div><p style='font-size:14px;line-height:1.6;color:#333;'>Hey {escape(mom_name)}!</p><p style='font-size:14px;line-height:1.6;'>Here's what came through today:</p>{emails_section}{frequency_section}<p style='font-size:12px;color:#888;font-style:italic;margin:18px 0 12px;'><em>{shonuff_closing()}</em></p><hr style='border:none;height:2px;width:40px;background:#cc0000;margin:0 0 12px 0;border-radius:2px;'><table cellpadding='0' cellspacing='0' border='0' style="font-family:'Inter',-apple-system,sans-serif;color:#2c2c2c;font-size:13px;line-height:1.6;"><tr><td style='padding-right:14px;vertical-align:middle;width:80px;'><img src='https://core.itpropartner.com/shonuff-signature.png' alt='SB' width='72' height='72' style='border-radius:50%;display:block;max-width:72px;'></td><td style='vertical-align:middle;'><div style='font-size:15px;font-weight:700;color:#1a1a1a;'>Sho'Nuff Brown</div><div style='font-size:12px;color:#888;margin-bottom:1px;'>{shonuff_title()}</div><div style='font-size:12px;color:#aaa;margin-bottom:6px;'>IT Pro Partner</div><div style='font-size:12px;color:#999;'><a href='mailto:shonuff@germainebrown.com' style='color:#555;text-decoration:none;border-bottom:1px dotted #ccc;'>shonuff@germainebrown.com</a></div></td></tr></table><p style='font-size:11px;color:#aaa;'>Reply to adjust how often you get these summaries, request {escape(child_name)}'s login credentials for your phone, or flag senders to unsubscribe.</p><p style='font-size:11px;color:#aaa;'>Webmail: <a href='http://webmail.iamgmb.com' style='color:#1d4ed8;'>webmail.iamgmb.com</a></p></body></html>"""
def build_plain_text(child_name, today_emails, senders_for_child):
mom_name = MOM_NAMES.get(child_name, "Mom")
lines = [f"{child_name}'s Email Summary", "=" * 40, "", f"Hey {mom_name},", "", "Here's what came through today:", ""]
if not today_emails:
lines.append("✅ No new emails today")
else:
lines.append("📨 New Emails:")
for em in today_emails:
lines.append(f" From: {em.get('from_name', em.get('from', 'Unknown'))}")
lines.append(f" Re: {em.get('subject', '(no subject)')}")
lines.append(f" Time: {format_time_for_summary(em.get('date', ''))}")
lines.append("")
if senders_for_child:
lines.append("📊 Top Senders This Week:")
for sender_email, info in sorted(senders_for_child.items(), key=lambda x: x[1].get("count", 0), reverse=True)[:10]:
note = " ⚠️ HIGH VOLUME" if info.get("count", 0) >= HIGH_VOLUME_THRESHOLD else ""
lines.append(f" {info.get('name', sender_email)} ({sender_email}): {info.get('count', 0)}{note}")
lines += ["", "--", "Sho'Nuff Brown", "shonuff@germainebrown.com", "", f"Reply to adjust frequency, request {child_name}'s login credentials for your phone, or flag senders to unsubscribe.", "Webmail: http://webmail.iamgmb.com"]
return "\n".join(lines)
def send_email(child_name, today_emails, senders_for_child, to_addr, test=False):
html = build_summary_html(child_name, today_emails, senders_for_child)
plain = build_plain_text(child_name, today_emails, senders_for_child)
subject = f"{'TEST: ' if test else ''}📬 {child_name}'s Email Summary — {datetime.now(timezone.utc).strftime('%b %d')}"
msg = MIMEMultipart("alternative")
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM}>"
msg["To"] = to_addr
if to_addr.lower() != BCC_ADDR.lower():
msg["Bcc"] = BCC_ADDR
msg["Subject"] = subject
msg.attach(MIMEText(plain, "plain"))
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s:
s.starttls()
s.login(SMTP_USER, read_secret(SMTP_PASS_FILE))
s.send_message(msg)
log.info("Summary sent for %s to %s", child_name, to_addr)
return True
def filter_today(mail_log, child_name):
today = datetime.now(timezone.utc).astimezone(timezone(timedelta(hours=-4))).strftime("%Y-%m-%d")
out = []
for e in mail_log:
if e.get("child") != child_name:
continue
if parse_email_date(e.get("date", "")).astimezone(timezone(timedelta(hours=-4))).strftime("%Y-%m-%d") == today:
out.append(e)
return out
def do_summary(test_to=None):
log.info("=" * 50)
log.info("Boys' Daily Summary Run")
log.info("=" * 50)
mail_log = load_json(MAIL_LOG, [])
senders = load_json(SENDERS_DB, {})
results = []
for child_name in BOYS_ACCOUNTS:
to_addr = test_to or RECIPIENTS.get(child_name)
today_emails = filter_today(mail_log, child_name)
senders_for_child = {email_addr: info for email_addr, info in senders.items() if info.get("child") == child_name and email_addr not in TRUSTED_IGNORE_SENDERS}
if to_addr and "@" in to_addr and "example.com" not in to_addr:
try:
send_email(child_name, today_emails, senders_for_child, to_addr, test=bool(test_to))
results.append((child_name, to_addr, True, len(today_emails)))
except Exception as e:
log.error("Failed to send summary for %s: %s", child_name, e)
results.append((child_name, to_addr, False, len(today_emails)))
else:
log.warning("%s: No valid recipient email configured (%s)", child_name, to_addr)
results.append((child_name, to_addr, False, len(today_emails)))
log.info("Summary run complete: %s", results)
return results
def do_test_summary():
do_summary(test_to=SMTP_FROM)
print("✅ Test summaries sent to shonuff@germainebrown.com — check your inbox.")
def main():
os.makedirs(DATA_DIR, exist_ok=True)
if len(sys.argv) < 2 or sys.argv[1] in {"--help", "-h", "help"}:
print("Usage: boys-mail-monitor.py [collect|summary|test-summary]")
print(" collect — Poll inboxes for new emails")
print(" summary — Send daily summary emails")
print(" test-summary — Send test summaries to shonuff inbox")
return 0
command = sys.argv[1]
if command == "collect":
do_collect()
elif command == "summary":
do_summary()
elif command == "test-summary":
do_test_summary()
else:
print(f"Unknown command: {command}")
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())