feat: Detect and display Teams meeting emails without ICS attachments
- Fix is_calendar_email() to decode base64 MIME content before checking - Add extract_teams_meeting_info() to parse meeting details from email body - Update parse_calendar_from_raw_message() to fall back to Teams extraction - Show 'Join Meeting' button for TEAMS method events in CalendarInvitePanel - Extract Teams URL, Meeting ID, and organizer from email content
This commit is contained in:
@@ -357,6 +357,43 @@ CALENDAR_SUBJECT_PATTERNS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> bool:
|
||||||
"""Check if an email is a calendar invite/update/cancellation.
|
"""Check if an email is a calendar invite/update/cancellation.
|
||||||
|
|
||||||
@@ -388,12 +425,15 @@ def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> b
|
|||||||
|
|
||||||
# If content is provided, check for calendar indicators
|
# If content is provided, check for calendar indicators
|
||||||
if content:
|
if content:
|
||||||
|
# Decode base64 parts for proper text searching
|
||||||
|
decoded_content = _decode_mime_content(content).lower()
|
||||||
|
|
||||||
# Teams meeting indicators
|
# Teams meeting indicators
|
||||||
if "microsoft teams meeting" in content.lower():
|
if "microsoft teams meeting" in decoded_content:
|
||||||
return True
|
return True
|
||||||
if "join the meeting" in content.lower():
|
if "join the meeting" in decoded_content:
|
||||||
return True
|
return True
|
||||||
# ICS content indicator
|
# ICS content indicator (check raw content for MIME headers)
|
||||||
if "text/calendar" in content.lower():
|
if "text/calendar" in content.lower():
|
||||||
return True
|
return True
|
||||||
# VCALENDAR block
|
# VCALENDAR block
|
||||||
|
|||||||
@@ -233,19 +233,135 @@ def parse_ics_content(ics_content: str) -> Optional[ParsedCalendarEvent]:
|
|||||||
return None
|
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]:
|
def parse_calendar_from_raw_message(raw_message: str) -> Optional[ParsedCalendarEvent]:
|
||||||
"""Extract and parse calendar event from raw email message.
|
"""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:
|
Args:
|
||||||
raw_message: Full raw email in EML/MIME format
|
raw_message: Full raw email in EML/MIME format
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ParsedCalendarEvent if found and parsed, None otherwise
|
ParsedCalendarEvent if found and parsed, None otherwise
|
||||||
"""
|
"""
|
||||||
|
# First try to extract ICS content
|
||||||
ics_content = extract_ics_from_mime(raw_message)
|
ics_content = extract_ics_from_mime(raw_message)
|
||||||
if ics_content:
|
if ics_content:
|
||||||
return parse_ics_content(ics_content)
|
event = parse_ics_content(ics_content)
|
||||||
return None
|
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
|
# Legacy function names for compatibility
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class CalendarInvitePanel(Vertical):
|
|||||||
classes="event-detail",
|
classes="event-detail",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Action buttons (only for REQUEST method, not for CANCEL)
|
# Action buttons (only for REQUEST method, not for CANCEL or TEAMS)
|
||||||
if is_event_request(self.event):
|
if is_event_request(self.event):
|
||||||
with Horizontal(classes="action-buttons"):
|
with Horizontal(classes="action-buttons"):
|
||||||
yield Button(
|
yield Button(
|
||||||
@@ -150,6 +150,20 @@ class CalendarInvitePanel(Vertical):
|
|||||||
id="btn-decline",
|
id="btn-decline",
|
||||||
variant="error",
|
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):
|
elif is_cancelled_event(self.event):
|
||||||
yield Static(
|
yield Static(
|
||||||
"[dim]This meeting has been cancelled[/dim]",
|
"[dim]This meeting has been cancelled[/dim]",
|
||||||
@@ -164,6 +178,8 @@ class CalendarInvitePanel(Vertical):
|
|||||||
return "CANCELLED", "cancelled"
|
return "CANCELLED", "cancelled"
|
||||||
elif method == "REQUEST":
|
elif method == "REQUEST":
|
||||||
return "INVITE", "request"
|
return "INVITE", "request"
|
||||||
|
elif method == "TEAMS":
|
||||||
|
return "TEAMS", "request"
|
||||||
elif method == "REPLY":
|
elif method == "REPLY":
|
||||||
return "REPLY", "reply"
|
return "REPLY", "reply"
|
||||||
elif method == "COUNTER":
|
elif method == "COUNTER":
|
||||||
@@ -188,3 +204,16 @@ class CalendarInvitePanel(Vertical):
|
|||||||
self.post_message(self.InviteAction("tentative", self.event))
|
self.post_message(self.InviteAction("tentative", self.event))
|
||||||
elif button_id == "btn-decline":
|
elif button_id == "btn-decline":
|
||||||
self.post_message(self.InviteAction("decline", self.event))
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user