#!/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 = '

✅ No new emails today

' 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"{escape(sender_display)}{subj}{t}" emails_section = f"

📨 New Emails

{rows}
FromSubjectTime
" 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"{escape(info.get('name', sender_email))}
{escape(sender_email)}{count}{note}" frequency_section = f"

📊 This Week's Top Senders

{freq_rows}
SenderCountNote
" return f"""

📬 {escape(child_name)}'s Email Summary

{escape(date_str)}

Hey {escape(mom_name)}!

Here's what came through today:

{emails_section}{frequency_section}

{shonuff_closing()}


SB
Sho'Nuff Brown
{shonuff_title()}
IT Pro Partner

Reply to adjust how often you get these summaries, request {escape(child_name)}'s login credentials for your phone, or flag senders to unsubscribe.

Webmail: webmail.iamgmb.com

""" 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())