Add imap-ingest CLI + ImapProvider: route emails to IE/Schwab parsers

Wires the IE + Schwab email parsers into an actual runnable sync. Walks
the IMAP mailbox, routes each message by sender domain:
  - *@investengine.com → invest_engine.parse_invest_engine_email
  - *@schwab.com       → schwab.parse_schwab_email
then pushes the resulting Activities through the shared pipeline.

broker-sync imap-ingest — new CLI command taking IMAP_HOST/USER/PASSWORD/
DIRECTORY (mirrors the old wealthfolio-sync image's env shape so the
Terraform CronJob's existing env wiring works unchanged).

Verified: poetry run pytest -q → 109 passed + 1 skipped; mypy strict
clean (37 files); ruff + yapf clean.
This commit is contained in:
Viktor Barzin 2026-04-17 22:12:05 +00:00
parent f089b8b93a
commit 6efd03570a
6 changed files with 290 additions and 35 deletions

View file

@ -130,10 +130,7 @@ class WealthfolioSink:
"""
existing = await self.list_accounts()
for a in existing:
if (
a.get("provider") == account.provider
and a.get("providerAccountId") == account.id
):
if (a.get("provider") == account.provider and a.get("providerAccountId") == account.id):
wf_id = a.get("id")
assert isinstance(wf_id, str)
return wf_id
@ -159,9 +156,7 @@ class WealthfolioSink:
created = resp.json()
wf_id = created.get("id")
if not isinstance(wf_id, str):
raise WealthfolioError(
f"POST /accounts returned no id: {created}"
)
raise WealthfolioError(f"POST /accounts returned no id: {created}")
return wf_id
# -- activity import --
@ -213,15 +208,12 @@ class WealthfolioSink:
checked = check.json()
if not isinstance(checked, list):
raise ImportValidationError(
f"Wealthfolio /import/check returned non-list: {type(checked).__name__}"
)
f"Wealthfolio /import/check returned non-list: {type(checked).__name__}")
invalid = [r for r in checked if isinstance(r, dict) and r.get("errors")]
if invalid:
raise ImportValidationError(
f"Wealthfolio /import/check flagged {len(invalid)} row(s); "
f"first: {invalid[0]}"
)
raise ImportValidationError(f"Wealthfolio /import/check flagged {len(invalid)} row(s); "
f"first: {invalid[0]}")
# Drop any row the server marked is_valid=false (shouldn't happen
# without errors, but defensive).
valid_rows = [r for r in checked if isinstance(r, dict) and r.get("isValid")]