Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions docs/decisions/0003-needs-me-command.md
Original file line number Diff line number Diff line change
@@ -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.
73 changes: 72 additions & 1 deletion src/nodary/cli.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""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

import argparse
import contextlib
import getpass
import os
import re
import sys
import time
from pathlib import Path
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions src/nodary/imap_sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading