diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index f0fcdf9a9..681e71ef0 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -118,6 +118,7 @@ class EmailChannel(BaseChannel): config = EmailConfig.model_validate(config) super().__init__(config, bus) self.config: EmailConfig = config + self._self_addresses = self._collect_self_addresses() self._last_subject_by_chat: dict[str, str] = {} self._last_message_id_by_chat: dict[str, str] = {} self._processed_uids: set[str] = set() # Capped to prevent unbounded growth @@ -379,6 +380,12 @@ class EmailChannel(BaseChannel): sender = parseaddr(parsed.get("From", ""))[1].strip().lower() if not sender: continue + if self._is_self_address(sender): + logger.info("Email from {} ignored: matches bot-owned address", sender) + self._remember_processed_uid(uid, dedupe, cycle_uids) + if mark_seen: + client.store(imap_id, "+FLAGS", "\\Seen") + continue # --- Anti-spoofing: verify Authentication-Results --- spf_pass, dkim_pass = self._check_authentication_results(parsed) @@ -446,14 +453,7 @@ class EmailChannel(BaseChannel): } ) - if uid: - cycle_uids.add(uid) - if dedupe and uid: - self._processed_uids.add(uid) - # mark_seen is the primary dedup; this set is a safety net - if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: - # Evict a random half to cap memory; mark_seen is the primary dedup - self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:]) + self._remember_processed_uid(uid, dedupe, cycle_uids) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") @@ -463,6 +463,50 @@ class EmailChannel(BaseChannel): except Exception: pass + def _collect_self_addresses(self) -> set[str]: + """Return normalized email addresses owned by this channel instance.""" + candidates = ( + self.config.from_address, + self.config.smtp_username, + self.config.imap_username, + ) + normalized = { + addr + for candidate in candidates + if (addr := self._normalize_address(candidate)) + } + return normalized + + @staticmethod + def _normalize_address(value: str) -> str: + """Normalize an address or mailbox-like identifier for comparisons.""" + raw = (value or "").strip() + if not raw: + return "" + parsed = parseaddr(raw)[1].strip().lower() + if parsed: + return parsed + if "@" in raw: + return raw.lower() + return "" + + def _is_self_address(self, sender: str) -> bool: + """Return True when an inbound sender belongs to the bot itself.""" + normalized_sender = self._normalize_address(sender) + return bool(normalized_sender) and normalized_sender in self._self_addresses + + def _remember_processed_uid(self, uid: str, dedupe: bool, cycle_uids: set[str]) -> None: + """Track a fetched UID so skipped messages are not reprocessed forever.""" + if not uid: + return + cycle_uids.add(uid) + if dedupe: + self._processed_uids.add(uid) + # mark_seen is the primary dedup; this set is a safety net + if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: + # Evict a random half to cap memory; mark_seen is the primary dedup + self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:]) + @classmethod def _is_stale_imap_error(cls, exc: Exception) -> bool: message = str(exc).lower() diff --git a/tests/channels/test_email_channel.py b/tests/channels/test_email_channel.py index 6d6d2f74f..4341dc5be 100644 --- a/tests/channels/test_email_channel.py +++ b/tests/channels/test_email_channel.py @@ -92,6 +92,46 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None: assert items_again == [] +def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) -> None: + raw = _make_raw_email(from_addr="Nanobot ", subject="Loop test") + + class FakeIMAP: + def __init__(self) -> None: + self.store_calls: list[tuple[bytes, str, str]] = [] + + def login(self, _user: str, _pw: str): + return "OK", [b"logged in"] + + def select(self, _mailbox: str): + return "OK", [b"1"] + + def search(self, *_args): + return "OK", [b"1"] + + def fetch(self, _imap_id: bytes, _parts: str): + return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"] + + def store(self, imap_id: bytes, op: str, flags: str): + self.store_calls.append((imap_id, op, flags)) + return "OK", [b""] + + def logout(self): + return "BYE", [b""] + + fake = FakeIMAP() + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus()) + items = channel._fetch_new_messages() + + assert items == [] + assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")] + + # Same UID should still be deduped after being ignored. + items_again = channel._fetch_new_messages() + assert items_again == [] + + def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeypatch) -> None: raw = _make_raw_email(subject="Invoice", body="Please pay") fail_once = {"pending": True}