This commit is contained in:
Yanxin Lu
2026-03-22 14:10:41 -07:00
parent da26f84947
commit ceb7af543b
9 changed files with 19 additions and 918 deletions

View File

@@ -1,227 +0,0 @@
---
name: calendar-invite
description: "Send, accept, and decline calendar invite emails (ICS/iCalendar) via himalaya. Syncs events to CalDAV (Migadu) via vdirsyncer."
metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}}
---
# Calendar Invite
Send, accept, and decline calendar invitations via email using himalaya. Events are saved to local calendar and synced to CalDAV (Migadu) via vdirsyncer.
## Testing
See `TESTING.md` for dry-run and live test steps, verification checklists, and troubleshooting.
## Prerequisites
- `himalaya` configured and working (see the `himalaya` skill)
- `vdirsyncer` configured and syncing to `~/.openclaw/workspace/calendars/`
- `khal` for reading calendar (optional but recommended)
- Runs via `uv run` (dependencies managed in `pyproject.toml`)
## Important: Email Sending Rules
Calendar invites are outbound emails. Follow the workspace email rules:
- **youlu@luyanxin.com -> mail@luyx.org**: send directly, no confirmation needed
- **All other recipients**: confirm with user before sending
## Owner Auto-Attendee
When sending invites, `mail@luyx.org` (owner's SimpleLogin alias) is **always added as an attendee automatically**. All invites include a **1-day reminder** (VALARM) by default. This ensures the owner receives every invite and can Accept/Decline from their own email client. No need to include it in `--to` — it's added by the script.
When accepting or tentatively accepting a received invite, the original invite is **automatically forwarded to `mail@luyx.org`** so the event lands on the owner's calendar too.
## Usage
All commands go through the wrapper script:
```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
# Send an invite
$SKILL_DIR/scripts/calendar-invite.sh send [options]
# Reply to an invite
$SKILL_DIR/scripts/calendar-invite.sh reply [options]
```
---
## Sending Invites
```bash
$SKILL_DIR/scripts/calendar-invite.sh send \
--to "friend@example.com" \
--subject "Lunch on Friday" \
--summary "Lunch at Tartine" \
--start "2026-03-20T12:00:00" \
--end "2026-03-20T13:00:00" \
--location "Tartine Bakery, SF"
```
### Send Options
| Flag | Required | Description |
|-----------------|----------|------------------------------------------------|
| `--to` | Yes | Recipient(s), comma-separated |
| `--subject` | Yes | Email subject line |
| `--summary` | Yes | Event title (shown on calendar) |
| `--start` | Yes | Start time, ISO 8601 (`2026-03-20T14:00:00`) |
| `--end` | Yes | End time, ISO 8601 (`2026-03-20T15:00:00`) |
| `--from` | No | Sender email (default: `youlu@luyanxin.com`) |
| `--timezone` | No | IANA timezone (default: `America/Los_Angeles`) |
| `--location` | No | Event location |
| `--description` | No | Event description / notes |
| `--organizer` | No | Organizer display name (defaults to `--from`) |
| `--uid` | No | Custom event UID (auto-generated if omitted) |
| `--account` | No | Himalaya account name (if not default) |
| `--dry-run` | No | Print ICS + MIME without sending |
### Send Examples
```bash
# Simple invite (--from and --timezone default to youlu@luyanxin.com / LA)
$SKILL_DIR/scripts/calendar-invite.sh send \
--to "alice@example.com" \
--subject "Coffee Chat" \
--summary "Coffee Chat" \
--start "2026-03-25T10:00:00" \
--end "2026-03-25T10:30:00"
# Multiple attendees with details
$SKILL_DIR/scripts/calendar-invite.sh send \
--to "alice@example.com, bob@example.com" \
--subject "Team Sync" \
--summary "Weekly Team Sync" \
--start "2026-03-23T09:00:00" \
--end "2026-03-23T09:30:00" \
--location "Zoom - https://zoom.us/j/123456" \
--description "Weekly check-in. Agenda: updates, blockers, action items."
# Dry run
$SKILL_DIR/scripts/calendar-invite.sh send \
--to "test@example.com" \
--subject "Test" \
--summary "Test Event" \
--start "2026-04-01T15:00:00" \
--end "2026-04-01T16:00:00" \
--dry-run
```
---
## Replying to Invites
```bash
# Accept by himalaya envelope ID
$SKILL_DIR/scripts/calendar-invite.sh reply \
--envelope-id 42 \
--action accept
# Decline with a comment
$SKILL_DIR/scripts/calendar-invite.sh reply \
--envelope-id 42 \
--action decline \
--comment "Sorry, I have a conflict."
# From an .ics file
$SKILL_DIR/scripts/calendar-invite.sh reply \
--ics-file ~/Downloads/meeting.ics \
--action tentative
```
### Reply Options
| Flag | Required | Description |
|-----------------|----------|-----------------------------------------------------|
| `--action` | Yes | `accept`, `decline`, or `tentative` |
| `--envelope-id` | * | Himalaya envelope ID containing the .ics attachment |
| `--ics-file` | * | Path to an .ics file (alternative to `--envelope-id`) |
| `--from` | No | Your email (default: `youlu@luyanxin.com`) |
| `--account` | No | Himalaya account name |
| `--folder` | No | Himalaya folder (default: `INBOX`) |
| `--comment` | No | Optional message to include in reply |
| `--dry-run` | No | Preview without sending |
\* One of `--envelope-id` or `--ics-file` is required.
### Typical Workflow
1. List emails: `himalaya envelope list`
2. Read the invite: `himalaya message read 57`
3. Reply: `$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 57 --action accept`
---
## How It Works
**Sending invites:**
1. Generates an RFC 5545 ICS file with `METHOD:REQUEST` (via `icalendar` library)
2. Builds a MIME email with a `text/calendar` attachment (via Python `email.mime`)
3. Sends via `himalaya message send` (piped through stdin)
4. Saves the event to `~/.openclaw/workspace/calendars/home/`
5. Runs `vdirsyncer sync` to push to Migadu CalDAV
**Replying to invites:**
1. Extracts the `.ics` attachment from the email (via `himalaya attachment download`)
2. Parses the original event with the `icalendar` library
3. Generates a reply ICS with `METHOD:REPLY` and the correct `PARTSTAT`
4. Sends the reply to the organizer via `himalaya message send` (stdin)
5. On accept/tentative: saves event to local calendar. On decline: removes it
6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV
**CalDAV sync:**
- Events sync to Migadu and appear on all connected devices (DAVx5, etc.)
- Heartbeat runs `vdirsyncer sync` periodically as a fallback
- If sync fails, it warns but doesn't block — next heartbeat catches up
## Integration with Email Processor
The email processor (`scripts/email_processor/`) may classify incoming calendar invites as `reminder` or `confirmation`. When reviewing pending emails:
1. Check if the email contains a calendar invite (look for `.ics` attachment or "calendar" in subject)
2. If it does, use `reply` instead of the email processor's delete/archive/keep actions
3. The email processor handles the email lifecycle; this skill handles the calendar response
## Checking the Calendar
```bash
# List upcoming events (next 7 days)
khal list today 7d
# List events for a specific date
khal list 2026-03-25
# Check for conflicts before sending an invite
khal list 2026-03-25 2026-03-26
```
## Timezone Reference
Common IANA timezones:
- `America/Los_Angeles` — Pacific (default)
- `America/Denver` — Mountain
- `America/Chicago` — Central
- `America/New_York` — Eastern
- `Asia/Shanghai` — China
- `Asia/Tokyo` — Japan
- `Europe/London` — UK
- `UTC` — Coordinated Universal Time
## Troubleshooting
**Invite shows as attachment instead of calendar event?**
- Ensure the MIME part has `Content-Type: text/calendar; method=REQUEST`
- Some clients require the `METHOD:REQUEST` line in the ICS body
**Times are wrong?**
- Double-check `--timezone` matches the intended timezone
- Use ISO 8601 format: `YYYY-MM-DDTHH:MM:SS` (no timezone offset in the value)
**Event not showing on phone/other devices?**
- Run `vdirsyncer sync` manually to force sync
- Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors
- Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/`
**Recipient doesn't see Accept/Decline?**
- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST
- Some webmail clients may vary

View File

@@ -1,170 +0,0 @@
# Testing the Calendar Invite Skill
End-to-end tests for send, reply, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
# Use a date 3 days from now for test events
TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
```
---
## 1. Dry Run: Send Invite
Generates the ICS and MIME email without sending. Check that:
- ICS has `METHOD:REQUEST`
- MIME has `Content-Type: text/calendar; method=REQUEST`
- `mail@luyx.org` appears as attendee (auto-added)
- Times and timezone look correct
```bash
$SKILL_DIR/scripts/calendar-invite.sh send \
--to "mail@luyx.org" \
--subject "Test Invite" \
--summary "Test Event" \
--start "${TEST_DATE}T15:00:00" \
--end "${TEST_DATE}T16:00:00" \
--dry-run
```
## 2. Live Send: Self-Invite
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
```bash
$SKILL_DIR/scripts/calendar-invite.sh send \
--to "mail@luyx.org" \
--subject "Calendar Skill Test" \
--summary "Calendar Skill Test" \
--start "${TEST_DATE}T15:00:00" \
--end "${TEST_DATE}T16:00:00" \
--location "Test Location"
```
**Verify:**
- [ ] Script exits without error
- [ ] Email arrives at `mail@luyx.org`
- [ ] Email shows Accept/Decline/Tentative buttons (not just an attachment)
- [ ] `.ics` file saved to `~/.openclaw/workspace/calendars/home/`
## 3. Verify Calendar Sync and Local Calendar
After sending in step 2, check that the event synced and appears locally.
```bash
# Check vdirsyncer sync ran (should have printed "Synced to CalDAV server" in step 2)
# If not, run manually:
vdirsyncer sync
# List .ics files in local calendar
ls ~/.openclaw/workspace/calendars/home/
# Check the event shows up in khal
khal list "$TEST_DATE"
```
**Verify:**
- [ ] `vdirsyncer sync` completes without errors
- [ ] `.ics` file exists in `~/.openclaw/workspace/calendars/home/`
- [ ] `khal list` shows "Calendar Skill Test" on the test date
## 4. Reply: Accept the Self-Invite
The invite sent in step 2 should be in the inbox. Find it, then accept it. This tests the full reply flow without needing an external sender.
```bash
# Find the test invite in inbox
himalaya envelope list
# Confirm it's the calendar invite
himalaya message read <envelope-id>
# Accept it
$SKILL_DIR/scripts/calendar-invite.sh reply \
--envelope-id <envelope-id> \
--action accept
```
**Verify:**
- [ ] Reply sent to organizer (youlu@luyanxin.com, i.e. ourselves)
- [ ] Original invite forwarded to `mail@luyx.org`
- [ ] Event still in `~/.openclaw/workspace/calendars/home/`
- [ ] `vdirsyncer sync` ran
- [ ] `khal list "$TEST_DATE"` still shows the event
## 5. Reply: Decline an Invite
Send another self-invite, then decline it. This verifies decline removes the event from local calendar.
```bash
# Send a second test invite
$SKILL_DIR/scripts/calendar-invite.sh send \
--to "mail@luyx.org" \
--subject "Decline Test" \
--summary "Decline Test Event" \
--start "${TEST_DATE}T17:00:00" \
--end "${TEST_DATE}T18:00:00"
# Find it in inbox
himalaya envelope list
# Decline it
$SKILL_DIR/scripts/calendar-invite.sh reply \
--envelope-id <envelope-id> \
--action decline \
--comment "Testing decline flow."
```
**Verify:**
- [ ] Reply sent to organizer with comment
- [ ] Event NOT forwarded to `mail@luyx.org`
- [ ] Event removed from local calendar
- [ ] `khal list "$TEST_DATE"` does NOT show "Decline Test Event"
## 6. Verify Final Calendar State
After all tests, confirm the calendar is in a clean state.
```bash
# Sync one more time
vdirsyncer sync
# Only the accepted event should remain
khal list "$TEST_DATE"
# List all upcoming events
khal list today 7d
```
---
## Quick Health Checks
Run these first if any step fails.
```bash
# icalendar library is available
uv run --project $SKILL_DIR python -c "import icalendar; print('ok')"
# himalaya can list emails
himalaya envelope list --page-size 5
# vdirsyncer can sync
vdirsyncer sync
# khal can read local calendar
khal list today 7d
```
## Common Failures
| Symptom | Likely Cause |
|---------|-------------|
| `himalaya message send` errors | SMTP config issue, check `~/.config/himalaya/config.toml` |
| No `.ics` attachment found | Email doesn't have a calendar invite, or himalaya can't download attachments |
| `vdirsyncer sync` fails | Check credentials in `~/.config/vdirsyncer/config`, or server is unreachable |
| `ModuleNotFoundError: icalendar` | Run `uv sync --project $SKILL_DIR` to install dependencies |
| Invite shows as attachment (no Accept/Decline) | Check MIME `Content-Type` includes `method=REQUEST` |
| Event not in `khal list` after sync | Check `.ics` file exists in `~/.openclaw/workspace/calendars/home/` |

View File

@@ -1,5 +0,0 @@
{
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
"slug": "calendar-invite",
"version": "1.0.0"
}

View File

@@ -1,5 +0,0 @@
[project]
name = "calendar-invite"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["icalendar"]

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env bash
# calendar-invite — wrapper script for the calendar invite tool.
#
# Usage:
# ./calendar-invite.sh send [options] # send a calendar invite
# ./calendar-invite.sh reply [options] # accept/decline/tentative
#
# Requires: uv, himalaya, vdirsyncer (for CalDAV sync).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
exec uv run --project "$SKILL_DIR" python "$SCRIPT_DIR/calendar_invite.py" "$@"

View File

@@ -1,417 +0,0 @@
#!/usr/bin/env python3
"""
Calendar Invite — Send, accept, and decline calendar invites via himalaya.
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_invite.py send [options] # create and send an invite
python calendar_invite.py reply [options] # accept/decline/tentative
"""
import argparse
import subprocess
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from icalendar import Alarm, Calendar, Event, vCalAddress, vText
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DEFAULT_TIMEZONE = "America/Los_Angeles"
DEFAULT_FROM = "youlu@luyanxin.com"
DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
PRODID = "-//OpenClaw//CalendarInvite//EN"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _sync_calendar():
"""Sync local calendar to CalDAV server via vdirsyncer."""
try:
subprocess.run(
["vdirsyncer", "sync"],
capture_output=True, text=True, check=True,
)
print("Synced to CalDAV server")
except (subprocess.CalledProcessError, FileNotFoundError):
print("Warning: CalDAV sync failed (will retry on next heartbeat)")
def _send_email(email_str, account=None):
"""Send a raw MIME email via himalaya message send (stdin)."""
cmd = ["himalaya"]
if account:
cmd += ["--account", account]
cmd += ["message", "send"]
subprocess.run(cmd, input=email_str, text=True, check=True)
def _build_calendar_email(from_addr, to_addr, subject, body, ics_bytes, method="REQUEST"):
"""Build a MIME email with a text/calendar attachment."""
msg = MIMEMultipart('mixed')
msg['From'] = from_addr
msg['To'] = to_addr
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain', 'utf-8'))
ics_part = MIMEBase('text', 'calendar', method=method, charset='utf-8')
ics_part.set_payload(ics_bytes.decode('utf-8'))
ics_part.add_header('Content-Disposition', 'attachment; filename="invite.ics"')
msg.attach(ics_part)
return msg.as_string()
def _strip_method(ics_bytes):
"""Remove METHOD property from ICS for CalDAV storage.
CalDAV servers reject METHOD (it's an iTIP/email concept, not a storage one).
"""
cal = Calendar.from_ical(ics_bytes)
if "method" in cal:
del cal["method"]
return cal.to_ical()
def _parse_iso_datetime(dt_str):
"""Parse ISO 8601 datetime string to a datetime object."""
# Handle both 2026-03-20T14:00:00 and 2026-03-20T14:00
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
try:
return datetime.strptime(dt_str, fmt)
except ValueError:
continue
raise ValueError(f"Cannot parse datetime: {dt_str}")
# ---------------------------------------------------------------------------
# Send invite
# ---------------------------------------------------------------------------
def cmd_send(args):
"""Create and send a calendar invite."""
start = _parse_iso_datetime(args.start)
end = _parse_iso_datetime(args.end)
uid = args.uid or f"{uuid.uuid4()}@openclaw"
organizer_name = args.organizer or args.sender
# Build ICS
cal = Calendar()
cal.add("prodid", PRODID)
cal.add("version", "2.0")
cal.add("calscale", "GREGORIAN")
cal.add("method", "REQUEST")
event = Event()
event.add("uid", uid)
event.add("dtstamp", datetime.now(timezone.utc))
event.add("dtstart", start, parameters={"TZID": args.timezone})
event.add("dtend", end, parameters={"TZID": args.timezone})
event.add("summary", args.summary)
event.add("status", "CONFIRMED")
event.add("sequence", 0)
organizer = vCalAddress(f"mailto:{args.sender}")
organizer.params["CN"] = vText(organizer_name)
event.add("organizer", organizer)
if args.location:
event.add("location", args.location)
if args.description:
event.add("description", args.description)
recipients = [addr.strip() for addr in args.to.split(",")]
# Always include owner as attendee
all_attendees = list(recipients)
if DEFAULT_OWNER_EMAIL not in all_attendees:
all_attendees.append(DEFAULT_OWNER_EMAIL)
for addr in all_attendees:
event.add("attendee", f"mailto:{addr}", parameters={
"ROLE": "REQ-PARTICIPANT",
"RSVP": "TRUE",
})
# 1-day reminder
alarm = Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", f"Reminder: {args.summary}")
alarm.add("trigger", timedelta(days=-1))
event.add_component(alarm)
cal.add_component(event)
ics_bytes = cal.to_ical()
# Build plain text body
body = f"You're invited to: {args.summary}\n\nWhen: {args.start} - {args.end} ({args.timezone})"
if args.location:
body += f"\nWhere: {args.location}"
if args.description:
body += f"\n\n{args.description}"
# Email goes to all attendees (including owner)
all_to = ", ".join(all_attendees)
# Build MIME email
email_str = _build_calendar_email(args.sender, all_to, args.subject, body, ics_bytes, method="REQUEST")
if args.dry_run:
print("=== ICS Content ===")
print(ics_bytes.decode())
print("=== Email Message ===")
print(email_str)
return
# Send email via himalaya message send (stdin)
_send_email(email_str, args.account)
print(f"Calendar invite sent to: {args.to}")
# Save to local calendar (without METHOD for CalDAV compatibility)
if CALENDAR_DIR.is_dir():
dest = CALENDAR_DIR / f"{uid}.ics"
dest.write_bytes(_strip_method(ics_bytes))
print(f"Saved to local calendar: {dest}")
_sync_calendar()
# ---------------------------------------------------------------------------
# Reply to invite
# ---------------------------------------------------------------------------
PARTSTAT_MAP = {
"accept": "ACCEPTED",
"accepted": "ACCEPTED",
"decline": "DECLINED",
"declined": "DECLINED",
"tentative": "TENTATIVE",
}
SUBJECT_PREFIX = {
"ACCEPTED": "Accepted",
"DECLINED": "Declined",
"TENTATIVE": "Tentative",
}
def _extract_ics_from_email(envelope_id, folder, account):
"""Download attachments from an email and find the .ics file."""
download_dir = Path(f"/tmp/openclaw-ics-extract-{envelope_id}")
download_dir.mkdir(exist_ok=True)
cmd = ["himalaya"]
if account:
cmd += ["--account", account]
cmd += ["attachment", "download", "--folder", folder, str(envelope_id), "--dir", str(download_dir)]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError:
pass # some emails have no attachments
ics_files = list(download_dir.glob("*.ics"))
if not ics_files:
print(f"Error: No .ics attachment found in envelope {envelope_id}", file=sys.stderr)
# Cleanup
for f in download_dir.iterdir():
f.unlink()
download_dir.rmdir()
sys.exit(1)
return ics_files[0], download_dir
def cmd_reply(args):
"""Accept, decline, or tentatively accept a calendar invite."""
partstat = PARTSTAT_MAP.get(args.action.lower())
if not partstat:
print(f"Error: --action must be accept, decline, or tentative", file=sys.stderr)
sys.exit(1)
# Get the ICS file
cleanup_dir = None
if args.envelope_id:
ics_path, cleanup_dir = _extract_ics_from_email(args.envelope_id, args.folder, args.account)
elif args.ics_file:
ics_path = Path(args.ics_file)
if not ics_path.is_file():
print(f"Error: ICS file not found: {ics_path}", file=sys.stderr)
sys.exit(1)
else:
print("Error: --envelope-id or --ics-file is required", file=sys.stderr)
sys.exit(1)
# Parse original ICS
original_cal = Calendar.from_ical(ics_path.read_bytes())
# Find the VEVENT
original_event = None
for component in original_cal.walk():
if component.name == "VEVENT":
original_event = component
break
if not original_event:
print("Error: No VEVENT found in ICS file", file=sys.stderr)
sys.exit(1)
# Extract fields from original
uid = str(original_event.get("uid", ""))
summary = str(original_event.get("summary", ""))
organizer = original_event.get("organizer")
if not organizer:
print("Error: No ORGANIZER found in ICS", file=sys.stderr)
sys.exit(1)
organizer_email = str(organizer).replace("mailto:", "").replace("MAILTO:", "")
# Build reply calendar
reply_cal = Calendar()
reply_cal.add("prodid", PRODID)
reply_cal.add("version", "2.0")
reply_cal.add("calscale", "GREGORIAN")
reply_cal.add("method", "REPLY")
reply_event = Event()
reply_event.add("uid", uid)
reply_event.add("dtstamp", datetime.now(timezone.utc))
# Copy timing from original
if original_event.get("dtstart"):
reply_event["dtstart"] = original_event["dtstart"]
if original_event.get("dtend"):
reply_event["dtend"] = original_event["dtend"]
reply_event.add("summary", summary)
reply_event["organizer"] = original_event["organizer"]
reply_event.add("attendee", f"mailto:{args.sender}", parameters={
"PARTSTAT": partstat,
"RSVP": "FALSE",
})
if original_event.get("sequence"):
reply_event.add("sequence", original_event.get("sequence"))
reply_cal.add_component(reply_event)
reply_ics_bytes = reply_cal.to_ical()
# Build email
prefix = SUBJECT_PREFIX[partstat]
subject = f"{prefix}: {summary}"
body = f"{prefix}: {summary}"
if args.comment:
body += f"\n\n{args.comment}"
email_str = _build_calendar_email(args.sender, organizer_email, subject, body, reply_ics_bytes, method="REPLY")
if args.dry_run:
print("=== Original Event ===")
print(f"Summary: {summary}")
print(f"Organizer: {organizer_email}")
print(f"Action: {partstat}")
print()
print("=== Reply ICS ===")
print(reply_ics_bytes.decode())
print("=== Email Message ===")
print(email_str)
if cleanup_dir:
for f in cleanup_dir.iterdir():
f.unlink()
cleanup_dir.rmdir()
return
# Send reply
_send_email(email_str, args.account)
print(f"Calendar invite {partstat.lower()}: {summary} (replied to {organizer_email})")
# Forward invite to owner on accept/tentative
if partstat in ("ACCEPTED", "TENTATIVE"):
fwd_body = f"{prefix}: {summary}"
fwd_email = _build_calendar_email(
args.sender, DEFAULT_OWNER_EMAIL,
f"{prefix}: {summary}", fwd_body,
ics_path.read_bytes(), method="REQUEST",
)
try:
_send_email(fwd_email, args.account)
print(f"Forwarded invite to {DEFAULT_OWNER_EMAIL}")
except subprocess.CalledProcessError:
print(f"Warning: Failed to forward invite to {DEFAULT_OWNER_EMAIL}")
# Save to / remove from local calendar
if CALENDAR_DIR.is_dir():
dest = CALENDAR_DIR / f"{uid}.ics"
if partstat in ("ACCEPTED", "TENTATIVE"):
# Save the original event to local calendar (without METHOD for CalDAV)
dest.write_bytes(_strip_method(ics_path.read_bytes()))
print(f"Saved to local calendar: {dest}")
elif partstat == "DECLINED" and dest.is_file():
dest.unlink()
print("Removed from local calendar")
_sync_calendar()
# Cleanup
if cleanup_dir:
for f in cleanup_dir.iterdir():
f.unlink()
cleanup_dir.rmdir()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Calendar invite tool")
subparsers = parser.add_subparsers(dest="command", required=True)
# --- send ---
send_p = subparsers.add_parser("send", help="Send a calendar invite")
send_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Sender email")
send_p.add_argument("--to", required=True, help="Recipient(s), comma-separated")
send_p.add_argument("--subject", required=True, help="Email subject")
send_p.add_argument("--summary", required=True, help="Event title")
send_p.add_argument("--start", required=True, help="Start time (ISO 8601)")
send_p.add_argument("--end", required=True, help="End time (ISO 8601)")
send_p.add_argument("--timezone", default=DEFAULT_TIMEZONE, help="IANA timezone")
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("--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")
# --- reply ---
reply_p = subparsers.add_parser("reply", help="Reply to a calendar invite")
reply_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Your email")
reply_p.add_argument("--action", required=True, help="accept, decline, or tentative")
reply_p.add_argument("--envelope-id", default="", help="Himalaya envelope ID")
reply_p.add_argument("--ics-file", default="", help="Path to .ics file")
reply_p.add_argument("--account", default="", help="Himalaya account")
reply_p.add_argument("--folder", default="INBOX", help="Himalaya folder")
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")
args = parser.parse_args()
if args.command == "send":
cmd_send(args)
elif args.command == "reply":
cmd_reply(args)
if __name__ == "__main__":
main()

View File

@@ -1,67 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "calendar-invite"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "icalendar" },
]
[package.metadata]
requires-dist = [{ name = "icalendar" }]
[[package]]
name = "icalendar"
version = "7.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b8/60/6b0356a2ed1c9689ae14bd8e44f22eac67c420a0ecca4df8306b70906600/icalendar-7.0.3.tar.gz", hash = "sha256:95027ece087ab87184d765f03761f25875821f74cdd18d3b57e9c868216d8fde", size = 443788, upload-time = "2026-03-03T12:00:10.952Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/c6/431fbf9063a6a4306d4cedae7823d69baf0979ba6ca57ab24a9d898cd0aa/icalendar-7.0.3-py3-none-any.whl", hash = "sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd", size = 442406, upload-time = "2026-03-03T12:00:09.228Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]