diff --git a/CHANGELOG.md b/CHANGELOG.md index 97749ed..538b731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,16 @@ stored score; bump it whenever a feature, weight, or threshold changes. ## [Unreleased] +### Added +- **`nodary needs-me`:** prints, as JSON, recent incoming mail from Tier 3 + senders plus any message at or above `--min-score`, leaving out the + user's own addresses and unflagged no-reply senders. Same privacy + projection as the dashboard. See ADR 0003. + ### Fixed +- **Rejected app-password login:** `nodary sync` now records the server's + rejection as the account's `last_error` and goes on to the next account, + instead of stopping the whole sync. OAuth2 accounts keep their refresh path. - **Dashboard last_error contract:** account-level sync failures (missing credential, unclaimed mail-store, OAuth2 auth failure) now persist `accounts.last_error` so `/api/status` and the status strip match the CLI. diff --git a/docs/decisions/0003-needs-me-command.md b/docs/decisions/0003-needs-me-command.md new file mode 100644 index 0000000..b205624 --- /dev/null +++ b/docs/decisions/0003-needs-me-command.md @@ -0,0 +1,33 @@ +# ADR 0003: `nodary needs-me` command + +Date: 2026-10-30 +Status: Accepted + +## Context + +Scores answer "is this message suspicious?", not "does this message need the +user?". The trust tiers already carry the second signal: Tier 3 is +established two-way correspondence. A consumer outside the dashboard (a +Claude Code pane, a Mail smart mailbox) needs that view without re-deriving +tiers or opening the database itself. + +## Decision + +- Add `nodary needs-me [--days N] [--min-score S]`, printing a JSON array of + incoming messages from the last N days (default 30) that come from a Tier 3 + sender or score at least S (default 25). +- Rows use the dashboard's `_SCORED_MESSAGE_COLS` projection and + `_scored_payload`, so no subject, body, filename or full URL is exposed. +- Leave out mail from the user's own addresses (`user_identities`) and + server-deleted messages. +- Leave out no-reply senders (`no-reply`, `donotreply`, `mailer-daemon`, + `bounce`, ...) unless they reach the score threshold: replying once in a + thread such a sender started can lift it to Tier 3, yet it cannot be a + correspondent. + +## Consequences + +- The no-reply rule is a local-part pattern, not a scoring feature, so it + does not change stored scores or `ENGINE_VERSION`. +- Senders the user replied to once (e.g. a retailer's order address) still + appear; tier computation is unchanged. diff --git a/src/nodary/cli.py b/src/nodary/cli.py index 10e6e26..2c356b0 100644 --- a/src/nodary/cli.py +++ b/src/nodary/cli.py @@ -1,4 +1,4 @@ -"""nodary CLI: add-account, set-secret, set-source, sync, rebuild, ui.""" +"""nodary CLI: add-account, set-secret, set-source, sync, rebuild, ui, needs-me.""" from __future__ import annotations @@ -6,6 +6,7 @@ import contextlib import getpass import os +import re import sys import time from pathlib import Path @@ -264,6 +265,14 @@ def cmd_sync(args) -> int: transport.login_password(acct["email"], secret) stats = sync_account(conn, transport, acct["id"], **sync_kwargs) except Exception as exc: + # A rejected password has no refresh path: record it and move + # on so one account's bad credential cannot stop the others. + if acct["auth_method"] != "oauth2" and isinstance( + exc, transport.login_error + ): + _account_error(conn, acct, f"login failed: {exc}") + any_failed = True + continue # Handle auth failures (login-time or mid-session) for # OAuth2 accounts. Non-auth or non-OAuth2 errors propagate. is_auth_expired = isinstance(exc, _AuthExpired) @@ -674,6 +683,55 @@ def cmd_ui(args) -> int: return 0 +_NO_REPLY = re.compile( + r"^(no[-_.]?reply|do-?not-?reply|mailer-daemon|bounces?)([-_.+][^@]*)?@" +) + + +def cmd_needs_me(args) -> int: + """Print, as JSON, recent incoming mail that needs the user: every message + from an established (Tier 3) sender, plus any message scoring at or above + --min-score whatever the sender's tier. Mail from the user's own addresses, + and unflagged mail from no-reply addresses, is left out. Same projection as the dashboard: no subject or body.""" + import json + + from .ui.server import _SCORED_MESSAGE_COLS, _scored_payload + + if args.days < 1: + print(f"--days must be at least 1, got {args.days}", file=sys.stderr) + return 2 + if not 0 <= args.min_score <= 100: + print(f"--min-score must be 0-100, got {args.min_score}", file=sys.stderr) + return 2 + conn = _open() + since = int(time.time()) - args.days * 86400 + rows = conn.execute( + f"""SELECT {_SCORED_MESSAGE_COLS}, f.account_id + FROM messages m + JOIN message_scores sc ON sc.message_id = m.id + LEFT JOIN sender_profiles p ON p.sender_id = m.sender_id + JOIN folders f ON f.id = m.folder_id + WHERE m.direction = 'in' AND m.deleted_upstream = 0 + AND m.sent_at >= ? + AND m.from_email_norm NOT IN (SELECT email_norm FROM user_identities) + AND (COALESCE(p.trust_tier, sc.trust_tier_at_scoring) = 3 + OR sc.anomaly_score >= ?) + ORDER BY m.sent_at DESC""", + (since, args.min_score), + ).fetchall() + # A no-reply address cannot be a correspondent, so its Tier 3 alone does + # not admit it; a high score still does. + rows = [ + r + for r in rows + if r["anomaly_score"] >= args.min_score + or not _NO_REPLY.match(r["from_email_norm"]) + ] + json.dump([_scored_payload(conn, r) for r in rows], sys.stdout) + sys.stdout.write("\n") + return 0 + + def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( prog="nodary", @@ -780,6 +838,19 @@ def main(argv: list[str] | None = None) -> int: ) ip.set_defaults(fn=cmd_import_profile) + nm = sub.add_parser( + "needs-me", + help="print recent mail from Tier 3 senders or above a score, as JSON", + ) + nm.add_argument("--days", type=int, default=30, help="look back N days") + nm.add_argument( + "--min-score", + type=float, + default=25.0, + help="also include any message scoring at least this (0-100)", + ) + nm.set_defaults(fn=cmd_needs_me) + args = p.parse_args(argv) return args.fn(args) diff --git a/src/nodary/imap_sync/client.py b/src/nodary/imap_sync/client.py index 8d65b83..1e585cb 100644 --- a/src/nodary/imap_sync/client.py +++ b/src/nodary/imap_sync/client.py @@ -49,9 +49,12 @@ def fetch_part(self, uid: int, section: str) -> bytes: ... class ImapTransport: def __init__(self, host: str, port: int = 993): import imapclient + from imapclient.exceptions import LoginError self._imapclient = imapclient self.client = imapclient.IMAPClient(host, port=port, ssl=True) + # The server-rejected-login error, for callers that handle it. + self.login_error = LoginError def login_password(self, user: str, password: str) -> None: self.client.login(user, password) diff --git a/tests/test_cli.py b/tests/test_cli.py index a637314..6bcc64c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -279,3 +279,62 @@ def test_sync_fails_cleanly_on_missing_layout(env, monkeypatch, capsys, tmp_path assert main(["sync"]) == 1 err = capsys.readouterr().err assert "does not exist" in err.lower() + + +def test_needs_me_lists_scored_incoming(env, monkeypatch, request, capsys): + import json + + mail_store = request.getfixturevalue("store") + + monkeypatch.setenv("NODARY_MAIL_STORE", str(mail_store.root)) + assert _add_account(monkeypatch) == 0 + main(["set-source", "1", "mail-store"]) + assert main(["sync"]) == 0 + capsys.readouterr() + # min-score 0 admits every scored incoming message regardless of tier + assert main(["needs-me", "--days", "100000", "--min-score", "0"]) == 0 + rows = json.loads(capsys.readouterr().out) + senders = {r["from_email_norm"] for r in rows} + assert "ada@example.com" in senders + assert all("subject" not in r for r in rows) + assert main(["needs-me", "--days", "0"]) == 2 + + +def test_sync_continues_after_password_login_rejected( + env, monkeypatch, request, capsys +): + """A rejected app-password login is recorded and later accounts still sync.""" + import imapclient + from imapclient.exceptions import LoginError + + class RejectingImapClient: + def __init__(self, host, port=993, ssl=True): + pass + + def login(self, user, password): + raise LoginError("b'[AUTHENTICATIONFAILED] Invalid credentials (Failure)'") + + def logout(self): + pass + + mail_store = request.getfixturevalue("store") + monkeypatch.setenv("NODARY_MAIL_STORE", str(mail_store.root)) + monkeypatch.setattr(imapclient, "IMAPClient", RejectingImapClient) + assert _add_account(monkeypatch, "rejected@example.com") == 0 + assert _add_account(monkeypatch, "jacob@example.com") == 0 + assert main(["set-source", "2", "mail-store"]) == 0 + monkeypatch.setattr("nodary.cli.get_account_secret", lambda _id: "wrong") + + assert main(["sync"]) == 1 + captured = capsys.readouterr() + assert "account #1 (rejected@example.com): login failed:" in captured.err + assert "2 new messages" in captured.out + from nodary.cli import _open + + conn = _open() + by_id = { + r["id"]: r["last_error"] + for r in conn.execute("SELECT id, last_error FROM accounts") + } + assert "Invalid credentials" in by_id[1] + assert by_id[2] is None