diff --git a/src/mail/actions/calendar_invite.py b/src/mail/actions/calendar_invite.py index bf11324..2f75b6d 100644 --- a/src/mail/actions/calendar_invite.py +++ b/src/mail/actions/calendar_invite.py @@ -195,13 +195,12 @@ async def _async_respond_to_invite( ): """Async worker to find and respond to calendar invite.""" # First, find the event - app.call_from_thread(app.notify, f"Searching for calendar event: {subject[:40]}...") + app.notify(f"Searching for calendar event: {subject[:40]}...") event = await find_event_by_subject(subject, organizer_email) if not event: - app.call_from_thread( - app.notify, + app.notify( f"Could not find calendar event matching: {subject[:40]}", severity="warning", ) @@ -209,8 +208,7 @@ async def _async_respond_to_invite( event_id = event.get("id") if not event_id: - app.call_from_thread( - app.notify, + app.notify( "Could not get event ID from calendar", severity="error", ) @@ -220,18 +218,14 @@ async def _async_respond_to_invite( # Check if already responded if current_response == "accepted" and response == "accept": - app.call_from_thread( - app.notify, "Already accepted this invite", severity="information" - ) + app.notify("Already accepted this invite", severity="information") return elif current_response == "declined" and response == "decline": - app.call_from_thread( - app.notify, "Already declined this invite", severity="information" - ) + app.notify("Already declined this invite", severity="information") return # Respond to the invite success, message = await respond_to_calendar_invite(event_id, response) severity = "information" if success else "error" - app.call_from_thread(app.notify, message, severity=severity) + app.notify(message, severity=severity) diff --git a/src/mail/notification_detector.py b/src/mail/notification_detector.py index e4c00a1..d66e8c4 100644 --- a/src/mail/notification_detector.py +++ b/src/mail/notification_detector.py @@ -357,11 +357,12 @@ CALENDAR_SUBJECT_PATTERNS = [ ] -def is_calendar_email(envelope: dict[str, Any]) -> bool: +def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> bool: """Check if an email is a calendar invite/update/cancellation. Args: envelope: Email envelope metadata from himalaya + content: Optional message content to check for calendar indicators Returns: True if email appears to be a calendar-related email @@ -378,4 +379,25 @@ def is_calendar_email(envelope: dict[str, Any]) -> bool: if any(keyword in subject for keyword in meeting_keywords): return True + # Check for forwarded meeting invites (FW: or Fwd:) with calendar keywords + if re.match(r"^(fw|fwd):", subject, re.IGNORECASE): + # Check for Teams/calendar-related terms that might indicate forwarded invite + forward_meeting_keywords = ["connect", "sync", "call", "discussion", "review"] + if any(keyword in subject for keyword in forward_meeting_keywords): + return True + + # If content is provided, check for calendar indicators + if content: + # Teams meeting indicators + if "microsoft teams meeting" in content.lower(): + return True + if "join the meeting" in content.lower(): + return True + # ICS content indicator + if "text/calendar" in content.lower(): + return True + # VCALENDAR block + if "begin:vcalendar" in content.lower(): + return True + return False diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index ecb16a7..6c5a1c5 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -391,18 +391,22 @@ class ContentContainer(Vertical): self.notify("No message ID provided.") return - # Check if this is a calendar email and fetch raw message for ICS parsing - if self.current_envelope and is_calendar_email(self.current_envelope): - raw_content, raw_success = await himalaya_client.get_raw_message( - message_id, folder=self.current_folder, account=self.current_account - ) - if raw_success and raw_content: - calendar_event = parse_calendar_from_raw_message(raw_content) - if calendar_event: - self.current_calendar_event = calendar_event - self._show_calendar_panel(calendar_event) - else: - self._hide_calendar_panel() + # Always fetch raw message first to check for calendar data + # This allows us to detect calendar invites even in forwarded emails + raw_content, raw_success = await himalaya_client.get_raw_message( + message_id, folder=self.current_folder, account=self.current_account + ) + + # Check if this is a calendar email (using envelope + content for better detection) + is_calendar = self.current_envelope and is_calendar_email( + self.current_envelope, content=raw_content if raw_success else None + ) + + if is_calendar and raw_success and raw_content: + calendar_event = parse_calendar_from_raw_message(raw_content) + if calendar_event: + self.current_calendar_event = calendar_event + self._show_calendar_panel(calendar_event) else: self._hide_calendar_panel() else: @@ -774,19 +778,30 @@ class ContentContainer(Vertical): self._hide_calendar_panel() # Create and mount new panel at the beginning of the scroll container - self.calendar_panel = CalendarInvitePanel(event, id="calendar_invite_panel") + # Don't use a fixed ID to avoid DuplicateIds errors when panels are + # removed asynchronously + self.calendar_panel = CalendarInvitePanel(event) self.scroll_container.mount(self.calendar_panel, before=0) def _hide_calendar_panel(self) -> None: """Hide/remove the calendar invite panel.""" self.current_calendar_event = None - if self.calendar_panel: + + # Remove the panel via instance reference (more reliable than ID query) + if self.calendar_panel is not None: try: self.calendar_panel.remove() except Exception: - pass # Panel may already be removed + pass # Already removed or not mounted self.calendar_panel = None + # Also remove any orphaned CalendarInvitePanel widgets + try: + for panel in self.query(CalendarInvitePanel): + panel.remove() + except Exception: + pass + def on_calendar_invite_panel_invite_action( self, event: CalendarInvitePanel.InviteAction ) -> None: