#!/usr/bin/env python3
"""
Harvey Email Hub — Newsletter Digest + Inbox Triage
Port 8766
  GET  /           — Newsletter digest
  POST /generate   — Re-fetch newsletters
  POST /action     — Archive/delete a newsletter
  POST /unsubscribe— Unsubscribe + archive
  GET  /inbox      — Full inbox triage
  POST /inbox/scan — Scan + categorize all unread (background)
  GET  /inbox/progress — Scan progress (JSON)
  POST /inbox/action   — Archive/delete inbox email
  POST /inbox/bulk     — Bulk action (archive/delete) by category or UIDs
  POST /inbox/draft    — AI-draft a reply for an email
  POST /inbox/reply    — Send a reply via Gmail SMTP
"""

import imaplib
import email
import smtplib
from email.header import decode_header
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import json
import re
import html
import urllib.request
import urllib.parse
from datetime import datetime
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading
import time

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
GMAIL_USER  = "mikeziarko@gmail.com"
SECRETS_DIR = Path.home() / ".openclaw/secrets"
WORKSPACE   = Path.home() / ".openclaw/workspace"
NL_STATE    = WORKSPACE / "memory" / "newsletter-state.json"
IX_STATE    = WORKSPACE / "memory" / "inbox-state.json"
PORT        = 8766

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_secret(name):
    return (SECRETS_DIR / name).read_text().strip()

def decode_str(s):
    if not s:
        return ""
    parts = decode_header(s)
    out = ""
    for part, charset in parts:
        if isinstance(part, bytes):
            out += part.decode(charset or "utf-8", errors="replace")
        else:
            out += part
    return out

def extract_email_addr(field):
    m = re.search(r'<([^>]+)>', field)
    return m.group(1) if m else field.strip()

def strip_html(raw):
    raw = re.sub(r'<style[^>]*>.*?</style>', ' ', raw, flags=re.DOTALL)
    raw = re.sub(r'<script[^>]*>.*?</script>', ' ', raw, flags=re.DOTALL)
    raw = re.sub(r'<[^>]+>', ' ', raw)
    for ent, ch in [('&nbsp;',' '),('&amp;','&'),('&lt;','<'),('&gt;','>'),('&#39;',"'"),('&quot;','"')]:
        raw = raw.replace(ent, ch)
    return re.sub(r'\s+', ' ', raw).strip()

def get_body(msg):
    text = ""
    if msg.is_multipart():
        for part in msg.walk():
            ct  = part.get_content_type()
            cd  = str(part.get("Content-Disposition", ""))
            if "attachment" in cd:
                continue
            if ct == "text/plain":
                try:
                    payload = part.get_payload(decode=True)
                    text += payload.decode(part.get_content_charset() or "utf-8", errors="replace")
                except: pass
            elif ct == "text/html" and not text:
                try:
                    payload = part.get_payload(decode=True)
                    text += strip_html(payload.decode(part.get_content_charset() or "utf-8", errors="replace"))
                except: pass
    else:
        try:
            payload = msg.get_payload(decode=True)
            text = payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
        except:
            text = str(msg.get_payload())
    return text[:5000].strip()

def claude(prompt, max_tokens=400, model="claude-haiku-4-5"):
    api_key = get_secret("anthropic-api-key.txt")
    payload = json.dumps({
        "model": model,
        "max_tokens": max_tokens,
        "messages": [{"role": "user", "content": prompt}]
    }).encode()
    req = urllib.request.Request(
        "https://api.anthropic.com/v1/messages",
        data=payload,
        headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
    )
    with urllib.request.urlopen(req, timeout=20) as r:
        return json.loads(r.read())["content"][0]["text"].strip()

def get_imap():
    imap = imaplib.IMAP4_SSL("imap.gmail.com")
    imap.login(GMAIL_USER, get_secret("gmail-app-password.txt"))
    return imap

def imap_op(uid, action):
    if isinstance(uid, bytes): uid = uid.decode()
    imap = get_imap()
    imap.select("INBOX")
    if action == "archive":
        imap.uid('COPY',  uid, '"[Gmail]/All Mail"')
        imap.uid('STORE', uid, '+FLAGS', '\\Deleted')
    elif action == "delete":
        imap.uid('STORE', uid, '+FLAGS', '\\Deleted')
    imap.expunge()
    imap.logout()

def send_email(to_addr, subject, body, in_reply_to=None):
    msg = MIMEMultipart()
    msg["From"]    = GMAIL_USER
    msg["To"]      = to_addr
    msg["Subject"] = subject
    if in_reply_to:
        msg["In-Reply-To"] = in_reply_to
        msg["References"]  = in_reply_to
    msg.attach(MIMEText(body, "plain"))
    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
        smtp.login(GMAIL_USER, get_secret("gmail-app-password.txt"))
        smtp.sendmail(GMAIL_USER, to_addr, msg.as_string())

# ---------------------------------------------------------------------------
# Newsletter state
# ---------------------------------------------------------------------------
def nl_load():
    if NL_STATE.exists():
        return json.loads(NL_STATE.read_text())
    return {"newsletters": [], "generated_at": None}

def nl_save(data):
    NL_STATE.parent.mkdir(exist_ok=True)
    NL_STATE.write_text(json.dumps(data, indent=2))

# ---------------------------------------------------------------------------
# Inbox state
# ---------------------------------------------------------------------------
_scan_progress = {"running": False, "done": 0, "total": 0, "status": "idle"}

def ix_load():
    if IX_STATE.exists():
        return json.loads(IX_STATE.read_text())
    return {"emails": [], "scanned_at": None}

def ix_save(data):
    IX_STATE.parent.mkdir(exist_ok=True)
    IX_STATE.write_text(json.dumps(data, indent=2))

# ---------------------------------------------------------------------------
# Newsletter: fetch + summarize
# ---------------------------------------------------------------------------
def nl_fetch(limit=20):
    imap = get_imap()
    imap.select("INBOX")
    _, msgs = imap.uid('SEARCH', None, 'UNSEEN')
    all_uids = msgs[0].split()
    newsletters, checked = [], 0
    for uid in reversed(all_uids):
        if len(newsletters) >= limit: break
        checked += 1
        if checked > 200: break
        _, data = imap.uid('FETCH', uid, '(BODY.PEEK[HEADER])')
        msg = email.message_from_bytes(data[0][1])
        if not (msg.get("List-Unsubscribe") or msg.get("List-ID")):
            continue
        newsletters.append({
            "uid":         uid.decode(),
            "sender":      decode_str(msg.get("From", "")),
            "subject":     decode_str(msg.get("Subject", "(no subject)")),
            "unsub_header":msg.get("List-Unsubscribe", ""),
            "unsub_post":  msg.get("List-Unsubscribe-Post", ""),
            "summary":     None, "body": None
        })
    for n in newsletters:
        _, data = imap.uid('FETCH', n["uid"].encode(), '(RFC822)')
        full = email.message_from_bytes(data[0][1])
        n["body"]    = get_body(full)
        n["summary"] = claude(
            f"Summarize this newsletter in 4-6 sentences. Be direct and specific -- key topics, insights, actionable points. Skip filler.\n\nFrom: {n['sender']}\nSubject: {n['subject']}\n\nBody:\n{n['body']}\n\nSummary:"
        )
    imap.logout()
    return newsletters

# ---------------------------------------------------------------------------
# Newsletter: unsubscribe
# ---------------------------------------------------------------------------
def nl_unsubscribe(n):
    header = n.get("unsub_header", "")
    post_h = n.get("unsub_post", "")
    urls    = re.findall(r'<(https?://[^>]+)>', header)
    mailtos = re.findall(r'<(mailto:[^>]+)>', header)

    if urls and "One-Click" in post_h:
        req = urllib.request.Request(urls[0], data=b"List-Unsubscribe=One-Click",
              headers={"Content-Type": "application/x-www-form-urlencoded"})
        req.get_method = lambda: "POST"
        try:
            urllib.request.urlopen(req, timeout=10)
            return f"One-click unsubscribe sent"
        except: pass

    if urls:
        try:
            urllib.request.urlopen(urllib.request.Request(urls[0], headers={"User-Agent":"Mozilla/5.0"}), timeout=10)
            return f"Unsubscribe link visited"
        except Exception as e:
            return f"URL unsubscribe error: {e}"

    if mailtos:
        to   = mailtos[0].replace("mailto:", "").split("?")[0]
        subj_m = re.search(r'subject=([^&]+)', mailtos[0])
        subj = urllib.parse.unquote(subj_m.group(1)) if subj_m else "Unsubscribe"
        try:
            send_email(to, subj, "")
            return f"Unsubscribe email sent to {to}"
        except Exception as e:
            return f"Unsubscribe email failed: {e}"

    return "No unsubscribe method found"

# ---------------------------------------------------------------------------
# Inbox: batch categorize with Claude
# ---------------------------------------------------------------------------
CATEGORIES = ("action", "read", "receipt", "newsletter", "junk")

def categorize_batch(emails_meta):
    """Given list of {uid, sender, subject, snippet}, return {uid: category}."""
    lines = "\n".join(
        f'{i+1}. From: {e["sender"][:60]} | Subject: {e["subject"][:80]} | Preview: {e["snippet"][:120]}'
        for i, e in enumerate(emails_meta)
    )
    prompt = f"""Categorize each email below into exactly one category.

Categories:
- action   : Needs a reply or decision from the business owner (customer complaints, team questions, vendor issues, anything requiring a response)
- read     : Worth reading, informational, no reply needed (Google alerts, business updates, notifications)
- receipt  : Payment confirmations, invoices, order confirmations, subscription receipts
- newsletter: Marketing emails, newsletters, promotional content with List-Unsubscribe headers
- junk     : Spam, irrelevant promotions, cold outreach, sweepstakes, anything safe to delete

Reply ONLY with a JSON object mapping line number (as string) to category name.
Example: {{"1":"action","2":"receipt","3":"junk"}}

Emails:
{lines}

JSON:"""
    try:
        result = claude(prompt, max_tokens=600)
        # Extract JSON from response
        m = re.search(r'\{[^{}]+\}', result, re.DOTALL)
        if m:
            return json.loads(m.group())
    except Exception as e:
        print(f"Categorize error: {e}")
    return {}

# ---------------------------------------------------------------------------
# Inbox: full scan
# ---------------------------------------------------------------------------
def ix_scan():
    global _scan_progress
    _scan_progress = {"running": True, "done": 0, "total": 0, "status": "Connecting to Gmail..."}
    try:
        imap = get_imap()
        imap.select("INBOX")
        _, msgs = imap.uid('SEARCH', None, 'UNSEEN')
        all_uids = msgs[0].split()
        total = len(all_uids)
        _scan_progress["total"]  = total
        _scan_progress["status"] = f"Fetching {total} emails..."

        # Fetch headers for all emails
        emails = []
        for uid in reversed(all_uids):  # newest first
            _, data = imap.uid('FETCH', uid, '(BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0.300>)')
            raw_header = data[0][1] if isinstance(data[0], tuple) else b""
            msg = email.message_from_bytes(raw_header)
            sender  = decode_str(msg.get("From", ""))
            subject = decode_str(msg.get("Subject", "(no subject)"))
            date    = msg.get("Date", "")
            unsub   = msg.get("List-Unsubscribe", "")
            unsub_p = msg.get("List-Unsubscribe-Post", "")
            msg_id  = msg.get("Message-ID", "")
            # Snippet from partial body
            try:
                snippet_raw = data[1][1] if len(data) > 1 and isinstance(data[1], tuple) else b""
                snippet = snippet_raw.decode("utf-8", errors="replace")[:200].replace("\n", " ").strip()
            except:
                snippet = ""
            emails.append({
                "uid":         uid.decode(),
                "sender":      sender,
                "subject":     subject,
                "date":        date,
                "snippet":     snippet,
                "unsub_header":unsub,
                "unsub_post":  unsub_p,
                "message_id":  msg_id,
                "category":    None,
                "summary":     None,
                "body":        None,
                "draft":       None
            })

        imap.logout()

        # Categorize in batches of 20
        BATCH = 20
        _scan_progress["status"] = "Categorizing with AI..."
        for i in range(0, len(emails), BATCH):
            batch = emails[i:i+BATCH]
            mapping = categorize_batch(batch)
            for j, e in enumerate(batch):
                cat = mapping.get(str(j+1), "read")
                if cat not in CATEGORIES: cat = "read"
                e["category"] = cat
            _scan_progress["done"] = min(i + BATCH, total)
            _scan_progress["status"] = f"Categorized {_scan_progress['done']}/{total}..."

        # Summarize Action emails (most important to read in full)
        action_emails = [e for e in emails if e["category"] == "action"]
        _scan_progress["status"] = f"Summarizing {len(action_emails)} action items..."
        if action_emails:
            imap = get_imap()
            imap.select("INBOX")
            for e in action_emails:
                try:
                    _, data = imap.uid('FETCH', e["uid"].encode(), '(RFC822)')
                    full = email.message_from_bytes(data[0][1])
                    e["body"] = get_body(full)
                    e["summary"] = claude(
                        f"Summarize this email in 2-3 sentences. What does it need from the recipient?\n\nFrom: {e['sender']}\nSubject: {e['subject']}\n\nBody:\n{e['body']}\n\nSummary:"
                    )
                except Exception as ex:
                    e["summary"] = e["snippet"]
            imap.logout()

        ix_save({"emails": emails, "scanned_at": datetime.now().isoformat()})
        _scan_progress = {"running": False, "done": total, "total": total, "status": "done"}
    except Exception as ex:
        _scan_progress = {"running": False, "done": 0, "total": 0, "status": f"Error: {ex}"}
        print(f"Scan error: {ex}")

# ---------------------------------------------------------------------------
# Newsletter page HTML
# ---------------------------------------------------------------------------
def render_newsletter_page(newsletters, generated_at, loading=False):
    ts = ""
    if generated_at:
        try:
            dt = datetime.fromisoformat(generated_at)
            ts = dt.strftime("%A, %B %-d at %-I:%M %p")
        except: ts = generated_at

    visible = [n for n in newsletters if not n.get("archived") and not n.get("deleted")]
    items_html = ""
    for i, n in enumerate(visible, 1):
        uid    = html.escape(n["uid"])
        sender = html.escape(n["sender"])
        subj   = html.escape(n["subject"])
        summ   = html.escape(n.get("summary") or "")
        body_e = html.escape(n.get("body") or "").replace("\n", "<br>")
        se     = extract_email_addr(n["sender"])
        glink  = f"https://mail.google.com/mail/u/0/#search/{urllib.parse.quote('from:'+se)}"
        items_html += f"""
        <div class="card" id="card-{uid}" data-uid="{uid}">
          <div class="card-top">
            <span class="num">{i}</span>
            <div class="meta"><div class="subj">{subj}</div><div class="from">{sender}</div></div>
          </div>
          <div class="summ">{summ}</div>
          <div class="body-wrap" id="body-{uid}" style="display:none"><div class="body-txt">{body_e[:3000]}</div></div>
          <div class="actions">
            <button class="btn expand-btn" onclick="toggleBody('{uid}')">Read more</button>
            <a class="btn gmail-btn" href="{glink}" target="_blank">Open in Gmail</a>
            <button class="btn arc-btn" onclick="doAction('{uid}','archive',this)">Archive</button>
            <button class="btn unsub-btn" onclick="doUnsub('{uid}',this)">Unsubscribe</button>
            <button class="btn del-btn" onclick="doAction('{uid}','delete',this)">Delete</button>
          </div>
        </div>"""

    load_bar = '<div class="info-bar blue">Generating digest, please wait...</div>' if loading else ""

    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Newsletter Digest</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f0f0f5;color:#222}}
.topbar{{background:#1a1a2e;color:white;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}}
.topbar h1{{font-size:17px;font-weight:700}}.topbar .sub{{font-size:12px;color:#aaa;margin-top:2px}}
.topbar-r{{display:flex;gap:8px}}
.tb{{background:rgba(255,255,255,.14);border:none;color:white;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:13px}}
.tb:hover{{background:rgba(255,255,255,.24)}}
.tb.gold{{background:#e9a825;font-weight:600}}.tb.gold:hover{{background:#d4941e}}
.nav-link{{background:rgba(255,255,255,.14);border:none;color:white;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:13px;text-decoration:none;display:inline-block}}
.nav-link:hover{{background:rgba(255,255,255,.24)}}
.info-bar{{text-align:center;padding:10px;font-size:13px}}
.info-bar.blue{{background:#dbeafe;color:#1e40af}}
.wrap{{max-width:760px;margin:0 auto;padding:18px 16px}}
.card{{background:white;border-radius:12px;padding:16px 18px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06);transition:opacity .3s}}
.card.done{{opacity:.25;pointer-events:none}}
.card-top{{display:flex;gap:12px;align-items:flex-start;margin-bottom:8px}}
.num{{background:#1a1a2e;color:white;border-radius:50%;width:25px;height:25px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;margin-top:2px}}
.subj{{font-weight:600;font-size:14px;line-height:1.3}}.from{{font-size:12px;color:#888;margin-top:2px}}
.summ{{font-size:13px;color:#444;line-height:1.6;padding-left:37px;margin-bottom:10px}}
.body-wrap{{padding-left:37px;margin-bottom:10px}}
.body-txt{{font-size:12px;color:#555;line-height:1.7;max-height:260px;overflow-y:auto;background:#f8f8f8;padding:10px;border-radius:8px;white-space:pre-wrap;word-break:break-word}}
.actions{{display:flex;gap:7px;padding-left:37px;flex-wrap:wrap}}
.btn{{border:none;padding:6px 13px;border-radius:6px;font-size:12px;cursor:pointer;font-weight:500;text-decoration:none;display:inline-block}}
.expand-btn{{background:#f0f0f0;color:#333}}.expand-btn:hover{{background:#e0e0e0}}
.gmail-btn{{background:#4285f4;color:white}}.gmail-btn:hover{{background:#3367d6}}
.arc-btn{{background:#f5a623;color:white}}.arc-btn:hover{{background:#d4941e}}
.unsub-btn{{background:#7c3aed;color:white}}.unsub-btn:hover{{background:#6d28d9}}
.del-btn{{background:#ef4444;color:white}}.del-btn:hover{{background:#dc2626}}
.empty{{text-align:center;color:#888;padding:50px 20px;font-size:15px}}
</style></head><body>
<div class="topbar">
  <div><h1>📰 Newsletter Digest</h1><div class="sub">{len(visible)} newsletters &bull; {ts}</div></div>
  <div class="topbar-r">
    <a class="nav-link" href="/inbox">📬 Inbox Triage</a>
    <button class="tb gold" onclick="archiveAll()">Archive All</button>
    <button class="tb" onclick="doRefresh()">Refresh</button>
  </div>
</div>
{load_bar}
<div class="wrap">
{"items_html" if visible else '<div class="empty">No unread newsletters. All clear.</div>'}
</div>
<script>
const ITEMS_HTML = `{items_html}`;
document.querySelector('.wrap').innerHTML = ITEMS_HTML || '<div class="empty">No unread newsletters. All clear.</div>';

function toggleBody(uid){{
  const el=document.getElementById('body-'+uid);
  const btn=document.getElementById('card-'+uid).querySelector('.expand-btn');
  el.style.display=el.style.display==='none'?'block':'none';
  btn.textContent=el.style.display==='none'?'Read more':'Show less';
}}
async function doAction(uid,action,btn){{
  btn.disabled=true;btn.textContent=action==='archive'?'Archiving...':'Deleting...';
  const r=await fetch('/action',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{uid,action}})}});
  if(r.ok){{const c=document.getElementById('card-'+uid);c.classList.add('done');setTimeout(()=>c.style.display='none',400);}}
  else{{btn.textContent='Error';btn.disabled=false;}}
}}
async function archiveAll(){{
  for(const c of document.querySelectorAll('.card:not(.done)')){{
    const uid=c.dataset.uid;
    await doAction(uid,'archive',c.querySelector('.arc-btn'));
    await new Promise(r=>setTimeout(r,150));
  }}
}}
async function doUnsub(uid,btn){{
  if(!confirm('Unsubscribe and archive?'))return;
  btn.disabled=true;btn.textContent='Working...';
  const r=await fetch('/unsubscribe',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{uid}})}});
  const d=await r.json();
  if(r.ok){{btn.textContent='Done!';const c=document.getElementById('card-'+uid);setTimeout(()=>{{c.classList.add('done');setTimeout(()=>c.style.display='none',400);}},600);}}
  else{{btn.textContent=d.error||'Error';btn.disabled=false;}}
}}
function doRefresh(){{
  if(!confirm('Fetch fresh newsletters? Takes ~1 min.'))return;
  fetch('/generate',{{method:'POST'}}).then(()=>setTimeout(()=>location.reload(),500));
}}
{'setTimeout(()=>location.reload(),5000);' if loading else ''}
</script></body></html>"""

# ---------------------------------------------------------------------------
# Inbox triage page HTML
# ---------------------------------------------------------------------------
def render_inbox_page(state, progress):
    emails      = state.get("emails", [])
    scanned_at  = state.get("scanned_at")
    ts = ""
    if scanned_at:
        try: ts = datetime.fromisoformat(scanned_at).strftime("%A, %B %-d at %-I:%M %p")
        except: ts = scanned_at

    # Count by category
    counts = {c: 0 for c in CATEGORIES}
    for e in emails:
        if not e.get("archived") and not e.get("deleted"):
            cat = e.get("category","read")
            if cat in counts: counts[cat] += 1

    total_visible = sum(counts.values())
    is_scanning   = progress.get("running", False)
    scan_pct      = int(100 * progress.get("done",0) / max(progress.get("total",1),1))

    # Build cards per category
    def cards_for(cat):
        out = ""
        cat_emails = [e for e in emails if e.get("category") == cat and not e.get("archived") and not e.get("deleted")]
        for e in cat_emails:
            uid    = html.escape(e["uid"])
            sender = html.escape(e["sender"])
            subj   = html.escape(e["subject"])
            date   = html.escape(e.get("date",""))
            summ   = html.escape(e.get("summary") or e.get("snippet",""))
            body_e = html.escape(e.get("body") or "").replace("\n","<br>")
            msg_id = html.escape(e.get("message_id",""))
            se     = extract_email_addr(e["sender"])
            glink  = f"https://mail.google.com/mail/u/0/#search/{urllib.parse.quote('from:'+se)}"

            reply_btn = f'<button class="btn reply-btn" onclick="openReply(\'{uid}\',\'{html.escape(se)}\',\'{html.escape(e["subject"])}\',\'{msg_id}\')">↩ Reply</button>' if cat == "action" else ""
            unsub_btn = f'<button class="btn unsub-btn" onclick="doUnsub(\'{uid}\',this)">Unsubscribe</button>' if e.get("unsub_header") else ""

            out += f"""
            <div class="card" id="card-{uid}" data-uid="{uid}">
              <div class="card-top" onclick="toggleBody('{uid}')">
                <input type="checkbox" class="chk" data-uid="{uid}" onclick="event.stopPropagation()">
                <div class="card-info">
                  <div class="card-row1">
                    <span class="pill pill-{cat}">{cat}</span>
                    <span class="sender">{sender}</span>
                    <span class="date">{date[:16]}</span>
                  </div>
                  <div class="subj">{subj}</div>
                  <div class="summ">{summ}</div>
                </div>
              </div>
              <div class="body-wrap" id="body-{uid}" style="display:none">
                <div class="body-txt">{body_e[:3000] or "Click Fetch Body to load full email."}</div>
              </div>
              <div class="reply-drawer" id="reply-{uid}" style="display:none">
                <div class="reply-to-line">Replying to <strong>{html.escape(se)}</strong></div>
                <textarea class="reply-ta" id="rta-{uid}" placeholder="Harvey is drafting a reply..."></textarea>
                <div class="reply-acts">
                  <button class="btn send-btn" onclick="sendReply('{uid}','{html.escape(se)}','{html.escape(e['subject'])}','{msg_id}')">Send</button>
                  <button class="btn cancel-btn" onclick="closeReply('{uid}')">Cancel</button>
                </div>
                <div class="ai-note">✨ Harvey drafted this — review before sending</div>
              </div>
              <div class="actions">
                {reply_btn}
                <a class="btn gmail-btn" href="{glink}" target="_blank">Open in Gmail</a>
                <button class="btn arc-btn" onclick="doAction('{uid}','archive',this)">Archive</button>
                {unsub_btn}
                <button class="btn del-btn" onclick="doAction('{uid}','delete',this)">Delete</button>
              </div>
            </div>"""
        if not cat_emails:
            out = '<div class="empty-cat">Nothing here.</div>'
        return out

    tabs_html = ""
    labels = {"action":"🔴 Action","read":"📖 Read","receipt":"💰 Receipts","newsletter":"📰 Newsletters","junk":"🗑️ Junk"}
    for cat in CATEGORIES:
        active = "active" if cat == "action" else ""
        tabs_html += f'<div class="tab {cat} {active}" onclick="switchTab(\'{cat}\',this)">{labels[cat]} <span class="badge badge-{cat}">{counts[cat]}</span></div>'

    content_html = ""
    for cat in CATEGORIES:
        display = "block" if cat == "action" else "none"
        bulk = ""
        if cat == "receipt":
            bulk = f'<div class="bulk-tip green">💡 {counts["receipt"]} receipts — safe to archive all. <button class="btn arc-btn" onclick="bulkCat(\'receipt\',\'archive\')">Archive all receipts</button></div>'
        elif cat == "junk":
            bulk = f'<div class="bulk-tip gray">💡 {counts["junk"]} junk emails — safe to delete all. <button class="btn del-btn" onclick="bulkCat(\'junk\',\'delete\')">Delete all junk</button></div>'
        elif cat == "newsletter":
            bulk = f'<div class="bulk-tip gold">💡 {counts["newsletter"]} newsletters — send to digest or archive all. <span style="display:flex;gap:8px;margin-left:auto"><a class="btn digest-btn" href="/">Open digest</a><button class="btn arc-btn" onclick="bulkCat(\'newsletter\',\'archive\')">Archive all</button></span></div>'
        content_html += f'<div class="tab-pane" id="pane-{cat}" style="display:{display}">{bulk}{cards_for(cat)}</div>'

    scan_bar = ""
    if is_scanning:
        scan_bar = f'<div class="scan-bar"><div class="scan-fill" style="width:{scan_pct}%"></div></div><div class="scan-status">{html.escape(progress.get("status",""))}</div>'

    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Inbox Triage</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f0f0f5;color:#222}}
.topbar{{background:#1a1a2e;color:white;padding:14px 22px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}}
.topbar h1{{font-size:17px;font-weight:700}}.topbar .sub{{font-size:12px;color:#aaa;margin-top:2px}}
.topbar-r{{display:flex;gap:8px}}
.tb{{background:rgba(255,255,255,.14);border:none;color:white;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:13px;text-decoration:none;display:inline-block}}
.tb:hover{{background:rgba(255,255,255,.24)}}.tb.red{{background:#dc2626}}.tb.red:hover{{background:#b91c1c}}
.scan-bar{{height:4px;background:#e0e7ff}}.scan-fill{{height:4px;background:#6366f1;transition:width .5s}}
.scan-status{{text-align:center;font-size:12px;color:#666;padding:6px;background:#eef2ff}}
.tabs{{background:white;border-bottom:1px solid #e5e5e5;padding:0 20px;display:flex;gap:2px;overflow-x:auto;position:sticky;top:53px;z-index:90}}
.tab{{padding:12px 14px;font-size:13px;font-weight:500;color:#666;cursor:pointer;border-bottom:3px solid transparent;white-space:nowrap;display:flex;align-items:center;gap:5px}}
.tab:hover{{color:#333}}.tab.active{{color:#1a1a2e;border-bottom-color:#1a1a2e;font-weight:700}}
.badge{{font-size:10px;font-weight:700;border-radius:10px;padding:1px 6px;color:white}}
.badge-action{{background:#ef4444}}.badge-read{{background:#3b82f6}}.badge-receipt{{background:#10b981}}
.badge-newsletter{{background:#f59e0b}}.badge-junk{{background:#9ca3af}}
.bulk-bar{{background:#f8f8ff;border-bottom:1px solid #e5e5e5;padding:8px 20px;display:flex;align-items:center;gap:10px;font-size:13px}}
.bulk-tip{{display:flex;align-items:center;gap:10px;padding:10px 16px;font-size:13px;font-weight:500;border-radius:0;margin:-0px}}
.bulk-tip.green{{background:#d1fae5;color:#065f46}}.bulk-tip.gray{{background:#f3f4f6;color:#374151}}
.bulk-tip.gold{{background:#fef3c7;color:#92400e}}
.wrap{{max-width:900px;margin:0 auto;padding:14px 14px}}
.card{{background:white;border-radius:10px;margin-bottom:10px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow:hidden;transition:opacity .3s}}
.card:hover{{box-shadow:0 2px 8px rgba(0,0,0,.1)}}.card.done{{opacity:.25;pointer-events:none}}
.card-top{{padding:14px 16px;display:flex;gap:10px;cursor:pointer}}
.chk{{margin-top:4px;flex-shrink:0}}
.card-info{{flex:1;min-width:0}}
.card-row1{{display:flex;align-items:center;gap:8px;margin-bottom:4px}}
.pill{{font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;text-transform:uppercase;flex-shrink:0}}
.pill-action{{background:#fee2e2;color:#b91c1c}}.pill-read{{background:#dbeafe;color:#1d4ed8}}
.pill-receipt{{background:#d1fae5;color:#065f46}}.pill-newsletter{{background:#fef3c7;color:#92400e}}
.pill-junk{{background:#f3f4f6;color:#6b7280}}
.sender{{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.date{{font-size:11px;color:#aaa;margin-left:auto;flex-shrink:0}}
.subj{{font-size:13px;color:#333;margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.summ{{font-size:12px;color:#666;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}
.body-wrap{{padding:0 16px 10px 40px}}
.body-txt{{font-size:12px;color:#555;line-height:1.7;max-height:220px;overflow-y:auto;background:#f9f9f9;padding:10px;border-radius:8px;white-space:pre-wrap;word-break:break-word}}
.reply-drawer{{border-top:1px solid #f0f0f0;padding:12px 16px;background:#fafafa}}
.reply-to-line{{font-size:12px;color:#888;margin-bottom:6px}}
.reply-ta{{width:100%;border:1px solid #ddd;border-radius:8px;padding:9px 12px;font-size:13px;font-family:inherit;resize:vertical;min-height:90px;outline:none}}
.reply-ta:focus{{border-color:#1a1a2e}}
.reply-acts{{display:flex;gap:8px;margin-top:7px}}
.ai-note{{font-size:11px;color:#888;margin-top:5px}}
.actions{{display:flex;gap:7px;padding:0 16px 12px 40px;flex-wrap:wrap}}
.btn{{border:none;padding:6px 13px;border-radius:6px;font-size:12px;cursor:pointer;font-weight:500;text-decoration:none;display:inline-flex;align-items:center}}
.reply-btn{{background:#1a1a2e;color:white}}.reply-btn:hover{{background:#2d2d4e}}
.gmail-btn{{background:#4285f4;color:white}}.gmail-btn:hover{{background:#3367d6}}
.arc-btn{{background:#f5a623;color:white}}.arc-btn:hover{{background:#d4941e}}
.unsub-btn{{background:#7c3aed;color:white}}.unsub-btn:hover{{background:#6d28d9}}
.del-btn{{background:#ef4444;color:white}}.del-btn:hover{{background:#dc2626}}
.digest-btn{{background:#f59e0b;color:white}}.digest-btn:hover{{background:#d97706}}
.send-btn{{background:#1a1a2e;color:white}}.cancel-btn{{background:#f0f0f0;color:#333}}
.empty-cat{{text-align:center;color:#aaa;padding:40px;font-size:14px}}
kbd{{background:#f0f0f0;border:1px solid #ddd;border-radius:4px;padding:1px 5px;font-size:11px}}
.kbd-hint{{text-align:center;font-size:12px;color:#aaa;margin:16px 0}}
</style></head><body>
<div class="topbar">
  <div><h1>📬 Inbox Triage</h1><div class="sub">{total_visible} emails &bull; {ts}</div></div>
  <div class="topbar-r">
    <a class="tb" href="/">📰 Newsletters</a>
    <button class="tb" onclick="doScan()">{'Scanning...' if is_scanning else 'Rescan'}</button>
    <button class="tb red" onclick="quickClean()">Quick Clean</button>
  </div>
</div>
{scan_bar}
<div class="bulk-bar">
  <input type="checkbox" id="sel-all" onchange="selectAll(this)">
  <label for="sel-all" style="margin-left:6px;color:#555">Select all</label>
  <span id="sel-count" style="color:#aaa;font-size:12px;margin-left:4px"></span>
  <div style="margin-left:auto;display:flex;gap:8px">
    <button class="btn arc-btn" onclick="bulkSelected('archive')">Archive selected</button>
    <button class="btn del-btn" onclick="bulkSelected('delete')">Delete selected</button>
  </div>
</div>
<div class="tabs" id="tabs">{tabs_html}</div>
<div class="wrap">{content_html}</div>
<div class="kbd-hint"><kbd>a</kbd> archive &nbsp; <kbd>d</kbd> delete &nbsp; <kbd>r</kbd> reply &nbsp; <kbd>Space</kbd> next &nbsp; <kbd>e</kbd> expand</div>

<script>
function switchTab(name, el) {{
  document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none');
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  document.getElementById('pane-' + name).style.display = 'block';
  el.classList.add('active');
}}
function toggleBody(uid) {{
  const b = document.getElementById('body-' + uid);
  b.style.display = b.style.display === 'none' ? 'block' : 'none';
}}
async function doAction(uid, action, btn) {{
  btn.disabled = true; btn.textContent = action === 'archive' ? 'Archiving...' : 'Deleting...';
  const r = await fetch('/inbox/action', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{uid,action}})}});
  if (r.ok) {{ const c = document.getElementById('card-'+uid); c.classList.add('done'); setTimeout(()=>c.style.display='none',400); }}
  else {{ btn.textContent = 'Error'; btn.disabled = false; }}
}}
async function openReply(uid, to, subj, msgId) {{
  const drawer = document.getElementById('reply-' + uid);
  if (drawer.style.display !== 'none') {{ drawer.style.display = 'none'; return; }}
  drawer.style.display = 'block';
  const ta = document.getElementById('rta-' + uid);
  ta.value = 'Drafting...';
  const r = await fetch('/inbox/draft', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{uid}})}});
  const d = await r.json();
  ta.value = d.draft || '';
  drawer.scrollIntoView({{behavior:'smooth',block:'nearest'}});
}}
function closeReply(uid) {{ document.getElementById('reply-'+uid).style.display='none'; }}
async function sendReply(uid, to, subj, msgId) {{
  const ta = document.getElementById('rta-' + uid);
  const body = ta.value.trim();
  if (!body) return;
  const btn = document.getElementById('reply-'+uid).querySelector('.send-btn');
  btn.disabled = true; btn.textContent = 'Sending...';
  const r = await fetch('/inbox/reply', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{uid,to,subject:'Re: '+subj.replace(/^Re: /i,''),body,in_reply_to:msgId}})}});
  if (r.ok) {{
    btn.textContent = 'Sent!';
    setTimeout(() => {{ doAction(uid, 'archive', document.getElementById('card-'+uid).querySelector('.arc-btn')); }}, 800);
  }} else {{ btn.textContent = 'Error'; btn.disabled = false; }}
}}
async function doUnsub(uid, btn) {{
  if (!confirm('Unsubscribe and archive?')) return;
  btn.disabled = true; btn.textContent = 'Working...';
  const r = await fetch('/unsubscribe', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{uid}})}});
  if (r.ok) {{ btn.textContent = 'Done!'; const c=document.getElementById('card-'+uid); setTimeout(()=>{{c.classList.add('done');setTimeout(()=>c.style.display='none',400);}},600); }}
  else {{ btn.textContent='Error'; btn.disabled=false; }}
}}
async function bulkCat(cat, action) {{
  const cards = document.querySelectorAll('#pane-'+cat+' .card:not(.done)');
  for (const c of cards) {{
    const uid = c.dataset.uid;
    const btn = c.querySelector(action==='archive'?'.arc-btn':'.del-btn');
    if (btn) {{ await doAction(uid, action, btn); await new Promise(r=>setTimeout(r,100)); }}
  }}
}}
async function bulkSelected(action) {{
  const checked = document.querySelectorAll('.chk:checked');
  for (const chk of checked) {{
    const uid = chk.dataset.uid;
    const card = document.getElementById('card-'+uid);
    const btn = card.querySelector(action==='archive'?'.arc-btn':'.del-btn');
    if (btn) {{ await doAction(uid, action, btn); await new Promise(r=>setTimeout(r,100)); }}
  }}
}}
function selectAll(cb) {{
  const pane = document.querySelector('.tab-pane[style*="block"]') || document.getElementById('pane-action');
  pane.querySelectorAll('.chk').forEach(c=>c.checked=cb.checked);
  const n = cb.checked ? pane.querySelectorAll('.chk').length : 0;
  document.getElementById('sel-count').textContent = n ? `(${{n}} selected)` : '';
}}
function quickClean() {{
  if (!confirm('Archive all Receipts and delete all Junk?')) return;
  bulkCat('receipt','archive').then(()=>bulkCat('junk','delete'));
}}
async function doScan() {{
  await fetch('/inbox/scan', {{method:'POST'}});
  pollProgress();
}}
function pollProgress() {{
  const iv = setInterval(async () => {{
    const r = await fetch('/inbox/progress');
    const d = await r.json();
    if (!d.running) {{ clearInterval(iv); location.reload(); }}
  }}, 3000);
}}
{f"pollProgress();" if is_scanning else ""}
</script></body></html>"""

# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------
_nl_generating = False

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a): pass

    def send_json(self, data, status=200):
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def send_html(self, content, status=200):
        body = content.encode()
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def read_body(self):
        n = int(self.headers.get("Content-Length", 0))
        return json.loads(self.rfile.read(n)) if n else {}

    # ---- GET ----
    def do_GET(self):
        path = self.path.split("?")[0]
        if path in ("/", "/digest"):
            state = nl_load()
            self.send_html(render_newsletter_page(state.get("newsletters",[]), state.get("generated_at"), _nl_generating))
        elif path == "/inbox":
            self.send_html(render_inbox_page(ix_load(), _scan_progress))
        elif path == "/inbox/progress":
            self.send_json(_scan_progress)
        else:
            self.send_response(404); self.end_headers()

    # ---- POST ----
    def do_POST(self):
        global _nl_generating
        path = self.path.split("?")[0]
        data = self.read_body()

        # --- Newsletter actions ---
        if path == "/action":
            uid = data.get("uid"); action = data.get("action")
            if not uid or action not in ("archive","delete"):
                return self.send_json({"error":"invalid"},400)
            try:
                imap_op(uid, action)
                state = nl_load()
                for n in state.get("newsletters",[]):
                    if n["uid"] == uid: n[action+"d"] = True
                nl_save(state)
                self.send_json({"ok":True})
            except Exception as e:
                self.send_json({"error":str(e)},500)

        elif path == "/unsubscribe":
            uid = data.get("uid")
            if not uid: return self.send_json({"error":"missing uid"},400)
            # Look in both newsletter and inbox state
            nl_state = nl_load(); ix_state = ix_load()
            item = next((n for n in nl_state.get("newsletters",[]) if n["uid"]==uid), None)
            if not item:
                item = next((e for e in ix_state.get("emails",[]) if e["uid"]==uid), None)
            if not item: return self.send_json({"error":"not found"},404)
            try:
                result = nl_unsubscribe(item)
                imap_op(uid, "archive")
                for n in nl_state.get("newsletters",[]): 
                    if n["uid"]==uid: n["archived"]=True
                for e in ix_state.get("emails",[]): 
                    if e["uid"]==uid: e["archived"]=True
                nl_save(nl_state); ix_save(ix_state)
                self.send_json({"ok":True,"message":result})
            except Exception as e:
                self.send_json({"error":str(e)},500)

        elif path == "/generate":
            if _nl_generating: return self.send_json({"ok":True,"status":"already running"})
            _nl_generating = True
            def run():
                global _nl_generating
                try:
                    newsletters = nl_fetch(limit=20)
                    nl_save({"newsletters":newsletters,"generated_at":datetime.now().isoformat()})
                except Exception as e:
                    print(f"NL generate error: {e}")
                finally:
                    _nl_generating = False
            threading.Thread(target=run, daemon=True).start()
            self.send_json({"ok":True})

        # --- Inbox actions ---
        elif path == "/inbox/action":
            uid = data.get("uid"); action = data.get("action")
            if not uid or action not in ("archive","delete"):
                return self.send_json({"error":"invalid"},400)
            try:
                imap_op(uid, action)
                state = ix_load()
                for e in state.get("emails",[]):
                    if e["uid"] == uid: e[action+"d"] = True
                ix_save(state)
                self.send_json({"ok":True})
            except Exception as e:
                self.send_json({"error":str(e)},500)

        elif path == "/inbox/scan":
            if _scan_progress.get("running"):
                return self.send_json({"ok":True,"status":"already scanning"})
            threading.Thread(target=ix_scan, daemon=True).start()
            self.send_json({"ok":True,"status":"started"})

        elif path == "/inbox/draft":
            uid = data.get("uid")
            state = ix_load()
            item = next((e for e in state.get("emails",[]) if e["uid"]==uid), None)
            if not item: return self.send_json({"error":"not found"},404)
            # Fetch full body if not already loaded
            if not item.get("body"):
                try:
                    imap = get_imap(); imap.select("INBOX")
                    _, d = imap.uid('FETCH', uid.encode(), '(RFC822)')
                    full = email.message_from_bytes(d[0][1])
                    item["body"] = get_body(full)
                    imap.logout()
                except: pass
            try:
                draft = claude(
                    f"""Draft a concise, warm reply to this email on behalf of Mike Ziarko, owner of No More Chores (Toronto cleaning company).
Be direct. No em dashes. Sign off as Mike.

From: {item['sender']}
Subject: {item['subject']}

Email:
{item.get('body','') or item.get('snippet','')}

Draft reply:""",
                    max_tokens=400
                )
                item["draft"] = draft
                ix_save(state)
                self.send_json({"ok":True,"draft":draft})
            except Exception as e:
                self.send_json({"error":str(e)},500)

        elif path == "/inbox/reply":
            uid        = data.get("uid")
            to         = data.get("to")
            subject    = data.get("subject","")
            body       = data.get("body","")
            in_reply_to= data.get("in_reply_to")
            if not to or not body: return self.send_json({"error":"missing fields"},400)
            try:
                send_email(to, subject, body, in_reply_to)
                self.send_json({"ok":True})
            except Exception as e:
                self.send_json({"error":str(e)},500)

        else:
            self.send_response(404); self.end_headers()

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
    print(f"Harvey Email Hub starting on port {PORT}...")
    server = HTTPServer(("0.0.0.0", PORT), Handler)
    print(f"  Newsletter digest: http://0.0.0.0:{PORT}/")
    print(f"  Inbox triage:      http://0.0.0.0:{PORT}/inbox")
    server.serve_forever()

if __name__ == "__main__":
    main()
