From efe417b41ae54bd7828f3168dd5690bb472b3840 Mon Sep 17 00:00:00 2001 From: Bendt Date: Fri, 2 Jan 2026 10:20:10 -0500 Subject: [PATCH] calendar replies --- .coverage | Bin 69632 -> 69632 bytes src/cli/sync.py | 6 +- src/cli/sync_dashboard.py | 2 +- src/mail/actions/calendar_invite.py | 481 +++++++++++++++++---------- src/mail/utils/calendar_parser.py | 13 + src/services/microsoft_graph/mail.py | 97 +++++- 6 files changed, 411 insertions(+), 188 deletions(-) diff --git a/.coverage b/.coverage index 07729746398bf2cef23d98cfb17045ee5c6663f0..8ce7f7c43c9938bde47a7aa0b96db77430cc0f69 100644 GIT binary patch delta 5389 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 literal 69632 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 O*V9f+lO~mcrvC+y`Qm#3 diff --git a/src/cli/sync.py b/src/cli/sync.py index a8ad815..5222787 100644 --- a/src/cli/sync.py +++ b/src/cli/sync.py @@ -425,7 +425,7 @@ async def _sync_outlook_data( # Define scopes for Microsoft Graph API scopes = [ - "https://graph.microsoft.com/Calendars.ReadWrite", + "https://graph.microsoft.com/Calendars.Read", "https://graph.microsoft.com/Mail.ReadWrite", ] @@ -721,7 +721,7 @@ def sync( # This prevents the TUI from appearing to freeze during device flow auth if not demo: scopes = [ - "https://graph.microsoft.com/Calendars.ReadWrite", + "https://graph.microsoft.com/Calendars.Read", "https://graph.microsoft.com/Mail.ReadWrite", ] if not has_valid_cached_token(scopes): @@ -963,7 +963,7 @@ def interactive(org, vdir, notify, dry_run, demo): # This prevents the TUI from appearing to freeze during device flow auth if not demo: scopes = [ - "https://graph.microsoft.com/Calendars.ReadWrite", + "https://graph.microsoft.com/Calendars.Read", "https://graph.microsoft.com/Mail.ReadWrite", ] if not has_valid_cached_token(scopes): diff --git a/src/cli/sync_dashboard.py b/src/cli/sync_dashboard.py index 369d741..94d0a4c 100644 --- a/src/cli/sync_dashboard.py +++ b/src/cli/sync_dashboard.py @@ -1103,7 +1103,7 @@ async def run_dashboard_sync( # Get auth token scopes = [ - "https://graph.microsoft.com/Calendars.ReadWrite", + "https://graph.microsoft.com/Calendars.Read", "https://graph.microsoft.com/Mail.ReadWrite", ] access_token, headers = get_access_token(scopes) diff --git a/src/mail/actions/calendar_invite.py b/src/mail/actions/calendar_invite.py index 346027f..b7f093e 100644 --- a/src/mail/actions/calendar_invite.py +++ b/src/mail/actions/calendar_invite.py @@ -1,14 +1,22 @@ """Calendar invite actions for mail app. -Allows responding to calendar invites directly from email. +Allows responding to calendar invites directly from email using ICS/SMTP. + +Uses the iTIP (iCalendar Transport-Independent Interoperability Protocol) +standard to send REPLY messages via email instead of requiring Calendar.ReadWrite +API permissions. """ -import asyncio -import aiohttp import logging import os +import time +from datetime import datetime, timezone +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText from typing import Optional, Tuple +from src.mail.utils.calendar_parser import ParsedCalendarEvent + # Set up dedicated RSVP logger rsvp_logger = logging.getLogger("calendar_rsvp") rsvp_logger.setLevel(logging.DEBUG) @@ -22,145 +30,306 @@ if not rsvp_logger.handlers: handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) rsvp_logger.addHandler(handler) -# Timeout for API calls (seconds) -API_TIMEOUT = 15 -# Required scopes for calendar operations -CALENDAR_SCOPES = [ - "https://graph.microsoft.com/Calendars.ReadWrite", -] - - -def _get_auth_headers_sync() -> Optional[dict]: - """Get auth headers synchronously using cached token only. - - Returns None if no valid cached token exists (to avoid blocking on device flow). - """ - from src.services.microsoft_graph.auth import ( - has_valid_cached_token, - get_access_token, - ) - - rsvp_logger.debug("Checking for valid cached token...") - - if not has_valid_cached_token(CALENDAR_SCOPES): - rsvp_logger.warning("No valid cached token found") - return None - - try: - rsvp_logger.debug("Getting access token from cache...") - _, headers = get_access_token(CALENDAR_SCOPES) - rsvp_logger.debug("Got auth headers successfully") - return headers - except Exception as e: - rsvp_logger.error(f"Failed to get auth headers: {e}") - return None - - -async def find_event_by_uid(uid: str, headers: dict) -> Optional[dict]: - """Find a calendar event by its iCalUId. - - Args: - uid: The iCalendar UID from the ICS file - headers: Auth headers for MS Graph API +def _get_user_email() -> Optional[str]: + """Get the current user's email address from MSAL cache. Returns: - Event dict if found, None otherwise + User's email address if found, None otherwise. """ - rsvp_logger.info(f"Looking up event by UID: {uid}") + import msal + + client_id = os.getenv("AZURE_CLIENT_ID") + tenant_id = os.getenv("AZURE_TENANT_ID") + + if not client_id or not tenant_id: + rsvp_logger.warning("Azure credentials not configured") + return None + + cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin") + if not os.path.exists(cache_file): + rsvp_logger.warning("Token cache file not found") + return None try: - # Search by iCalUId - this is the unique identifier that should match - uid_escaped = uid.replace("'", "''") - url = ( - f"https://graph.microsoft.com/v1.0/me/events?" - f"$filter=iCalUId eq '{uid_escaped}'&" - f"$select=id,subject,organizer,start,end,responseStatus,iCalUId" + cache = msal.SerializableTokenCache() + cache.deserialize(open(cache_file, "r").read()) + authority = f"https://login.microsoftonline.com/{tenant_id}" + app = msal.PublicClientApplication( + client_id, authority=authority, token_cache=cache ) + accounts = app.get_accounts() - rsvp_logger.debug(f"Request URL: {url}") - - # Use aiohttp directly with timeout - timeout = aiohttp.ClientTimeout(total=API_TIMEOUT) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url, headers=headers) as response: - rsvp_logger.debug(f"Response status: {response.status}") - - if response.status != 200: - error_text = await response.text() - rsvp_logger.error(f"API error: {response.status} - {error_text}") - return None - - data = await response.json() - events = data.get("value", []) - - rsvp_logger.info(f"Found {len(events)} events matching UID") - - if events: - event = events[0] - rsvp_logger.debug( - f"Event found: {event.get('subject')} - ID: {event.get('id')}" - ) - return event - - return None - - except asyncio.TimeoutError: - rsvp_logger.error(f"Timeout after {API_TIMEOUT}s looking up event by UID") + if accounts: + # The username field contains the user's email + return accounts[0].get("username") return None except Exception as e: - rsvp_logger.error(f"Error finding event by UID: {e}", exc_info=True) + rsvp_logger.error(f"Failed to get user email from MSAL: {e}") return None -async def respond_to_calendar_invite( - event_id: str, response: str, headers: dict -) -> Tuple[bool, str]: - """Respond to a calendar invite. +def _get_user_display_name() -> Optional[str]: + """Get the current user's display name from MSAL cache. + + Returns: + User's display name if found, None otherwise. + """ + import msal + + client_id = os.getenv("AZURE_CLIENT_ID") + tenant_id = os.getenv("AZURE_TENANT_ID") + + if not client_id or not tenant_id: + return None + + cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin") + if not os.path.exists(cache_file): + return None + + try: + cache = msal.SerializableTokenCache() + cache.deserialize(open(cache_file, "r").read()) + authority = f"https://login.microsoftonline.com/{tenant_id}" + app = msal.PublicClientApplication( + client_id, authority=authority, token_cache=cache + ) + accounts = app.get_accounts() + + if accounts: + # Try to get name from account, fallback to username + name = accounts[0].get("name") + if name: + return name + # Fallback: construct name from email + username = accounts[0].get("username", "") + if "@" in username: + local_part = username.split("@")[0] + # Convert firstname.lastname to Firstname Lastname + parts = local_part.replace(".", " ").replace("_", " ").split() + return " ".join(p.capitalize() for p in parts) + return None + except Exception as e: + rsvp_logger.debug(f"Failed to get display name: {e}") + return None + + +def generate_ics_reply( + event: ParsedCalendarEvent, + response: str, + attendee_email: str, + attendee_name: Optional[str] = None, +) -> str: + """Generate an iCalendar REPLY for a calendar invite. Args: - event_id: Microsoft Graph event ID + event: The parsed calendar event from the original invite + response: Response type - 'ACCEPTED', 'TENTATIVE', or 'DECLINED' + attendee_email: The attendee's email address + attendee_name: The attendee's display name (optional) + + Returns: + ICS content string formatted as an iTIP REPLY + """ + # Map response to PARTSTAT value + partstat_map = { + "accept": "ACCEPTED", + "tentativelyAccept": "TENTATIVE", + "decline": "DECLINED", + } + partstat = partstat_map.get(response, "ACCEPTED") + + # Generate DTSTAMP in UTC format + dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + # Build attendee line with proper formatting + if attendee_name: + attendee_line = ( + f'ATTENDEE;PARTSTAT={partstat};CN="{attendee_name}":MAILTO:{attendee_email}' + ) + else: + attendee_line = f"ATTENDEE;PARTSTAT={partstat}:MAILTO:{attendee_email}" + + # Build organizer line + if event.organizer_name: + organizer_line = ( + f'ORGANIZER;CN="{event.organizer_name}":MAILTO:{event.organizer_email}' + ) + else: + organizer_line = f"ORGANIZER:MAILTO:{event.organizer_email}" + + # Build the response subject prefix + response_prefix = { + "accept": "Accepted", + "tentativelyAccept": "Tentative", + "decline": "Declined", + }.get(response, "Accepted") + + summary = f"{response_prefix}: {event.summary or '(no subject)'}" + + # Build the ICS content following iTIP REPLY standard + ics_lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//LUK Mail//Calendar Reply//EN", + "METHOD:REPLY", + "BEGIN:VEVENT", + f"UID:{event.uid}", + f"DTSTAMP:{dtstamp}", + organizer_line, + attendee_line, + f"SEQUENCE:{event.sequence}", + f"SUMMARY:{summary}", + "END:VEVENT", + "END:VCALENDAR", + ] + + return "\r\n".join(ics_lines) + + +def build_calendar_reply_email( + event: ParsedCalendarEvent, + response: str, + from_email: str, + to_email: str, + from_name: Optional[str] = None, +) -> str: + """Build a MIME email with calendar REPLY attachment. + + The email is formatted according to iTIP/iMIP standards so that + Exchange/Outlook will recognize it as a calendar action. + + Args: + event: The parsed calendar event from the original invite response: Response type - 'accept', 'tentativelyAccept', or 'decline' - headers: Auth headers for MS Graph API + from_email: Sender's email address + to_email: Recipient's email address (the organizer) + from_name: Sender's display name (optional) + + Returns: + Complete RFC 5322 email as string + """ + # Generate the ICS reply content + ics_content = generate_ics_reply(event, response, from_email, from_name) + + # Build response text for email body + response_text = { + "accept": "accepted", + "tentativelyAccept": "tentatively accepted", + "decline": "declined", + }.get(response, "accepted") + + subject_prefix = { + "accept": "Accepted", + "tentativelyAccept": "Tentative", + "decline": "Declined", + }.get(response, "Accepted") + + subject = f"{subject_prefix}: {event.summary or '(no subject)'}" + + # Create the email message + msg = MIMEMultipart("mixed") + + # Set headers + if from_name: + msg["From"] = f'"{from_name}" <{from_email}>' + else: + msg["From"] = from_email + + msg["To"] = to_email + msg["Subject"] = subject + + # Add Content-Class header for Exchange compatibility + msg["Content-Class"] = "urn:content-classes:calendarmessage" + + # Create text body + body_text = f"This meeting has been {response_text}." + text_part = MIMEText(body_text, "plain", "utf-8") + msg.attach(text_part) + + # Create calendar part with proper iTIP headers + # The content-type must include method=REPLY for Exchange to recognize it + calendar_part = MIMEText(ics_content, "calendar", "utf-8") + calendar_part.set_param("method", "REPLY") + calendar_part.add_header("Content-Disposition", "attachment", filename="invite.ics") + msg.attach(calendar_part) + + return msg.as_string() + + +def queue_calendar_reply( + event: ParsedCalendarEvent, + response: str, + from_email: str, + to_email: str, + from_name: Optional[str] = None, +) -> Tuple[bool, str]: + """Queue a calendar reply email for sending via the outbox. + + Args: + event: The parsed calendar event from the original invite + response: Response type - 'accept', 'tentativelyAccept', or 'decline' + from_email: Sender's email address + to_email: Recipient's email address (the organizer) + from_name: Sender's display name (optional) Returns: Tuple of (success, message) """ - rsvp_logger.info(f"Responding to event {event_id} with: {response}") - try: - response_url = ( - f"https://graph.microsoft.com/v1.0/me/events/{event_id}/{response}" + # Build the email + email_content = build_calendar_reply_email( + event, response, from_email, to_email, from_name ) - rsvp_logger.debug(f"Response URL: {response_url}") - # Use aiohttp directly with timeout - timeout = aiohttp.ClientTimeout(total=API_TIMEOUT) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(response_url, headers=headers, json={}) as resp: - rsvp_logger.debug(f"Response status: {resp.status}") + # Determine organization from email domain + org = "default" + if "@" in from_email: + domain = from_email.split("@")[1].lower() + # Map known domains to org names (matching sendmail script logic) + domain_to_org = { + "corteva.com": "corteva", + } + org = domain_to_org.get(domain, domain.split(".")[0]) - if resp.status in (200, 202): - response_text = { - "accept": "accepted", - "tentativelyAccept": "tentatively accepted", - "decline": "declined", - }.get(response, response) - rsvp_logger.info(f"Successfully {response_text} the meeting") - return True, f"Successfully {response_text} the meeting" - else: - error_text = await resp.text() - rsvp_logger.error( - f"Failed to respond: {resp.status} - {error_text}" - ) - return False, f"Failed to respond: {resp.status}" + # Queue the email in the outbox + base_path = os.path.expanduser(os.getenv("MAILDIR_PATH", "~/Mail")) + outbox_path = os.path.join(base_path, org, "outbox") + + # Ensure directories exist + for subdir in ["new", "cur", "tmp", "failed"]: + dir_path = os.path.join(outbox_path, subdir) + os.makedirs(dir_path, exist_ok=True) + + # Generate unique filename + timestamp = str(int(time.time() * 1000000)) + hostname = os.uname().nodename + filename = f"{timestamp}.{os.getpid()}.{hostname}" + + # Write to tmp first, then move to new (atomic operation) + tmp_path = os.path.join(outbox_path, "tmp", filename) + new_path = os.path.join(outbox_path, "new", filename) + + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(email_content) + + os.rename(tmp_path, new_path) + + response_text = { + "accept": "accepted", + "tentativelyAccept": "tentatively accepted", + "decline": "declined", + }.get(response, "accepted") + + rsvp_logger.info( + f"Queued calendar reply: {response_text} for '{event.summary}' to {event.organizer_email}" + ) + + return True, f"Response queued - will be sent on next sync" - except asyncio.TimeoutError: - rsvp_logger.error(f"Timeout after {API_TIMEOUT}s responding to invite") - return False, f"Request timed out after {API_TIMEOUT}s" except Exception as e: - rsvp_logger.error(f"Error responding to invite: {e}", exc_info=True) - return False, f"Error: {str(e)}" + rsvp_logger.error(f"Failed to queue calendar reply: {e}", exc_info=True) + return False, f"Failed to queue response: {str(e)}" def action_accept_invite(app): @@ -179,7 +348,7 @@ def action_tentative_invite(app): def _respond_to_current_invite(app, response: str): - """Helper to respond to the current message's calendar invite.""" + """Helper to respond to the current message's calendar invite using ICS/SMTP.""" from src.mail.widgets.ContentContainer import ContentContainer rsvp_logger.info(f"Starting invite response: {response}") @@ -190,18 +359,19 @@ def _respond_to_current_invite(app, response: str): app.notify("No message selected", severity="warning") return - # Get auth headers FIRST (synchronously, before spawning worker) - # This uses cached token only - won't block on device flow - headers = _get_auth_headers_sync() - if not headers: - rsvp_logger.error("No valid auth token - user needs to run luk sync first") + # Get user's email from MSAL cache + user_email = _get_user_email() + if not user_email: + rsvp_logger.error("Could not determine user email - run 'luk sync' first") app.notify( - "Not authenticated. Run 'luk sync' first to login.", severity="error" + "Could not determine your email. Run 'luk sync' first.", severity="error" ) return + user_name = _get_user_display_name() + rsvp_logger.debug(f"User: {user_name} <{user_email}>") + # Get the parsed calendar event from ContentContainer - # This has the UID from the ICS which we can use for direct lookup calendar_event = None try: content_container = app.query_one(ContentContainer) @@ -216,61 +386,36 @@ def _respond_to_current_invite(app, response: str): event_uid = calendar_event.uid event_summary = calendar_event.summary or "(no subject)" + organizer_email = calendar_event.organizer_email - rsvp_logger.info(f"Calendar event: {event_summary}, UID: {event_uid}") + rsvp_logger.info( + f"Calendar event: {event_summary}, UID: {event_uid}, Organizer: {organizer_email}" + ) if not event_uid: rsvp_logger.warning("No UID found in calendar event") app.notify("Calendar invite missing UID - cannot respond", severity="warning") return - app.run_worker( - _async_respond_to_invite(app, event_uid, event_summary, response, headers), - exclusive=True, - name="respond_invite", - ) - - -async def _async_respond_to_invite( - app, event_uid: str, event_summary: str, response: str, headers: dict -): - """Async worker to find and respond to calendar invite using UID.""" - rsvp_logger.info(f"Async response started for UID: {event_uid}") - - app.notify(f"Looking up event...") - - # Find event by UID (direct lookup, no search needed) - graph_event = await find_event_by_uid(event_uid, headers) - - if not graph_event: - rsvp_logger.warning(f"Event not found for UID: {event_uid}") + if not organizer_email: + rsvp_logger.warning("No organizer email found in calendar event") app.notify( - f"Event not found in calendar: {event_summary[:40]}", - severity="warning", + "Calendar invite missing organizer - cannot respond", severity="warning" ) return - event_id = graph_event.get("id") - if not event_id: - rsvp_logger.error("No event ID in response") - app.notify("Could not get event ID from calendar", severity="error") - return - - current_response = graph_event.get("responseStatus", {}).get("response", "") - rsvp_logger.debug(f"Current response status: {current_response}") - - # Check if already responded - if current_response == "accepted" and response == "accept": - rsvp_logger.info("Already accepted") - app.notify("Already accepted this invite", severity="information") - return - elif current_response == "declined" and response == "decline": - rsvp_logger.info("Already declined") - app.notify("Already declined this invite", severity="information") - return - - # Respond to the invite - success, message = await respond_to_calendar_invite(event_id, response, headers) + # Queue the calendar reply (organizer_email is guaranteed non-None here) + success, message = queue_calendar_reply( + calendar_event, response, user_email, organizer_email, user_name + ) severity = "information" if success else "error" app.notify(message, severity=severity) + + if success: + response_text = { + "accept": "Accepted", + "tentativelyAccept": "Tentatively accepted", + "decline": "Declined", + }.get(response, "Responded to") + rsvp_logger.info(f"{response_text} invite: {event_summary}") diff --git a/src/mail/utils/calendar_parser.py b/src/mail/utils/calendar_parser.py index c7a6d64..fb0b2cf 100644 --- a/src/mail/utils/calendar_parser.py +++ b/src/mail/utils/calendar_parser.py @@ -41,6 +41,9 @@ class ParsedCalendarEvent: # UID for matching with Graph API uid: Optional[str] = None + # Sequence number for iTIP REPLY + sequence: int = 0 + def extract_ics_from_mime(raw_message: str) -> Optional[str]: """Extract ICS calendar content from raw MIME message. @@ -200,6 +203,15 @@ def parse_ics_content(ics_content: str) -> Optional[ParsedCalendarEvent]: if dtend: end_dt = dtend.dt + # Extract sequence number (defaults to 0) + sequence = 0 + seq_val = event.get("sequence") + if seq_val is not None: + try: + sequence = int(seq_val) + except (ValueError, TypeError): + sequence = 0 + return ParsedCalendarEvent( summary=str(event.get("summary", "")) or None, location=str(event.get("location", "")) or None, @@ -213,6 +225,7 @@ def parse_ics_content(ics_content: str) -> Optional[ParsedCalendarEvent]: attendees=attendees, status=str(event.get("status", "")).upper() or None, uid=str(event.get("uid", "")) or None, + sequence=sequence, ) except Exception as e: diff --git a/src/services/microsoft_graph/mail.py b/src/services/microsoft_graph/mail.py index 26948b5..6e9953f 100644 --- a/src/services/microsoft_graph/mail.py +++ b/src/services/microsoft_graph/mail.py @@ -2,6 +2,7 @@ Mail operations for Microsoft Graph API. """ +import base64 import os import re import glob @@ -860,30 +861,90 @@ def parse_email_for_graph_api(email_content: str) -> Dict[str, Any]: cc_recipients = parse_recipients(msg.get("Cc", "")) bcc_recipients = parse_recipients(msg.get("Bcc", "")) - # Get body content + # Get body content and attachments body_content = "" body_type = "text" + attachments: List[Dict[str, Any]] = [] if msg.is_multipart(): for part in msg.walk(): - if part.get_content_type() == "text/plain": - body_content = part.get_payload(decode=True).decode( - "utf-8", errors="ignore" - ) - body_type = "text" - break - elif part.get_content_type() == "text/html": - body_content = part.get_payload(decode=True).decode( - "utf-8", errors="ignore" - ) - body_type = "html" + content_type = part.get_content_type() + content_disposition = part.get("Content-Disposition", "") + + # Skip multipart containers + if content_type.startswith("multipart/"): + continue + + # Handle text/plain body + if content_type == "text/plain" and "attachment" not in content_disposition: + payload = part.get_payload(decode=True) + if payload: + body_content = payload.decode("utf-8", errors="ignore") + body_type = "text" + + # Handle text/html body + elif ( + content_type == "text/html" and "attachment" not in content_disposition + ): + payload = part.get_payload(decode=True) + if payload: + body_content = payload.decode("utf-8", errors="ignore") + body_type = "html" + + # Handle calendar attachments (text/calendar) + elif content_type == "text/calendar": + payload = part.get_payload(decode=True) + if payload: + # Get filename from Content-Disposition or use default + filename = part.get_filename() or "invite.ics" + + # Base64 encode the content for Graph API + content_bytes = ( + payload + if isinstance(payload, bytes) + else payload.encode("utf-8") + ) + + attachments.append( + { + "@odata.type": "#microsoft.graph.fileAttachment", + "name": filename, + "contentType": "text/calendar; method=REPLY", + "contentBytes": base64.b64encode(content_bytes).decode( + "ascii" + ), + } + ) + + # Handle other attachments + elif "attachment" in content_disposition or part.get_filename(): + payload = part.get_payload(decode=True) + if payload: + filename = part.get_filename() or "attachment" + content_bytes = ( + payload + if isinstance(payload, bytes) + else payload.encode("utf-8") + ) + attachments.append( + { + "@odata.type": "#microsoft.graph.fileAttachment", + "name": filename, + "contentType": content_type, + "contentBytes": base64.b64encode(content_bytes).decode( + "ascii" + ), + } + ) else: - body_content = msg.get_payload(decode=True).decode("utf-8", errors="ignore") - if msg.get_content_type() == "text/html": - body_type = "html" + payload = msg.get_payload(decode=True) + if payload: + body_content = payload.decode("utf-8", errors="ignore") + if msg.get_content_type() == "text/html": + body_type = "html" # Build Graph API message - message = { + message: Dict[str, Any] = { "subject": msg.get("Subject", ""), "body": {"contentType": body_type, "content": body_content}, "toRecipients": to_recipients, @@ -891,6 +952,10 @@ def parse_email_for_graph_api(email_content: str) -> Dict[str, Any]: "bccRecipients": bcc_recipients, } + # Add attachments if present + if attachments: + message["attachments"] = attachments + # Add reply-to if present reply_to = msg.get("Reply-To", "") if reply_to: