diff --git a/pyproject.toml b/pyproject.toml index e16b2a4..b7418ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "certifi>=2025.4.26", "click>=8.1.0", "html2text>=2025.4.15", + "icalendar>=6.0.0", "mammoth>=1.9.0", "markitdown[all]>=0.1.1", "msal>=1.32.3", diff --git a/src/calendar/widgets/WeekGrid.py b/src/calendar/widgets/WeekGrid.py index f502512..b35860a 100644 --- a/src/calendar/widgets/WeekGrid.py +++ b/src/calendar/widgets/WeekGrid.py @@ -199,7 +199,7 @@ class WeekGridHeader(Widget): style = Style(bold=True, reverse=True) elif day == today: # Highlight today with theme secondary color - style = Style(bold=True, color="white", bgcolor=secondary_color) + style = Style(bold=True, color=secondary_color) elif day.weekday() >= 5: # Weekend style = Style(color="bright_black") else: diff --git a/src/mail/notification_detector.py b/src/mail/notification_detector.py index 93c1af5..e4c00a1 100644 --- a/src/mail/notification_detector.py +++ b/src/mail/notification_detector.py @@ -84,9 +84,7 @@ NOTIFICATION_TYPES = [ ] -def is_notification_email( - envelope: dict[str, Any], content: str | None = None -) -> bool: +def is_notification_email(envelope: dict[str, Any], content: str | None = None) -> bool: """Check if an email is a notification-style email. Args: @@ -341,3 +339,43 @@ def _extract_general_notification_summary(content: str) -> dict[str, Any]: summary["action_items"] = summary["action_items"][:5] return summary + + +# Calendar email patterns +CALENDAR_SUBJECT_PATTERNS = [ + r"^canceled:", + r"^cancelled:", + r"^accepted:", + r"^declined:", + r"^tentative:", + r"^updated:", + r"^invitation:", + r"^meeting\s+(request|update|cancel)", + r"^\[calendar\]", + r"invite\s+you\s+to", + r"has\s+invited\s+you", +] + + +def is_calendar_email(envelope: dict[str, Any]) -> bool: + """Check if an email is a calendar invite/update/cancellation. + + Args: + envelope: Email envelope metadata from himalaya + + Returns: + True if email appears to be a calendar-related email + """ + subject = envelope.get("subject", "").lower().strip() + + # Check subject patterns + for pattern in CALENDAR_SUBJECT_PATTERNS: + if re.search(pattern, subject, re.IGNORECASE): + return True + + # Check for meeting-related keywords in subject + meeting_keywords = ["meeting", "appointment", "calendar", "invite", "rsvp"] + if any(keyword in subject for keyword in meeting_keywords): + return True + + return False diff --git a/src/mail/utils/calendar_parser.py b/src/mail/utils/calendar_parser.py index 98295b9..c7a6d64 100644 --- a/src/mail/utils/calendar_parser.py +++ b/src/mail/utils/calendar_parser.py @@ -1,11 +1,16 @@ """Calendar ICS file parser utilities.""" import base64 +import re from typing import Optional, List -from dataclasses import dataclass -from icalendar import Calendar +from dataclasses import dataclass, field +from datetime import datetime import logging -from pathlib import Path + +try: + from icalendar import Calendar +except ImportError: + Calendar = None # type: ignore @dataclass @@ -16,11 +21,11 @@ class ParsedCalendarEvent: summary: Optional[str] = None location: Optional[str] = None description: Optional[str] = None - start: Optional[str] = None - end: Optional[str] = None + start: Optional[datetime] = None + end: Optional[datetime] = None all_day: bool = False - # Calendar method + # Calendar method (REQUEST, CANCEL, REPLY, etc.) method: Optional[str] = None # Organizer @@ -28,75 +33,266 @@ class ParsedCalendarEvent: organizer_email: Optional[str] = None # Attendees - attendees: List[str] = [] + attendees: List[str] = field(default_factory=list) - # Status + # Status (CONFIRMED, TENTATIVE, CANCELLED) status: Optional[str] = None + # UID for matching with Graph API + uid: Optional[str] = None -def parse_calendar_part(content: str) -> Optional[ParsedCalendarEvent]: - """Parse calendar MIME part content.""" + +def extract_ics_from_mime(raw_message: str) -> Optional[str]: + """Extract ICS calendar content from raw MIME message. + + Looks for text/calendar parts and base64-decoded .ics attachments. + + Args: + raw_message: Full raw email in EML/MIME format + + Returns: + ICS content string if found, None otherwise + """ + # Pattern 1: Look for inline text/calendar content + # Content-Type: text/calendar followed by the ICS content + calendar_pattern = re.compile( + r"Content-Type:\s*text/calendar[^\n]*\n" + r"(?:Content-Transfer-Encoding:\s*(\w+)[^\n]*\n)?" + r"(?:[^\n]+\n)*?" # Other headers + r"\n" # Empty line before content + r"(BEGIN:VCALENDAR.*?END:VCALENDAR)", + re.DOTALL | re.IGNORECASE, + ) + + match = calendar_pattern.search(raw_message) + if match: + encoding = match.group(1) + ics_content = match.group(2) + + if encoding and encoding.lower() == "base64": + try: + # Remove line breaks and decode + ics_bytes = base64.b64decode( + ics_content.replace("\n", "").replace("\r", "") + ) + return ics_bytes.decode("utf-8", errors="replace") + except Exception as e: + logging.debug(f"Failed to decode base64 ICS: {e}") + else: + return ics_content + + # Pattern 2: Look for base64-encoded text/calendar + base64_pattern = re.compile( + r"Content-Type:\s*text/calendar[^\n]*\n" + r"Content-Transfer-Encoding:\s*base64[^\n]*\n" + r"(?:[^\n]+\n)*?" # Other headers + r"\n" # Empty line before content + r"([A-Za-z0-9+/=\s]+)", + re.IGNORECASE, + ) + + match = base64_pattern.search(raw_message) + if match: + try: + b64_content = ( + match.group(1).replace("\n", "").replace("\r", "").replace(" ", "") + ) + ics_bytes = base64.b64decode(b64_content) + return ics_bytes.decode("utf-8", errors="replace") + except Exception as e: + logging.debug(f"Failed to decode base64 calendar: {e}") + + # Pattern 3: Just look for raw VCALENDAR block + vcal_pattern = re.compile(r"(BEGIN:VCALENDAR.*?END:VCALENDAR)", re.DOTALL) + match = vcal_pattern.search(raw_message) + if match: + return match.group(1) + + return None + + +def parse_ics_content(ics_content: str) -> Optional[ParsedCalendarEvent]: + """Parse ICS calendar content into a ParsedCalendarEvent. + + Args: + ics_content: Raw ICS/iCalendar content string + + Returns: + ParsedCalendarEvent if parsing succeeded, None otherwise + """ + if Calendar is None: + logging.warning("icalendar library not installed, cannot parse ICS") + return None try: - calendar = Calendar.from_ical(content) + # Handle bytes input + if isinstance(ics_content, bytes): + ics_content = ics_content.decode("utf-8", errors="replace") - # Get first event (most invites are single events) - if calendar.events: - event = calendar.events[0] + calendar = Calendar.from_ical(ics_content) - # Extract organizer - organizer = event.get("organizer") - if organizer: - organizer_name = organizer.cn if organizer else None - organizer_email = organizer.email if organizer else None + # METHOD is a calendar-level property, not event-level + method = str(calendar.get("method", "")).upper() or None - # Extract attendees - attendees = [] - if event.get("attendees"): - for attendee in event.attendees: - email = attendee.email if attendee else None - name = attendee.cn if attendee else None - if email: - attendees.append(f"{name} ({email})" if name else email) - else: - attendees.append(email) - return ParsedCalendarEvent( - summary=event.get("summary"), - location=event.get("location"), - description=event.get("description"), - start=str(event.get("dtstart")) if event.get("dtstart") else None, - end=str(event.get("dtend")) if event.get("dtend") else None, - all_day=event.get("x-google", "all-day") == "true", - method=event.get("method"), - organizer_name=organizer_name, - organizer_email=organizer_email, - attendees=attendees, - status=event.get("status"), + # Get first VEVENT component + events = [c for c in calendar.walk() if c.name == "VEVENT"] + if not events: + logging.debug("No VEVENT found in calendar") + return None + + event = events[0] + + # Extract organizer info + organizer_name = None + organizer_email = None + organizer = event.get("organizer") + if organizer: + # Organizer can be a vCalAddress object + organizer_name = ( + str(organizer.params.get("CN", "")) + if hasattr(organizer, "params") + else None ) + # Extract email from mailto: URI + organizer_str = str(organizer) + if organizer_str.lower().startswith("mailto:"): + organizer_email = organizer_str[7:] + else: + organizer_email = organizer_str + + # Extract attendees + attendees = [] + attendee_list = event.get("attendee") + if attendee_list: + # Can be a single attendee or a list + if not isinstance(attendee_list, list): + attendee_list = [attendee_list] + for att in attendee_list: + att_str = str(att) + if att_str.lower().startswith("mailto:"): + att_email = att_str[7:] + else: + att_email = att_str + att_name = ( + str(att.params.get("CN", "")) if hasattr(att, "params") else None + ) + if att_name and att_email: + attendees.append(f"{att_name} <{att_email}>") + elif att_email: + attendees.append(att_email) + + # Extract start/end times + start_dt = None + end_dt = None + all_day = False + + dtstart = event.get("dtstart") + if dtstart: + dt_val = dtstart.dt + if hasattr(dt_val, "hour"): + start_dt = dt_val + else: + # Date only = all day event + start_dt = dt_val + all_day = True + + dtend = event.get("dtend") + if dtend: + end_dt = dtend.dt + + return ParsedCalendarEvent( + summary=str(event.get("summary", "")) or None, + location=str(event.get("location", "")) or None, + description=str(event.get("description", "")) or None, + start=start_dt, + end=end_dt, + all_day=all_day, + method=method, + organizer_name=organizer_name, + organizer_email=organizer_email, + attendees=attendees, + status=str(event.get("status", "")).upper() or None, + uid=str(event.get("uid", "")) or None, + ) except Exception as e: - logging.error(f"Error parsing calendar ICS {e}") + logging.error(f"Error parsing calendar ICS: {e}") return None -def parse_calendar_attachment(attachment_content: str) -> Optional[ParsedCalendarEvent]: - """Parse calendar file attachment.""" +def parse_calendar_from_raw_message(raw_message: str) -> Optional[ParsedCalendarEvent]: + """Extract and parse calendar event from raw email message. - # Handle base64 encoded ICS files + Args: + raw_message: Full raw email in EML/MIME format + + Returns: + ParsedCalendarEvent if found and parsed, None otherwise + """ + ics_content = extract_ics_from_mime(raw_message) + if ics_content: + return parse_ics_content(ics_content) + return None + + +# Legacy function names for compatibility +def parse_calendar_part(content: str) -> Optional[ParsedCalendarEvent]: + """Parse calendar MIME part content. Legacy wrapper for parse_ics_content.""" + return parse_ics_content(content) + + +def parse_calendar_attachment(attachment_content: str) -> Optional[ParsedCalendarEvent]: + """Parse base64-encoded calendar file attachment.""" try: decoded = base64.b64decode(attachment_content) - return parse_calendar_part(decoded) - + return parse_ics_content(decoded.decode("utf-8", errors="replace")) except Exception as e: logging.error(f"Error decoding calendar attachment: {e}") return None def is_cancelled_event(event: ParsedCalendarEvent) -> bool: - """Check if event is cancelled.""" - return event.method == "CANCEL" + """Check if event is a cancellation.""" + return event.method == "CANCEL" or event.status == "CANCELLED" def is_event_request(event: ParsedCalendarEvent) -> bool: """Check if event is an invite request.""" return event.method == "REQUEST" + + +def format_event_time(event: ParsedCalendarEvent) -> str: + """Format event time for display. + + Returns a human-readable string like: + - "Mon, Dec 30, 2025 2:00 PM - 3:00 PM" + - "All day: Mon, Dec 30, 2025" + """ + if not event.start: + return "Time not specified" + + if event.all_day: + if hasattr(event.start, "strftime"): + return f"All day: {event.start.strftime('%a, %b %d, %Y')}" + return f"All day: {event.start}" + + try: + start_str = ( + event.start.strftime("%a, %b %d, %Y %I:%M %p") + if hasattr(event.start, "strftime") + else str(event.start) + ) + if event.end and hasattr(event.end, "strftime"): + # Same day? Just show end time + if ( + hasattr(event.start, "date") + and hasattr(event.end, "date") + and event.start.date() == event.end.date() + ): + end_str = event.end.strftime("%I:%M %p") + else: + end_str = event.end.strftime("%a, %b %d, %Y %I:%M %p") + return f"{start_str} - {end_str}" + return start_str + except Exception: + return str(event.start) diff --git a/src/mail/widgets/CalendarInvitePanel.py b/src/mail/widgets/CalendarInvitePanel.py new file mode 100644 index 0000000..e211833 --- /dev/null +++ b/src/mail/widgets/CalendarInvitePanel.py @@ -0,0 +1,190 @@ +"""Calendar invite panel widget for displaying calendar event details with actions.""" + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Static, Button, Label +from textual.reactive import reactive +from textual.message import Message + +from src.mail.utils.calendar_parser import ( + ParsedCalendarEvent, + is_cancelled_event, + is_event_request, + format_event_time, +) + + +class CalendarInvitePanel(Vertical): + """Panel displaying calendar invite details with accept/decline/tentative actions. + + This widget shows at the top of the ContentContainer when viewing a calendar email. + """ + + DEFAULT_CSS = """ + CalendarInvitePanel { + height: auto; + max-height: 14; + padding: 1; + margin-bottom: 1; + background: $surface; + border: solid $primary; + } + + CalendarInvitePanel.cancelled { + border: solid $error; + } + + CalendarInvitePanel.request { + border: solid $success; + } + + CalendarInvitePanel .event-badge { + padding: 0 1; + margin-right: 1; + } + + CalendarInvitePanel .event-badge.cancelled { + background: $error; + color: $text; + } + + CalendarInvitePanel .event-badge.request { + background: $success; + color: $text; + } + + CalendarInvitePanel .event-badge.reply { + background: $warning; + color: $text; + } + + CalendarInvitePanel .event-title { + text-style: bold; + width: 100%; + } + + CalendarInvitePanel .event-detail { + color: $text-muted; + } + + CalendarInvitePanel .action-buttons { + height: auto; + margin-top: 1; + } + + CalendarInvitePanel .action-buttons Button { + margin-right: 1; + } + """ + + class InviteAction(Message): + """Message sent when user takes an action on the invite.""" + + def __init__(self, action: str, event: ParsedCalendarEvent) -> None: + self.action = action # "accept", "decline", "tentative" + self.event = event + super().__init__() + + def __init__( + self, + event: ParsedCalendarEvent, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.event = event + + def compose(self) -> ComposeResult: + """Compose the calendar invite panel.""" + # Determine badge and styling based on method + badge_text, badge_class = self._get_badge_info() + + with Horizontal(): + yield Label(badge_text, classes=f"event-badge {badge_class}") + yield Label( + self.event.summary or "Calendar Event", + classes="event-title", + ) + + # Event time + time_str = format_event_time(self.event) + yield Static(f"\uf017 {time_str}", classes="event-detail") # nf-fa-clock_o + + # Location if present + if self.event.location: + yield Static( + f"\uf041 {self.event.location}", # nf-fa-map_marker + classes="event-detail", + ) + + # Organizer + if self.event.organizer_name or self.event.organizer_email: + organizer = self.event.organizer_name or self.event.organizer_email + yield Static( + f"\uf007 {organizer}", # nf-fa-user + classes="event-detail", + ) + + # Attendees count + if self.event.attendees: + count = len(self.event.attendees) + yield Static( + f"\uf0c0 {count} attendee{'s' if count != 1 else ''}", # nf-fa-users + classes="event-detail", + ) + + # Action buttons (only for REQUEST method, not for CANCEL) + if is_event_request(self.event): + with Horizontal(classes="action-buttons"): + yield Button( + "\uf00c Accept", # nf-fa-check + id="btn-accept", + variant="success", + ) + yield Button( + "? Tentative", + id="btn-tentative", + variant="warning", + ) + yield Button( + "\uf00d Decline", # nf-fa-times + id="btn-decline", + variant="error", + ) + elif is_cancelled_event(self.event): + yield Static( + "[dim]This meeting has been cancelled[/dim]", + classes="event-detail", + ) + + def _get_badge_info(self) -> tuple[str, str]: + """Get badge text and CSS class based on event method.""" + method = self.event.method or "" + + if method == "CANCEL" or self.event.status == "CANCELLED": + return "CANCELLED", "cancelled" + elif method == "REQUEST": + return "INVITE", "request" + elif method == "REPLY": + return "REPLY", "reply" + elif method == "COUNTER": + return "COUNTER", "reply" + else: + return "EVENT", "" + + def on_mount(self) -> None: + """Apply styling based on event type.""" + if is_cancelled_event(self.event): + self.add_class("cancelled") + elif is_event_request(self.event): + self.add_class("request") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle action button presses.""" + button_id = event.button.id + + if button_id == "btn-accept": + self.post_message(self.InviteAction("accept", self.event)) + elif button_id == "btn-tentative": + self.post_message(self.InviteAction("tentative", self.event)) + elif button_id == "btn-decline": + self.post_message(self.InviteAction("decline", self.event)) diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index cee7d72..fb94d62 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -12,7 +12,12 @@ from src.mail.screens.LinkPanel import ( LinkItem as LinkItemClass, ) from src.mail.notification_compressor import create_compressor -from src.mail.notification_detector import NotificationType +from src.mail.notification_detector import NotificationType, is_calendar_email +from src.mail.utils.calendar_parser import ( + ParsedCalendarEvent, + parse_calendar_from_raw_message, +) +from src.mail.widgets.CalendarInvitePanel import CalendarInvitePanel import logging from datetime import datetime from typing import Literal, List, Dict, Any, Optional @@ -177,6 +182,10 @@ class ContentContainer(ScrollableContainer): self.current_notification_type: Optional[NotificationType] = None self.is_compressed_view: bool = False + # Calendar invite state + self.calendar_panel: Optional[CalendarInvitePanel] = None + self.current_calendar_event: Optional[ParsedCalendarEvent] = None + # Load default view mode and notification compression from config config = get_config() self.current_mode = config.content_display.default_view_mode @@ -236,6 +245,23 @@ class ContentContainer(ScrollableContainer): self.notify("No message ID provided.") return + # Check if this is a calendar email and fetch raw message for ICS parsing + if self.current_envelope and is_calendar_email(self.current_envelope): + raw_content, raw_success = await himalaya_client.get_raw_message( + message_id, folder=self.current_folder, account=self.current_account + ) + if raw_success and raw_content: + calendar_event = parse_calendar_from_raw_message(raw_content) + if calendar_event: + self.current_calendar_event = calendar_event + self._show_calendar_panel(calendar_event) + else: + self._hide_calendar_panel() + else: + self._hide_calendar_panel() + else: + self._hide_calendar_panel() + content, success = await himalaya_client.get_message_content( message_id, folder=self.current_folder, account=self.current_account ) @@ -349,3 +375,40 @@ class ContentContainer(ScrollableContainer): if not self.current_content: return [] return extract_links_from_content(self.current_content) + + def _show_calendar_panel(self, event: ParsedCalendarEvent) -> None: + """Show the calendar invite panel at the top of the content.""" + # Remove existing panel if any + self._hide_calendar_panel() + + # Create and mount new panel at the beginning + self.calendar_panel = CalendarInvitePanel(event, id="calendar_invite_panel") + self.mount(self.calendar_panel, before=0) + + def _hide_calendar_panel(self) -> None: + """Hide/remove the calendar invite panel.""" + self.current_calendar_event = None + if self.calendar_panel: + try: + self.calendar_panel.remove() + except Exception: + pass # Panel may already be removed + self.calendar_panel = None + + def on_calendar_invite_panel_invite_action( + self, event: CalendarInvitePanel.InviteAction + ) -> None: + """Handle calendar invite actions from the panel. + + Bubbles the action up to the app level for processing. + """ + # Get the app and call the appropriate action + action = event.action + calendar_event = event.event + + if action == "accept": + self.app.action_accept_invite() + elif action == "decline": + self.app.action_decline_invite() + elif action == "tentative": + self.app.action_tentative_invite() diff --git a/src/services/himalaya/client.py b/src/services/himalaya/client.py index 4574a56..1f75baf 100644 --- a/src/services/himalaya/client.py +++ b/src/services/himalaya/client.py @@ -482,3 +482,62 @@ def sync_himalaya(): print("Himalaya sync completed successfully.") except subprocess.CalledProcessError as e: print(f"Error during Himalaya sync: {e}") + + +async def get_raw_message( + message_id: int, + folder: Optional[str] = None, + account: Optional[str] = None, +) -> Tuple[Optional[str], bool]: + """ + Retrieve the full raw message (EML format) by its ID. + + This exports the complete MIME message including all parts (text, HTML, + attachments like ICS calendar files). Useful for parsing calendar invites. + + Args: + message_id: The ID of the message to retrieve + folder: The folder containing the message + account: The account to use + + Returns: + Tuple containing: + - Raw message content (EML format) or None if retrieval failed + - Success status (True if operation was successful) + """ + import tempfile + import os + + try: + # Create a temporary directory for the export + with tempfile.TemporaryDirectory() as tmpdir: + eml_path = os.path.join(tmpdir, f"message_{message_id}.eml") + + cmd = f"himalaya message export -F -d '{eml_path}' {message_id}" + if folder: + cmd += f" -f '{folder}'" + if account: + cmd += f" -a '{account}'" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + # Read the exported EML file + if os.path.exists(eml_path): + with open(eml_path, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + return content, True + else: + logging.error(f"EML file not created at {eml_path}") + return None, False + else: + logging.error(f"Error exporting raw message: {stderr.decode()}") + return None, False + except Exception as e: + logging.error(f"Exception during raw message export: {e}") + return None, False diff --git a/tests/test_calendar_parsing.py b/tests/test_calendar_parsing.py index 081f8c2..a66e068 100644 --- a/tests/test_calendar_parsing.py +++ b/tests/test_calendar_parsing.py @@ -1,7 +1,14 @@ """Unit tests for calendar email detection and ICS parsing.""" import pytest -from src.mail.utils import calendar_parser +from src.mail.utils.calendar_parser import ( + parse_ics_content, + parse_calendar_from_raw_message, + extract_ics_from_mime, + is_cancelled_event, + is_event_request, + ParsedCalendarEvent, +) from src.mail.notification_detector import is_calendar_email @@ -44,58 +51,91 @@ class TestICSParsing: """Test ICS file parsing.""" def test_parse_cancellation_ics(self): - """Test parsing of cancellation ICS from test fixture.""" - import base64 - from pathlib import Path + """Test parsing of cancellation ICS.""" + ics_content = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +METHOD:CANCEL +BEGIN:VEVENT +UID:test-uid-001 +SUMMARY:Technical Refinement Meeting +DTSTART:20251230T140000Z +DTEND:20251230T150000Z +STATUS:CANCELLED +ORGANIZER;CN=Test Organizer:mailto:organizer@example.com +ATTENDEE;CN=Test Attendee:mailto:attendee@example.com +END:VEVENT +END:VCALENDAR""" - fixture_path = Path( - "tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2" - ) - if not fixture_path.exists(): - pytest.skip("Test fixture file not found") - return - - with open(fixture_path, "r") as f: - content = f.read() - - event = parse_calendar_part(content) + event = parse_ics_content(ics_content) assert event is not None assert is_cancelled_event(event) is True assert event.method == "CANCEL" assert event.summary == "Technical Refinement Meeting" def test_parse_invite_ics(self): - """Test parsing of invite ICS from test fixture.""" - import base64 - from pathlib import Path + """Test parsing of invite/request ICS.""" + ics_content = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +METHOD:REQUEST +BEGIN:VEVENT +UID:test-uid-002 +SUMMARY:Team Standup +DTSTART:20251230T100000Z +DTEND:20251230T103000Z +STATUS:CONFIRMED +ORGANIZER;CN=Test Organizer:mailto:organizer@example.com +ATTENDEE;CN=Test Attendee:mailto:attendee@example.com +LOCATION:Conference Room A +END:VEVENT +END:VCALENDAR""" - fixture_path = Path( - "tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2" - ) - if not fixture_path.exists(): - pytest.skip("Test fixture file not found") - return - - with open(fixture_path, "r") as f: - content = f.read() - - event = parse_calendar_part(content) + event = parse_ics_content(ics_content) assert event is not None assert is_event_request(event) is True assert event.method == "REQUEST" - assert event.summary == "Technical Refinement Meeting" + assert event.summary == "Team Standup" + assert event.location == "Conference Room A" def test_invalid_ics(self): """Test parsing of invalid ICS content.""" - event = parse_calendar_part("invalid ics content") - + event = parse_ics_content("invalid ics content") assert event is None # Should return None for invalid ICS - def test_base64_decoding(self): - """Test base64 decoding of ICS attachment.""" - # Test that we can decode base64 - encoded = "BASE64ENCODED_I_TEST" - import base64 + def test_extract_ics_from_mime(self): + """Test extraction of ICS from raw MIME message.""" + raw_message = """From: organizer@example.com +To: attendee@example.com +Subject: Meeting Invite +Content-Type: multipart/mixed; boundary="boundary123" - decoded = base64.b64decode(encoded) - assert decoded == encoded +--boundary123 +Content-Type: text/plain + +You have been invited to a meeting. + +--boundary123 +Content-Type: text/calendar + +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:REQUEST +BEGIN:VEVENT +UID:mime-test-001 +SUMMARY:MIME Test Meeting +DTSTART:20251230T140000Z +DTEND:20251230T150000Z +END:VEVENT +END:VCALENDAR +--boundary123-- +""" + ics = extract_ics_from_mime(raw_message) + assert ics is not None + assert "BEGIN:VCALENDAR" in ics + assert "MIME Test Meeting" in ics + + event = parse_ics_content(ics) + assert event is not None + assert event.summary == "MIME Test Meeting" + assert event.method == "REQUEST"