Initial commit: IMAP IDLE to CalDAV daemon
Monitor an IMAP mailbox via IDLE for iMIP emails (invitations, RSVPs, cancellations) and ICS attachments, then update a CalDAV calendar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
37b032771f
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
config.yaml
|
||||
*.log
|
||||
15
config.example.yaml
Normal file
15
config.example.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
imap:
|
||||
host: imap.example.com
|
||||
port: 993
|
||||
username: user@example.com
|
||||
password: secret
|
||||
folder: INBOX # folder to monitor
|
||||
ssl: true
|
||||
|
||||
caldav:
|
||||
url: https://caldav.example.com/dav/calendars/user/calendar/
|
||||
username: user@example.com
|
||||
password: secret
|
||||
|
||||
logging:
|
||||
level: INFO # DEBUG, INFO, WARNING, ERROR
|
||||
0
imap_idle_caldav/__init__.py
Normal file
0
imap_idle_caldav/__init__.py
Normal file
49
imap_idle_caldav/__main__.py
Normal file
49
imap_idle_caldav/__main__.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Entry point for imap-idle-caldav daemon."""
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from .caldav_client import CalDAVHandler
|
||||
from .config import load_config, parse_args
|
||||
from .email_processor import process_email
|
||||
from .imap_client import run_idle_loop
|
||||
|
||||
log = logging.getLogger("imap_idle_caldav")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config_path = parse_args()
|
||||
config = load_config(config_path)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, config.logging.level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
|
||||
caldav_handler = CalDAVHandler(config.caldav)
|
||||
caldav_handler.connect()
|
||||
|
||||
shutdown = threading.Event()
|
||||
|
||||
def on_signal(signum, _frame):
|
||||
log.info("Received signal %s, shutting down", signal.Signals(signum).name)
|
||||
shutdown.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, on_signal)
|
||||
signal.signal(signal.SIGINT, on_signal)
|
||||
|
||||
def on_message(raw_bytes: bytes) -> None:
|
||||
events = process_email(raw_bytes)
|
||||
for event in events:
|
||||
caldav_handler.handle_event(event)
|
||||
|
||||
log.info("Starting IMAP IDLE daemon")
|
||||
run_idle_loop(config.imap, on_message, shutdown_event=shutdown)
|
||||
log.info("Daemon stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
134
imap_idle_caldav/caldav_client.py
Normal file
134
imap_idle_caldav/caldav_client.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""CalDAV operations: create, update, and cancel events."""
|
||||
|
||||
import logging
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
|
||||
from .config import CaldavConfig
|
||||
from .email_processor import CalendarEvent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalDAVHandler:
|
||||
def __init__(self, config: CaldavConfig):
|
||||
self._config = config
|
||||
self._client: caldav.DAVClient | None = None
|
||||
self._calendar: caldav.Calendar | None = None
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Connect to the CalDAV server and locate the calendar."""
|
||||
self._client = caldav.DAVClient(
|
||||
url=self._config.url,
|
||||
username=self._config.username,
|
||||
password=self._config.password,
|
||||
)
|
||||
self._calendar = caldav.Calendar(client=self._client, url=self._config.url)
|
||||
log.info("Connected to CalDAV server: %s", self._config.url)
|
||||
|
||||
def handle_event(self, event: CalendarEvent) -> None:
|
||||
"""Dispatch a calendar event based on its METHOD."""
|
||||
try:
|
||||
if event.method == "REQUEST" or event.method == "PUBLISH":
|
||||
self._handle_request(event)
|
||||
elif event.method == "REPLY":
|
||||
self._handle_reply(event)
|
||||
elif event.method == "CANCEL":
|
||||
self._handle_cancel(event)
|
||||
else:
|
||||
log.warning("Unknown METHOD %s for UID %s, treating as REQUEST", event.method, event.uid)
|
||||
self._handle_request(event)
|
||||
except Exception:
|
||||
log.error("CalDAV error processing UID %s (method=%s)", event.uid, event.method, exc_info=True)
|
||||
|
||||
def _find_event(self, uid: str) -> caldav.CalendarObjectResource | None:
|
||||
"""Search the calendar for an event by UID."""
|
||||
try:
|
||||
return self._calendar.event_by_uid(uid)
|
||||
except caldav.error.NotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
log.debug("Error searching for UID %s", uid, exc_info=True)
|
||||
return None
|
||||
|
||||
def _handle_request(self, event: CalendarEvent) -> None:
|
||||
"""Handle REQUEST/PUBLISH: create or update an event."""
|
||||
existing = self._find_event(event.uid)
|
||||
|
||||
cal = icalendar.Calendar()
|
||||
cal.add("PRODID", "-//imap-idle-caldav//EN")
|
||||
cal.add("VERSION", "2.0")
|
||||
cal.add_component(event.vevent)
|
||||
ical_data = cal.to_ical().decode("utf-8")
|
||||
|
||||
if existing:
|
||||
existing.data = ical_data
|
||||
existing.save()
|
||||
log.info("Updated event UID %s", event.uid)
|
||||
else:
|
||||
self._calendar.save_event(ical_data)
|
||||
log.info("Created event UID %s", event.uid)
|
||||
|
||||
def _handle_reply(self, event: CalendarEvent) -> None:
|
||||
"""Handle REPLY: update attendee PARTSTAT on an existing event."""
|
||||
existing = self._find_event(event.uid)
|
||||
if not existing:
|
||||
log.warning("REPLY for unknown UID %s, skipping", event.uid)
|
||||
return
|
||||
|
||||
existing_cal = icalendar.Calendar.from_ical(existing.data)
|
||||
reply_attendees = event.vevent.get("ATTENDEE")
|
||||
if reply_attendees is None:
|
||||
log.warning("REPLY with no ATTENDEE for UID %s", event.uid)
|
||||
return
|
||||
|
||||
# Normalize to list
|
||||
if not isinstance(reply_attendees, list):
|
||||
reply_attendees = [reply_attendees]
|
||||
|
||||
# Build a map of reply attendee addresses to their PARTSTAT
|
||||
reply_map: dict[str, str] = {}
|
||||
for att in reply_attendees:
|
||||
addr = str(att).lower()
|
||||
partstat = att.params.get("PARTSTAT", "NEEDS-ACTION")
|
||||
reply_map[addr] = str(partstat)
|
||||
|
||||
updated = False
|
||||
for component in existing_cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
existing_attendees = component.get("ATTENDEE")
|
||||
if existing_attendees is None:
|
||||
continue
|
||||
if not isinstance(existing_attendees, list):
|
||||
existing_attendees = [existing_attendees]
|
||||
|
||||
for att in existing_attendees:
|
||||
addr = str(att).lower()
|
||||
if addr in reply_map:
|
||||
att.params["PARTSTAT"] = reply_map[addr]
|
||||
updated = True
|
||||
log.info("Updated PARTSTAT for %s to %s on UID %s", addr, reply_map[addr], event.uid)
|
||||
|
||||
if updated:
|
||||
existing.data = existing_cal.to_ical().decode("utf-8")
|
||||
existing.save()
|
||||
else:
|
||||
log.warning("REPLY attendee not found in existing event UID %s", event.uid)
|
||||
|
||||
def _handle_cancel(self, event: CalendarEvent) -> None:
|
||||
"""Handle CANCEL: mark event as CANCELLED."""
|
||||
existing = self._find_event(event.uid)
|
||||
if not existing:
|
||||
log.warning("CANCEL for unknown UID %s, skipping", event.uid)
|
||||
return
|
||||
|
||||
existing_cal = icalendar.Calendar.from_ical(existing.data)
|
||||
for component in existing_cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
component["STATUS"] = "CANCELLED"
|
||||
|
||||
existing.data = existing_cal.to_ical().decode("utf-8")
|
||||
existing.save()
|
||||
log.info("Cancelled event UID %s", event.uid)
|
||||
104
imap_idle_caldav/config.py
Normal file
104
imap_idle_caldav/config.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""YAML configuration loading and validation."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImapConfig:
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
folder: str = "INBOX"
|
||||
ssl: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaldavConfig:
|
||||
url: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingConfig:
|
||||
level: str = "INFO"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
imap: ImapConfig
|
||||
caldav: CaldavConfig
|
||||
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
"""Load and validate configuration from a YAML file."""
|
||||
if not path.exists():
|
||||
print(f"Error: config file not found: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(path) as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
print("Error: config file must be a YAML mapping", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
for section in ("imap", "caldav"):
|
||||
if section not in raw:
|
||||
print(f"Error: missing required config section: {section}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
imap_raw = raw["imap"]
|
||||
for key in ("host", "username", "password"):
|
||||
if key not in imap_raw:
|
||||
print(f"Error: missing required imap config key: {key}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
caldav_raw = raw["caldav"]
|
||||
for key in ("url", "username", "password"):
|
||||
if key not in caldav_raw:
|
||||
print(f"Error: missing required caldav config key: {key}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
imap_cfg = ImapConfig(
|
||||
host=imap_raw["host"],
|
||||
port=imap_raw.get("port", 993),
|
||||
username=imap_raw["username"],
|
||||
password=imap_raw["password"],
|
||||
folder=imap_raw.get("folder", "INBOX"),
|
||||
ssl=imap_raw.get("ssl", True),
|
||||
)
|
||||
|
||||
caldav_cfg = CaldavConfig(
|
||||
url=caldav_raw["url"],
|
||||
username=caldav_raw["username"],
|
||||
password=caldav_raw["password"],
|
||||
)
|
||||
|
||||
logging_raw = raw.get("logging", {})
|
||||
logging_cfg = LoggingConfig(
|
||||
level=logging_raw.get("level", "INFO"),
|
||||
)
|
||||
|
||||
return Config(imap=imap_cfg, caldav=caldav_cfg, logging=logging_cfg)
|
||||
|
||||
|
||||
def parse_args() -> Path:
|
||||
"""Parse CLI arguments and return the config file path."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="IMAP IDLE to CalDAV daemon",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config",
|
||||
default="config.yaml",
|
||||
help="Path to YAML config file (default: config.yaml)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return Path(args.config)
|
||||
70
imap_idle_caldav/email_processor.py
Normal file
70
imap_idle_caldav/email_processor.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Extract ICS calendar data from emails."""
|
||||
|
||||
import email
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from email.message import Message
|
||||
|
||||
import icalendar
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarEvent:
|
||||
method: str # REQUEST, REPLY, CANCEL, PUBLISH
|
||||
vevent: icalendar.cal.Event
|
||||
uid: str
|
||||
|
||||
|
||||
def process_email(raw_bytes: bytes) -> list[CalendarEvent]:
|
||||
"""Parse a raw email and extract calendar events from ICS content.
|
||||
|
||||
Looks for:
|
||||
- text/calendar MIME parts (iMIP inline)
|
||||
- application/ics attachments
|
||||
- Attachments with .ics extension
|
||||
"""
|
||||
msg = email.message_from_bytes(raw_bytes)
|
||||
subject = msg.get("Subject", "(no subject)")
|
||||
log.debug("Processing email: %s", subject)
|
||||
|
||||
events: list[CalendarEvent] = []
|
||||
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
filename = part.get_filename() or ""
|
||||
|
||||
is_calendar = content_type in ("text/calendar", "application/ics")
|
||||
is_ics_attachment = filename.lower().endswith(".ics")
|
||||
|
||||
if not (is_calendar or is_ics_attachment):
|
||||
continue
|
||||
|
||||
payload = part.get_payload(decode=True)
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
try:
|
||||
cal = icalendar.Calendar.from_ical(payload)
|
||||
except Exception:
|
||||
log.warning("Failed to parse ICS from email: %s", subject, exc_info=True)
|
||||
continue
|
||||
|
||||
method = str(cal.get("METHOD", "PUBLISH")).upper()
|
||||
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
uid = str(component.get("UID", ""))
|
||||
if not uid:
|
||||
log.warning("VEVENT without UID in email: %s", subject)
|
||||
continue
|
||||
events.append(CalendarEvent(method=method, vevent=component, uid=uid))
|
||||
|
||||
if events:
|
||||
log.info("Found %d calendar event(s) in email: %s", len(events), subject)
|
||||
else:
|
||||
log.debug("No calendar content in email: %s", subject)
|
||||
|
||||
return events
|
||||
118
imap_idle_caldav/imap_client.py
Normal file
118
imap_idle_caldav/imap_client.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""IMAP connection, IDLE loop, and email fetching."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from imapclient import IMAPClient
|
||||
|
||||
from .config import ImapConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
IDLE_TIMEOUT = 300 # 5 minutes, per RFC 2177
|
||||
MAX_BACKOFF = 300 # 5 minutes max backoff
|
||||
|
||||
|
||||
def run_idle_loop(
|
||||
config: ImapConfig,
|
||||
on_message: Callable[[bytes], None],
|
||||
shutdown_event=None,
|
||||
) -> None:
|
||||
"""Main IMAP IDLE loop with automatic reconnection.
|
||||
|
||||
Calls on_message(raw_email_bytes) for each new message received.
|
||||
Runs until shutdown_event is set (if provided) or forever.
|
||||
"""
|
||||
backoff = 1
|
||||
|
||||
while not (shutdown_event and shutdown_event.is_set()):
|
||||
try:
|
||||
_idle_session(config, on_message, shutdown_event)
|
||||
backoff = 1 # reset on clean disconnect
|
||||
except Exception:
|
||||
log.error("IMAP connection error, reconnecting in %ds", backoff, exc_info=True)
|
||||
if shutdown_event:
|
||||
shutdown_event.wait(backoff)
|
||||
else:
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, MAX_BACKOFF)
|
||||
|
||||
|
||||
def _idle_session(
|
||||
config: ImapConfig,
|
||||
on_message: Callable[[bytes], None],
|
||||
shutdown_event=None,
|
||||
) -> None:
|
||||
"""Run a single IMAP IDLE session until disconnection."""
|
||||
client = IMAPClient(config.host, port=config.port, ssl=config.ssl)
|
||||
try:
|
||||
client.login(config.username, config.password)
|
||||
log.info("Logged in to IMAP %s as %s", config.host, config.username)
|
||||
|
||||
client.select_folder(config.folder, readonly=True)
|
||||
log.info("Monitoring folder: %s", config.folder)
|
||||
|
||||
# Get the current highest UID so we only process new messages
|
||||
last_uid = _get_highest_uid(client)
|
||||
log.debug("Starting UID: %s", last_uid)
|
||||
|
||||
while not (shutdown_event and shutdown_event.is_set()):
|
||||
client.idle()
|
||||
try:
|
||||
responses = client.idle_check(timeout=IDLE_TIMEOUT)
|
||||
finally:
|
||||
client.idle_done()
|
||||
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
break
|
||||
|
||||
# Check if any EXISTS response indicates new mail
|
||||
has_new = any(
|
||||
isinstance(resp, tuple) and len(resp) >= 2 and resp[1] == b"EXISTS"
|
||||
for resp in responses
|
||||
)
|
||||
|
||||
if not has_new:
|
||||
continue
|
||||
|
||||
# Fetch new messages since last_uid
|
||||
new_uids = _fetch_new_uids(client, last_uid)
|
||||
if not new_uids:
|
||||
continue
|
||||
|
||||
log.info("Fetching %d new message(s)", len(new_uids))
|
||||
|
||||
# Fetch full message bodies
|
||||
messages = client.fetch(new_uids, ["BODY.PEEK[]"])
|
||||
for uid, data in messages.items():
|
||||
raw = data.get(b"BODY[]")
|
||||
if raw:
|
||||
try:
|
||||
on_message(raw)
|
||||
except Exception:
|
||||
log.error("Error processing message UID %s", uid, exc_info=True)
|
||||
|
||||
last_uid = max(new_uids)
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_highest_uid(client: IMAPClient) -> int:
|
||||
"""Get the highest UID currently in the mailbox."""
|
||||
uids = client.search(["ALL"])
|
||||
if uids:
|
||||
return max(uids)
|
||||
return 0
|
||||
|
||||
|
||||
def _fetch_new_uids(client: IMAPClient, last_uid: int) -> list[int]:
|
||||
"""Search for messages with UID greater than last_uid."""
|
||||
if last_uid > 0:
|
||||
uids = client.search(["UID", f"{last_uid + 1}:*"])
|
||||
# IMAP UID search with * can return last_uid if no new messages
|
||||
return [u for u in uids if u > last_uid]
|
||||
return client.search(["ALL"])
|
||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "imap-idle-caldav"
|
||||
version = "0.1.0"
|
||||
description = "Daemon that monitors IMAP via IDLE for iMIP emails and updates a CalDAV calendar"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"imapclient>=3.0",
|
||||
"caldav>=1.3",
|
||||
"icalendar>=5.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
imap-idle-caldav = "imap_idle_caldav.__main__:main"
|
||||
Loading…
x
Reference in New Issue
Block a user