diff --git a/.coverage b/.coverage deleted file mode 100644 index 737fdce..0000000 Binary files a/.coverage and /dev/null differ diff --git a/CALENDAR_INVITE_PLAN.md b/CALENDAR_INVITE_PLAN.md index a9e00fc..fc2fcca 100644 --- a/CALENDAR_INVITE_PLAN.md +++ b/CALENDAR_INVITE_PLAN.md @@ -810,29 +810,29 @@ enable_calendar_actions = false 2. ✅ Add icalendar dependency to pyproject.toml 3. ✅ Create calendar_parser.py with ICS parsing utilities 4. ✅ Create CalendarEventViewer widget -5. ✅ Add unit tests for calendar parsing -6. ✅ Update help screen documentation -7. ✅ Add configuration options +5. ✅ Add calendar detection to EnvelopeListItem +6. ✅ Add calendar viewer to ContentContainer +7. ✅ Add calendar action placeholders in app.py +8. ✅ Create calendar action handlers +9. ✅ Create proper ICS test fixture (calendar invite) +10. ✅ Update help screen documentation +11. ✅ Add configuration options ### Week 2: Mail App Integration 1. ✅ Integrate calendar detection in EnvelopeListItem 2. ✅ Add calendar viewer to ContentContainer 3. ✅ Add calendar action placeholders in app.py -4. ✅ Create calendar action handlers -5. ✅ Add Microsoft Graph API methods for calendar actions +4. ✅ Add unit tests for calendar parsing ### Week 3: Advanced Features 1. ✅ Implement Microsoft Graph API calendar actions -2. ✅ Test with real calendar invites -3. ✅ Document calendar features in help -4. ✅ Performance optimization for calendar parsing +2. ⏳ Test with real calendar invites +3. ⏳ Document calendar features in help -### Week 4: Calendar Sync Integration (Future) +### Week 4: Calendar Sync Integration 1. ⏳ Calendar invite acceptance (Graph API) 2. ⏳ Calendar invite declination (Graph API) -3. ⏳ Calendar invite tentative acceptance (Graph API) -4. ⏳ Calendar event removal (Graph API) -5. ⏳ Two-way sync between mail actions and calendar +3. ⏳ Calendar event removal (Graph API) --- diff --git a/src/mail/utils/__init__.py b/src/mail/utils/__init__.py new file mode 100644 index 0000000..844da06 --- /dev/null +++ b/src/mail/utils/__init__.py @@ -0,0 +1,9 @@ +"""Calendar utilities module.""" + +from .calendar_parser import ( + parse_calendar_part, + parse_calendar_attachment, + is_cancelled_event, + is_event_request, + ParsedCalendarEvent, +) diff --git a/src/mail/utils/calendar_parser.py b/src/mail/utils/calendar_parser.py new file mode 100644 index 0000000..2b14fed --- /dev/null +++ b/src/mail/utils/calendar_parser.py @@ -0,0 +1,103 @@ +"""Calendar ICS file parser utilities.""" + +import base64 +from typing import Optional, List +from dataclasses import dataclass +import logging +from icalendar import Calendar +from pathlib import Path + + +@dataclass +class ParsedCalendarEvent: + """Parsed calendar event from ICS file.""" + + # Core event properties + summary: Optional[str] = None + location: Optional[str] = None + description: Optional[str] = None + start: Optional[str] = None + end: Optional[str] = None + all_day: bool = False + + # Calendar method + method: Optional[str] = None + + # Organizer + organizer_name: Optional[str] = None + organizer_email: Optional[str] = None + + # Attendees + attendees: List[str] = [] + + # Status + status: Optional[str] = None + + +def parse_calendar_part(content: str) -> 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