feat: Fetch ICS attachments from Graph API when skipped during sync
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user