| 1 | """Zion PM - email intake (a sense). Polls each Gmail account's `PM` label over IMAP and turns |
| 2 | new messages into tasks. Label-gated on purpose: set a Gmail filter to label what you want the PM |
| 3 | to handle (outreach replies, client mail) as `PM`, and ONLY those reach the agent. No flooding. |
| 4 | |
| 5 | Run periodically (cron/systemd timer): python3 bin/intake-email.py |
| 6 | Needs each account's Gmail APP PASSWORD in /shared/projects/.env.secrets under its app_pw_var. |
| 7 | """ |
| 8 | import imaplib, email, json, os, time, uuid, re |
| 9 | from email.header import decode_header |
| 10 | |
| 11 | PM = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 12 | ST = os.path.join(PM, "state"); INBOX = os.path.join(PM, "inbox") |
| 13 | LABEL = os.environ.get("PM_EMAIL_LABEL", "PM") |
| 14 | |
| 15 | def secrets(): |
| 16 | d = {} |
| 17 | p = "/shared/projects/.env.secrets" |
| 18 | if os.path.exists(p): |
| 19 | for line in open(p): |
| 20 | line = line.strip().replace("\r", "") |
| 21 | if "=" in line and not line.startswith("#"): |
| 22 | k, _, v = line.partition("="); d[k.strip()] = v.strip().strip('"').strip("'") |
| 23 | return d |
| 24 | |
| 25 | def policy_accounts(): |
| 26 | import yaml |
| 27 | pol = yaml.safe_load(open(os.path.join(PM, "policy.yaml"))) |
| 28 | return (pol.get("sender_identities", {}) or {}).get("accounts", {}) |
| 29 | |
| 30 | def _dec(s): |
| 31 | if not s: return "" |
| 32 | out = [] |
| 33 | for part, enc in decode_header(s): |
| 34 | out.append(part.decode(enc or "utf-8", "ignore") if isinstance(part, bytes) else part) |
| 35 | return "".join(out) |
| 36 | |
| 37 | def add_task(title, project, ttype): |
| 38 | p = os.path.join(ST, "tasks.json") |
| 39 | t = json.load(open(p)) if os.path.exists(p) else {"tasks": []} |
| 40 | tid = "t-" + uuid.uuid4().hex[:8] |
| 41 | t["tasks"].append({"id": tid, "title": title, "project": project, "type": ttype, |
| 42 | "status": "open", "created": time.strftime("%Y-%m-%dT%H:%M:%S"), |
| 43 | "artifacts": [], "worker": None, "source": "email"}) |
| 44 | json.dump(t, open(p, "w"), indent=2) |
| 45 | open(os.path.join(INBOX, tid + ".task"), "w").write(title + "\n") |
| 46 | with open(os.path.join(ST, "activity.jsonl"), "a") as f: |
| 47 | f.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "event": "intake_email", "id": tid, "title": title}) + "\n") |
| 48 | return tid |
| 49 | |
| 50 | def poll_account(name, acct, sec): |
| 51 | addr = (acct.get("address") or "").strip() |
| 52 | pwvar = acct.get("app_pw_var", "") |
| 53 | pw = sec.get(pwvar, "") |
| 54 | if not addr or not pw: |
| 55 | return f" skip {name}: no address or {pwvar} not set" |
| 56 | use = acct.get("use", "") |
| 57 | ttype = "outreach" if "outreach" in use or "recruit" in use else "client" if "client" in use else "misc" |
| 58 | try: |
| 59 | M = imaplib.IMAP4_SSL("imap.gmail.com") |
| 60 | M.login(addr, pw) |
| 61 | typ, _ = M.select(f'"{LABEL}"', readonly=False) |
| 62 | if typ != "OK": |
| 63 | M.logout(); return f" {name}: no '{LABEL}' label (create a Gmail filter -> label '{LABEL}')" |
| 64 | typ, data = M.search(None, "UNSEEN") |
| 65 | ids = data[0].split() if data and data[0] else [] |
| 66 | made = 0 |
| 67 | for i in ids: |
| 68 | typ, msg_data = M.fetch(i, "(RFC822)") |
| 69 | if typ != "OK": continue |
| 70 | msg = email.message_from_bytes(msg_data[0][1]) |
| 71 | frm = _dec(msg.get("From")); subj = _dec(msg.get("Subject")) |
| 72 | title = f"Email to {name} from {frm}: {subj}".strip() |
| 73 | add_task(title, project=None, ttype=ttype) |
| 74 | M.store(i, "+FLAGS", "\\Seen") |
| 75 | made += 1 |
| 76 | M.logout() |
| 77 | return f" {name} ({addr}): {made} new -> tasks" |
| 78 | except Exception as e: |
| 79 | return f" {name} ERROR: {e}" |
| 80 | |
| 81 | def main(): |
| 82 | sec = secrets(); accts = policy_accounts() |
| 83 | print(f"[intake-email] polling '{LABEL}' label across {len(accts)} accounts") |
| 84 | for name, acct in accts.items(): |
| 85 | print(poll_account(name, acct, sec)) |
| 86 | |
| 87 | if __name__ == "__main__": |
| 88 | main() |