diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index 9ef7800..7054ff6 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -16,6 +16,7 @@ from src.mail.notification_detector import NotificationType, is_calendar_email from src.mail.utils.calendar_parser import ( ParsedCalendarEvent, parse_calendar_from_raw_message, + parse_ics_content, ) from src.mail.widgets.CalendarInvitePanel import CalendarInvitePanel import logging @@ -402,8 +403,31 @@ class ContentContainer(Vertical): self.current_envelope, content=raw_content if raw_success else None ) + calendar_event = None if is_calendar and raw_success and raw_content: calendar_event = parse_calendar_from_raw_message(raw_content) + + # If attachments were skipped during sync, try to fetch ICS from Graph API + # This handles both: + # 1. TEAMS method (Teams meeting detected but no ICS in message) + # 2. No calendar_event parsed but we detected calendar email patterns + if "X-Attachments-Skipped" in raw_content: + should_fetch_ics = ( + # No calendar event parsed at all + calendar_event is None + # Or we got a TEAMS fallback (no real ICS found) + or calendar_event.method == "TEAMS" + ) + + if should_fetch_ics: + # Try to fetch ICS from Graph API + ics_content = await self._fetch_ics_from_graph(raw_content) + if ics_content: + # Re-parse with the actual ICS + real_event = parse_ics_content(ics_content) + if real_event: + calendar_event = real_event + if calendar_event: self._show_calendar_panel(calendar_event) else: @@ -419,6 +443,54 @@ class ContentContainer(Vertical): else: self.notify(f"Failed to fetch content for message ID {message_id}.") + async def _fetch_ics_from_graph(self, raw_content: str) -> str | None: + """Fetch ICS attachment from Graph API using the message ID in headers. + + Args: + raw_content: Raw MIME content containing Message-ID header + + Returns: + ICS content string if found, None otherwise + """ + import re + + # Extract Graph message ID from Message-ID header + # Format: Message-ID: \n AAkALg... + match = re.search( + r"Message-ID:\s*\n?\s*([A-Za-z0-9+/=-]+)", + raw_content, + re.IGNORECASE, + ) + if not match: + return None + + graph_message_id = match.group(1).strip() + + try: + # Get auth headers + from src.services.microsoft_graph.auth import get_access_token + from src.services.microsoft_graph.mail import fetch_message_ics_attachment + + # Use Mail.Read scope for reading attachments + scopes = ["https://graph.microsoft.com/Mail.Read"] + token, _ = get_access_token(scopes) + if not token: + return None + + headers = {"Authorization": f"Bearer {token}"} + + ics_content, success = await fetch_message_ics_attachment( + graph_message_id, headers + ) + + if success and ics_content: + return ics_content + + except Exception as e: + logging.error(f"Error fetching ICS from Graph: {e}") + + return None + def display_content( self, message_id: int, diff --git a/src/services/microsoft_graph/mail.py b/src/services/microsoft_graph/mail.py index 1e78aee..215e5c4 100644 --- a/src/services/microsoft_graph/mail.py +++ b/src/services/microsoft_graph/mail.py @@ -1406,3 +1406,98 @@ async def process_outbox_async( progress.console.print(f"✗ Failed to send {failed_sends} emails") return successful_sends, failed_sends + + +async def fetch_message_ics_attachment( + graph_message_id: str, + headers: Dict[str, str], +) -> tuple[str | None, bool]: + """ + Fetch the ICS calendar attachment from a message via Microsoft Graph API. + + Args: + graph_message_id: The Microsoft Graph API message ID + headers: Authentication headers for Microsoft Graph API + + Returns: + Tuple of (ICS content string or None, success boolean) + """ + from urllib.parse import quote + + try: + # URL-encode the message ID (may contain special chars like = + /) + encoded_id = quote(graph_message_id, safe="") + + # Fetch attachments list for the message + attachments_url = ( + f"https://graph.microsoft.com/v1.0/me/messages/{encoded_id}/attachments" + ) + + response = await fetch_with_aiohttp(attachments_url, headers) + + attachments = response.get("value", []) + + for attachment in attachments: + content_type = (attachment.get("contentType") or "").lower() + name = (attachment.get("name") or "").lower() + + # Look for calendar attachments (text/calendar or application/ics) + if "calendar" in content_type or name.endswith(".ics"): + # contentBytes is base64-encoded + content_bytes = attachment.get("contentBytes") + if content_bytes: + import base64 + + decoded = base64.b64decode(content_bytes) + return decoded.decode("utf-8", errors="replace"), True + + # No ICS attachment found + return None, True + + except Exception as e: + import logging + + logging.error(f"Error fetching ICS attachment: {e}") + return None, False + + +async def fetch_message_with_ics( + graph_message_id: str, + headers: Dict[str, str], +) -> tuple[str | None, bool]: + """ + Fetch the full MIME content of a message including ICS attachment. + + This fetches the raw $value of the message which includes all MIME parts. + + Args: + graph_message_id: The Microsoft Graph API message ID + headers: Authentication headers for Microsoft Graph API + + Returns: + Tuple of (raw MIME content or None, success boolean) + """ + import aiohttp + + try: + # Fetch the raw MIME content + mime_url = ( + f"https://graph.microsoft.com/v1.0/me/messages/{graph_message_id}/$value" + ) + + async with aiohttp.ClientSession() as session: + async with session.get(mime_url, headers=headers) as response: + if response.status == 200: + content = await response.text() + return content, True + else: + import logging + + logging.error(f"Failed to fetch MIME content: {response.status}") + return None, False + + except Exception as e: + import logging + + logging.error(f"Error fetching MIME content: {e}") + return None, False