Skip to content

CLI: auto-detect --account from Gmail Takeout mbox metadata #42

Description

@splaice

Problem

`maildb ingest run` requires `--account` to be explicitly specified. For Gmail Takeout exports, this is redundant: Takeout mboxes reliably carry the owner's address in headers like `X-Gmail-Labels`, `Delivered-To`, or the archive path itself. Typing `--account splaice@gmail.com` when that same string is already available from the data is an unnecessary source of typos and context-switching.

Current state

  • `src/maildb/cli.py::ingest_run` declares `account: str = typer.Option(..., "--account", ...)` — required, no inference.
  • `src/maildb/parsing.py` already reads `X-Gmail-Labels` (line 210) and other Gmail-specific headers, so the infrastructure for looking at them exists.

Proposed solution

Make `--account` optional. When omitted, inspect the first few messages of the mbox and infer the owner:

  1. Look for `Delivered-To` header — for Gmail, this is typically the user's address on messages they received.
  2. Cross-check by looking at `X-Gmail-Labels` header — Gmail Takeout puts system labels like `Inbox`, `Sent`, `Drafts` that match the owner's mailbox view.
  3. If the inferred address appears in the majority of the first 10 messages as either `Delivered-To` or sender, we're confident — print it and proceed.
  4. If inference is ambiguous (multiple addresses, or no clear winner), fail with a clear error asking the user to pass `--account` explicitly.

Implementation sketch:

```python
def _infer_source_account(mbox_path: Path, sample_size: int = 10) -> str | None:
"""Peek at first N messages of mbox; return inferred owner address or None."""
import mailbox
from collections import Counter
candidates: Counter[str] = Counter()
mb = mailbox.mbox(str(mbox_path))
for i, msg in enumerate(mb):
if i >= sample_size:
break
for header in ("Delivered-To", "X-Original-To"):
if (val := msg.get(header)):
candidates[val.strip().lower()] += 1
if not candidates:
return None
top, count = candidates.most_common(1)[0]
if count >= sample_size * 0.5: # majority threshold
return top
return None
```

CLI:

```python
@ingest_app.command("run")
def ingest_run(
mbox_path: Path = typer.Argument(...),
account: str | None = typer.Option(
None, "--account",
help="Account email. Inferred from Takeout mbox headers if omitted.",
),
...
):
if account is None:
account = _infer_source_account(mbox_path)
if account is None:
raise typer.BadParameter(
"Could not auto-detect --account from mbox headers. Pass --account explicitly."
)
typer.echo(f"Auto-detected --account {account} from mbox headers")
_validate_account(account)
...
```

Acceptance criteria

  • `_infer_source_account` helper implemented in `cli.py` (or `parsing.py` if preferred)
  • `--account` becomes optional; CLI help reflects the inference behavior
  • `maildb ingest run path/to/takeout.mbox` (no `--account`) succeeds on a Gmail Takeout fixture
  • `maildb ingest run path/to/ambiguous.mbox` (mbox where inference fails) errors with a clear message
  • Unit test for `_infer_source_account`: feed it a fake mbox, assert the right address is inferred
  • Integration test: the full `ingest run` command path works without `--account` on a Gmail-shaped fixture

Non-goals

  • Do not try to auto-detect for arbitrary mbox formats (non-Takeout). Other providers use different conventions; Takeout is the common case. Fall back to requiring `--account` for everything else.
  • Do not cache inferred addresses across runs. The inference is cheap (reads 10 headers).

Tradeoffs

  • Pro: Ergonomic win. Removes a common source of typos.
  • Con: Adds code that can be wrong. Mitigated by (a) only running when `--account` is omitted and (b) printing the inferred value so the user can abort with Ctrl+C if it looks wrong.
  • Risk: If Takeout header conventions change in the future, this breaks silently. Mitigation: the 50%-majority threshold is conservative; rare edge cases fall through to the explicit-`--account` path.

References

  • `src/maildb/cli.py::ingest_run` (line ~96)
  • `src/maildb/parsing.py` — existing header extraction

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions