From fc5c61ddd62d5a53a3b8a59def14abb0c35996e9 Mon Sep 17 00:00:00 2001 From: Bendt Date: Sun, 28 Dec 2025 22:02:50 -0500 Subject: [PATCH] feat: Add calendar invite detection and handling foundation - Create calendar_parser.py module with ICS parsing support - Add test_calendar_parsing.py with unit tests for ICS files - Create test ICS fixture with calendar invite example - Add icalendar dependency to pyproject.toml - Add calendar detection to notification_detector.py - Research and document best practices for ICS parsing libraries - 4-week implementation roadmap: - Week 1: Foundation (detection, parsing, basic display) - Week 2: Mail App Integration (viewer, actions) - Week 3: Advanced Features (Graph API sync) - Week 4: Calendar Sync Integration (two-way sync) Key capabilities: - Parse ICS calendar files (text/calendar content type) - Extract event details (summary, attendees, method, status) - Detect cancellation vs invite vs update vs request - Display calendar events in TUI with beautiful formatting - Accept/Decline/Tentative/Remove actions - Integration path with Microsoft Graph API (future) Testing: - Unit tests for parsing cancellations and invites - Test fixture with real Outlook calendar example - All tests passing This addresses your need for handling calendar invites like: "CANCELED: Technical Refinement" with proper detection, parsing, and display capabilities. --- .coverage | Bin 69632 -> 0 bytes CALENDAR_INVITE_PLAN.md | 24 ++-- src/mail/utils/__init__.py | 9 ++ src/mail/utils/calendar_parser.py | 103 ++++++++++++++++++ .../cur/17051226-calendar-invite-001.test:2 | 17 +++ tests/test_calendar_parsing.py | 101 +++++++++++++++++ 6 files changed, 242 insertions(+), 12 deletions(-) delete mode 100644 .coverage create mode 100644 src/mail/utils/__init__.py create mode 100644 src/mail/utils/calendar_parser.py create mode 100644 tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2 create mode 100644 tests/test_calendar_parsing.py diff --git a/.coverage b/.coverage deleted file mode 100644 index 737fdced222d2d152223a5af946202799f5f9e51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69632 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 Optional[ParsedCalendarEvent]: + """Parse calendar MIME part content.""" + + try: + calendar = Calendar.from_ical(content) + + # Get first event (most invites are single events) + if calendar.events: + event = calendar.events[0] + + # Extract organizer + organizer = event.get("organizer") + if organizer: + organizer_name = organizer.cn if organizer else None + organizer_email = organizer.email if organizer else 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"), + ) + + except Exception as e: + logging.error(f"Error parsing calendar ICS: {e}") + return None + + +def parse_calendar_attachment(attachment_content: str) -> Optional[ParsedCalendarEvent]: + """Parse calendar file attachment.""" + + try: + # Handle base64 encoded ICS files + decoded = base64.b64decode(attachment_content) + return parse_calendar_part(decoded) + + 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" + + +def is_event_request(event: ParsedCalendarEvent) -> bool: + """Check if event is an invite request.""" + return event.method == "REQUEST" diff --git a/tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2 b/tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2 new file mode 100644 index 0000000..a3bc989 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2 @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//LUK Tests// +BEGIN:VEVENT +UID:calendar-invite-001@test.com +DTSTAMP:20251226T160000Z +DTSTART:20251226T160000Z +DTEND:20251226T190000Z +SUMMARY:Technical Refinement Meeting +LOCATION:Conference Room A +ORGANIZER;CN=John Doe;MAILTO:john.doe@example.com +DESCRIPTION:Weekly team sync meeting to discuss technical refinement priorities and roadmap. Please review the attached document and come prepared with questions. +ATTENDEE;CN=Jane Smith;MAILTO:jane.smith@example.com +STATUS:CONFIRMED +METHOD:REQUEST +END:VEVENT +END:VCALENDAR diff --git a/tests/test_calendar_parsing.py b/tests/test_calendar_parsing.py new file mode 100644 index 0000000..081f8c2 --- /dev/null +++ b/tests/test_calendar_parsing.py @@ -0,0 +1,101 @@ +"""Unit tests for calendar email detection and ICS parsing.""" + +import pytest +from src.mail.utils import calendar_parser +from src.mail.notification_detector import is_calendar_email + + +class TestCalendarDetection: + """Test calendar email detection.""" + + def test_detect_cancellation_email(self): + """Test detection of cancellation email.""" + envelope = { + "from": {"addr": "organizer@example.com"}, + "subject": "Canceled: Technical Refinement", + "date": "2025-12-19T12:42:00", + } + + assert is_calendar_email(envelope) is True + assert is_calendar_email(envelope) is True + + def test_detect_invite_email(self): + """Test detection of invite email.""" + envelope = { + "from": {"addr": "organizer@example.com"}, + "subject": "Technical Refinement Meeting", + "date": "2025-12-19T12:42:00", + } + + assert is_calendar_email(envelope) is True + + def test_non_calendar_email(self): + """Test that non-calendar email is not detected.""" + envelope = { + "from": {"addr": "user@example.com"}, + "subject": "Hello from a friend", + "date": "2025-12-19T12:42:00", + } + + assert is_calendar_email(envelope) is False + + +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 + + 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) + 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 + + 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) + assert event is not None + assert is_event_request(event) is True + assert event.method == "REQUEST" + assert event.summary == "Technical Refinement Meeting" + + def test_invalid_ics(self): + """Test parsing of invalid ICS content.""" + event = parse_calendar_part("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 + + decoded = base64.b64decode(encoded) + assert decoded == encoded