feat: Fetch ICS attachments from Graph API when skipped during sync

This commit is contained in:
Bendt
2026-01-07 09:24:53 -05:00
parent f8a179e096
commit 86297ae350
2 changed files with 167 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ from src.mail.notification_detector import NotificationType, is_calendar_email
from src.mail.utils.calendar_parser import ( from src.mail.utils.calendar_parser import (
ParsedCalendarEvent, ParsedCalendarEvent,
parse_calendar_from_raw_message, parse_calendar_from_raw_message,
parse_ics_content,
) )
from src.mail.widgets.CalendarInvitePanel import CalendarInvitePanel from src.mail.widgets.CalendarInvitePanel import CalendarInvitePanel
import logging import logging
@@ -402,8 +403,31 @@ class ContentContainer(Vertical):
self.current_envelope, content=raw_content if raw_success else None self.current_envelope, content=raw_content if raw_success else None
) )
calendar_event = None
if is_calendar and raw_success and raw_content: if is_calendar and raw_success and raw_content:
calendar_event = parse_calendar_from_raw_message(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: if calendar_event:
self._show_calendar_panel(calendar_event) self._show_calendar_panel(calendar_event)
else: else:
@@ -419,6 +443,54 @@ class ContentContainer(Vertical):
else: else:
self.notify(f"Failed to fetch content for message ID {message_id}.") 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( def display_content(
self, self,
message_id: int, message_id: int,

View File

@@ -1406,3 +1406,98 @@ async def process_outbox_async(
progress.console.print(f"✗ Failed to send {failed_sends} emails") progress.console.print(f"✗ Failed to send {failed_sends} emails")
return successful_sends, failed_sends 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