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