🌈 Iris — Inbox Intelligence v1.0
⚡ Quick Start
- Go to myaccount.google.com/apppasswords → generate an app password for "Mail"
- Set
GMAIL_ADDRESSandGMAIL_APP_PASSWORD - Run — get your inbox scored, prioritised, and reply drafts ready in under 2 minutes
What you get:
- Every email scored 0–100 by urgency, sender importance, and content signals
- Attachment 📎 and meeting 📅 flags — so action items are never missed
- Fast 2-pass fetch: headers for all emails, full body only for top 5 (handles large inboxes)
- Colour-coded priority table: 🔴 Act Now · 🟠 Today · 🟡 This Week · ⚪ Archive
- Context-aware reply drafts for your top 5 emails based on your name/role
- Unsubscribe candidates: emails from lists you never open
inbox_report_[DATE].mdwith everything organised
🔒 Security
Connects to: imap.gmail.com (your credentials, read-only) · No external services
App passwords only scope to the specific app — revoking them in Google Account settings instantly cuts access.
Step 1 — Install
pip3 install rich --break-system-packages --quiet
Step 2 — Triage your inbox
import os, imaplib, email, re
from email.header import decode_header
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box
console = Console()
GMAIL_ADDR = os.environ.get("GMAIL_ADDRESS", "")
GMAIL_PASS = os.environ.get("GMAIL_APP_PASSWORD", "")
try:
SCAN_COUNT = int(os.environ.get("SCAN_COUNT", "50"))
except ValueError:
console.print("[yellow]⚠️ SCAN_COUNT must be a whole number — defaulting to 50[/yellow]")
SCAN_COUNT = 50
VIP_RAW = os.environ.get("VIP_SENDERS", "")
VIP_LIST = [v.strip().lower() for v in VIP_RAW.split(",") if v.strip()]
YOUR_NAME = os.environ.get("YOUR_NAME", "")
YOUR_ROLE = os.environ.get("YOUR_ROLE", "")
if not GMAIL_ADDR or not GMAIL_PASS:
console.print(Panel(
"[red]GMAIL_ADDRESS and GMAIL_APP_PASSWORD are required.[/red]\n\n"
"Create an app password at: [bold]myaccount.google.com/apppasswords[/bold]\n"
"Choose: Mail → Your computer",
title="Setup Required", border_style="red"))
raise SystemExit(1)
URGENT_KEYWORDS = ["urgent","asap","deadline","immediately","action required","time sensitive",
"overdue","past due","invoice","payment","legal","lawsuit","critical","emergency"]
REPLY_KEYWORDS = ["?","question","can you","could you","please","request","following up","reminder"]
NOISE_PATTERNS = [r"unsubscribe",r"newsletter",r"no-reply",r"noreply",r"marketing@",
r"notifications?@",r"donotreply",r"info@.*\.(com|net)"]
def decode_str(s):
if not s:
return ""
parts = decode_header(s)
result = ""
for part, enc in parts:
if isinstance(part, bytes):
try: result += part.decode(enc or "utf-8", errors="replace")
except Exception: result += part.decode("latin-1", errors="replace")
else:
result += str(part)
return result.strip()
MEETING_KEYWORDS = ["calendar invite","meeting request","zoom","teams meeting","google meet",
"webex","invited you","join the meeting","scheduled a","let's meet","video call"]
def score_email(subject, sender, body_snippet, age_hours, has_attachment=False):
score = 50
subj_lower = subject.lower()
body_lower = body_snippet.lower()
sender_lower = sender.lower()
# Noise penalty applied first — VIPs can still overcome it
is_noise = any(re.search(p, sender_lower) for p in NOISE_PATTERNS)
if is_noise:
score -= 40
# VIP sender
if any(vip in sender_lower for vip in VIP_LIST):
score += 40
# Urgency keywords in subject
for kw in URGENT_KEYWORDS:
if kw in subj_lower:
score += 15
break
# Reply signals
if any(kw in subj_lower or kw in body_lower for kw in REPLY_KEYWORDS):
score += 10
# Meeting / calendar invite
if any(kw in subj_lower or kw in body_lower for kw in MEETING_KEYWORDS):
score += 20
# Has attachment — likely needs action
if has_attachment:
score += 15
# Freshness
if age_hours < 2: score += 20
elif age_hours < 8: score += 10
elif age_hours > 48: score -= 10
# RE: or FWD: (thread, likely needs response)
if re.match(r"^(re:|fwd?:)", subj_lower):
score += 5
return max(0, min(100, score))
def get_priority(score):
if score >= 80: return ("🔴", "Act Now", "red")
if score >= 60: return ("🟠", "Today", "orange3")
if score >= 40: return ("🟡", "This Week", "yellow")
return ("⚪", "Archive", "dim")
console.print(Panel("[bold cyan]📧 🌈 Iris — Inbox Intelligence v1.0[/bold cyan]\nConnecting to Gmail...", border_style="cyan"))
try:
mail = imaplib.IMAP4_SSL("imap.gmail.com")
mail.login(GMAIL_ADDR, GMAIL_PASS)
mail.select("inbox")
except Exception as e:
console.print(Panel(f"[red]IMAP login failed: {e}[/red]\nCheck GMAIL_ADDRESS and GMAIL_APP_PASSWORD.", title="Login Error", border_style="red"))
raise SystemExit(1)
_, msg_ids = mail.search(None, "ALL")
all_ids = msg_ids[0].split() if msg_ids and msg_ids[0] else []
if not all_ids:
console.print(Panel("[yellow]Inbox is empty — nothing to triage.[/yellow]", border_style="yellow"))
raise SystemExit(0)
recent_ids = all_ids[-SCAN_COUNT:] if len(all_ids) > SCAN_COUNT else all_ids
recent_ids = list(reversed(recent_ids))
console.print(f"[dim]Scanning {len(recent_ids)} emails...[/dim]")
# Pass 1: fetch headers only (fast) — score every email
emails_data = []
for eid in recent_ids:
try:
_, data = mail.fetch(eid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])")
raw_header = data[0][1]
msg = email.message_from_bytes(raw_header)
subject = decode_str(msg.get("Subject", "(no subject)"))
sender = decode_str(msg.get("From", ""))
date_hdr = msg.get("Date", "")
has_attachment = False # unknown at header stage; updated in pass 2 for top5
try:
dt = parsedate_to_datetime(date_hdr)
age_h = (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 3600
except Exception:
age_h = 24
score = score_email(subject, sender, "", age_h, has_attachment)
icon, priority, color = get_priority(score)
emails_data.append({
"eid": eid, "subject": subject[:80], "sender": sender[:50],
"score": score, "priority": priority, "icon": icon, "color": color,
"age_hours": round(age_h, 1), "snippet": "", "is_noise": score < 20,
})
except Exception:
continue
emails_data.sort(key=lambda x: x["score"], reverse=True)
# Pass 2: fetch full RFC822 only for top 5 non-noise — get body + attachments for drafts
top5_ids = [e["eid"] for e in emails_data if not e["is_noise"]][:5]
eid_to_idx = {e["eid"]: i for i, e in enumerate(emails_data)}
for eid in top5_ids:
try:
_, data = mail.fetch(eid, "(RFC822)")
raw = data[0][1]
msg = email.message_from_bytes(raw)
body_snippet = ""
has_attachment = False
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if "attachment" in cd:
has_attachment = True
if ct == "text/plain" and "attachment" not in cd and not body_snippet:
try:
body_snippet = part.get_payload(decode=True).decode("utf-8", errors="replace")[:300]
except Exception:
pass
idx = eid_to_idx.get(eid)
if idx is not None:
e = emails_data[idx]
e["snippet"] = body_snippet[:150]
e["has_attachment"] = has_attachment
# Re-score with body + attachment info
new_score = score_email(e["subject"], e["sender"], body_snippet, e["age_hours"], has_attachment)
e["score"] = new_score
e["icon"], e["priority"], e["color"] = get_priority(new_score)
e["is_noise"] = new_score < 20
except Exception:
continue
mail.logout()
emails_data.sort(key=lambda x: x["score"], reverse=True)
# Rich table
table = Table(title=f"📧 🌈 Iris — Inbox Intelligence — {len(emails_data)} emails scanned", box=box.ROUNDED, border_style="cyan")
table.add_column("Priority", width=12)
table.add_column("Score", width=6, justify="center")
table.add_column("Subject", width=45)
table.add_column("From", width=28)
table.add_column("Age", width=6, justify="center")
table.add_column("Flags", width=6)
for e in emails_data[:20]:
flags = ""
if e.get("has_attachment"): flags += "📎"
if any(k in e["subject"].lower() or k in e["snippet"].lower() for k in ["zoom","meet","calendar invite","meeting"]): flags += "📅"
table.add_row(
f"[{e['color']}]{e['icon']} {e['priority']}[/{e['color']}]",
str(e["score"]), e["subject"], e["sender"], f"{e['age_hours']:.0f}h", flags)
console.print(table)
# Draft replies for top 5 — contextual openers based on subject signals
top5 = [e for e in emails_data if not e["is_noise"]][:5]
drafts = []
URGENT_KW = ["urgent","asap","deadline","overdue","invoice","payment","action required"]
QUESTION_KW = ["?","can you","could you","please advise","let me know","following up","reminder"]
for e in top5:
greeting = "Hi there," # YOUR_NAME is for the signature, not the greeting
role_line = f"\n{YOUR_ROLE}" if YOUR_ROLE else ""
sig = f"\n\nBest,\n{YOUR_NAME}{role_line}" if YOUR_NAME else ""
subj_low = e["subject"].lower()
snip_low = e["snippet"].lower()
if any(k in subj_low for k in URGENT_KW):
body = "Thank you for flagging this — I'll look into it right away and get back to you shortly."
elif any(k in subj_low or k in snip_low for k in QUESTION_KW):
body = "Thanks for your message. To answer your question: [your answer here]\n\nLet me know if you need anything else."
elif "re:" in subj_low or "fwd:" in subj_low:
body = "Thanks for the follow-up. Here's where things stand: [brief update]\n\nHappy to jump on a call if that's easier."
else:
body = "Thanks for reaching out. I've reviewed your message and [your response here]."
draft = f"{greeting}\n\n{body}{sig}"
drafts.append({"subject": e["subject"], "draft": draft})
noise = [e for e in emails_data if e["is_noise"]]
# Save report
date_str = datetime.now().strftime("%Y-%m-%d")
report_file = f"inbox_report_{date_str}.md"
with open(report_file, "w", encoding="utf-8") as f:
f.write(f"# 🌈 Iris — Inbox Intelligence — {date_str}\n\n")
f.write(f"**Scanned:** {len(emails_data)} emails · **Act Now:** {len([e for e in emails_data if e['priority']=='Act Now'])} · **Unsubscribe candidates:** {len(noise)}\n\n---\n\n")
f.write("## 🔴 Action Required\n\n")
for e in [x for x in emails_data if x["priority"] == "Act Now"][:10]:
f.write(f"### {e['subject']}\n**From:** {e['sender']} · **Score:** {e['score']} · **Age:** {e['age_hours']:.0f}h\n\n{e['snippet']}\n\n---\n\n")
f.write("## ✉️ Reply Drafts\n\n")
if drafts:
for d in drafts:
f.write(f"### Re: {d['subject']}\n\n{d['draft']}\n\n---\n\n")
else:
f.write("_No reply drafts generated — top emails were all newsletters or noise. Check Unsubscribe Candidates below._\n\n")
f.write("## 🗑️ Unsubscribe Candidates\n\n")
for e in noise[:15]:
f.write(f"- {e['sender']} — {e['subject']}\n")
act_now = len([e for e in emails_data if e["priority"] == "Act Now"])
console.print(Panel(
f"[bold green]✅ Inbox triaged![/bold green]\n\n"
f"[red bold]🔴 {act_now} Act Now[/red bold]\n"
f"[orange3]{len([e for e in emails_data if e['priority']=='Today'])} Today[/orange3]\n"
f"[yellow]{len([e for e in emails_data if e['priority']=='This Week'])} This Week[/yellow]\n"
f"[dim]{len(noise)} Unsubscribe candidates[/dim]\n\n"
f"📝 Full report: [cyan]{report_file}[/cyan]\n\n"
f"[bold]What to do next:[/bold]\n"
f" 1. Handle all 🔴 Act Now emails first\n"
f" 2. Use the reply drafts as starting points\n"
f" 3. Unsubscribe from the noise senders\n"
f" 4. Run daily for a permanently clean inbox",
title="📧 Triage Complete", border_style="green"))