Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7b05076
docs(spec): Gmail connector design (approach B, two-stage sync/build)
maxdolphin Jul 6, 2026
32fe61f
docs(spec): tighten Gmail connector spec after review
maxdolphin Jul 6, 2026
65f404b
docs(plan): Gmail connector implementation plan (7 tasks, TDD)
maxdolphin Jul 6, 2026
a3f5bb3
feat(connectors): package skeleton + Google client deps
maxdolphin Jul 6, 2026
2ff0f35
feat(connectors): gmail_interactions store (metadata-only DAO)
maxdolphin Jul 6, 2026
e13f851
fix(connectors): lazy package exports (PEP 562) so submodules import …
maxdolphin Jul 6, 2026
b3d326c
refactor(connectors): address store review — drop dead _COLUMNS, expl…
maxdolphin Jul 6, 2026
983c2cf
feat(connectors): pure hybrid decay x sustained weighting (Stage 2)
maxdolphin Jul 6, 2026
9ebc3f3
refactor(connectors): validate weighting inputs; narrow import except…
maxdolphin Jul 6, 2026
bef4451
feat(connectors): GmailConnector auth + metadata sync (Stage 1)
maxdolphin Jul 6, 2026
757dbe8
fix(connectors): idempotent inserts + atomic auth + robust address pa…
maxdolphin Jul 6, 2026
89eebd2
refactor(connectors): retire GoogleWorkspace stub, point to GmailConn…
maxdolphin Jul 6, 2026
812d6c5
feat(app): Connect Gmail data-source mode (sync + build + analyze)
maxdolphin Jul 6, 2026
4d7b5d8
fix(app): guard st.secrets access in Connect Gmail (no secrets.toml c…
maxdolphin Jul 6, 2026
bca221a
docs(spec): mark Gmail connector implemented
maxdolphin Jul 6, 2026
84a01b7
fix(connectors): metadata-scope-safe SENT listing, per-user sync isol…
maxdolphin Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 114 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,8 @@ def show_main_page():
mode_list = [
"📊 Upload Data",
"🧪 Use Sample Data",
"⚡ Generate Synthetic Data"
"⚡ Generate Synthetic Data",
"🔌 Connect Gmail"
]
if DISCOVERY_AVAILABLE:
mode_list.append("🔍 Discover Datasets")
Expand All @@ -1009,6 +1010,8 @@ def show_main_page():
sample_data_interface()
elif analysis_mode == "⚡ Generate Synthetic Data":
synthetic_data_interface()
elif analysis_mode == "🔌 Connect Gmail":
connect_gmail_interface()
elif analysis_mode == "🔍 Discover Datasets":
discovery_interface()
elif analysis_mode == "📖 Documentation":
Expand Down Expand Up @@ -1742,6 +1745,116 @@ def _try_direct_analyze(ds_info, ds_name):
except Exception as e:
st.error(f"Error loading sample data: {str(e)}")

def connect_gmail_interface():
"""Self-provisioning Gmail connector: admin OAuth -> sync -> build -> analyze."""
from datetime import datetime, timedelta, timezone
from src.network_ingestion import NetworkIngestionError
st.header("🔌 Connect Gmail")
st.info(
"OASIS reads only **who-emailed-whom and when** — never subjects or "
"message contents. Requires a Google Workspace **admin** to authorize the "
"app (domain-wide delegation)."
)

try:
from src.connectors import GmailConnector, GmailInteractionStore, build_flow_matrix
except Exception as exc:
st.error(f"Connector unavailable: {exc}")
return

# 1) Credentials come from Streamlit secrets (never hard-coded / committed).
# st.secrets raises StreamlitSecretNotFoundError when no secrets.toml exists,
# so guard the access rather than the attribute.
try:
creds = dict(st.secrets.get("gmail", {}))
except Exception:
creds = {}
if not creds.get("service_account_file"):
st.warning(
"No Gmail credentials configured. Add a `[gmail]` block to "
"`.streamlit/secrets.toml` with `service_account_file`, `subject` "
"(admin email), and `domain`."
)
return

if st.button("🔗 Connect", type="primary"):
conn = GmailConnector()
if conn.authenticate(creds):
st.session_state["gmail_domain"] = creds["domain"]
org = conn.get_organization_structure()
st.success(
f"Connected to **{creds['domain']}** — "
f"{org['total_users']} users."
)
else:
st.error("Authentication failed. Check the service account, admin "
"subject, and that domain-wide delegation is granted.")

if not st.session_state.get("gmail_domain"):
return

domain = st.session_state["gmail_domain"]

# 2) Sync controls
st.subheader("1 · Sync mailbox metadata")
win_days = st.selectbox("Pull window (days)", [30, 90, 180, 365], index=1)
if st.button("⬇️ Sync now"):
conn = GmailConnector()
if not conn.authenticate(creds):
st.error("Re-authentication failed.")
return
now = int(datetime.now(timezone.utc).timestamp())
start = now - win_days * 86400
run_id = f"sync-{now}"
with st.spinner(f"Syncing last {win_days} days…"):
n = conn.sync(start, now, sync_run_id=run_id)
st.session_state["gmail_last_sync"] = now
st.success(f"Synced {n} directed interactions.")

if not st.session_state.get("gmail_last_sync"):
return

# 3) Build controls
st.subheader("2 · Build the network")
granularity = st.radio("Granularity", ["individual", "department"], index=1)
half_life_days = st.slider("Recency half-life (days)", 7, 180, 30)
beta = st.slider("Sustained-engagement weight (β)", 0.0, 2.0, 0.5, 0.1,
help="Calibration parameter — boosts relationships active "
"across many weeks. Not a scientific metric formula.")
build_win_days = st.selectbox("Analysis window (days)", [30, 90, 180, 365],
index=1, key="build_win")
if st.button("🧮 Build & Analyze", type="primary"):
store = GmailInteractionStore()
conn = GmailConnector()
conn.authenticate(creds)
org = conn.get_organization_structure()
now = int(datetime.now(timezone.utc).timestamp())
rows = store.query_window(domain, now - build_win_days * 86400, now)
try:
parsed, dropped = build_flow_matrix(
rows, org_users=org["org_users"], now_utc=now,
window_seconds=build_win_days * 86400,
half_life_seconds=half_life_days * 86400,
beta=beta, granularity=granularity)
except NetworkIngestionError as exc:
st.warning(
f"No internal network could be built for this window: {exc} "
"Try a longer window or a different granularity."
)
return
if dropped:
st.caption(f"Dropped {dropped} external-address interactions.")
st.session_state.analysis_data = {
"flow_matrix": parsed.flow_matrix,
"node_names": parsed.node_names,
"org_name": f"{domain} (Gmail · {granularity})",
"source": "gmail_connector",
}
provision_network(st.session_state.analysis_data)
st.session_state.current_page = "analysis"
st.rerun()


def synthetic_data_interface():
"""Visual Network Generator Interface."""

Expand Down
3 changes: 3 additions & 0 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ reportlab>=4.0.0
kaleido==0.2.1
huggingface_hub>=0.16.0
datasets>=2.14.0
google-api-python-client>=2.100
google-auth>=2.23
google-auth-oauthlib>=1.1
Loading