Initial Code Commit

This commit is contained in:
wagesj45 2025-05-01 17:04:01 -05:00
commit 3b709107bb
5 changed files with 386 additions and 0 deletions

10
Dockerfile Normal file
View file

@ -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"]

9
caldav-cron Normal file
View file

@ -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)

87
dispatch.py Normal file
View file

@ -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()

57
entrypoint.sh Normal file
View file

@ -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 dont 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

223
sync.py Normal file
View file

@ -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 = """<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:resourcetype/></d:prop>
</d:propfind>
"""
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 = """<?xml version="1.0" encoding="UTF-8"?>
<calendar-query xmlns="urn:ietf:params:xml:ns:caldav">
<prop><getetag/><getlastmodified/><calendar-data/></prop>
<filter><comp-filter name="VCALENDAR">
<comp-filter name="VEVENT"/>
</comp-filter></filter>
</calendar-query>
"""
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()