From 3b709107bbd51353198757b87a533ca3c5122d16 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Thu, 1 May 2025 17:04:01 -0500 Subject: [PATCH] Initial Code Commit --- Dockerfile | 10 +++ caldav-cron | 9 ++ dispatch.py | 87 ++++++++++++++++++++ entrypoint.sh | 57 +++++++++++++ sync.py | 223 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 386 insertions(+) create mode 100644 Dockerfile create mode 100644 caldav-cron create mode 100644 dispatch.py create mode 100644 entrypoint.sh create mode 100644 sync.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddf3bf5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +# install only Python deps +RUN pip install --no-cache-dir requests icalendar python-dateutil + +WORKDIR /app +COPY sync.py dispatch.py entrypoint.sh /app/ +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/caldav-cron b/caldav-cron new file mode 100644 index 0000000..e8f6d8c --- /dev/null +++ b/caldav-cron @@ -0,0 +1,9 @@ +# ── environment is inherited from Docker, so your .env values will be visible ── + +# run sync every 5 minutes +*/5 * * * * root /usr/local/bin/python /app/sync.py + +# run dispatch every minute +* * * * * root /usr/local/bin/python /app/dispatch.py + +# (blank line required at end) diff --git a/dispatch.py b/dispatch.py new file mode 100644 index 0000000..91ea84d --- /dev/null +++ b/dispatch.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +import os +import sqlite3 +import smtplib +import logging +from email.message import EmailMessage +from datetime import datetime, timezone + +# ─── Environment & Logging ───────────────────────────────────────────────────── +DB_PATH = os.getenv("DB_PATH", "/data/sync_state.db") + +# SMTP config from env; convert TLS/STARTTLS flags to real booleans +SMTP_HOST = os.getenv("SMTP_HOST") +SMTP_PORT = int(os.getenv("SMTP_PORT", "25")) +SMTP_USERNAME = os.getenv("SMTP_USERNAME") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") +SMTP_USE_SSL = os.getenv("SMTP_USE_TLS", "false").lower() in ("1","true","yes") +SMTP_USE_STARTTLS = os.getenv("SMTP_USE_STARTTLS", "false").lower() in ("1","true","yes") + +EMAIL_FROM = os.getenv("EMAIL_FROM") +EMAIL_TO = os.getenv("EMAIL_TO") +EMAIL_SUBJECT_PREFIX = os.getenv("EMAIL_SUBJECT_PREFIX", "") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s" +) +logger = logging.getLogger(__name__) + +# ─── Email sender ────────────────────────────────────────────────────────────── +def send_email(subject: str, body: str, to_addrs: str): + # 1) Choose SSL socket or plain + if SMTP_USE_SSL: + server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) + else: + server = smtplib.SMTP(SMTP_HOST, SMTP_PORT) + if SMTP_USE_STARTTLS: + server.ehlo() + server.starttls() + server.ehlo() + + # 2) Login if creds provided + if SMTP_USERNAME and SMTP_PASSWORD: + server.login(SMTP_USERNAME, SMTP_PASSWORD) + + # 3) Build & send + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = EMAIL_FROM + msg["To"] = to_addrs + msg.set_content(body) + + server.send_message(msg) + server.quit() + +# ─── Dispatch loop ───────────────────────────────────────────────────────────── +def dispatch(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + now_iso = datetime.now(timezone.utc).isoformat() + c.execute(""" + SELECT id, event_uid, trigger_dt, summary, description + FROM reminders + WHERE trigger_dt <= ? AND sent_flag = 0 + """, (now_iso,)) + rows = c.fetchall() + + for rid, uid, trigger_dt, summary, description in rows: + subject = f"{EMAIL_SUBJECT_PREFIX} {summary}" + body = ( + f"{summary}\n\n" + f"{description}\n\n" + f"Scheduled at: {trigger_dt}" + ) + try: + send_email(subject, body, EMAIL_TO) + c.execute("UPDATE reminders SET sent_flag = 1 WHERE id = ?", (rid,)) + conn.commit() + logger.info(f"Sent reminder {rid} ({uid})") + except Exception as e: + logger.error(f"Failed to send reminder {rid}: {e}") + + conn.close() + +if __name__ == "__main__": + dispatch() diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..1c82538 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/sh +set -e + +# ─── Map / export all of your .env into what the Python scripts expect ───────── + +# CalDAV (your .env uses CALDAV_BASE_URL and CALDAV_CALENDARS) +export CALDAV_BASE="$CALDAV_BASE_URL" +export CALENDAR_URLS="$CALDAV_CALENDARS" + +# credentials +export CALDAV_USERNAME +export CALDAV_PASSWORD + +# sync settings +export FETCH_WINDOW_DAYS="${FETCH_WINDOW_DAYS:-90}" +export DB_PATH="${DB_PATH:-/data/sync_state.db}" +export RETENTION_DAYS="${RETENTION_DAYS:-30}" + +# SMTP / dispatch settings (already in env from --env-file) +export SMTP_HOST +export SMTP_PORT +export SMTP_USERNAME +export SMTP_PASSWORD +export SMTP_USE_TLS +export SMTP_USE_STARTTLS +export EMAIL_FROM +export EMAIL_TO +export EMAIL_SUBJECT_PREFIX + +# optional override intervals (in seconds) +: "${SYNC_INTERVAL:=300}" # default 5min +: "${DISPATCH_INTERVAL:=60}" # default 1min + +# ─── Initial run so you don’t have to wait ──────────────────────────────────── +echo "[entrypoint] Starting initial sync at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" +python3 /app/sync.py || echo "[entrypoint] sync failed on first run" + +# ─── Background sync loop ──────────────────────────────────────────────────── +( + while true; do + sleep "$SYNC_INTERVAL" + echo "[entrypoint] Running sync at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + python3 /app/sync.py || echo "[entrypoint] sync failed" + done +) & + +# ─── Background dispatch loop ──────────────────────────────────────────────── +( + while true; do + sleep "$DISPATCH_INTERVAL" + echo "[entrypoint] Running dispatch at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + python3 /app/dispatch.py || echo "[entrypoint] dispatch failed" + done +) & + +# ─── Wait on both loops (keeps container alive) ─────────────────────────────── +wait diff --git a/sync.py b/sync.py new file mode 100644 index 0000000..1bc590c --- /dev/null +++ b/sync.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +import os +import sqlite3 +import logging +import requests +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone, date +from urllib.parse import urljoin +from icalendar import Calendar +from dateutil.rrule import rrulestr + +# ─── Configuration from environment ───────────────────────────────────────────── +CALDAV_BASE = os.getenv("CALDAV_BASE", "https://example.com/") +USERNAME = os.getenv("CALDAV_USERNAME", "your-username") +PASSWORD = os.getenv("CALDAV_PASSWORD", "your-password") +# Optional comma-separated override: e.g. +# CALENDAR_URLS="https://…/personal/,https://…/work/" +CALENDAR_URLS_ENV = os.getenv("CALENDAR_URLS", "") +FETCH_WINDOW_DAYS = int(os.getenv("FETCH_WINDOW_DAYS", "90")) +DB_PATH = os.getenv("DB_PATH", "/data/sync_state.db") + +# ─── Logging setup ─────────────────────────────────────────────────────────────── +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s [%(levelname)s] %(message)s" +) +logger = logging.getLogger(__name__) + +# ─── Database initialization ──────────────────────────────────────────────────── +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS events ( + uid TEXT PRIMARY KEY, + lastmod TEXT + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_uid TEXT, + trigger_dt TEXT, + summary TEXT, + description TEXT, + sent_flag INTEGER DEFAULT 0, + UNIQUE(event_uid, trigger_dt) + ) + """) + conn.commit() + return conn + +# ─── Calendar discovery ───────────────────────────────────────────────────────── +def discover_calendars(): + if CALENDAR_URLS_ENV: + urls = [u.strip() for u in CALENDAR_URLS_ENV.split(",") if u.strip()] + logger.info(f"Using override: {urls}") + return urls + + # autodiscover via PROPFIND + root_coll = urljoin(CALDAV_BASE, f"remote.php/dav/calendars/{USERNAME}/") + body = """ + + + +""" + headers = {"Depth":"1","Content-Type":"application/xml"} + resp = requests.request("PROPFIND", root_coll, + auth=(USERNAME, PASSWORD), + headers=headers, data=body) + resp.raise_for_status() + tree = ET.fromstring(resp.text) + ns = {"d":"DAV:"} + cals = [] + for r in tree.findall(".//d:response", ns): + href = r.find("d:href", ns).text + # skip the root itself + if href.rstrip("/").endswith(f"calendars/{USERNAME}"): + continue + full = urljoin(CALDAV_BASE, href) + cals.append(full) + logger.info(f"Discovered calendars: {cals}") + return cals + +# ─── Fetch report XML for one calendar ─────────────────────────────────────────── +def fetch_report_xml(caldav_url): + body = """ + + + + + + +""" + resp = requests.request( + "REPORT", + caldav_url, + auth=(USERNAME, PASSWORD), + headers={"Depth":"1","Content-Type":"application/xml"}, + data=body + ) + resp.raise_for_status() + return resp.text + +# ─── Parse the REPORT response ───────────────────────────────────────────────── +def parse_report(xml_text): + ns = {"d":"DAV:"} + root = ET.fromstring(xml_text) + for resp in root.findall(".//d:response", ns): + href = resp.find("d:href", ns).text + lastmod = resp.find(".//d:getlastmodified", ns).text + yield href, lastmod + +# ─── Download a single .ics by href ───────────────────────────────────────────── +def download_ics(href): + url = urljoin(CALDAV_BASE, href) + r = requests.get(url, auth=(USERNAME, PASSWORD)) + r.raise_for_status() + return r.content + +# ─── Expand VEVENT alarms, handling RRULE and timezones ──────────────────────── +def expand_event_alarms(ics_bytes, window_days=FETCH_WINDOW_DAYS): + cal = Calendar.from_ical(ics_bytes) + now = datetime.now(timezone.utc) + horizon = now + timedelta(days=window_days) + reminders = [] + + for comp in cal.walk(): + if comp.name != "VEVENT": + continue + + # UID + uid = str(comp.get("UID")) + + # DTSTART normalized to aware datetime + dt = comp.decoded("DTSTART") + if isinstance(dt, date) and not isinstance(dt, datetime): + dtstart = datetime.combine(dt, datetime.min.time(), tzinfo=timezone.utc) + else: + dtstart = dt + if dtstart.tzinfo is None: + dtstart = dtstart.replace(tzinfo=timezone.utc) + else: + dtstart = dtstart.astimezone(timezone.utc) + + # Summary & Description + summary = str(comp.get("SUMMARY", "(no title)")) + description = str(comp.get("DESCRIPTION", "")) + + # VALARM blocks with EMAIL action + alarms = [ + a for a in comp.subcomponents + if a.name=="VALARM" and a.get("ACTION")=="EMAIL" + ] + if not alarms: + continue + + # Recurrence expansion + if comp.get("RRULE"): + rule_str = comp["RRULE"].to_ical().decode() + rule = rrulestr(rule_str, dtstart=dtstart) + occs = rule.between(now, horizon, inc=True) + else: + occs = [dtstart] if dtstart >= now else [] + + for occ in occs: + for alarm in alarms: + trig = alarm.decoded("TRIGGER") + if isinstance(trig, timedelta): + trigger_dt = occ + trig + else: + # absolute triggers + trigger_dt = parse_dt(str(trig)).astimezone(timezone.utc) + reminders.append((uid, + trigger_dt.isoformat(), + summary, + description)) + return reminders + +# ─── Main sync logic ──────────────────────────────────────────────────────────── +def sync(): + conn = init_db() + c = conn.cursor() + + calendars = discover_calendars() + logger.info(f"Calendars to sync: {calendars}") + + for cal_url in calendars: + try: + report = fetch_report_xml(cal_url) + for href, lastmod in parse_report(report): + uid = os.path.basename(href).rsplit(".ics",1)[0] + + # check state + c.execute("SELECT lastmod FROM events WHERE uid=?", (uid,)) + row = c.fetchone() + + if not row or row[0] != lastmod: + logger.info(f"Fetching event {uid}") + ics = download_ics(href) + for (u, trigger_iso, summary, desc) in expand_event_alarms(ics): + c.execute(""" + INSERT OR IGNORE INTO reminders + (event_uid, trigger_dt, summary, description) + VALUES (?,?,?,?) + """, (u, trigger_iso, summary, desc)) + # update lastmod + c.execute(""" + INSERT INTO events(uid, lastmod) + VALUES (?,?) + ON CONFLICT(uid) DO UPDATE SET lastmod=excluded.lastmod + """, (uid, lastmod)) + conn.commit() + else: + logger.debug(f"Skipping up-to-date {uid}") + + except Exception as e: + logger.error(f"Error syncing {cal_url}: {e}") + + conn.close() + +if __name__ == "__main__": + sync() \ No newline at end of file