Remove Instructions/Feedback + lp_profiles; sync retry, purge, mobile fixes (v0.1.0:104)

Removals (net -570 lines):
- Delete the Instructions and Feedback (feature_requests) pages + backend.
- Retire lp_profiles + investor_type across server, ingest, and seeds; migration
  0008 drops both empty tables (a sanctioned one-off exception to
  never-hard-delete). 0001's lp_profiles ALTER is removed so a fresh DB doesn't
  break the migration chain (live DBs already applied it).

Fixes:
- Email sync: a transient timeout no longer terminally parks a mailbox; the
  scheduler retries 'retrying' each cycle and re-includes errored accounts on an
  hourly backoff, so stuck mailboxes self-heal.
- Mobile Contacts: page through the full directory (server caps 500/page) -- one
  fetch silently truncated at 720, hiding people from the list and from search.
- Mobile email review: clock icon to set a reminder inline; approval cards show
  date/time.

New:
- Admin-only purge of soft-deleted rows (Settings -> Admin; type-to-confirm,
  refuses any row still linked to live data).

Tests: 45/45 (adds test_sync_ready + test_purge_soft_deleted). Reviewer pass
applied (NULL reminders.contact_id on contact purge). Bumped to v0.1.0:104.
This commit is contained in:
Keysat
2026-06-20 20:06:11 -05:00
parent 985cba3c81
commit 1564c087bf
21 changed files with 629 additions and 694 deletions
+11 -1
View File
@@ -57,10 +57,20 @@ def _json(v) -> str:
# ------------------------------------------------------------------ email_accounts
def list_sync_ready_accounts(conn: sqlite3.Connection) -> list[sqlite3.Row]:
# Ready = healthy ('pending'/'active') + transient-failing ('retrying', retried every
# cycle for fast recovery) + 'error' accounts whose last attempt was over an hour ago.
# The hour-backoff on 'error' means a terminal failure (auth/permanent) self-heals once
# the operator fixes it WITHOUT hammering Google, and un-sticks any mailbox parked by the
# pre-v0.1.0:104 bug where one timeout dark-listed it forever. (last_synced_at is stamped
# on every attempt, success or fail, so it doubles as the last-attempt clock here.)
cur = conn.cursor()
cur.execute(
"SELECT * FROM email_accounts "
"WHERE sync_enabled = 1 AND sync_status IN ('pending','active') "
"WHERE sync_enabled = 1 AND ("
" sync_status IN ('pending','active','retrying') "
" OR (sync_status = 'error' AND (last_synced_at IS NULL "
" OR last_synced_at < datetime('now','-1 hour')))"
") "
"ORDER BY last_synced_at IS NOT NULL, last_synced_at"
)
return cur.fetchall()
+10
View File
@@ -23,6 +23,7 @@ import logging
import sqlite3
import traceback
from typing import Optional
from urllib.error import URLError
from . import attachments as _attach
from . import config as _cfg
@@ -112,6 +113,15 @@ def sync_account(conn_factory, credential_provider, account,
error_str = "history expired; fallback to date backfill"
status = "partial"
_fallback_date_backfill(conn_factory, client, account, index, run_stats)
except (_errors.RateLimitError, _errors.TransientError, URLError, TimeoutError) as e:
# A network / 5xx / rate-limit error that outlived the in-pass retry loop.
# This is TRANSIENT, not terminal: park it as 'retrying' (which the scheduler
# still picks up every cycle) instead of 'error' (which it excludes). Fixes the
# v<=0.1.0:103 bug where a single timeout dark-listed a mailbox until a manual
# kick. Terminal causes (auth, permanent, unexpected) still fall through to 'error'.
error_str = f"transient: {type(e).__name__}: {e}"
status = "retrying"
log.warning("transient error during sync of %s: %s", email_addr, e)
except Exception as e:
error_str = f"unexpected: {type(e).__name__}: {e}"
status = "error"
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Regression test for list_sync_ready_accounts (v0.1.0:104).
Guards the Bug-B fix: a transient network timeout used to flip a mailbox to terminal
sync_status='error', which the old `IN ('pending','active')` filter excluded forever —
so a single blip dark-listed a mailbox until a manual kick. The new filter:
* always includes 'pending' / 'active' / 'retrying' (the transient-retry state), and
* re-includes 'error' accounts whose last attempt was over an hour ago (gentle backoff,
so a fixed credential self-heals and the pre-fix stuck mailboxes recover on deploy).
Synthetic data only (guardrail #9).
Run: cd backend && python3 email_integration/test_sync_ready.py
"""
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from email_integration import db as edb # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def make_account(conn, email, status, *, enabled=1, last_synced_sql="NULL"):
aid = edb.upsert_account(conn, user_id="u-" + email,
email_address=email, auth_method="dwd")
conn.execute(
f"UPDATE email_accounts SET sync_status=?, sync_enabled=?, "
f"last_synced_at={last_synced_sql} WHERE id=?",
(status, enabled, aid),
)
conn.commit()
return aid
def main():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
edb.apply_migrations(conn.cursor())
make_account(conn, "active@t.xyz", "active", last_synced_sql="datetime('now','-5 minutes')")
make_account(conn, "retrying@t.xyz", "retrying", last_synced_sql="datetime('now','-5 minutes')")
make_account(conn, "pending@t.xyz", "pending", last_synced_sql="NULL")
make_account(conn, "error_stale@t.xyz", "error", last_synced_sql="datetime('now','-2 hours')")
make_account(conn, "error_recent@t.xyz", "error", last_synced_sql="datetime('now','-5 minutes')")
make_account(conn, "error_neversync@t.xyz", "error", last_synced_sql="NULL")
make_account(conn, "disabled@t.xyz", "active", enabled=0, last_synced_sql="datetime('now','-5 minutes')")
ready = {r["email_address"] for r in edb.list_sync_ready_accounts(conn)}
check("active@t.xyz" in ready, "healthy 'active' is ready")
check("retrying@t.xyz" in ready, "transient 'retrying' is ready (fast retry)")
check("pending@t.xyz" in ready, "'pending' is ready")
check("error_stale@t.xyz" in ready, "'error' last attempted >1h ago is ready (backoff elapsed → recovers stuck mailbox)")
check("error_neversync@t.xyz" in ready, "'error' never synced (NULL last attempt) is ready")
check("error_recent@t.xyz" not in ready, "'error' attempted <1h ago is NOT ready (gentle backoff, no hammering)")
check("disabled@t.xyz" not in ready, "sync_enabled=0 is never ready")
print()
if FAILS:
print(f"{len(FAILS)} FAILED")
sys.exit(1)
print("ALL PASS (sync ready filter)")
if __name__ == "__main__":
main()