diff --git a/src/aipass/ai_mail/.seedgo/bypass.json b/src/aipass/ai_mail/.seedgo/bypass.json index c4773590..12f188ad 100644 --- a/src/aipass/ai_mail/.seedgo/bypass.json +++ b/src/aipass/ai_mail/.seedgo/bypass.json @@ -68,7 +68,7 @@ { "file": "apps/handlers/email/delivery.py", "standard": "handlers", - "reason": "Imports json_utils.json_handler (load_json, save_json) — shared handler utility for JSON I/O, same-branch cross-handler import. Also imports registry.read.get_all_branches — consolidated from dual implementation per DPLAN-0036." + "reason": "Imports json_utils.json_handler (load_json, save_json) — shared handler utility for JSON I/O, same-branch cross-handler import. Also imports registry.read.get_all_branches — consolidated from dual implementation per DPLAN-0036. Also lazy-imports email.contacts.register_contact inside _auto_register_contact() and _auto_register_sender() for post-delivery contact registration (DPLAN-0121 Phase 5)." }, { "file": "apps/handlers/email/inbox_cleanup.py", @@ -233,7 +233,17 @@ { "file": "apps/handlers/users/branch_detection.py", "standard": "handlers", - "reason": "Imports paths.find_repo_root — shared utility consolidated from 8 copies per DPLAN-0036." + "reason": "Imports paths.find_repo_root — shared utility consolidated from 8 copies per DPLAN-0036. Also lazy-imports email.contacts.get_contact inside _get_contact_info() for contacts-first sender detection (DPLAN-0121 Phase 5)." + }, + { + "file": "apps/handlers/email/contacts.py", + "standard": "handlers", + "reason": "Imports paths.find_repo_root — shared utility for CONTACTS_FILE path resolution. Same pattern as registry/read.py and other handlers per DPLAN-0036." + }, + { + "file": "apps/handlers/email/identity.py", + "standard": "handlers", + "reason": "bootstrap_aipass_identities() lazy-imports registry.read.get_all_branches and paths.find_repo_root inside function body to avoid circular imports. Same cross-handler pattern as reply.py." }, { "file": "tests/test_delivery.py", @@ -259,6 +269,26 @@ "file": "tests/test_send_identity.py", "standard": "documentation", "reason": "Test helper functions (_load_active_source, fixtures) are private/internal — docstring requirement does not apply to test infrastructure." + }, + { + "file": "tests/test_contacts.py", + "standard": "architecture", + "reason": "Test file lives in tests/ directory — not subject to 3-layer app structure rule." + }, + { + "file": "tests/test_contacts.py", + "standard": "encapsulation", + "reason": "Unit tests must import handlers directly to exercise internal functions (_load_contacts, get_contact, register_contact, all_contacts). Module entry-point-only rule does not apply to tests." + }, + { + "file": "tests/test_identity.py", + "standard": "architecture", + "reason": "Test file lives in tests/ directory — not subject to 3-layer app structure rule." + }, + { + "file": "tests/test_identity.py", + "standard": "encapsulation", + "reason": "Unit tests must import handlers directly to exercise create_identity, read_identity. Module entry-point-only rule does not apply to tests." } ], "notes": { diff --git a/src/aipass/ai_mail/apps/handlers/email/delivery.py b/src/aipass/ai_mail/apps/handlers/email/delivery.py index ff3f6614..18c5bafe 100644 --- a/src/aipass/ai_mail/apps/handlers/email/delivery.py +++ b/src/aipass/ai_mail/apps/handlers/email/delivery.py @@ -33,6 +33,47 @@ _REPO_ROOT = find_repo_root() _INBOX_LOCK = None +def _auto_register_contact(email: str, branch_path: Path, inbox_file: Path) -> None: + """Auto-register a recipient in the contacts address book after successful delivery. + + Non-critical: failures are logged and silently ignored. + + Args: + email: Recipient email address (e.g., '@devpulse'). + branch_path: Resolved path to the branch root directory. + inbox_file: Path to the branch's inbox.json file. + """ + try: + from aipass.ai_mail.apps.handlers.email.contacts import register_contact + name_key = email.lstrip("@").lower() + register_contact(name_key, "AIPass", str(inbox_file)) + except Exception as e: + logger.warning("[delivery] _auto_register_contact(%s) failed: %s", email, e) + + +def _auto_register_sender(branch_name: str, caller_cwd: str) -> None: + """Auto-register a sender in contacts when called from an external project. + + Walks up from caller_cwd to find .ai_mail.local/inbox.json. + Non-critical: failures are logged and silently ignored. + + Args: + branch_name: Sender branch name or email (e.g., 'vera' or '@vera'). + caller_cwd: Working directory of the calling project. + """ + try: + candidate = Path(caller_cwd) + for path in [candidate] + list(candidate.parents)[:5]: + inbox_file = path / ".ai_mail.local" / "inbox.json" + if inbox_file.exists(): + from aipass.ai_mail.apps.handlers.email.contacts import register_contact + name_key = branch_name.lstrip("@").lower() + register_contact(name_key, "", str(inbox_file)) + return + except Exception as e: + logger.warning("[delivery] _auto_register_sender(%s) failed: %s", branch_name, e) + + def _get_inbox_lock(): """Lazy import inbox_lock context manager.""" global _INBOX_LOCK @@ -301,6 +342,15 @@ def deliver_email_to_branch( logger.warning("[delivery] failed to acquire inbox lock for %s: %s", to_branch, e) return False, f"Failed to acquire inbox lock: {e}" + # Auto-register recipient in contacts for future fast lookup + _auto_register_contact(to_branch, branch_path, inbox_file) + + # Auto-register sender if external project called with AIPASS_CALLER_BRANCH + caller_branch = os.environ.get("AIPASS_CALLER_BRANCH", "") + caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "") + if caller_branch and caller_cwd: + _auto_register_sender(caller_branch, caller_cwd) + # Send desktop notification for new email _send_desktop_notification(email_data['from'], to_branch, email_data['subject'], email_data.get('message', '')) diff --git a/src/aipass/ai_mail/apps/handlers/users/branch_detection.py b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py index 225d7037..ff54fd8b 100644 --- a/src/aipass/ai_mail/apps/handlers/users/branch_detection.py +++ b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py @@ -31,6 +31,37 @@ from aipass.ai_mail.apps.handlers.paths import find_repo_root BRANCH_REGISTRY_PATH = find_repo_root() / "AIPASS_REGISTRY.json" +def _get_contact_info(branch_name: str) -> Optional[Dict]: + """Look up branch info from the contacts address book. + + Fastest path for sender detection — works for external projects that + have previously registered via contacts, bypassing registry/CWD walk. + + Args: + branch_name: Branch name or email (e.g., 'devpulse' or '@devpulse'). + + Returns: + Synthetic branch info dict compatible with registry format, or None. + """ + try: + from aipass.ai_mail.apps.handlers.email.contacts import get_contact + contact = get_contact(branch_name) + if not contact: + return None + inbox_path = Path(contact["inbox"]) + branch_path = inbox_path.parent.parent # .ai_mail.local -> branch root + name_key = branch_name.lstrip("@").lower() + return { + "name": name_key.upper(), + "email": "@" + name_key, + "path": str(branch_path), + "project": contact.get("project", ""), + } + except Exception as e: + logger.warning("[identity] _get_contact_info(%s) failed: %s", branch_name, e) + return None + + def _find_caller_registry() -> Optional[Path]: """Find the caller's AIPASS_REGISTRY.json by walking up from AIPASS_CALLER_CWD. @@ -97,6 +128,11 @@ def detect_branch_from_pwd() -> Optional[Dict]: # Primary: use explicit branch name passed by drone (works in Docker + local) caller_branch = os.environ.get("AIPASS_CALLER_BRANCH") if caller_branch: + # Try contacts first (fastest, works for external projects) + contact = _get_contact_info(caller_branch) + if contact: + return contact + # Fall back to registry lookup branch_info = _lookup_branch_by_name(caller_branch) if branch_info: return branch_info diff --git a/src/aipass/ai_mail/apps/modules/email.py b/src/aipass/ai_mail/apps/modules/email.py index c792f5ba..deec0f65 100644 --- a/src/aipass/ai_mail/apps/modules/email.py +++ b/src/aipass/ai_mail/apps/modules/email.py @@ -119,7 +119,7 @@ def handle_command(command: str, args: List[str]) -> bool: if command in ("--help", "-h"): print_help() return True - valid = ["send", "email", "inbox", "view", "close", "reply", "sent", "contacts", "read"] + valid = ["send", "email", "inbox", "view", "close", "reply", "sent", "contacts", "read", "register"] if command not in valid: return False if args and args[0] in ['--help', '-h', 'help']: @@ -131,6 +131,7 @@ def handle_command(command: str, args: List[str]) -> bool: "inbox": handle_inbox, "view": handle_view, "close": handle_close, "reply": handle_reply, "read": handle_view, "sent": handle_sent, "contacts": handle_contacts, + "register": handle_register, } return dispatch[command](args) @@ -471,6 +472,27 @@ def handle_contacts(args: List[str]) -> bool: return True +def handle_register(args: List[str]) -> bool: + """Register a branch in the contacts address book. + + Usage: drone @ai_mail register @branch /path/to/inbox [project] + """ + json_handler.log_operation("register_contact_initiated", {"args": args}) + if len(args) < 2: + error("Usage: register @branch /path/to/inbox [project]") + return True + branch_name = args[0].lstrip("@") + inbox_path = args[1] + project = args[2] if len(args) > 2 else "" + from aipass.ai_mail.apps.handlers.email.contacts import register_contact + ok = register_contact(branch_name, project, inbox_path) + if ok: + console.print(f"[green]Registered @{branch_name} -> {inbox_path}[/green]") + else: + error(f"Failed to register @{branch_name}") + return True + + def print_introspection(): """Display module introspection info.""" console.print()