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 (
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user