Compare commits
64 Commits
8be4b4785c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca266ce1c | ||
|
|
86297ae350 | ||
|
|
f8a179e096 | ||
|
|
c71c506b84 | ||
|
|
b52a06f2cf | ||
|
|
efe417b41a | ||
|
|
8a121d7fec | ||
|
|
2f002081e5 | ||
|
|
09d4bc18d7 | ||
|
|
de61795476 | ||
|
|
279beeabcc | ||
|
|
16995a4465 | ||
|
|
db58cb7a2f | ||
|
|
b89f72cd28 | ||
|
|
fc5c61ddd6 | ||
|
|
55515c050e | ||
|
|
7c685f3044 | ||
|
|
040a180a17 | ||
|
|
dd6d7e645f | ||
|
|
a0057f4d83 | ||
|
|
977c8e4ee0 | ||
|
|
fa54f45998 | ||
|
|
5f3fe302f1 | ||
|
|
de96353554 | ||
|
|
7564d11931 | ||
|
|
b1cd99abf2 | ||
|
|
78ab945a4d | ||
|
|
1c1b86b96b | ||
|
|
504e0d534d | ||
|
|
2b76458de1 | ||
|
|
d6e10e3dc5 | ||
|
|
ab6e080bb4 | ||
|
|
44cfe3f714 | ||
|
|
19bc1c7832 | ||
|
|
c5202793d4 | ||
|
|
95d3098bf3 | ||
|
|
599507068a | ||
|
|
505fdbcd3d | ||
|
|
1337d84369 | ||
|
|
f1ec6c23e1 | ||
|
|
4836bda9f9 | ||
|
|
9f596b10ae | ||
|
|
98c318af04 | ||
|
|
994e545bd0 | ||
|
|
fb0af600a1 | ||
|
|
39a5efbb81 | ||
|
|
b903832d17 | ||
|
|
8233829621 | ||
|
|
36a1ea7c47 | ||
|
|
4e859613f9 | ||
|
|
b9d818ac09 | ||
|
|
ab55d0836e | ||
|
|
f5ad43323c | ||
|
|
8933dadcd0 | ||
|
|
aaabd83fc7 | ||
|
|
560bc1d3bd | ||
|
|
d4b09e5338 | ||
|
|
9a2f8ee211 | ||
|
|
5deebbbf98 | ||
|
|
807736f808 | ||
|
|
a5f7e78d8d | ||
|
|
f56f1931bf | ||
|
|
848e2a43a6 | ||
|
|
bbc53b4ce7 |
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 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. ✅ 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
|
||||||
|
|
||||||
|
### Week 4: Calendar Sync Integration
|
||||||
|
1. ⏳ Calendar invite acceptance (Graph API)
|
||||||
|
2. ⏳ Calendar invite declination (Graph API)
|
||||||
|
3. ⏳ Calendar event removal (Graph API)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
808
PERFORMANCE_OPTIMIZATION_PLAN.md
Normal file
808
PERFORMANCE_OPTIMIZATION_PLAN.md
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
# LUK Performance Optimization & Cleanup Plan
|
||||||
|
|
||||||
|
**Created:** 2025-12-28
|
||||||
|
**Priority:** High
|
||||||
|
**Focus:** Mail app performance optimization and code quality improvements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The LUK mail app is experiencing performance issues:
|
||||||
|
- **Slow rendering** when scrolling through messages
|
||||||
|
- **Laggy navigation** between messages
|
||||||
|
- **High memory usage** during extended use
|
||||||
|
- **Flickering** or unresponsive UI
|
||||||
|
- **Poor startup time**
|
||||||
|
|
||||||
|
These issues make the app difficult to use for daily email management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Research: Textual Best Practices
|
||||||
|
|
||||||
|
### Key Principles for High-Performance Textual Apps
|
||||||
|
|
||||||
|
#### 1. **Use `compose()` Method, Not Manual Mounting**
|
||||||
|
```python
|
||||||
|
# ❌ BAD: Manual mounting in on_mount()
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.mount(Header())
|
||||||
|
self.mount(Sidebar())
|
||||||
|
self.mount(Content())
|
||||||
|
self.mount(Footer())
|
||||||
|
|
||||||
|
# ✅ GOOD: Use compose() for declarative UI
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Horizontal():
|
||||||
|
yield Sidebar()
|
||||||
|
yield Content()
|
||||||
|
yield Footer()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `compose()` is called once and builds the widget tree efficiently. Manual mounting triggers multiple render cycles.
|
||||||
|
|
||||||
|
#### 2. **Lazy Load Content - Defer Until Needed**
|
||||||
|
```python
|
||||||
|
# ❌ BAD: Load everything at startup
|
||||||
|
class MailApp(App):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.all_envelopes = load_all_envelopes() # Expensive!
|
||||||
|
self.message_store = build_full_message_store() # Expensive!
|
||||||
|
|
||||||
|
# ✅ GOOD: Load on-demand with workers
|
||||||
|
class MailApp(App):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._envelopes_cache = []
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
@work(exclusive=True)
|
||||||
|
async def load_envelopes_lazy(self):
|
||||||
|
if not self._envelopes_cache:
|
||||||
|
envelopes = await fetch_envelopes() # Load in background
|
||||||
|
self._envelopes_cache = envelopes
|
||||||
|
self._update_list()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.load_envelopes_lazy()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Defers expensive operations until the app is ready and visible.
|
||||||
|
|
||||||
|
#### 3. **Use Reactive Properties Efficiently**
|
||||||
|
```python
|
||||||
|
# ❌ BAD: Re-compute values in methods
|
||||||
|
def action_next(self):
|
||||||
|
index = self.envelopes.index(self.current_envelope)
|
||||||
|
self.current_message_index = index + 1 # Triggers re-render
|
||||||
|
self.update_envelope_list_view() # Another re-render
|
||||||
|
|
||||||
|
# ✅ GOOD: Use reactive for automatic UI updates
|
||||||
|
current_message_index: reactive[int] = reactive(-1)
|
||||||
|
|
||||||
|
@reactive_var.on_change
|
||||||
|
def action_next(self):
|
||||||
|
# Automatically triggers minimal re-render
|
||||||
|
self.current_message_index += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Textual's reactive system only updates changed widgets, not the entire app.
|
||||||
|
|
||||||
|
#### 4. **Avoid String Concatenation in Loops for Updates**
|
||||||
|
```python
|
||||||
|
# ❌ BAD: Creates new strings every time
|
||||||
|
def update_status(self):
|
||||||
|
text = "Status: "
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
text += f"{i+1}. {item.name}\n" # O(n²) string operations
|
||||||
|
self.status.update(text)
|
||||||
|
|
||||||
|
# ✅ GOOD: Build list once
|
||||||
|
def update_status(self):
|
||||||
|
lines = [f"{i+1}. {item.name}" for i, item in enumerate(items)]
|
||||||
|
text = "\n".join(lines) # O(n) operations
|
||||||
|
self.status.update(text)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** String concatenation is O(n²), while join is O(n).
|
||||||
|
|
||||||
|
#### 5. **Use Efficient List Widgets**
|
||||||
|
```python
|
||||||
|
# ❌ BAD: Creating custom widget for each item
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
def create_mail_list(items):
|
||||||
|
for item in items:
|
||||||
|
yield Static(item.subject) # N widgets = N render cycles
|
||||||
|
|
||||||
|
# ✅ GOOD: Use ListView with data binding
|
||||||
|
from textual.widgets import ListView, ListItem
|
||||||
|
|
||||||
|
class MailApp(App):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield ListView(id="envelopes_list")
|
||||||
|
|
||||||
|
def update_list(self, items: list):
|
||||||
|
list_view = self.query_one("#envelopes_list", ListView)
|
||||||
|
list_view.clear()
|
||||||
|
list_view.extend([ListItem(item.subject) for item in items]) # Efficient
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `ListView` is optimized for lists with virtualization and pooling.
|
||||||
|
|
||||||
|
#### 6. **Debounce Expensive Operations**
|
||||||
|
```python
|
||||||
|
from textual.timer import Timer
|
||||||
|
|
||||||
|
# ❌ BAD: Update on every keypress
|
||||||
|
def action_search(self, query: str):
|
||||||
|
results = self.search_messages(query) # Expensive
|
||||||
|
self.update_results(results)
|
||||||
|
|
||||||
|
# ✅ GOOD: Debounce search
|
||||||
|
class MailApp(App):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._search_debounce = None
|
||||||
|
|
||||||
|
def action_search(self, query: str):
|
||||||
|
if self._search_debounce:
|
||||||
|
self._search_debounce.stop() # Cancel pending search
|
||||||
|
self._search_debounce = Timer(
|
||||||
|
0.3, # Wait 300ms
|
||||||
|
self._do_search,
|
||||||
|
query
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _do_search(self, query: str) -> None:
|
||||||
|
results = self.search_messages(query)
|
||||||
|
self.update_results(results)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Avoids expensive recomputations for rapid user input.
|
||||||
|
|
||||||
|
#### 7. **Use `work()` Decorator for Background Tasks**
|
||||||
|
```python
|
||||||
|
from textual import work
|
||||||
|
|
||||||
|
class MailApp(App):
|
||||||
|
@work(exclusive=True)
|
||||||
|
async def load_message_content(self, message_id: int):
|
||||||
|
"""Load message content without blocking UI."""
|
||||||
|
content = await himalaya_client.get_message_content(message_id)
|
||||||
|
self._update_content_display(content)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Background workers don't block the UI thread.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mail App Performance Issues Analysis
|
||||||
|
|
||||||
|
### Current Implementation Problems
|
||||||
|
|
||||||
|
#### 1. **Message List Rendering** (src/mail/app.py)
|
||||||
|
```python
|
||||||
|
# PROBLEM: Rebuilding entire list on navigation
|
||||||
|
def action_next(self) -> None:
|
||||||
|
if not self.current_message_index >= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_id, next_idx = self.message_store.find_next_valid_id(
|
||||||
|
self.current_message_index
|
||||||
|
)
|
||||||
|
if next_id is not None and next_idx is not None:
|
||||||
|
self.current_message_id = next_id
|
||||||
|
self.current_message_index = next_idx
|
||||||
|
self._update_envelope_list_view() # ❌ Rebuilds entire list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** `_update_envelope_list_view()` rebuilds the entire message list on every navigation.
|
||||||
|
|
||||||
|
#### 2. **Envelope List Item Creation** (src/mail/widgets/EnvelopeListItem.py)
|
||||||
|
```python
|
||||||
|
# PROBLEM: Creating many widgets
|
||||||
|
class EnvelopeListItem(CustomListItem):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static(self._from_display, classes="from")
|
||||||
|
yield Static(self._subject_display, classes="subject")
|
||||||
|
yield Static(self._date_display, classes="date")
|
||||||
|
# ❌ Each item creates 4+ Static widgets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** For 100 emails, this creates 400+ widgets. Should use a single widget.
|
||||||
|
|
||||||
|
#### 3. **Message Content Loading** (src/mail/widgets/ContentContainer.py)
|
||||||
|
```python
|
||||||
|
# PROBLEM: Blocking UI during content fetch
|
||||||
|
def display_content(self, message_id: int):
|
||||||
|
# ... loading logic
|
||||||
|
format_type = "text" if self.current_mode == "markdown" else "html"
|
||||||
|
self.content_worker = self.fetch_message_content(message_id, format_type)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** Content fetch may block UI. Should use `@work` decorator.
|
||||||
|
|
||||||
|
#### 4. **Envelope List Updates** (src/mail/app.py lines 920-950)
|
||||||
|
```python
|
||||||
|
# PROBLEM: Complex envelope list rebuilding
|
||||||
|
def _update_envelope_list_view(self) -> None:
|
||||||
|
grouped_envelopes = []
|
||||||
|
for i, envelope in enumerate(self.message_store.envelopes):
|
||||||
|
# ❌ Processing every envelope on every update
|
||||||
|
if envelope.get("type") == "header":
|
||||||
|
grouped_envelopes.append({"type": "header", "label": ...})
|
||||||
|
else:
|
||||||
|
# Complex formatting
|
||||||
|
grouped_envelopes.append({...})
|
||||||
|
|
||||||
|
# ❌ Clearing and rebuilding entire list
|
||||||
|
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||||
|
envelopes_list.clear()
|
||||||
|
envelopes_list.extend([...])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** Rebuilding entire list is expensive. Should only update changed items.
|
||||||
|
|
||||||
|
#### 5. **Folder/Account Count Updates** (src/mail/app.py)
|
||||||
|
```python
|
||||||
|
# PROBLEM: Re-computing counts on every change
|
||||||
|
def _update_folder_list_view(self) -> None:
|
||||||
|
for folder in self.folders:
|
||||||
|
count = len([e for e in self.envelopes if e.get("folder") == folder]) # ❌ O(n) scan
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** Counting all envelopes for each folder is expensive. Should cache counts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimization Plan
|
||||||
|
|
||||||
|
### Phase 1: Critical Performance Fixes (Week 1)
|
||||||
|
|
||||||
|
#### 1.1 Convert to `compose()` Pattern
|
||||||
|
**File:** `src/mail/app.py`
|
||||||
|
|
||||||
|
**Current:** Manual widget mounting in `on_mount()` and other methods
|
||||||
|
**Goal:** Use `compose()` for declarative UI building
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
# Before (BAD):
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
# ... manual mounting
|
||||||
|
|
||||||
|
# After (GOOD):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="app_container"):
|
||||||
|
with Horizontal():
|
||||||
|
# Left panel
|
||||||
|
with Vertical(id="left_panel"):
|
||||||
|
yield Static("Accounts", id="accounts_header")
|
||||||
|
yield ListView(id="accounts_list")
|
||||||
|
|
||||||
|
yield Static("Folders", id="folders_header")
|
||||||
|
yield ListView(id="folders_list")
|
||||||
|
|
||||||
|
# Middle panel
|
||||||
|
with Vertical(id="middle_panel"):
|
||||||
|
yield Static("Messages", id="messages_header")
|
||||||
|
yield ListView(id="envelopes_list")
|
||||||
|
|
||||||
|
# Right panel
|
||||||
|
yield ContentContainer(id="content_container")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** 30-50% faster startup, reduced memory usage
|
||||||
|
|
||||||
|
#### 1.2 Implement Lazy Loading for Envelopes
|
||||||
|
**File:** `src/mail/app.py`
|
||||||
|
|
||||||
|
**Current:** Load all envelopes at startup
|
||||||
|
**Goal:** Load envelopes on-demand using background workers
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class MailApp(App):
|
||||||
|
envelopes_loaded: reactive[bool] = reactive(False)
|
||||||
|
_envelopes_cache: list[dict] = []
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
# Start background loading
|
||||||
|
self._load_initial_envelopes()
|
||||||
|
|
||||||
|
@work(exclusive=True, group="envelope_loading")
|
||||||
|
async def _load_initial_envelopes(self):
|
||||||
|
"""Load initial envelopes in background."""
|
||||||
|
envelopes, success = await himalaya_client.list_envelopes()
|
||||||
|
if success:
|
||||||
|
self._envelopes_cache = envelopes
|
||||||
|
self.envelopes_loaded = True
|
||||||
|
self._update_envelope_list_view()
|
||||||
|
|
||||||
|
def _load_more_envelopes(self) -> None:
|
||||||
|
"""Load more envelopes when scrolling."""
|
||||||
|
pass # Implement lazy loading
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** 60-70% faster startup, perceived instant UI
|
||||||
|
|
||||||
|
#### 1.3 Optimize Message List Updates
|
||||||
|
**File:** `src/mail/app.py`
|
||||||
|
|
||||||
|
**Current:** Rebuild entire list on navigation
|
||||||
|
**Goal:** Only update changed items, use reactive properties
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class MailApp(App):
|
||||||
|
current_message_index: reactive[int] = reactive(-1)
|
||||||
|
|
||||||
|
def action_next(self) -> None:
|
||||||
|
"""Move to next message efficiently."""
|
||||||
|
if not self.current_message_index >= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_id, next_idx = self.message_store.find_next_valid_id(
|
||||||
|
self.current_message_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if next_id is not None:
|
||||||
|
# ✅ Only update reactive property
|
||||||
|
self.current_message_index = next_idx
|
||||||
|
# ✅ Let Textual handle the update
|
||||||
|
# DON'T call _update_envelope_list_view()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** 80-90% faster navigation, no UI flicker
|
||||||
|
|
||||||
|
#### 1.4 Use Background Workers for Content Loading
|
||||||
|
**File:** `src/mail/widgets/ContentContainer.py`
|
||||||
|
|
||||||
|
**Current:** Blocking content fetch
|
||||||
|
**Goal:** Use `@work` decorator for non-blocking loads
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class ContentContainer(ScrollableContainer):
|
||||||
|
@work(exclusive=True)
|
||||||
|
async def fetch_message_content(self, message_id: int, format_type: str) -> None:
|
||||||
|
"""Fetch message content in background without blocking UI."""
|
||||||
|
content, success = await himalaya_client.get_message_content(
|
||||||
|
message_id,
|
||||||
|
folder=self.current_folder,
|
||||||
|
account=self.current_account
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and content:
|
||||||
|
self._update_content(content)
|
||||||
|
else:
|
||||||
|
self.notify("Failed to fetch message content")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** No UI blocking, smooth content transitions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Code Quality & Architecture (Week 2)
|
||||||
|
|
||||||
|
#### 2.1 Refactor Message Store for Efficiency
|
||||||
|
**File:** `src/mail/message_store.py`
|
||||||
|
|
||||||
|
**Current:** Linear searches, no caching
|
||||||
|
**Goal:** Implement indexed lookups, cache counts
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class MessageStore:
|
||||||
|
"""Optimized message store with caching."""
|
||||||
|
|
||||||
|
def __init__(self, envelopes: list[dict]):
|
||||||
|
self.envelopes = envelopes
|
||||||
|
self._index_cache = {} # O(1) lookup cache
|
||||||
|
self._folder_counts = {} # Cached folder counts
|
||||||
|
self._unread_counts = {} # Cached unread counts
|
||||||
|
|
||||||
|
# Build caches
|
||||||
|
self._build_caches()
|
||||||
|
|
||||||
|
def _build_caches(self) -> None:
|
||||||
|
"""Build lookup caches."""
|
||||||
|
for idx, envelope in enumerate(self.envelopes):
|
||||||
|
self._index_cache[envelope["id"]] = idx
|
||||||
|
folder = envelope.get("folder", "INBOX")
|
||||||
|
self._folder_counts[folder] = self._folder_counts.get(folder, 0) + 1
|
||||||
|
if not envelope.get("flags", {}).get("seen", False):
|
||||||
|
self._unread_counts[folder] = self._unread_counts.get(folder, 0) + 1
|
||||||
|
|
||||||
|
def get_index(self, message_id: int) -> int | None:
|
||||||
|
"""Get envelope index in O(1)."""
|
||||||
|
return self._index_cache.get(message_id)
|
||||||
|
|
||||||
|
def get_folder_count(self, folder: str) -> int:
|
||||||
|
"""Get folder count in O(1)."""
|
||||||
|
return self._folder_counts.get(folder, 0)
|
||||||
|
|
||||||
|
def get_unread_count(self, folder: str) -> int:
|
||||||
|
"""Get unread count in O(1)."""
|
||||||
|
return self._unread_counts.get(folder, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** O(1) lookups instead of O(n), instant count retrieval
|
||||||
|
|
||||||
|
#### 2.2 Consolidate Envelope List Item
|
||||||
|
**File:** `src/mail/widgets/EnvelopeListItem.py`
|
||||||
|
|
||||||
|
**Current:** Multiple widgets per item
|
||||||
|
**Goal:** Use single widget with custom rendering
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class EnvelopeListItem(CustomListItem):
|
||||||
|
"""Optimized envelope list item using single widget."""
|
||||||
|
|
||||||
|
def __init__(self, envelope: dict, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.envelope = envelope
|
||||||
|
|
||||||
|
def render(self) -> RichText:
|
||||||
|
"""Render as single RichText widget."""
|
||||||
|
from rich.text import Text, Text as RichText
|
||||||
|
|
||||||
|
# Build RichText once (more efficient than multiple widgets)
|
||||||
|
text = Text.assemble(
|
||||||
|
self._from_display,
|
||||||
|
" ",
|
||||||
|
self._subject_display,
|
||||||
|
" ",
|
||||||
|
self._date_display,
|
||||||
|
style="on" if self.envelope.get("flags", {}).get("seen") else "bold"
|
||||||
|
)
|
||||||
|
|
||||||
|
return text
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** 70% reduction in widget count, faster rendering
|
||||||
|
|
||||||
|
#### 2.3 Add Memoization for Expensive Operations
|
||||||
|
**File:** `src/mail/utils.py`
|
||||||
|
|
||||||
|
**Current:** Re-computing values
|
||||||
|
**Goal:** Cache computed values
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def format_sender_name(envelope: dict) -> str:
|
||||||
|
"""Format sender name with caching."""
|
||||||
|
from_name = envelope.get("from", {}).get("name", "")
|
||||||
|
from_addr = envelope.get("from", {}).get("addr", "")
|
||||||
|
|
||||||
|
if not from_name:
|
||||||
|
return from_addr
|
||||||
|
|
||||||
|
# Truncate if too long
|
||||||
|
if len(from_name) > 25:
|
||||||
|
return from_name[:22] + "..."
|
||||||
|
|
||||||
|
return from_name
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def format_date(date_str: str) -> str:
|
||||||
|
"""Format date with caching."""
|
||||||
|
# Parse and format date string
|
||||||
|
# Implementation...
|
||||||
|
return formatted_date
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** Faster repeated operations, reduced CPU usage
|
||||||
|
|
||||||
|
#### 2.4 Add Notification Compression Caching
|
||||||
|
**File:** `src/mail/notification_compressor.py`
|
||||||
|
|
||||||
|
**Current:** Re-compressing on every view
|
||||||
|
**Goal:** Cache compressed results
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class NotificationCompressor:
|
||||||
|
"""Compressor with caching for performance."""
|
||||||
|
|
||||||
|
def __init__(self, mode: str = "summary"):
|
||||||
|
self.mode = mode
|
||||||
|
self._compression_cache = {} # Cache compressed content
|
||||||
|
|
||||||
|
def compress(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
envelope: dict[str, Any]
|
||||||
|
) -> tuple[str, NotificationType | None]:
|
||||||
|
"""Compress with caching."""
|
||||||
|
cache_key = f"{envelope['id']}:{self.mode}"
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
if cache_key in self._compression_cache:
|
||||||
|
return self._compression_cache[cache_key]
|
||||||
|
|
||||||
|
# Compress and cache
|
||||||
|
compressed, notif_type = self._compress_impl(content, envelope)
|
||||||
|
self._compression_cache[cache_key] = (compressed, notif_type)
|
||||||
|
|
||||||
|
return compressed, notif_type
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** Instant display for previously viewed notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Advanced Optimizations (Week 3-4)
|
||||||
|
|
||||||
|
#### 3.1 Implement Virtual Scrolling
|
||||||
|
**File:** `src/mail/app.py`
|
||||||
|
|
||||||
|
**Current:** Render all items in list
|
||||||
|
**Goal:** Use ListView virtualization
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield ListView(
|
||||||
|
id="envelopes_list",
|
||||||
|
initial_index=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ListView automatically virtualizes for performance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** Constant time rendering regardless of list size
|
||||||
|
|
||||||
|
#### 3.2 Debounce User Input
|
||||||
|
**File:** `src/mail/screens/SearchPanel.py`
|
||||||
|
|
||||||
|
**Current:** Search on every keystroke
|
||||||
|
**Goal:** Debounce search with 300ms delay
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class SearchPanel(Screen):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._search_debounce = None
|
||||||
|
|
||||||
|
def on_input_changed(self, event) -> None:
|
||||||
|
"""Debounce search input."""
|
||||||
|
if self._search_debounce:
|
||||||
|
self._search_debounce.stop()
|
||||||
|
|
||||||
|
self._search_debounce = Timer(
|
||||||
|
0.3,
|
||||||
|
self._perform_search,
|
||||||
|
event.value
|
||||||
|
).start()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** 80% reduction in expensive search operations
|
||||||
|
|
||||||
|
#### 3.3 Use `dataclass` for Data Models
|
||||||
|
**File:** `src/mail/notification_detector.py`
|
||||||
|
|
||||||
|
**Current:** Dict-based data access
|
||||||
|
**Goal:** Use dataclasses for type safety and performance
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Envelope:
|
||||||
|
"""Typed envelope data model."""
|
||||||
|
id: int
|
||||||
|
subject: str
|
||||||
|
from_name: str
|
||||||
|
from_addr: str
|
||||||
|
date: str
|
||||||
|
flags: dict = field(default_factory=dict)
|
||||||
|
folder: str = "INBOX"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** Type safety, better IDE support, faster attribute access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Memory & Resource Management (Week 4)
|
||||||
|
|
||||||
|
#### 4.1 Implement Widget Pooling
|
||||||
|
**File:** `src/mail/app.py`
|
||||||
|
|
||||||
|
**Current:** Creating new widgets constantly
|
||||||
|
**Goal:** Reuse widgets to reduce allocations
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class WidgetPool:
|
||||||
|
"""Pool for reusing widgets."""
|
||||||
|
|
||||||
|
def __init__(self, widget_class, max_size: int = 50):
|
||||||
|
self.widget_class = widget_class
|
||||||
|
self.pool = []
|
||||||
|
self.max_size = max_size
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""Get widget from pool or create new."""
|
||||||
|
if self.pool:
|
||||||
|
return self.pool.pop()
|
||||||
|
return self.widget_class()
|
||||||
|
|
||||||
|
def release(self, widget) -> None:
|
||||||
|
"""Return widget to pool."""
|
||||||
|
if len(self.pool) < self.max_size:
|
||||||
|
self.pool.append(widget)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** Reduced garbage collection, smoother scrolling
|
||||||
|
|
||||||
|
#### 4.2 Implement Content Pagination
|
||||||
|
**File:** `src/mail/widgets/ContentContainer.py`
|
||||||
|
|
||||||
|
**Current:** Load full content
|
||||||
|
**Goal:** Load content in chunks for large emails
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```python
|
||||||
|
class ContentContainer(ScrollableContainer):
|
||||||
|
PAGE_SIZE = 500 # Characters per page
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._pages: list[str] = []
|
||||||
|
self._current_page = 0
|
||||||
|
|
||||||
|
def _load_next_page(self) -> None:
|
||||||
|
"""Load next page of content when scrolling."""
|
||||||
|
if self._current_page + 1 < len(self._pages):
|
||||||
|
self._current_page += 1
|
||||||
|
self.content.update(self._pages[self._current_page])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Impact:** Faster initial load, smoother scrolling for large emails
|
||||||
|
|
||||||
|
#### 4.3 Clean Up Unused Imports
|
||||||
|
**Files:** All Python files in `src/mail/`
|
||||||
|
|
||||||
|
**Current:** Unused imports, circular dependencies
|
||||||
|
**Goal:** Remove all unused code
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Run `ruff check` and fix all unused imports
|
||||||
|
- Remove circular dependencies
|
||||||
|
- Clean up `__all__` exports
|
||||||
|
- Optimize import order
|
||||||
|
|
||||||
|
**Expected Impact:** Faster import time, smaller memory footprint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Week 1: Critical Performance Fixes
|
||||||
|
1. Day 1-2: Implement `compose()` pattern
|
||||||
|
2. Day 3-4: Lazy loading for envelopes
|
||||||
|
3. Day 5: Optimize message list navigation
|
||||||
|
4. Day 6-7: Background workers for content loading
|
||||||
|
5. Day 8-10: Testing and benchmarking
|
||||||
|
|
||||||
|
### Week 2: Code Quality
|
||||||
|
1. Day 1-2: Refactor MessageStore with caching
|
||||||
|
2. Day 3-4: Consolidate EnvelopeListItem
|
||||||
|
3. Day 5: Add memoization utilities
|
||||||
|
4. Day 6-7: Notification compression caching
|
||||||
|
5. Day 8-10: Code review and cleanup
|
||||||
|
|
||||||
|
### Week 3: Advanced Optimizations
|
||||||
|
1. Day 1-3: Virtual scrolling implementation
|
||||||
|
2. Day 4-5: Debounce user input
|
||||||
|
3. Day 6-7: Data model refactoring
|
||||||
|
4. Day 8-10: Performance testing
|
||||||
|
|
||||||
|
### Week 4: Memory Management
|
||||||
|
1. Day 1-3: Widget pooling
|
||||||
|
2. Day 4-5: Content pagination
|
||||||
|
3. Day 6-7: Import cleanup
|
||||||
|
4. Day 8-10: Final optimization and polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Performance Targets
|
||||||
|
- **Startup Time:** < 1 second (currently: 3-5 seconds)
|
||||||
|
- **Navigation Latency:** < 50ms between messages (currently: 200-500ms)
|
||||||
|
- **List Rendering:** < 100ms for 100 items (currently: 500-1000ms)
|
||||||
|
- **Memory Usage:** < 100MB for 1000 emails (currently: 300-500MB)
|
||||||
|
- **Frame Rate:** 60 FPS during navigation (currently: 10-20 FPS)
|
||||||
|
|
||||||
|
### Code Quality Targets
|
||||||
|
- **Test Coverage:** > 80% (currently: ~10%)
|
||||||
|
- **Ruff Warnings:** 0 critical, < 5 style warnings
|
||||||
|
- **Import Cleanup:** 100% of files cleaned
|
||||||
|
- **Type Coverage:** 100% typed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Performance Benchmarking
|
||||||
|
```python
|
||||||
|
# benchmark_performance.py
|
||||||
|
import time
|
||||||
|
import tracemalloc
|
||||||
|
from src.mail.app import EmailViewerApp
|
||||||
|
|
||||||
|
def benchmark_startup():
|
||||||
|
"""Benchmark app startup time."""
|
||||||
|
tracemalloc.start()
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
app = EmailViewerApp()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
end = time.time()
|
||||||
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
|
|
||||||
|
print(f"Startup Time: {end - start:.3f}s")
|
||||||
|
print(f"Memory Usage: {peak / 1024 / 1024:.2f} MB")
|
||||||
|
|
||||||
|
def benchmark_navigation():
|
||||||
|
"""Benchmark message navigation."""
|
||||||
|
app = EmailViewerApp()
|
||||||
|
# ... measure navigation timing
|
||||||
|
|
||||||
|
timings = []
|
||||||
|
for i in range(100):
|
||||||
|
start = time.time()
|
||||||
|
app.action_next()
|
||||||
|
end = time.time()
|
||||||
|
timings.append(end - start)
|
||||||
|
|
||||||
|
print(f"Average Navigation Time: {sum(timings) / len(timings) * 1000:.1f}ms")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Test with 100, 1000, and 10000 messages
|
||||||
|
- Measure memory usage over time
|
||||||
|
- Test with slow network conditions
|
||||||
|
- Test on different terminal sizes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
### Textual Documentation
|
||||||
|
- **Main Docs:** https://textual.textualize.io/
|
||||||
|
- **Widget Guide:** https://textual.textualize.io/guide/widgets/
|
||||||
|
- **Best Practices:** https://textual.textualize.io/blog/
|
||||||
|
- **Performance Guide:** https://textual.textualize.io/blog/2024/12/12/algorithms-for-high-performance-terminal-apps/
|
||||||
|
|
||||||
|
### Python Performance Guides
|
||||||
|
- **Python Performance Guide:** https://www.fyld.pt/blog/python-performance-guide-writing-code-2025
|
||||||
|
- **Optimization Techniques:** https://analyticsvidhya.com/blog/2024/01/optimize-python-code-for-high-speed-execution
|
||||||
|
|
||||||
|
### Similar Projects
|
||||||
|
- **Rich Console Examples:** https://github.com/Textualize/rich
|
||||||
|
- **Prompt Toolkit:** https://github.com/prompt-toolkit/python-prompt-toolkit
|
||||||
|
- **Urwid:** https://github.com/urwid/urwid
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This plan focuses on the mail app but principles apply to calendar and tasks apps
|
||||||
|
- All changes should be backward compatible
|
||||||
|
- Run performance benchmarks before and after each phase
|
||||||
|
- Document any Textual-specific optimizations discovered during implementation
|
||||||
118
PROJECT_PLAN.md
118
PROJECT_PLAN.md
@@ -439,26 +439,26 @@ Implement `/` keybinding for search across all apps with similar UX:
|
|||||||
|
|
||||||
### Phase 1: Critical/High Priority
|
### Phase 1: Critical/High Priority
|
||||||
1. ~~Tasks App: Fix table display~~ (DONE)
|
1. ~~Tasks App: Fix table display~~ (DONE)
|
||||||
2. Sync: Parallelize message downloads
|
2. ~~Sync: Parallelize message downloads~~ (DONE - connection pooling + batch size increase)
|
||||||
3. Mail: Replace hardcoded RGB colors
|
3. ~~Mail: Replace hardcoded RGB colors~~ (DONE - already using theme variables)
|
||||||
4. Mail: Remove envelope icon/checkbox gap
|
4. ~~Mail: Remove envelope icon/checkbox gap~~ (DONE)
|
||||||
5. Calendar: Current time hour line styling
|
5. ~~Calendar: Current time hour line styling~~ (DONE - added surface background)
|
||||||
6. IPC: Implement cross-app refresh notifications
|
6. ~~IPC: Implement cross-app refresh notifications~~ (DONE)
|
||||||
|
|
||||||
### Phase 2: Medium Priority
|
### Phase 2: Medium Priority
|
||||||
1. Sync: Default to TUI mode
|
1. ~~Sync: Default to TUI mode~~ (DONE - already implemented)
|
||||||
2. Calendar: Cursor hour header highlighting
|
2. ~~Calendar: Cursor hour header highlighting~~ (DONE)
|
||||||
3. Calendar: Responsive detail panel
|
3. Calendar: Responsive detail panel
|
||||||
4. Calendar: Sidebar mini-calendar
|
4. Calendar: Sidebar mini-calendar
|
||||||
5. Calendar: Calendar invites sidebar
|
5. Calendar: Calendar invites sidebar
|
||||||
6. Mail: Add refresh keybinding
|
6. ~~Mail: Add refresh keybinding~~ (DONE - `r` key)
|
||||||
7. Mail: Add mark read/unread action
|
7. ~~Mail: Add mark read/unread action~~ (DONE - `u` key)
|
||||||
8. Mail: Folder message counts
|
8. ~~Mail: Folder message counts~~ (DONE)
|
||||||
9. Mail: URL compression in markdown view
|
8. ~~Mail: URL compression in markdown view~~ (DONE)
|
||||||
10. Mail: Enhance subject styling
|
9. ~~Mail: Enhance subject styling~~ (DONE)
|
||||||
11. Mail: Search feature
|
10. Mail: Search feature
|
||||||
12. Tasks: Search feature
|
11. ~~Tasks: Search feature~~ (DONE - `/` key with live filtering)
|
||||||
13. Calendar: Search feature
|
12. ~~Calendar: Search feature~~ (DONE - `/` key using khal search)
|
||||||
|
|
||||||
### Phase 3: Low Priority
|
### Phase 3: Low Priority
|
||||||
1. Sync: UI consistency (j/k navigation, borders)
|
1. Sync: UI consistency (j/k navigation, borders)
|
||||||
@@ -519,6 +519,94 @@ class IPCClient:
|
|||||||
- IPC should gracefully handle missing socket (apps work standalone)
|
- IPC should gracefully handle missing socket (apps work standalone)
|
||||||
- Search should be responsive and not block UI
|
- Search should be responsive and not block UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mail Rendering Improvements
|
||||||
|
|
||||||
|
### Smart Notification Email Compression (COMPLETED)
|
||||||
|
**Priority:** High
|
||||||
|
**Files:** `src/mail/notification_detector.py`, `src/mail/notification_compressor.py`, `src/mail/config.py`
|
||||||
|
|
||||||
|
**Problem:** Transactional notification emails from tools like Renovate, Jira, Confluence, and Datadog are hard to parse and display poorly in plain text terminal environments.
|
||||||
|
|
||||||
|
**Solution:** Implemented intelligent notification detection and compression system:
|
||||||
|
|
||||||
|
1. **Notification Type Detection**
|
||||||
|
- Automatically detects emails from:
|
||||||
|
- GitLab (pipelines, MRs, mentions)
|
||||||
|
- GitHub (PRs, issues, reviews)
|
||||||
|
- Jira (issues, status changes)
|
||||||
|
- Confluence (page updates, comments)
|
||||||
|
- Datadog (alerts, incidents)
|
||||||
|
- Renovate (dependency updates)
|
||||||
|
- General notifications (digests, automated emails)
|
||||||
|
- Uses sender domain and subject pattern matching
|
||||||
|
- Distinguishes between similar services (e.g., Jira vs Confluence)
|
||||||
|
|
||||||
|
2. **Structured Summary Extraction**
|
||||||
|
- Type-specific extractors for each platform
|
||||||
|
- Extracts: IDs, titles, status changes, action items
|
||||||
|
- Preserves important links for quick access
|
||||||
|
|
||||||
|
3. **Terminal-Friendly Formatting**
|
||||||
|
- Two compression modes:
|
||||||
|
- `summary`: Brief one-page view
|
||||||
|
- `detailed`: Structured table format
|
||||||
|
- Markdown rendering with icons and clear hierarchy
|
||||||
|
- Shows notification type, key details, actions
|
||||||
|
- Footer indicates compressed view (toggle to full with `m`)
|
||||||
|
|
||||||
|
4. **Configuration Options**
|
||||||
|
```toml
|
||||||
|
[content_display]
|
||||||
|
compress_notifications = true
|
||||||
|
notification_compression_mode = "summary" # "summary", "detailed", or "off"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Features**
|
||||||
|
- Zero-dependency (no LLM required, fast)
|
||||||
|
- Rule-based for reliability
|
||||||
|
- Extensible to add new notification types
|
||||||
|
- Preserves original content for full view toggle
|
||||||
|
- Type-specific icons using NerdFont glyphs
|
||||||
|
|
||||||
|
**Implementation Details:**
|
||||||
|
- `NotificationType` dataclass for type definitions
|
||||||
|
- `is_notification_email()` for detection
|
||||||
|
- `classify_notification()` for type classification
|
||||||
|
- `extract_notification_summary()` for structured data
|
||||||
|
- `NotificationCompressor` for formatting
|
||||||
|
- `DetailedCompressor` for extended summaries
|
||||||
|
- Integrated with `ContentContainer` widget
|
||||||
|
- 13 unit tests covering all notification types
|
||||||
|
|
||||||
|
**Future Enhancements:**
|
||||||
|
- Add LLM-based summarization option (opt-in)
|
||||||
|
- Learn notification patterns from user feedback
|
||||||
|
- Custom notification type definitions
|
||||||
|
- Action-based email filtering (e.g., "archive all Renovate emails")
|
||||||
|
- Bulk actions on notification emails (archive all, mark all read)
|
||||||
|
- Notification grouping in envelope list
|
||||||
|
- Statistics on notification emails (counts by type, frequency)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note-taking integration and more cross-app integrations
|
||||||
|
|
||||||
|
I like the `tdo` (https://github.com/2KAbhishek/tdo) program for managing markdown notes with fzf and my terminal text editor. It makes it easy to have a "today" note and a list of todos. Perhaps we can gather those todos from the text files in the $NOTES_DIR and put them into the task list during regular sync - and when users mark a task complete the sync can find the text file it was in and mark it complete on that line of markdown text. We need a little ore features for the related annotations then, because when I press `n` in the notes app we would want to actually open the textfile that task came from, not just make another annotation. So we would need a special cross-linking format for knowing which tasks came from a $NOTES_DIR sync. And then we can work on the same IPC scenario for tasks that were created in the email app. Then those tasks should be connected so that when the user views the notes on those tasks they see the whole email. That would be simpe enough if we just copied the email text into an annotation. But maybe we need a way to actually change the selected message ID in the mail app if it's open. So if the user activates the "open" feature on a task the related email will be displayed in the other terminal window where the user has mail open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Polish for release and Auth
|
||||||
|
|
||||||
|
- Authentication isn't working if the user is in a different directory than expected. Store the auth token in a ~/.local directory so it's consistently availabe in every dir.
|
||||||
|
- Setup scripts, look at popular modern python CLI or TUI projects and create an install or first run script that works for most scenarios, using `uv` or `pip` to install globally for users, or maybe get a homebrew installation to work? Get users to set up himalaya and khal and dstask but ask them for their choice once we have other backends available.
|
||||||
|
- Documentation files in the repo, and documentation site to publish.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Library Updates & Python Version Review
|
## Library Updates & Python Version Review
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -12,6 +12,7 @@ A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local fil
|
|||||||
- **TUI Dashboard**: Interactive terminal dashboard for monitoring sync progress
|
- **TUI Dashboard**: Interactive terminal dashboard for monitoring sync progress
|
||||||
- **Daemon Mode**: Background daemon with proper Unix logging
|
- **Daemon Mode**: Background daemon with proper Unix logging
|
||||||
- **Cross-Platform**: Works on macOS, Linux, and Windows
|
- **Cross-Platform**: Works on macOS, Linux, and Windows
|
||||||
|
- **Smart Notification Compression**: Automatically detects and compresses transactional notification emails (GitLab, GitHub, Jira, Confluence, Datadog, Renovate) into terminal-friendly summaries
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -50,6 +51,37 @@ mkdir -p ~/.local/share/luk
|
|||||||
|
|
||||||
Create a configuration file at `~/.config/luk/config.env`:
|
Create a configuration file at `~/.config/luk/config.env`:
|
||||||
|
|
||||||
|
### Notification Email Compression
|
||||||
|
|
||||||
|
LUK includes intelligent notification email compression to reduce visual noise from automated emails (GitLab, GitHub, Jira, Confluence, Datadog, Renovate). Configure it in `~/.config/luk/mail.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[content_display]
|
||||||
|
# Enable/disable notification compression
|
||||||
|
compress_notifications = true
|
||||||
|
|
||||||
|
# Compression mode:
|
||||||
|
# - "summary": Brief one-page view (default)
|
||||||
|
# - "detailed": More details in structured format
|
||||||
|
# - "off": Disable compression, show full emails
|
||||||
|
notification_compression_mode = "summary"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Automatic detection**: Identifies notification emails by sender domain and subject patterns
|
||||||
|
- **Type-specific summaries**: Extracts relevant info per platform (pipeline IDs, PR numbers, etc.)
|
||||||
|
- **Terminal-friendly formatting**: Clean markdown with icons and clear hierarchy
|
||||||
|
- **Toggle support**: Press `m` in mail view to switch between compressed and full email
|
||||||
|
|
||||||
|
**Demo:**
|
||||||
|
```bash
|
||||||
|
python demo_notification_compression.py
|
||||||
|
```
|
||||||
|
|
||||||
|
See `mail.toml.example` for full configuration options.
|
||||||
|
|
||||||
|
### General Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Microsoft Graph settings
|
# Microsoft Graph settings
|
||||||
MICROSOFT_CLIENT_ID=your_client_id
|
MICROSOFT_CLIENT_ID=your_client_id
|
||||||
|
|||||||
202
demo_notification_compression.py
Normal file
202
demo_notification_compression.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Demo script for notification email compression.
|
||||||
|
|
||||||
|
This script demonstrates how notification emails are detected and compressed
|
||||||
|
into terminal-friendly summaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.mail.notification_detector import (
|
||||||
|
is_notification_email,
|
||||||
|
classify_notification,
|
||||||
|
extract_notification_summary,
|
||||||
|
NOTIFICATION_TYPES,
|
||||||
|
)
|
||||||
|
from src.mail.notification_compressor import NotificationCompressor, DetailedCompressor
|
||||||
|
|
||||||
|
|
||||||
|
def demo_detection():
|
||||||
|
"""Demonstrate notification detection for various email types."""
|
||||||
|
|
||||||
|
test_emails = [
|
||||||
|
{
|
||||||
|
"from": {"addr": "notifications@gitlab.com", "name": "GitLab"},
|
||||||
|
"subject": "Pipeline #12345 failed by john.doe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": {"addr": "noreply@github.com", "name": "GitHub"},
|
||||||
|
"subject": "[GitHub] PR #42: Add new feature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": {"addr": "jira@atlassian.net", "name": "Jira"},
|
||||||
|
"subject": "[Jira] ABC-123: Fix login bug",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": {"addr": "confluence@atlassian.net", "name": "Confluence"},
|
||||||
|
"subject": "[Confluence] New comment on page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": {"addr": "alerts@datadoghq.com", "name": "Datadog"},
|
||||||
|
"subject": "[Datadog] Alert: High CPU usage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": {"addr": "renovate@renovatebot.com", "name": "Renovate"},
|
||||||
|
"subject": "[Renovate] Update dependency to v2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": {"addr": "john.doe@example.com", "name": "John Doe"},
|
||||||
|
"subject": "Let's meet for lunch",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("NOTIFICATION DETECTION DEMO")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, envelope in enumerate(test_emails, 1):
|
||||||
|
from_addr = envelope.get("from", {}).get("addr", "")
|
||||||
|
subject = envelope.get("subject", "")
|
||||||
|
|
||||||
|
print(f"Email {i}: {subject}")
|
||||||
|
print(f" From: {from_addr}")
|
||||||
|
|
||||||
|
# Check if notification
|
||||||
|
is_notif = is_notification_email(envelope)
|
||||||
|
print(f" Is Notification: {is_notif}")
|
||||||
|
|
||||||
|
if is_notif:
|
||||||
|
notif_type = classify_notification(envelope)
|
||||||
|
if notif_type:
|
||||||
|
print(f" Type: {notif_type.name}")
|
||||||
|
print(f" Icon: {notif_type.icon}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def demo_compression():
|
||||||
|
"""Demonstrate notification compression."""
|
||||||
|
|
||||||
|
# GitLab pipeline email content (simplified)
|
||||||
|
gitlab_content = """
|
||||||
|
Pipeline #12345 failed by john.doe
|
||||||
|
|
||||||
|
The pipeline failed on stage: build
|
||||||
|
Commit: abc123def
|
||||||
|
|
||||||
|
View pipeline: https://gitlab.com/project/pipelines/12345
|
||||||
|
"""
|
||||||
|
|
||||||
|
# GitHub PR email content (simplified)
|
||||||
|
github_content = """
|
||||||
|
PR #42: Add new feature
|
||||||
|
|
||||||
|
@john.doe requested your review
|
||||||
|
|
||||||
|
View PR: https://github.com/repo/pull/42
|
||||||
|
"""
|
||||||
|
|
||||||
|
gitlab_envelope = {
|
||||||
|
"from": {"addr": "notifications@gitlab.com", "name": "GitLab"},
|
||||||
|
"subject": "Pipeline #12345 failed",
|
||||||
|
"date": "2025-12-28T15:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
github_envelope = {
|
||||||
|
"from": {"addr": "noreply@github.com", "name": "GitHub"},
|
||||||
|
"subject": "[GitHub] PR #42: Add new feature",
|
||||||
|
"date": "2025-12-28T15:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("NOTIFICATION COMPRESSION DEMO")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# GitLab compression - summary mode
|
||||||
|
print("1. GitLab Pipeline (Summary Mode)")
|
||||||
|
print("-" * 70)
|
||||||
|
compressor = NotificationCompressor(mode="summary")
|
||||||
|
compressed, notif_type = compressor.compress(gitlab_content, gitlab_envelope)
|
||||||
|
print(compressed)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# GitLab compression - detailed mode
|
||||||
|
print("2. GitLab Pipeline (Detailed Mode)")
|
||||||
|
print("-" * 70)
|
||||||
|
detailed_compressor = DetailedCompressor(mode="detailed")
|
||||||
|
compressed, notif_type = detailed_compressor.compress(
|
||||||
|
gitlab_content, gitlab_envelope
|
||||||
|
)
|
||||||
|
print(compressed)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# GitHub PR - summary mode
|
||||||
|
print("3. GitHub PR (Summary Mode)")
|
||||||
|
print("-" * 70)
|
||||||
|
compressor = NotificationCompressor(mode="summary")
|
||||||
|
compressed, notif_type = compressor.compress(github_content, github_envelope)
|
||||||
|
print(compressed)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def demo_summary_extraction():
|
||||||
|
"""Demonstrate structured summary extraction."""
|
||||||
|
|
||||||
|
test_content = """
|
||||||
|
ABC-123: Fix login bug
|
||||||
|
|
||||||
|
Status changed from In Progress to Done
|
||||||
|
|
||||||
|
View issue: https://jira.atlassian.net/browse/ABC-123
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("SUMMARY EXTRACTION DEMO")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
notif_type = NOTIFICATION_TYPES[2] # jira
|
||||||
|
summary = extract_notification_summary(test_content, notif_type)
|
||||||
|
|
||||||
|
print("Extracted Summary:")
|
||||||
|
print(f" Title: {summary.get('title')}")
|
||||||
|
print(f" Metadata: {summary.get('metadata')}")
|
||||||
|
print(f" Action Items: {summary.get('action_items')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run all demos."""
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("╔" + "═" * 68 + "╗")
|
||||||
|
print("║" + " " * 68 + "║")
|
||||||
|
print("║" + " LUK Notification Email Compression - Feature Demo".center(68) + "║")
|
||||||
|
print("║" + " " * 68 + "║")
|
||||||
|
print("╚" + "═" * 68 + "╝")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Run demos
|
||||||
|
demo_detection()
|
||||||
|
print()
|
||||||
|
demo_compression()
|
||||||
|
print()
|
||||||
|
demo_summary_extraction()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("DEMO COMPLETE")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
print("The notification compression feature is now integrated into the mail app.")
|
||||||
|
print("Configure it in ~/.config/luk/mail.toml:")
|
||||||
|
print()
|
||||||
|
print(" [content_display]")
|
||||||
|
print(" compress_notifications = true")
|
||||||
|
print(" notification_compression_mode = 'summary' # or 'detailed' or 'off'")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
82
mail.toml.example
Normal file
82
mail.toml.example
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# LUK Mail Configuration Example
|
||||||
|
# Copy this file to ~/.config/luk/mail.toml and customize
|
||||||
|
|
||||||
|
# [task]
|
||||||
|
# # Task management backend (taskwarrior or dstask)
|
||||||
|
# backend = "taskwarrior"
|
||||||
|
# taskwarrior_path = "task"
|
||||||
|
# dstask_path = "~/.local/bin/dstask"
|
||||||
|
|
||||||
|
[envelope_display]
|
||||||
|
# Sender name maximum length before truncation
|
||||||
|
max_sender_length = 25
|
||||||
|
|
||||||
|
# Date/time formatting
|
||||||
|
date_format = "%m/%d"
|
||||||
|
time_format = "%H:%M"
|
||||||
|
show_date = true
|
||||||
|
show_time = true
|
||||||
|
|
||||||
|
# Group envelopes by date
|
||||||
|
# "relative" = Today, Yesterday, This Week, etc.
|
||||||
|
# "absolute" = December 2025, November 2025, etc.
|
||||||
|
group_by = "relative"
|
||||||
|
|
||||||
|
# Layout: 2-line or 3-line (3-line shows preview)
|
||||||
|
lines = 2
|
||||||
|
show_checkbox = true
|
||||||
|
show_preview = false
|
||||||
|
|
||||||
|
# NerdFont icons
|
||||||
|
icon_unread = "\uf0e0" # nf-fa-envelope (filled)
|
||||||
|
icon_read = "\uf2b6" # nf-fa-envelope_open (open)
|
||||||
|
icon_flagged = "\uf024" # nf-fa-flag
|
||||||
|
icon_attachment = "\uf0c6" # nf-fa-paperclip
|
||||||
|
|
||||||
|
[content_display]
|
||||||
|
# Default view mode: "markdown" or "html"
|
||||||
|
default_view_mode = "markdown"
|
||||||
|
|
||||||
|
# URL compression settings
|
||||||
|
compress_urls = true
|
||||||
|
max_url_length = 50
|
||||||
|
|
||||||
|
# Notification email compression
|
||||||
|
# "summary" - Brief one-page summary
|
||||||
|
# "detailed" - More details in structured format
|
||||||
|
# "off" - Disable notification compression
|
||||||
|
compress_notifications = true
|
||||||
|
notification_compression_mode = "summary"
|
||||||
|
|
||||||
|
[link_panel]
|
||||||
|
# Close link panel after opening a link
|
||||||
|
close_on_open = false
|
||||||
|
|
||||||
|
[mail]
|
||||||
|
# Default folder to archive messages to
|
||||||
|
archive_folder = "Archive"
|
||||||
|
|
||||||
|
[keybindings]
|
||||||
|
# Custom keybindings (leave blank to use defaults)
|
||||||
|
# next_message = "j"
|
||||||
|
# prev_message = "k"
|
||||||
|
# delete = "#"
|
||||||
|
# archive = "e"
|
||||||
|
# open_by_id = "o"
|
||||||
|
# quit = "q"
|
||||||
|
# toggle_header = "h"
|
||||||
|
# create_task = "t"
|
||||||
|
# reload = "%"
|
||||||
|
# toggle_sort = "s"
|
||||||
|
# toggle_selection = "space"
|
||||||
|
# clear_selection = "escape"
|
||||||
|
# scroll_page_down = "pagedown"
|
||||||
|
# scroll_page_up = "b"
|
||||||
|
# toggle_main_content = "w"
|
||||||
|
# open_links = "l"
|
||||||
|
# toggle_view_mode = "m"
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
# Textual theme name
|
||||||
|
# Available themes: monokai, dracula, gruvbox, nord, etc.
|
||||||
|
theme_name = "monokai"
|
||||||
@@ -27,6 +27,7 @@ dependencies = [
|
|||||||
"certifi>=2025.4.26",
|
"certifi>=2025.4.26",
|
||||||
"click>=8.1.0",
|
"click>=8.1.0",
|
||||||
"html2text>=2025.4.15",
|
"html2text>=2025.4.15",
|
||||||
|
"icalendar>=6.0.0",
|
||||||
"mammoth>=1.9.0",
|
"mammoth>=1.9.0",
|
||||||
"markitdown[all]>=0.1.1",
|
"markitdown[all]>=0.1.1",
|
||||||
"msal>=1.32.3",
|
"msal>=1.32.3",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from textual.app import App, ComposeResult
|
|||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
from textual.logging import TextualHandler
|
from textual.logging import TextualHandler
|
||||||
from textual.widgets import Footer, Header, Static
|
from textual.widgets import Footer, Header, Static, Input
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
|
|
||||||
from src.calendar.backend import CalendarBackend, Event
|
from src.calendar.backend import CalendarBackend, Event
|
||||||
@@ -22,6 +22,7 @@ from src.calendar.widgets.MonthCalendar import MonthCalendar
|
|||||||
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
|
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
|
||||||
from src.calendar.widgets.AddEventForm import EventFormData
|
from src.calendar.widgets.AddEventForm import EventFormData
|
||||||
from src.utils.shared_config import get_theme_name
|
from src.utils.shared_config import get_theme_name
|
||||||
|
from src.utils.ipc import IPCListener, IPCMessage
|
||||||
|
|
||||||
# Add the parent directory to the system path to resolve relative imports
|
# Add the parent directory to the system path to resolve relative imports
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
@@ -115,6 +116,42 @@ class CalendarApp(App):
|
|||||||
#event-detail.hidden {
|
#event-detail.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#search-container {
|
||||||
|
dock: top;
|
||||||
|
height: 4;
|
||||||
|
width: 100%;
|
||||||
|
background: $surface;
|
||||||
|
border-bottom: solid $primary;
|
||||||
|
padding: 0 1;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-container.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-container .search-label {
|
||||||
|
width: auto;
|
||||||
|
padding: 0 1;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-results {
|
||||||
|
dock: bottom;
|
||||||
|
height: 40%;
|
||||||
|
border-top: solid $primary;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-results.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -132,6 +169,8 @@ class CalendarApp(App):
|
|||||||
Binding("r", "refresh", "Refresh", show=True),
|
Binding("r", "refresh", "Refresh", show=True),
|
||||||
Binding("enter", "view_event", "View", show=True),
|
Binding("enter", "view_event", "View", show=True),
|
||||||
Binding("a", "add_event", "Add", show=True),
|
Binding("a", "add_event", "Add", show=True),
|
||||||
|
Binding("slash", "search", "Search", show=True),
|
||||||
|
Binding("escape", "clear_search", "Clear Search", show=False),
|
||||||
Binding("?", "help", "Help", show=True),
|
Binding("?", "help", "Help", show=True),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -142,10 +181,12 @@ class CalendarApp(App):
|
|||||||
# Instance attributes
|
# Instance attributes
|
||||||
backend: Optional[CalendarBackend]
|
backend: Optional[CalendarBackend]
|
||||||
_invites: list[CalendarInvite]
|
_invites: list[CalendarInvite]
|
||||||
|
_search_results: list[Event]
|
||||||
|
|
||||||
def __init__(self, backend: Optional[CalendarBackend] = None):
|
def __init__(self, backend: Optional[CalendarBackend] = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._invites = []
|
self._invites = []
|
||||||
|
self._search_results = []
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
@@ -158,11 +199,18 @@ class CalendarApp(App):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the app layout."""
|
"""Create the app layout."""
|
||||||
yield Header()
|
yield Header()
|
||||||
|
yield Horizontal(
|
||||||
|
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
|
||||||
|
Input(placeholder="Search events...", id="search-input", disabled=True),
|
||||||
|
id="search-container",
|
||||||
|
classes="hidden",
|
||||||
|
)
|
||||||
with Horizontal(id="main-content"):
|
with Horizontal(id="main-content"):
|
||||||
with Vertical(id="sidebar"):
|
with Vertical(id="sidebar"):
|
||||||
yield MonthCalendar(id="sidebar-calendar")
|
yield MonthCalendar(id="sidebar-calendar")
|
||||||
yield InvitesPanel(id="sidebar-invites")
|
yield InvitesPanel(id="sidebar-invites")
|
||||||
yield WeekGrid(id="week-grid")
|
yield WeekGrid(id="week-grid")
|
||||||
|
yield Static(id="search-results", classes="hidden")
|
||||||
yield Static(id="event-detail", classes="hidden")
|
yield Static(id="event-detail", classes="hidden")
|
||||||
yield CalendarStatusBar(id="status-bar")
|
yield CalendarStatusBar(id="status-bar")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
@@ -171,6 +219,10 @@ class CalendarApp(App):
|
|||||||
"""Initialize the app on mount."""
|
"""Initialize the app on mount."""
|
||||||
self.theme = get_theme_name()
|
self.theme = get_theme_name()
|
||||||
|
|
||||||
|
# Start IPC listener for refresh notifications from sync daemon
|
||||||
|
self._ipc_listener = IPCListener("calendar", self._on_ipc_message)
|
||||||
|
self._ipc_listener.start()
|
||||||
|
|
||||||
# Load events for current week
|
# Load events for current week
|
||||||
self.load_events()
|
self.load_events()
|
||||||
|
|
||||||
@@ -184,6 +236,15 @@ class CalendarApp(App):
|
|||||||
self._update_status()
|
self._update_status()
|
||||||
self._update_title()
|
self._update_title()
|
||||||
|
|
||||||
|
# Focus the week grid (not the hidden search input)
|
||||||
|
self.query_one("#week-grid", WeekGrid).focus()
|
||||||
|
|
||||||
|
def _on_ipc_message(self, message: IPCMessage) -> None:
|
||||||
|
"""Handle IPC messages from sync daemon."""
|
||||||
|
if message.event == "refresh":
|
||||||
|
# Schedule a reload on the main thread
|
||||||
|
self.call_from_thread(self.load_events)
|
||||||
|
|
||||||
async def _load_invites_async(self) -> None:
|
async def _load_invites_async(self) -> None:
|
||||||
"""Load pending calendar invites from Microsoft Graph."""
|
"""Load pending calendar invites from Microsoft Graph."""
|
||||||
try:
|
try:
|
||||||
@@ -243,6 +304,8 @@ class CalendarApp(App):
|
|||||||
try:
|
try:
|
||||||
panel = self.query_one("#sidebar-invites", InvitesPanel)
|
panel = self.query_one("#sidebar-invites", InvitesPanel)
|
||||||
panel.set_invites(self._invites)
|
panel.set_invites(self._invites)
|
||||||
|
if self._invites:
|
||||||
|
self.notify(f"Loaded {len(self._invites)} pending invite(s)")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -521,6 +584,8 @@ Keybindings:
|
|||||||
w - Toggle weekends (5/7 days)
|
w - Toggle weekends (5/7 days)
|
||||||
s - Toggle sidebar
|
s - Toggle sidebar
|
||||||
i - Focus invites panel
|
i - Focus invites panel
|
||||||
|
/ - Search events
|
||||||
|
Esc - Clear search
|
||||||
Enter - View event details
|
Enter - View event details
|
||||||
a - Add new event
|
a - Add new event
|
||||||
r - Refresh
|
r - Refresh
|
||||||
@@ -528,6 +593,90 @@ Keybindings:
|
|||||||
"""
|
"""
|
||||||
self.notify(help_text.strip(), timeout=10)
|
self.notify(help_text.strip(), timeout=10)
|
||||||
|
|
||||||
|
# Search actions
|
||||||
|
def action_search(self) -> None:
|
||||||
|
"""Show search input and focus it."""
|
||||||
|
search_container = self.query_one("#search-container")
|
||||||
|
search_container.remove_class("hidden")
|
||||||
|
search_input = self.query_one("#search-input", Input)
|
||||||
|
search_input.disabled = False
|
||||||
|
search_input.focus()
|
||||||
|
|
||||||
|
def action_clear_search(self) -> None:
|
||||||
|
"""Clear search and hide search UI."""
|
||||||
|
search_container = self.query_one("#search-container")
|
||||||
|
search_results = self.query_one("#search-results", Static)
|
||||||
|
search_input = self.query_one("#search-input", Input)
|
||||||
|
|
||||||
|
# Only act if search is visible
|
||||||
|
if not search_container.has_class("hidden") or not search_results.has_class(
|
||||||
|
"hidden"
|
||||||
|
):
|
||||||
|
search_input.value = ""
|
||||||
|
search_input.disabled = True
|
||||||
|
search_container.add_class("hidden")
|
||||||
|
search_results.add_class("hidden")
|
||||||
|
self._search_results = []
|
||||||
|
# Focus back to grid
|
||||||
|
grid = self.query_one("#week-grid", WeekGrid)
|
||||||
|
grid.focus()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
"""Handle Enter in search input - perform search."""
|
||||||
|
if event.input.id != "search-input":
|
||||||
|
return
|
||||||
|
|
||||||
|
query = event.value.strip()
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._perform_search(query)
|
||||||
|
|
||||||
|
def _perform_search(self, query: str) -> None:
|
||||||
|
"""Perform event search and display results."""
|
||||||
|
if not self.backend:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if backend has search_events method
|
||||||
|
if not hasattr(self.backend, "search_events"):
|
||||||
|
self.notify(
|
||||||
|
"Search not supported by this calendar backend", severity="warning"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
results = self.backend.search_events(query)
|
||||||
|
self._search_results = results
|
||||||
|
|
||||||
|
# Update results display
|
||||||
|
search_results = self.query_one("#search-results", Static)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
lines = [f"[b]Search results for '{query}': {len(results)} found[/b]", ""]
|
||||||
|
for event in results[:20]: # Limit display to 20 results
|
||||||
|
date_str = event.start.strftime("%Y-%m-%d %H:%M")
|
||||||
|
lines.append(f" {date_str} [b]{event.title}[/b]")
|
||||||
|
if event.location:
|
||||||
|
lines.append(f" [dim]{event.location}[/dim]")
|
||||||
|
if len(results) > 20:
|
||||||
|
lines.append(f" ... and {len(results) - 20} more")
|
||||||
|
search_results.update("\n".join(lines))
|
||||||
|
search_results.remove_class("hidden")
|
||||||
|
self.notify(f"Found {len(results)} event(s)")
|
||||||
|
else:
|
||||||
|
search_results.update(f"[b]No events found matching '{query}'[/b]")
|
||||||
|
search_results.remove_class("hidden")
|
||||||
|
self.notify("No events found")
|
||||||
|
|
||||||
|
# Focus back to grid
|
||||||
|
grid = self.query_one("#week-grid", WeekGrid)
|
||||||
|
grid.focus()
|
||||||
|
|
||||||
|
async def action_quit(self) -> None:
|
||||||
|
"""Quit the app and clean up IPC listener."""
|
||||||
|
if hasattr(self, "_ipc_listener"):
|
||||||
|
self._ipc_listener.stop()
|
||||||
|
self.exit()
|
||||||
|
|
||||||
def action_focus_invites(self) -> None:
|
def action_focus_invites(self) -> None:
|
||||||
"""Focus on the invites panel and show invite count."""
|
"""Focus on the invites panel and show invite count."""
|
||||||
if not self.show_sidebar:
|
if not self.show_sidebar:
|
||||||
|
|||||||
@@ -216,3 +216,17 @@ class CalendarBackend(ABC):
|
|||||||
by_date[d].sort(key=lambda e: e.start)
|
by_date[d].sort(key=lambda e: e.start)
|
||||||
|
|
||||||
return by_date
|
return by_date
|
||||||
|
|
||||||
|
def search_events(self, query: str) -> List[Event]:
|
||||||
|
"""Search for events matching a query string.
|
||||||
|
|
||||||
|
Default implementation returns empty list. Override in subclasses
|
||||||
|
that support search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search string to match against event titles and descriptions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching events
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ class AddEventForm(Widget):
|
|||||||
|
|
||||||
AddEventForm .form-label {
|
AddEventForm .form-label {
|
||||||
width: 12;
|
width: 12;
|
||||||
height: 1;
|
height: 2;
|
||||||
|
padding-top: 1;
|
||||||
padding-right: 1;
|
padding-right: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ class AddEventForm(Widget):
|
|||||||
}
|
}
|
||||||
|
|
||||||
AddEventForm .date-input {
|
AddEventForm .date-input {
|
||||||
width: 14;
|
width: 18;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddEventForm .time-input {
|
AddEventForm .time-input {
|
||||||
@@ -117,6 +118,8 @@ class AddEventForm(Widget):
|
|||||||
AddEventForm .datetime-label {
|
AddEventForm .datetime-label {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding-right: 1;
|
padding-right: 1;
|
||||||
|
height: 2;
|
||||||
|
padding-top: 1;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class WeekGridHeader(Widget):
|
|||||||
style = Style(bold=True, reverse=True)
|
style = Style(bold=True, reverse=True)
|
||||||
elif day == today:
|
elif day == today:
|
||||||
# Highlight today with theme secondary color
|
# 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
|
elif day.weekday() >= 5: # Weekend
|
||||||
style = Style(color="bright_black")
|
style = Style(color="bright_black")
|
||||||
else:
|
else:
|
||||||
@@ -364,6 +364,9 @@ class WeekGridBody(ScrollView):
|
|||||||
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
||||||
is_current_time_row = row_index == current_row
|
is_current_time_row = row_index == current_row
|
||||||
|
|
||||||
|
# Check if cursor is on this row
|
||||||
|
is_cursor_row = row_index == self.cursor_row
|
||||||
|
|
||||||
# Time label (only show on the hour)
|
# Time label (only show on the hour)
|
||||||
if row_index % rows_per_hour == 0:
|
if row_index % rows_per_hour == 0:
|
||||||
hour = row_index // rows_per_hour
|
hour = row_index // rows_per_hour
|
||||||
@@ -371,10 +374,16 @@ class WeekGridBody(ScrollView):
|
|||||||
else:
|
else:
|
||||||
time_str = " " # Blank for half-hour
|
time_str = " " # Blank for half-hour
|
||||||
|
|
||||||
# Style time label - highlight current time, dim outside work hours
|
# Style time label - highlight current time or cursor, dim outside work hours
|
||||||
if is_current_time_row:
|
if is_cursor_row:
|
||||||
|
# Highlight the hour label when cursor is on this row
|
||||||
|
primary_color = self._get_theme_color("primary")
|
||||||
|
time_style = Style(color=primary_color, bold=True, reverse=True)
|
||||||
|
elif is_current_time_row:
|
||||||
error_color = self._get_theme_color("error")
|
error_color = self._get_theme_color("error")
|
||||||
time_style = Style(color=error_color, bold=True)
|
# Add subtle background to current time row for better visibility
|
||||||
|
surface_color = self._get_theme_color("surface")
|
||||||
|
time_style = Style(color=error_color, bold=True, bgcolor=surface_color)
|
||||||
elif (
|
elif (
|
||||||
row_index < self._work_day_start * rows_per_hour
|
row_index < self._work_day_start * rows_per_hour
|
||||||
or row_index >= self._work_day_end * rows_per_hour
|
or row_index >= self._work_day_end * rows_per_hour
|
||||||
|
|||||||
@@ -475,7 +475,13 @@ async def _sync_outlook_data(
|
|||||||
archive_mail_async(maildir_path, headers, progress, task_archive, dry_run),
|
archive_mail_async(maildir_path, headers, progress, task_archive, dry_run),
|
||||||
delete_mail_async(maildir_path, headers, progress, task_delete, dry_run),
|
delete_mail_async(maildir_path, headers, progress, task_delete, dry_run),
|
||||||
process_outbox_async(
|
process_outbox_async(
|
||||||
base_maildir_path, org, headers, progress, task_outbox, dry_run
|
base_maildir_path,
|
||||||
|
org,
|
||||||
|
headers,
|
||||||
|
progress,
|
||||||
|
task_outbox,
|
||||||
|
dry_run,
|
||||||
|
access_token=access_token,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]")
|
progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]")
|
||||||
@@ -715,6 +721,45 @@ def sync(
|
|||||||
else:
|
else:
|
||||||
# Default: Launch interactive TUI dashboard
|
# Default: Launch interactive TUI dashboard
|
||||||
from .sync_dashboard import run_dashboard_sync
|
from .sync_dashboard import run_dashboard_sync
|
||||||
|
from src.services.microsoft_graph.auth import has_valid_cached_token
|
||||||
|
|
||||||
|
# Check if we need to authenticate before starting the TUI
|
||||||
|
# This prevents the TUI from appearing to freeze during device flow auth
|
||||||
|
if not demo:
|
||||||
|
scopes = [
|
||||||
|
"https://graph.microsoft.com/Calendars.Read",
|
||||||
|
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||||
|
]
|
||||||
|
if not has_valid_cached_token(scopes):
|
||||||
|
click.echo("Authentication required. Please complete the login flow...")
|
||||||
|
try:
|
||||||
|
# This will trigger the device flow auth in the console
|
||||||
|
get_access_token(scopes)
|
||||||
|
click.echo("Authentication successful! Starting dashboard...")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Authentication failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pre-authenticate SMTP token only if SMTP sending is enabled in config
|
||||||
|
from src.mail.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
if config.mail.enable_smtp_send:
|
||||||
|
from src.services.microsoft_graph.auth import get_smtp_access_token
|
||||||
|
|
||||||
|
smtp_token = get_smtp_access_token(silent_only=True)
|
||||||
|
if not smtp_token:
|
||||||
|
click.echo(
|
||||||
|
"SMTP authentication required for sending calendar replies..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
smtp_token = get_smtp_access_token(silent_only=False)
|
||||||
|
if smtp_token:
|
||||||
|
click.echo("SMTP authentication successful!")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(
|
||||||
|
f"SMTP authentication failed (calendar replies may not work): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
sync_config = {
|
sync_config = {
|
||||||
"org": org,
|
"org": org,
|
||||||
@@ -936,6 +981,48 @@ def status():
|
|||||||
def interactive(org, vdir, notify, dry_run, demo):
|
def interactive(org, vdir, notify, dry_run, demo):
|
||||||
"""Launch interactive TUI dashboard for sync operations."""
|
"""Launch interactive TUI dashboard for sync operations."""
|
||||||
from .sync_dashboard import run_dashboard_sync
|
from .sync_dashboard import run_dashboard_sync
|
||||||
|
from src.services.microsoft_graph.auth import (
|
||||||
|
has_valid_cached_token,
|
||||||
|
get_access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we need to authenticate before starting the TUI
|
||||||
|
# This prevents the TUI from appearing to freeze during device flow auth
|
||||||
|
if not demo:
|
||||||
|
scopes = [
|
||||||
|
"https://graph.microsoft.com/Calendars.Read",
|
||||||
|
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||||
|
]
|
||||||
|
if not has_valid_cached_token(scopes):
|
||||||
|
click.echo("Authentication required. Please complete the login flow...")
|
||||||
|
try:
|
||||||
|
# This will trigger the device flow auth in the console
|
||||||
|
get_access_token(scopes)
|
||||||
|
click.echo("Authentication successful! Starting dashboard...")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Authentication failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pre-authenticate SMTP token only if SMTP sending is enabled in config
|
||||||
|
from src.mail.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
if config.mail.enable_smtp_send:
|
||||||
|
from src.services.microsoft_graph.auth import get_smtp_access_token
|
||||||
|
|
||||||
|
smtp_token = get_smtp_access_token(silent_only=True)
|
||||||
|
if not smtp_token:
|
||||||
|
click.echo(
|
||||||
|
"SMTP authentication required for sending calendar replies..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
smtp_token = get_smtp_access_token(silent_only=False)
|
||||||
|
if smtp_token:
|
||||||
|
click.echo("SMTP authentication successful!")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(
|
||||||
|
f"SMTP authentication failed (calendar replies may not work): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
sync_config = {
|
sync_config = {
|
||||||
"org": org,
|
"org": org,
|
||||||
|
|||||||
@@ -1038,6 +1038,11 @@ async def run_dashboard_sync(
|
|||||||
# Schedule next sync
|
# Schedule next sync
|
||||||
dashboard.schedule_next_sync()
|
dashboard.schedule_next_sync()
|
||||||
|
|
||||||
|
# Notify all running TUI apps to refresh their data
|
||||||
|
from src.utils.ipc import notify_all
|
||||||
|
|
||||||
|
await notify_all({"source": "sync_dashboard_demo"})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tracker.error_task("archive", str(e))
|
tracker.error_task("archive", str(e))
|
||||||
|
|
||||||
@@ -1070,6 +1075,7 @@ async def run_dashboard_sync(
|
|||||||
)
|
)
|
||||||
from src.utils.calendar_utils import save_events_to_vdir, save_events_to_file
|
from src.utils.calendar_utils import save_events_to_vdir, save_events_to_file
|
||||||
from src.utils.notifications import notify_new_emails
|
from src.utils.notifications import notify_new_emails
|
||||||
|
from src.utils.ipc import notify_all
|
||||||
|
|
||||||
config = dashboard._sync_config
|
config = dashboard._sync_config
|
||||||
|
|
||||||
@@ -1134,7 +1140,13 @@ async def run_dashboard_sync(
|
|||||||
try:
|
try:
|
||||||
outbox_progress = DashboardProgressAdapter(tracker, "outbox")
|
outbox_progress = DashboardProgressAdapter(tracker, "outbox")
|
||||||
result = await process_outbox_async(
|
result = await process_outbox_async(
|
||||||
base_maildir_path, org, headers, outbox_progress, None, dry_run
|
base_maildir_path,
|
||||||
|
org,
|
||||||
|
headers,
|
||||||
|
outbox_progress,
|
||||||
|
None,
|
||||||
|
dry_run,
|
||||||
|
access_token=access_token,
|
||||||
)
|
)
|
||||||
sent_count, failed_count = result if result else (0, 0)
|
sent_count, failed_count = result if result else (0, 0)
|
||||||
if sent_count > 0:
|
if sent_count > 0:
|
||||||
@@ -1372,6 +1384,9 @@ async def run_dashboard_sync(
|
|||||||
# Schedule next sync
|
# Schedule next sync
|
||||||
dashboard.schedule_next_sync()
|
dashboard.schedule_next_sync()
|
||||||
|
|
||||||
|
# Notify all running TUI apps to refresh their data
|
||||||
|
await notify_all({"source": "sync_dashboard"})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If we fail early (e.g., auth), log to the first pending task
|
# If we fail early (e.g., auth), log to the first pending task
|
||||||
for task_id in [
|
for task_id in [
|
||||||
|
|||||||
490
src/mail/actions/calendar_invite.py
Normal file
490
src/mail/actions/calendar_invite.py
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
"""Calendar invite actions for mail app.
|
||||||
|
|
||||||
|
Allows responding to calendar invites directly from email using ICS/SMTP.
|
||||||
|
|
||||||
|
Uses the iTIP (iCalendar Transport-Independent Interoperability Protocol)
|
||||||
|
standard to send REPLY messages via email instead of requiring Calendar.ReadWrite
|
||||||
|
API permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from src.mail.utils.calendar_parser import ParsedCalendarEvent
|
||||||
|
|
||||||
|
# Set up dedicated RSVP logger
|
||||||
|
rsvp_logger = logging.getLogger("calendar_rsvp")
|
||||||
|
rsvp_logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Create file handler if not already set up
|
||||||
|
if not rsvp_logger.handlers:
|
||||||
|
log_dir = os.path.expanduser("~/.local/share/luk")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
log_file = os.path.join(log_dir, "calendar_rsvp.log")
|
||||||
|
handler = logging.FileHandler(log_file)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
||||||
|
rsvp_logger.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_email() -> Optional[str]:
|
||||||
|
"""Get the current user's email address from MSAL cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User's email address if found, None otherwise.
|
||||||
|
"""
|
||||||
|
import msal
|
||||||
|
|
||||||
|
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||||
|
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||||
|
|
||||||
|
if not client_id or not tenant_id:
|
||||||
|
rsvp_logger.warning("Azure credentials not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin")
|
||||||
|
if not os.path.exists(cache_file):
|
||||||
|
rsvp_logger.warning("Token cache file not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache = msal.SerializableTokenCache()
|
||||||
|
cache.deserialize(open(cache_file, "r").read())
|
||||||
|
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||||
|
app = msal.PublicClientApplication(
|
||||||
|
client_id, authority=authority, token_cache=cache
|
||||||
|
)
|
||||||
|
accounts = app.get_accounts()
|
||||||
|
|
||||||
|
if accounts:
|
||||||
|
# The username field contains the user's email
|
||||||
|
return accounts[0].get("username")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
rsvp_logger.error(f"Failed to get user email from MSAL: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_display_name() -> Optional[str]:
|
||||||
|
"""Get the current user's display name from MSAL cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User's display name if found, None otherwise.
|
||||||
|
"""
|
||||||
|
import msal
|
||||||
|
|
||||||
|
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||||
|
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||||
|
|
||||||
|
if not client_id or not tenant_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin")
|
||||||
|
if not os.path.exists(cache_file):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache = msal.SerializableTokenCache()
|
||||||
|
cache.deserialize(open(cache_file, "r").read())
|
||||||
|
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||||
|
app = msal.PublicClientApplication(
|
||||||
|
client_id, authority=authority, token_cache=cache
|
||||||
|
)
|
||||||
|
accounts = app.get_accounts()
|
||||||
|
|
||||||
|
if accounts:
|
||||||
|
# Try to get name from account, fallback to username
|
||||||
|
name = accounts[0].get("name")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
# Fallback: construct name from email
|
||||||
|
username = accounts[0].get("username", "")
|
||||||
|
if "@" in username:
|
||||||
|
local_part = username.split("@")[0]
|
||||||
|
# Convert firstname.lastname to Firstname Lastname
|
||||||
|
parts = local_part.replace(".", " ").replace("_", " ").split()
|
||||||
|
return " ".join(p.capitalize() for p in parts)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
rsvp_logger.debug(f"Failed to get display name: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ics_reply(
|
||||||
|
event: ParsedCalendarEvent,
|
||||||
|
response: str,
|
||||||
|
attendee_email: str,
|
||||||
|
attendee_name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate an iCalendar REPLY for a calendar invite.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The parsed calendar event from the original invite
|
||||||
|
response: Response type - 'ACCEPTED', 'TENTATIVE', or 'DECLINED'
|
||||||
|
attendee_email: The attendee's email address
|
||||||
|
attendee_name: The attendee's display name (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ICS content string formatted as an iTIP REPLY
|
||||||
|
"""
|
||||||
|
# Map response to PARTSTAT value
|
||||||
|
partstat_map = {
|
||||||
|
"accept": "ACCEPTED",
|
||||||
|
"tentativelyAccept": "TENTATIVE",
|
||||||
|
"decline": "DECLINED",
|
||||||
|
}
|
||||||
|
partstat = partstat_map.get(response, "ACCEPTED")
|
||||||
|
|
||||||
|
# Generate DTSTAMP in UTC format
|
||||||
|
dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
# Build attendee line with proper formatting
|
||||||
|
if attendee_name:
|
||||||
|
attendee_line = (
|
||||||
|
f'ATTENDEE;PARTSTAT={partstat};CN="{attendee_name}":MAILTO:{attendee_email}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
attendee_line = f"ATTENDEE;PARTSTAT={partstat}:MAILTO:{attendee_email}"
|
||||||
|
|
||||||
|
# Build organizer line
|
||||||
|
if event.organizer_name:
|
||||||
|
organizer_line = (
|
||||||
|
f'ORGANIZER;CN="{event.organizer_name}":MAILTO:{event.organizer_email}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
organizer_line = f"ORGANIZER:MAILTO:{event.organizer_email}"
|
||||||
|
|
||||||
|
# Build the response subject prefix
|
||||||
|
response_prefix = {
|
||||||
|
"accept": "Accepted",
|
||||||
|
"tentativelyAccept": "Tentative",
|
||||||
|
"decline": "Declined",
|
||||||
|
}.get(response, "Accepted")
|
||||||
|
|
||||||
|
summary = f"{response_prefix}: {event.summary or '(no subject)'}"
|
||||||
|
|
||||||
|
# Build the ICS content following iTIP REPLY standard
|
||||||
|
ics_lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//LUK Mail//Calendar Reply//EN",
|
||||||
|
"METHOD:REPLY",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
f"UID:{event.uid}",
|
||||||
|
f"DTSTAMP:{dtstamp}",
|
||||||
|
organizer_line,
|
||||||
|
attendee_line,
|
||||||
|
f"SEQUENCE:{event.sequence}",
|
||||||
|
f"SUMMARY:{summary}",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR",
|
||||||
|
]
|
||||||
|
|
||||||
|
return "\r\n".join(ics_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_calendar_reply_email(
|
||||||
|
event: ParsedCalendarEvent,
|
||||||
|
response: str,
|
||||||
|
from_email: str,
|
||||||
|
to_email: str,
|
||||||
|
from_name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a MIME email with calendar REPLY attachment.
|
||||||
|
|
||||||
|
The email is formatted according to iTIP/iMIP standards so that
|
||||||
|
Exchange/Outlook will recognize it as a calendar action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The parsed calendar event from the original invite
|
||||||
|
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||||
|
from_email: Sender's email address
|
||||||
|
to_email: Recipient's email address (the organizer)
|
||||||
|
from_name: Sender's display name (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete RFC 5322 email as string
|
||||||
|
"""
|
||||||
|
# Generate the ICS reply content
|
||||||
|
ics_content = generate_ics_reply(event, response, from_email, from_name)
|
||||||
|
|
||||||
|
# Build response text for email body
|
||||||
|
response_text = {
|
||||||
|
"accept": "accepted",
|
||||||
|
"tentativelyAccept": "tentatively accepted",
|
||||||
|
"decline": "declined",
|
||||||
|
}.get(response, "accepted")
|
||||||
|
|
||||||
|
subject_prefix = {
|
||||||
|
"accept": "Accepted",
|
||||||
|
"tentativelyAccept": "Tentative",
|
||||||
|
"decline": "Declined",
|
||||||
|
}.get(response, "Accepted")
|
||||||
|
|
||||||
|
subject = f"{subject_prefix}: {event.summary or '(no subject)'}"
|
||||||
|
|
||||||
|
# Create the email message
|
||||||
|
msg = MIMEMultipart("mixed")
|
||||||
|
|
||||||
|
# Set headers
|
||||||
|
if from_name:
|
||||||
|
msg["From"] = f'"{from_name}" <{from_email}>'
|
||||||
|
else:
|
||||||
|
msg["From"] = from_email
|
||||||
|
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Subject"] = subject
|
||||||
|
|
||||||
|
# Add Content-Class header for Exchange compatibility
|
||||||
|
msg["Content-Class"] = "urn:content-classes:calendarmessage"
|
||||||
|
|
||||||
|
# Create text body
|
||||||
|
body_text = f"This meeting has been {response_text}."
|
||||||
|
text_part = MIMEText(body_text, "plain", "utf-8")
|
||||||
|
msg.attach(text_part)
|
||||||
|
|
||||||
|
# Create calendar part with proper iTIP headers
|
||||||
|
# The content-type must include method=REPLY for Exchange to recognize it
|
||||||
|
calendar_part = MIMEText(ics_content, "calendar", "utf-8")
|
||||||
|
calendar_part.set_param("method", "REPLY")
|
||||||
|
calendar_part.add_header("Content-Disposition", "attachment", filename="invite.ics")
|
||||||
|
msg.attach(calendar_part)
|
||||||
|
|
||||||
|
return msg.as_string()
|
||||||
|
|
||||||
|
|
||||||
|
def queue_calendar_reply(
|
||||||
|
event: ParsedCalendarEvent,
|
||||||
|
response: str,
|
||||||
|
from_email: str,
|
||||||
|
to_email: str,
|
||||||
|
from_name: Optional[str] = None,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Queue a calendar reply email for sending via the outbox.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The parsed calendar event from the original invite
|
||||||
|
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||||
|
from_email: Sender's email address
|
||||||
|
to_email: Recipient's email address (the organizer)
|
||||||
|
from_name: Sender's display name (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Build the email
|
||||||
|
email_content = build_calendar_reply_email(
|
||||||
|
event, response, from_email, to_email, from_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine organization from email domain
|
||||||
|
org = "default"
|
||||||
|
if "@" in from_email:
|
||||||
|
domain = from_email.split("@")[1].lower()
|
||||||
|
# Map known domains to org names (matching sendmail script logic)
|
||||||
|
domain_to_org = {
|
||||||
|
"corteva.com": "corteva",
|
||||||
|
}
|
||||||
|
org = domain_to_org.get(domain, domain.split(".")[0])
|
||||||
|
|
||||||
|
# Queue the email in the outbox
|
||||||
|
base_path = os.path.expanduser(os.getenv("MAILDIR_PATH", "~/Mail"))
|
||||||
|
outbox_path = os.path.join(base_path, org, "outbox")
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
for subdir in ["new", "cur", "tmp", "failed"]:
|
||||||
|
dir_path = os.path.join(outbox_path, subdir)
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
timestamp = str(int(time.time() * 1000000))
|
||||||
|
hostname = os.uname().nodename
|
||||||
|
filename = f"{timestamp}.{os.getpid()}.{hostname}"
|
||||||
|
|
||||||
|
# Write to tmp first, then move to new (atomic operation)
|
||||||
|
tmp_path = os.path.join(outbox_path, "tmp", filename)
|
||||||
|
new_path = os.path.join(outbox_path, "new", filename)
|
||||||
|
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(email_content)
|
||||||
|
|
||||||
|
os.rename(tmp_path, new_path)
|
||||||
|
|
||||||
|
response_text = {
|
||||||
|
"accept": "accepted",
|
||||||
|
"tentativelyAccept": "tentatively accepted",
|
||||||
|
"decline": "declined",
|
||||||
|
}.get(response, "accepted")
|
||||||
|
|
||||||
|
rsvp_logger.info(
|
||||||
|
f"Queued calendar reply: {response_text} for '{event.summary}' to {event.organizer_email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, f"Response queued - will be sent on next sync"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
rsvp_logger.error(f"Failed to queue calendar reply: {e}", exc_info=True)
|
||||||
|
return False, f"Failed to queue response: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_calendar_reply_via_apple_mail(
|
||||||
|
event: ParsedCalendarEvent,
|
||||||
|
response: str,
|
||||||
|
from_email: str,
|
||||||
|
to_email: str,
|
||||||
|
from_name: Optional[str] = None,
|
||||||
|
auto_send: bool = False,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Send a calendar reply immediately via Apple Mail.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The parsed calendar event from the original invite
|
||||||
|
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||||
|
from_email: Sender's email address
|
||||||
|
to_email: Recipient's email address (the organizer)
|
||||||
|
from_name: Sender's display name (optional)
|
||||||
|
auto_send: If True, automatically send via AppleScript
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
from src.mail.utils.apple_mail import open_eml_in_apple_mail
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build the email
|
||||||
|
email_content = build_calendar_reply_email(
|
||||||
|
event, response, from_email, to_email, from_name
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = {
|
||||||
|
"accept": "accepted",
|
||||||
|
"tentativelyAccept": "tentatively accepted",
|
||||||
|
"decline": "declined",
|
||||||
|
}.get(response, "accepted")
|
||||||
|
|
||||||
|
subject = f"{response_text.capitalize()}: {event.summary or '(no subject)'}"
|
||||||
|
|
||||||
|
# Open in Apple Mail (and optionally auto-send)
|
||||||
|
success, message = open_eml_in_apple_mail(
|
||||||
|
email_content, auto_send=auto_send, subject=subject
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
rsvp_logger.info(
|
||||||
|
f"Calendar reply via Apple Mail: {response_text} for '{event.summary}' to {to_email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return success, message
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
rsvp_logger.error(
|
||||||
|
f"Failed to send calendar reply via Apple Mail: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
return False, f"Failed to send response: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def action_accept_invite(app):
|
||||||
|
"""Accept the current calendar invite."""
|
||||||
|
_respond_to_current_invite(app, "accept")
|
||||||
|
|
||||||
|
|
||||||
|
def action_decline_invite(app):
|
||||||
|
"""Decline the current calendar invite."""
|
||||||
|
_respond_to_current_invite(app, "decline")
|
||||||
|
|
||||||
|
|
||||||
|
def action_tentative_invite(app):
|
||||||
|
"""Tentatively accept the current calendar invite."""
|
||||||
|
_respond_to_current_invite(app, "tentativelyAccept")
|
||||||
|
|
||||||
|
|
||||||
|
def _respond_to_current_invite(app, response: str):
|
||||||
|
"""Helper to respond to the current message's calendar invite.
|
||||||
|
|
||||||
|
Sends the response immediately via Apple Mail instead of queuing for sync.
|
||||||
|
"""
|
||||||
|
from src.mail.widgets.ContentContainer import ContentContainer
|
||||||
|
from src.mail.config import get_config
|
||||||
|
|
||||||
|
rsvp_logger.info(f"Starting invite response: {response}")
|
||||||
|
|
||||||
|
current_message_id = app.current_message_id
|
||||||
|
if not current_message_id:
|
||||||
|
rsvp_logger.warning("No message selected")
|
||||||
|
app.notify("No message selected", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user's email from MSAL cache
|
||||||
|
user_email = _get_user_email()
|
||||||
|
if not user_email:
|
||||||
|
rsvp_logger.error("Could not determine user email - run 'luk sync' first")
|
||||||
|
app.notify(
|
||||||
|
"Could not determine your email. Run 'luk sync' first.", severity="error"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_name = _get_user_display_name()
|
||||||
|
rsvp_logger.debug(f"User: {user_name} <{user_email}>")
|
||||||
|
|
||||||
|
# Get the parsed calendar event from ContentContainer
|
||||||
|
calendar_event = None
|
||||||
|
try:
|
||||||
|
content_container = app.query_one(ContentContainer)
|
||||||
|
calendar_event = content_container.current_calendar_event
|
||||||
|
except Exception as e:
|
||||||
|
rsvp_logger.error(f"Failed to get ContentContainer: {e}")
|
||||||
|
|
||||||
|
if not calendar_event:
|
||||||
|
rsvp_logger.warning("No calendar event data found in current message")
|
||||||
|
app.notify("No calendar invite found in this message", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
event_uid = calendar_event.uid
|
||||||
|
event_summary = calendar_event.summary or "(no subject)"
|
||||||
|
organizer_email = calendar_event.organizer_email
|
||||||
|
|
||||||
|
rsvp_logger.info(
|
||||||
|
f"Calendar event: {event_summary}, UID: {event_uid}, Organizer: {organizer_email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not event_uid:
|
||||||
|
rsvp_logger.warning("No UID found in calendar event")
|
||||||
|
app.notify("Calendar invite missing UID - cannot respond", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not organizer_email:
|
||||||
|
rsvp_logger.warning("No organizer email found in calendar event")
|
||||||
|
app.notify(
|
||||||
|
"Calendar invite missing organizer - cannot respond", severity="warning"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get config for auto-send preference
|
||||||
|
config = get_config()
|
||||||
|
auto_send = config.mail.auto_send_via_applescript
|
||||||
|
|
||||||
|
# Send immediately via Apple Mail
|
||||||
|
success, message = send_calendar_reply_via_apple_mail(
|
||||||
|
calendar_event,
|
||||||
|
response,
|
||||||
|
user_email,
|
||||||
|
organizer_email,
|
||||||
|
user_name,
|
||||||
|
auto_send=auto_send,
|
||||||
|
)
|
||||||
|
|
||||||
|
severity = "information" if success else "error"
|
||||||
|
app.notify(message, severity=severity)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
response_text = {
|
||||||
|
"accept": "Accepted",
|
||||||
|
"tentativelyAccept": "Tentatively accepted",
|
||||||
|
"decline": "Declined",
|
||||||
|
}.get(response, "Responded to")
|
||||||
|
rsvp_logger.info(f"{response_text} invite: {event_summary}")
|
||||||
170
src/mail/actions/compose.py
Normal file
170
src/mail/actions/compose.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Compose, reply, and forward email actions for mail app.
|
||||||
|
|
||||||
|
Uses Apple Mail for composing and sending emails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.mail.utils.apple_mail import (
|
||||||
|
compose_new_email,
|
||||||
|
reply_to_email,
|
||||||
|
forward_email,
|
||||||
|
)
|
||||||
|
from src.services.himalaya import client as himalaya_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Module-level temp directory for exported messages (persists across calls)
|
||||||
|
_temp_dir: Optional[tempfile.TemporaryDirectory] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_temp_dir() -> str:
|
||||||
|
"""Get or create a persistent temp directory for exported messages."""
|
||||||
|
global _temp_dir
|
||||||
|
if _temp_dir is None:
|
||||||
|
_temp_dir = tempfile.TemporaryDirectory(prefix="luk_mail_")
|
||||||
|
return _temp_dir.name
|
||||||
|
|
||||||
|
|
||||||
|
async def _export_current_message(app) -> Optional[str]:
|
||||||
|
"""Export the currently selected message to a temp .eml file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The mail app instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the exported .eml file, or None if export failed
|
||||||
|
"""
|
||||||
|
current_message_id = app.current_message_id
|
||||||
|
if not current_message_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use himalaya to export the raw message
|
||||||
|
raw_content, success = await himalaya_client.get_raw_message(current_message_id)
|
||||||
|
if not success or not raw_content:
|
||||||
|
logger.error(f"Failed to export message {current_message_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Save to a temp file
|
||||||
|
temp_dir = _get_temp_dir()
|
||||||
|
eml_path = os.path.join(temp_dir, f"message_{current_message_id}.eml")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(eml_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(raw_content)
|
||||||
|
return eml_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write temp .eml file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_email() -> Optional[str]:
|
||||||
|
"""Get the current user's email address from MSAL cache."""
|
||||||
|
import msal
|
||||||
|
|
||||||
|
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||||
|
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||||
|
|
||||||
|
if not client_id or not tenant_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin")
|
||||||
|
if not os.path.exists(cache_file):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache = msal.SerializableTokenCache()
|
||||||
|
cache.deserialize(open(cache_file, "r").read())
|
||||||
|
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||||
|
app = msal.PublicClientApplication(
|
||||||
|
client_id, authority=authority, token_cache=cache
|
||||||
|
)
|
||||||
|
accounts = app.get_accounts()
|
||||||
|
|
||||||
|
if accounts:
|
||||||
|
return accounts[0].get("username")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def action_compose(app):
|
||||||
|
"""Open a new compose window in Apple Mail."""
|
||||||
|
user_email = _get_user_email()
|
||||||
|
|
||||||
|
success, message = compose_new_email(
|
||||||
|
to="",
|
||||||
|
subject="",
|
||||||
|
body="",
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
app.notify("Compose window opened in Mail", severity="information")
|
||||||
|
else:
|
||||||
|
app.notify(f"Failed to open compose: {message}", severity="error")
|
||||||
|
|
||||||
|
|
||||||
|
async def action_reply(app):
|
||||||
|
"""Reply to the current message in Apple Mail."""
|
||||||
|
if not app.current_message_id:
|
||||||
|
app.notify("No message selected", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
app.notify("Exporting message...", severity="information")
|
||||||
|
message_path = await _export_current_message(app)
|
||||||
|
|
||||||
|
if not message_path:
|
||||||
|
app.notify("Failed to export message", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
success, message = reply_to_email(message_path, reply_all=False)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
app.notify("Reply window opened in Mail", severity="information")
|
||||||
|
else:
|
||||||
|
app.notify(f"Failed to open reply: {message}", severity="error")
|
||||||
|
|
||||||
|
|
||||||
|
async def action_reply_all(app):
|
||||||
|
"""Reply to all on the current message in Apple Mail."""
|
||||||
|
if not app.current_message_id:
|
||||||
|
app.notify("No message selected", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
app.notify("Exporting message...", severity="information")
|
||||||
|
message_path = await _export_current_message(app)
|
||||||
|
|
||||||
|
if not message_path:
|
||||||
|
app.notify("Failed to export message", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
success, message = reply_to_email(message_path, reply_all=True)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
app.notify("Reply-all window opened in Mail", severity="information")
|
||||||
|
else:
|
||||||
|
app.notify(f"Failed to open reply-all: {message}", severity="error")
|
||||||
|
|
||||||
|
|
||||||
|
async def action_forward(app):
|
||||||
|
"""Forward the current message in Apple Mail."""
|
||||||
|
if not app.current_message_id:
|
||||||
|
app.notify("No message selected", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
app.notify("Exporting message...", severity="information")
|
||||||
|
message_path = await _export_current_message(app)
|
||||||
|
|
||||||
|
if not message_path:
|
||||||
|
app.notify("Failed to export message", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
success, message = forward_email(message_path)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
app.notify("Forward window opened in Mail", severity="information")
|
||||||
|
else:
|
||||||
|
app.notify(f"Failed to open forward: {message}", severity="error")
|
||||||
338
src/mail/app.py
338
src/mail/app.py
@@ -5,9 +5,21 @@ from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader
|
|||||||
from .screens.LinkPanel import LinkPanel
|
from .screens.LinkPanel import LinkPanel
|
||||||
from .screens.ConfirmDialog import ConfirmDialog
|
from .screens.ConfirmDialog import ConfirmDialog
|
||||||
from .screens.SearchPanel import SearchPanel
|
from .screens.SearchPanel import SearchPanel
|
||||||
|
from src.mail.screens.HelpScreen import HelpScreen
|
||||||
from .actions.task import action_create_task
|
from .actions.task import action_create_task
|
||||||
from .actions.open import action_open
|
from .actions.open import action_open
|
||||||
from .actions.delete import delete_current
|
from .actions.delete import delete_current
|
||||||
|
from .actions.calendar_invite import (
|
||||||
|
action_accept_invite,
|
||||||
|
action_decline_invite,
|
||||||
|
action_tentative_invite,
|
||||||
|
)
|
||||||
|
from .actions.compose import (
|
||||||
|
action_compose,
|
||||||
|
action_reply,
|
||||||
|
action_reply_all,
|
||||||
|
action_forward,
|
||||||
|
)
|
||||||
from src.services.taskwarrior import client as taskwarrior_client
|
from src.services.taskwarrior import client as taskwarrior_client
|
||||||
from src.services.himalaya import client as himalaya_client
|
from src.services.himalaya import client as himalaya_client
|
||||||
from src.utils.shared_config import get_theme_name
|
from src.utils.shared_config import get_theme_name
|
||||||
@@ -77,6 +89,7 @@ class EmailViewerApp(App):
|
|||||||
search_query: Reactive[str] = reactive("") # Current search filter
|
search_query: Reactive[str] = reactive("") # Current search filter
|
||||||
search_mode: Reactive[bool] = reactive(False) # True when showing search results
|
search_mode: Reactive[bool] = reactive(False) # True when showing search results
|
||||||
_cached_envelopes: List[Dict[str, Any]] = [] # Cached envelopes before search
|
_cached_envelopes: List[Dict[str, Any]] = [] # Cached envelopes before search
|
||||||
|
_cached_metadata: Dict[int, Dict[str, Any]] = {} # Cached metadata before search
|
||||||
|
|
||||||
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
||||||
yield from super().get_system_commands(screen)
|
yield from super().get_system_commands(screen)
|
||||||
@@ -114,12 +127,14 @@ class EmailViewerApp(App):
|
|||||||
Binding("h", "toggle_header", "Toggle Envelope Header"),
|
Binding("h", "toggle_header", "Toggle Envelope Header"),
|
||||||
Binding("t", "create_task", "Create Task"),
|
Binding("t", "create_task", "Create Task"),
|
||||||
Binding("l", "open_links", "Show Links"),
|
Binding("l", "open_links", "Show Links"),
|
||||||
Binding("%", "reload", "Reload message list"),
|
Binding("r", "reload", "Reload message list"),
|
||||||
|
Binding("%", "reload", "Reload message list", show=False),
|
||||||
Binding("1", "focus_1", "Focus Accounts Panel"),
|
Binding("1", "focus_1", "Focus Accounts Panel"),
|
||||||
Binding("2", "focus_2", "Focus Folders Panel"),
|
Binding("2", "focus_2", "Focus Folders Panel"),
|
||||||
Binding("3", "focus_3", "Focus Envelopes Panel"),
|
Binding("3", "focus_3", "Focus Envelopes Panel"),
|
||||||
Binding("4", "focus_4", "Focus Main Content"),
|
Binding("4", "focus_4", "Focus Main Content"),
|
||||||
Binding("w", "toggle_main_content", "Toggle Message View Window"),
|
Binding("w", "toggle_main_content", "Toggle Message View Window"),
|
||||||
|
Binding("m", "toggle_mode", "Toggle Markdown/HTML"),
|
||||||
]
|
]
|
||||||
|
|
||||||
BINDINGS.extend(
|
BINDINGS.extend(
|
||||||
@@ -131,6 +146,14 @@ class EmailViewerApp(App):
|
|||||||
Binding("space", "toggle_selection", "Toggle selection"),
|
Binding("space", "toggle_selection", "Toggle selection"),
|
||||||
Binding("escape", "clear_selection", "Clear selection"),
|
Binding("escape", "clear_selection", "Clear selection"),
|
||||||
Binding("/", "search", "Search"),
|
Binding("/", "search", "Search"),
|
||||||
|
Binding("u", "toggle_read", "Toggle read/unread"),
|
||||||
|
Binding("A", "accept_invite", "Accept invite"),
|
||||||
|
Binding("D", "decline_invite", "Decline invite"),
|
||||||
|
Binding("T", "tentative_invite", "Tentative"),
|
||||||
|
Binding("c", "compose", "Compose new email"),
|
||||||
|
Binding("R", "reply", "Reply to email"),
|
||||||
|
Binding("F", "forward", "Forward email"),
|
||||||
|
Binding("?", "show_help", "Show Help"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -254,9 +277,20 @@ class EmailViewerApp(App):
|
|||||||
content_container = self.query_one(ContentContainer)
|
content_container = self.query_one(ContentContainer)
|
||||||
folder = self.folder if self.folder else None
|
folder = self.folder if self.folder else None
|
||||||
account = self.current_account if self.current_account else None
|
account = self.current_account if self.current_account else None
|
||||||
content_container.display_content(message_id, folder=folder, account=account)
|
|
||||||
|
|
||||||
|
# Get envelope data for notification compression
|
||||||
metadata = self.message_store.get_metadata(message_id)
|
metadata = self.message_store.get_metadata(message_id)
|
||||||
|
envelope = None
|
||||||
|
if metadata:
|
||||||
|
index = metadata.get("index", 0)
|
||||||
|
# Check bounds before accessing envelopes list
|
||||||
|
if 0 <= index < len(self.message_store.envelopes):
|
||||||
|
envelope = self.message_store.envelopes[index]
|
||||||
|
|
||||||
|
content_container.display_content(
|
||||||
|
message_id, folder=folder, account=account, envelope=envelope
|
||||||
|
)
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
message_date = metadata["date"]
|
message_date = metadata["date"]
|
||||||
if self.current_message_index != metadata["index"]:
|
if self.current_message_index != metadata["index"]:
|
||||||
@@ -273,6 +307,12 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
async def _mark_message_as_read(self, message_id: int, index: int) -> None:
|
async def _mark_message_as_read(self, message_id: int, index: int) -> None:
|
||||||
"""Mark a message as read and update the UI."""
|
"""Mark a message as read and update the UI."""
|
||||||
|
# Skip if message_id is invalid or index is out of bounds
|
||||||
|
if message_id <= 0:
|
||||||
|
return
|
||||||
|
if index < 0 or index >= len(self.message_store.envelopes):
|
||||||
|
return
|
||||||
|
|
||||||
# Check if already read
|
# Check if already read
|
||||||
envelope_data = self.message_store.envelopes[index]
|
envelope_data = self.message_store.envelopes[index]
|
||||||
if envelope_data and envelope_data.get("type") != "header":
|
if envelope_data and envelope_data.get("type") != "header":
|
||||||
@@ -347,7 +387,13 @@ class EmailViewerApp(App):
|
|||||||
try:
|
try:
|
||||||
list_item = event.item
|
list_item = event.item
|
||||||
label = list_item.query_one(Label)
|
label = list_item.query_one(Label)
|
||||||
folder_name = str(label.renderable).strip()
|
folder_text = str(label.renderable).strip()
|
||||||
|
|
||||||
|
# Extract folder name (remove count suffix like " [dim](10)[/dim]")
|
||||||
|
# The format is "FolderName [dim](count)[/dim]" or just "FolderName"
|
||||||
|
import re
|
||||||
|
|
||||||
|
folder_name = re.sub(r"\s*\[dim\]\(\d+\)\[/dim\]$", "", folder_text)
|
||||||
|
|
||||||
if folder_name and folder_name != self.folder:
|
if folder_name and folder_name != self.folder:
|
||||||
self.folder = folder_name
|
self.folder = folder_name
|
||||||
@@ -483,14 +529,19 @@ class EmailViewerApp(App):
|
|||||||
async def fetch_folders(self) -> None:
|
async def fetch_folders(self) -> None:
|
||||||
folders_list = self.query_one("#folders_list", ListView)
|
folders_list = self.query_one("#folders_list", ListView)
|
||||||
folders_list.clear()
|
folders_list.clear()
|
||||||
|
|
||||||
|
# Store folder names for count updates
|
||||||
|
folder_names = ["INBOX"]
|
||||||
|
|
||||||
|
# Use the Himalaya client to fetch folders for current account
|
||||||
|
account = self.current_account if self.current_account else None
|
||||||
|
|
||||||
folders_list.append(
|
folders_list.append(
|
||||||
ListItem(Label("INBOX", classes="folder_name", markup=False))
|
ListItem(Label("INBOX", classes="folder_name", markup=True))
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
folders_list.loading = True
|
folders_list.loading = True
|
||||||
|
|
||||||
# Use the Himalaya client to fetch folders for current account
|
|
||||||
account = self.current_account if self.current_account else None
|
|
||||||
folders, success = await himalaya_client.list_folders(account=account)
|
folders, success = await himalaya_client.list_folders(account=account)
|
||||||
|
|
||||||
if success and folders:
|
if success and folders:
|
||||||
@@ -499,11 +550,12 @@ class EmailViewerApp(App):
|
|||||||
# Skip INBOX since we already added it
|
# Skip INBOX since we already added it
|
||||||
if folder_name.upper() == "INBOX":
|
if folder_name.upper() == "INBOX":
|
||||||
continue
|
continue
|
||||||
|
folder_names.append(folder_name)
|
||||||
item = ListItem(
|
item = ListItem(
|
||||||
Label(
|
Label(
|
||||||
folder_name,
|
folder_name,
|
||||||
classes="folder_name",
|
classes="folder_name",
|
||||||
markup=False,
|
markup=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
folders_list.append(item)
|
folders_list.append(item)
|
||||||
@@ -514,6 +566,34 @@ class EmailViewerApp(App):
|
|||||||
finally:
|
finally:
|
||||||
folders_list.loading = False
|
folders_list.loading = False
|
||||||
|
|
||||||
|
# Fetch counts in background and update labels
|
||||||
|
self._update_folder_counts(folder_names, account)
|
||||||
|
|
||||||
|
@work(exclusive=False)
|
||||||
|
async def _update_folder_counts(
|
||||||
|
self, folder_names: List[str], account: str | None
|
||||||
|
) -> None:
|
||||||
|
"""Fetch and display message counts for folders."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
folders_list = self.query_one("#folders_list", ListView)
|
||||||
|
|
||||||
|
async def get_count_for_folder(folder_name: str, index: int):
|
||||||
|
count, success = await himalaya_client.get_folder_count(
|
||||||
|
folder_name, account
|
||||||
|
)
|
||||||
|
if success and index < len(folders_list.children):
|
||||||
|
try:
|
||||||
|
list_item = folders_list.children[index]
|
||||||
|
label = list_item.query_one(Label)
|
||||||
|
label.update(f"{folder_name} [dim]({count})[/dim]")
|
||||||
|
except Exception:
|
||||||
|
pass # Widget may have been removed
|
||||||
|
|
||||||
|
# Fetch counts in parallel
|
||||||
|
tasks = [get_count_for_folder(name, i) for i, name in enumerate(folder_names)]
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
def _populate_list_view(self) -> None:
|
def _populate_list_view(self) -> None:
|
||||||
"""Populate the ListView with new items using the new EnvelopeListItem widget."""
|
"""Populate the ListView with new items using the new EnvelopeListItem widget."""
|
||||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||||
@@ -543,6 +623,9 @@ class EmailViewerApp(App):
|
|||||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||||
for i, list_item in enumerate(envelopes_list.children):
|
for i, list_item in enumerate(envelopes_list.children):
|
||||||
if isinstance(list_item, ListItem):
|
if isinstance(list_item, ListItem):
|
||||||
|
# Bounds check - ListView and message_store may be out of sync during transitions
|
||||||
|
if i >= len(self.message_store.envelopes):
|
||||||
|
break
|
||||||
item_data = self.message_store.envelopes[i]
|
item_data = self.message_store.envelopes[i]
|
||||||
|
|
||||||
if item_data and item_data.get("type") != "header":
|
if item_data and item_data.get("type") != "header":
|
||||||
@@ -581,10 +664,15 @@ class EmailViewerApp(App):
|
|||||||
self.action_newest()
|
self.action_newest()
|
||||||
|
|
||||||
async def action_toggle_mode(self) -> None:
|
async def action_toggle_mode(self) -> None:
|
||||||
"""Toggle the content mode between plaintext and markdown."""
|
"""Toggle of content mode between plaintext and markdown."""
|
||||||
content_container = self.query_one(ContentContainer)
|
content_container = self.query_one(ContentContainer)
|
||||||
await content_container.action_toggle_mode()
|
await content_container.action_toggle_mode()
|
||||||
|
|
||||||
|
async def action_show_help(self) -> None:
|
||||||
|
"""Show help screen with keyboard shortcuts."""
|
||||||
|
help_screen = HelpScreen(list(self.BINDINGS))
|
||||||
|
self.push_screen(help_screen)
|
||||||
|
|
||||||
def action_next(self) -> None:
|
def action_next(self) -> None:
|
||||||
if not self.current_message_index >= 0:
|
if not self.current_message_index >= 0:
|
||||||
return
|
return
|
||||||
@@ -802,6 +890,30 @@ class EmailViewerApp(App):
|
|||||||
def action_create_task(self) -> None:
|
def action_create_task(self) -> None:
|
||||||
action_create_task(self)
|
action_create_task(self)
|
||||||
|
|
||||||
|
def action_accept_invite(self) -> None:
|
||||||
|
"""Accept the calendar invite from the current email."""
|
||||||
|
action_accept_invite(self)
|
||||||
|
|
||||||
|
def action_decline_invite(self) -> None:
|
||||||
|
"""Decline the calendar invite from the current email."""
|
||||||
|
action_decline_invite(self)
|
||||||
|
|
||||||
|
def action_tentative_invite(self) -> None:
|
||||||
|
"""Tentatively accept the calendar invite from the current email."""
|
||||||
|
action_tentative_invite(self)
|
||||||
|
|
||||||
|
def action_compose(self) -> None:
|
||||||
|
"""Open a new compose window in Apple Mail."""
|
||||||
|
action_compose(self)
|
||||||
|
|
||||||
|
async def action_reply(self) -> None:
|
||||||
|
"""Reply to the current email in Apple Mail."""
|
||||||
|
await action_reply(self)
|
||||||
|
|
||||||
|
async def action_forward(self) -> None:
|
||||||
|
"""Forward the current email in Apple Mail."""
|
||||||
|
await action_forward(self)
|
||||||
|
|
||||||
def action_open_links(self) -> None:
|
def action_open_links(self) -> None:
|
||||||
"""Open the link panel showing links from the current message."""
|
"""Open the link panel showing links from the current message."""
|
||||||
content_container = self.query_one(ContentContainer)
|
content_container = self.query_one(ContentContainer)
|
||||||
@@ -810,19 +922,30 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
def action_scroll_down(self) -> None:
|
def action_scroll_down(self) -> None:
|
||||||
"""Scroll the main content down."""
|
"""Scroll the main content down."""
|
||||||
self.query_one("#main_content").scroll_down()
|
self.query_one("#content_scroll").scroll_down()
|
||||||
|
|
||||||
def action_scroll_up(self) -> None:
|
def action_scroll_up(self) -> None:
|
||||||
"""Scroll the main content up."""
|
"""Scroll the main content up."""
|
||||||
self.query_one("#main_content").scroll_up()
|
self.query_one("#content_scroll").scroll_up()
|
||||||
|
|
||||||
def action_scroll_page_down(self) -> None:
|
def action_scroll_page_down(self) -> None:
|
||||||
"""Scroll the main content down by a page."""
|
"""Scroll the main content down by a page."""
|
||||||
self.query_one("#main_content").scroll_page_down()
|
self.query_one("#content_scroll").scroll_page_down()
|
||||||
|
|
||||||
def action_scroll_page_up(self) -> None:
|
def action_scroll_page_up(self) -> None:
|
||||||
"""Scroll the main content up by a page."""
|
"""Scroll the main content up by a page."""
|
||||||
self.query_one("#main_content").scroll_page_up()
|
self.query_one("#content_scroll").scroll_page_up()
|
||||||
|
|
||||||
|
def action_toggle_header(self) -> None:
|
||||||
|
"""Toggle between compressed and full envelope headers."""
|
||||||
|
content_container = self.query_one("#main_content", ContentContainer)
|
||||||
|
if hasattr(content_container, "header") and content_container.header:
|
||||||
|
content_container.header.toggle_full_headers()
|
||||||
|
# Provide visual feedback
|
||||||
|
if content_container.header.show_full_headers:
|
||||||
|
self.notify("Showing full headers", timeout=1)
|
||||||
|
else:
|
||||||
|
self.notify("Showing compressed headers", timeout=1)
|
||||||
|
|
||||||
def action_toggle_main_content(self) -> None:
|
def action_toggle_main_content(self) -> None:
|
||||||
"""Toggle the visibility of the main content pane."""
|
"""Toggle the visibility of the main content pane."""
|
||||||
@@ -882,31 +1005,97 @@ class EmailViewerApp(App):
|
|||||||
self._update_list_view_subtitle()
|
self._update_list_view_subtitle()
|
||||||
|
|
||||||
def action_clear_selection(self) -> None:
|
def action_clear_selection(self) -> None:
|
||||||
"""Clear all selected messages and exit search mode."""
|
"""Clear all selected messages or focus search input if in search mode."""
|
||||||
|
# If in search mode, focus the search input instead of exiting
|
||||||
|
if self.search_mode:
|
||||||
|
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||||
|
search_panel.focus_input()
|
||||||
|
return
|
||||||
|
|
||||||
if self.selected_messages:
|
if self.selected_messages:
|
||||||
self.selected_messages.clear()
|
self.selected_messages.clear()
|
||||||
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
||||||
self._update_list_view_subtitle()
|
self._update_list_view_subtitle()
|
||||||
|
|
||||||
# Exit search mode if active
|
async def action_toggle_read(self) -> None:
|
||||||
if self.search_mode:
|
"""Toggle read/unread status for the current or selected messages."""
|
||||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
folder = self.folder if self.folder else None
|
||||||
search_panel.hide()
|
account = self.current_account if self.current_account else None
|
||||||
self.search_mode = False
|
|
||||||
self.search_query = ""
|
|
||||||
|
|
||||||
# Restore cached envelopes
|
if self.selected_messages:
|
||||||
if self._cached_envelopes:
|
# Toggle multiple selected messages
|
||||||
self.message_store.envelopes = self._cached_envelopes
|
for message_id in self.selected_messages:
|
||||||
self._cached_envelopes = []
|
await self._toggle_message_read_status(message_id, folder, account)
|
||||||
self._populate_list_view()
|
self.show_status(
|
||||||
|
f"Toggled read status for {len(self.selected_messages)} messages"
|
||||||
|
)
|
||||||
|
self.selected_messages.clear()
|
||||||
|
else:
|
||||||
|
# Toggle current message
|
||||||
|
if self.current_message_id:
|
||||||
|
await self._toggle_message_read_status(
|
||||||
|
self.current_message_id, folder, account
|
||||||
|
)
|
||||||
|
|
||||||
# Restore envelope list title
|
# Refresh the list to show updated read status
|
||||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
await self.fetch_envelopes().wait()
|
||||||
self.query_one(
|
|
||||||
"#envelopes_list"
|
async def _toggle_message_read_status(
|
||||||
).border_title = f"1️⃣ Emails {sort_indicator}"
|
self, message_id: int, folder: str | None, account: str | None
|
||||||
self._update_list_view_subtitle()
|
) -> None:
|
||||||
|
"""Toggle read status for a single message."""
|
||||||
|
# Find the message in the store to check current status
|
||||||
|
metadata = self.message_store.get_metadata(message_id)
|
||||||
|
if not metadata:
|
||||||
|
return
|
||||||
|
|
||||||
|
index = metadata.get("index", -1)
|
||||||
|
if index < 0 or index >= len(self.message_store.envelopes):
|
||||||
|
return
|
||||||
|
|
||||||
|
envelope_data = self.message_store.envelopes[index]
|
||||||
|
if not envelope_data or envelope_data.get("type") == "header":
|
||||||
|
return
|
||||||
|
|
||||||
|
flags = envelope_data.get("flags", [])
|
||||||
|
is_read = "Seen" in flags
|
||||||
|
|
||||||
|
if is_read:
|
||||||
|
# Mark as unread
|
||||||
|
result, success = await himalaya_client.mark_as_unread(
|
||||||
|
message_id, folder=folder, account=account
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
if "Seen" in envelope_data.get("flags", []):
|
||||||
|
envelope_data["flags"].remove("Seen")
|
||||||
|
self.show_status(f"Marked message {message_id} as unread")
|
||||||
|
self._update_envelope_read_state(index, is_read=False)
|
||||||
|
else:
|
||||||
|
# Mark as read
|
||||||
|
result, success = await himalaya_client.mark_as_read(
|
||||||
|
message_id, folder=folder, account=account
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
if "flags" not in envelope_data:
|
||||||
|
envelope_data["flags"] = []
|
||||||
|
if "Seen" not in envelope_data["flags"]:
|
||||||
|
envelope_data["flags"].append("Seen")
|
||||||
|
self.show_status(f"Marked message {message_id} as read")
|
||||||
|
self._update_envelope_read_state(index, is_read=True)
|
||||||
|
|
||||||
|
def _update_envelope_read_state(self, index: int, is_read: bool) -> None:
|
||||||
|
"""Update the visual state of an envelope in the list."""
|
||||||
|
try:
|
||||||
|
list_view = self.query_one("#envelopes_list", ListView)
|
||||||
|
list_item = list_view.children[index]
|
||||||
|
envelope_widget = list_item.query_one(EnvelopeListItem)
|
||||||
|
envelope_widget.is_read = is_read
|
||||||
|
if is_read:
|
||||||
|
envelope_widget.remove_class("unread")
|
||||||
|
else:
|
||||||
|
envelope_widget.add_class("unread")
|
||||||
|
except Exception:
|
||||||
|
pass # Widget may not exist
|
||||||
|
|
||||||
def action_oldest(self) -> None:
|
def action_oldest(self) -> None:
|
||||||
self.fetch_envelopes() if self.reload_needed else None
|
self.fetch_envelopes() if self.reload_needed else None
|
||||||
@@ -916,12 +1105,19 @@ class EmailViewerApp(App):
|
|||||||
self.fetch_envelopes() if self.reload_needed else None
|
self.fetch_envelopes() if self.reload_needed else None
|
||||||
self.show_message(self.message_store.get_newest_id())
|
self.show_message(self.message_store.get_newest_id())
|
||||||
|
|
||||||
|
def action_reload(self) -> None:
|
||||||
|
"""Reload the message list."""
|
||||||
|
self.fetch_envelopes()
|
||||||
|
self.show_status("Reloading messages...")
|
||||||
|
|
||||||
def action_search(self) -> None:
|
def action_search(self) -> None:
|
||||||
"""Open the search panel."""
|
"""Open the search panel."""
|
||||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||||
if not search_panel.is_visible:
|
if not search_panel.is_visible:
|
||||||
# Cache current envelopes before searching
|
# Cache current envelopes before searching
|
||||||
self._cached_envelopes = list(self.message_store.envelopes)
|
self._cached_envelopes = list(self.message_store.envelopes)
|
||||||
|
self._cached_metadata = dict(self.message_store.metadata_by_id)
|
||||||
|
self.search_mode = True
|
||||||
search_panel.show(self.search_query)
|
search_panel.show(self.search_query)
|
||||||
|
|
||||||
def on_search_panel_search_requested(
|
def on_search_panel_search_requested(
|
||||||
@@ -943,11 +1139,14 @@ class EmailViewerApp(App):
|
|||||||
self.search_mode = False
|
self.search_mode = False
|
||||||
self.search_query = ""
|
self.search_query = ""
|
||||||
|
|
||||||
# Restore cached envelopes
|
# Restore cached envelopes and metadata
|
||||||
if self._cached_envelopes:
|
if self._cached_envelopes:
|
||||||
self.message_store.envelopes = self._cached_envelopes
|
self.message_store.envelopes = self._cached_envelopes
|
||||||
self._cached_envelopes = []
|
self._cached_envelopes = []
|
||||||
self._populate_list_view()
|
if self._cached_metadata:
|
||||||
|
self.message_store.metadata_by_id = self._cached_metadata
|
||||||
|
self._cached_metadata = {}
|
||||||
|
self._populate_list_view()
|
||||||
|
|
||||||
# Restore envelope list title
|
# Restore envelope list title
|
||||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||||
@@ -1002,19 +1201,82 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
# Add search results header
|
# Build search header label
|
||||||
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
|
if results:
|
||||||
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
|
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
|
||||||
|
else:
|
||||||
|
header_label = f"Search: '{query}' - No results found"
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
# Clear the message viewer when no results
|
||||||
|
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
|
||||||
|
content_container = self.query_one(ContentContainer)
|
||||||
|
content_container.clear_content()
|
||||||
|
self.message_store.envelopes = []
|
||||||
|
self.message_store.metadata_by_id = {}
|
||||||
|
self.total_messages = 0
|
||||||
|
self.current_message_id = 0
|
||||||
|
return
|
||||||
|
|
||||||
# Create a temporary message store for search results
|
# Create a temporary message store for search results
|
||||||
|
# We need to include the search header in the envelopes so indices match
|
||||||
search_store = MessageStore()
|
search_store = MessageStore()
|
||||||
search_store.load(results, self.sort_order_ascending)
|
|
||||||
|
|
||||||
# Store for navigation (replace main store temporarily)
|
# Manually build envelopes list with search header first
|
||||||
|
# so that ListView indices match message_store.envelopes indices
|
||||||
|
grouped_envelopes = [{"type": "header", "label": header_label}]
|
||||||
|
|
||||||
|
# Sort results by date
|
||||||
|
sorted_results = sorted(
|
||||||
|
results,
|
||||||
|
key=lambda x: x.get("date", ""),
|
||||||
|
reverse=not self.sort_order_ascending,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group by month and build metadata
|
||||||
|
months: Dict[str, bool] = {}
|
||||||
|
for envelope in sorted_results:
|
||||||
|
if "id" not in envelope:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract date and determine month group
|
||||||
|
date_str = envelope.get("date", "")
|
||||||
|
try:
|
||||||
|
date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||||
|
month_key = date.strftime("%B %Y")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
month_key = "Unknown Date"
|
||||||
|
|
||||||
|
# Add month header if this is a new month
|
||||||
|
if month_key not in months:
|
||||||
|
months[month_key] = True
|
||||||
|
grouped_envelopes.append({"type": "header", "label": month_key})
|
||||||
|
|
||||||
|
# Add the envelope
|
||||||
|
grouped_envelopes.append(envelope)
|
||||||
|
|
||||||
|
# Store metadata for quick access (index matches grouped_envelopes)
|
||||||
|
envelope_id = int(envelope["id"])
|
||||||
|
search_store.metadata_by_id[envelope_id] = {
|
||||||
|
"id": envelope_id,
|
||||||
|
"subject": envelope.get("subject", ""),
|
||||||
|
"from": envelope.get("from", {}),
|
||||||
|
"to": envelope.get("to", {}),
|
||||||
|
"cc": envelope.get("cc", {}),
|
||||||
|
"date": date_str,
|
||||||
|
"index": len(grouped_envelopes) - 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
search_store.envelopes = grouped_envelopes
|
||||||
|
search_store.total_messages = len(search_store.metadata_by_id)
|
||||||
|
|
||||||
|
# Store for navigation (replace main store)
|
||||||
self.message_store.envelopes = search_store.envelopes
|
self.message_store.envelopes = search_store.envelopes
|
||||||
|
self.message_store.metadata_by_id = search_store.metadata_by_id
|
||||||
self.total_messages = len(results)
|
self.total_messages = len(results)
|
||||||
|
|
||||||
for item in search_store.envelopes:
|
# Build ListView to match envelopes list exactly
|
||||||
|
for item in self.message_store.envelopes:
|
||||||
if item and item.get("type") == "header":
|
if item and item.get("type") == "header":
|
||||||
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
||||||
elif item:
|
elif item:
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ class ContentDisplayConfig(BaseModel):
|
|||||||
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
|
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
|
||||||
default_view_mode: Literal["markdown", "html"] = "markdown"
|
default_view_mode: Literal["markdown", "html"] = "markdown"
|
||||||
|
|
||||||
|
# URL compression: shorten long URLs for better readability
|
||||||
|
compress_urls: bool = True
|
||||||
|
max_url_length: int = 50 # Maximum length before URL is compressed
|
||||||
|
|
||||||
|
# Notification compression: compress notification emails into summaries
|
||||||
|
compress_notifications: bool = True
|
||||||
|
notification_compression_mode: Literal["summary", "detailed", "off"] = "summary"
|
||||||
|
|
||||||
|
|
||||||
class LinkPanelConfig(BaseModel):
|
class LinkPanelConfig(BaseModel):
|
||||||
"""Configuration for the link panel."""
|
"""Configuration for the link panel."""
|
||||||
@@ -96,6 +104,15 @@ class MailOperationsConfig(BaseModel):
|
|||||||
# Folder to move messages to when archiving
|
# Folder to move messages to when archiving
|
||||||
archive_folder: str = "Archive"
|
archive_folder: str = "Archive"
|
||||||
|
|
||||||
|
# Enable SMTP OAuth2 sending (requires IT to enable SMTP AUTH for your mailbox)
|
||||||
|
# When disabled, calendar replies will open in your default mail client instead
|
||||||
|
enable_smtp_send: bool = False
|
||||||
|
|
||||||
|
# Auto-send emails opened in Apple Mail via AppleScript
|
||||||
|
# When True, calendar replies will be sent automatically after opening in Mail
|
||||||
|
# When False, the email will be opened for manual review before sending
|
||||||
|
auto_send_via_applescript: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ThemeConfig(BaseModel):
|
class ThemeConfig(BaseModel):
|
||||||
"""Theme/appearance settings."""
|
"""Theme/appearance settings."""
|
||||||
|
|||||||
@@ -71,10 +71,36 @@ StatusTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeHeader {
|
EnvelopeHeader {
|
||||||
dock: top;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 2;
|
height: auto;
|
||||||
tint: $primary 10%;
|
min-height: 4;
|
||||||
|
max-height: 6;
|
||||||
|
padding: 0 1;
|
||||||
|
background: $surface-darken-1;
|
||||||
|
scrollbar-size: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full headers mode - allow more height for scrolling */
|
||||||
|
EnvelopeHeader.full-headers {
|
||||||
|
max-height: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_scroll {
|
||||||
|
height: 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header labels should be single line with truncation */
|
||||||
|
EnvelopeHeader Label {
|
||||||
|
width: 100%;
|
||||||
|
height: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full headers mode - allow wrapping */
|
||||||
|
EnvelopeHeader.full-headers Label {
|
||||||
|
height: auto;
|
||||||
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
Markdown {
|
Markdown {
|
||||||
@@ -123,7 +149,7 @@ EnvelopeListItem .status-icon.unread {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeListItem .checkbox {
|
EnvelopeListItem .checkbox {
|
||||||
width: 2;
|
width: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +165,12 @@ EnvelopeListItem .message-datetime {
|
|||||||
|
|
||||||
EnvelopeListItem .email-subject {
|
EnvelopeListItem .email-subject {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
padding: 0 4;
|
padding: 0 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeListItem .email-preview {
|
EnvelopeListItem .email-preview {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
padding: 0 4;
|
padding: 0 3;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,11 +183,30 @@ EnvelopeListItem.unread .email-subject {
|
|||||||
text-style: bold;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selected message styling */
|
/* Selected/checked message styling (for multi-select) */
|
||||||
EnvelopeListItem.selected {
|
EnvelopeListItem.selected {
|
||||||
tint: $accent 20%;
|
tint: $accent 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Currently highlighted/focused item styling - more prominent */
|
||||||
|
EnvelopeListItem.highlighted {
|
||||||
|
background: $primary-darken-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnvelopeListItem.highlighted .sender-name {
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnvelopeListItem.highlighted .email-subject {
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnvelopeListItem.highlighted .message-datetime {
|
||||||
|
color: $secondary-lighten-2;
|
||||||
|
}
|
||||||
|
|
||||||
/* GroupHeader - date group separator */
|
/* GroupHeader - date group separator */
|
||||||
GroupHeader {
|
GroupHeader {
|
||||||
height: 1;
|
height: 1;
|
||||||
@@ -228,12 +273,30 @@ GroupHeader .group-header-label {
|
|||||||
background: $surface-darken-1;
|
background: $surface-darken-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > ListItem {
|
/* Currently highlighted/focused item - make it very visible */
|
||||||
&.-highlight, .selection {
|
& > ListItem.-highlight {
|
||||||
color: $block-cursor-blurred-foreground;
|
background: $primary-darken-2;
|
||||||
background: $block-cursor-blurred-background;
|
color: $text;
|
||||||
text-style: $block-cursor-blurred-text-style;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Highlighted item's child elements */
|
||||||
|
& > ListItem.-highlight EnvelopeListItem {
|
||||||
|
tint: $primary 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ListItem.-highlight .sender-name {
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ListItem.-highlight .email-subject {
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ListItem.-highlight .message-datetime {
|
||||||
|
color: $secondary-lighten-2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
208
src/mail/invite_compressor.py
Normal file
208
src/mail/invite_compressor.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""Calendar invite compressor for terminal-friendly display."""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .utils.calendar_parser import (
|
||||||
|
ParsedCalendarEvent,
|
||||||
|
parse_calendar_from_raw_message,
|
||||||
|
format_event_time,
|
||||||
|
is_cancelled_event,
|
||||||
|
is_event_request,
|
||||||
|
)
|
||||||
|
from .notification_detector import is_calendar_email
|
||||||
|
|
||||||
|
|
||||||
|
class InviteCompressor:
|
||||||
|
"""Compress calendar invite emails into terminal-friendly summaries."""
|
||||||
|
|
||||||
|
# Nerdfont icons
|
||||||
|
ICON_CALENDAR = "\uf073" # calendar icon
|
||||||
|
ICON_CANCELLED = "\uf057" # times-circle
|
||||||
|
ICON_INVITE = "\uf0e0" # envelope
|
||||||
|
ICON_REPLY = "\uf3e5" # reply
|
||||||
|
ICON_LOCATION = "\uf3c5" # map-marker-alt
|
||||||
|
ICON_CLOCK = "\uf017" # clock
|
||||||
|
ICON_USER = "\uf007" # user
|
||||||
|
ICON_USERS = "\uf0c0" # users
|
||||||
|
|
||||||
|
def __init__(self, mode: str = "summary"):
|
||||||
|
"""Initialize compressor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Compression mode - "summary", "detailed", or "off"
|
||||||
|
"""
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def should_compress(self, envelope: dict[str, Any]) -> bool:
|
||||||
|
"""Check if email should be compressed as calendar invite.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: Email envelope metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email is a calendar invite that should be compressed
|
||||||
|
"""
|
||||||
|
if self.mode == "off":
|
||||||
|
return False
|
||||||
|
|
||||||
|
return is_calendar_email(envelope)
|
||||||
|
|
||||||
|
def compress(
|
||||||
|
self, raw_message: str, envelope: dict[str, Any]
|
||||||
|
) -> tuple[str, Optional[ParsedCalendarEvent]]:
|
||||||
|
"""Compress calendar invite email content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_message: Raw email MIME content
|
||||||
|
envelope: Email envelope metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (compressed content, parsed event or None)
|
||||||
|
"""
|
||||||
|
if not self.should_compress(envelope):
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
# Parse the ICS content from raw message
|
||||||
|
event = parse_calendar_from_raw_message(raw_message)
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
# Format as markdown
|
||||||
|
compressed = self._format_as_markdown(event, envelope)
|
||||||
|
|
||||||
|
return compressed, event
|
||||||
|
|
||||||
|
def _format_as_markdown(
|
||||||
|
self,
|
||||||
|
event: ParsedCalendarEvent,
|
||||||
|
envelope: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
"""Format event as markdown for terminal display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Parsed calendar event
|
||||||
|
envelope: Email envelope metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Markdown-formatted compressed invite
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Determine event type and icon
|
||||||
|
if is_cancelled_event(event):
|
||||||
|
icon = self.ICON_CANCELLED
|
||||||
|
type_label = "CANCELLED"
|
||||||
|
type_style = "~~" # strikethrough
|
||||||
|
elif is_event_request(event):
|
||||||
|
icon = self.ICON_INVITE
|
||||||
|
type_label = "MEETING INVITE"
|
||||||
|
type_style = "**"
|
||||||
|
else:
|
||||||
|
icon = self.ICON_CALENDAR
|
||||||
|
type_label = event.method or "CALENDAR"
|
||||||
|
type_style = ""
|
||||||
|
|
||||||
|
# Header
|
||||||
|
lines.append(f"## {icon} {type_label}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Event title
|
||||||
|
title = event.summary or envelope.get("subject", "Untitled Event")
|
||||||
|
if is_cancelled_event(event):
|
||||||
|
# Remove "Canceled: " prefix if present
|
||||||
|
if title.lower().startswith("canceled:"):
|
||||||
|
title = title[9:].strip()
|
||||||
|
elif title.lower().startswith("cancelled:"):
|
||||||
|
title = title[10:].strip()
|
||||||
|
lines.append(f"~~{title}~~")
|
||||||
|
else:
|
||||||
|
lines.append(f"**{title}**")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Time
|
||||||
|
time_str = format_event_time(event)
|
||||||
|
lines.append(f"{self.ICON_CLOCK} {time_str}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Location
|
||||||
|
if event.location:
|
||||||
|
lines.append(f"{self.ICON_LOCATION} {event.location}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Organizer
|
||||||
|
if event.organizer_name or event.organizer_email:
|
||||||
|
organizer = event.organizer_name or event.organizer_email
|
||||||
|
lines.append(f"{self.ICON_USER} **Organizer:** {organizer}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Attendees (compressed)
|
||||||
|
if event.attendees:
|
||||||
|
attendee_summary = self._compress_attendees(event.attendees)
|
||||||
|
lines.append(f"{self.ICON_USERS} **Attendees:** {attendee_summary}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Actions hint
|
||||||
|
if is_event_request(event):
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("*Press `A` to Accept, `T` for Tentative, `D` to Decline*")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _compress_attendees(self, attendees: list[str], max_shown: int = 3) -> str:
|
||||||
|
"""Compress attendee list to a short summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attendees: List of attendee strings (name <email> or just email)
|
||||||
|
max_shown: Maximum number of attendees to show before truncating
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compressed attendee summary like "Alice, Bob, Carol... (+12 more)"
|
||||||
|
"""
|
||||||
|
if not attendees:
|
||||||
|
return "None"
|
||||||
|
|
||||||
|
# Extract just names from attendees
|
||||||
|
names = []
|
||||||
|
for att in attendees:
|
||||||
|
# Handle "Name <email>" format
|
||||||
|
if "<" in att:
|
||||||
|
name = att.split("<")[0].strip()
|
||||||
|
if name:
|
||||||
|
# Get just first name for brevity
|
||||||
|
first_name = (
|
||||||
|
name.split(",")[0].strip() if "," in name else name.split()[0]
|
||||||
|
)
|
||||||
|
names.append(first_name)
|
||||||
|
else:
|
||||||
|
names.append(att.split("<")[1].rstrip(">").split("@")[0])
|
||||||
|
else:
|
||||||
|
# Just email, use local part
|
||||||
|
names.append(att.split("@")[0])
|
||||||
|
|
||||||
|
total = len(names)
|
||||||
|
|
||||||
|
if total <= max_shown:
|
||||||
|
return ", ".join(names)
|
||||||
|
else:
|
||||||
|
shown = ", ".join(names[:max_shown])
|
||||||
|
remaining = total - max_shown
|
||||||
|
return f"{shown}... (+{remaining} more)"
|
||||||
|
|
||||||
|
|
||||||
|
def compress_invite(
|
||||||
|
raw_message: str, envelope: dict[str, Any], mode: str = "summary"
|
||||||
|
) -> tuple[str, Optional[ParsedCalendarEvent]]:
|
||||||
|
"""Convenience function to compress a calendar invite.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_message: Raw email MIME content
|
||||||
|
envelope: Email envelope metadata
|
||||||
|
mode: Compression mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (compressed content, parsed event or None)
|
||||||
|
"""
|
||||||
|
compressor = InviteCompressor(mode=mode)
|
||||||
|
return compressor.compress(raw_message, envelope)
|
||||||
@@ -83,7 +83,17 @@ class MessageStore:
|
|||||||
self, current_index: int
|
self, current_index: int
|
||||||
) -> Tuple[Optional[int], Optional[int]]:
|
) -> Tuple[Optional[int], Optional[int]]:
|
||||||
"""Find the next valid message ID and its index"""
|
"""Find the next valid message ID and its index"""
|
||||||
if not self.envelopes or current_index >= len(self.envelopes) - 1:
|
if not self.envelopes:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Clamp current_index to valid range in case list shrunk during async operations
|
||||||
|
if current_index >= len(self.envelopes):
|
||||||
|
current_index = len(self.envelopes) - 1
|
||||||
|
if current_index < 0:
|
||||||
|
current_index = 0
|
||||||
|
|
||||||
|
# If already at or past the end, no next item
|
||||||
|
if current_index >= len(self.envelopes) - 1:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Start from current index + 1
|
# Start from current index + 1
|
||||||
@@ -99,7 +109,17 @@ class MessageStore:
|
|||||||
self, current_index: int
|
self, current_index: int
|
||||||
) -> Tuple[Optional[int], Optional[int]]:
|
) -> Tuple[Optional[int], Optional[int]]:
|
||||||
"""Find the previous valid message ID and its index"""
|
"""Find the previous valid message ID and its index"""
|
||||||
if not self.envelopes or current_index <= 0:
|
if not self.envelopes:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Clamp current_index to valid range in case list shrunk during async operations
|
||||||
|
if current_index >= len(self.envelopes):
|
||||||
|
current_index = len(self.envelopes) - 1
|
||||||
|
if current_index < 0:
|
||||||
|
current_index = 0
|
||||||
|
|
||||||
|
# If at the beginning, no previous item
|
||||||
|
if current_index <= 0:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Start from current index - 1
|
# Start from current index - 1
|
||||||
|
|||||||
219
src/mail/notification_compressor.py
Normal file
219
src/mail/notification_compressor.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""Notification email compressor for terminal-friendly display."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .notification_detector import (
|
||||||
|
NotificationType,
|
||||||
|
classify_notification,
|
||||||
|
extract_notification_summary,
|
||||||
|
is_notification_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationCompressor:
|
||||||
|
"""Compress notification emails into terminal-friendly summaries."""
|
||||||
|
|
||||||
|
def __init__(self, mode: str = "summary"):
|
||||||
|
"""Initialize compressor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Compression mode - "summary", "detailed", or "off"
|
||||||
|
"""
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def should_compress(self, envelope: dict[str, Any]) -> bool:
|
||||||
|
"""Check if email should be compressed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: Email envelope metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email should be compressed
|
||||||
|
"""
|
||||||
|
if self.mode == "off":
|
||||||
|
return False
|
||||||
|
|
||||||
|
return is_notification_email(envelope)
|
||||||
|
|
||||||
|
def compress(
|
||||||
|
self, content: str, envelope: dict[str, Any]
|
||||||
|
) -> tuple[str, NotificationType | None]:
|
||||||
|
"""Compress notification email content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Raw email content
|
||||||
|
envelope: Email envelope metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (compressed content, notification_type)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.should_compress(envelope):
|
||||||
|
return content, None
|
||||||
|
|
||||||
|
# Classify notification type
|
||||||
|
notif_type = classify_notification(envelope, content)
|
||||||
|
|
||||||
|
# Extract summary
|
||||||
|
summary = extract_notification_summary(content, notif_type)
|
||||||
|
|
||||||
|
# Format as markdown
|
||||||
|
compressed = self._format_as_markdown(summary, envelope, notif_type)
|
||||||
|
|
||||||
|
return compressed, notif_type
|
||||||
|
|
||||||
|
def _format_as_markdown(
|
||||||
|
self,
|
||||||
|
summary: dict[str, Any],
|
||||||
|
envelope: dict[str, Any],
|
||||||
|
notif_type: NotificationType | None,
|
||||||
|
) -> str:
|
||||||
|
"""Format summary as markdown for terminal display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
summary: Extracted summary data
|
||||||
|
envelope: Email envelope metadata
|
||||||
|
notif_type: Classified notification type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Markdown-formatted compressed email
|
||||||
|
"""
|
||||||
|
|
||||||
|
from_addr = envelope.get("from", {}).get("name") or envelope.get(
|
||||||
|
"from", {}
|
||||||
|
).get("addr", "")
|
||||||
|
subject = envelope.get("subject", "")
|
||||||
|
|
||||||
|
# Get icon
|
||||||
|
icon = notif_type.icon if notif_type else "\uf0f3"
|
||||||
|
|
||||||
|
# Build markdown
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header with icon
|
||||||
|
if notif_type:
|
||||||
|
lines.append(f"## {icon} {notif_type.name.title()} Notification")
|
||||||
|
else:
|
||||||
|
lines.append(f"## {icon} Notification")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Title/subject
|
||||||
|
if summary.get("title"):
|
||||||
|
lines.append(f"**{summary['title']}**")
|
||||||
|
else:
|
||||||
|
lines.append(f"**{subject}**")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Metadata section
|
||||||
|
if summary.get("metadata"):
|
||||||
|
lines.append("### Details")
|
||||||
|
for key, value in summary["metadata"].items():
|
||||||
|
# Format key nicely
|
||||||
|
key_formatted = key.replace("_", " ").title()
|
||||||
|
lines.append(f"- **{key_formatted}**: {value}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Action items
|
||||||
|
if summary.get("action_items"):
|
||||||
|
lines.append("### Actions")
|
||||||
|
for i, action in enumerate(summary["action_items"], 1):
|
||||||
|
lines.append(f"{i}. {action}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Add footer
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"*From: {from_addr}*")
|
||||||
|
lines.append(
|
||||||
|
"*This is a compressed notification. Press `m` to see full email.*"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class DetailedCompressor(NotificationCompressor):
|
||||||
|
"""Compressor that includes more detail in summaries."""
|
||||||
|
|
||||||
|
def _format_as_markdown(
|
||||||
|
self,
|
||||||
|
summary: dict[str, Any],
|
||||||
|
envelope: dict[str, Any],
|
||||||
|
notif_type: NotificationType | None,
|
||||||
|
) -> str:
|
||||||
|
"""Format summary with more detail."""
|
||||||
|
|
||||||
|
from_addr = envelope.get("from", {}).get("name") or envelope.get(
|
||||||
|
"from", {}
|
||||||
|
).get("addr", "")
|
||||||
|
subject = envelope.get("subject", "")
|
||||||
|
date = envelope.get("date", "")
|
||||||
|
|
||||||
|
icon = notif_type.icon if notif_type else "\uf0f3"
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
lines.append(
|
||||||
|
f"## {icon} {notif_type.name.title()} Notification"
|
||||||
|
if notif_type
|
||||||
|
else f"## {icon} Notification"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Subject and from
|
||||||
|
lines.append(f"**Subject:** {subject}")
|
||||||
|
lines.append(f"**From:** {from_addr}")
|
||||||
|
lines.append(f"**Date:** {date}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Summary title
|
||||||
|
if summary.get("title"):
|
||||||
|
lines.append(f"### {summary['title']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Metadata table
|
||||||
|
if summary.get("metadata"):
|
||||||
|
lines.append("| Property | Value |")
|
||||||
|
lines.append("|----------|-------|")
|
||||||
|
for key, value in summary["metadata"].items():
|
||||||
|
key_formatted = key.replace("_", " ").title()
|
||||||
|
lines.append(f"| {key_formatted} | {value} |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Action items
|
||||||
|
if summary.get("action_items"):
|
||||||
|
lines.append("### Action Items")
|
||||||
|
for i, action in enumerate(summary["action_items"], 1):
|
||||||
|
lines.append(f"- [ ] {action}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Key links
|
||||||
|
if summary.get("key_links"):
|
||||||
|
lines.append("### Important Links")
|
||||||
|
for link in summary["key_links"]:
|
||||||
|
lines.append(f"- [{link.get('text', 'Link')}]({link.get('url', '#')})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
lines.append("---")
|
||||||
|
lines.append(
|
||||||
|
"*This is a compressed notification view. Press `m` to toggle full view.*"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def create_compressor(mode: str) -> NotificationCompressor:
|
||||||
|
"""Factory function to create appropriate compressor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Compression mode - "summary", "detailed", or "off"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NotificationCompressor instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
if mode == "detailed":
|
||||||
|
return DetailedCompressor(mode=mode)
|
||||||
|
return NotificationCompressor(mode=mode)
|
||||||
443
src/mail/notification_detector.py
Normal file
443
src/mail/notification_detector.py
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
"""Email notification detection utilities."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationType:
|
||||||
|
"""Classification of notification email types."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
patterns: list[str]
|
||||||
|
domains: list[str]
|
||||||
|
icon: str
|
||||||
|
|
||||||
|
def matches(self, envelope: dict[str, Any], content: str | None = None) -> bool:
|
||||||
|
"""Check if envelope matches this notification type."""
|
||||||
|
|
||||||
|
# Check sender domain (more specific check)
|
||||||
|
from_addr = envelope.get("from", {}).get("addr", "").lower()
|
||||||
|
for domain in self.domains:
|
||||||
|
# For atlassian.net, check if it's specifically jira or confluence in the address
|
||||||
|
if domain == "atlassian.net":
|
||||||
|
if "jira@" in from_addr:
|
||||||
|
return self.name == "jira"
|
||||||
|
if "confluence@" in from_addr:
|
||||||
|
return self.name == "confluence"
|
||||||
|
elif domain in from_addr:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check subject patterns
|
||||||
|
subject = envelope.get("subject", "").lower()
|
||||||
|
if any(re.search(pattern, subject, re.IGNORECASE) for pattern in self.patterns):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Common notification types
|
||||||
|
NOTIFICATION_TYPES = [
|
||||||
|
NotificationType(
|
||||||
|
name="gitlab",
|
||||||
|
patterns=[r"\[gitlab\]", r"pipeline", r"merge request", r"mention.*you"],
|
||||||
|
domains=["gitlab.com", "@gitlab"],
|
||||||
|
icon="\uf296",
|
||||||
|
),
|
||||||
|
NotificationType(
|
||||||
|
name="github",
|
||||||
|
patterns=[r"\[github\]", r"pr #", r"pull request", r"issue #", r"mention"],
|
||||||
|
domains=["github.com", "noreply@github.com"],
|
||||||
|
icon="\uf09b",
|
||||||
|
),
|
||||||
|
NotificationType(
|
||||||
|
name="jira",
|
||||||
|
patterns=[r"\[jira\]", r"[a-z]+-\d+", r"issue updated", r"comment added"],
|
||||||
|
domains=["atlassian.net", "jira"],
|
||||||
|
icon="\uf1b3",
|
||||||
|
),
|
||||||
|
NotificationType(
|
||||||
|
name="confluence",
|
||||||
|
patterns=[r"\[confluence\]", r"page created", r"page updated", r"comment"],
|
||||||
|
domains=["atlassian.net", "confluence"],
|
||||||
|
icon="\uf298",
|
||||||
|
),
|
||||||
|
NotificationType(
|
||||||
|
name="datadog",
|
||||||
|
patterns=[r"alert", r"monitor", r"incident", r"downtime"],
|
||||||
|
domains=["datadoghq.com", "datadog"],
|
||||||
|
icon="\uf1b0",
|
||||||
|
),
|
||||||
|
NotificationType(
|
||||||
|
name="renovate",
|
||||||
|
patterns=[r"renovate", r"dependency update", r"lock file"],
|
||||||
|
domains=["renovate", "renovatebot"],
|
||||||
|
icon="\uf1e6",
|
||||||
|
),
|
||||||
|
NotificationType(
|
||||||
|
name="general",
|
||||||
|
patterns=[r"\[.*?\]", r"notification", r"digest", r"summary"],
|
||||||
|
domains=["noreply@", "no-reply@", "notifications@"],
|
||||||
|
icon="\uf0f3",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_notification_email(envelope: dict[str, Any], content: str | None = None) -> bool:
|
||||||
|
"""Check if an email is a notification-style email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: Email envelope metadata from himalaya
|
||||||
|
content: Optional email content for content-based detection
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email appears to be a notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check against known notification types
|
||||||
|
for notif_type in NOTIFICATION_TYPES:
|
||||||
|
if notif_type.matches(envelope, content):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for generic notification indicators
|
||||||
|
subject = envelope.get("subject", "").lower()
|
||||||
|
from_addr = envelope.get("from", {}).get("addr", "").lower()
|
||||||
|
|
||||||
|
# Generic notification patterns
|
||||||
|
generic_patterns = [
|
||||||
|
r"^\[.*?\]", # Brackets at start
|
||||||
|
r"weekly|daily|monthly.*report|digest|summary",
|
||||||
|
r"you were mentioned",
|
||||||
|
r"this is an automated message",
|
||||||
|
r"do not reply|don't reply",
|
||||||
|
]
|
||||||
|
|
||||||
|
if any(re.search(pattern, subject, re.IGNORECASE) for pattern in generic_patterns):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for notification senders
|
||||||
|
notification_senders = ["noreply", "no-reply", "notifications", "robot", "bot"]
|
||||||
|
if any(sender in from_addr for sender in notification_senders):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def classify_notification(
|
||||||
|
envelope: dict[str, Any], content: str | None = None
|
||||||
|
) -> NotificationType | None:
|
||||||
|
"""Classify the type of notification email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: Email envelope metadata from himalaya
|
||||||
|
content: Optional email content for content-based detection
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NotificationType if classified, None if not a notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
for notif_type in NOTIFICATION_TYPES:
|
||||||
|
if notif_type.matches(envelope, content):
|
||||||
|
return notif_type
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_notification_summary(
|
||||||
|
content: str, notification_type: NotificationType | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Extract structured summary from notification email content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Email body content
|
||||||
|
notification_type: Classified notification type (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with extracted summary fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"title": None,
|
||||||
|
"action_items": [],
|
||||||
|
"key_links": [],
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract based on notification type
|
||||||
|
if notification_type and notification_type.name == "gitlab":
|
||||||
|
summary.update(_extract_gitlab_summary(content))
|
||||||
|
elif notification_type and notification_type.name == "github":
|
||||||
|
summary.update(_extract_github_summary(content))
|
||||||
|
elif notification_type and notification_type.name == "jira":
|
||||||
|
summary.update(_extract_jira_summary(content))
|
||||||
|
elif notification_type and notification_type.name == "confluence":
|
||||||
|
summary.update(_extract_confluence_summary(content))
|
||||||
|
elif notification_type and notification_type.name == "datadog":
|
||||||
|
summary.update(_extract_datadog_summary(content))
|
||||||
|
elif notification_type and notification_type.name == "renovate":
|
||||||
|
summary.update(_extract_renovate_summary(content))
|
||||||
|
else:
|
||||||
|
summary.update(_extract_general_notification_summary(content))
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_gitlab_summary(content: str) -> dict[str, Any]:
|
||||||
|
"""Extract summary from GitLab notification."""
|
||||||
|
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||||
|
|
||||||
|
# Pipeline patterns
|
||||||
|
pipeline_match = re.search(
|
||||||
|
r"Pipeline #(\d+).*?(?:failed|passed|canceled) by (.+?)[\n\r]",
|
||||||
|
content,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if pipeline_match:
|
||||||
|
summary["metadata"]["pipeline_id"] = pipeline_match.group(1)
|
||||||
|
summary["metadata"]["triggered_by"] = pipeline_match.group(2)
|
||||||
|
summary["title"] = f"Pipeline #{pipeline_match.group(1)}"
|
||||||
|
|
||||||
|
# Merge request patterns
|
||||||
|
mr_match = re.search(r"Merge request #(\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE)
|
||||||
|
if mr_match:
|
||||||
|
summary["metadata"]["mr_id"] = mr_match.group(1)
|
||||||
|
summary["metadata"]["mr_title"] = mr_match.group(2)
|
||||||
|
summary["title"] = f"MR #{mr_match.group(1)}: {mr_match.group(2)}"
|
||||||
|
|
||||||
|
# Mention patterns
|
||||||
|
mention_match = re.search(
|
||||||
|
r"<@(.+?)> mentioned you in (?:#|@)(.+?)[\n\r]", content, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if mention_match:
|
||||||
|
summary["metadata"]["mentioned_by"] = mention_match.group(1)
|
||||||
|
summary["metadata"]["mentioned_in"] = mention_match.group(2)
|
||||||
|
summary["title"] = f"Mention by {mention_match.group(1)}"
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_github_summary(content: str) -> dict[str, Any]:
|
||||||
|
"""Extract summary from GitHub notification."""
|
||||||
|
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||||
|
|
||||||
|
# PR/Issue patterns
|
||||||
|
pr_match = re.search(r"(?:PR|Issue) #(\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE)
|
||||||
|
if pr_match:
|
||||||
|
summary["metadata"]["number"] = pr_match.group(1)
|
||||||
|
summary["metadata"]["title"] = pr_match.group(2)
|
||||||
|
summary["title"] = f"#{pr_match.group(1)}: {pr_match.group(2)}"
|
||||||
|
|
||||||
|
# Review requested
|
||||||
|
if "review requested" in content.lower():
|
||||||
|
summary["action_items"].append("Review requested")
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_jira_summary(content: str) -> dict[str, Any]:
|
||||||
|
"""Extract summary from Jira notification."""
|
||||||
|
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||||
|
|
||||||
|
# Issue patterns
|
||||||
|
issue_match = re.search(r"([A-Z]+-\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE)
|
||||||
|
if issue_match:
|
||||||
|
summary["metadata"]["issue_key"] = issue_match.group(1)
|
||||||
|
summary["metadata"]["issue_title"] = issue_match.group(2)
|
||||||
|
summary["title"] = f"{issue_match.group(1)}: {issue_match.group(2)}"
|
||||||
|
|
||||||
|
# Status changes
|
||||||
|
if "status changed" in content.lower():
|
||||||
|
status_match = re.search(
|
||||||
|
r"status changed from (.+?) to (.+)", content, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if status_match:
|
||||||
|
summary["metadata"]["status_from"] = status_match.group(1)
|
||||||
|
summary["metadata"]["status_to"] = status_match.group(2)
|
||||||
|
summary["action_items"].append(
|
||||||
|
f"Status: {status_match.group(1)} → {status_match.group(2)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_confluence_summary(content: str) -> dict[str, Any]:
|
||||||
|
"""Extract summary from Confluence notification."""
|
||||||
|
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||||
|
|
||||||
|
# Page patterns
|
||||||
|
page_match = re.search(r"Page \"(.+?)\"", content, re.IGNORECASE)
|
||||||
|
if page_match:
|
||||||
|
summary["metadata"]["page_title"] = page_match.group(1)
|
||||||
|
summary["title"] = f"Page: {page_match.group(1)}"
|
||||||
|
|
||||||
|
# Author
|
||||||
|
author_match = re.search(
|
||||||
|
r"(?:created|updated) by (.+?)[\n\r]", content, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if author_match:
|
||||||
|
summary["metadata"]["author"] = author_match.group(1)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_datadog_summary(content: str) -> dict[str, Any]:
|
||||||
|
"""Extract summary from Datadog notification."""
|
||||||
|
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||||
|
|
||||||
|
# Alert status
|
||||||
|
if "triggered" in content.lower():
|
||||||
|
summary["metadata"]["status"] = "Triggered"
|
||||||
|
summary["action_items"].append("Alert triggered - investigate")
|
||||||
|
elif "recovered" in content.lower():
|
||||||
|
summary["metadata"]["status"] = "Recovered"
|
||||||
|
|
||||||
|
# Monitor name
|
||||||
|
monitor_match = re.search(r"Monitor: (.+?)[\n\r]", content, re.IGNORECASE)
|
||||||
|
if monitor_match:
|
||||||
|
summary["metadata"]["monitor"] = monitor_match.group(1)
|
||||||
|
summary["title"] = f"Alert: {monitor_match.group(1)}"
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_renovate_summary(content: str) -> dict[str, Any]:
|
||||||
|
"""Extract summary from Renovate notification."""
|
||||||
|
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||||
|
|
||||||
|
# Dependency patterns
|
||||||
|
dep_match = re.search(
|
||||||
|
r"Update (?:.+) dependency (.+?) to (v?\d+\.\d+\.?\d*)", content, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if dep_match:
|
||||||
|
summary["metadata"]["dependency"] = dep_match.group(2)
|
||||||
|
summary["metadata"]["version"] = dep_match.group(3)
|
||||||
|
summary["title"] = f"Update {dep_match.group(2)} to {dep_match.group(3)}"
|
||||||
|
summary["action_items"].append("Review and merge dependency update")
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_general_notification_summary(content: str) -> dict[str, Any]:
|
||||||
|
"""Extract summary from general notification."""
|
||||||
|
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||||
|
|
||||||
|
# Look for action-oriented phrases
|
||||||
|
action_patterns = [
|
||||||
|
r"you need to (.+)",
|
||||||
|
r"please (.+)",
|
||||||
|
r"action required",
|
||||||
|
r"review requested",
|
||||||
|
r"approval needed",
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in action_patterns:
|
||||||
|
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||||
|
summary["action_items"].extend(matches)
|
||||||
|
|
||||||
|
# Limit action items
|
||||||
|
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 _decode_mime_content(raw_content: str) -> str:
|
||||||
|
"""Decode base64 parts from MIME content for text searching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_content: Raw MIME message content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded text content for searching
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
decoded_parts = [raw_content] # Include raw content for non-base64 parts
|
||||||
|
|
||||||
|
# Find and decode base64 text parts
|
||||||
|
b64_pattern = re.compile(
|
||||||
|
r"Content-Type:\s*text/(?:plain|html)[^\n]*\n"
|
||||||
|
r"(?:[^\n]+\n)*?" # Other headers
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
for match in b64_pattern.finditer(raw_content):
|
||||||
|
try:
|
||||||
|
b64_content = (
|
||||||
|
match.group(1).replace("\n", "").replace("\r", "").replace(" ", "")
|
||||||
|
)
|
||||||
|
decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace")
|
||||||
|
decoded_parts.append(decoded)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return " ".join(decoded_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> bool:
|
||||||
|
"""Check if an email is a calendar invite/update/cancellation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: Email envelope metadata from himalaya
|
||||||
|
content: Optional message content to check for calendar indicators
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Check for forwarded meeting invites (FW: or Fwd:) with calendar keywords
|
||||||
|
if re.match(r"^(fw|fwd):", subject, re.IGNORECASE):
|
||||||
|
# Check for Teams/calendar-related terms that might indicate forwarded invite
|
||||||
|
forward_meeting_keywords = ["connect", "sync", "call", "discussion", "review"]
|
||||||
|
if any(keyword in subject for keyword in forward_meeting_keywords):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If content is provided, check for calendar indicators
|
||||||
|
if content:
|
||||||
|
# Decode base64 parts for proper text searching
|
||||||
|
decoded_content = _decode_mime_content(content).lower()
|
||||||
|
|
||||||
|
# Teams meeting indicators
|
||||||
|
if "microsoft teams meeting" in decoded_content:
|
||||||
|
return True
|
||||||
|
if "join the meeting" in decoded_content:
|
||||||
|
return True
|
||||||
|
# ICS content indicator (check raw content for MIME headers)
|
||||||
|
if "text/calendar" in content.lower():
|
||||||
|
return True
|
||||||
|
# VCALENDAR block
|
||||||
|
if "begin:vcalendar" in content.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Input, Label, Button, ListView, ListItem
|
from textual.widgets import Input, Label, Button, ListView, ListItem
|
||||||
from textual.containers import Vertical, Horizontal, Container
|
from textual.containers import Vertical, Horizontal, Container
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual import on, work
|
from textual import on, work
|
||||||
from src.services.task_client import create_task, get_backend_info
|
from src.services.task_client import create_task, get_backend_info
|
||||||
|
from src.utils.ipc import notify_refresh
|
||||||
|
|
||||||
|
|
||||||
class CreateTaskScreen(ModalScreen):
|
class CreateTaskScreen(ModalScreen):
|
||||||
@@ -208,6 +210,8 @@ class CreateTaskScreen(ModalScreen):
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.app.show_status(f"Task created: {subject}", "success")
|
self.app.show_status(f"Task created: {subject}", "success")
|
||||||
|
# Notify the tasks app to refresh
|
||||||
|
asyncio.create_task(notify_refresh("tasks", {"source": "mail"}))
|
||||||
self.dismiss()
|
self.dismiss()
|
||||||
else:
|
else:
|
||||||
self.app.show_status(f"Failed to create task: {result}", "error")
|
self.app.show_status(f"Failed to create task: {result}", "error")
|
||||||
|
|||||||
150
src/mail/screens/HelpScreen.py
Normal file
150
src/mail/screens/HelpScreen.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Help screen modal for mail app."""
|
||||||
|
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.containers import Vertical, Horizontal, Center, ScrollableContainer
|
||||||
|
from textual.widgets import Static, Button, Footer
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
|
||||||
|
|
||||||
|
class HelpScreen(Screen):
|
||||||
|
"""Help screen showing all keyboard shortcuts and app information."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "pop_screen", "Close", show=False),
|
||||||
|
Binding("q", "pop_screen", "Close", show=False),
|
||||||
|
Binding("?", "pop_screen", "Close", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, app_bindings: list[Binding], **kwargs):
|
||||||
|
"""Initialize help screen with app bindings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_bindings: List of bindings from the main app
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.app_bindings = app_bindings
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the help screen."""
|
||||||
|
|
||||||
|
with Vertical(id="help_container"):
|
||||||
|
# Header
|
||||||
|
yield Static(
|
||||||
|
"╔══════════════════════════════════════════════════════════════════╗\n"
|
||||||
|
"║" + " " * 68 + "║\n"
|
||||||
|
"║" + " LUK Mail - Keyboard Shortcuts & Help".center(68) + " ║\n"
|
||||||
|
"╚════════════════════════════════════════════════════════════════════╝"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom instructions section
|
||||||
|
yield Static("", id="spacer_1")
|
||||||
|
yield Static("[b cyan]Quick Actions[/b cyan]", id="instructions_title")
|
||||||
|
yield Static("─" * 70, id="instructions_separator")
|
||||||
|
yield Static("")
|
||||||
|
yield Static(
|
||||||
|
" The mail app automatically compresses notification emails from:"
|
||||||
|
)
|
||||||
|
yield Static(" • GitLab (pipelines, MRs, mentions)")
|
||||||
|
yield Static(" • GitHub (PRs, issues, reviews)")
|
||||||
|
yield Static(" • Jira (issues, status changes)")
|
||||||
|
yield Static(" • Confluence (page updates, comments)")
|
||||||
|
yield Static(" • Datadog (alerts, incidents)")
|
||||||
|
yield Static(" • Renovate (dependency updates)")
|
||||||
|
yield Static("")
|
||||||
|
yield Static(
|
||||||
|
" [yellow]Tip:[/yellow] Toggle between compressed and full view with [b]m[/b]"
|
||||||
|
)
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
# Auto-generated keybindings section
|
||||||
|
yield Static("", id="spacer_2")
|
||||||
|
yield Static("[b cyan]Keyboard Shortcuts[/b cyan]", id="bindings_title")
|
||||||
|
yield Static("─" * 70, id="bindings_separator")
|
||||||
|
yield Static("")
|
||||||
|
yield Static("[b green]Navigation[/b green]")
|
||||||
|
yield Static(" j/k - Next/Previous message")
|
||||||
|
yield Static(" g - Go to oldest message")
|
||||||
|
yield Static(" G - Go to newest message")
|
||||||
|
yield Static(" b - Scroll page up")
|
||||||
|
yield Static(" PageDown/PageUp - Scroll page down/up")
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
yield Static("[b green]Message Actions[/b green]")
|
||||||
|
yield Static(" o - Open message externally")
|
||||||
|
yield Static(" # - Delete message(s)")
|
||||||
|
yield Static(" e - Archive message(s)")
|
||||||
|
yield Static(" u - Toggle read/unread")
|
||||||
|
yield Static(" t - Create task from message")
|
||||||
|
yield Static(" l - Show links in message")
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
yield Static("[b green]View Options[/b green]")
|
||||||
|
yield Static(" w - Toggle message view window")
|
||||||
|
yield Static(
|
||||||
|
" m - Toggle markdown/html view (or compressed/html for notifications)"
|
||||||
|
)
|
||||||
|
yield Static(" h - Toggle full/compressed envelope headers")
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
yield Static("[b green]Search & Filter[/b green]")
|
||||||
|
yield Static(" / - Search messages")
|
||||||
|
yield Static(" s - Toggle sort order")
|
||||||
|
yield Static(" x - Toggle selection mode")
|
||||||
|
yield Static(" Space - Select/deselect message")
|
||||||
|
yield Static(" Escape - Clear selection")
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
yield Static("[b green]Calendar Actions (when applicable)[/b green]")
|
||||||
|
yield Static(" A - Accept invite")
|
||||||
|
yield Static(" D - Decline invite")
|
||||||
|
yield Static(" T - Tentative")
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
yield Static("[b green]Application[/b green]")
|
||||||
|
yield Static(" r - Reload message list")
|
||||||
|
yield Static(
|
||||||
|
" 1-4 - Focus panel (Accounts, Folders, Messages, Content)"
|
||||||
|
)
|
||||||
|
yield Static(" q - Quit application")
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
# Notification compression section
|
||||||
|
yield Static("", id="spacer_3")
|
||||||
|
yield Static(
|
||||||
|
"[b cyan]Notification Email Compression[/b cyan]",
|
||||||
|
id="compression_title",
|
||||||
|
)
|
||||||
|
yield Static("─" * 70, id="compression_separator")
|
||||||
|
yield Static("")
|
||||||
|
yield Static(
|
||||||
|
" Notification emails are automatically detected and compressed"
|
||||||
|
)
|
||||||
|
yield Static(" into terminal-friendly summaries showing:")
|
||||||
|
yield Static(" • Notification type and icon")
|
||||||
|
yield Static(" • Key details (ID, title, status)")
|
||||||
|
yield Static(" • Action items")
|
||||||
|
yield Static(" • Important links")
|
||||||
|
yield Static("")
|
||||||
|
yield Static(" [yellow]Configuration:[/yellow]")
|
||||||
|
yield Static(" Edit ~/.config/luk/mail.toml to customize:")
|
||||||
|
yield Static(" [dim]compress_notifications = true[/dim]")
|
||||||
|
yield Static(" [dim]notification_compression_mode = 'summary'[/dim]")
|
||||||
|
yield Static(" # Options: 'summary', 'detailed', 'off'")
|
||||||
|
yield Static("")
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
yield Static("─" * 70, id="footer_separator")
|
||||||
|
yield Static(
|
||||||
|
"[dim]Press [b]ESC[/b], [b]q[/b], or [b]?[/b] to close this help screen[/dim]",
|
||||||
|
id="footer_text",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close button at bottom
|
||||||
|
with Horizontal(id="button_container"):
|
||||||
|
yield Button("Close", id="close_button", variant="primary")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button press to close help screen."""
|
||||||
|
if event.button.id == "close_button":
|
||||||
|
self.dismiss()
|
||||||
@@ -86,6 +86,9 @@ class LinkItem:
|
|||||||
- Keeping first and last path segments, eliding middle only if needed
|
- Keeping first and last path segments, eliding middle only if needed
|
||||||
- Adapting to available width
|
- Adapting to available width
|
||||||
"""
|
"""
|
||||||
|
# Nerdfont chevron separator (nf-cod-chevron_right)
|
||||||
|
sep = " \ueab6 "
|
||||||
|
|
||||||
# Special handling for common sites
|
# Special handling for common sites
|
||||||
path = path.strip("/")
|
path = path.strip("/")
|
||||||
|
|
||||||
@@ -95,26 +98,26 @@ class LinkItem:
|
|||||||
if match:
|
if match:
|
||||||
repo, type_, num = match.groups()
|
repo, type_, num = match.groups()
|
||||||
icon = "#" if type_ == "issues" else "PR#"
|
icon = "#" if type_ == "issues" else "PR#"
|
||||||
return f"{domain} > {repo} {icon}{num}"
|
return f"{domain}{sep}{repo} {icon}{num}"
|
||||||
|
|
||||||
match = re.match(r"([^/]+/[^/]+)", path)
|
match = re.match(r"([^/]+/[^/]+)", path)
|
||||||
if match:
|
if match:
|
||||||
return f"{domain} > {match.group(1)}"
|
return f"{domain}{sep}{match.group(1)}"
|
||||||
|
|
||||||
# Google Docs
|
# Google Docs
|
||||||
if "docs.google.com" in domain:
|
if "docs.google.com" in domain:
|
||||||
if "/document/" in path:
|
if "/document/" in path:
|
||||||
return f"{domain} > Document"
|
return f"{domain}{sep}Document"
|
||||||
if "/spreadsheets/" in path:
|
if "/spreadsheets/" in path:
|
||||||
return f"{domain} > Spreadsheet"
|
return f"{domain}{sep}Spreadsheet"
|
||||||
if "/presentation/" in path:
|
if "/presentation/" in path:
|
||||||
return f"{domain} > Slides"
|
return f"{domain}{sep}Slides"
|
||||||
|
|
||||||
# Jira/Atlassian
|
# Jira/Atlassian
|
||||||
if "atlassian.net" in domain or "jira" in domain.lower():
|
if "atlassian.net" in domain or "jira" in domain.lower():
|
||||||
match = re.search(r"([A-Z]+-\d+)", path)
|
match = re.search(r"([A-Z]+-\d+)", path)
|
||||||
if match:
|
if match:
|
||||||
return f"{domain} > {match.group(1)}"
|
return f"{domain}{sep}{match.group(1)}"
|
||||||
|
|
||||||
# GitLab
|
# GitLab
|
||||||
if "gitlab" in domain.lower():
|
if "gitlab" in domain.lower():
|
||||||
@@ -122,7 +125,7 @@ class LinkItem:
|
|||||||
if match:
|
if match:
|
||||||
repo, type_, num = match.groups()
|
repo, type_, num = match.groups()
|
||||||
icon = "#" if type_ == "issues" else "MR!"
|
icon = "#" if type_ == "issues" else "MR!"
|
||||||
return f"{domain} > {repo} {icon}{num}"
|
return f"{domain}{sep}{repo} {icon}{num}"
|
||||||
|
|
||||||
# Generic shortening - keep URL readable
|
# Generic shortening - keep URL readable
|
||||||
if len(url) <= max_len:
|
if len(url) <= max_len:
|
||||||
@@ -136,31 +139,31 @@ class LinkItem:
|
|||||||
|
|
||||||
# Try to fit the full path first
|
# Try to fit the full path first
|
||||||
full_path = "/".join(path_parts)
|
full_path = "/".join(path_parts)
|
||||||
result = f"{domain} > {full_path}"
|
result = f"{domain}{sep}{full_path}"
|
||||||
if len(result) <= max_len:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Keep first segment + last two segments if possible
|
# Keep first segment + last two segments if possible
|
||||||
if len(path_parts) >= 3:
|
if len(path_parts) >= 3:
|
||||||
short_path = f"{path_parts[0]}/.../{path_parts[-2]}/{path_parts[-1]}"
|
short_path = f"{path_parts[0]}/.../{path_parts[-2]}/{path_parts[-1]}"
|
||||||
result = f"{domain} > {short_path}"
|
result = f"{domain}{sep}{short_path}"
|
||||||
if len(result) <= max_len:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Keep first + last segment
|
# Keep first + last segment
|
||||||
if len(path_parts) >= 2:
|
if len(path_parts) >= 2:
|
||||||
short_path = f"{path_parts[0]}/.../{path_parts[-1]}"
|
short_path = f"{path_parts[0]}/.../{path_parts[-1]}"
|
||||||
result = f"{domain} > {short_path}"
|
result = f"{domain}{sep}{short_path}"
|
||||||
if len(result) <= max_len:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Just last segment
|
# Just last segment
|
||||||
result = f"{domain} > .../{path_parts[-1]}"
|
result = f"{domain}{sep}.../{path_parts[-1]}"
|
||||||
if len(result) <= max_len:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Truncate with ellipsis as last resort
|
# Truncate with ellipsis as last resort
|
||||||
result = f"{domain} > {path_parts[-1]}"
|
result = f"{domain}{sep}{path_parts[-1]}"
|
||||||
if len(result) > max_len:
|
if len(result) > max_len:
|
||||||
result = result[: max_len - 3] + "..."
|
result = result[: max_len - 3] + "..."
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ Provides a search input docked to the top of the window with:
|
|||||||
- Live search with 1 second debounce
|
- Live search with 1 second debounce
|
||||||
- Cancel button to restore previous state
|
- Cancel button to restore previous state
|
||||||
- Help button showing Himalaya search syntax
|
- Help button showing Himalaya search syntax
|
||||||
|
- Date picker for date/before/after keywords
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
@@ -17,6 +19,34 @@ from textual.screen import ModalScreen
|
|||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.widgets import Button, Input, Label, Static
|
from textual.widgets import Button, Input, Label, Static
|
||||||
|
from textual.suggester import SuggestFromList
|
||||||
|
|
||||||
|
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||||
|
|
||||||
|
# Himalaya search keywords for autocomplete
|
||||||
|
HIMALAYA_KEYWORDS = [
|
||||||
|
"from ",
|
||||||
|
"to ",
|
||||||
|
"subject ",
|
||||||
|
"body ",
|
||||||
|
"date ",
|
||||||
|
"before ",
|
||||||
|
"after ",
|
||||||
|
"flag ",
|
||||||
|
"not ",
|
||||||
|
"and ",
|
||||||
|
"or ",
|
||||||
|
"order by ",
|
||||||
|
"order by date ",
|
||||||
|
"order by date asc",
|
||||||
|
"order by date desc",
|
||||||
|
"order by from ",
|
||||||
|
"order by to ",
|
||||||
|
"order by subject ",
|
||||||
|
"flag seen",
|
||||||
|
"flag flagged",
|
||||||
|
"not flag seen",
|
||||||
|
]
|
||||||
|
|
||||||
HIMALAYA_SEARCH_HELP = """
|
HIMALAYA_SEARCH_HELP = """
|
||||||
## Himalaya Search Query Syntax
|
## Himalaya Search Query Syntax
|
||||||
@@ -106,6 +136,94 @@ class SearchHelpModal(ModalScreen[None]):
|
|||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|
||||||
|
|
||||||
|
class DatePickerModal(ModalScreen[Optional[date]]):
|
||||||
|
"""Modal with a calendar for selecting a date."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
DatePickerModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePickerModal > Vertical {
|
||||||
|
width: 30;
|
||||||
|
height: auto;
|
||||||
|
border: solid $primary;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePickerModal > Vertical > Label {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePickerModal > Vertical > Horizontal {
|
||||||
|
height: auto;
|
||||||
|
align: center middle;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePickerModal > Vertical > Horizontal > Button {
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "cancel", "Cancel"),
|
||||||
|
Binding("left", "prev_month", "Previous month", show=False),
|
||||||
|
Binding("right", "next_month", "Next month", show=False),
|
||||||
|
Binding("enter", "select_date", "Select date", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, keyword: str = "date") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.keyword = keyword
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical():
|
||||||
|
yield Label(f"Select date for '{self.keyword}':", id="picker-title")
|
||||||
|
yield MonthCalendar(id="date-picker-calendar")
|
||||||
|
with Horizontal():
|
||||||
|
yield Button("Today", variant="default", id="today-btn")
|
||||||
|
yield Button("Select", variant="primary", id="select-btn")
|
||||||
|
yield Button("Cancel", variant="warning", id="cancel-btn")
|
||||||
|
|
||||||
|
def on_month_calendar_date_selected(
|
||||||
|
self, event: MonthCalendar.DateSelected
|
||||||
|
) -> None:
|
||||||
|
"""Handle date selection from calendar click."""
|
||||||
|
self.dismiss(event.date)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "select-btn":
|
||||||
|
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||||
|
self.dismiss(calendar.selected_date)
|
||||||
|
elif event.button.id == "today-btn":
|
||||||
|
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||||
|
today = date.today()
|
||||||
|
calendar.selected_date = today
|
||||||
|
calendar.display_month = today.replace(day=1)
|
||||||
|
calendar.refresh()
|
||||||
|
elif event.button.id == "cancel-btn":
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def action_prev_month(self) -> None:
|
||||||
|
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||||
|
calendar.prev_month()
|
||||||
|
|
||||||
|
def action_next_month(self) -> None:
|
||||||
|
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||||
|
calendar.next_month()
|
||||||
|
|
||||||
|
def action_select_date(self) -> None:
|
||||||
|
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||||
|
self.dismiss(calendar.selected_date)
|
||||||
|
|
||||||
|
|
||||||
class SearchPanel(Widget):
|
class SearchPanel(Widget):
|
||||||
"""Docked search panel with live search capability."""
|
"""Docked search panel with live search capability."""
|
||||||
|
|
||||||
@@ -125,7 +243,7 @@ class SearchPanel(Widget):
|
|||||||
}
|
}
|
||||||
|
|
||||||
SearchPanel > Horizontal {
|
SearchPanel > Horizontal {
|
||||||
height: auto;
|
height: 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align: left middle;
|
align: left middle;
|
||||||
}
|
}
|
||||||
@@ -190,6 +308,7 @@ class SearchPanel(Widget):
|
|||||||
super().__init__(name=name, id=id, classes=classes)
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
self._debounce_timer: Optional[Timer] = None
|
self._debounce_timer: Optional[Timer] = None
|
||||||
self._last_query: str = ""
|
self._last_query: str = ""
|
||||||
|
self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Horizontal():
|
with Horizontal():
|
||||||
@@ -197,11 +316,40 @@ class SearchPanel(Widget):
|
|||||||
yield Input(
|
yield Input(
|
||||||
placeholder="from <name> or subject <text> or body <text>...",
|
placeholder="from <name> or subject <text> or body <text>...",
|
||||||
id="search-input",
|
id="search-input",
|
||||||
|
suggester=SuggestFromList(HIMALAYA_KEYWORDS, case_sensitive=False),
|
||||||
)
|
)
|
||||||
yield Label("", classes="search-status", id="search-status")
|
yield Label("", classes="search-status", id="search-status")
|
||||||
yield Button("?", variant="default", id="help-btn")
|
yield Button("?", variant="default", id="help-btn")
|
||||||
yield Button("Cancel", variant="warning", id="cancel-btn")
|
yield Button("Cancel", variant="warning", id="cancel-btn")
|
||||||
|
|
||||||
|
def _has_suggestion(self) -> bool:
|
||||||
|
"""Check if the search input currently has an autocomplete suggestion."""
|
||||||
|
try:
|
||||||
|
input_widget = self.query_one("#search-input", Input)
|
||||||
|
return bool(input_widget._suggestion and input_widget._cursor_at_end)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _accept_suggestion(self) -> bool:
|
||||||
|
"""Accept the current autocomplete suggestion if present. Returns True if accepted."""
|
||||||
|
try:
|
||||||
|
input_widget = self.query_one("#search-input", Input)
|
||||||
|
if input_widget._suggestion and input_widget._cursor_at_end:
|
||||||
|
input_widget.value = input_widget._suggestion
|
||||||
|
input_widget.cursor_position = len(input_widget.value)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def on_key(self, event) -> None:
|
||||||
|
"""Handle key events to intercept Tab for autocomplete."""
|
||||||
|
if event.key == "tab":
|
||||||
|
# Try to accept suggestion; if successful, prevent default tab behavior
|
||||||
|
if self._accept_suggestion():
|
||||||
|
event.prevent_default()
|
||||||
|
event.stop()
|
||||||
|
|
||||||
def show(self, initial_query: str = "") -> None:
|
def show(self, initial_query: str = "") -> None:
|
||||||
"""Show the search panel and focus the input."""
|
"""Show the search panel and focus the input."""
|
||||||
self.add_class("visible")
|
self.add_class("visible")
|
||||||
@@ -216,6 +364,11 @@ class SearchPanel(Widget):
|
|||||||
self._cancel_debounce()
|
self._cancel_debounce()
|
||||||
self.result_count = -1
|
self.result_count = -1
|
||||||
|
|
||||||
|
def focus_input(self) -> None:
|
||||||
|
"""Focus the search input field."""
|
||||||
|
input_widget = self.query_one("#search-input", Input)
|
||||||
|
input_widget.focus()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_visible(self) -> bool:
|
def is_visible(self) -> bool:
|
||||||
"""Check if the panel is visible."""
|
"""Check if the panel is visible."""
|
||||||
@@ -234,12 +387,44 @@ class SearchPanel(Widget):
|
|||||||
|
|
||||||
def _trigger_search(self) -> None:
|
def _trigger_search(self) -> None:
|
||||||
"""Trigger the actual search after debounce."""
|
"""Trigger the actual search after debounce."""
|
||||||
|
# Don't search if an autocomplete suggestion is visible
|
||||||
|
if self._has_suggestion():
|
||||||
|
return
|
||||||
|
|
||||||
query = self.query_one("#search-input", Input).value.strip()
|
query = self.query_one("#search-input", Input).value.strip()
|
||||||
if query and query != self._last_query:
|
if query and query != self._last_query:
|
||||||
self._last_query = query
|
self._last_query = query
|
||||||
self.is_searching = True
|
self.is_searching = True
|
||||||
self.post_message(self.SearchRequested(query))
|
self.post_message(self.SearchRequested(query))
|
||||||
|
|
||||||
|
def _check_date_keyword(self, value: str) -> Optional[str]:
|
||||||
|
"""Check if the input ends with a date keyword that needs a date picker.
|
||||||
|
|
||||||
|
Returns the keyword (date/before/after) if found, None otherwise.
|
||||||
|
"""
|
||||||
|
value_lower = value.lower()
|
||||||
|
for keyword in ("date ", "before ", "after "):
|
||||||
|
if value_lower.endswith(keyword):
|
||||||
|
return keyword.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _show_date_picker(self, keyword: str) -> None:
|
||||||
|
"""Show the date picker modal for the given keyword."""
|
||||||
|
self._pending_date_keyword = keyword
|
||||||
|
|
||||||
|
def on_date_selected(selected_date: Optional[date]) -> None:
|
||||||
|
if selected_date:
|
||||||
|
# Insert the date into the search input
|
||||||
|
input_widget = self.query_one("#search-input", Input)
|
||||||
|
date_str = selected_date.strftime("%Y-%m-%d")
|
||||||
|
input_widget.value = input_widget.value + date_str
|
||||||
|
input_widget.cursor_position = len(input_widget.value)
|
||||||
|
self._pending_date_keyword = None
|
||||||
|
# Refocus the input
|
||||||
|
self.query_one("#search-input", Input).focus()
|
||||||
|
|
||||||
|
self.app.push_screen(DatePickerModal(keyword), on_date_selected)
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed) -> None:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
"""Handle input changes with debounce."""
|
"""Handle input changes with debounce."""
|
||||||
if event.input.id != "search-input":
|
if event.input.id != "search-input":
|
||||||
@@ -252,6 +437,12 @@ class SearchPanel(Widget):
|
|||||||
if not event.value.strip():
|
if not event.value.strip():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check for date keywords and show picker
|
||||||
|
date_keyword = self._check_date_keyword(event.value)
|
||||||
|
if date_keyword:
|
||||||
|
self._show_date_picker(date_keyword)
|
||||||
|
return
|
||||||
|
|
||||||
# Set up new debounce timer (1 second)
|
# Set up new debounce timer (1 second)
|
||||||
self._debounce_timer = self.set_timer(1.0, self._trigger_search)
|
self._debounce_timer = self.set_timer(1.0, self._trigger_search)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Initialize the screens package
|
# Initialize screens package
|
||||||
from .CreateTask import CreateTaskScreen
|
from .CreateTask import CreateTaskScreen
|
||||||
from .OpenMessage import OpenMessageScreen
|
from .OpenMessage import OpenMessageScreen
|
||||||
from .DocumentViewer import DocumentViewerScreen
|
from .DocumentViewer import DocumentViewerScreen
|
||||||
from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content
|
from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content
|
||||||
from .ConfirmDialog import ConfirmDialog
|
from .ConfirmDialog import ConfirmDialog
|
||||||
from .SearchPanel import SearchPanel, SearchHelpModal
|
from .SearchPanel import SearchPanel, SearchHelpModal
|
||||||
|
from .HelpScreen import HelpScreen
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CreateTaskScreen",
|
"CreateTaskScreen",
|
||||||
@@ -16,4 +17,5 @@ __all__ = [
|
|||||||
"ConfirmDialog",
|
"ConfirmDialog",
|
||||||
"SearchPanel",
|
"SearchPanel",
|
||||||
"SearchHelpModal",
|
"SearchHelpModal",
|
||||||
|
"HelpScreen",
|
||||||
]
|
]
|
||||||
|
|||||||
16
src/mail/utils/__init__.py
Normal file
16
src/mail/utils/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Mail utilities module."""
|
||||||
|
|
||||||
|
from .calendar_parser import (
|
||||||
|
parse_calendar_part,
|
||||||
|
parse_calendar_attachment,
|
||||||
|
is_cancelled_event,
|
||||||
|
is_event_request,
|
||||||
|
ParsedCalendarEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .apple_mail import (
|
||||||
|
open_eml_in_apple_mail,
|
||||||
|
compose_new_email,
|
||||||
|
reply_to_email,
|
||||||
|
forward_email,
|
||||||
|
)
|
||||||
255
src/mail/utils/apple_mail.py
Normal file
255
src/mail/utils/apple_mail.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""Apple Mail integration utilities.
|
||||||
|
|
||||||
|
Provides functions for opening emails in Apple Mail and optionally
|
||||||
|
auto-sending them via AppleScript.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def open_eml_in_apple_mail(
|
||||||
|
email_content: str,
|
||||||
|
auto_send: bool = False,
|
||||||
|
subject: str = "",
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Open an email in Apple Mail, optionally auto-sending it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_content: The raw email content (RFC 5322 format)
|
||||||
|
auto_send: If True, automatically send the email after opening
|
||||||
|
subject: Email subject for logging purposes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create a temp .eml file
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".eml", delete=False, encoding="utf-8"
|
||||||
|
) as tmp:
|
||||||
|
tmp.write(email_content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
logger.info(f"Created temp .eml file: {tmp_path}")
|
||||||
|
|
||||||
|
# Open with Apple Mail
|
||||||
|
result = subprocess.run(
|
||||||
|
["open", "-a", "Mail", tmp_path], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Failed to open Mail: {result.stderr}")
|
||||||
|
return False, f"Failed to open Mail: {result.stderr}"
|
||||||
|
|
||||||
|
if auto_send:
|
||||||
|
# Wait for Mail to open the message
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
# Use AppleScript to send the frontmost message
|
||||||
|
success, message = _applescript_send_frontmost_message()
|
||||||
|
if success:
|
||||||
|
logger.info(f"Auto-sent email: {subject}")
|
||||||
|
# Clean up temp file after sending
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True, "Email sent successfully"
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Auto-send failed, email opened for manual sending: {message}"
|
||||||
|
)
|
||||||
|
return True, f"Email opened (auto-send failed: {message})"
|
||||||
|
else:
|
||||||
|
logger.info(f"Opened email in Mail for manual sending: {subject}")
|
||||||
|
return True, "Email opened in Mail - please send manually"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error opening email in Apple Mail: {e}", exc_info=True)
|
||||||
|
return False, f"Error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _applescript_send_frontmost_message() -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Use AppleScript to send the frontmost message in Apple Mail.
|
||||||
|
|
||||||
|
When an .eml file is opened, Mail shows it as a "view" not a compose window.
|
||||||
|
We need to use Message > Send Again to convert it to a compose window,
|
||||||
|
then send it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
# AppleScript to:
|
||||||
|
# 1. Activate Mail
|
||||||
|
# 2. Use "Send Again" menu item to convert viewed message to compose
|
||||||
|
# 3. Send the message with Cmd+Shift+D
|
||||||
|
applescript = """
|
||||||
|
tell application "Mail"
|
||||||
|
activate
|
||||||
|
delay 0.3
|
||||||
|
end tell
|
||||||
|
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "Mail"
|
||||||
|
-- First, trigger "Send Again" from Message menu to convert to compose window
|
||||||
|
-- Menu: Message > Send Again (Cmd+Shift+D also works for this in some contexts)
|
||||||
|
try
|
||||||
|
click menu item "Send Again" of menu "Message" of menu bar 1
|
||||||
|
delay 0.5
|
||||||
|
on error
|
||||||
|
-- If Send Again fails, window might already be a compose window
|
||||||
|
end try
|
||||||
|
|
||||||
|
-- Now send the message with Cmd+Shift+D
|
||||||
|
keystroke "d" using {command down, shift down}
|
||||||
|
delay 0.3
|
||||||
|
|
||||||
|
return "sent"
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["osascript", "-e", applescript], capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
output = result.stdout.strip()
|
||||||
|
if output == "sent":
|
||||||
|
return True, "Message sent"
|
||||||
|
else:
|
||||||
|
return False, output
|
||||||
|
else:
|
||||||
|
return False, result.stderr.strip()
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "AppleScript timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_new_email(
|
||||||
|
to: str = "",
|
||||||
|
subject: str = "",
|
||||||
|
body: str = "",
|
||||||
|
auto_send: bool = False,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Open a new compose window in Apple Mail using mailto: URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to: Recipient email address
|
||||||
|
subject: Email subject
|
||||||
|
body: Email body text
|
||||||
|
auto_send: Ignored - no AppleScript automation for compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build mailto: URL
|
||||||
|
params = {}
|
||||||
|
if subject:
|
||||||
|
params["subject"] = subject
|
||||||
|
if body:
|
||||||
|
params["body"] = body
|
||||||
|
|
||||||
|
query_string = urllib.parse.urlencode(params)
|
||||||
|
mailto_url = f"mailto:{to}"
|
||||||
|
if query_string:
|
||||||
|
mailto_url += f"?{query_string}"
|
||||||
|
|
||||||
|
# Open mailto: URL - this will open the default mail client
|
||||||
|
result = subprocess.run(["open", mailto_url], capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Failed to open mailto: {result.stderr}")
|
||||||
|
return False, f"Failed to open compose: {result.stderr}"
|
||||||
|
|
||||||
|
return True, "Compose window opened"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error composing email: {e}", exc_info=True)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def reply_to_email(
|
||||||
|
original_message_path: str,
|
||||||
|
reply_all: bool = False,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Open an email in Apple Mail for the user to manually reply.
|
||||||
|
|
||||||
|
This just opens the .eml file in Mail. The user can then use
|
||||||
|
Mail's Reply button (Cmd+R) or Reply All (Cmd+Shift+R) themselves.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_message_path: Path to the original .eml file
|
||||||
|
reply_all: Ignored - user will manually choose reply type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Just open the message in Mail - no AppleScript automation
|
||||||
|
result = subprocess.run(
|
||||||
|
["open", "-a", "Mail", original_message_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, f"Failed to open message: {result.stderr}"
|
||||||
|
|
||||||
|
reply_type = "Reply All" if reply_all else "Reply"
|
||||||
|
return (
|
||||||
|
True,
|
||||||
|
f"Message opened - use {reply_type} (Cmd+{'Shift+' if reply_all else ''}R)",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error opening message for reply: {e}", exc_info=True)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def forward_email(original_message_path: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Open an email in Apple Mail for the user to manually forward.
|
||||||
|
|
||||||
|
This just opens the .eml file in Mail. The user can then use
|
||||||
|
Mail's Forward button (Cmd+Shift+F) themselves.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_message_path: Path to the original .eml file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Just open the message in Mail - no AppleScript automation
|
||||||
|
result = subprocess.run(
|
||||||
|
["open", "-a", "Mail", original_message_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, f"Failed to open message: {result.stderr}"
|
||||||
|
|
||||||
|
return True, "Message opened - use Forward (Cmd+Shift+F)"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error opening message for forward: {e}", exc_info=True)
|
||||||
|
return False, str(e)
|
||||||
427
src/mail/utils/calendar_parser.py
Normal file
427
src/mail/utils/calendar_parser.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""Calendar ICS file parser utilities."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from typing import Optional, List
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
try:
|
||||||
|
from icalendar import Calendar
|
||||||
|
except ImportError:
|
||||||
|
Calendar = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@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[datetime] = None
|
||||||
|
end: Optional[datetime] = None
|
||||||
|
all_day: bool = False
|
||||||
|
|
||||||
|
# Calendar method (REQUEST, CANCEL, REPLY, etc.)
|
||||||
|
method: Optional[str] = None
|
||||||
|
|
||||||
|
# Organizer
|
||||||
|
organizer_name: Optional[str] = None
|
||||||
|
organizer_email: Optional[str] = None
|
||||||
|
|
||||||
|
# Attendees
|
||||||
|
attendees: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Status (CONFIRMED, TENTATIVE, CANCELLED)
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
# UID for matching with Graph API
|
||||||
|
uid: Optional[str] = None
|
||||||
|
|
||||||
|
# Sequence number for iTIP REPLY
|
||||||
|
sequence: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Handle bytes input
|
||||||
|
if isinstance(ics_content, bytes):
|
||||||
|
ics_content = ics_content.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
calendar = Calendar.from_ical(ics_content)
|
||||||
|
|
||||||
|
# METHOD is a calendar-level property, not event-level
|
||||||
|
method = str(calendar.get("method", "")).upper() or None
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Extract sequence number (defaults to 0)
|
||||||
|
sequence = 0
|
||||||
|
seq_val = event.get("sequence")
|
||||||
|
if seq_val is not None:
|
||||||
|
try:
|
||||||
|
sequence = int(seq_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
sequence = 0
|
||||||
|
|
||||||
|
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,
|
||||||
|
sequence=sequence,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error parsing calendar ICS: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_mime_text(raw_message: str) -> str:
|
||||||
|
"""Decode base64 text parts from MIME message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_message: Raw MIME message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded text content
|
||||||
|
"""
|
||||||
|
decoded_parts = []
|
||||||
|
|
||||||
|
# Find and decode base64 text parts
|
||||||
|
b64_pattern = re.compile(
|
||||||
|
r"Content-Type:\s*text/(?:plain|html)[^\n]*\n"
|
||||||
|
r"(?:[^\n]+\n)*?"
|
||||||
|
r"Content-Transfer-Encoding:\s*base64[^\n]*\n"
|
||||||
|
r"(?:[^\n]+\n)*?"
|
||||||
|
r"\n"
|
||||||
|
r"([A-Za-z0-9+/=\s]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
for match in b64_pattern.finditer(raw_message):
|
||||||
|
try:
|
||||||
|
b64_content = (
|
||||||
|
match.group(1).replace("\n", "").replace("\r", "").replace(" ", "")
|
||||||
|
)
|
||||||
|
decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace")
|
||||||
|
decoded_parts.append(decoded)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "\n".join(decoded_parts) if decoded_parts else raw_message
|
||||||
|
|
||||||
|
|
||||||
|
def extract_teams_meeting_info(raw_message: str) -> Optional[ParsedCalendarEvent]:
|
||||||
|
"""Extract Teams meeting info from email body when no ICS is present.
|
||||||
|
|
||||||
|
This handles emails that contain Teams meeting details in the body
|
||||||
|
but don't have an ICS calendar attachment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_message: Full raw email in EML/MIME format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ParsedCalendarEvent with Teams meeting info, or None if not a Teams meeting
|
||||||
|
"""
|
||||||
|
# Decode the message content
|
||||||
|
content = _decode_mime_text(raw_message)
|
||||||
|
content_lower = content.lower()
|
||||||
|
|
||||||
|
# Check if this is a Teams meeting email
|
||||||
|
if (
|
||||||
|
"microsoft teams" not in content_lower
|
||||||
|
and "join the meeting" not in content_lower
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract Teams meeting URL
|
||||||
|
teams_url_pattern = re.compile(
|
||||||
|
r"https://teams\.microsoft\.com/l/meetup-join/[^\s<>\"']+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
teams_url_match = teams_url_pattern.search(content)
|
||||||
|
teams_url = teams_url_match.group(0) if teams_url_match else None
|
||||||
|
|
||||||
|
# Extract meeting ID
|
||||||
|
meeting_id_pattern = re.compile(r"Meeting ID:\s*([\d\s]+)", re.IGNORECASE)
|
||||||
|
meeting_id_match = meeting_id_pattern.search(content)
|
||||||
|
meeting_id = meeting_id_match.group(1).strip() if meeting_id_match else None
|
||||||
|
|
||||||
|
# Extract subject from email headers
|
||||||
|
subject = None
|
||||||
|
subject_match = re.search(
|
||||||
|
r"^Subject:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE
|
||||||
|
)
|
||||||
|
if subject_match:
|
||||||
|
subject = subject_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract organizer from From header
|
||||||
|
organizer_email = None
|
||||||
|
organizer_name = None
|
||||||
|
from_match = re.search(r"^From:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE)
|
||||||
|
if from_match:
|
||||||
|
from_value = from_match.group(1).strip()
|
||||||
|
# Parse "Name <email>" format
|
||||||
|
email_match = re.search(r"<([^>]+)>", from_value)
|
||||||
|
if email_match:
|
||||||
|
organizer_email = email_match.group(1)
|
||||||
|
organizer_name = from_value.split("<")[0].strip().strip('"')
|
||||||
|
else:
|
||||||
|
organizer_email = from_value
|
||||||
|
|
||||||
|
# Create location string with Teams info
|
||||||
|
location = teams_url if teams_url else "Microsoft Teams Meeting"
|
||||||
|
if meeting_id:
|
||||||
|
location = f"Teams Meeting (ID: {meeting_id})"
|
||||||
|
|
||||||
|
return ParsedCalendarEvent(
|
||||||
|
summary=subject or "Teams Meeting",
|
||||||
|
location=location,
|
||||||
|
description=f"Join: {teams_url}" if teams_url else None,
|
||||||
|
method="TEAMS", # Custom method to indicate this is extracted, not from ICS
|
||||||
|
organizer_name=organizer_name,
|
||||||
|
organizer_email=organizer_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_calendar_from_raw_message(raw_message: str) -> Optional[ParsedCalendarEvent]:
|
||||||
|
"""Extract and parse calendar event from raw email message.
|
||||||
|
|
||||||
|
First tries to extract ICS content from the message. If no ICS is found,
|
||||||
|
falls back to extracting Teams meeting info from the email body.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_message: Full raw email in EML/MIME format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ParsedCalendarEvent if found and parsed, None otherwise
|
||||||
|
"""
|
||||||
|
# First try to extract ICS content
|
||||||
|
ics_content = extract_ics_from_mime(raw_message)
|
||||||
|
if ics_content:
|
||||||
|
event = parse_ics_content(ics_content)
|
||||||
|
if event:
|
||||||
|
return event
|
||||||
|
|
||||||
|
# Fall back to extracting Teams meeting info from body
|
||||||
|
return extract_teams_meeting_info(raw_message)
|
||||||
|
|
||||||
|
|
||||||
|
# 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_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 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)
|
||||||
219
src/mail/widgets/CalendarInvitePanel.py
Normal file
219
src/mail/widgets/CalendarInvitePanel.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""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 or TEAMS)
|
||||||
|
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 self.event.method == "TEAMS":
|
||||||
|
# Teams meeting extracted from email body (no ICS)
|
||||||
|
# Show join button if we have a URL in the description
|
||||||
|
if self.event.description and "Join:" in self.event.description:
|
||||||
|
with Horizontal(classes="action-buttons"):
|
||||||
|
yield Button(
|
||||||
|
"\uf0c1 Join Meeting", # nf-fa-link
|
||||||
|
id="btn-join",
|
||||||
|
variant="primary",
|
||||||
|
)
|
||||||
|
yield Static(
|
||||||
|
"[dim]Teams meeting - no calendar invite attached[/dim]",
|
||||||
|
classes="event-detail",
|
||||||
|
)
|
||||||
|
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 == "TEAMS":
|
||||||
|
return "TEAMS", "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))
|
||||||
|
elif button_id == "btn-join":
|
||||||
|
# Open Teams meeting URL
|
||||||
|
if self.event.description and "Join:" in self.event.description:
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
url_match = re.search(
|
||||||
|
r"Join:\s*(https://[^\s]+)", self.event.description
|
||||||
|
)
|
||||||
|
if url_match:
|
||||||
|
url = url_match.group(1)
|
||||||
|
subprocess.run(["open", url], capture_output=True)
|
||||||
|
self.app.notify("Opening Teams meeting...", severity="information")
|
||||||
@@ -6,10 +6,23 @@ from textual.widgets import Static, Markdown, Label
|
|||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
from src.services.himalaya import client as himalaya_client
|
from src.services.himalaya import client as himalaya_client
|
||||||
from src.mail.config import get_config
|
from src.mail.config import get_config
|
||||||
from src.mail.screens.LinkPanel import extract_links_from_content, LinkItem
|
from src.mail.screens.LinkPanel import (
|
||||||
|
extract_links_from_content,
|
||||||
|
LinkItem,
|
||||||
|
LinkItem as LinkItemClass,
|
||||||
|
)
|
||||||
|
from src.mail.notification_compressor import create_compressor
|
||||||
|
from src.mail.notification_detector import NotificationType, is_calendar_email
|
||||||
|
from src.mail.utils.calendar_parser import (
|
||||||
|
ParsedCalendarEvent,
|
||||||
|
parse_calendar_from_raw_message,
|
||||||
|
parse_ics_content,
|
||||||
|
)
|
||||||
|
from src.mail.widgets.CalendarInvitePanel import CalendarInvitePanel
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, List
|
from typing import Literal, List, Dict, Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -18,27 +31,240 @@ import sys
|
|||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
class EnvelopeHeader(Vertical):
|
def compress_urls_in_content(content: str, max_url_len: int = 50) -> str:
|
||||||
|
"""Compress long URLs in markdown/text content for better readability.
|
||||||
|
|
||||||
|
Replaces long URLs with shortened versions using the same algorithm
|
||||||
|
as LinkPanel._shorten_url. Preserves markdown link syntax.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The markdown/text content to process
|
||||||
|
max_url_len: Maximum length for displayed URLs (default 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Content with compressed URLs
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Pattern for markdown links: [text](url)
|
||||||
|
def replace_md_link(match):
|
||||||
|
anchor_text = match.group(1)
|
||||||
|
url = match.group(2)
|
||||||
|
|
||||||
|
# Don't compress if URL is already short
|
||||||
|
if len(url) <= max_url_len:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
# Use LinkItem's shortening algorithm
|
||||||
|
short_url = LinkItemClass._shorten_url(
|
||||||
|
url,
|
||||||
|
urlparse(url).netloc.replace("www.", ""),
|
||||||
|
urlparse(url).path,
|
||||||
|
max_url_len,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep original anchor text, but if it's the same as URL, use short version
|
||||||
|
if anchor_text == url or anchor_text.startswith("http"):
|
||||||
|
return f"[\uf0c1 {short_url}]({url})"
|
||||||
|
else:
|
||||||
|
return match.group(0) # Keep original if anchor text is meaningful
|
||||||
|
|
||||||
|
# Pattern for bare URLs (not inside markdown links)
|
||||||
|
def replace_bare_url(match):
|
||||||
|
url = match.group(0)
|
||||||
|
|
||||||
|
# Don't compress if URL is already short
|
||||||
|
if len(url) <= max_url_len:
|
||||||
|
return url
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
short_url = LinkItemClass._shorten_url(
|
||||||
|
url, parsed.netloc.replace("www.", ""), parsed.path, max_url_len
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return as markdown link with icon
|
||||||
|
return f"[\uf0c1 {short_url}]({url})"
|
||||||
|
|
||||||
|
# First, process markdown links
|
||||||
|
md_link_pattern = r"\[([^\]]+)\]\((https?://[^)]+)\)"
|
||||||
|
content = re.sub(md_link_pattern, replace_md_link, content)
|
||||||
|
|
||||||
|
# Then process bare URLs that aren't already in markdown links
|
||||||
|
# This regex matches URLs not preceded by ]( which would indicate markdown link
|
||||||
|
bare_url_pattern = r'(?<!\]\()https?://[^\s<>"\'\)]+[^\s<>"\'\.\,\)\]]'
|
||||||
|
|
||||||
|
# Use a more careful approach to avoid double-processing
|
||||||
|
# Split content, process bare URLs, rejoin
|
||||||
|
result = []
|
||||||
|
last_end = 0
|
||||||
|
|
||||||
|
for match in re.finditer(bare_url_pattern, content):
|
||||||
|
# Check if this URL is inside a markdown link (preceded by "](")
|
||||||
|
prefix_start = max(0, match.start() - 2)
|
||||||
|
prefix = content[prefix_start : match.start()]
|
||||||
|
if prefix.endswith("]("):
|
||||||
|
continue # Skip URLs that are already markdown link targets
|
||||||
|
|
||||||
|
result.append(content[last_end : match.start()])
|
||||||
|
result.append(replace_bare_url(match))
|
||||||
|
last_end = match.end()
|
||||||
|
|
||||||
|
result.append(content[last_end:])
|
||||||
|
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvelopeHeader(ScrollableContainer):
|
||||||
|
"""Email envelope header with compressible To/CC fields.
|
||||||
|
|
||||||
|
Scrollable when in full-headers mode to handle long recipient lists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Maximum recipients to show before truncating
|
||||||
|
MAX_RECIPIENTS_SHOWN = 2
|
||||||
|
# Show full headers when toggled
|
||||||
|
show_full_headers: bool = False
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.subject_label = Label("")
|
self.subject_label = Label("", id="header_subject")
|
||||||
self.from_label = Label("")
|
self.from_label = Label("", id="header_from")
|
||||||
self.to_label = Label("")
|
self.to_label = Label("", id="header_to")
|
||||||
self.date_label = Label("")
|
self.date_label = Label("", id="header_date")
|
||||||
self.cc_label = Label("")
|
self.cc_label = Label("", id="header_cc")
|
||||||
|
# Store full values for toggle
|
||||||
|
self._full_subject = ""
|
||||||
|
self._full_to = ""
|
||||||
|
self._full_cc = ""
|
||||||
|
self._full_from = ""
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
self.styles.height = "auto"
|
|
||||||
self.mount(self.subject_label)
|
self.mount(self.subject_label)
|
||||||
self.mount(self.from_label)
|
self.mount(self.from_label)
|
||||||
self.mount(self.to_label)
|
self.mount(self.to_label)
|
||||||
self.mount(self.cc_label)
|
self.mount(self.cc_label)
|
||||||
self.mount(self.date_label)
|
self.mount(self.date_label)
|
||||||
|
# Add bottom margin to subject for visual separation from metadata
|
||||||
|
self.subject_label.styles.margin = (0, 0, 1, 0)
|
||||||
|
# Hide CC label by default (shown when CC is present)
|
||||||
|
self.cc_label.styles.display = "none"
|
||||||
|
# Set initial placeholder content
|
||||||
|
self.subject_label.update("[dim]Select a message to view[/dim]")
|
||||||
|
self.from_label.update("[b]From:[/b] -")
|
||||||
|
self.to_label.update("[b]To:[/b] -")
|
||||||
|
self.date_label.update("[b]Date:[/b] -")
|
||||||
|
|
||||||
|
def _compress_recipients(self, recipients_str: str, max_shown: int = 2) -> str:
|
||||||
|
"""Compress a list of recipients to a single line with truncation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipients_str: Comma-separated list of recipients
|
||||||
|
max_shown: Maximum number of recipients to show
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compressed string like "Alice, Bob... (+15 more)"
|
||||||
|
"""
|
||||||
|
if not recipients_str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Split by comma, handling "Name <email>" format
|
||||||
|
# Use regex to split on ", " only when not inside < >
|
||||||
|
parts = []
|
||||||
|
current = ""
|
||||||
|
in_angle = False
|
||||||
|
for char in recipients_str:
|
||||||
|
if char == "<":
|
||||||
|
in_angle = True
|
||||||
|
elif char == ">":
|
||||||
|
in_angle = False
|
||||||
|
elif char == "," and not in_angle:
|
||||||
|
if current.strip():
|
||||||
|
parts.append(current.strip())
|
||||||
|
current = ""
|
||||||
|
continue
|
||||||
|
current += char
|
||||||
|
if current.strip():
|
||||||
|
parts.append(current.strip())
|
||||||
|
|
||||||
|
total = len(parts)
|
||||||
|
|
||||||
|
if total <= max_shown:
|
||||||
|
return recipients_str
|
||||||
|
|
||||||
|
# Extract short names from first few recipients
|
||||||
|
short_names = []
|
||||||
|
for part in parts[:max_shown]:
|
||||||
|
# Handle "Last, First <email>" or just "email@example.com"
|
||||||
|
if "<" in part:
|
||||||
|
name = part.split("<")[0].strip()
|
||||||
|
if name:
|
||||||
|
# Get first name for brevity (handle "Last, First" format)
|
||||||
|
if "," in name:
|
||||||
|
# "Last, First" -> "First"
|
||||||
|
name_parts = name.split(",")
|
||||||
|
if len(name_parts) >= 2:
|
||||||
|
name = name_parts[1].strip().split()[0]
|
||||||
|
else:
|
||||||
|
name = name_parts[0].strip()
|
||||||
|
else:
|
||||||
|
# "First Last" -> "First"
|
||||||
|
name = name.split()[0]
|
||||||
|
short_names.append(name)
|
||||||
|
else:
|
||||||
|
# No name, use email local part
|
||||||
|
email = part.split("<")[1].rstrip(">").split("@")[0]
|
||||||
|
short_names.append(email)
|
||||||
|
else:
|
||||||
|
# Just email address
|
||||||
|
short_names.append(part.split("@")[0])
|
||||||
|
|
||||||
|
remaining = total - max_shown
|
||||||
|
return f"{', '.join(short_names)}... (+{remaining} more)"
|
||||||
|
|
||||||
|
def toggle_full_headers(self) -> None:
|
||||||
|
"""Toggle between compressed and full header view."""
|
||||||
|
self.show_full_headers = not self.show_full_headers
|
||||||
|
# Update CSS class for styling
|
||||||
|
if self.show_full_headers:
|
||||||
|
self.add_class("full-headers")
|
||||||
|
else:
|
||||||
|
self.remove_class("full-headers")
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
|
def _refresh_display(self) -> None:
|
||||||
|
"""Refresh the display based on current mode."""
|
||||||
|
if self.show_full_headers:
|
||||||
|
# Full view - show complete text
|
||||||
|
self.subject_label.update(
|
||||||
|
f"[b bright_white]{self._full_subject}[/b bright_white]"
|
||||||
|
)
|
||||||
|
self.from_label.update(f"[b]From:[/b] {self._full_from}")
|
||||||
|
self.to_label.update(f"[b]To:[/b] {self._full_to}")
|
||||||
|
if self._full_cc:
|
||||||
|
self.cc_label.update(f"[b]CC:[/b] {self._full_cc}")
|
||||||
|
self.cc_label.styles.display = "block"
|
||||||
|
else:
|
||||||
|
# Compressed view - truncate for single line display
|
||||||
|
self.subject_label.update(
|
||||||
|
f"[b bright_white]{self._full_subject}[/b bright_white]"
|
||||||
|
)
|
||||||
|
self.from_label.update(
|
||||||
|
f"[b]From:[/b] {self._compress_recipients(self._full_from, max_shown=1)}"
|
||||||
|
)
|
||||||
|
self.to_label.update(
|
||||||
|
f"[b]To:[/b] {self._compress_recipients(self._full_to)}"
|
||||||
|
)
|
||||||
|
if self._full_cc:
|
||||||
|
self.cc_label.update(
|
||||||
|
f"[b]CC:[/b] {self._compress_recipients(self._full_cc)}"
|
||||||
|
)
|
||||||
|
self.cc_label.styles.display = "block"
|
||||||
|
|
||||||
def update(self, subject, from_, to, date, cc=None):
|
def update(self, subject, from_, to, date, cc=None):
|
||||||
self.subject_label.update(f"[b]Subject:[/b] {subject}")
|
# Store full values
|
||||||
self.from_label.update(f"[b]From:[/b] {from_}")
|
self._full_subject = subject or ""
|
||||||
self.to_label.update(f"[b]To:[/b] {to}")
|
self._full_from = from_ or ""
|
||||||
|
self._full_to = to or ""
|
||||||
|
self._full_cc = cc or ""
|
||||||
|
|
||||||
# Format the date for better readability
|
# Format the date for better readability
|
||||||
if date:
|
if date:
|
||||||
@@ -53,44 +279,62 @@ class EnvelopeHeader(Vertical):
|
|||||||
else:
|
else:
|
||||||
self.date_label.update("[b]Date:[/b] Unknown")
|
self.date_label.update("[b]Date:[/b] Unknown")
|
||||||
|
|
||||||
if cc:
|
if not cc:
|
||||||
self.cc_label.update(f"[b]CC:[/b] {cc}")
|
|
||||||
self.cc_label.styles.display = "block"
|
|
||||||
else:
|
|
||||||
self.cc_label.styles.display = "none"
|
self.cc_label.styles.display = "none"
|
||||||
|
|
||||||
|
# Apply current display mode
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
class ContentContainer(ScrollableContainer):
|
|
||||||
"""Container for displaying email content with toggleable view modes."""
|
class ContentContainer(Vertical):
|
||||||
|
"""Container for displaying email content with toggleable view modes.
|
||||||
|
|
||||||
|
Uses a Vertical layout with:
|
||||||
|
- EnvelopeHeader (fixed at top, non-scrolling)
|
||||||
|
- ScrollableContainer for the message content (scrollable)
|
||||||
|
"""
|
||||||
|
|
||||||
can_focus = True
|
can_focus = True
|
||||||
|
|
||||||
# Reactive to track view mode and update UI
|
# Reactive to track view mode and update UI
|
||||||
current_mode: reactive[Literal["markdown", "html"]] = reactive("markdown")
|
current_mode: reactive[Literal["markdown", "html"]] = reactive("markdown")
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = []
|
||||||
Binding("m", "toggle_mode", "Toggle View Mode"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.md = MarkItDown()
|
self.md = MarkItDown()
|
||||||
self.header = EnvelopeHeader(id="envelope_header")
|
self.header = EnvelopeHeader(id="envelope_header")
|
||||||
|
self.scroll_container = ScrollableContainer(id="content_scroll")
|
||||||
self.content = Markdown("", id="markdown_content")
|
self.content = Markdown("", id="markdown_content")
|
||||||
self.html_content = Static("", id="html_content", markup=False)
|
self.html_content = Static("", id="html_content", markup=False)
|
||||||
self.current_content = None
|
self.current_content = None
|
||||||
|
self.current_raw_content = None # Store original uncompressed content
|
||||||
self.current_message_id = None
|
self.current_message_id = None
|
||||||
self.current_folder: str | None = None
|
self.current_folder: str | None = None
|
||||||
self.current_account: str | None = None
|
self.current_account: str | None = None
|
||||||
self.content_worker = None
|
self.content_worker = None
|
||||||
|
self.current_envelope: Optional[Dict[str, Any]] = None
|
||||||
|
self.current_notification_type: Optional[NotificationType] = None
|
||||||
|
self.is_compressed_view: bool = False
|
||||||
|
self.compression_enabled: bool = True # Toggle for compression on/off
|
||||||
|
|
||||||
# Load default view mode from config
|
# 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()
|
config = get_config()
|
||||||
self.current_mode = config.content_display.default_view_mode
|
self.current_mode = config.content_display.default_view_mode
|
||||||
|
self.compressor = create_compressor(
|
||||||
|
config.content_display.notification_compression_mode
|
||||||
|
)
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
yield self.content
|
yield self.header
|
||||||
yield self.html_content
|
with self.scroll_container:
|
||||||
|
yield self.content
|
||||||
|
yield self.html_content
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
# Set initial display based on config default
|
# Set initial display based on config default
|
||||||
@@ -113,22 +357,65 @@ class ContentContainer(ScrollableContainer):
|
|||||||
|
|
||||||
def _update_mode_indicator(self) -> None:
|
def _update_mode_indicator(self) -> None:
|
||||||
"""Update the border subtitle to show current mode."""
|
"""Update the border subtitle to show current mode."""
|
||||||
mode_label = "Markdown" if self.current_mode == "markdown" else "HTML/Text"
|
if self.current_mode == "markdown":
|
||||||
mode_icon = (
|
if self.is_compressed_view:
|
||||||
"\ue73e" if self.current_mode == "markdown" else "\uf121"
|
mode_label = "Compressed"
|
||||||
) # nf-md-language_markdown / nf-fa-code
|
mode_icon = "\uf066" # nf-fa-compress
|
||||||
|
else:
|
||||||
|
mode_label = "Markdown"
|
||||||
|
mode_icon = "\ue73e" # nf-md-language_markdown
|
||||||
|
else:
|
||||||
|
mode_label = "HTML/Text"
|
||||||
|
mode_icon = "\uf121" # nf-fa-code
|
||||||
self.border_subtitle = f"{mode_icon} {mode_label}"
|
self.border_subtitle = f"{mode_icon} {mode_label}"
|
||||||
|
|
||||||
async def action_toggle_mode(self):
|
async def action_toggle_mode(self):
|
||||||
"""Toggle between markdown and HTML viewing modes."""
|
"""Toggle between viewing modes.
|
||||||
if self.current_mode == "html":
|
|
||||||
self.current_mode = "markdown"
|
|
||||||
else:
|
|
||||||
self.current_mode = "html"
|
|
||||||
|
|
||||||
# Reload the content if we have a message ID
|
For notification emails: cycles compressed → full markdown → HTML → compressed
|
||||||
if self.current_message_id:
|
For regular emails: cycles between markdown and HTML.
|
||||||
self.display_content(self.current_message_id)
|
"""
|
||||||
|
# Check if this is a compressible notification email
|
||||||
|
is_notification = (
|
||||||
|
self.compressor.mode != "off"
|
||||||
|
and self.current_envelope
|
||||||
|
and self.compressor.should_compress(self.current_envelope)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_notification:
|
||||||
|
# Three-state cycle for notifications: compressed → full → html → compressed
|
||||||
|
if self.current_mode == "markdown" and self.compression_enabled:
|
||||||
|
# Currently compressed markdown → show full markdown
|
||||||
|
self.compression_enabled = False
|
||||||
|
# Don't change mode, just re-display with compression off
|
||||||
|
elif self.current_mode == "markdown" and not self.compression_enabled:
|
||||||
|
# Currently full markdown → switch to HTML
|
||||||
|
self.current_mode = "html"
|
||||||
|
else:
|
||||||
|
# Currently HTML → back to compressed markdown
|
||||||
|
self.current_mode = "markdown"
|
||||||
|
self.compression_enabled = True
|
||||||
|
else:
|
||||||
|
# Simple two-state toggle for regular emails
|
||||||
|
if self.current_mode == "html":
|
||||||
|
self.current_mode = "markdown"
|
||||||
|
else:
|
||||||
|
self.current_mode = "html"
|
||||||
|
|
||||||
|
# Re-display content with new mode/compression settings
|
||||||
|
if self.current_raw_content is not None:
|
||||||
|
# Use cached raw content instead of re-fetching
|
||||||
|
self._update_content(self.current_raw_content)
|
||||||
|
self._apply_view_mode()
|
||||||
|
self._update_mode_indicator()
|
||||||
|
elif self.current_message_id:
|
||||||
|
# Fall back to re-fetching if no cached content
|
||||||
|
self.display_content(
|
||||||
|
self.current_message_id,
|
||||||
|
folder=self.current_folder,
|
||||||
|
account=self.current_account,
|
||||||
|
envelope=self.current_envelope,
|
||||||
|
)
|
||||||
|
|
||||||
def update_header(self, subject, from_, to, date, cc=None):
|
def update_header(self, subject, from_, to, date, cc=None):
|
||||||
self.header.update(subject, from_, to, date, cc)
|
self.header.update(subject, from_, to, date, cc)
|
||||||
@@ -140,6 +427,49 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.notify("No message ID provided.")
|
self.notify("No message ID provided.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Always fetch raw message first to check for calendar data
|
||||||
|
# This allows us to detect calendar invites even in forwarded emails
|
||||||
|
raw_content, raw_success = await himalaya_client.get_raw_message(
|
||||||
|
message_id, folder=self.current_folder, account=self.current_account
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if this is a calendar email (using envelope + content for better detection)
|
||||||
|
is_calendar = self.current_envelope and is_calendar_email(
|
||||||
|
self.current_envelope, content=raw_content if raw_success else None
|
||||||
|
)
|
||||||
|
|
||||||
|
calendar_event = None
|
||||||
|
if is_calendar and raw_success and raw_content:
|
||||||
|
calendar_event = parse_calendar_from_raw_message(raw_content)
|
||||||
|
|
||||||
|
# If attachments were skipped during sync, try to fetch ICS from Graph API
|
||||||
|
# This handles both:
|
||||||
|
# 1. TEAMS method (Teams meeting detected but no ICS in message)
|
||||||
|
# 2. No calendar_event parsed but we detected calendar email patterns
|
||||||
|
if "X-Attachments-Skipped" in raw_content:
|
||||||
|
should_fetch_ics = (
|
||||||
|
# No calendar event parsed at all
|
||||||
|
calendar_event is None
|
||||||
|
# Or we got a TEAMS fallback (no real ICS found)
|
||||||
|
or calendar_event.method == "TEAMS"
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_fetch_ics:
|
||||||
|
# Try to fetch ICS from Graph API
|
||||||
|
ics_content = await self._fetch_ics_from_graph(raw_content)
|
||||||
|
if ics_content:
|
||||||
|
# Re-parse with the actual ICS
|
||||||
|
real_event = parse_ics_content(ics_content)
|
||||||
|
if real_event:
|
||||||
|
calendar_event = real_event
|
||||||
|
|
||||||
|
if calendar_event:
|
||||||
|
self._show_calendar_panel(calendar_event)
|
||||||
|
else:
|
||||||
|
self._hide_calendar_panel()
|
||||||
|
else:
|
||||||
|
self._hide_calendar_panel()
|
||||||
|
|
||||||
content, success = await himalaya_client.get_message_content(
|
content, success = await himalaya_client.get_message_content(
|
||||||
message_id, folder=self.current_folder, account=self.current_account
|
message_id, folder=self.current_folder, account=self.current_account
|
||||||
)
|
)
|
||||||
@@ -148,11 +478,60 @@ class ContentContainer(ScrollableContainer):
|
|||||||
else:
|
else:
|
||||||
self.notify(f"Failed to fetch content for message ID {message_id}.")
|
self.notify(f"Failed to fetch content for message ID {message_id}.")
|
||||||
|
|
||||||
|
async def _fetch_ics_from_graph(self, raw_content: str) -> str | None:
|
||||||
|
"""Fetch ICS attachment from Graph API using the message ID in headers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_content: Raw MIME content containing Message-ID header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ICS content string if found, None otherwise
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Extract Graph message ID from Message-ID header
|
||||||
|
# Format: Message-ID: \n AAkALg...
|
||||||
|
match = re.search(
|
||||||
|
r"Message-ID:\s*\n?\s*([A-Za-z0-9+/=-]+)",
|
||||||
|
raw_content,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
graph_message_id = match.group(1).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get auth headers
|
||||||
|
from src.services.microsoft_graph.auth import get_access_token
|
||||||
|
from src.services.microsoft_graph.mail import fetch_message_ics_attachment
|
||||||
|
|
||||||
|
# Use Mail.Read scope for reading attachments
|
||||||
|
scopes = ["https://graph.microsoft.com/Mail.Read"]
|
||||||
|
token, _ = get_access_token(scopes)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
ics_content, success = await fetch_message_ics_attachment(
|
||||||
|
graph_message_id, headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and ics_content:
|
||||||
|
return ics_content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error fetching ICS from Graph: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def display_content(
|
def display_content(
|
||||||
self,
|
self,
|
||||||
message_id: int,
|
message_id: int,
|
||||||
folder: str | None = None,
|
folder: str | None = None,
|
||||||
account: str | None = None,
|
account: str | None = None,
|
||||||
|
envelope: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Display the content of a message."""
|
"""Display the content of a message."""
|
||||||
if not message_id:
|
if not message_id:
|
||||||
@@ -161,6 +540,41 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.current_message_id = message_id
|
self.current_message_id = message_id
|
||||||
self.current_folder = folder
|
self.current_folder = folder
|
||||||
self.current_account = account
|
self.current_account = account
|
||||||
|
self.current_envelope = envelope
|
||||||
|
|
||||||
|
# Reset compression state for new message (start with compression enabled)
|
||||||
|
self.compression_enabled = True
|
||||||
|
self.current_raw_content = None
|
||||||
|
|
||||||
|
# Update the header with envelope data
|
||||||
|
if envelope:
|
||||||
|
subject = envelope.get("subject", "")
|
||||||
|
|
||||||
|
# Extract from - can be dict with name/addr or string
|
||||||
|
from_info = envelope.get("from", {})
|
||||||
|
if isinstance(from_info, dict):
|
||||||
|
from_name = from_info.get("name") or ""
|
||||||
|
from_addr = from_info.get("addr") or ""
|
||||||
|
if from_name and from_addr:
|
||||||
|
from_str = f"{from_name} <{from_addr}>"
|
||||||
|
elif from_name:
|
||||||
|
from_str = from_name
|
||||||
|
else:
|
||||||
|
from_str = from_addr
|
||||||
|
else:
|
||||||
|
from_str = str(from_info)
|
||||||
|
|
||||||
|
# Extract to - can be dict, list of dicts, or string
|
||||||
|
to_info = envelope.get("to", {})
|
||||||
|
to_str = self._format_recipients(to_info)
|
||||||
|
|
||||||
|
# Extract cc - can be dict, list of dicts, or string
|
||||||
|
cc_info = envelope.get("cc", {})
|
||||||
|
cc_str = self._format_recipients(cc_info) if cc_info else None
|
||||||
|
|
||||||
|
date = envelope.get("date", "")
|
||||||
|
|
||||||
|
self.header.update(subject, from_str, to_str, date, cc_str)
|
||||||
|
|
||||||
# Immediately show a loading message
|
# Immediately show a loading message
|
||||||
if self.current_mode == "markdown":
|
if self.current_mode == "markdown":
|
||||||
@@ -176,18 +590,275 @@ class ContentContainer(ScrollableContainer):
|
|||||||
format_type = "text" if self.current_mode == "markdown" else "html"
|
format_type = "text" if self.current_mode == "markdown" else "html"
|
||||||
self.content_worker = self.fetch_message_content(message_id, format_type)
|
self.content_worker = self.fetch_message_content(message_id, format_type)
|
||||||
|
|
||||||
|
def _strip_headers_from_content(self, content: str) -> str:
|
||||||
|
"""Strip email headers and multipart MIME noise from content.
|
||||||
|
|
||||||
|
Email content from himalaya may include:
|
||||||
|
1. Headers at the top (From, To, Subject, etc.) - shown in EnvelopeHeader
|
||||||
|
2. Additional full headers after a blank line (Received, etc.)
|
||||||
|
3. MIME multipart boundaries and part headers
|
||||||
|
4. Base64 encoded content (attachments, calendar data)
|
||||||
|
|
||||||
|
This extracts just the readable plain text content.
|
||||||
|
"""
|
||||||
|
lines = content.split("\n")
|
||||||
|
result_lines = []
|
||||||
|
in_base64_block = False
|
||||||
|
in_calendar_block = False
|
||||||
|
in_header_block = True # Start assuming we're in headers
|
||||||
|
|
||||||
|
# Common email header patterns (case insensitive)
|
||||||
|
header_pattern = re.compile(
|
||||||
|
r"^(From|To|Subject|Date|CC|BCC|Reply-To|Message-ID|Received|"
|
||||||
|
r"Content-Type|Content-Transfer-Encoding|Content-Disposition|"
|
||||||
|
r"Content-Language|MIME-Version|Thread-Topic|Thread-Index|"
|
||||||
|
r"Importance|X-Priority|Accept-Language|X-MS-|x-ms-|"
|
||||||
|
r"x-microsoft-|x-forefront-|authentication-results).*:",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Skip MIME boundary lines (--boundary or --boundary--)
|
||||||
|
if stripped.startswith("--") and len(stripped) > 10:
|
||||||
|
in_base64_block = False
|
||||||
|
in_header_block = False # After boundary, might be content
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for Content-Type to detect base64/calendar sections
|
||||||
|
if stripped.lower().startswith("content-type:"):
|
||||||
|
if (
|
||||||
|
"base64" in stripped.lower() or "base64" in lines[i + 1].lower()
|
||||||
|
if i + 1 < len(lines)
|
||||||
|
else False
|
||||||
|
):
|
||||||
|
in_base64_block = True
|
||||||
|
if "text/calendar" in stripped.lower():
|
||||||
|
in_calendar_block = True
|
||||||
|
# Skip this header and any continuation lines
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and (
|
||||||
|
lines[i].startswith(" ") or lines[i].startswith("\t")
|
||||||
|
):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip Content-Transfer-Encoding header
|
||||||
|
if stripped.lower().startswith("content-transfer-encoding:"):
|
||||||
|
if "base64" in stripped.lower():
|
||||||
|
in_base64_block = True
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip email headers (matches header pattern)
|
||||||
|
if header_pattern.match(line):
|
||||||
|
# Skip this header and any continuation lines
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and (
|
||||||
|
lines[i].startswith(" ") or lines[i].startswith("\t")
|
||||||
|
):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Blank line - could be end of headers or part separator
|
||||||
|
if stripped == "":
|
||||||
|
# If we haven't collected any content yet, keep skipping
|
||||||
|
if not result_lines:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
# Otherwise keep the blank line (paragraph separator in body)
|
||||||
|
result_lines.append(line)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect and skip base64 encoded blocks
|
||||||
|
if in_base64_block:
|
||||||
|
# Check if line looks like base64 (long string of base64 chars)
|
||||||
|
if len(stripped) > 20 and re.match(r"^[A-Za-z0-9+/=]+$", stripped):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# End of base64 block
|
||||||
|
in_base64_block = False
|
||||||
|
|
||||||
|
# Skip calendar/ICS content (BEGIN:VCALENDAR to END:VCALENDAR)
|
||||||
|
if stripped.startswith("BEGIN:VCALENDAR"):
|
||||||
|
in_calendar_block = True
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if in_calendar_block:
|
||||||
|
if stripped.startswith("END:VCALENDAR"):
|
||||||
|
in_calendar_block = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# This looks like actual content - add it
|
||||||
|
result_lines.append(line)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return "\n".join(result_lines).strip()
|
||||||
|
|
||||||
|
return "\n".join(result_lines).strip()
|
||||||
|
|
||||||
|
def _format_recipients(self, recipients_info) -> str:
|
||||||
|
"""Format recipients info (dict, list of dicts, or string) to a string."""
|
||||||
|
if not recipients_info:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if isinstance(recipients_info, str):
|
||||||
|
return recipients_info
|
||||||
|
|
||||||
|
if isinstance(recipients_info, dict):
|
||||||
|
# Single recipient
|
||||||
|
name = recipients_info.get("name") or ""
|
||||||
|
addr = recipients_info.get("addr") or ""
|
||||||
|
if name and addr:
|
||||||
|
return f"{name} <{addr}>"
|
||||||
|
elif name:
|
||||||
|
return name
|
||||||
|
else:
|
||||||
|
return addr
|
||||||
|
|
||||||
|
if isinstance(recipients_info, list):
|
||||||
|
# Multiple recipients
|
||||||
|
parts = []
|
||||||
|
for r in recipients_info:
|
||||||
|
if isinstance(r, dict):
|
||||||
|
name = r.get("name") or ""
|
||||||
|
addr = r.get("addr") or ""
|
||||||
|
if name and addr:
|
||||||
|
parts.append(f"{name} <{addr}>")
|
||||||
|
elif name:
|
||||||
|
parts.append(name)
|
||||||
|
elif addr:
|
||||||
|
parts.append(addr)
|
||||||
|
elif isinstance(r, str):
|
||||||
|
parts.append(r)
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
return str(recipients_info)
|
||||||
|
|
||||||
|
def clear_content(self) -> None:
|
||||||
|
"""Clear the message content display."""
|
||||||
|
self.content.update("")
|
||||||
|
self.html_content.update("")
|
||||||
|
self.current_content = None
|
||||||
|
self.current_message_id = None
|
||||||
|
self.border_title = "No message selected"
|
||||||
|
|
||||||
|
def _parse_headers_from_content(self, content: str) -> Dict[str, str]:
|
||||||
|
"""Parse email headers from message content.
|
||||||
|
|
||||||
|
Returns a dict with keys: from, to, subject, date, cc
|
||||||
|
"""
|
||||||
|
headers = {}
|
||||||
|
lines = content.split("\n")
|
||||||
|
current_header = None
|
||||||
|
current_value = ""
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Blank line marks end of headers
|
||||||
|
if line.strip() == "":
|
||||||
|
if current_header:
|
||||||
|
headers[current_header] = current_value.strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for header continuation (line starts with whitespace)
|
||||||
|
if line.startswith(" ") or line.startswith("\t"):
|
||||||
|
if current_header:
|
||||||
|
current_value += " " + line.strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save previous header if any
|
||||||
|
if current_header:
|
||||||
|
headers[current_header] = current_value.strip()
|
||||||
|
|
||||||
|
# Parse new header
|
||||||
|
if ":" in line:
|
||||||
|
header_name, _, value = line.partition(":")
|
||||||
|
header_lower = header_name.lower().strip()
|
||||||
|
if header_lower in ("from", "to", "subject", "date", "cc"):
|
||||||
|
current_header = header_lower
|
||||||
|
current_value = value.strip()
|
||||||
|
else:
|
||||||
|
current_header = None
|
||||||
|
else:
|
||||||
|
# Line doesn't look like a header, we've reached body
|
||||||
|
break
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
def _update_content(self, content: str | None) -> None:
|
def _update_content(self, content: str | None) -> None:
|
||||||
"""Update the content widgets with the fetched content."""
|
"""Update the content widgets with the fetched content."""
|
||||||
if content is None:
|
if content is None:
|
||||||
content = "(No content)"
|
content = "(No content)"
|
||||||
|
|
||||||
# Store the raw content for link extraction
|
# Parse headers from content to update the EnvelopeHeader
|
||||||
|
# (himalaya envelope list doesn't include full To/CC info)
|
||||||
|
parsed_headers = self._parse_headers_from_content(content)
|
||||||
|
if parsed_headers:
|
||||||
|
# Update header with parsed values, falling back to envelope data
|
||||||
|
subject = parsed_headers.get("subject") or (
|
||||||
|
self.current_envelope.get("subject", "")
|
||||||
|
if self.current_envelope
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
from_str = parsed_headers.get("from") or ""
|
||||||
|
to_str = parsed_headers.get("to") or ""
|
||||||
|
date_str = parsed_headers.get("date") or (
|
||||||
|
self.current_envelope.get("date", "") if self.current_envelope else ""
|
||||||
|
)
|
||||||
|
cc_str = parsed_headers.get("cc") or None
|
||||||
|
|
||||||
|
self.header.update(subject, from_str, to_str, date_str, cc_str)
|
||||||
|
|
||||||
|
# Strip headers from content (they're shown in EnvelopeHeader)
|
||||||
|
content = self._strip_headers_from_content(content)
|
||||||
|
|
||||||
|
# Store the raw content for link extraction and for toggle mode
|
||||||
self.current_content = content
|
self.current_content = content
|
||||||
|
self.current_raw_content = content # Keep original for mode toggling
|
||||||
|
|
||||||
|
# Apply notification compression if enabled AND compression toggle is on
|
||||||
|
display_content = content
|
||||||
|
if (
|
||||||
|
self.compressor.mode != "off"
|
||||||
|
and self.current_envelope
|
||||||
|
and self.compression_enabled
|
||||||
|
):
|
||||||
|
compressed_content, notif_type = self.compressor.compress(
|
||||||
|
content, self.current_envelope
|
||||||
|
)
|
||||||
|
self.current_notification_type = notif_type
|
||||||
|
if notif_type is not None:
|
||||||
|
# Only use compressed content if compression was actually applied
|
||||||
|
display_content = compressed_content
|
||||||
|
self.is_compressed_view = True
|
||||||
|
else:
|
||||||
|
self.is_compressed_view = False
|
||||||
|
else:
|
||||||
|
self.current_notification_type = None
|
||||||
|
self.is_compressed_view = False
|
||||||
|
|
||||||
|
# Get URL compression settings from config
|
||||||
|
config = get_config()
|
||||||
|
compress_urls = config.content_display.compress_urls
|
||||||
|
max_url_len = config.content_display.max_url_length
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.current_mode == "markdown":
|
if self.current_mode == "markdown":
|
||||||
# For markdown mode, use the Markdown widget
|
# For markdown mode, use the Markdown widget
|
||||||
self.content.update(content)
|
final_content = display_content
|
||||||
|
if compress_urls and not self.is_compressed_view:
|
||||||
|
# Don't compress URLs in notification summaries (they're already formatted)
|
||||||
|
final_content = compress_urls_in_content(
|
||||||
|
display_content, max_url_len
|
||||||
|
)
|
||||||
|
self.content.update(final_content)
|
||||||
else:
|
else:
|
||||||
# For HTML mode, use the Static widget with markup
|
# For HTML mode, use the Static widget with markup
|
||||||
# First, try to extract the body content if it's HTML
|
# First, try to extract the body content if it's HTML
|
||||||
@@ -222,3 +893,54 @@ class ContentContainer(ScrollableContainer):
|
|||||||
if not self.current_content:
|
if not self.current_content:
|
||||||
return []
|
return []
|
||||||
return extract_links_from_content(self.current_content)
|
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 scrollable content."""
|
||||||
|
# Remove existing panel if any
|
||||||
|
self._hide_calendar_panel()
|
||||||
|
|
||||||
|
# Store the calendar event for RSVP actions
|
||||||
|
self.current_calendar_event = event
|
||||||
|
|
||||||
|
# Create and mount new panel at the beginning of the scroll container
|
||||||
|
# Don't use a fixed ID to avoid DuplicateIds errors when panels are
|
||||||
|
# removed asynchronously
|
||||||
|
self.calendar_panel = CalendarInvitePanel(event)
|
||||||
|
self.scroll_container.mount(self.calendar_panel, before=0)
|
||||||
|
|
||||||
|
def _hide_calendar_panel(self) -> None:
|
||||||
|
"""Hide/remove the calendar invite panel."""
|
||||||
|
self.current_calendar_event = None
|
||||||
|
|
||||||
|
# Remove the panel via instance reference (more reliable than ID query)
|
||||||
|
if self.calendar_panel is not None:
|
||||||
|
try:
|
||||||
|
self.calendar_panel.remove()
|
||||||
|
except Exception:
|
||||||
|
pass # Already removed or not mounted
|
||||||
|
self.calendar_panel = None
|
||||||
|
|
||||||
|
# Also remove any orphaned CalendarInvitePanel widgets
|
||||||
|
try:
|
||||||
|
for panel in self.query(CalendarInvitePanel):
|
||||||
|
panel.remove()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ class EnvelopeListItem(Static):
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeListItem .checkbox {
|
EnvelopeListItem .checkbox {
|
||||||
width: 2;
|
width: 1;
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
EnvelopeListItem .checkbox {
|
|
||||||
width: 2;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,7 +193,12 @@ class DstaskClient(TaskBackend):
|
|||||||
due: Optional[datetime] = None,
|
due: Optional[datetime] = None,
|
||||||
notes: Optional[str] = None,
|
notes: Optional[str] = None,
|
||||||
) -> Task:
|
) -> Task:
|
||||||
"""Create a new task."""
|
"""Create a new task.
|
||||||
|
|
||||||
|
Notes are added using dstask's / syntax during creation, where
|
||||||
|
everything after / becomes the note content. Each word must be
|
||||||
|
a separate argument for this to work.
|
||||||
|
"""
|
||||||
args = ["add", summary]
|
args = ["add", summary]
|
||||||
|
|
||||||
if project:
|
if project:
|
||||||
@@ -210,6 +215,13 @@ class DstaskClient(TaskBackend):
|
|||||||
# dstask uses various date formats
|
# dstask uses various date formats
|
||||||
args.append(f"due:{due.strftime('%Y-%m-%d')}")
|
args.append(f"due:{due.strftime('%Y-%m-%d')}")
|
||||||
|
|
||||||
|
# Add notes using / syntax - each word must be a separate argument
|
||||||
|
# dstask interprets everything after "/" as note content
|
||||||
|
if notes:
|
||||||
|
args.append("/")
|
||||||
|
# Split notes into words to pass as separate arguments
|
||||||
|
args.extend(notes.split())
|
||||||
|
|
||||||
result = self._run_command(args)
|
result = self._run_command(args)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -221,10 +233,6 @@ class DstaskClient(TaskBackend):
|
|||||||
# Find task by summary (best effort)
|
# Find task by summary (best effort)
|
||||||
for task in reversed(tasks):
|
for task in reversed(tasks):
|
||||||
if task.summary == summary:
|
if task.summary == summary:
|
||||||
# Add notes if provided
|
|
||||||
if notes:
|
|
||||||
self._run_command(["note", str(task.id), notes])
|
|
||||||
task.notes = notes
|
|
||||||
return task
|
return task
|
||||||
|
|
||||||
# Return a placeholder if we can't find it
|
# Return a placeholder if we can't find it
|
||||||
|
|||||||
@@ -115,6 +115,47 @@ async def list_folders(
|
|||||||
return [], False
|
return [], False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_folder_count(
|
||||||
|
folder: str,
|
||||||
|
account: Optional[str] = None,
|
||||||
|
) -> Tuple[int, bool]:
|
||||||
|
"""
|
||||||
|
Get the count of messages in a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: The folder to count messages in
|
||||||
|
account: The account to use (defaults to default account)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple containing:
|
||||||
|
- Message count
|
||||||
|
- Success status (True if operation was successful)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use a high limit to get all messages, then count them
|
||||||
|
# This is the most reliable way with himalaya
|
||||||
|
cmd = f"himalaya envelope list -o json -s 9999 -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:
|
||||||
|
envelopes = json.loads(stdout.decode())
|
||||||
|
return len(envelopes), True
|
||||||
|
else:
|
||||||
|
logging.error(f"Error getting folder count: {stderr.decode()}")
|
||||||
|
return 0, False
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Exception during folder count: {e}")
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
message_id: int,
|
message_id: int,
|
||||||
folder: Optional[str] = None,
|
folder: Optional[str] = None,
|
||||||
@@ -245,6 +286,8 @@ async def get_message_content(
|
|||||||
- Success status (True if operation was successful)
|
- Success status (True if operation was successful)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Don't use --no-headers - we parse headers for EnvelopeHeader display
|
||||||
|
# and strip them from the body content ourselves
|
||||||
cmd = f"himalaya message read {message_id}"
|
cmd = f"himalaya message read {message_id}"
|
||||||
if folder:
|
if folder:
|
||||||
cmd += f" -f '{folder}'"
|
cmd += f" -f '{folder}'"
|
||||||
@@ -312,6 +355,49 @@ async def mark_as_read(
|
|||||||
return str(e), False
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_as_unread(
|
||||||
|
message_id: int,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
account: Optional[str] = None,
|
||||||
|
) -> Tuple[Optional[str], bool]:
|
||||||
|
"""
|
||||||
|
Mark a message as unread by removing the 'seen' flag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: The ID of the message to mark as unread
|
||||||
|
folder: The folder containing the message
|
||||||
|
account: The account to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple containing:
|
||||||
|
- Result message or error
|
||||||
|
- Success status (True if operation was successful)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cmd = f"himalaya flag remove seen {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:
|
||||||
|
return stdout.decode().strip() or "Marked as unread", True
|
||||||
|
else:
|
||||||
|
error_msg = stderr.decode().strip()
|
||||||
|
logging.error(f"Error marking message as unread: {error_msg}")
|
||||||
|
return error_msg or "Unknown error", False
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Exception during marking message as unread: {e}")
|
||||||
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
async def search_envelopes(
|
async def search_envelopes(
|
||||||
query: str,
|
query: str,
|
||||||
folder: Optional[str] = None,
|
folder: Optional[str] = None,
|
||||||
@@ -335,15 +421,43 @@ async def search_envelopes(
|
|||||||
- Success status (True if operation was successful)
|
- Success status (True if operation was successful)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build a compound query to search from, to, subject, and body
|
# Himalaya query keywords that indicate the user is writing a raw query
|
||||||
# Himalaya query syntax: from <pattern> or to <pattern> or subject <pattern> or body <pattern>
|
query_keywords = (
|
||||||
search_query = f"from {query} or to {query} or subject {query} or body {query}"
|
"from ",
|
||||||
|
"to ",
|
||||||
|
"subject ",
|
||||||
|
"body ",
|
||||||
|
"date ",
|
||||||
|
"before ",
|
||||||
|
"after ",
|
||||||
|
"flag ",
|
||||||
|
"not ",
|
||||||
|
"order by ",
|
||||||
|
)
|
||||||
|
|
||||||
cmd = f"himalaya envelope list -o json -s {limit} {search_query}"
|
# Check if user is using raw query syntax
|
||||||
|
query_lower = query.lower()
|
||||||
|
is_raw_query = any(query_lower.startswith(kw) for kw in query_keywords)
|
||||||
|
|
||||||
|
if is_raw_query:
|
||||||
|
# Pass through as-is (user knows what they're doing)
|
||||||
|
search_query = query
|
||||||
|
else:
|
||||||
|
# Build a compound query to search from, to, subject, and body
|
||||||
|
# Himalaya query syntax: from <pattern> or to <pattern> or subject <pattern> or body <pattern>
|
||||||
|
search_query = (
|
||||||
|
f"from {query} or to {query} or subject {query} or body {query}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build command with options before the query (query must be at the end, quoted)
|
||||||
|
cmd = "himalaya envelope list -o json"
|
||||||
if folder:
|
if folder:
|
||||||
cmd += f" -f '{folder}'"
|
cmd += f" -f '{folder}'"
|
||||||
if account:
|
if account:
|
||||||
cmd += f" -a '{account}'"
|
cmd += f" -a '{account}'"
|
||||||
|
cmd += f" -s {limit}"
|
||||||
|
# Query must be quoted and at the end of the command
|
||||||
|
cmd += f' "{search_query}"'
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_shell(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -370,3 +484,62 @@ def sync_himalaya():
|
|||||||
print("Himalaya sync completed successfully.")
|
print("Himalaya sync completed successfully.")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"Error during Himalaya sync: {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
|
||||||
|
|||||||
@@ -330,3 +330,44 @@ class KhalClient(CalendarBackend):
|
|||||||
# khal edit is interactive, so this is limited via CLI
|
# khal edit is interactive, so this is limited via CLI
|
||||||
logger.warning("update_event not fully implemented for khal CLI")
|
logger.warning("update_event not fully implemented for khal CLI")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def search_events(self, query: str) -> List[Event]:
|
||||||
|
"""Search for events matching a query string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search string to match against event titles and descriptions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching events
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use khal search with custom format
|
||||||
|
format_str = "{title}|{start-time}|{end-time}|{start}|{end}|{location}|{uid}|{description}|{organizer}|{url}|{categories}|{status}|{repeat-symbol}"
|
||||||
|
args = ["search", "-f", format_str, query]
|
||||||
|
|
||||||
|
result = self._run_khal(args)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"khal search failed: {result.stderr}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip day headers
|
||||||
|
if ", " in line and "|" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
event = self._parse_event_line(line)
|
||||||
|
if event:
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
# Sort by start time
|
||||||
|
events.sort(key=lambda e: e.start)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|||||||
@@ -19,12 +19,85 @@ logging.getLogger("asyncio").setLevel(logging.ERROR)
|
|||||||
logging.getLogger("azure").setLevel(logging.ERROR)
|
logging.getLogger("azure").setLevel(logging.ERROR)
|
||||||
logging.getLogger("azure.core").setLevel(logging.ERROR)
|
logging.getLogger("azure.core").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
# Token cache location - use consistent path regardless of working directory
|
||||||
|
TOKEN_CACHE_DIR = os.path.expanduser("~/.local/share/luk")
|
||||||
|
TOKEN_CACHE_FILE = os.path.join(TOKEN_CACHE_DIR, "token_cache.bin")
|
||||||
|
|
||||||
|
# Legacy cache file (in current working directory) - for migration
|
||||||
|
LEGACY_CACHE_FILE = "token_cache.bin"
|
||||||
|
|
||||||
|
|
||||||
def ensure_directory_exists(path):
|
def ensure_directory_exists(path):
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
os.makedirs(path)
|
os.makedirs(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cache_file():
|
||||||
|
"""Get the token cache file path, migrating from legacy location if needed."""
|
||||||
|
ensure_directory_exists(TOKEN_CACHE_DIR)
|
||||||
|
|
||||||
|
# If new location exists, use it
|
||||||
|
if os.path.exists(TOKEN_CACHE_FILE):
|
||||||
|
return TOKEN_CACHE_FILE
|
||||||
|
|
||||||
|
# If legacy location exists, migrate it
|
||||||
|
if os.path.exists(LEGACY_CACHE_FILE):
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.copy2(LEGACY_CACHE_FILE, TOKEN_CACHE_FILE)
|
||||||
|
os.remove(LEGACY_CACHE_FILE)
|
||||||
|
except Exception:
|
||||||
|
pass # If migration fails, just use new location
|
||||||
|
return TOKEN_CACHE_FILE
|
||||||
|
|
||||||
|
# Default to new location
|
||||||
|
return TOKEN_CACHE_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_cached_token(scopes=None):
|
||||||
|
"""
|
||||||
|
Check if we have a valid cached token (without triggering auth flow).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scopes: List of scopes to check. If None, uses default scopes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if a valid cached token exists, False otherwise.
|
||||||
|
"""
|
||||||
|
if scopes is None:
|
||||||
|
scopes = ["https://graph.microsoft.com/Mail.Read"]
|
||||||
|
|
||||||
|
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||||
|
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||||
|
|
||||||
|
if not client_id or not tenant_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cache = msal.SerializableTokenCache()
|
||||||
|
cache_file = _get_cache_file()
|
||||||
|
|
||||||
|
if not os.path.exists(cache_file):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache.deserialize(open(cache_file, "r").read())
|
||||||
|
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||||
|
app = msal.PublicClientApplication(
|
||||||
|
client_id, authority=authority, token_cache=cache
|
||||||
|
)
|
||||||
|
accounts = app.get_accounts()
|
||||||
|
|
||||||
|
if not accounts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Try silent auth - this will return None if token is expired
|
||||||
|
token_response = app.acquire_token_silent(scopes, account=accounts[0])
|
||||||
|
return token_response is not None and "access_token" in token_response
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_access_token(scopes):
|
def get_access_token(scopes):
|
||||||
"""
|
"""
|
||||||
Authenticate with Microsoft Graph API and obtain an access token.
|
Authenticate with Microsoft Graph API and obtain an access token.
|
||||||
@@ -49,9 +122,9 @@ def get_access_token(scopes):
|
|||||||
"Please set the AZURE_CLIENT_ID and AZURE_TENANT_ID environment variables."
|
"Please set the AZURE_CLIENT_ID and AZURE_TENANT_ID environment variables."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Token cache
|
# Token cache - use consistent location
|
||||||
cache = msal.SerializableTokenCache()
|
cache = msal.SerializableTokenCache()
|
||||||
cache_file = "token_cache.bin"
|
cache_file = _get_cache_file()
|
||||||
|
|
||||||
if os.path.exists(cache_file):
|
if os.path.exists(cache_file):
|
||||||
cache.deserialize(open(cache_file, "r").read())
|
cache.deserialize(open(cache_file, "r").read())
|
||||||
@@ -113,3 +186,78 @@ def get_access_token(scopes):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return access_token, headers
|
return access_token, headers
|
||||||
|
|
||||||
|
|
||||||
|
def get_smtp_access_token(silent_only: bool = False):
|
||||||
|
"""
|
||||||
|
Get an access token specifically for SMTP sending via Outlook.
|
||||||
|
|
||||||
|
SMTP OAuth2 requires a token with the outlook.office.com resource,
|
||||||
|
which is different from the graph.microsoft.com resource used for
|
||||||
|
other operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
silent_only: If True, only attempt silent auth (no interactive prompts).
|
||||||
|
Use this when calling from within a TUI to avoid blocking.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Access token for SMTP, or None if authentication fails.
|
||||||
|
"""
|
||||||
|
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||||
|
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||||
|
|
||||||
|
if not client_id or not tenant_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Token cache - use consistent location
|
||||||
|
cache = msal.SerializableTokenCache()
|
||||||
|
cache_file = _get_cache_file()
|
||||||
|
|
||||||
|
if os.path.exists(cache_file):
|
||||||
|
cache.deserialize(open(cache_file, "r").read())
|
||||||
|
|
||||||
|
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||||
|
app = msal.PublicClientApplication(
|
||||||
|
client_id, authority=authority, token_cache=cache
|
||||||
|
)
|
||||||
|
accounts = app.get_accounts()
|
||||||
|
|
||||||
|
if not accounts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Request token for Outlook SMTP scope
|
||||||
|
smtp_scopes = ["https://outlook.office.com/SMTP.Send"]
|
||||||
|
token_response = app.acquire_token_silent(smtp_scopes, account=accounts[0])
|
||||||
|
|
||||||
|
if token_response and "access_token" in token_response:
|
||||||
|
# Save updated cache
|
||||||
|
with open(cache_file, "w") as f:
|
||||||
|
f.write(cache.serialize())
|
||||||
|
return token_response["access_token"]
|
||||||
|
|
||||||
|
# If silent auth failed and we're not in silent_only mode, try interactive flow
|
||||||
|
if not silent_only:
|
||||||
|
try:
|
||||||
|
flow = app.initiate_device_flow(scopes=smtp_scopes)
|
||||||
|
if "user_code" not in flow:
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(
|
||||||
|
Panel(
|
||||||
|
flow["message"],
|
||||||
|
border_style="magenta",
|
||||||
|
padding=2,
|
||||||
|
title="SMTP Authentication Required",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
token_response = app.acquire_token_by_device_flow(flow)
|
||||||
|
|
||||||
|
if token_response and "access_token" in token_response:
|
||||||
|
with open(cache_file, "w") as f:
|
||||||
|
f.write(cache.serialize())
|
||||||
|
return token_response["access_token"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Mail operations for Microsoft Graph API.
|
Mail operations for Microsoft Graph API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import glob
|
import glob
|
||||||
@@ -111,7 +112,8 @@ async def fetch_mail_async(
|
|||||||
downloaded_count = 0
|
downloaded_count = 0
|
||||||
|
|
||||||
# Download messages in parallel batches for better performance
|
# Download messages in parallel batches for better performance
|
||||||
BATCH_SIZE = 5
|
# Using 10 concurrent downloads with connection pooling for better throughput
|
||||||
|
BATCH_SIZE = 10
|
||||||
|
|
||||||
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
||||||
# Check if task was cancelled/disabled
|
# Check if task was cancelled/disabled
|
||||||
@@ -487,7 +489,8 @@ async def fetch_archive_mail_async(
|
|||||||
downloaded_count = 0
|
downloaded_count = 0
|
||||||
|
|
||||||
# Download messages in parallel batches for better performance
|
# Download messages in parallel batches for better performance
|
||||||
BATCH_SIZE = 5
|
# Using 10 concurrent downloads with connection pooling for better throughput
|
||||||
|
BATCH_SIZE = 10
|
||||||
|
|
||||||
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
||||||
# Check if task was cancelled/disabled
|
# Check if task was cancelled/disabled
|
||||||
@@ -858,30 +861,90 @@ def parse_email_for_graph_api(email_content: str) -> Dict[str, Any]:
|
|||||||
cc_recipients = parse_recipients(msg.get("Cc", ""))
|
cc_recipients = parse_recipients(msg.get("Cc", ""))
|
||||||
bcc_recipients = parse_recipients(msg.get("Bcc", ""))
|
bcc_recipients = parse_recipients(msg.get("Bcc", ""))
|
||||||
|
|
||||||
# Get body content
|
# Get body content and attachments
|
||||||
body_content = ""
|
body_content = ""
|
||||||
body_type = "text"
|
body_type = "text"
|
||||||
|
attachments: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
if msg.is_multipart():
|
if msg.is_multipart():
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
if part.get_content_type() == "text/plain":
|
content_type = part.get_content_type()
|
||||||
body_content = part.get_payload(decode=True).decode(
|
content_disposition = part.get("Content-Disposition", "")
|
||||||
"utf-8", errors="ignore"
|
|
||||||
)
|
# Skip multipart containers
|
||||||
body_type = "text"
|
if content_type.startswith("multipart/"):
|
||||||
break
|
continue
|
||||||
elif part.get_content_type() == "text/html":
|
|
||||||
body_content = part.get_payload(decode=True).decode(
|
# Handle text/plain body
|
||||||
"utf-8", errors="ignore"
|
if content_type == "text/plain" and "attachment" not in content_disposition:
|
||||||
)
|
payload = part.get_payload(decode=True)
|
||||||
body_type = "html"
|
if payload:
|
||||||
|
body_content = payload.decode("utf-8", errors="ignore")
|
||||||
|
body_type = "text"
|
||||||
|
|
||||||
|
# Handle text/html body
|
||||||
|
elif (
|
||||||
|
content_type == "text/html" and "attachment" not in content_disposition
|
||||||
|
):
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_content = payload.decode("utf-8", errors="ignore")
|
||||||
|
body_type = "html"
|
||||||
|
|
||||||
|
# Handle calendar attachments (text/calendar)
|
||||||
|
elif content_type == "text/calendar":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
# Get filename from Content-Disposition or use default
|
||||||
|
filename = part.get_filename() or "invite.ics"
|
||||||
|
|
||||||
|
# Base64 encode the content for Graph API
|
||||||
|
content_bytes = (
|
||||||
|
payload
|
||||||
|
if isinstance(payload, bytes)
|
||||||
|
else payload.encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
attachments.append(
|
||||||
|
{
|
||||||
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||||
|
"name": filename,
|
||||||
|
"contentType": "text/calendar; method=REPLY",
|
||||||
|
"contentBytes": base64.b64encode(content_bytes).decode(
|
||||||
|
"ascii"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle other attachments
|
||||||
|
elif "attachment" in content_disposition or part.get_filename():
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
filename = part.get_filename() or "attachment"
|
||||||
|
content_bytes = (
|
||||||
|
payload
|
||||||
|
if isinstance(payload, bytes)
|
||||||
|
else payload.encode("utf-8")
|
||||||
|
)
|
||||||
|
attachments.append(
|
||||||
|
{
|
||||||
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||||
|
"name": filename,
|
||||||
|
"contentType": content_type,
|
||||||
|
"contentBytes": base64.b64encode(content_bytes).decode(
|
||||||
|
"ascii"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
body_content = msg.get_payload(decode=True).decode("utf-8", errors="ignore")
|
payload = msg.get_payload(decode=True)
|
||||||
if msg.get_content_type() == "text/html":
|
if payload:
|
||||||
body_type = "html"
|
body_content = payload.decode("utf-8", errors="ignore")
|
||||||
|
if msg.get_content_type() == "text/html":
|
||||||
|
body_type = "html"
|
||||||
|
|
||||||
# Build Graph API message
|
# Build Graph API message
|
||||||
message = {
|
message: Dict[str, Any] = {
|
||||||
"subject": msg.get("Subject", ""),
|
"subject": msg.get("Subject", ""),
|
||||||
"body": {"contentType": body_type, "content": body_content},
|
"body": {"contentType": body_type, "content": body_content},
|
||||||
"toRecipients": to_recipients,
|
"toRecipients": to_recipients,
|
||||||
@@ -889,6 +952,10 @@ def parse_email_for_graph_api(email_content: str) -> Dict[str, Any]:
|
|||||||
"bccRecipients": bcc_recipients,
|
"bccRecipients": bcc_recipients,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add attachments if present
|
||||||
|
if attachments:
|
||||||
|
message["attachments"] = attachments
|
||||||
|
|
||||||
# Add reply-to if present
|
# Add reply-to if present
|
||||||
reply_to = msg.get("Reply-To", "")
|
reply_to = msg.get("Reply-To", "")
|
||||||
if reply_to:
|
if reply_to:
|
||||||
@@ -970,6 +1037,189 @@ async def send_email_async(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_smtp(
|
||||||
|
email_content: str, access_token: str, from_email: str, dry_run: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send email using SMTP with OAuth2 XOAUTH2 authentication.
|
||||||
|
|
||||||
|
This uses Microsoft 365's SMTP AUTH with OAuth2, which requires the
|
||||||
|
SMTP.Send scope (often available when Mail.ReadWrite is granted).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_content: Raw email content (RFC 5322 format)
|
||||||
|
access_token: OAuth2 access token
|
||||||
|
from_email: Sender's email address
|
||||||
|
dry_run: If True, don't actually send the email
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email was sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
import smtplib
|
||||||
|
import logging
|
||||||
|
from email.parser import Parser
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(os.path.expanduser("~/Mail/sendmail.log"), mode="a"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse email to get recipients
|
||||||
|
parser = Parser()
|
||||||
|
msg = parser.parsestr(email_content)
|
||||||
|
|
||||||
|
to_addrs = []
|
||||||
|
for header in ["To", "Cc", "Bcc"]:
|
||||||
|
if msg.get(header):
|
||||||
|
addrs = getaddresses([msg.get(header)])
|
||||||
|
to_addrs.extend([addr for name, addr in addrs if addr])
|
||||||
|
|
||||||
|
subject = msg.get("Subject", "(no subject)")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"[DRY-RUN] Would send email via SMTP: {subject}")
|
||||||
|
print(f"[DRY-RUN] To: {to_addrs}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logging.info(f"Attempting SMTP send: {subject} to {to_addrs}")
|
||||||
|
|
||||||
|
# Build XOAUTH2 auth string
|
||||||
|
# Format: base64("user=" + user + "\x01auth=Bearer " + token + "\x01\x01")
|
||||||
|
auth_string = f"user={from_email}\x01auth=Bearer {access_token}\x01\x01"
|
||||||
|
|
||||||
|
# Connect to Office 365 SMTP
|
||||||
|
with smtplib.SMTP("smtp.office365.com", 587) as server:
|
||||||
|
server.set_debuglevel(0)
|
||||||
|
server.ehlo()
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo()
|
||||||
|
|
||||||
|
# Authenticate using XOAUTH2
|
||||||
|
server.auth("XOAUTH2", lambda: auth_string)
|
||||||
|
|
||||||
|
# Send the email
|
||||||
|
server.sendmail(from_email, to_addrs, email_content)
|
||||||
|
|
||||||
|
logging.info(f"Successfully sent email via SMTP: {subject}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
|
logging.error(f"SMTP authentication failed: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SMTP send failed: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email_smtp_async(
|
||||||
|
email_content: str, access_token: str, from_email: str, dry_run: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Async wrapper for send_email_smtp."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None, send_email_smtp, email_content, access_token, from_email, dry_run
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_email_in_client_async(email_path: str, subject: str) -> bool:
|
||||||
|
"""
|
||||||
|
Open an email file in the default mail client for manual sending.
|
||||||
|
|
||||||
|
This is used as a fallback when automated sending (Graph API, SMTP) fails.
|
||||||
|
The email is copied to a .eml temp file and opened with the system default
|
||||||
|
mail application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_path: Path to the email file in maildir format
|
||||||
|
subject: Email subject for logging/notification purposes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the email was successfully opened, False otherwise
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import logging
|
||||||
|
from email.parser import Parser
|
||||||
|
from email.utils import parseaddr
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(os.path.expanduser("~/Mail/sendmail.log"), mode="a"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read and parse the email
|
||||||
|
with open(email_path, "r", encoding="utf-8") as f:
|
||||||
|
email_content = f.read()
|
||||||
|
|
||||||
|
parser = Parser()
|
||||||
|
msg = parser.parsestr(email_content)
|
||||||
|
|
||||||
|
# Extract headers
|
||||||
|
to_header = msg.get("To", "")
|
||||||
|
_, to_email = parseaddr(to_header)
|
||||||
|
from_header = msg.get("From", "")
|
||||||
|
|
||||||
|
# Create a temp .eml file that mail clients can open
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".eml", delete=False, encoding="utf-8"
|
||||||
|
) as tmp:
|
||||||
|
tmp.write(email_content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
# Try to open with Outlook first (better .eml support), fall back to default
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# Try Outlook
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: subprocess.run(
|
||||||
|
["open", "-a", "Microsoft Outlook", tmp_path], capture_output=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
# Fall back to default mail client
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None, lambda: subprocess.run(["open", tmp_path], capture_output=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
logging.info(f"Opened email in mail client: {subject} (To: {to_email})")
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
from src.utils.notifications import send_notification
|
||||||
|
|
||||||
|
send_notification(
|
||||||
|
title="Calendar Reply Ready",
|
||||||
|
message=f"To: {to_email}",
|
||||||
|
subtitle=f"Please send: {subject}",
|
||||||
|
sound="default",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.error(f"Failed to open email: {result.stderr.decode()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error opening email in client: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error opening email in client: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def process_outbox_async(
|
async def process_outbox_async(
|
||||||
maildir_path: str,
|
maildir_path: str,
|
||||||
org: str,
|
org: str,
|
||||||
@@ -977,10 +1227,14 @@ async def process_outbox_async(
|
|||||||
progress,
|
progress,
|
||||||
task_id,
|
task_id,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
access_token: str | None = None,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
Process outbound emails in the outbox queue.
|
Process outbound emails in the outbox queue.
|
||||||
|
|
||||||
|
Tries Graph API first, falls back to SMTP OAuth2 if Graph API fails
|
||||||
|
(e.g., when Mail.Send scope is not available but SMTP.Send is).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
maildir_path: Base maildir path
|
maildir_path: Base maildir path
|
||||||
org: Organization name
|
org: Organization name
|
||||||
@@ -988,6 +1242,7 @@ async def process_outbox_async(
|
|||||||
progress: Progress instance for updating progress bars
|
progress: Progress instance for updating progress bars
|
||||||
task_id: ID of the task in the progress bar
|
task_id: ID of the task in the progress bar
|
||||||
dry_run: If True, don't actually send emails
|
dry_run: If True, don't actually send emails
|
||||||
|
access_token: OAuth2 access token for SMTP fallback
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (successful_sends, failed_sends)
|
Tuple of (successful_sends, failed_sends)
|
||||||
@@ -1027,8 +1282,59 @@ async def process_outbox_async(
|
|||||||
with open(email_path, "r", encoding="utf-8") as f:
|
with open(email_path, "r", encoding="utf-8") as f:
|
||||||
email_content = f.read()
|
email_content = f.read()
|
||||||
|
|
||||||
# Send email
|
# Parse email to get from address for SMTP fallback
|
||||||
if await send_email_async(email_content, headers, dry_run):
|
parser = Parser()
|
||||||
|
msg = parser.parsestr(email_content)
|
||||||
|
from_header = msg.get("From", "")
|
||||||
|
subject = msg.get("Subject", "Unknown")
|
||||||
|
# Extract email from "Name <email@domain.com>" format
|
||||||
|
from email.utils import parseaddr
|
||||||
|
|
||||||
|
_, from_email = parseaddr(from_header)
|
||||||
|
|
||||||
|
# Try Graph API first (will fail without Mail.Send scope)
|
||||||
|
send_success = await send_email_async(email_content, headers, dry_run)
|
||||||
|
|
||||||
|
# If Graph API failed, check config for SMTP fallback
|
||||||
|
if not send_success and from_email and not dry_run:
|
||||||
|
import logging
|
||||||
|
from src.mail.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if config.mail.enable_smtp_send:
|
||||||
|
# SMTP sending is enabled in config
|
||||||
|
from src.services.microsoft_graph.auth import get_smtp_access_token
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
f"Graph API send failed, trying SMTP fallback for: {email_file}"
|
||||||
|
)
|
||||||
|
progress.console.print(f" Graph API failed, trying SMTP...")
|
||||||
|
|
||||||
|
# Get SMTP-specific token (different resource than Graph API)
|
||||||
|
# Use silent_only=True to avoid blocking the TUI with auth prompts
|
||||||
|
smtp_token = get_smtp_access_token(silent_only=True)
|
||||||
|
if smtp_token:
|
||||||
|
send_success = await send_email_smtp_async(
|
||||||
|
email_content, smtp_token, from_email, dry_run
|
||||||
|
)
|
||||||
|
if send_success:
|
||||||
|
logging.info(f"SMTP fallback succeeded for: {email_file}")
|
||||||
|
else:
|
||||||
|
logging.error("Failed to get SMTP access token")
|
||||||
|
else:
|
||||||
|
# SMTP disabled - open email in default mail client
|
||||||
|
logging.info(
|
||||||
|
f"Graph API send failed, opening in mail client: {email_file}"
|
||||||
|
)
|
||||||
|
progress.console.print(f" Opening in mail client...")
|
||||||
|
|
||||||
|
if await open_email_in_client_async(email_path, subject):
|
||||||
|
# Mark as handled (move to cur) since user will send manually
|
||||||
|
send_success = True
|
||||||
|
logging.info(f"Opened email in mail client: {email_file}")
|
||||||
|
|
||||||
|
if send_success:
|
||||||
# Move to cur directory on success
|
# Move to cur directory on success
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
cur_path = os.path.join(cur_dir, email_file)
|
cur_path = os.path.join(cur_dir, email_file)
|
||||||
@@ -1047,14 +1353,13 @@ async def process_outbox_async(
|
|||||||
# Log the failure
|
# Log the failure
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.error(f"Failed to send email: {email_file}")
|
logging.error(
|
||||||
|
f"Failed to send email via Graph API and SMTP: {email_file}"
|
||||||
|
)
|
||||||
|
|
||||||
# Send notification about failure
|
# Send notification about failure
|
||||||
from src.utils.notifications import send_notification
|
from src.utils.notifications import send_notification
|
||||||
|
|
||||||
parser = Parser()
|
|
||||||
msg = parser.parsestr(email_content)
|
|
||||||
subject = msg.get("Subject", "Unknown")
|
|
||||||
send_notification(
|
send_notification(
|
||||||
title="Email Send Failed",
|
title="Email Send Failed",
|
||||||
message=f"Failed to send: {subject}",
|
message=f"Failed to send: {subject}",
|
||||||
@@ -1101,3 +1406,98 @@ async def process_outbox_async(
|
|||||||
progress.console.print(f"✗ Failed to send {failed_sends} emails")
|
progress.console.print(f"✗ Failed to send {failed_sends} emails")
|
||||||
|
|
||||||
return successful_sends, failed_sends
|
return successful_sends, failed_sends
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_message_ics_attachment(
|
||||||
|
graph_message_id: str,
|
||||||
|
headers: Dict[str, str],
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""
|
||||||
|
Fetch the ICS calendar attachment from a message via Microsoft Graph API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
graph_message_id: The Microsoft Graph API message ID
|
||||||
|
headers: Authentication headers for Microsoft Graph API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (ICS content string or None, success boolean)
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
try:
|
||||||
|
# URL-encode the message ID (may contain special chars like = + /)
|
||||||
|
encoded_id = quote(graph_message_id, safe="")
|
||||||
|
|
||||||
|
# Fetch attachments list for the message
|
||||||
|
attachments_url = (
|
||||||
|
f"https://graph.microsoft.com/v1.0/me/messages/{encoded_id}/attachments"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await fetch_with_aiohttp(attachments_url, headers)
|
||||||
|
|
||||||
|
attachments = response.get("value", [])
|
||||||
|
|
||||||
|
for attachment in attachments:
|
||||||
|
content_type = (attachment.get("contentType") or "").lower()
|
||||||
|
name = (attachment.get("name") or "").lower()
|
||||||
|
|
||||||
|
# Look for calendar attachments (text/calendar or application/ics)
|
||||||
|
if "calendar" in content_type or name.endswith(".ics"):
|
||||||
|
# contentBytes is base64-encoded
|
||||||
|
content_bytes = attachment.get("contentBytes")
|
||||||
|
if content_bytes:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
decoded = base64.b64decode(content_bytes)
|
||||||
|
return decoded.decode("utf-8", errors="replace"), True
|
||||||
|
|
||||||
|
# No ICS attachment found
|
||||||
|
return None, True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.error(f"Error fetching ICS attachment: {e}")
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_message_with_ics(
|
||||||
|
graph_message_id: str,
|
||||||
|
headers: Dict[str, str],
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""
|
||||||
|
Fetch the full MIME content of a message including ICS attachment.
|
||||||
|
|
||||||
|
This fetches the raw $value of the message which includes all MIME parts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
graph_message_id: The Microsoft Graph API message ID
|
||||||
|
headers: Authentication headers for Microsoft Graph API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (raw MIME content or None, success boolean)
|
||||||
|
"""
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch the raw MIME content
|
||||||
|
mime_url = (
|
||||||
|
f"https://graph.microsoft.com/v1.0/me/messages/{graph_message_id}/$value"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(mime_url, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
content = await response.text()
|
||||||
|
return content, True
|
||||||
|
else:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.error(f"Failed to fetch MIME content: {response.status}")
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.error(f"Error fetching MIME content: {e}")
|
||||||
|
return None, False
|
||||||
|
|||||||
141
src/tasks/app.py
141
src/tasks/app.py
@@ -12,12 +12,13 @@ from textual.app import App, ComposeResult
|
|||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
||||||
from textual.logging import TextualHandler
|
from textual.logging import TextualHandler
|
||||||
from textual.widgets import DataTable, Footer, Header, Static, Markdown
|
from textual.widgets import DataTable, Footer, Header, Static, Markdown, Input
|
||||||
|
|
||||||
from .config import get_config, TasksAppConfig
|
from .config import get_config, TasksAppConfig
|
||||||
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
||||||
from .widgets.FilterSidebar import FilterSidebar
|
from .widgets.FilterSidebar import FilterSidebar
|
||||||
from src.utils.shared_config import get_theme_name
|
from src.utils.shared_config import get_theme_name
|
||||||
|
from src.utils.ipc import IPCListener, IPCMessage
|
||||||
|
|
||||||
# Add the parent directory to the system path to resolve relative imports
|
# Add the parent directory to the system path to resolve relative imports
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
@@ -153,6 +154,30 @@ class TasksApp(App):
|
|||||||
#notes-pane.hidden {
|
#notes-pane.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#search-container {
|
||||||
|
dock: top;
|
||||||
|
height: 4;
|
||||||
|
width: 100%;
|
||||||
|
background: $surface;
|
||||||
|
border-bottom: solid $primary;
|
||||||
|
padding: 0 1;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-container.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-container .search-label {
|
||||||
|
width: auto;
|
||||||
|
padding: 0 1;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -174,6 +199,8 @@ class TasksApp(App):
|
|||||||
Binding("r", "refresh", "Refresh", show=True),
|
Binding("r", "refresh", "Refresh", show=True),
|
||||||
Binding("y", "sync", "Sync", show=True),
|
Binding("y", "sync", "Sync", show=True),
|
||||||
Binding("?", "help", "Help", show=True),
|
Binding("?", "help", "Help", show=True),
|
||||||
|
Binding("slash", "search", "Search", show=True),
|
||||||
|
Binding("escape", "clear_search", "Clear Search", show=False),
|
||||||
Binding("enter", "view_task", "View", show=False),
|
Binding("enter", "view_task", "View", show=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -208,6 +235,7 @@ class TasksApp(App):
|
|||||||
self.notes_visible = False
|
self.notes_visible = False
|
||||||
self.detail_visible = False
|
self.detail_visible = False
|
||||||
self.sidebar_visible = True # Start with sidebar visible
|
self.sidebar_visible = True # Start with sidebar visible
|
||||||
|
self.current_search_query = "" # Current search filter
|
||||||
self.config = get_config()
|
self.config = get_config()
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
@@ -221,6 +249,12 @@ class TasksApp(App):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the app layout."""
|
"""Create the app layout."""
|
||||||
yield Header()
|
yield Header()
|
||||||
|
yield Horizontal(
|
||||||
|
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
|
||||||
|
Input(placeholder="Filter tasks...", id="search-input", disabled=True),
|
||||||
|
id="search-container",
|
||||||
|
classes="hidden",
|
||||||
|
)
|
||||||
yield FilterSidebar(id="sidebar")
|
yield FilterSidebar(id="sidebar")
|
||||||
yield Vertical(
|
yield Vertical(
|
||||||
DataTable(id="task-table", cursor_type="row"),
|
DataTable(id="task-table", cursor_type="row"),
|
||||||
@@ -246,6 +280,11 @@ class TasksApp(App):
|
|||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Initialize the app on mount."""
|
"""Initialize the app on mount."""
|
||||||
self.theme = get_theme_name()
|
self.theme = get_theme_name()
|
||||||
|
|
||||||
|
# Start IPC listener for refresh notifications from sync daemon
|
||||||
|
self._ipc_listener = IPCListener("tasks", self._on_ipc_message)
|
||||||
|
self._ipc_listener.start()
|
||||||
|
|
||||||
table = self.query_one("#task-table", DataTable)
|
table = self.query_one("#task-table", DataTable)
|
||||||
|
|
||||||
# Setup columns based on config with dynamic widths
|
# Setup columns based on config with dynamic widths
|
||||||
@@ -271,6 +310,9 @@ class TasksApp(App):
|
|||||||
# Load tasks (filtered by current filters)
|
# Load tasks (filtered by current filters)
|
||||||
self.load_tasks()
|
self.load_tasks()
|
||||||
|
|
||||||
|
# Focus the task table (not the hidden search input)
|
||||||
|
table.focus()
|
||||||
|
|
||||||
def _setup_columns(self, table: DataTable, columns: list[str]) -> None:
|
def _setup_columns(self, table: DataTable, columns: list[str]) -> None:
|
||||||
"""Setup table columns with dynamic widths based on available space."""
|
"""Setup table columns with dynamic widths based on available space."""
|
||||||
# Minimum widths for each column type
|
# Minimum widths for each column type
|
||||||
@@ -408,10 +450,11 @@ class TasksApp(App):
|
|||||||
self._update_sidebar()
|
self._update_sidebar()
|
||||||
|
|
||||||
def _filter_tasks(self, tasks: list[Task]) -> list[Task]:
|
def _filter_tasks(self, tasks: list[Task]) -> list[Task]:
|
||||||
"""Filter tasks by current project and tag filters using OR logic.
|
"""Filter tasks by current project, tag filters, and search query.
|
||||||
|
|
||||||
- If project filter is set, only show tasks from that project
|
- If project filter is set, only show tasks from that project
|
||||||
- If tag filters are set, show tasks that have ANY of the selected tags (OR)
|
- If tag filters are set, show tasks that have ANY of the selected tags (OR)
|
||||||
|
- If search query is set, filter by summary, notes, project, and tags
|
||||||
"""
|
"""
|
||||||
filtered = tasks
|
filtered = tasks
|
||||||
|
|
||||||
@@ -427,6 +470,18 @@ class TasksApp(App):
|
|||||||
if any(tag in t.tags for tag in self.current_tag_filters)
|
if any(tag in t.tags for tag in self.current_tag_filters)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Filter by search query (case-insensitive match on summary, notes, project, tags)
|
||||||
|
if self.current_search_query:
|
||||||
|
query = self.current_search_query.lower()
|
||||||
|
filtered = [
|
||||||
|
t
|
||||||
|
for t in filtered
|
||||||
|
if query in t.summary.lower()
|
||||||
|
or (t.notes and query in t.notes.lower())
|
||||||
|
or (t.project and query in t.project.lower())
|
||||||
|
or any(query in tag.lower() for tag in t.tags)
|
||||||
|
]
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
def _update_sidebar(self) -> None:
|
def _update_sidebar(self) -> None:
|
||||||
@@ -485,6 +540,8 @@ class TasksApp(App):
|
|||||||
status_bar.total_tasks = len(self.tasks)
|
status_bar.total_tasks = len(self.tasks)
|
||||||
|
|
||||||
filters = []
|
filters = []
|
||||||
|
if self.current_search_query:
|
||||||
|
filters.append(f"\uf002 {self.current_search_query}") # nf-fa-search
|
||||||
if self.current_project_filter:
|
if self.current_project_filter:
|
||||||
filters.append(f"project:{self.current_project_filter}")
|
filters.append(f"project:{self.current_project_filter}")
|
||||||
for tag in self.current_tag_filters:
|
for tag in self.current_tag_filters:
|
||||||
@@ -588,8 +645,8 @@ class TasksApp(App):
|
|||||||
from .screens.AddTaskScreen import AddTaskScreen
|
from .screens.AddTaskScreen import AddTaskScreen
|
||||||
from .widgets.AddTaskForm import TaskFormData
|
from .widgets.AddTaskForm import TaskFormData
|
||||||
|
|
||||||
# Get project names for dropdown
|
# Get project names for dropdown (use all_projects which is populated on mount)
|
||||||
project_names = [p.name for p in self.projects if p.name]
|
project_names = [p.name for p in self.all_projects if p.name]
|
||||||
|
|
||||||
def handle_task_created(data: TaskFormData | None) -> None:
|
def handle_task_created(data: TaskFormData | None) -> None:
|
||||||
if data is None or not self.backend:
|
if data is None or not self.backend:
|
||||||
@@ -754,9 +811,10 @@ class TasksApp(App):
|
|||||||
self.notify(f"Sorted by {event.column} ({direction})")
|
self.notify(f"Sorted by {event.column} ({direction})")
|
||||||
|
|
||||||
def action_clear_filters(self) -> None:
|
def action_clear_filters(self) -> None:
|
||||||
"""Clear all filters."""
|
"""Clear all filters including search."""
|
||||||
self.current_project_filter = None
|
self.current_project_filter = None
|
||||||
self.current_tag_filters = []
|
self.current_tag_filters = []
|
||||||
|
self.current_search_query = ""
|
||||||
|
|
||||||
# Also clear sidebar selections
|
# Also clear sidebar selections
|
||||||
try:
|
try:
|
||||||
@@ -765,6 +823,15 @@ class TasksApp(App):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Clear and hide search input
|
||||||
|
try:
|
||||||
|
search_container = self.query_one("#search-container")
|
||||||
|
search_input = self.query_one("#search-input", Input)
|
||||||
|
search_input.value = ""
|
||||||
|
search_container.add_class("hidden")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
self.load_tasks()
|
self.load_tasks()
|
||||||
self.notify("Filters cleared", severity="information")
|
self.notify("Filters cleared", severity="information")
|
||||||
|
|
||||||
@@ -800,6 +867,8 @@ Keybindings:
|
|||||||
x - Delete task
|
x - Delete task
|
||||||
w - Toggle filter sidebar
|
w - Toggle filter sidebar
|
||||||
c - Clear all filters
|
c - Clear all filters
|
||||||
|
/ - Search tasks
|
||||||
|
Esc - Clear search
|
||||||
r - Refresh
|
r - Refresh
|
||||||
y - Sync with remote
|
y - Sync with remote
|
||||||
Enter - View task details
|
Enter - View task details
|
||||||
@@ -807,6 +876,56 @@ Keybindings:
|
|||||||
"""
|
"""
|
||||||
self.notify(help_text.strip(), timeout=10)
|
self.notify(help_text.strip(), timeout=10)
|
||||||
|
|
||||||
|
# Search actions
|
||||||
|
def action_search(self) -> None:
|
||||||
|
"""Show search input and focus it."""
|
||||||
|
search_container = self.query_one("#search-container")
|
||||||
|
search_container.remove_class("hidden")
|
||||||
|
search_input = self.query_one("#search-input", Input)
|
||||||
|
search_input.disabled = False
|
||||||
|
search_input.focus()
|
||||||
|
|
||||||
|
def action_clear_search(self) -> None:
|
||||||
|
"""Clear search and hide search input."""
|
||||||
|
search_container = self.query_one("#search-container")
|
||||||
|
search_input = self.query_one("#search-input", Input)
|
||||||
|
|
||||||
|
# Only act if search is visible or there's a query
|
||||||
|
if not search_container.has_class("hidden") or self.current_search_query:
|
||||||
|
search_input.value = ""
|
||||||
|
search_input.disabled = True
|
||||||
|
self.current_search_query = ""
|
||||||
|
search_container.add_class("hidden")
|
||||||
|
self.load_tasks()
|
||||||
|
# Focus back to table
|
||||||
|
table = self.query_one("#task-table", DataTable)
|
||||||
|
table.focus()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
"""Handle Enter in search input - apply search and focus table."""
|
||||||
|
if event.input.id != "search-input":
|
||||||
|
return
|
||||||
|
|
||||||
|
query = event.value.strip()
|
||||||
|
self.current_search_query = query
|
||||||
|
self.load_tasks()
|
||||||
|
|
||||||
|
# Focus back to table
|
||||||
|
table = self.query_one("#task-table", DataTable)
|
||||||
|
table.focus()
|
||||||
|
|
||||||
|
if query:
|
||||||
|
self.notify(f"Searching: {query}")
|
||||||
|
|
||||||
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
"""Handle live search as user types."""
|
||||||
|
if event.input.id != "search-input":
|
||||||
|
return
|
||||||
|
|
||||||
|
# Live search - filter as user types
|
||||||
|
self.current_search_query = event.value.strip()
|
||||||
|
self.load_tasks()
|
||||||
|
|
||||||
# Notes actions
|
# Notes actions
|
||||||
def action_toggle_notes(self) -> None:
|
def action_toggle_notes(self) -> None:
|
||||||
"""Toggle the notes-only pane visibility."""
|
"""Toggle the notes-only pane visibility."""
|
||||||
@@ -897,6 +1016,18 @@ Keybindings:
|
|||||||
if task:
|
if task:
|
||||||
self._update_detail_display(task)
|
self._update_detail_display(task)
|
||||||
|
|
||||||
|
def _on_ipc_message(self, message: IPCMessage) -> None:
|
||||||
|
"""Handle IPC messages from sync daemon."""
|
||||||
|
if message.event == "refresh":
|
||||||
|
# Schedule a reload on the main thread
|
||||||
|
self.call_from_thread(self.load_tasks)
|
||||||
|
|
||||||
|
async def action_quit(self) -> None:
|
||||||
|
"""Quit the app and clean up IPC listener."""
|
||||||
|
if hasattr(self, "_ipc_listener"):
|
||||||
|
self._ipc_listener.stop()
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
|
||||||
def run_app(backend: Optional[TaskBackend] = None) -> None:
|
def run_app(backend: Optional[TaskBackend] = None) -> None:
|
||||||
"""Run the Tasks TUI application."""
|
"""Run the Tasks TUI application."""
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ from src.utils.mail_utils.helpers import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level session for reuse
|
||||||
|
_shared_session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shared_session() -> aiohttp.ClientSession:
|
||||||
|
"""Get or create a shared aiohttp session for connection reuse."""
|
||||||
|
global _shared_session
|
||||||
|
if _shared_session is None or _shared_session.closed:
|
||||||
|
connector = aiohttp.TCPConnector(
|
||||||
|
limit=20, # Max concurrent connections
|
||||||
|
limit_per_host=10, # Max connections per host
|
||||||
|
ttl_dns_cache=300, # Cache DNS for 5 minutes
|
||||||
|
)
|
||||||
|
_shared_session = aiohttp.ClientSession(connector=connector)
|
||||||
|
return _shared_session
|
||||||
|
|
||||||
|
|
||||||
|
async def close_shared_session():
|
||||||
|
"""Close the shared session when done."""
|
||||||
|
global _shared_session
|
||||||
|
if _shared_session and not _shared_session.closed:
|
||||||
|
await _shared_session.close()
|
||||||
|
_shared_session = None
|
||||||
|
|
||||||
|
|
||||||
async def save_mime_to_maildir_async(
|
async def save_mime_to_maildir_async(
|
||||||
maildir_path,
|
maildir_path,
|
||||||
message,
|
message,
|
||||||
@@ -136,63 +161,68 @@ async def create_mime_message_async(
|
|||||||
|
|
||||||
# First try the direct body content approach
|
# First try the direct body content approach
|
||||||
message_id = message.get("id", "")
|
message_id = message.get("id", "")
|
||||||
|
|
||||||
|
# Get shared session for connection reuse
|
||||||
|
session = await get_shared_session()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First get the message with body content
|
# First get the message with body content
|
||||||
body_url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}?$select=body,bodyPreview"
|
body_url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}?$select=body,bodyPreview"
|
||||||
async with aiohttp.ClientSession() as session:
|
async with session.get(body_url, headers=headers) as response:
|
||||||
async with session.get(body_url, headers=headers) as response:
|
if response.status == 200:
|
||||||
if response.status == 200:
|
body_data = await response.json()
|
||||||
body_data = await response.json()
|
|
||||||
|
|
||||||
# Get body content
|
# Get body content
|
||||||
body_content = body_data.get("body", {}).get("content", "")
|
body_content = body_data.get("body", {}).get("content", "")
|
||||||
body_type = body_data.get("body", {}).get("contentType", "text")
|
body_type = body_data.get("body", {}).get("contentType", "text")
|
||||||
body_preview = body_data.get("bodyPreview", "")
|
body_preview = body_data.get("bodyPreview", "")
|
||||||
|
|
||||||
# If we have body content, use it
|
# If we have body content, use it
|
||||||
if body_content:
|
if body_content:
|
||||||
if body_type.lower() == "html":
|
if body_type.lower() == "html":
|
||||||
# Add both HTML and plain text versions
|
# Add both HTML and plain text versions
|
||||||
# Plain text conversion
|
# Plain text conversion
|
||||||
plain_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
plain_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
||||||
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
||||||
|
|
||||||
mime_msg.attach(MIMEText(plain_text, "plain"))
|
mime_msg.attach(MIMEText(plain_text, "plain"))
|
||||||
mime_msg.attach(MIMEText(body_content, "html"))
|
mime_msg.attach(MIMEText(body_content, "html"))
|
||||||
else:
|
|
||||||
# Just plain text
|
|
||||||
mime_msg.attach(MIMEText(body_content, "plain"))
|
|
||||||
elif body_preview:
|
|
||||||
# Use preview if we have it
|
|
||||||
mime_msg.attach(
|
|
||||||
MIMEText(
|
|
||||||
f"{body_preview}\n\n[Message preview only. Full content not available.]",
|
|
||||||
"plain",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Fallback to MIME content
|
# Just plain text
|
||||||
progress.console.print(
|
mime_msg.attach(MIMEText(body_content, "plain"))
|
||||||
f"No direct body content for message {truncate_id(message_id)}, trying MIME content..."
|
elif body_preview:
|
||||||
|
# Use preview if we have it
|
||||||
|
mime_msg.attach(
|
||||||
|
MIMEText(
|
||||||
|
f"{body_preview}\n\n[Message preview only. Full content not available.]",
|
||||||
|
"plain",
|
||||||
)
|
)
|
||||||
await fetch_mime_content(
|
|
||||||
mime_msg, message_id, headers, progress
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
progress.console.print(
|
|
||||||
f"Failed to get message body: {response.status}. Trying MIME content..."
|
|
||||||
)
|
)
|
||||||
await fetch_mime_content(mime_msg, message_id, headers, progress)
|
else:
|
||||||
|
# Fallback to MIME content
|
||||||
|
progress.console.print(
|
||||||
|
f"No direct body content for message {truncate_id(message_id)}, trying MIME content..."
|
||||||
|
)
|
||||||
|
await fetch_mime_content(
|
||||||
|
mime_msg, message_id, headers, progress, session
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress.console.print(
|
||||||
|
f"Failed to get message body: {response.status}. Trying MIME content..."
|
||||||
|
)
|
||||||
|
await fetch_mime_content(
|
||||||
|
mime_msg, message_id, headers, progress, session
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
progress.console.print(
|
progress.console.print(
|
||||||
f"Error getting message body: {e}. Trying MIME content..."
|
f"Error getting message body: {e}. Trying MIME content..."
|
||||||
)
|
)
|
||||||
await fetch_mime_content(mime_msg, message_id, headers, progress)
|
await fetch_mime_content(mime_msg, message_id, headers, progress, session)
|
||||||
|
|
||||||
# Handle attachments only if we want to download them
|
# Handle attachments only if we want to download them
|
||||||
if download_attachments:
|
if download_attachments:
|
||||||
await add_attachments_async(
|
await add_attachments_async(
|
||||||
mime_msg, message, headers, attachments_dir, progress
|
mime_msg, message, headers, attachments_dir, progress, session
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Add a header to indicate attachment info was skipped
|
# Add a header to indicate attachment info was skipped
|
||||||
@@ -201,7 +231,7 @@ async def create_mime_message_async(
|
|||||||
return mime_msg
|
return mime_msg
|
||||||
|
|
||||||
|
|
||||||
async def fetch_mime_content(mime_msg, message_id, headers, progress):
|
async def fetch_mime_content(mime_msg, message_id, headers, progress, session=None):
|
||||||
"""
|
"""
|
||||||
Fetch and add MIME content to a message when direct body access fails.
|
Fetch and add MIME content to a message when direct body access fails.
|
||||||
|
|
||||||
@@ -210,72 +240,78 @@ async def fetch_mime_content(mime_msg, message_id, headers, progress):
|
|||||||
message_id (str): Message ID.
|
message_id (str): Message ID.
|
||||||
headers (dict): Headers including authentication.
|
headers (dict): Headers including authentication.
|
||||||
progress: Progress instance for updating progress bars.
|
progress: Progress instance for updating progress bars.
|
||||||
|
session (aiohttp.ClientSession, optional): Shared session to use.
|
||||||
"""
|
"""
|
||||||
# Fallback to getting the MIME content
|
# Fallback to getting the MIME content
|
||||||
message_content_url = (
|
message_content_url = (
|
||||||
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
|
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
# Use provided session or get shared session
|
||||||
async with session.get(message_content_url, headers=headers) as response:
|
if session is None:
|
||||||
if response.status == 200:
|
session = await get_shared_session()
|
||||||
full_content = await response.text()
|
|
||||||
|
|
||||||
# Check for body tags
|
async with session.get(message_content_url, headers=headers) as response:
|
||||||
body_match = re.search(
|
if response.status == 200:
|
||||||
r"<body[^>]*>(.*?)</body>",
|
full_content = await response.text()
|
||||||
|
|
||||||
|
# Check for body tags
|
||||||
|
body_match = re.search(
|
||||||
|
r"<body[^>]*>(.*?)</body>",
|
||||||
|
full_content,
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if body_match:
|
||||||
|
body_content = body_match.group(1)
|
||||||
|
# Simple HTML to text conversion
|
||||||
|
body_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
||||||
|
body_text = re.sub(r"<[^>]*>", "", body_text)
|
||||||
|
|
||||||
|
# Add the plain text body
|
||||||
|
mime_msg.attach(MIMEText(body_text, "plain"))
|
||||||
|
|
||||||
|
# Also add the HTML body
|
||||||
|
mime_msg.attach(MIMEText(full_content, "html"))
|
||||||
|
else:
|
||||||
|
# Fallback - try to find content between Content-Type: text/html and next boundary
|
||||||
|
html_parts = re.findall(
|
||||||
|
r"Content-Type: text/html.*?\r?\n\r?\n(.*?)(?:\r?\n\r?\n|$)",
|
||||||
full_content,
|
full_content,
|
||||||
re.DOTALL | re.IGNORECASE,
|
re.DOTALL | re.IGNORECASE,
|
||||||
)
|
)
|
||||||
if body_match:
|
if html_parts:
|
||||||
body_content = body_match.group(1)
|
html_content = html_parts[0]
|
||||||
# Simple HTML to text conversion
|
mime_msg.attach(MIMEText(html_content, "html"))
|
||||||
body_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
|
||||||
body_text = re.sub(r"<[^>]*>", "", body_text)
|
|
||||||
|
|
||||||
# Add the plain text body
|
# Also make plain text version
|
||||||
mime_msg.attach(MIMEText(body_text, "plain"))
|
plain_text = re.sub(r"<br\s*/?>", "\n", html_content)
|
||||||
|
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
||||||
# Also add the HTML body
|
mime_msg.attach(MIMEText(plain_text, "plain"))
|
||||||
mime_msg.attach(MIMEText(full_content, "html"))
|
|
||||||
else:
|
else:
|
||||||
# Fallback - try to find content between Content-Type: text/html and next boundary
|
# Just use the raw content as text if nothing else works
|
||||||
html_parts = re.findall(
|
mime_msg.attach(MIMEText(full_content, "plain"))
|
||||||
r"Content-Type: text/html.*?\r?\n\r?\n(.*?)(?:\r?\n\r?\n|$)",
|
progress.console.print(
|
||||||
full_content,
|
f"Using raw content for message {message_id} - no body tags found"
|
||||||
re.DOTALL | re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if html_parts:
|
|
||||||
html_content = html_parts[0]
|
|
||||||
mime_msg.attach(MIMEText(html_content, "html"))
|
|
||||||
|
|
||||||
# Also make plain text version
|
|
||||||
plain_text = re.sub(r"<br\s*/?>", "\n", html_content)
|
|
||||||
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
|
||||||
mime_msg.attach(MIMEText(plain_text, "plain"))
|
|
||||||
else:
|
|
||||||
# Just use the raw content as text if nothing else works
|
|
||||||
mime_msg.attach(MIMEText(full_content, "plain"))
|
|
||||||
progress.console.print(
|
|
||||||
f"Using raw content for message {message_id} - no body tags found"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
error_text = await response.text()
|
|
||||||
progress.console.print(
|
|
||||||
f"Failed to get MIME content: {response.status} {error_text}"
|
|
||||||
)
|
|
||||||
mime_msg.attach(
|
|
||||||
MIMEText(
|
|
||||||
f"Failed to retrieve message body: HTTP {response.status}",
|
|
||||||
"plain",
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
progress.console.print(
|
||||||
|
f"Failed to get MIME content: {response.status} {error_text}"
|
||||||
|
)
|
||||||
|
mime_msg.attach(
|
||||||
|
MIMEText(
|
||||||
|
f"Failed to retrieve message body: HTTP {response.status}",
|
||||||
|
"plain",
|
||||||
)
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
progress.console.print(f"Error retrieving MIME content: {e}")
|
progress.console.print(f"Error retrieving MIME content: {e}")
|
||||||
mime_msg.attach(MIMEText(f"Failed to retrieve message body: {str(e)}", "plain"))
|
mime_msg.attach(MIMEText(f"Failed to retrieve message body: {str(e)}", "plain"))
|
||||||
|
|
||||||
|
|
||||||
async def add_attachments_async(mime_msg, message, headers, attachments_dir, progress):
|
async def add_attachments_async(
|
||||||
|
mime_msg, message, headers, attachments_dir, progress, session=None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Add attachments to a MIME message.
|
Add attachments to a MIME message.
|
||||||
|
|
||||||
@@ -285,6 +321,7 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
|
|||||||
headers (dict): Headers including authentication.
|
headers (dict): Headers including authentication.
|
||||||
attachments_dir (str): Path to save attachments.
|
attachments_dir (str): Path to save attachments.
|
||||||
progress: Progress instance for updating progress bars.
|
progress: Progress instance for updating progress bars.
|
||||||
|
session (aiohttp.ClientSession, optional): Shared session to use.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None
|
None
|
||||||
@@ -296,58 +333,57 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
|
|||||||
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/attachments"
|
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/attachments"
|
||||||
)
|
)
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
# Use provided session or get shared session
|
||||||
async with session.get(attachments_url, headers=headers) as response:
|
if session is None:
|
||||||
if response.status != 200:
|
session = await get_shared_session()
|
||||||
return
|
|
||||||
|
|
||||||
attachments_data = await response.json()
|
async with session.get(attachments_url, headers=headers) as response:
|
||||||
attachments = attachments_data.get("value", [])
|
if response.status != 200:
|
||||||
|
return
|
||||||
|
|
||||||
if not attachments:
|
attachments_data = await response.json()
|
||||||
return
|
attachments = attachments_data.get("value", [])
|
||||||
|
|
||||||
# Create a directory for this message's attachments
|
if not attachments:
|
||||||
message_attachments_dir = os.path.join(attachments_dir, message_id)
|
return
|
||||||
ensure_directory_exists(message_attachments_dir)
|
|
||||||
|
|
||||||
# Add a header with attachment count
|
# Create a directory for this message's attachments
|
||||||
mime_msg["X-Attachment-Count"] = str(len(attachments))
|
message_attachments_dir = os.path.join(attachments_dir, message_id)
|
||||||
|
ensure_directory_exists(message_attachments_dir)
|
||||||
|
|
||||||
for idx, attachment in enumerate(attachments):
|
# Add a header with attachment count
|
||||||
attachment_name = safe_filename(attachment.get("name", "attachment"))
|
mime_msg["X-Attachment-Count"] = str(len(attachments))
|
||||||
attachment_type = attachment.get(
|
|
||||||
"contentType", "application/octet-stream"
|
for idx, attachment in enumerate(attachments):
|
||||||
|
attachment_name = safe_filename(attachment.get("name", "attachment"))
|
||||||
|
attachment_type = attachment.get("contentType", "application/octet-stream")
|
||||||
|
|
||||||
|
# Add attachment info to headers for reference
|
||||||
|
mime_msg[f"X-Attachment-{idx + 1}-Name"] = attachment_name
|
||||||
|
mime_msg[f"X-Attachment-{idx + 1}-Type"] = attachment_type
|
||||||
|
|
||||||
|
attachment_part = MIMEBase(*attachment_type.split("/", 1))
|
||||||
|
|
||||||
|
# Get attachment content
|
||||||
|
if "contentBytes" in attachment:
|
||||||
|
attachment_content = base64.b64decode(attachment["contentBytes"])
|
||||||
|
|
||||||
|
# Save attachment to disk
|
||||||
|
attachment_path = os.path.join(message_attachments_dir, attachment_name)
|
||||||
|
with open(attachment_path, "wb") as f:
|
||||||
|
f.write(attachment_content)
|
||||||
|
|
||||||
|
# Add to MIME message
|
||||||
|
attachment_part.set_payload(attachment_content)
|
||||||
|
encoders.encode_base64(attachment_part)
|
||||||
|
attachment_part.add_header(
|
||||||
|
"Content-Disposition",
|
||||||
|
f'attachment; filename="{attachment_name}"',
|
||||||
)
|
)
|
||||||
|
mime_msg.attach(attachment_part)
|
||||||
|
|
||||||
# Add attachment info to headers for reference
|
progress.console.print(f"Downloaded attachment: {attachment_name}")
|
||||||
mime_msg[f"X-Attachment-{idx + 1}-Name"] = attachment_name
|
else:
|
||||||
mime_msg[f"X-Attachment-{idx + 1}-Type"] = attachment_type
|
progress.console.print(
|
||||||
|
f"Skipping attachment with no content: {attachment_name}"
|
||||||
attachment_part = MIMEBase(*attachment_type.split("/", 1))
|
)
|
||||||
|
|
||||||
# Get attachment content
|
|
||||||
if "contentBytes" in attachment:
|
|
||||||
attachment_content = base64.b64decode(attachment["contentBytes"])
|
|
||||||
|
|
||||||
# Save attachment to disk
|
|
||||||
attachment_path = os.path.join(
|
|
||||||
message_attachments_dir, attachment_name
|
|
||||||
)
|
|
||||||
with open(attachment_path, "wb") as f:
|
|
||||||
f.write(attachment_content)
|
|
||||||
|
|
||||||
# Add to MIME message
|
|
||||||
attachment_part.set_payload(attachment_content)
|
|
||||||
encoders.encode_base64(attachment_part)
|
|
||||||
attachment_part.add_header(
|
|
||||||
"Content-Disposition",
|
|
||||||
f'attachment; filename="{attachment_name}"',
|
|
||||||
)
|
|
||||||
mime_msg.attach(attachment_part)
|
|
||||||
|
|
||||||
progress.console.print(f"Downloaded attachment: {attachment_name}")
|
|
||||||
else:
|
|
||||||
progress.console.print(
|
|
||||||
f"Skipping attachment with no content: {attachment_name}"
|
|
||||||
)
|
|
||||||
|
|||||||
24
tests/fixtures/himalaya_test_config.toml
vendored
Normal file
24
tests/fixtures/himalaya_test_config.toml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Himalaya Test Configuration
|
||||||
|
#
|
||||||
|
# This configuration file sets up a local Maildir test account for integration testing.
|
||||||
|
# Copy this file to ~/.config/himalaya/config.toml or merge with existing config.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# himalaya -c tests/fixtures/himalaya_test_config.toml envelope list -a test-account
|
||||||
|
# himalaya -c tests/fixtures/himalaya_test_config.toml envelope list -a test-account from edson
|
||||||
|
#
|
||||||
|
# Or set the config path and use the test account:
|
||||||
|
# export HIMALAYA_CONFIG=tests/fixtures/himalaya_test_config.toml
|
||||||
|
# himalaya envelope list -a test-account
|
||||||
|
|
||||||
|
[accounts.test-account]
|
||||||
|
default = true
|
||||||
|
email = "test@example.com"
|
||||||
|
display-name = "Test User"
|
||||||
|
|
||||||
|
# Maildir backend configuration
|
||||||
|
backend.type = "maildir"
|
||||||
|
backend.root-dir = "tests/fixtures/test_mailbox"
|
||||||
|
|
||||||
|
# Message configuration
|
||||||
|
message.send.backend.type = "none"
|
||||||
22
tests/fixtures/test_mailbox/INBOX/cur/1702500005.000005.test:2,S
vendored
Normal file
22
tests/fixtures/test_mailbox/INBOX/cur/1702500005.000005.test:2,S
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
From: Edson Martinez <edson.martinez@example.com>
|
||||||
|
To: Test User <test@example.com>
|
||||||
|
Subject: DevOps weekly report
|
||||||
|
Date: Fri, 14 Dec 2025 16:00:00 -0600
|
||||||
|
Message-ID: <msg005@example.com>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Hi Team,
|
||||||
|
|
||||||
|
Here's the weekly DevOps report:
|
||||||
|
|
||||||
|
1. Server uptime: 99.9%
|
||||||
|
2. Deployments this week: 12
|
||||||
|
3. Incidents resolved: 3
|
||||||
|
4. Pending tasks: 5
|
||||||
|
|
||||||
|
The CI/CD pipeline improvements are on track for next week.
|
||||||
|
|
||||||
|
Best,
|
||||||
|
Edson Martinez
|
||||||
|
DevOps Lead
|
||||||
17
tests/fixtures/test_mailbox/INBOX/cur/1702600004.000004.test:2,S
vendored
Normal file
17
tests/fixtures/test_mailbox/INBOX/cur/1702600004.000004.test:2,S
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
From: Carol Davis <carol.davis@example.com>
|
||||||
|
To: Test User <test@example.com>
|
||||||
|
Subject: Re: Budget spreadsheet
|
||||||
|
Date: Thu, 15 Dec 2025 11:20:00 -0600
|
||||||
|
Message-ID: <msg004@example.com>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Hi,
|
||||||
|
|
||||||
|
Thanks for sending over the budget spreadsheet. I've reviewed it and everything looks good.
|
||||||
|
|
||||||
|
One small note: the Q3 numbers need to be updated with the final figures from accounting.
|
||||||
|
|
||||||
|
Let me know once that's done.
|
||||||
|
|
||||||
|
Carol
|
||||||
19
tests/fixtures/test_mailbox/INBOX/cur/1702700003.000003.test:2,
vendored
Normal file
19
tests/fixtures/test_mailbox/INBOX/cur/1702700003.000003.test:2,
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
From: Bob Williams <bob.williams@example.com>
|
||||||
|
To: Test User <test@example.com>
|
||||||
|
Subject: Urgent: Server maintenance tonight
|
||||||
|
Date: Wed, 16 Dec 2025 18:45:00 -0600
|
||||||
|
Message-ID: <msg003@example.com>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
URGENT
|
||||||
|
|
||||||
|
The production server will be undergoing maintenance tonight from 11pm to 2am.
|
||||||
|
Please save all your work before 10:30pm.
|
||||||
|
|
||||||
|
Edson from the DevOps team will be handling the maintenance.
|
||||||
|
|
||||||
|
Contact the IT helpdesk if you have any concerns.
|
||||||
|
|
||||||
|
Bob Williams
|
||||||
|
IT Department
|
||||||
17
tests/fixtures/test_mailbox/INBOX/cur/1702800002.000002.test:2,S
vendored
Normal file
17
tests/fixtures/test_mailbox/INBOX/cur/1702800002.000002.test:2,S
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
From: Alice Johnson <alice.johnson@example.com>
|
||||||
|
To: Test User <test@example.com>
|
||||||
|
Subject: Project proposal review
|
||||||
|
Date: Tue, 17 Dec 2025 14:30:00 -0600
|
||||||
|
Message-ID: <msg002@example.com>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Hello,
|
||||||
|
|
||||||
|
I've attached the project proposal for your review.
|
||||||
|
Please take a look and let me know if you have any questions.
|
||||||
|
|
||||||
|
The deadline for feedback is Friday.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
Alice Johnson
|
||||||
17
tests/fixtures/test_mailbox/INBOX/cur/1702900001.000001.test:2,S
vendored
Normal file
17
tests/fixtures/test_mailbox/INBOX/cur/1702900001.000001.test:2,S
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
From: John Smith <john.smith@example.com>
|
||||||
|
To: Test User <test@example.com>
|
||||||
|
Subject: Meeting tomorrow at 10am
|
||||||
|
Date: Mon, 18 Dec 2025 09:00:00 -0600
|
||||||
|
Message-ID: <msg001@example.com>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Hi Test User,
|
||||||
|
|
||||||
|
Just a reminder about our meeting tomorrow at 10am in the conference room.
|
||||||
|
We'll be discussing the Q4 budget review.
|
||||||
|
|
||||||
|
Please bring your laptop.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
John Smith
|
||||||
17
tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2
vendored
Normal file
17
tests/fixtures/test_mailbox/INBOX/cur/17051226-calendar-invite-001.test:2
vendored
Normal file
@@ -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
|
||||||
105
tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S
vendored
Normal file
105
tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S
vendored
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
Content-Type: multipart/mixed; boundary="===============1234567890123456789=="
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Message-ID: test-large-group-invite-001
|
||||||
|
Subject: Project Kickoff Meeting
|
||||||
|
From: Product Development <product.dev@example.com>
|
||||||
|
To: Wolf, Taylor <taylor.wolf@example.com>, Marshall, Cody <cody.marshall@example.com>,
|
||||||
|
Hernandez, Florencia <florencia.hernandez@example.com>, Santana, Jonatas <jonatas.santana@example.com>,
|
||||||
|
Product Development <product.dev@example.com>
|
||||||
|
Cc: Sevier, Josh <josh.sevier@example.com>, Rich, Melani <melani.rich@example.com>,
|
||||||
|
Gardner, Doug <doug.gardner@example.com>, Young, Lindsey <lindsey.young@example.com>,
|
||||||
|
Weathers, Robbie <robbie.weathers@example.com>, Wagner, Eric <eric.wagner@example.com>,
|
||||||
|
Richardson, Adrian <adrian.richardson@example.com>, Roeschlein, Mitch <mitch.roeschlein@example.com>,
|
||||||
|
Westphal, Bryan <bryan.westphal@example.com>, Jepsen, Gary <gary.jepsen@example.com>,
|
||||||
|
Srinivasan, Sathya <sathya.srinivasan@example.com>, Bomani, Zenobia <zenobia.bomani@example.com>,
|
||||||
|
Meyer, Andrew <andrew.meyer@example.com>, Stacy, Eric <eric.stacy@example.com>,
|
||||||
|
Bitra, Avinash <avinash.bitra@example.com>, Alvarado, Joseph <joseph.alvarado@example.com>,
|
||||||
|
Anderson, Pete <pete.anderson@example.com>, Modukuri, Savya <savya.modukuri@example.com>,
|
||||||
|
Vazrala, Sowjanya <sowjanya.vazrala@example.com>, Bendt, Timothy <timothy.bendt@example.com>
|
||||||
|
Date: Fri, 19 Dec 2025 21:42:58 +0000
|
||||||
|
|
||||||
|
--===============1234567890123456789==
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
|
Project Kickoff Meetings: officially launches each project. Provides alignment for everyone involved with the project (project team, scrum team members, stakeholders).
|
||||||
|
|
||||||
|
* Present project's purpose, goals, and scope. This meeting should ensure a shared understanding and commitment to success, preventing misunderstandings, building momentum, and setting clear expectations for collaboration from day one.
|
||||||
|
* Discuss possible subprojects and seasonal deliverables to meet commitments.
|
||||||
|
* Required Attendees: Project Team, Contributing Scrum Team Members, & Product Owners
|
||||||
|
* Optional Attendees: PDLT and Portfolio
|
||||||
|
|
||||||
|
Join the meeting: https://teams.microsoft.com/l/meetup-join/example
|
||||||
|
|
||||||
|
--===============1234567890123456789==
|
||||||
|
Content-Type: text/calendar; charset="utf-8"; method=REQUEST
|
||||||
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
METHOD:REQUEST
|
||||||
|
PRODID:Microsoft Exchange Server 2010
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Central Standard Time
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0600
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0600
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
ORGANIZER;CN="Product Development":mailto:product.dev@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Wolf, Taylor":mailto:taylor.wolf@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Marshall, Cody":mailto:cody.marshall@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Hernandez, Florencia":mailto:florencia.hernandez@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Santana, Jonatas":mailto:jonatas.santana@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Sevier, Josh":mailto:josh.sevier@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Rich, Melani":mailto:melani.rich@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Gardner, Doug":mailto:doug.gardner@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Young, Lindsey":mailto:lindsey.young@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Weathers, Robbie":mailto:robbie.weathers@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Wagner, Eric":mailto:eric.wagner@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Richardson, Adrian":mailto:adrian.richardson@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Roeschlein, Mitch":mailto:mitch.roeschlein@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Westphal, Bryan":mailto:bryan.westphal@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Jepsen, Gary":mailto:gary.jepsen@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Srinivasan, Sathya":mailto:sathya.srinivasan@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bomani, Zenobia":mailto:zenobia.bomani@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Meyer, Andrew":mailto:andrew.meyer@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Stacy, Eric":mailto:eric.stacy@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bitra, Avinash":mailto:avinash.bitra@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Alvarado, Joseph":mailto:joseph.alvarado@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Anderson, Pete":mailto:pete.anderson@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Modukuri, Savya":mailto:savya.modukuri@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Vazrala, Sowjanya":mailto:sowjanya.vazrala@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bendt, Timothy":mailto:timothy.bendt@example.com
|
||||||
|
UID:040000008200E00074C5B7101A82E0080000000004321F5267A12DA01000000000000000
|
||||||
|
10000000030899396012345678968B934EDD6628570
|
||||||
|
SUMMARY;LANGUAGE=en-US:Project Kickoff Meeting
|
||||||
|
DTSTART;TZID=Central Standard Time:20251219T140000
|
||||||
|
DTEND;TZID=Central Standard Time:20251219T150000
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20251219T214258Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:0
|
||||||
|
LOCATION;LANGUAGE=en-US:Microsoft Teams Meeting
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:0
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:0
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
|
||||||
|
--===============1234567890123456789==--
|
||||||
72
tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S
vendored
Normal file
72
tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
Content-Type: multipart/mixed; boundary="===============9876543210987654321=="
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Message-ID: test-cancellation-001
|
||||||
|
Subject: Canceled: Technical Refinement
|
||||||
|
From: Marshall, Cody <cody.marshall@example.com>
|
||||||
|
To: Ruttencutter, Chris <chris.ruttencutter@example.com>, Dake, Ryan <ryan.dake@example.com>,
|
||||||
|
Smith, James <james.smith@example.com>, Santana, Jonatas <jonatas.santana@example.com>
|
||||||
|
Cc: Bendt, Timothy <timothy.bendt@example.com>
|
||||||
|
Date: Fri, 19 Dec 2025 19:12:46 +0000
|
||||||
|
Importance: high
|
||||||
|
X-Priority: 1
|
||||||
|
|
||||||
|
--===============9876543210987654321==
|
||||||
|
Content-Type: text/plain; charset="us-ascii"
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
|
The meeting has been cancelled.
|
||||||
|
|
||||||
|
--===============9876543210987654321==
|
||||||
|
Content-Type: text/calendar; charset="utf-8"; method=CANCEL
|
||||||
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
METHOD:CANCEL
|
||||||
|
PRODID:Microsoft Exchange Server 2010
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Central Standard Time
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0600
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0600
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
ORGANIZER;CN="Marshall, Cody":mailto:cody.marshall@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Ruttencutter, Chris":mailto:chris.ruttencutter@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Dake, Ryan":mailto:ryan.dake@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Smith, James":mailto:james.smith@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Santana, Jonatas":mailto:jonatas.santana@example.com
|
||||||
|
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bendt, Timothy":mailto:timothy.bendt@example.com
|
||||||
|
UID:040000008200E00074C5B7101A82E00800000000043F526712345678901000000000000000
|
||||||
|
10000000308993960B03FD4C968B934EDD662857
|
||||||
|
RECURRENCE-ID;TZID=Central Standard Time:20251224T133000
|
||||||
|
SUMMARY;LANGUAGE=en-US:Canceled: Technical Refinement
|
||||||
|
DTSTART;TZID=Central Standard Time:20251224T133000
|
||||||
|
DTEND;TZID=Central Standard Time:20251224T140000
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:1
|
||||||
|
DTSTAMP:20251219T191240Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
STATUS:CANCELLED
|
||||||
|
SEQUENCE:84
|
||||||
|
LOCATION;LANGUAGE=en-US:Microsoft Teams Meeting
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:84
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:FREE
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:FREE
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:2
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:3
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
|
||||||
|
--===============9876543210987654321==--
|
||||||
141
tests/test_calendar_parsing.py
Normal file
141
tests/test_calendar_parsing.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Unit tests for calendar email detection and ICS parsing."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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"""
|
||||||
|
|
||||||
|
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/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"""
|
||||||
|
|
||||||
|
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 == "Team Standup"
|
||||||
|
assert event.location == "Conference Room A"
|
||||||
|
|
||||||
|
def test_invalid_ics(self):
|
||||||
|
"""Test parsing of invalid ICS content."""
|
||||||
|
event = parse_ics_content("invalid ics content")
|
||||||
|
assert event is None # Should return None for invalid ICS
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
--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"
|
||||||
375
tests/test_header_parsing.py
Normal file
375
tests/test_header_parsing.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
"""Unit tests for email header parsing from message content.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
pytest tests/test_header_parsing.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path for proper imports
|
||||||
|
project_root = str(Path(__file__).parent.parent)
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
|
||||||
|
# Sample cancelled meeting email from himalaya (message 114)
|
||||||
|
CANCELLED_MEETING_EMAIL = """From: Marshall <unknown>, Cody <john.marshall@corteva.com>
|
||||||
|
To: Ruttencutter <unknown>, Chris <chris.ruttencutter@corteva.com>, Dake <unknown>, Ryan <ryan.dake@corteva.com>, Smith <unknown>, James <james.l.smith@corteva.com>, Santana <unknown>, Jonatas <jonatas.santana@corteva.com>
|
||||||
|
Cc: Bendt <unknown>, Timothy <timothy.bendt@corteva.com>
|
||||||
|
Subject: Canceled: Technical Refinement
|
||||||
|
|
||||||
|
Received: from CY8PR17MB7060.namprd17.prod.outlook.com (2603:10b6:930:6d::6)
|
||||||
|
by PH7PR17MB7149.namprd17.prod.outlook.com with HTTPS; Fri, 19 Dec 2025
|
||||||
|
19:12:45 +0000
|
||||||
|
Received: from SA6PR17MB7362.namprd17.prod.outlook.com (2603:10b6:806:411::6)
|
||||||
|
by CY8PR17MB7060.namprd17.prod.outlook.com (2603:10b6:930:6d::6) with
|
||||||
|
Microsoft SMTP Server (version=TLS1_2,
|
||||||
|
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.9434.8; Fri, 19 Dec
|
||||||
|
2025 19:12:42 +0000
|
||||||
|
From: "Marshall, Cody" <john.marshall@corteva.com>
|
||||||
|
To: "Ruttencutter, Chris" <chris.ruttencutter@corteva.com>, "Dake, Ryan"
|
||||||
|
<ryan.dake@corteva.com>, "Smith, James" <james.l.smith@corteva.com>,
|
||||||
|
"Santana, Jonatas" <jonatas.santana@corteva.com>
|
||||||
|
CC: "Bendt, Timothy" <timothy.bendt@corteva.com>
|
||||||
|
Subject: Canceled: Technical Refinement
|
||||||
|
Thread-Topic: Technical Refinement
|
||||||
|
Thread-Index: AdoSeicQGeYQHp7iHUWAUBWrOGskKw==
|
||||||
|
Importance: high
|
||||||
|
X-Priority: 1
|
||||||
|
Date: Fri, 19 Dec 2025 19:12:42 +0000
|
||||||
|
Message-ID:
|
||||||
|
<SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362.namprd17.prod.outlook.com>
|
||||||
|
Accept-Language: en-US
|
||||||
|
Content-Language: en-US
|
||||||
|
X-MS-Exchange-Organization-AuthAs: Internal
|
||||||
|
X-MS-Exchange-Organization-AuthMechanism: 04
|
||||||
|
Content-Type: multipart/alternative;
|
||||||
|
boundary="_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_"
|
||||||
|
MIME-Version: 1.0
|
||||||
|
|
||||||
|
--_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_
|
||||||
|
Content-Type: text/plain; charset="us-ascii"
|
||||||
|
|
||||||
|
|
||||||
|
--_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_
|
||||||
|
Content-Type: text/calendar; charset="utf-8"; method=CANCEL
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
QkVHSU46VkNBTEVOREFSDQpNRVRIT0Q6Q0FOQ0VMDQpQUk9ESUQ6TWljcm9zb2Z0IEV4Y2hhbmdl
|
||||||
|
IFNlcnZlciAyMDEwDQpWRVJTSU9OOjIuMA0KQkVHSU46VlRJTUVaT05FDQpUWklEOkNlbnRyYWwg
|
||||||
|
U3RhbmRhcmQgVGltZQ0KQkVHSU46U1RBTkRBUkQNCkRUU1RBUlQ6MTYwMTAxMDFUMDIwMDAwDQpU
|
||||||
|
|
||||||
|
--_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_--
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseHeadersFromContent:
|
||||||
|
"""Tests for _parse_headers_from_content method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parser(self):
|
||||||
|
"""Create a ContentContainer instance for testing."""
|
||||||
|
# We need to create a minimal instance just for the parsing method
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from src.mail.widgets.ContentContainer import ContentContainer
|
||||||
|
|
||||||
|
container = ContentContainer()
|
||||||
|
return container._parse_headers_from_content
|
||||||
|
|
||||||
|
def test_parse_simple_headers(self, parser):
|
||||||
|
"""Test parsing simple email headers."""
|
||||||
|
content = """From: John Doe <john@example.com>
|
||||||
|
To: Jane Smith <jane@example.com>
|
||||||
|
Subject: Test Email
|
||||||
|
Date: Mon, 29 Dec 2025 10:00:00 +0000
|
||||||
|
|
||||||
|
This is the body of the email.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert headers["from"] == "John Doe <john@example.com>"
|
||||||
|
assert headers["to"] == "Jane Smith <jane@example.com>"
|
||||||
|
assert headers["subject"] == "Test Email"
|
||||||
|
assert headers["date"] == "Mon, 29 Dec 2025 10:00:00 +0000"
|
||||||
|
|
||||||
|
def test_parse_multiple_recipients(self, parser):
|
||||||
|
"""Test parsing headers with multiple recipients."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: user1@example.com, user2@example.com, user3@example.com
|
||||||
|
Subject: Multi-recipient email
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body here.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
headers["to"] == "user1@example.com, user2@example.com, user3@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_with_cc(self, parser):
|
||||||
|
"""Test parsing headers including CC."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: recipient@example.com
|
||||||
|
CC: cc1@example.com, cc2@example.com
|
||||||
|
Subject: Email with CC
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert headers["to"] == "recipient@example.com"
|
||||||
|
assert headers["cc"] == "cc1@example.com, cc2@example.com"
|
||||||
|
|
||||||
|
def test_parse_multiline_to_header(self, parser):
|
||||||
|
"""Test parsing To header that spans multiple lines."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: First User <first@example.com>,
|
||||||
|
Second User <second@example.com>,
|
||||||
|
Third User <third@example.com>
|
||||||
|
Subject: Multi-line To
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
# Should combine continuation lines
|
||||||
|
assert "First User" in headers["to"]
|
||||||
|
assert "Second User" in headers["to"]
|
||||||
|
assert "Third User" in headers["to"]
|
||||||
|
|
||||||
|
def test_parse_with_name_and_email(self, parser):
|
||||||
|
"""Test parsing headers with display names and email addresses."""
|
||||||
|
content = """From: Renovate Bot (SA @renovate-bot-sa) <gitlab@example.com>
|
||||||
|
To: Bendt <unknown>, Timothy <timothy.bendt@example.com>
|
||||||
|
Subject: Test Subject
|
||||||
|
Date: 2025-12-29 02:07+00:00
|
||||||
|
|
||||||
|
Body content.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
headers["from"] == "Renovate Bot (SA @renovate-bot-sa) <gitlab@example.com>"
|
||||||
|
)
|
||||||
|
assert "Timothy <timothy.bendt@example.com>" in headers["to"]
|
||||||
|
assert "Bendt <unknown>" in headers["to"]
|
||||||
|
|
||||||
|
def test_parse_empty_content(self, parser):
|
||||||
|
"""Test parsing empty content."""
|
||||||
|
headers = parser("")
|
||||||
|
assert headers == {}
|
||||||
|
|
||||||
|
def test_parse_no_headers(self, parser):
|
||||||
|
"""Test parsing content with no recognizable headers."""
|
||||||
|
content = """This is just body content
|
||||||
|
without any headers.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
assert headers == {}
|
||||||
|
|
||||||
|
def test_parse_ignores_unknown_headers(self, parser):
|
||||||
|
"""Test that unknown headers are ignored."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
X-Custom-Header: some value
|
||||||
|
To: recipient@example.com
|
||||||
|
Message-ID: <123@example.com>
|
||||||
|
Subject: Test
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
# Should only have the recognized headers
|
||||||
|
assert set(headers.keys()) == {"from", "to", "subject", "date"}
|
||||||
|
assert "X-Custom-Header" not in headers
|
||||||
|
assert "Message-ID" not in headers
|
||||||
|
|
||||||
|
def test_parse_real_himalaya_output(self, parser):
|
||||||
|
"""Test parsing actual himalaya message read output format."""
|
||||||
|
content = """From: Renovate Bot (SA @renovate-bot-sa) <gitlab@gitlab.research.corteva.com>
|
||||||
|
To: Bendt <unknown>, Timothy <timothy.bendt@corteva.com>
|
||||||
|
Subject: Re: Fabric3 Monorepo | chore(deps): update vitest monorepo to v4 (major) (!6861)
|
||||||
|
|
||||||
|
Renovate Bot (SA) pushed new commits to merge request !6861<https://gitlab.research.corteva.com/granular/fabric/fabric3/-/merge_requests/6861>
|
||||||
|
|
||||||
|
* f96fec2b...2fb2ae10 <https://gitlab.research.corteva.com/granular/fabric/fabric3/-/compare/f96fec2b...2fb2ae10> - 2 commits from branch `main`
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
headers["from"]
|
||||||
|
== "Renovate Bot (SA @renovate-bot-sa) <gitlab@gitlab.research.corteva.com>"
|
||||||
|
)
|
||||||
|
assert headers["to"] == "Bendt <unknown>, Timothy <timothy.bendt@corteva.com>"
|
||||||
|
assert "Fabric3 Monorepo" in headers["subject"]
|
||||||
|
|
||||||
|
def test_parse_cancelled_meeting_headers(self, parser):
|
||||||
|
"""Test parsing headers from a cancelled meeting email."""
|
||||||
|
headers = parser(CANCELLED_MEETING_EMAIL)
|
||||||
|
|
||||||
|
# Should extract the first occurrence of headers (simplified format)
|
||||||
|
assert "Canceled: Technical Refinement" in headers.get("subject", "")
|
||||||
|
assert "corteva.com" in headers.get("to", "")
|
||||||
|
assert "cc" in headers # Should have CC
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripHeadersFromContent:
|
||||||
|
"""Tests for _strip_headers_from_content method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stripper(self):
|
||||||
|
"""Create a ContentContainer instance for testing."""
|
||||||
|
from src.mail.widgets.ContentContainer import ContentContainer
|
||||||
|
|
||||||
|
container = ContentContainer()
|
||||||
|
return container._strip_headers_from_content
|
||||||
|
|
||||||
|
def test_strip_simple_headers(self, stripper):
|
||||||
|
"""Test stripping simple headers from content."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: recipient@example.com
|
||||||
|
Subject: Test
|
||||||
|
|
||||||
|
This is the body.
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
assert "From:" not in result
|
||||||
|
assert "To:" not in result
|
||||||
|
assert "Subject:" not in result
|
||||||
|
assert "This is the body" in result
|
||||||
|
|
||||||
|
def test_strip_mime_boundaries(self, stripper):
|
||||||
|
"""Test stripping MIME boundary markers."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
Subject: Test
|
||||||
|
|
||||||
|
--boundary123456789
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
Hello world
|
||||||
|
|
||||||
|
--boundary123456789--
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
assert "--boundary" not in result
|
||||||
|
assert "Hello world" in result
|
||||||
|
|
||||||
|
def test_strip_base64_content(self, stripper):
|
||||||
|
"""Test stripping base64 encoded content."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
Subject: Test
|
||||||
|
|
||||||
|
--boundary
|
||||||
|
Content-Type: text/calendar
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
QkVHSU46VkNBTEVOREFSDQpNRVRIT0Q6Q0FOQ0VMDQpQUk9ESUQ6TWljcm9zb2Z0
|
||||||
|
IEV4Y2hhbmdlIFNlcnZlciAyMDEwDQpWRVJTSU9OOjIuMA0KQkVHSU46VlRJTUVa
|
||||||
|
|
||||||
|
--boundary--
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
# Should not contain base64 content
|
||||||
|
assert "QkVHSU46" not in result
|
||||||
|
assert "VKNTVU9OOjIuMA" not in result
|
||||||
|
|
||||||
|
def test_strip_cancelled_meeting_email(self, stripper):
|
||||||
|
"""Test stripping a cancelled meeting email - should result in empty/minimal content."""
|
||||||
|
result = stripper(CANCELLED_MEETING_EMAIL)
|
||||||
|
|
||||||
|
# Should not contain headers
|
||||||
|
assert "From:" not in result
|
||||||
|
assert "To:" not in result
|
||||||
|
assert "Subject:" not in result
|
||||||
|
assert "Received:" not in result
|
||||||
|
assert "Content-Type:" not in result
|
||||||
|
|
||||||
|
# Should not contain MIME boundaries
|
||||||
|
assert "--_002_" not in result
|
||||||
|
|
||||||
|
# Should not contain base64
|
||||||
|
assert "QkVHSU46" not in result
|
||||||
|
|
||||||
|
# The result should be empty or just whitespace since the text/plain part is empty
|
||||||
|
assert result.strip() == "" or len(result.strip()) < 50
|
||||||
|
|
||||||
|
def test_strip_vcalendar_content(self, stripper):
|
||||||
|
"""Test stripping vCalendar/ICS content."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
Subject: Meeting
|
||||||
|
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
METHOD:REQUEST
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Team Meeting
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
assert "BEGIN:VCALENDAR" not in result
|
||||||
|
assert "END:VCALENDAR" not in result
|
||||||
|
assert "VEVENT" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatRecipients:
|
||||||
|
"""Tests for _format_recipients method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def formatter(self):
|
||||||
|
"""Create a ContentContainer instance for testing."""
|
||||||
|
from src.mail.widgets.ContentContainer import ContentContainer
|
||||||
|
|
||||||
|
container = ContentContainer()
|
||||||
|
return container._format_recipients
|
||||||
|
|
||||||
|
def test_format_string_recipient(self, formatter):
|
||||||
|
"""Test formatting a string recipient."""
|
||||||
|
result = formatter("user@example.com")
|
||||||
|
assert result == "user@example.com"
|
||||||
|
|
||||||
|
def test_format_dict_recipient(self, formatter):
|
||||||
|
"""Test formatting a dict recipient."""
|
||||||
|
result = formatter({"name": "John Doe", "addr": "john@example.com"})
|
||||||
|
assert result == "John Doe <john@example.com>"
|
||||||
|
|
||||||
|
def test_format_dict_recipient_name_only(self, formatter):
|
||||||
|
"""Test formatting a dict with name only."""
|
||||||
|
result = formatter({"name": "John Doe", "addr": ""})
|
||||||
|
assert result == "John Doe"
|
||||||
|
|
||||||
|
def test_format_dict_recipient_addr_only(self, formatter):
|
||||||
|
"""Test formatting a dict with addr only."""
|
||||||
|
result = formatter({"name": None, "addr": "john@example.com"})
|
||||||
|
assert result == "john@example.com"
|
||||||
|
|
||||||
|
def test_format_dict_recipient_empty(self, formatter):
|
||||||
|
"""Test formatting an empty dict recipient."""
|
||||||
|
result = formatter({"name": None, "addr": ""})
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_format_list_recipients(self, formatter):
|
||||||
|
"""Test formatting a list of recipients."""
|
||||||
|
result = formatter(
|
||||||
|
[
|
||||||
|
{"name": "John", "addr": "john@example.com"},
|
||||||
|
{"name": "Jane", "addr": "jane@example.com"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert result == "John <john@example.com>, Jane <jane@example.com>"
|
||||||
|
|
||||||
|
def test_format_empty(self, formatter):
|
||||||
|
"""Test formatting empty input."""
|
||||||
|
assert formatter(None) == ""
|
||||||
|
assert formatter("") == ""
|
||||||
|
assert formatter([]) == ""
|
||||||
274
tests/test_himalaya_integration.py
Normal file
274
tests/test_himalaya_integration.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""Integration tests for Himalaya client with test mailbox.
|
||||||
|
|
||||||
|
These tests use a local Maildir test mailbox to verify himalaya operations
|
||||||
|
without touching real email accounts.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
pytest tests/test_himalaya_integration.py -v
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- himalaya CLI installed
|
||||||
|
- tests/fixtures/test_mailbox with sample emails
|
||||||
|
- tests/fixtures/himalaya_test_config.toml
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Path to the test config
|
||||||
|
TEST_CONFIG = Path(__file__).parent / "fixtures" / "himalaya_test_config.toml"
|
||||||
|
TEST_MAILBOX = Path(__file__).parent / "fixtures" / "test_mailbox"
|
||||||
|
|
||||||
|
|
||||||
|
def himalaya_available() -> bool:
|
||||||
|
"""Check if himalaya CLI is installed."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["himalaya", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Skip all tests if himalaya is not installed
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not himalaya_available(),
|
||||||
|
reason="himalaya CLI not installed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def himalaya_cmd():
|
||||||
|
"""Return the base himalaya command with test config."""
|
||||||
|
# Note: -a must come after the subcommand in himalaya
|
||||||
|
return f"himalaya -c {TEST_CONFIG}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def account_arg():
|
||||||
|
"""Return the account argument for himalaya."""
|
||||||
|
return "-a test-account"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHimalayaListEnvelopes:
|
||||||
|
"""Tests for listing envelopes."""
|
||||||
|
|
||||||
|
def test_list_all_envelopes(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test listing all envelopes from test mailbox."""
|
||||||
|
result = subprocess.run(
|
||||||
|
f"{himalaya_cmd} envelope list {account_arg} -o json",
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Error: {result.stderr}"
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
assert len(envelopes) == 5, f"Expected 5 emails, got {len(envelopes)}"
|
||||||
|
|
||||||
|
def test_envelope_has_required_fields(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test that envelopes have all required fields."""
|
||||||
|
result = subprocess.run(
|
||||||
|
f"{himalaya_cmd} envelope list {account_arg} -o json",
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
required_fields = ["id", "subject", "from", "to", "date"]
|
||||||
|
|
||||||
|
for envelope in envelopes:
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in envelope, f"Missing field: {field}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHimalayaSearch:
|
||||||
|
"""Tests for search functionality."""
|
||||||
|
|
||||||
|
def test_search_by_from_name(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test searching by sender name."""
|
||||||
|
result = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "from edson"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Error: {result.stderr}"
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
assert len(envelopes) == 1
|
||||||
|
assert "Edson" in envelopes[0]["from"]["name"]
|
||||||
|
|
||||||
|
def test_search_by_body_content(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test searching by body content."""
|
||||||
|
result = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "body edson"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Error: {result.stderr}"
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
# Should find 2: one from Edson, one mentioning Edson in body
|
||||||
|
assert len(envelopes) == 2
|
||||||
|
|
||||||
|
def test_search_by_subject(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test searching by subject."""
|
||||||
|
result = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "subject meeting"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Error: {result.stderr}"
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
assert len(envelopes) == 1
|
||||||
|
assert "Meeting" in envelopes[0]["subject"]
|
||||||
|
|
||||||
|
def test_search_compound_or_query(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test compound OR search query."""
|
||||||
|
result = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "from edson or body edson"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Error: {result.stderr}"
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
# Should find both emails mentioning Edson
|
||||||
|
assert len(envelopes) >= 2
|
||||||
|
|
||||||
|
def test_search_no_results(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test search that returns no results."""
|
||||||
|
result = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "from nonexistent_person_xyz"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Error: {result.stderr}"
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
assert len(envelopes) == 0
|
||||||
|
|
||||||
|
def test_search_full_compound_query(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test the full compound query format used by our search function."""
|
||||||
|
query = "edson"
|
||||||
|
search_query = f"from {query} or to {query} or subject {query} or body {query}"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "{search_query}"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Error: {result.stderr}"
|
||||||
|
|
||||||
|
envelopes = json.loads(result.stdout)
|
||||||
|
# Should find emails where Edson is sender or mentioned in body
|
||||||
|
assert len(envelopes) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestHimalayaReadMessage:
|
||||||
|
"""Tests for reading message content."""
|
||||||
|
|
||||||
|
def test_read_message_by_id(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test reading a message by ID."""
|
||||||
|
# First get the list to find an ID
|
||||||
|
list_result = subprocess.run(
|
||||||
|
f"{himalaya_cmd} envelope list {account_arg} -o json",
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert list_result.returncode == 0
|
||||||
|
|
||||||
|
envelopes = json.loads(list_result.stdout)
|
||||||
|
message_id = envelopes[0]["id"]
|
||||||
|
|
||||||
|
# Read the message
|
||||||
|
read_result = subprocess.run(
|
||||||
|
f"{himalaya_cmd} message read {account_arg} {message_id}",
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert read_result.returncode == 0, f"Error: {read_result.stderr}"
|
||||||
|
assert len(read_result.stdout) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestHimalayaAsyncClient:
|
||||||
|
"""Tests for the async himalaya client module."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_envelopes_async(self):
|
||||||
|
"""Test the async search_envelopes function."""
|
||||||
|
# Import here to avoid issues if himalaya module has import errors
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add project root to path for proper imports
|
||||||
|
project_root = str(Path(__file__).parent.parent)
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
from src.services.himalaya import client as himalaya_client
|
||||||
|
|
||||||
|
# Note: This test would need the CLI to use our test config
|
||||||
|
# For now, just verify the function exists and has correct signature
|
||||||
|
assert hasattr(himalaya_client, "search_envelopes")
|
||||||
|
assert asyncio.iscoroutinefunction(himalaya_client.search_envelopes)
|
||||||
|
|
||||||
|
|
||||||
|
# Additional test for edge cases
|
||||||
|
class TestSearchEdgeCases:
|
||||||
|
"""Edge case tests for search."""
|
||||||
|
|
||||||
|
def test_search_with_special_characters(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test searching with special characters in query."""
|
||||||
|
# This should not crash, even if no results
|
||||||
|
result = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "subject Q4"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
# May fail or succeed depending on himalaya version
|
||||||
|
# Just verify it doesn't crash catastrophically
|
||||||
|
assert result.returncode in [0, 1]
|
||||||
|
|
||||||
|
def test_search_case_insensitive(self, himalaya_cmd, account_arg):
|
||||||
|
"""Test that search is case insensitive."""
|
||||||
|
result_upper = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "from EDSON"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
result_lower = subprocess.run(
|
||||||
|
f'{himalaya_cmd} envelope list {account_arg} -o json "from edson"',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both should succeed
|
||||||
|
assert result_upper.returncode == 0
|
||||||
|
assert result_lower.returncode == 0
|
||||||
|
|
||||||
|
# Results should be the same (case insensitive)
|
||||||
|
upper_envelopes = json.loads(result_upper.stdout)
|
||||||
|
lower_envelopes = json.loads(result_lower.stdout)
|
||||||
|
assert len(upper_envelopes) == len(lower_envelopes)
|
||||||
183
tests/test_invite_compressor.py
Normal file
183
tests/test_invite_compressor.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""Tests for calendar invite compression."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.mail.invite_compressor import InviteCompressor, compress_invite
|
||||||
|
from src.mail.utils.calendar_parser import (
|
||||||
|
parse_calendar_from_raw_message,
|
||||||
|
is_cancelled_event,
|
||||||
|
is_event_request,
|
||||||
|
)
|
||||||
|
from src.mail.notification_detector import is_calendar_email
|
||||||
|
|
||||||
|
|
||||||
|
class TestInviteDetection:
|
||||||
|
"""Test detection of calendar invite emails."""
|
||||||
|
|
||||||
|
def test_detect_large_group_invite(self):
|
||||||
|
"""Test detection of large group meeting invite."""
|
||||||
|
fixture_path = Path(
|
||||||
|
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||||
|
)
|
||||||
|
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||||
|
|
||||||
|
with open(fixture_path, "r") as f:
|
||||||
|
raw_message = f.read()
|
||||||
|
|
||||||
|
# Create envelope from message
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "product.dev@example.com", "name": "Product Development"},
|
||||||
|
"subject": "Project Kickoff Meeting",
|
||||||
|
"date": "2025-12-19T21:42:58+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should be detected as calendar email
|
||||||
|
assert is_calendar_email(envelope) is True
|
||||||
|
|
||||||
|
# Parse the ICS
|
||||||
|
event = parse_calendar_from_raw_message(raw_message)
|
||||||
|
assert event is not None
|
||||||
|
assert event.method == "REQUEST"
|
||||||
|
assert is_event_request(event) is True
|
||||||
|
assert event.summary == "Project Kickoff Meeting"
|
||||||
|
assert len(event.attendees) >= 20 # Large group
|
||||||
|
|
||||||
|
def test_detect_cancellation(self):
|
||||||
|
"""Test detection of meeting cancellation."""
|
||||||
|
fixture_path = Path(
|
||||||
|
"tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S"
|
||||||
|
)
|
||||||
|
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||||
|
|
||||||
|
with open(fixture_path, "r") as f:
|
||||||
|
raw_message = f.read()
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "cody.marshall@example.com", "name": "Marshall, Cody"},
|
||||||
|
"subject": "Canceled: Technical Refinement",
|
||||||
|
"date": "2025-12-19T19:12:46+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should be detected as calendar email
|
||||||
|
assert is_calendar_email(envelope) is True
|
||||||
|
|
||||||
|
# Parse the ICS
|
||||||
|
event = parse_calendar_from_raw_message(raw_message)
|
||||||
|
assert event is not None
|
||||||
|
assert event.method == "CANCEL"
|
||||||
|
assert is_cancelled_event(event) is True
|
||||||
|
assert event.status == "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInviteCompression:
|
||||||
|
"""Test compression of calendar invite content."""
|
||||||
|
|
||||||
|
def test_compress_large_group_invite(self):
|
||||||
|
"""Test compression of large group meeting invite."""
|
||||||
|
fixture_path = Path(
|
||||||
|
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||||
|
)
|
||||||
|
with open(fixture_path, "r") as f:
|
||||||
|
raw_message = f.read()
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "product.dev@example.com", "name": "Product Development"},
|
||||||
|
"subject": "Project Kickoff Meeting",
|
||||||
|
"date": "2025-12-19T21:42:58+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
compressor = InviteCompressor(mode="summary")
|
||||||
|
compressed, event = compressor.compress(raw_message, envelope)
|
||||||
|
|
||||||
|
assert event is not None
|
||||||
|
assert "MEETING INVITE" in compressed
|
||||||
|
assert "Project Kickoff Meeting" in compressed
|
||||||
|
# Should show compressed attendee list
|
||||||
|
assert "more)" in compressed # Truncated attendee list
|
||||||
|
# Should show action hints for REQUEST
|
||||||
|
assert "Accept" in compressed
|
||||||
|
|
||||||
|
def test_compress_cancellation(self):
|
||||||
|
"""Test compression of meeting cancellation."""
|
||||||
|
fixture_path = Path(
|
||||||
|
"tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S"
|
||||||
|
)
|
||||||
|
with open(fixture_path, "r") as f:
|
||||||
|
raw_message = f.read()
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "cody.marshall@example.com", "name": "Marshall, Cody"},
|
||||||
|
"subject": "Canceled: Technical Refinement",
|
||||||
|
"date": "2025-12-19T19:12:46+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
compressor = InviteCompressor(mode="summary")
|
||||||
|
compressed, event = compressor.compress(raw_message, envelope)
|
||||||
|
|
||||||
|
assert event is not None
|
||||||
|
assert "CANCELLED" in compressed
|
||||||
|
# Title should be strikethrough (without the Canceled: prefix)
|
||||||
|
assert "~~Technical Refinement~~" in compressed
|
||||||
|
# Should NOT show action hints for cancelled meetings
|
||||||
|
assert "Accept" not in compressed
|
||||||
|
|
||||||
|
def test_attendee_compression(self):
|
||||||
|
"""Test attendee list compression."""
|
||||||
|
compressor = InviteCompressor()
|
||||||
|
|
||||||
|
# Test with few attendees
|
||||||
|
attendees = ["Alice <alice@example.com>", "Bob <bob@example.com>"]
|
||||||
|
result = compressor._compress_attendees(attendees)
|
||||||
|
assert result == "Alice, Bob"
|
||||||
|
|
||||||
|
# Test with many attendees
|
||||||
|
many_attendees = [
|
||||||
|
"Alice <alice@example.com>",
|
||||||
|
"Bob <bob@example.com>",
|
||||||
|
"Carol <carol@example.com>",
|
||||||
|
"Dave <dave@example.com>",
|
||||||
|
"Eve <eve@example.com>",
|
||||||
|
]
|
||||||
|
result = compressor._compress_attendees(many_attendees, max_shown=3)
|
||||||
|
assert "Alice" in result
|
||||||
|
assert "Bob" in result
|
||||||
|
assert "Carol" in result
|
||||||
|
assert "(+2 more)" in result
|
||||||
|
|
||||||
|
def test_compress_off_mode(self):
|
||||||
|
"""Test that compression can be disabled."""
|
||||||
|
fixture_path = Path(
|
||||||
|
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||||
|
)
|
||||||
|
with open(fixture_path, "r") as f:
|
||||||
|
raw_message = f.read()
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "product.dev@example.com"},
|
||||||
|
"subject": "Project Kickoff Meeting",
|
||||||
|
}
|
||||||
|
|
||||||
|
compressor = InviteCompressor(mode="off")
|
||||||
|
assert compressor.should_compress(envelope) is False
|
||||||
|
|
||||||
|
compressed, event = compressor.compress(raw_message, envelope)
|
||||||
|
assert compressed == ""
|
||||||
|
assert event is None
|
||||||
|
|
||||||
|
def test_convenience_function(self):
|
||||||
|
"""Test the compress_invite convenience function."""
|
||||||
|
fixture_path = Path(
|
||||||
|
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||||
|
)
|
||||||
|
with open(fixture_path, "r") as f:
|
||||||
|
raw_message = f.read()
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "product.dev@example.com"},
|
||||||
|
"subject": "Project Kickoff Meeting",
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed, event = compress_invite(raw_message, envelope)
|
||||||
|
assert event is not None
|
||||||
|
assert "Project Kickoff Meeting" in compressed
|
||||||
172
tests/test_notification_detector.py
Normal file
172
tests/test_notification_detector.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Tests for notification email detection and classification."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.mail.notification_detector import (
|
||||||
|
is_notification_email,
|
||||||
|
classify_notification,
|
||||||
|
extract_notification_summary,
|
||||||
|
NOTIFICATION_TYPES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotificationDetection:
|
||||||
|
"""Test notification email detection."""
|
||||||
|
|
||||||
|
def test_gitlab_pipeline_notification(self):
|
||||||
|
"""Test GitLab pipeline notification detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "notifications@gitlab.com"},
|
||||||
|
"subject": "Pipeline #12345 failed",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
notif_type = classify_notification(envelope)
|
||||||
|
assert notif_type is not None
|
||||||
|
assert notif_type.name == "gitlab"
|
||||||
|
|
||||||
|
def test_gitlab_mr_notification(self):
|
||||||
|
"""Test GitLab merge request notification detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "noreply@gitlab.com"},
|
||||||
|
"subject": "[GitLab] Merge request: Update dependencies",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
def test_github_pr_notification(self):
|
||||||
|
"""Test GitHub PR notification detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "noreply@github.com"},
|
||||||
|
"subject": "[GitHub] PR #42: Add new feature",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
notif_type = classify_notification(envelope)
|
||||||
|
assert notif_type is not None
|
||||||
|
assert notif_type.name == "github"
|
||||||
|
|
||||||
|
def test_jira_notification(self):
|
||||||
|
"""Test Jira notification detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "jira@company.com"},
|
||||||
|
"subject": "[Jira] ABC-123: Fix login bug",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
notif_type = classify_notification(envelope)
|
||||||
|
assert notif_type is not None
|
||||||
|
assert notif_type.name == "jira"
|
||||||
|
|
||||||
|
def test_confluence_notification(self):
|
||||||
|
"""Test Confluence notification detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "confluence@atlassian.net"},
|
||||||
|
"subject": "[Confluence] New comment on page",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
notif_type = classify_notification(envelope)
|
||||||
|
assert notif_type is not None
|
||||||
|
assert notif_type.name == "confluence"
|
||||||
|
|
||||||
|
def test_datadog_alert_notification(self):
|
||||||
|
"""Test Datadog alert notification detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "alerts@datadoghq.com"},
|
||||||
|
"subject": "[Datadog] Alert: High CPU usage",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
notif_type = classify_notification(envelope)
|
||||||
|
assert notif_type is not None
|
||||||
|
assert notif_type.name == "datadog"
|
||||||
|
|
||||||
|
def test_renovate_notification(self):
|
||||||
|
"""Test Renovate notification detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "renovate@renovatebot.com"},
|
||||||
|
"subject": "[Renovate] Update dependency to v2.0.0",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
notif_type = classify_notification(envelope)
|
||||||
|
assert notif_type is not None
|
||||||
|
assert notif_type.name == "renovate"
|
||||||
|
|
||||||
|
def test_general_notification(self):
|
||||||
|
"""Test general notification email detection."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "noreply@example.com"},
|
||||||
|
"subject": "[Notification] Daily digest",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is True
|
||||||
|
|
||||||
|
def test_non_notification_email(self):
|
||||||
|
"""Test that personal emails are not detected as notifications."""
|
||||||
|
envelope = {
|
||||||
|
"from": {"addr": "john.doe@example.com"},
|
||||||
|
"subject": "Let's meet for lunch",
|
||||||
|
}
|
||||||
|
assert is_notification_email(envelope) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummaryExtraction:
|
||||||
|
"""Test notification summary extraction."""
|
||||||
|
|
||||||
|
def test_gitlab_pipeline_summary(self):
|
||||||
|
"""Test GitLab pipeline summary extraction."""
|
||||||
|
content = """
|
||||||
|
Pipeline #12345 failed by john.doe
|
||||||
|
|
||||||
|
The pipeline failed on stage: build
|
||||||
|
View pipeline: https://gitlab.com/project/pipelines/12345
|
||||||
|
"""
|
||||||
|
summary = extract_notification_summary(content, NOTIFICATION_TYPES[0]) # gitlab
|
||||||
|
assert summary["metadata"]["pipeline_id"] == "12345"
|
||||||
|
assert summary["metadata"]["triggered_by"] == "john.doe"
|
||||||
|
assert summary["title"] == "Pipeline #12345"
|
||||||
|
|
||||||
|
def test_github_pr_summary(self):
|
||||||
|
"""Test GitHub PR summary extraction."""
|
||||||
|
content = """
|
||||||
|
PR #42: Add new feature
|
||||||
|
|
||||||
|
@john.doe requested your review
|
||||||
|
View PR: https://github.com/repo/pull/42
|
||||||
|
"""
|
||||||
|
summary = extract_notification_summary(content, NOTIFICATION_TYPES[1]) # github
|
||||||
|
assert summary["metadata"]["number"] == "42"
|
||||||
|
assert summary["metadata"]["title"] == "Add new feature"
|
||||||
|
assert summary["title"] == "#42: Add new feature"
|
||||||
|
|
||||||
|
def test_jira_issue_summary(self):
|
||||||
|
"""Test Jira issue summary extraction."""
|
||||||
|
content = """
|
||||||
|
ABC-123: Fix login bug
|
||||||
|
|
||||||
|
Status changed from In Progress to Done
|
||||||
|
View issue: https://jira.atlassian.net/browse/ABC-123
|
||||||
|
"""
|
||||||
|
summary = extract_notification_summary(content, NOTIFICATION_TYPES[2]) # jira
|
||||||
|
assert summary["metadata"]["issue_key"] == "ABC-123"
|
||||||
|
assert summary["metadata"]["issue_title"] == "Fix login bug"
|
||||||
|
assert summary["metadata"]["status_from"] == "In Progress"
|
||||||
|
assert summary["metadata"]["status_to"] == "Done"
|
||||||
|
|
||||||
|
def test_datadog_alert_summary(self):
|
||||||
|
"""Test Datadog alert summary extraction."""
|
||||||
|
content = """
|
||||||
|
Alert triggered
|
||||||
|
|
||||||
|
Monitor: Production CPU usage
|
||||||
|
Status: Critical
|
||||||
|
View alert: https://app.datadoghq.com/monitors/123
|
||||||
|
"""
|
||||||
|
summary = extract_notification_summary(
|
||||||
|
content, NOTIFICATION_TYPES[4]
|
||||||
|
) # datadog
|
||||||
|
assert summary["metadata"]["monitor"] == "Production CPU usage"
|
||||||
|
assert "investigate" in summary["action_items"][0].lower()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
15
uv.lock
generated
15
uv.lock
generated
@@ -643,6 +643,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icalendar"
|
||||||
|
version = "6.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "tzdata" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422, upload-time = "2025-11-05T12:49:32.286Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403, upload-time = "2025-11-05T12:49:30.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "id"
|
name = "id"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -858,6 +871,7 @@ dependencies = [
|
|||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "html2text" },
|
{ name = "html2text" },
|
||||||
|
{ name = "icalendar" },
|
||||||
{ name = "mammoth" },
|
{ name = "mammoth" },
|
||||||
{ name = "markitdown", extra = ["all"] },
|
{ name = "markitdown", extra = ["all"] },
|
||||||
{ name = "msal" },
|
{ name = "msal" },
|
||||||
@@ -896,6 +910,7 @@ requires-dist = [
|
|||||||
{ name = "certifi", specifier = ">=2025.4.26" },
|
{ name = "certifi", specifier = ">=2025.4.26" },
|
||||||
{ name = "click", specifier = ">=8.1.0" },
|
{ name = "click", specifier = ">=8.1.0" },
|
||||||
{ name = "html2text", specifier = ">=2025.4.15" },
|
{ name = "html2text", specifier = ">=2025.4.15" },
|
||||||
|
{ name = "icalendar", specifier = ">=6.0.0" },
|
||||||
{ name = "mammoth", specifier = ">=1.9.0" },
|
{ name = "mammoth", specifier = ">=1.9.0" },
|
||||||
{ name = "markitdown", extras = ["all"], specifier = ">=0.1.1" },
|
{ name = "markitdown", extras = ["all"], specifier = ">=0.1.1" },
|
||||||
{ name = "msal", specifier = ">=1.32.3" },
|
{ name = "msal", specifier = ">=1.32.3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user