diff --git a/docs/installation.md b/docs/installation.md index 5e23699..f612f3c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -181,7 +181,7 @@ gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 \ run --mode live --workers 2 ``` -Run the reader separately. Add `--mark-seen` only when the bridge should consume GitHub notifications from the inbox: +Run the reader separately. Add `--mark-seen` only when the bridge should consume GitHub notifications from the configured mailbox: ```bash gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 \ @@ -189,6 +189,7 @@ gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 \ read-imap-once \ --email "$GITHUB_AGENT_BRIDGE_EMAIL" \ --password "$GITHUB_AGENT_BRIDGE_PASSWORD" \ + --mailbox "$GITHUB_AGENT_BRIDGE_MAILBOX" \ --mark-seen ``` diff --git a/docs/operations.md b/docs/operations.md index eb5f683..2280335 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -38,6 +38,7 @@ gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 \ read-imap-once \ --email "$GITHUB_AGENT_BRIDGE_EMAIL" \ --password "$GITHUB_AGENT_BRIDGE_PASSWORD" \ + --mailbox "$GITHUB_AGENT_BRIDGE_MAILBOX" \ --mark-seen ``` diff --git a/docs/shadow-canary.md b/docs/shadow-canary.md index 02e1e4d..dcf5fab 100644 --- a/docs/shadow-canary.md +++ b/docs/shadow-canary.md @@ -32,7 +32,8 @@ Read live IMAP with an independent bridge DB cursor, but do **not** mark message ```bash gab --db ~/.local/state/github-agent-bridge-shadow/bridge.sqlite3 read-imap-once \ - --email "$EMAIL" --password "$APP_PASSWORD" + --email "$EMAIL" --password "$APP_PASSWORD" \ + --mailbox "${GITHUB_AGENT_BRIDGE_MAILBOX:-INBOX}" gab --db ~/.local/state/github-agent-bridge-shadow/bridge.sqlite3 run --mode shadow --once --workers 4 ``` diff --git a/src/github_agent_bridge/cli.py b/src/github_agent_bridge/cli.py index d2fedc6..e4511f2 100644 --- a/src/github_agent_bridge/cli.py +++ b/src/github_agent_bridge/cli.py @@ -21,7 +21,7 @@ from .parser import decode_header_value, extract_body_text, parse_auth_results from .policy import Policy from .queue import JobQueue -from .reader import ImapConfig, ImapReader +from .reader import ImapConfig, ImapReader, imap_mailbox_arg DEFAULT_DB = os.path.expanduser("~/.local/state/github-agent-bridge/bridge.sqlite3") DEFAULT_POLICY = os.path.expanduser("~/.config/github-agent-bridge/policy.json") @@ -136,7 +136,7 @@ def cmd_replay(args: argparse.Namespace) -> int: def cmd_read_imap_once(args: argparse.Namespace) -> int: q = JobQueue(args.db); policy = load_policy(args.policy) - cfg = ImapConfig(args.imap_host, args.imap_port, args.email, args.password, args.mailbox) + cfg = ImapConfig(args.imap_host, args.imap_port, args.email, args.password, imap_mailbox_arg(args.mailbox)) count = ImapReader(cfg, q, policy, mark_seen=args.mark_seen).fetch_once() print(json.dumps({"enqueued_or_seen": count, "mark_seen": args.mark_seen}, ensure_ascii=False)) return 0 @@ -297,7 +297,7 @@ def build_parser() -> argparse.ArgumentParser: s.add_argument("--imap-port", type=int, default=int(os.getenv("GITHUB_AGENT_BRIDGE_IMAP_PORT", "993"))) s.add_argument("--email", default=os.getenv("GITHUB_AGENT_BRIDGE_EMAIL", "")) s.add_argument("--password", default=os.getenv("GITHUB_AGENT_BRIDGE_PASSWORD", "")) - s.add_argument("--mailbox", default="INBOX"); s.add_argument("--mark-seen", action="store_true", help="mark GitHub notifications as seen; leave off for shadow mode") + s.add_argument("--mailbox", default=os.getenv("GITHUB_AGENT_BRIDGE_MAILBOX", "INBOX")); s.add_argument("--mark-seen", action="store_true", help="mark GitHub notifications as seen; leave off for shadow mode") s.set_defaults(func=cmd_read_imap_once) s = sub.add_parser("run") s.add_argument("--mode", choices=[m.value for m in RunMode], default=RunMode.SHADOW.value) diff --git a/src/github_agent_bridge/reader.py b/src/github_agent_bridge/reader.py index 07492d9..6b267f1 100644 --- a/src/github_agent_bridge/reader.py +++ b/src/github_agent_bridge/reader.py @@ -10,6 +10,13 @@ from .queue import JobQueue +def imap_mailbox_arg(value: str) -> str: + """Quote mailbox names with spaces for imaplib.select.""" + if " " in value and not value.startswith('"'): + return f'"{value}"' + return value + + @dataclass(frozen=True) class ImapConfig: host: str @@ -37,7 +44,7 @@ def fetch_once(self) -> int: imap = imaplib.IMAP4_SSL(self.config.host, self.config.port) try: imap.login(self.config.username, self.config.password) - imap.select(self.config.mailbox) + imap.select(imap_mailbox_arg(self.config.mailbox)) status, data = imap.uid("search", None, f"UID {last_uid + 1}:*") if status != "OK" or not data or not data[0]: return 0 diff --git a/src/github_agent_bridge/reader_run.py b/src/github_agent_bridge/reader_run.py index dc76a67..e3270c1 100644 --- a/src/github_agent_bridge/reader_run.py +++ b/src/github_agent_bridge/reader_run.py @@ -4,19 +4,13 @@ import sys from .cli import DEFAULT_DB, DEFAULT_POLICY, main as cli_main +from .reader import imap_mailbox_arg def env(name: str, default: str = "") -> str: return os.getenv(name, default) -def imap_mailbox_arg(value: str) -> str: - """Quote Gmail-style mailbox names with spaces for imaplib.select.""" - if " " in value and not value.startswith('"'): - return f'"{value}"' - return value - - def main() -> int: """Run one IMAP reader pass from GITHUB_AGENT_BRIDGE_* environment. diff --git a/systemd/env.example b/systemd/env.example index 16ffdbc..2802c36 100644 --- a/systemd/env.example +++ b/systemd/env.example @@ -24,6 +24,8 @@ GITHUB_AGENT_BRIDGE_EMAIL=you@example.com GITHUB_AGENT_BRIDGE_PASSWORD=imap-app-password GITHUB_AGENT_BRIDGE_IMAP_HOST=imap.gmail.com GITHUB_AGENT_BRIDGE_IMAP_PORT=993 +# IMAP folder/mailbox to scan. For shared accounts this can be a dedicated +# folder such as GitHub or "[Gmail]/All Mail". GITHUB_AGENT_BRIDGE_MAILBOX=INBOX # Leave empty for shadow reads; set to --mark-seen only when this bridge owns GitHub notifications. GITHUB_AGENT_BRIDGE_MARK_SEEN= diff --git a/tests/test_reader_run.py b/tests/test_reader_run.py index b9b4418..69da586 100644 --- a/tests/test_reader_run.py +++ b/tests/test_reader_run.py @@ -1,4 +1,4 @@ -from github_agent_bridge import reader_run +from github_agent_bridge import cli, reader_run def test_reader_run_builds_imap_args_without_mark_seen(monkeypatch): @@ -87,6 +87,44 @@ def fake_cli_main(argv): assert captured["argv"][captured["argv"].index("--mailbox") + 1] == '"[Gmail]/All Mail"' +def test_read_imap_cli_defaults_mailbox_from_env(monkeypatch): + monkeypatch.setenv("GITHUB_AGENT_BRIDGE_MAILBOX", "Shared GitHub") + + args = cli.build_parser().parse_args(["read-imap-once"]) + + assert args.mailbox == "Shared GitHub" + + +def test_read_imap_cli_quotes_mailbox_with_spaces(monkeypatch, tmp_path, capsys): + captured = {} + + class FakeReader: + def __init__(self, cfg, queue, policy, mark_seen=False): + captured["mailbox"] = cfg.mailbox + + def fetch_once(self): + return 0 + + monkeypatch.setattr(cli, "ImapReader", FakeReader) + monkeypatch.setattr(cli, "load_policy", lambda path: object()) + + rc = cli.main([ + "--db", + str(tmp_path / "bridge.sqlite3"), + "read-imap-once", + "--email", + "bot@example.com", + "--password", + "secret", + "--mailbox", + "[Gmail]/All Mail", + ]) + + assert rc == 0 + assert captured["mailbox"] == '"[Gmail]/All Mail"' + assert '"enqueued_or_seen": 0' in capsys.readouterr().out + + def test_reader_run_requires_email_and_password(monkeypatch, capsys): monkeypatch.delenv("GITHUB_AGENT_BRIDGE_EMAIL", raising=False) monkeypatch.delenv("GITHUB_AGENT_BRIDGE_PASSWORD", raising=False)