calendar: add recurring events, event management, and safety rules

Add --rrule flag to send with DTSTART/BYDAY validation (RFC 5545).
Add event list (via khal) and event delete (safe single-event removal).
Document safety rules from 2026-03-25 incident: always dry-run recurring
events, never rm .ics files, space out SMTP sends.
This commit is contained in:
Yanxin Lu
2026-03-25 09:32:22 -07:00
parent 810a9923f9
commit 35f048dd83
6 changed files with 347 additions and 19 deletions

View File

@@ -6,8 +6,10 @@ Uses the icalendar library for proper RFC 5545 ICS generation and parsing.
Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer.
Subcommands:
python calendar.py send [options] # create and send an invite
python calendar.py send [options] # create and send an invite (supports --rrule)
python calendar.py reply [options] # accept/decline/tentative
python calendar.py event list [options] # list/search calendar events
python calendar.py event delete [options] # delete an event by UID or summary
python calendar.py todo add [options] # create a VTODO task
python calendar.py todo list [options] # list pending tasks
python calendar.py todo edit [options] # edit a task's fields
@@ -27,7 +29,7 @@ from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText
# ---------------------------------------------------------------------------
# Config
@@ -111,6 +113,54 @@ def _parse_date(date_str):
return datetime.strptime(date_str, "%Y-%m-%d").date()
# iCalendar day abbreviation -> Python weekday (0=Mon, 6=Sun)
_ICAL_DAY_TO_WEEKDAY = {
"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6,
}
def _validate_rrule_dtstart(rrule_dict, dtstart):
"""Validate that DTSTART day-of-week matches BYDAY when FREQ=WEEKLY.
RFC 5545: 'The DTSTART property value SHOULD be synchronized with the
recurrence rule. The recurrence set generated with a DTSTART property
value not synchronized with the recurrence rule is undefined.'
"""
freq = rrule_dict.get("FREQ")
if not freq:
return
# Normalize: icalendar may return freq as list or string
if isinstance(freq, list):
freq = freq[0]
if str(freq).upper() != "WEEKLY":
return
byday = rrule_dict.get("BYDAY")
if not byday:
return
if not isinstance(byday, list):
byday = [byday]
# Only validate alignment when there's a single BYDAY value
if len(byday) != 1:
return
day_str = str(byday[0]).upper()
expected_weekday = _ICAL_DAY_TO_WEEKDAY.get(day_str)
if expected_weekday is None:
return
if dtstart.weekday() != expected_weekday:
actual_day = [k for k, v in _ICAL_DAY_TO_WEEKDAY.items() if v == dtstart.weekday()][0]
print(
f"Error: DTSTART falls on {actual_day} but RRULE says BYDAY={day_str}. "
f"RFC 5545 requires these to match for FREQ=WEEKLY. "
f"Change --start to a date that falls on {day_str}.",
file=sys.stderr,
)
sys.exit(1)
# ---------------------------------------------------------------------------
# Send invite
# ---------------------------------------------------------------------------
@@ -154,6 +204,12 @@ def cmd_send(args):
"RSVP": "TRUE",
})
# Recurrence rule
if args.rrule:
rrule = vRecur.from_ical(args.rrule)
_validate_rrule_dtstart(rrule, start)
event.add("rrule", rrule)
# 1-day reminder
alarm = Alarm()
alarm.add("action", "DISPLAY")
@@ -683,6 +739,81 @@ def cmd_todo_check(args):
print(output)
# ---------------------------------------------------------------------------
# Event management
# ---------------------------------------------------------------------------
def cmd_event_list(args):
"""List calendar events via khal."""
if args.search:
cmd = ["khal", "search"]
if args.format:
cmd += ["--format", args.format]
cmd.append(args.search)
else:
cmd = ["khal", "list"]
if args.format:
cmd += ["--format", args.format]
cmd += [args.range_start, args.range_end]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
print(result.stdout.rstrip())
except subprocess.CalledProcessError as e:
print(f"Error: khal failed: {e.stderr.strip()}", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print("Error: khal is not installed", file=sys.stderr)
sys.exit(1)
def cmd_event_delete(args):
"""Delete a calendar event by UID or summary match."""
if not args.uid and not args.match:
print("Error: --uid or --match is required", file=sys.stderr)
sys.exit(1)
if not CALENDAR_DIR.is_dir():
print(f"Error: Calendar directory not found: {CALENDAR_DIR}", file=sys.stderr)
sys.exit(1)
ics_files = list(CALENDAR_DIR.glob("*.ics"))
if not ics_files:
print("No events found in calendar.", file=sys.stderr)
sys.exit(1)
# Find matching event(s)
matches = []
for ics_path in ics_files:
try:
cal = Calendar.from_ical(ics_path.read_bytes())
except Exception:
continue
for component in cal.walk():
if component.name == "VEVENT":
uid = str(component.get("uid", ""))
summary = str(component.get("summary", ""))
if args.uid and args.uid in uid:
matches.append((ics_path, uid, summary))
elif args.match and args.match in summary:
matches.append((ics_path, uid, summary))
if not matches:
target = args.uid or args.match
print(f"Error: No event matching '{target}'", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"Error: Multiple events match:", file=sys.stderr)
for _, uid, summary in matches:
print(f" - {summary} (uid: {uid})", file=sys.stderr)
sys.exit(1)
ics_path, uid, summary = matches[0]
ics_path.unlink()
print(f"Deleted event: {summary} (uid: {uid})")
_sync_calendar()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -703,6 +834,7 @@ def main():
send_p.add_argument("--location", default="", help="Event location")
send_p.add_argument("--description", default="", help="Event description")
send_p.add_argument("--organizer", default="", help="Organizer display name")
send_p.add_argument("--rrule", default="", help="RRULE string (e.g. FREQ=WEEKLY;COUNT=13;BYDAY=TU)")
send_p.add_argument("--uid", default="", help="Custom event UID")
send_p.add_argument("--account", default="", help="Himalaya account")
send_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
@@ -718,6 +850,22 @@ def main():
reply_p.add_argument("--comment", default="", help="Message to include in reply")
reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
# --- event ---
event_p = subparsers.add_parser("event", help="Manage calendar events")
event_sub = event_p.add_subparsers(dest="event_command", required=True)
# event list
elist_p = event_sub.add_parser("list", help="List events (via khal)")
elist_p.add_argument("--search", default="", help="Search events by text")
elist_p.add_argument("--range-start", default="today", help="Start of range (default: today)")
elist_p.add_argument("--range-end", default="90d", help="End of range (default: 90d)")
elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')")
# event delete
edel_p = event_sub.add_parser("delete", help="Delete an event")
edel_p.add_argument("--uid", default="", help="Event UID")
edel_p.add_argument("--match", default="", help="Match on summary text")
# --- todo ---
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
@@ -761,6 +909,11 @@ def main():
cmd_send(args)
elif args.command == "reply":
cmd_reply(args)
elif args.command == "event":
if args.event_command == "list":
cmd_event_list(args)
elif args.event_command == "delete":
cmd_event_delete(args)
elif args.command == "todo":
if args.todo_command == "add":
cmd_todo_add(args)

View File

@@ -2,8 +2,10 @@
# calendar — wrapper script for the calendar and todo tool.
#
# Usage:
# ./calendar.sh send [options] # send a calendar invite
# ./calendar.sh send [options] # send a calendar invite (supports --rrule)
# ./calendar.sh reply [options] # accept/decline/tentative
# ./calendar.sh event list [options] # list/search calendar events
# ./calendar.sh event delete [options] # delete an event by UID or summary
# ./calendar.sh todo add [options] # create a todo
# ./calendar.sh todo list [options] # list pending todos
# ./calendar.sh todo edit [options] # edit a todo's fields