From 260c7c0f96de0b917103eef436788b7702369abe Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:18:51 -0800 Subject: [PATCH] feat: add Settings tab with IMAP account GUI + connection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⚙️ Settings tab: add/edit/remove accounts without touching YAML - Per-account: name, host, port, SSL, username, password (masked), folder, days back - Test connection button: connect → login → select folder → report message count - Save writes config/label_tool.yaml; Reload discards unsaved changes - _sync_settings_to_state() prevents index-key drift on add/remove - _test_imap_connection() helper shared with fetch tab indirectly - CLAUDE.md: document new tab, Settings UI design notes --- app/label_tool.py | 164 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 1 deletion(-) diff --git a/app/label_tool.py b/app/label_tool.py index a4a2fdd..e84147e 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -106,6 +106,27 @@ def _extract_body(msg: Any) -> str: return "" +def _test_imap_connection(acc: dict) -> tuple[bool, str]: + """Try connect → login → select folder. Returns (ok, human message).""" + host = acc.get("host", "") + port = int(acc.get("port", 993)) + use_ssl = acc.get("use_ssl", True) + username = acc.get("username", "") + password = acc.get("password", "") + folder = acc.get("folder", "INBOX") + if not host or not username or not password: + return False, "Host, username, and password are all required." + try: + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + typ, data = conn.select(folder, readonly=True) + count = data[0].decode() if data and data[0] else "?" + conn.logout() + return True, f"Connected — {count} message(s) in {folder}." + except Exception as exc: + return False, str(exc) + + def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str], progress_cb=None) -> list[dict]: """Fetch emails from one IMAP account using wide recruitment search terms.""" @@ -308,7 +329,7 @@ with st.sidebar: # ── Tabs ───────────────────────────────────────────────────────────────────── -tab_label, tab_fetch, tab_stats = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats"]) +tab_label, tab_fetch, tab_stats, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"]) # ══════════════════════════════════════════════════════════════════════════════ @@ -566,3 +587,144 @@ with tab_stats: file_name="email_score.jsonl", mime="application/jsonlines", ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SETTINGS TAB +# ══════════════════════════════════════════════════════════════════════════════ + +def _sync_settings_to_state() -> None: + """Collect current widget values back into settings_accounts, then clear + widget keys so the next render picks up freshly from the updated list.""" + accounts = st.session_state.get("settings_accounts", []) + synced = [] + for i in range(len(accounts)): + synced.append({ + "name": st.session_state.get(f"s_name_{i}", accounts[i].get("name", "")), + "host": st.session_state.get(f"s_host_{i}", accounts[i].get("host", "imap.gmail.com")), + "port": int(st.session_state.get(f"s_port_{i}", accounts[i].get("port", 993))), + "use_ssl": bool(st.session_state.get(f"s_ssl_{i}", accounts[i].get("use_ssl", True))), + "username": st.session_state.get(f"s_user_{i}", accounts[i].get("username", "")), + "password": st.session_state.get(f"s_pass_{i}", accounts[i].get("password", "")), + "folder": st.session_state.get(f"s_folder_{i}", accounts[i].get("folder", "INBOX")), + "days_back": int(st.session_state.get(f"s_days_{i}", accounts[i].get("days_back", 90))), + }) + st.session_state.settings_accounts = synced + for key in list(st.session_state.keys()): + if key.startswith("s_"): + del st.session_state[key] + + +with tab_settings: + # ── Init from disk on first load ───────────────────────────────────────── + if "settings_accounts" not in st.session_state: + _cfg_raw = yaml.safe_load(_CFG_FILE.read_text()) or {} if _CFG_FILE.exists() else {} + st.session_state.settings_accounts = [dict(a) for a in _cfg_raw.get("accounts", [])] + st.session_state.settings_max = _cfg_raw.get("max_per_account", 500) + + _accs = st.session_state.settings_accounts + + st.subheader("📧 IMAP Accounts") + st.caption( + "Credentials are saved to `config/label_tool.yaml` (gitignored). " + "Use an **App Password** for Gmail/Outlook — not your login password." + ) + + if not _accs: + st.info("No accounts configured yet. Click **➕ Add account** to get started.", icon="📭") + + _to_remove = None + for _i, _acc in enumerate(_accs): + _label = f"**{_acc.get('name', 'Unnamed')}** — {_acc.get('username', '(no username)')}" + with st.expander(_label, expanded=not _acc.get("username")): + _c1, _c2 = st.columns(2) + _c1.text_input("Display name", key=f"s_name_{_i}", value=_acc.get("name", "")) + _c2.text_input("IMAP host", key=f"s_host_{_i}", value=_acc.get("host", "imap.gmail.com")) + + _c3, _c4, _c5 = st.columns([3, 2, 1]) + _c3.text_input("Username / email", key=f"s_user_{_i}", value=_acc.get("username", "")) + _c4.number_input("Port", key=f"s_port_{_i}", value=int(_acc.get("port", 993)), + min_value=1, max_value=65535, step=1) + _c5.checkbox("SSL", key=f"s_ssl_{_i}", value=bool(_acc.get("use_ssl", True))) + + st.text_input("Password / app password", key=f"s_pass_{_i}", + value=_acc.get("password", ""), type="password") + + _c6, _c7 = st.columns(2) + _c6.text_input("Folder", key=f"s_folder_{_i}", value=_acc.get("folder", "INBOX")) + _c7.number_input("Default days back", key=f"s_days_{_i}", + value=int(_acc.get("days_back", 90)), min_value=1, max_value=730) + + _btn_l, _btn_r = st.columns([1, 3]) + if _btn_l.button("🗑️ Remove", key=f"s_remove_{_i}"): + _to_remove = _i + if _btn_r.button("🔌 Test connection", key=f"s_test_{_i}"): + _test_acc = { + "host": st.session_state.get(f"s_host_{_i}", _acc.get("host", "")), + "port": st.session_state.get(f"s_port_{_i}", _acc.get("port", 993)), + "use_ssl": st.session_state.get(f"s_ssl_{_i}", _acc.get("use_ssl", True)), + "username": st.session_state.get(f"s_user_{_i}", _acc.get("username", "")), + "password": st.session_state.get(f"s_pass_{_i}", _acc.get("password", "")), + "folder": st.session_state.get(f"s_folder_{_i}", _acc.get("folder", "INBOX")), + } + with st.spinner("Connecting…"): + _ok, _msg = _test_imap_connection(_test_acc) + if _ok: + st.success(_msg) + else: + st.error(f"Connection failed: {_msg}") + + if _to_remove is not None: + _sync_settings_to_state() + st.session_state.settings_accounts.pop(_to_remove) + st.rerun() + + if st.button("➕ Add account"): + _sync_settings_to_state() + st.session_state.settings_accounts.append({ + "name": f"Account {len(_accs) + 1}", + "host": "imap.gmail.com", "port": 993, "use_ssl": True, + "username": "", "password": "", "folder": "INBOX", "days_back": 90, + }) + st.rerun() + + st.divider() + st.subheader("⚙️ Global Settings") + st.number_input( + "Max emails per account per fetch (0 = unlimited)", + key="s_max_per_account", + value=st.session_state.settings_max, + min_value=0, max_value=5000, step=50, + ) + + st.divider() + _save_col, _reload_col = st.columns([3, 1]) + if _save_col.button("💾 Save settings", type="primary", use_container_width=True): + _saved_accounts = [] + for _i in range(len(st.session_state.settings_accounts)): + _a = st.session_state.settings_accounts[_i] + _saved_accounts.append({ + "name": st.session_state.get(f"s_name_{_i}", _a.get("name", "")), + "host": st.session_state.get(f"s_host_{_i}", _a.get("host", "imap.gmail.com")), + "port": int(st.session_state.get(f"s_port_{_i}", _a.get("port", 993))), + "use_ssl": bool(st.session_state.get(f"s_ssl_{_i}", _a.get("use_ssl", True))), + "username": st.session_state.get(f"s_user_{_i}", _a.get("username", "")), + "password": st.session_state.get(f"s_pass_{_i}", _a.get("password", "")), + "folder": st.session_state.get(f"s_folder_{_i}", _a.get("folder", "INBOX")), + "days_back": int(st.session_state.get(f"s_days_{_i}", _a.get("days_back", 90))), + }) + _cfg_out = { + "accounts": _saved_accounts, + "max_per_account": int(st.session_state.get("s_max_per_account", 500)), + } + _CFG_FILE.parent.mkdir(parents=True, exist_ok=True) + _CFG_FILE.write_text(yaml.dump(_cfg_out, default_flow_style=False, allow_unicode=True)) + st.session_state.settings_accounts = _saved_accounts + st.session_state.settings_max = _cfg_out["max_per_account"] + st.success(f"Saved {len(_saved_accounts)} account(s) to `config/label_tool.yaml`.") + + if _reload_col.button("↩ Reload", use_container_width=True, help="Discard unsaved changes and reload from disk"): + for _k in list(st.session_state.keys()): + if _k in ("settings_accounts", "settings_max") or _k.startswith("s_"): + del st.session_state[_k] + st.rerun()