From f8a179e096d330d16cacf36e3c559c7c86c46559 Mon Sep 17 00:00:00 2001 From: Bendt Date: Tue, 6 Jan 2026 16:07:07 -0500 Subject: [PATCH] feat: Detect and display Teams meeting emails without ICS attachments - Fix is_calendar_email() to decode base64 MIME content before checking - Add extract_teams_meeting_info() to parse meeting details from email body - Update parse_calendar_from_raw_message() to fall back to Teams extraction - Show 'Join Meeting' button for TEAMS method events in CalendarInvitePanel - Extract Teams URL, Meeting ID, and organizer from email content --- src/mail/notification_detector.py | 46 ++++++++- src/mail/utils/calendar_parser.py | 120 +++++++++++++++++++++++- src/mail/widgets/CalendarInvitePanel.py | 31 +++++- 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/src/mail/notification_detector.py b/src/mail/notification_detector.py index d66e8c4..64a9d3a 100644 --- a/src/mail/notification_detector.py +++ b/src/mail/notification_detector.py @@ -357,6 +357,43 @@ CALENDAR_SUBJECT_PATTERNS = [ ] +def _decode_mime_content(raw_content: str) -> str: + """Decode base64 parts from MIME content for text searching. + + Args: + raw_content: Raw MIME message content + + Returns: + Decoded text content for searching + """ + import base64 + + decoded_parts = [raw_content] # Include raw content for non-base64 parts + + # Find and decode base64 text parts + b64_pattern = re.compile( + r"Content-Type:\s*text/(?:plain|html)[^\n]*\n" + r"(?:[^\n]+\n)*?" # Other headers + r"Content-Transfer-Encoding:\s*base64[^\n]*\n" + r"(?:[^\n]+\n)*?" # Other headers + r"\n" # Empty line before content + r"([A-Za-z0-9+/=\s]+)", + re.IGNORECASE, + ) + + for match in b64_pattern.finditer(raw_content): + try: + b64_content = ( + match.group(1).replace("\n", "").replace("\r", "").replace(" ", "") + ) + decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace") + decoded_parts.append(decoded) + except Exception: + pass + + return " ".join(decoded_parts) + + def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> bool: """Check if an email is a calendar invite/update/cancellation. @@ -388,12 +425,15 @@ def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> b # If content is provided, check for calendar indicators if content: + # Decode base64 parts for proper text searching + decoded_content = _decode_mime_content(content).lower() + # Teams meeting indicators - if "microsoft teams meeting" in content.lower(): + if "microsoft teams meeting" in decoded_content: return True - if "join the meeting" in content.lower(): + if "join the meeting" in decoded_content: return True - # ICS content indicator + # ICS content indicator (check raw content for MIME headers) if "text/calendar" in content.lower(): return True # VCALENDAR block diff --git a/src/mail/utils/calendar_parser.py b/src/mail/utils/calendar_parser.py index fb0b2cf..77a197a 100644 --- a/src/mail/utils/calendar_parser.py +++ b/src/mail/utils/calendar_parser.py @@ -233,19 +233,135 @@ def parse_ics_content(ics_content: str) -> Optional[ParsedCalendarEvent]: return None +def _decode_mime_text(raw_message: str) -> str: + """Decode base64 text parts from MIME message. + + Args: + raw_message: Raw MIME message + + Returns: + Decoded text content + """ + decoded_parts = [] + + # Find and decode base64 text parts + b64_pattern = re.compile( + r"Content-Type:\s*text/(?:plain|html)[^\n]*\n" + r"(?:[^\n]+\n)*?" + r"Content-Transfer-Encoding:\s*base64[^\n]*\n" + r"(?:[^\n]+\n)*?" + r"\n" + r"([A-Za-z0-9+/=\s]+)", + re.IGNORECASE, + ) + + for match in b64_pattern.finditer(raw_message): + try: + b64_content = ( + match.group(1).replace("\n", "").replace("\r", "").replace(" ", "") + ) + decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace") + decoded_parts.append(decoded) + except Exception: + pass + + return "\n".join(decoded_parts) if decoded_parts else raw_message + + +def extract_teams_meeting_info(raw_message: str) -> Optional[ParsedCalendarEvent]: + """Extract Teams meeting info from email body when no ICS is present. + + This handles emails that contain Teams meeting details in the body + but don't have an ICS calendar attachment. + + Args: + raw_message: Full raw email in EML/MIME format + + Returns: + ParsedCalendarEvent with Teams meeting info, or None if not a Teams meeting + """ + # Decode the message content + content = _decode_mime_text(raw_message) + content_lower = content.lower() + + # Check if this is a Teams meeting email + if ( + "microsoft teams" not in content_lower + and "join the meeting" not in content_lower + ): + return None + + # Extract Teams meeting URL + teams_url_pattern = re.compile( + r"https://teams\.microsoft\.com/l/meetup-join/[^\s<>\"']+", + re.IGNORECASE, + ) + teams_url_match = teams_url_pattern.search(content) + teams_url = teams_url_match.group(0) if teams_url_match else None + + # Extract meeting ID + meeting_id_pattern = re.compile(r"Meeting ID:\s*([\d\s]+)", re.IGNORECASE) + meeting_id_match = meeting_id_pattern.search(content) + meeting_id = meeting_id_match.group(1).strip() if meeting_id_match else None + + # Extract subject from email headers + subject = None + subject_match = re.search( + r"^Subject:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE + ) + if subject_match: + subject = subject_match.group(1).strip() + + # Extract organizer from From header + organizer_email = None + organizer_name = None + from_match = re.search(r"^From:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE) + if from_match: + from_value = from_match.group(1).strip() + # Parse "Name " format + email_match = re.search(r"<([^>]+)>", from_value) + if email_match: + organizer_email = email_match.group(1) + organizer_name = from_value.split("<")[0].strip().strip('"') + else: + organizer_email = from_value + + # Create location string with Teams info + location = teams_url if teams_url else "Microsoft Teams Meeting" + if meeting_id: + location = f"Teams Meeting (ID: {meeting_id})" + + return ParsedCalendarEvent( + summary=subject or "Teams Meeting", + location=location, + description=f"Join: {teams_url}" if teams_url else None, + method="TEAMS", # Custom method to indicate this is extracted, not from ICS + organizer_name=organizer_name, + organizer_email=organizer_email, + ) + + def parse_calendar_from_raw_message(raw_message: str) -> Optional[ParsedCalendarEvent]: """Extract and parse calendar event from raw email message. + First tries to extract ICS content from the message. If no ICS is found, + falls back to extracting Teams meeting info from the email body. + Args: raw_message: Full raw email in EML/MIME format Returns: ParsedCalendarEvent if found and parsed, None otherwise """ + # First try to extract ICS content ics_content = extract_ics_from_mime(raw_message) if ics_content: - return parse_ics_content(ics_content) - return None + event = parse_ics_content(ics_content) + if event: + return event + + # Fall back to extracting Teams meeting info from body + return extract_teams_meeting_info(raw_message) # Legacy function names for compatibility diff --git a/src/mail/widgets/CalendarInvitePanel.py b/src/mail/widgets/CalendarInvitePanel.py index e211833..e83532f 100644 --- a/src/mail/widgets/CalendarInvitePanel.py +++ b/src/mail/widgets/CalendarInvitePanel.py @@ -132,7 +132,7 @@ class CalendarInvitePanel(Vertical): classes="event-detail", ) - # Action buttons (only for REQUEST method, not for CANCEL) + # Action buttons (only for REQUEST method, not for CANCEL or TEAMS) if is_event_request(self.event): with Horizontal(classes="action-buttons"): yield Button( @@ -150,6 +150,20 @@ class CalendarInvitePanel(Vertical): id="btn-decline", variant="error", ) + elif self.event.method == "TEAMS": + # Teams meeting extracted from email body (no ICS) + # Show join button if we have a URL in the description + if self.event.description and "Join:" in self.event.description: + with Horizontal(classes="action-buttons"): + yield Button( + "\uf0c1 Join Meeting", # nf-fa-link + id="btn-join", + variant="primary", + ) + yield Static( + "[dim]Teams meeting - no calendar invite attached[/dim]", + classes="event-detail", + ) elif is_cancelled_event(self.event): yield Static( "[dim]This meeting has been cancelled[/dim]", @@ -164,6 +178,8 @@ class CalendarInvitePanel(Vertical): return "CANCELLED", "cancelled" elif method == "REQUEST": return "INVITE", "request" + elif method == "TEAMS": + return "TEAMS", "request" elif method == "REPLY": return "REPLY", "reply" elif method == "COUNTER": @@ -188,3 +204,16 @@ class CalendarInvitePanel(Vertical): self.post_message(self.InviteAction("tentative", self.event)) elif button_id == "btn-decline": self.post_message(self.InviteAction("decline", self.event)) + elif button_id == "btn-join": + # Open Teams meeting URL + if self.event.description and "Join:" in self.event.description: + import re + import subprocess + + url_match = re.search( + r"Join:\s*(https://[^\s]+)", self.event.description + ) + if url_match: + url = url_match.group(1) + subprocess.run(["open", url], capture_output=True) + self.app.notify("Opening Teams meeting...", severity="information")