docs: Create calendar invite handling plan
- Research best ICS parsing libraries (icalendar, ics) - Design CalendarEventViewer widget for displaying invites - Add calendar detection to notification_detector.py - Implement ICS parsing utilities in calendar_parser.py - Plan integration with Microsoft Graph API for calendar actions - Provide clear action flow (Accept/Decline/Tentative/Remove) - 4-week implementation roadmap with success metrics - Configuration options for parser library and display settings Key features: - Automatic calendar email detection (invites, cancellations, updates) - ICS file parsing with proper timezone and attendee handling - Beautiful TUI display of calendar events - Integration path with Microsoft Graph API (future) - Action buttons tied to Graph API for updating Outlook calendar
This commit is contained in:
961
CALENDAR_INVITE_PLAN.md
Normal file
961
CALENDAR_INVITE_PLAN.md
Normal file
@@ -0,0 +1,961 @@
|
||||
# Calendar Invite Handling Plan
|
||||
|
||||
**Created:** 2025-12-28
|
||||
**Priority:** High
|
||||
**Focus:** Parse and display calendar invite/cancellation emails with user actions
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Users receive calendar-related emails (invites, updates, cancellations) from Outlook/Exchange. These emails contain structured calendar data in MIME attachments (typically ICS files) that's currently not being parsed or displayed in a user-friendly way.
|
||||
|
||||
### Current Issues
|
||||
|
||||
1. **Raw Email Display** - Calendar emails show as raw MIME content
|
||||
2. **No Actionable Items** - Users cannot accept/decline invites from within the mail app
|
||||
3. **Poor Readability** - Calendar data is embedded in MIME parts, hard to understand
|
||||
4. **No Integration** - Actions don't synchronize with the calendar system
|
||||
|
||||
### Example Email Received
|
||||
|
||||
```
|
||||
Subject: Canceled: Technical Refinement
|
||||
From: Marshall, Cody <john.marshall@corteva.com>
|
||||
|
||||
MIME multipart message with:
|
||||
- text/plain part: "Canceled: Technical Refinement"
|
||||
- text/calendar part: base64 encoded ICS file containing:
|
||||
- method=CANCEL (indicates cancellation)
|
||||
- event details (title, date/time, organizer, attendees)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Research: Calendar/I CS File Parsing
|
||||
|
||||
### Standard Libraries
|
||||
|
||||
#### 1. **icalendar** (Recommended)
|
||||
**Repository:** https://github.com/collective/icalendar
|
||||
|
||||
**Pros:**
|
||||
- Most mature and well-maintained
|
||||
- Comprehensive API for reading/writing ICS files
|
||||
- Handles timezones, recurrence, alarms
|
||||
- Full iCalendar RFC 5545 compliance
|
||||
- Python 3.8+ support
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
pip install icalendar
|
||||
```
|
||||
|
||||
**Basic Usage:**
|
||||
```python
|
||||
from icalendar import Calendar
|
||||
from datetime import datetime
|
||||
|
||||
# Parse ICS content
|
||||
calendar = Calendar.from_ical(ics_content)
|
||||
|
||||
for event in calendar.events:
|
||||
print(f"Summary: {event.get('summary')}")
|
||||
print(f"Start: {event.get('dtstart').dt}")
|
||||
print(f"End: {event.get('dtend').dt}")
|
||||
print(f"Location: {event.get('location')}")
|
||||
print(f"Organizer: {event.get('organizer')}")
|
||||
print(f"Method: {event.get('method')}") # REQUEST, CANCEL, etc.
|
||||
```
|
||||
|
||||
#### 2. **ics** (Alternative)
|
||||
**Repository:** https://github.com/collective/ics
|
||||
|
||||
**Pros:**
|
||||
- Simpler API than icalendar
|
||||
- Good for basic ICS parsing
|
||||
- Active maintenance
|
||||
- Lightweight
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
pip install ics
|
||||
```
|
||||
|
||||
**Basic Usage:**
|
||||
```python
|
||||
import ics
|
||||
|
||||
calendar = ics.Calendar(ics_content)
|
||||
for event in calendar.events:
|
||||
print(event.summary)
|
||||
print(event.begin)
|
||||
print(event.end)
|
||||
print(event.location)
|
||||
```
|
||||
|
||||
#### 3. **python-recurring-ical-events**
|
||||
**Repository:** https://github.com/brotaur/recurring-ical-events
|
||||
|
||||
**Pros:**
|
||||
- Specialized for handling complex recurrence patterns
|
||||
- Good for recurring meetings
|
||||
|
||||
**Note:** More complex, use only if needed for advanced scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Analysis of Calendar Invite Email Structure
|
||||
|
||||
### MIME Parts Detection
|
||||
|
||||
Calendar emails typically use `multipart/alternative` or `multipart/mixed` with these parts:
|
||||
|
||||
1. **Plain Text Part** - Human-readable message
|
||||
2. **Calendar Part** (`text/calendar` content type) - ICS file data
|
||||
3. **HTML Part** - Formatted message (optional)
|
||||
4. **Attachments** - Separate ICS files
|
||||
|
||||
### ICS File Content Structure
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Example Corp//Calendar App//EN
|
||||
BEGIN:VEVENT
|
||||
UID:12345@example.com
|
||||
DTSTAMP:20251228T120000Z
|
||||
DTSTART:20251228T120000Z
|
||||
DTEND:20251228T130000Z
|
||||
SUMMARY:Weekly Team Meeting
|
||||
LOCATION:Conference Room A
|
||||
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
|
||||
DESCRIPTION:Weekly team sync meeting
|
||||
ATTENDEE;CN=Jane Smith:mailto:jane.smith@example.com
|
||||
STATUS:CONFIRMED
|
||||
METHOD:REQUEST
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
### Key Calendar Methods
|
||||
|
||||
The `METHOD` property indicates the type of calendar operation:
|
||||
|
||||
- **REQUEST** - Meeting invite request
|
||||
- **CANCEL** - Meeting cancellation (your email example)
|
||||
- **DECLINE** - Meeting declined
|
||||
- **ACCEPT** - Meeting accepted
|
||||
- **TENTATIVE** - Tentative acceptance
|
||||
- **COUNTER** - Counter proposal
|
||||
- **DELEGATE** - Meeting delegated
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Calendar Email Detection (Week 1)
|
||||
|
||||
#### 1.1 Add Calendar Detection to Notification Detector
|
||||
**File:** `src/mail/notification_detector.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class CalendarInvite:
|
||||
"""Calendar invite/cancellation email."""
|
||||
|
||||
# Basic info
|
||||
subject: str
|
||||
from_name: str
|
||||
from_addr: str
|
||||
date: str
|
||||
|
||||
# Parsed calendar data
|
||||
calendar_method: Optional[str] # REQUEST, CANCEL, etc.
|
||||
event_summary: Optional[str]
|
||||
event_start: Optional[str]
|
||||
event_end: Optional[str]
|
||||
location: Optional[str]
|
||||
organizer: Optional[str]
|
||||
attendees: Optional[list[str]]
|
||||
has_attachments: bool = False
|
||||
|
||||
# Actionable
|
||||
can_accept: bool = False
|
||||
can_decline: bool = False
|
||||
can_tentative: bool = False
|
||||
can_remove: bool = False # Remove from calendar if supported
|
||||
|
||||
def is_calendar_email(envelope: dict) -> bool:
|
||||
"""Check if email contains calendar data."""
|
||||
subject = envelope.get("subject", "").lower()
|
||||
|
||||
# Subject patterns for calendar emails
|
||||
calendar_patterns = [
|
||||
r"invitation",
|
||||
r"meeting",
|
||||
r"canceled",
|
||||
r"rescheduled",
|
||||
r"updated",
|
||||
]
|
||||
|
||||
if any(re.search(pattern, subject) for pattern in calendar_patterns):
|
||||
return True
|
||||
|
||||
# Check for calendar attachment
|
||||
# (Will need to examine attachment list when available)
|
||||
|
||||
return False
|
||||
|
||||
def detect_calendar_email_type(envelope: dict, content: str) -> Optional[str]:
|
||||
"""Detect calendar email type."""
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
#### 1.2 Add ICS Parser Dependency
|
||||
**File:** `pyproject.toml`
|
||||
|
||||
**Changes:**
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
icalendar = ">=5.0,<7.0"
|
||||
# OR
|
||||
ics = ">=0.6,<1.0"
|
||||
|
||||
[project.optional-dependencies-extras]
|
||||
icalendar = ["all"]
|
||||
```
|
||||
|
||||
**Install Command:**
|
||||
```bash
|
||||
uv pip install 'luk[icalendar]'
|
||||
# Or if using uv
|
||||
uv add --optional icalendar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Calendar Content Display Widget (Week 1-2)
|
||||
|
||||
#### 2.1 Create Calendar Event Viewer Widget
|
||||
**File:** `src/mail/widgets/CalendarEventViewer.py`
|
||||
|
||||
**Design:**
|
||||
```python
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.widgets import Static, Button, Label
|
||||
from textual.screen import Screen
|
||||
from textual.app import ComposeResult
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class CalendarEventViewer(Screen):
|
||||
"""Widget to display calendar invite/event details."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "pop_screen", "Close", show=False),
|
||||
Binding("q", "pop_screen", "Close", show=False),
|
||||
]
|
||||
|
||||
def __init__(self, calendar_data: CalendarInvite, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.calendar_data = calendar_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="calendar_viewer_container"):
|
||||
# Header with event type indicator
|
||||
event_type = self._get_event_type_badge()
|
||||
yield Static(f" {event_type} Calendar Event ")
|
||||
yield Static("─" * 70)
|
||||
|
||||
# Event Details Section
|
||||
with Horizontal():
|
||||
yield Static("[bold cyan]Summary:[/bold cyan]")
|
||||
yield Static(" " + self.calendar_data.event_summary or "No subject")
|
||||
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Time:[/bold cyan]")
|
||||
time_str = self._format_time_range()
|
||||
yield Static(" " + time_str)
|
||||
|
||||
if self.calendar_data.location:
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Location:[/bold cyan]")
|
||||
yield Static(" " + self.calendar_data.location)
|
||||
|
||||
if self.calendar_data.organizer:
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Organizer:[/bold cyan]")
|
||||
yield Static(" " + self.calendar_data.organizer)
|
||||
|
||||
if self.calendar_data.attendees:
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Attendees:[/bold cyan]")
|
||||
attendees_str = ", ".join(self.calendar_data.attendees[:5])
|
||||
if len(self.calendar_data.attendees) > 5:
|
||||
attendees_str += f" + {len(self.calendar_data.attendees) - 5} more"
|
||||
yield Static(" " + attendees_str)
|
||||
|
||||
# Method/Status Section
|
||||
if self.calendar_data.calendar_method:
|
||||
yield Static("")
|
||||
yield Static("[bold yellow]Status:[/bold yellow]")
|
||||
yield Static(" " + self._format_calendar_method())
|
||||
|
||||
# Description Section (if available)
|
||||
if hasattr(self.calendar_data, 'description'):
|
||||
desc = self.calendar_data.description
|
||||
if desc and len(desc) > 200:
|
||||
desc = desc[:200] + "..."
|
||||
yield Static("")
|
||||
yield Static("[dim]Description:[/dim]")
|
||||
yield Static(" " + desc)
|
||||
|
||||
# Action Buttons
|
||||
yield Static("")
|
||||
yield Static("[bold green]Actions:[/bold green]")
|
||||
with Horizontal(id="action_buttons"):
|
||||
if self.calendar_data.can_accept:
|
||||
yield Button("✓ Accept", id="btn_accept", variant="success")
|
||||
if self.calendar_data.can_decline:
|
||||
yield Button("✗ Decline", id="btn_decline", variant="error")
|
||||
if self.calendar_data.can_tentative:
|
||||
yield Button("? Tentative", id="btn_tentative", variant="warning")
|
||||
if self.calendar_data.can_remove:
|
||||
yield Button("🗑 Remove from Calendar", id="btn_remove", variant="primary")
|
||||
|
||||
def _get_event_type_badge(self) -> str:
|
||||
"""Get event type badge."""
|
||||
method = self.calendar_data.calendar_method or ""
|
||||
|
||||
if method == "CANCEL":
|
||||
return "[red]CANCELLED[/red]"
|
||||
elif method == "REQUEST":
|
||||
return "[green]INVITE[/green]"
|
||||
elif method == "ACCEPTED":
|
||||
return "[blue]ACCEPTED[/blue]"
|
||||
elif method == "DECLINED":
|
||||
return "[yellow]DECLINED[/yellow]"
|
||||
elif method == "TENTATIVE":
|
||||
return "[magenta]TENTATIVE[/magenta]"
|
||||
else:
|
||||
return "[cyan]EVENT[/cyan]"
|
||||
|
||||
def _format_time_range(self) -> str:
|
||||
"""Format time range for display."""
|
||||
if self.calendar_data.event_start and self.calendar_data.event_end:
|
||||
start = self._parse_date_time(self.calendar_data.event_start)
|
||||
end = self._parse_date_time(self.calendar_data.event_end)
|
||||
return f"{start} - {end}"
|
||||
elif self.calendar_data.event_start:
|
||||
return self._parse_date_time(self.calendar_data.event_start) + " onwards"
|
||||
else:
|
||||
return "Time not specified"
|
||||
|
||||
def _parse_date_time(self, date_str: str) -> str:
|
||||
"""Parse date string and format."""
|
||||
# Simple parser - can be enhanced
|
||||
try:
|
||||
# Handle various date formats
|
||||
# ISO 8601: 2025-12-28T12:00:00
|
||||
# RFC 2822: Mon, 19 Dec 2025 12:00:00
|
||||
# Display based on what we find
|
||||
return date_str[:25] # Truncate for display
|
||||
except:
|
||||
return date_str
|
||||
|
||||
def _format_calendar_method(self) -> str:
|
||||
"""Format calendar method for display."""
|
||||
method = self.calendar_data.calendar_method
|
||||
method_display = method.upper() if method else "UNKNOWN"
|
||||
|
||||
# Add icon and color
|
||||
if method == "REQUEST":
|
||||
return f"[green]📧[/green] [bold]{method_display}[/bold] - Meeting invite"
|
||||
elif method == "CANCEL":
|
||||
return f"[red]✕[/red] [bold]{method_display}[/bold] - Meeting canceled"
|
||||
elif method == "ACCEPTED":
|
||||
return f"[blue]✓[/blue] [bold]{method_display}[/bold] - Meeting accepted"
|
||||
elif method == "DECLINED":
|
||||
return f"[yellow]✗[/yellow] [bold]{method_display}[/bold] - Meeting declined"
|
||||
else:
|
||||
return f"[cyan]{method_display}[/cyan] - Calendar update"
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press."""
|
||||
if event.button.id == "btn_accept":
|
||||
self._handle_accept()
|
||||
elif event.button.id == "btn_decline":
|
||||
self._handle_decline()
|
||||
elif event.button.id == "btn_tentative":
|
||||
self._handle_tentative()
|
||||
elif event.button.id == "btn_remove":
|
||||
self._handle_remove()
|
||||
|
||||
def _handle_accept(self) -> None:
|
||||
"""Handle accept action."""
|
||||
self.dismiss("accept")
|
||||
self.notify(f"Meeting invitation accepted", title="Calendar", severity="information")
|
||||
|
||||
def _handle_decline(self) -> None:
|
||||
"""Handle decline action."""
|
||||
self.dismiss("decline")
|
||||
self.notify(f"Meeting invitation declined", title="Calendar", severity="warning")
|
||||
|
||||
def _handle_tentative(self) -> None:
|
||||
"""Handle tentative action."""
|
||||
self.dismiss("tentative")
|
||||
self.notify(f"Meeting marked as tentative", title="Calendar", severity="information")
|
||||
|
||||
def _handle_remove(self) -> None:
|
||||
"""Handle remove from calendar."""
|
||||
self.dismiss("remove")
|
||||
self.notify(f"Event removed from calendar", title="Calendar", severity="information")
|
||||
```
|
||||
|
||||
#### 2.2 Parse ICS Content from Email
|
||||
**File:** `src/mail/utils/calendar_parser.py`
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
"""Calendar ICS file parser utilities."""
|
||||
|
||||
import base64
|
||||
from icalendar import Calendar
|
||||
from typing import Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
@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 # REQUEST, CANCEL, etc.
|
||||
|
||||
# Organizer
|
||||
organizer_name: Optional[str] = None
|
||||
organizer_email: Optional[str] = None
|
||||
|
||||
# Attendees
|
||||
attendees: List[str] = list()
|
||||
|
||||
# Status
|
||||
status: Optional[str] = None # CONFIRMED, TENTATIVE, etc.
|
||||
|
||||
def parse_calendar_part(content: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Parse calendar MIME part content."""
|
||||
|
||||
try:
|
||||
# Try to parse as ICS file
|
||||
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")
|
||||
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)
|
||||
|
||||
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", "CONFIRMED")
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
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."""
|
||||
# Handle base64 encoded ICS files
|
||||
try:
|
||||
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"
|
||||
|
||||
def extract_email_from_vcard(email_str: str) -> Optional[str]:
|
||||
"""Extract email address from VCard format."""
|
||||
# VCard format: "CN=Name:MAILTO:email@example.com"
|
||||
# Simple regex to extract
|
||||
import re
|
||||
match = re.search(r"MAILTO:([^>\s]+)", email_str)
|
||||
return match.group(1) if match else None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Integration with Mail App (Week 1-3)
|
||||
|
||||
#### 3.1 Add Calendar Detection to Envelope Display
|
||||
**File:** `src/mail/widgets/EnvelopeListItem.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
from .notification_detector import is_calendar_email, CalendarInvite
|
||||
|
||||
class EnvelopeListItem(CustomListItem):
|
||||
"""Enhanced envelope list item with calendar indicators."""
|
||||
|
||||
def __init__(self, envelope: dict, **kwargs):
|
||||
super().__init__(envelope, **kwargs)
|
||||
self.calendar_type = self._detect_calendar_type(envelope)
|
||||
|
||||
def _detect_calendar_type(self, envelope: dict) -> str:
|
||||
"""Detect calendar email type."""
|
||||
if is_calendar_email(envelope):
|
||||
return "[cyan]📅[/cyan]" # Calendar icon
|
||||
return ""
|
||||
|
||||
def render(self) -> RichText:
|
||||
"""Render with calendar indicator."""
|
||||
from rich.text import Text
|
||||
|
||||
# Get base render from parent
|
||||
base_render = super().render()
|
||||
|
||||
# Add calendar icon if applicable
|
||||
calendar_indicator = Text.assemble(
|
||||
self.calendar_type + " ",
|
||||
style="on" if self.calendar_type else ""
|
||||
)
|
||||
|
||||
return Text.assemble(base_render, calendar_indicator)
|
||||
```
|
||||
|
||||
#### 3.2 Add Calendar Viewer to Mail App
|
||||
**File:** `src/mail/widgets/ContentContainer.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class ContentContainer(ScrollableContainer):
|
||||
"""Enhanced with calendar event display support."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.calendar_data: Optional[CalendarInvite] = None
|
||||
self.is_calendar_view: bool = False
|
||||
|
||||
def display_calendar_event(self, calendar_data: CalendarInvite) -> None:
|
||||
"""Display calendar event in main content area."""
|
||||
self.calendar_data = calendar_data
|
||||
self.is_calendar_view = True
|
||||
|
||||
# Switch to calendar viewer
|
||||
from .CalendarEventViewer import CalendarEventViewer
|
||||
viewer = CalendarEventViewer(calendar_data)
|
||||
self.push_screen(viewer)
|
||||
|
||||
def display_content(
|
||||
self,
|
||||
message_id: int,
|
||||
folder: str | None = None,
|
||||
account: str | None = None,
|
||||
envelope: dict | None = None,
|
||||
) -> None:
|
||||
"""Override to check for calendar emails."""
|
||||
if not message_id:
|
||||
return
|
||||
|
||||
self.current_message_id = message_id
|
||||
self.current_folder = folder
|
||||
self.current_account = account
|
||||
self.current_envelope = envelope
|
||||
|
||||
# Check if this is a calendar email
|
||||
if envelope and is_calendar_email(envelope):
|
||||
# Parse calendar content (will need to fetch full content)
|
||||
# For now, show placeholder
|
||||
self.content.update("Calendar invite detected - parsing...")
|
||||
self.html_content.update("Calendar invite detected - parsing...")
|
||||
```
|
||||
|
||||
#### 3.3 Add Calendar Actions to Keybindings
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
# Existing actions preserved
|
||||
# Add new calendar-specific actions
|
||||
|
||||
async def action_calendar_accept(self) -> None:
|
||||
"""Accept calendar invitation."""
|
||||
# Implementation depends on backend support
|
||||
|
||||
async def action_calendar_decline(self) -> None:
|
||||
"""Decline calendar invitation."""
|
||||
# Implementation depends on backend support
|
||||
|
||||
async def action_calendar_remove(self) -> None:
|
||||
"""Remove calendar event."""
|
||||
# Implementation depends on backend support
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Calendar Sync Integration (Week 2-3)
|
||||
|
||||
#### 4.1 Design API Integration Strategy
|
||||
|
||||
**Approach:** Use Microsoft Graph API for all calendar operations
|
||||
|
||||
**Rationale:**
|
||||
- Single source of truth for calendar data
|
||||
- Real-time sync between Outlook and local calendar
|
||||
- Actions taken in mail app will be reflected in Outlook calendar
|
||||
- Supports all calendar features (recurrence, attendees, etc.)
|
||||
- Cancellations will update the actual event in Outlook
|
||||
|
||||
**Key Decision:** Before implementing calendar actions, we should call Microsoft Graph API to:
|
||||
1. Accept meeting → Update event status to ACCEPTED
|
||||
2. Decline meeting → Update event status to DECLINED
|
||||
3. Tentatively accept → Update event status to TENTATIVE
|
||||
4. Cancel meeting → Send cancellation to organizer, update event status
|
||||
|
||||
**Files to Modify:**
|
||||
- `src/services/microsoft_graph/calendar.py` - Add action methods
|
||||
- `src/mail/actions/calendar_actions.py` - Create action handlers
|
||||
- `src/mail/app.py` - Add calendar action keybindings
|
||||
|
||||
**API Calls Needed:**
|
||||
```python
|
||||
# Accept invitation
|
||||
PATCH /me/events/{id}
|
||||
{
|
||||
"response": {
|
||||
"response": "accepted",
|
||||
"comment": "Accepted via LUK Mail app"
|
||||
}
|
||||
}
|
||||
|
||||
# Decline invitation
|
||||
PATCH /me/events/{id}
|
||||
{
|
||||
"response": {
|
||||
"response": "declined",
|
||||
"comment": "Declined via LUK Mail app"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Testing & Documentation (Week 3)
|
||||
|
||||
#### 5.1 Unit Tests for Calendar Parsing
|
||||
**File:** `tests/test_calendar_parser.py`
|
||||
|
||||
**Test Cases:**
|
||||
```python
|
||||
import pytest
|
||||
from src.mail.utils.calendar_parser import (
|
||||
parse_calendar_part,
|
||||
parse_calendar_attachment,
|
||||
is_cancelled_event,
|
||||
is_event_request,
|
||||
ParsedCalendarEvent,
|
||||
)
|
||||
|
||||
def test_parse_cancellation():
|
||||
"""Test parsing of cancellation ICS."""
|
||||
ics_content = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test-cancel@example.com
|
||||
DTSTAMP:20251228T120000Z
|
||||
DTSTART:20251228T120000Z
|
||||
DTEND:20251228T130000Z
|
||||
SUMMARY:Canceled Meeting
|
||||
LOCATION:Conference Room A
|
||||
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
|
||||
METHOD:CANCEL
|
||||
STATUS:CANCELLED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
event = parse_calendar_part(ics_content)
|
||||
assert event is not None
|
||||
assert is_cancelled_event(event)
|
||||
assert event.method == "CANCEL"
|
||||
print("✅ Cancellation parsing works")
|
||||
|
||||
def test_parse_invite_request():
|
||||
"""Test parsing of invitation ICS."""
|
||||
ics_content = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test-invite@example.com
|
||||
DTSTAMP:20251228T120000Z
|
||||
DTSTART:20251229T150000Z
|
||||
DTEND:20251229T160000Z
|
||||
SUMMARY:Team Meeting
|
||||
LOCATION:Conference Room B
|
||||
ORGANIZER;CN=Manager:MAILTO:manager@example.com
|
||||
METHOD:REQUEST
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
event = parse_calendar_part(ics_content)
|
||||
assert event is not None
|
||||
assert is_event_request(event)
|
||||
assert event.method == "REQUEST"
|
||||
print("✅ Invite request parsing works")
|
||||
|
||||
def test_parse_with_attendees():
|
||||
"""Test parsing events with attendees."""
|
||||
# Implementation...
|
||||
pass
|
||||
```
|
||||
|
||||
#### 5.2 Update Help Screen
|
||||
**File:** `src/mail/screens/HelpScreen.py`
|
||||
|
||||
**Additions:**
|
||||
```python
|
||||
# Add to Quick Actions section:
|
||||
yield Static(" [yellow]Calendar:[/yellow]")
|
||||
yield Static(" • Calendar invites automatically detected")
|
||||
yield Static(" • ICS files parsed to show event details")
|
||||
yield Static(" • Accept/Decline/Remove actions for invites")
|
||||
yield Static(" • Actions sync with Microsoft Outlook via Graph API")
|
||||
yield Static("")
|
||||
```
|
||||
|
||||
#### 5.3 Update Configuration
|
||||
**File:** `src/mail/config.py`
|
||||
|
||||
**Additions:**
|
||||
```python
|
||||
class MailAppConfig(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Calendar settings
|
||||
calendar_parser_library: Literal["icalendar", "ics"] = "icalendar"
|
||||
auto_detect_calendar_emails: bool = True
|
||||
show_calendar_indicator_in_list: bool = True
|
||||
enable_calendar_actions: bool = False # When Graph API integration ready
|
||||
```
|
||||
|
||||
**Config File Example:**
|
||||
```toml
|
||||
[calendar]
|
||||
# Which ICS library to use (icalendar recommended)
|
||||
parser_library = "icalendar"
|
||||
|
||||
# Automatically detect and highlight calendar emails
|
||||
auto_detect_calendar = true
|
||||
|
||||
# Show calendar icon in message list
|
||||
show_calendar_indicator = true
|
||||
|
||||
# Calendar action integration (requires Microsoft Graph API)
|
||||
enable_calendar_actions = false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Week 1: Foundation
|
||||
1. ✅ Add calendar detection to notification_detector.py
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
### Week 4: Calendar Sync Integration (Future)
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### User Experience Goals
|
||||
- **Calendar Detection:** 95%+ accuracy for invite/cancellation emails
|
||||
- **ICS Parsing:** 100% RFC 5545 compliance with icalendar
|
||||
- **Display Quality:** Clear, readable calendar event details
|
||||
- **Actionable:** Accept/Decline/Tentative/Remove buttons (ready for Graph API integration)
|
||||
- **Performance:** Parse ICS files in <100ms
|
||||
|
||||
### Technical Metrics
|
||||
- **Library Coverage:** icalendar (mature, RFC 5545 compliant)
|
||||
- **Code Quality:** Type-safe with dataclasses, full error handling
|
||||
- **Test Coverage:** >80% for calendar parsing code
|
||||
- **Configuration:** Flexible parser library selection, toggleable features
|
||||
|
||||
---
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Parser Library
|
||||
```toml
|
||||
[calendar]
|
||||
parser_library = "icalendar" # or "ics"
|
||||
auto_detect_calendar = true
|
||||
```
|
||||
|
||||
### Display Options
|
||||
```toml
|
||||
[envelope_display]
|
||||
show_calendar_icon = true
|
||||
```
|
||||
|
||||
### Action Configuration
|
||||
```toml
|
||||
[calendar_actions]
|
||||
# When true, actions call Microsoft Graph API
|
||||
enable_graph_api_actions = false
|
||||
|
||||
# User preferences
|
||||
default_response = "accept" # accept, decline, tentative
|
||||
auto_decline_duplicates = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes & Considerations
|
||||
|
||||
### Important Design Decisions
|
||||
|
||||
1. **Library Choice:** `icalendar` is recommended over `ics` for:
|
||||
- Better RFC compliance
|
||||
- More features (recurrence, timezones)
|
||||
- Better error handling
|
||||
- Active maintenance
|
||||
|
||||
2. **Display Priority:** Calendar events should be displayed prominently:
|
||||
- Use `push_screen()` to show full event details
|
||||
- Show in dedicated viewer, not inline in message list
|
||||
- Provide clear visual distinction for different event types (invite vs cancellation)
|
||||
|
||||
3. **Action Strategy:**
|
||||
- Implement Graph API integration first before enabling actions
|
||||
- Use Graph API as single source of truth for calendar
|
||||
- Actions in mail app should trigger Graph API calls to update Outlook
|
||||
- This prevents sync conflicts and ensures consistency
|
||||
|
||||
4. **Error Handling:**
|
||||
- Gracefully handle malformed ICS files
|
||||
- Provide user feedback when parsing fails
|
||||
- Fall back to raw email display if parsing fails
|
||||
|
||||
5. **Performance:**
|
||||
- Parse ICS files on-demand (not in message list rendering)
|
||||
- Use caching for parsed calendar data
|
||||
- Consider lazy loading for large mailboxes with many calendar emails
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
- **Recurring Events:** Full support for recurring meetings
|
||||
- **Multiple Events:** Handle ICS files with multiple events
|
||||
- **Timezone Support:** Proper timezone handling for events
|
||||
- **Attachments:** Process calendar file attachments
|
||||
- **Proposed Times:** Handle proposed meeting times
|
||||
- **Updates:** Process event updates (time/location changes)
|
||||
- **Decline with Note:** Add optional note when declining
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### iCalendar Standard (RFC 5545)
|
||||
- https://datatracker.ietf.org/doc/html/rfc5545
|
||||
- Full specification for iCalendar format
|
||||
|
||||
### Textual Widget Documentation
|
||||
- https://textual.textualize.io/guide/widgets/
|
||||
- Best practices for widget composition
|
||||
|
||||
### Microsoft Graph API Documentation
|
||||
- https://learn.microsoft.com/en-us/graph/api/calendar/
|
||||
- Calendar REST API reference
|
||||
|
||||
### Testing Resources
|
||||
- Sample ICS files for testing various scenarios
|
||||
- Calendar test fixtures for different event types
|
||||
|
||||
---
|
||||
|
||||
## Timeline Summary
|
||||
|
||||
**Week 1:** Foundation & Detection
|
||||
**Week 2:** Mail App Integration & Display
|
||||
**Week 3:** Advanced Features & Actions
|
||||
**Week 4:** Calendar Sync Integration (future)
|
||||
|
||||
**Total Estimated Time:** 4-6 weeks for full implementation
|
||||
|
||||
**Deliverable:** A production-ready calendar invite handling system that:
|
||||
- Detects calendar emails automatically
|
||||
- Parses ICS calendar data
|
||||
- Displays events beautifully in TUI
|
||||
- Provides user actions (accept/decline/tentative/remove)
|
||||
- Integrates with Microsoft Graph API for calendar management
|
||||
Reference in New Issue
Block a user