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