Files
hermes-recovery/scripts/boys-mail-monitor.py.bak.20260712-191959
T

675 lines
25 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:
# Collect new emails (run every 60 min via cron)
python3 boys-mail-monitor.py collect
# Send daily summary (run at 7 PM ET via cron)
python3 boys-mail-monitor.py summary
# Test send (dry run summary to sho'nuff)
python3 boys-mail-monitor.py test-summary
"""
import imaplib
import email
import json
import os
import smtplib
import sys
import logging
import re
import random
# ── 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?",
]
def shonuff_title():
return random.choice(SHONUFF_TITLES)
def shonuff_closing():
return random.choice(SHONUFF_CLOSINGS)
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, formataddr
from html import escape
# ── Configuration ──────────────────────────────────────────────────────────
BASE_DIR = "/root/.hermes"
DATA_DIR = os.path.join(BASE_DIR, "data")
SCRIPTS_DIR = os.path.join(BASE_DIR, "scripts")
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")
# IMAP — Boys' accounts
BOYS_ACCOUNTS = {
"Garrison": {
"host": "heracles.mxrouting.net",
"port": 993,
"user": "garrison@iamgmb.com",
"pw": "Baby.g2015!",
},
"Greyson": {
"host": "heracles.mxrouting.net",
"port": 993,
"user": "greyson@iamgmb.com",
"pw": "Boogie.woogie20",
},
}
# SMTP — Sho'Nuff's outbound
SMTP_HOST = "mail.germainebrown.com"
SMTP_PORT = 2525
SMTP_USER = "shonuff@germainebrown.com"
SMTP_PASS = "Catches.bullets1985"
SMTP_FROM = "shonuff@germainebrown.com"
SMTP_FROM_NAME = "Sho'Nuff Brown"
BCC_ADDR = "g@germainebrown.com"
# Recipient email addresses — PLACEHOLDERS until Germaine provides them
# Tina gets Garrison's summaries, Kate gets Greyson's summaries
RECIPIENTS = {
"Garrison": "tinamichelle1008@gmail.com",
"Greyson": "katherineeubank@gmail.com",
}
MOM_NAMES = {
"Garrison": "Tina",
"Greyson": "Kate",
}
HIGH_VOLUME_THRESHOLD = 3
# ── Logging ────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(),
],
)
log = logging.getLogger("boys-mail")
# ── Data helpers ───────────────────────────────────────────────────────────
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(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
def safe_str(val):
if val is None:
return ""
if isinstance(val, str):
return val
try:
return str(val)
except Exception:
return ""
def extract_sender_name(from_header):
"""Extract a display name from a From header like 'John Doe <john@example.com>'."""
if not from_header:
return "Unknown Sender"
name, addr = email.utils.parseaddr(from_header)
return name if name else addr
def extract_sender_email(from_header):
"""Extract just the email address from a From header."""
if not from_header:
return "unknown@unknown"
name, addr = email.utils.parseaddr(from_header)
return addr.lower() if addr else from_header.lower().strip()
def parse_email_date(date_str):
"""Try to parse an email date string."""
try:
dt = parsedate_to_datetime(date_str)
return dt
except Exception:
return datetime.now(timezone.utc)
def format_time_for_summary(email_date):
"""Format a datetime to something like '2:30 PM'."""
if isinstance(email_date, str):
email_date = parse_email_date(email_date)
# Convert to ET
et = email_date - timedelta(hours=4) # EDT roughly
return et.strftime("%I:%M %p").lstrip("0")
def format_date_header(date_obj):
"""Format a date like 'Wednesday, July 8, 2026'."""
if isinstance(date_obj, str):
date_obj = datetime.now(timezone.utc)
# Convert to ET
et = date_obj
return et.strftime("%A, %B %d, %Y")
def get_day_name(date_obj):
"""Get day name like 'Wednesday'."""
if isinstance(date_obj, str):
date_obj = datetime.now(timezone.utc)
return date_obj.strftime("%A")
# ── IMAP Polling ───────────────────────────────────────────────────────────
def poll_inbox(child_name, config, mail_log_data, senders_data):
"""Poll a single child's inbox for UNSEEN messages. Logs new ones."""
child_lower = child_name.lower()
new_count = 0
try:
conn = imaplib.IMAP4_SSL(config["host"], config["port"])
conn.login(config["user"], config["pw"])
conn.select("INBOX")
status, data = conn.search(None, "UNSEEN")
if status != "OK" or not data[0]:
conn.logout()
log.info("%s: No UNSEEN messages", child_name)
return 0
for num in data[0].split():
try:
# BODY.PEEK so we don't mark as seen — boys leave UNSEEN
status, msg_data = conn.fetch(num, "(BODY.PEEK[] BODY.PEEK[HEADER])")
if status != "OK":
status, msg_data = conn.fetch(num, "(RFC822)")
if status != "OK":
continue
raw = email.message_from_bytes(msg_data[0][1])
from_hdr = safe_str(raw.get("From", "")).strip()
subject = safe_str(raw.get("Subject", "(no subject)"))
date_str = safe_str(raw.get("Date", ""))
msg_id = safe_str(raw.get("Message-ID", str(num)))
sender_email = extract_sender_email(from_hdr)
sender_name = extract_sender_name(from_hdr)
# Build entry
entry = {
"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": num.decode(),
}
# Add to mail log
mail_log_data.append(entry)
# Update sender tracking
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
# Update name if we now have a better one
if sender_name != "Unknown Sender" and sender_name:
senders_data[sender_email]["name"] = sender_name
new_count += 1
log.info(
"%s: New email from %s%s",
child_name, sender_email, subject[:60],
)
except Exception as e:
log.error("%s: Error processing message %s: %s", child_name, num, e)
continue
conn.logout()
except Exception as e:
log.error("%s: IMAP connection error: %s", child_name, e)
return 0
return new_count
def do_collect():
"""Collect new emails from both inboxes."""
log.info("=" * 50)
log.info("Boys' Mail Collection Run")
log.info("=" * 50)
mail_log = load_json(MAIL_LOG, [])
senders = load_json(SENDERS_DB, {})
total = 0
for child_name, config in BOYS_ACCOUNTS.items():
try:
count = poll_inbox(child_name, config, mail_log, senders)
total += count
except Exception as e:
log.error("Failed to poll %s: %s", child_name, e)
# Trim mail log — keep entries from the last 30 days using the script's timestamp
cutoff_ts = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
mail_log = [e for e in mail_log if e.get("timestamp", "") >= cutoff_ts]
save_json(MAIL_LOG, mail_log)
save_json(SENDERS_DB, senders)
log.info("Total new emails collected: %d", total)
log.info("Total tracked senders: %d", len(senders))
# Flag high-volume senders
for email_addr, info in senders.items():
if info["count"] >= HIGH_VOLUME_THRESHOLD and not info.get("flagged"):
info["flagged"] = True
log.warning(
"HIGH VOLUME: %s (%s) — %d emails for %s",
info["name"], email_addr, info["count"], info["child"],
)
save_json(SENDERS_DB, senders)
return total
# ── Email Builder ──────────────────────────────────────────────────────────
def build_summary_html(child_name, today_emails, senders_for_child):
"""Build the full HTML summary email body manually (no send-shonuff.py)."""
mom_name = MOM_NAMES.get(child_name, "Mom")
today = datetime.now(timezone.utc)
date_str = format_date_header(today)
# ── New Emails Table ───────────────────────────────────────────────
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 += (
"<tr>"
f'<td style="padding:8px 10px;border:1px solid #e5e7eb;">{escape(sender_display)}</td>'
f'<td style="padding:8px 10px;border:1px solid #e5e7eb;">{subj}</td>'
f'<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;font-weight:600;">From</th>
<th style="padding:8px 10px;border:1px solid #e5e7eb;text-align:left;font-weight:600;">Subject</th>
<th style="padding:8px 10px;border:1px solid #e5e7eb;text-align:left;font-weight:600;">Time</th>
</tr>
{rows}
</table>
"""
# ── Sender Frequency Section ───────────────────────────────────────
if not senders_for_child:
frequency_section = ""
else:
# Sort by count descending
sorted_senders = sorted(
senders_for_child.items(), key=lambda x: x[1]["count"], reverse=True
)
freq_rows = ""
for sender_email, info in sorted_senders:
name = info.get("name", sender_email)
count = info["count"]
note = ""
bg = ""
if count >= HIGH_VOLUME_THRESHOLD:
note = "⚠️ High volume — potential subscription"
bg = ' style="background:#fef3c7;"'
freq_rows += (
f"<tr{bg}>"
f'<td style="padding:8px 10px;border:1px solid #e5e7eb;">{escape(name)}<br><span style="font-size:11px;color:#999;">{escape(sender_email)}</span></td>'
f'<td style="padding:8px 10px;border:1px solid #e5e7eb;text-align:center;font-weight:600;">{count}</td>'
f'<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;font-weight:600;">Sender</th>
<th style="padding:8px 10px;border:1px solid #e5e7eb;text-align:center;font-weight:600;">Count</th>
<th style="padding:8px 10px;border:1px solid #e5e7eb;text-align:left;font-weight:600;">Note</th>
</tr>
{freq_rows}
</table>
"""
html = 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;">
<!-- Red divider title bar -->
<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>
<!-- Body content -->
<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}
<!-- Circular signature -->
<p style="font-size:12px;color:#888;font-style:italic;margin:0 0 12px;">_{shonuff_closing()}_</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="auto" 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;letter-spacing:-0.2px;">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;">iAmGMB</div>
<table cellpadding="0" cellspacing="0" border="0" style="font-size:12px;color:#999;line-height:1.7;">
<tr><td style="padding-right:4px;vertical-align:top;white-space:nowrap;color:#bbb;">@</td><td><a href="mailto:shonuff@germainebrown.com" style="color:#555;text-decoration:none;border-bottom:1px dotted #ccc;">shonuff@germainebrown.com</a></td></tr>
</table>
</td>
</tr>
</table>
</div>
<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>"""
return html
def build_plain_text(child_name, today_emails, senders_for_child):
"""Build a plain-text fallback version."""
mom_name = MOM_NAMES.get(child_name, "Mom")
lines = [f"{child_name}'s Email Summary", "=" * 40, ""]
lines.append(f"Hey {mom_name},")
lines.append("")
lines.append("Here's what came through today:")
lines.append("")
if not today_emails:
lines.append("✅ No new emails today")
else:
lines.append("📨 New Emails:")
lines.append("-" * 40)
for em in today_emails:
sender = em.get("from_name", em.get("from", "Unknown"))
subj = em.get("subject", "(no subject)")
t = format_time_for_summary(em.get("date", ""))
lines.append(f" From: {sender}")
lines.append(f" Re: {subj}")
lines.append(f" Time: {t}")
lines.append("")
if senders_for_child:
lines.append("📊 Top Senders This Week:")
lines.append("-" * 40)
sorted_senders = sorted(
senders_for_child.items(), key=lambda x: x[1]["count"], reverse=True
)
for sender_email, info in sorted_senders:
name = info.get("name", sender_email)
count = info["count"]
note = " ⚠️ HIGH VOLUME" if count >= HIGH_VOLUME_THRESHOLD else ""
lines.append(f" {name} ({sender_email}): {count}{note}")
lines.append("")
lines.append("--")
lines.append("Sho'Nuff Brown — Operations Engineer")
lines.append("shonuff@germainebrown.com")
lines.append("")
lines.append(f"Reply to adjust frequency, request {child_name}'s login credentials for your phone, or flag senders to unsubscribe.")
lines.append("Webmail: http://webmail.iamgmb.com")
return "\n".join(lines)
def send_email(child_name, today_emails, senders_for_child, to_addr):
"""
Send a daily summary email for a child.
Builds HTML manually (no send-shonuff.py wrapper).
"""
html = build_summary_html(child_name, today_emails, senders_for_child)
plain = build_plain_text(child_name, today_emails, senders_for_child)
subject = f"📬 {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
msg["Bcc"] = BCC_ADDR
msg["Subject"] = subject
msg.attach(MIMEText(plain, "plain"))
msg.attach(MIMEText(html, "html"))
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as s:
s.starttls()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
log.info(
"Summary sent for %s to %s (BCC: %s)",
child_name, to_addr, BCC_ADDR,
)
return True
except Exception as e:
log.error("Failed to send summary for %s: %s", child_name, e)
return False
# ── Summary Generation ─────────────────────────────────────────────────────
def do_summary():
"""Generate and send daily email summaries for both children."""
log.info("=" * 50)
log.info("Boys' Daily Summary Run")
log.info("=" * 50)
mail_log = load_json(MAIL_LOG, [])
senders = load_json(SENDERS_DB, {})
# Get today's date in ET
today = datetime.now(timezone.utc) - timedelta(hours=4) # EDT
today_str = today.strftime("%Y-%m-%d")
log.info("Processing summaries for date: %s", today_str)
log.info("Total mail log entries: %d", len(mail_log))
log.info("Total tracked senders: %d", len(senders))
results = []
for child_name in BOYS_ACCOUNTS:
mom_name = MOM_NAMES.get(child_name, "Mom")
to_addr = RECIPIENTS.get(child_name)
# Filter today's emails for this child
today_emails = [
e for e in mail_log
if e.get("child") == child_name
and parse_email_date(e.get("date", "")).strftime("%Y-%m-%d") == today_str
]
# Filter senders for this child
senders_for_child = {
email_addr: info
for email_addr, info in senders.items()
if info.get("child") == child_name
}
log.info(
"%s: %d emails today, %d tracked senders, sending to %s",
child_name, len(today_emails), len(senders_for_child), to_addr,
)
if to_addr and "@" in to_addr and "example.com" not in to_addr:
success = send_email(child_name, today_emails, senders_for_child, to_addr)
results.append((child_name, to_addr, success, len(today_emails)))
else:
log.warning(
"%s: No valid recipient email configured for %s (%s). "
"Germaine needs to provide it!",
child_name, mom_name, to_addr,
)
# Print the summary to log anyway for verification
log.info(
"Would have sent to %s:\n%s",
to_addr,
build_summary_html(child_name, today_emails, senders_for_child),
)
results.append((child_name, to_addr, False, len(today_emails)))
log.info("Summary run complete: %s", results)
return results
def do_test_summary():
"""Send a test summary to Sho'Nuff himself to verify formatting."""
log.info("=" * 50)
log.info("Boys' Mail — Test Summary (sent to shonuff inbox)")
log.info("=" * 50)
mail_log = load_json(MAIL_LOG, [])
senders = load_json(SENDERS_DB, {})
today = datetime.now(timezone.utc) - timedelta(hours=4)
today_str = today.strftime("%Y-%m-%d")
for child_name in BOYS_ACCOUNTS:
today_emails = [
e for e in mail_log
if e.get("child") == child_name
and parse_email_date(e.get("date", "")).strftime("%Y-%m-%d") == today_str
]
senders_for_child = {
email_addr: info
for email_addr, info in senders.items()
if info.get("child") == child_name
}
# Send test to sho'nuff
subject = f"📬 TEST: {child_name}'s Email Summary — {today.strftime('%b %d')}"
html = build_summary_html(child_name, today_emails, senders_for_child)
plain = build_plain_text(child_name, today_emails, senders_for_child)
msg = MIMEMultipart("alternative")
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM}>"
msg["To"] = SMTP_FROM
msg["Subject"] = subject
msg.attach(MIMEText(plain, "plain"))
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as s:
s.starttls()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
log.info("Test summary for %s sent to %s", child_name, SMTP_FROM)
print("✅ Test summaries sent to shonuff@germainebrown.com — check your inbox.")
# ── Main CLI ───────────────────────────────────────────────────────────────
def main():
os.makedirs(DATA_DIR, exist_ok=True)
if len(sys.argv) < 2:
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
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}")
print("Usage: boys-mail-monitor.py [collect|summary|test-summary]")
if __name__ == "__main__":
main()