From 765825a8d15b157f5a89e78f2c38c61eb6e3410a Mon Sep 17 00:00:00 2001 From: Yanxin Lu Date: Wed, 18 Mar 2026 14:36:29 -0700 Subject: [PATCH] using MIME instead of MML --- skills/calendar-invite/SKILL.md | 14 +- skills/calendar-invite/TESTING.md | 170 ++++++++++++++++++ .../scripts/calendar_invite.py | 106 +++++------ 3 files changed, 221 insertions(+), 69 deletions(-) create mode 100644 skills/calendar-invite/TESTING.md diff --git a/skills/calendar-invite/SKILL.md b/skills/calendar-invite/SKILL.md index 2b840bd..890b0b4 100644 --- a/skills/calendar-invite/SKILL.md +++ b/skills/calendar-invite/SKILL.md @@ -8,6 +8,10 @@ metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer 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) @@ -71,7 +75,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \ | `--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 + MML without sending | +| `--dry-run` | No | Print ICS + MIME without sending | ### Send Examples @@ -153,8 +157,8 @@ $SKILL_DIR/scripts/calendar-invite.sh reply \ **Sending invites:** 1. Generates an RFC 5545 ICS file with `METHOD:REQUEST` (via `icalendar` library) -2. Builds an MML email with a `text/calendar` attachment -3. Sends via `himalaya template send` +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 @@ -162,7 +166,7 @@ $SKILL_DIR/scripts/calendar-invite.sh reply \ 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 template send` +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 @@ -206,7 +210,7 @@ Common IANA timezones: ## Troubleshooting **Invite shows as attachment instead of calendar event?** -- Ensure `type=text/calendar method=REQUEST` is set on the MML part +- 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?** diff --git a/skills/calendar-invite/TESTING.md b/skills/calendar-invite/TESTING.md new file mode 100644 index 0000000..63e75d0 --- /dev/null +++ b/skills/calendar-invite/TESTING.md @@ -0,0 +1,170 @@ +# 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 + +# Accept it +$SKILL_DIR/scripts/calendar-invite.sh reply \ + --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 \ + --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/` | diff --git a/skills/calendar-invite/scripts/calendar_invite.py b/skills/calendar-invite/scripts/calendar_invite.py index d4f3227..cab6686 100644 --- a/skills/calendar-invite/scripts/calendar_invite.py +++ b/skills/calendar-invite/scripts/calendar_invite.py @@ -17,7 +17,11 @@ import uuid from datetime import datetime from pathlib import Path -from icalendar import Calendar, Event, vText +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from icalendar import Calendar, Event, vCalAddress, vText # --------------------------------------------------------------------------- # Config @@ -65,13 +69,27 @@ def _sync_calendar(): print("Warning: CalDAV sync failed (will retry on next heartbeat)") -def _send_mml(mml, account=None): - """Send an MML message via himalaya template send.""" +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 += ["template", "send"] - subprocess.run(cmd, input=mml, text=True, check=True) + 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 _parse_iso_datetime(dt_str): @@ -111,8 +129,9 @@ def cmd_send(args): event.add("summary", args.summary) event.add("status", "CONFIRMED") event.add("sequence", 0) - event["organizer"] = f"mailto:{args.sender}" - event["organizer"].params["CN"] = vText(organizer_name) + organizer = vCalAddress(f"mailto:{args.sender}") + organizer.params["CN"] = vText(organizer_name) + event.add("organizer", organizer) if args.location: event.add("location", args.location) @@ -135,10 +154,6 @@ def cmd_send(args): cal.add_component(event) ics_bytes = cal.to_ical() - # Write ICS to temp file - tmp_ics = Path(f"/tmp/openclaw-invite-{int(datetime.now().timestamp())}.ics") - tmp_ics.write_bytes(ics_bytes) - # Build plain text body body = f"You're invited to: {args.summary}\n\nWhen: {args.start} - {args.end} ({args.timezone})" if args.location: @@ -149,29 +164,18 @@ def cmd_send(args): # Email goes to all attendees (including owner) all_to = ", ".join(all_attendees) - # Build MML message - mml = ( - f"From: {args.sender}\n" - f"To: {all_to}\n" - f"Subject: {args.subject}\n" - f"\n" - f"<#multipart type=mixed>\n" - f"<#part type=text/plain>\n" - f"{body}\n" - f"<#part type=text/calendar method=REQUEST filename={tmp_ics} name=invite.ics><#/part>\n" - f"<#/multipart>" - ) + # 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("=== MML Message ===") - print(mml) - tmp_ics.unlink(missing_ok=True) + print("=== Email Message ===") + print(email_str) return - # Send email - _send_mml(mml, args.account) + # 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 @@ -181,8 +185,6 @@ def cmd_send(args): print(f"Saved to local calendar: {dest}") _sync_calendar() - tmp_ics.unlink(missing_ok=True) - # --------------------------------------------------------------------------- # Reply to invite @@ -305,10 +307,6 @@ def cmd_reply(args): reply_cal.add_component(reply_event) reply_ics_bytes = reply_cal.to_ical() - # Write reply ICS to temp file - tmp_reply = Path(f"/tmp/openclaw-reply-{int(datetime.now().timestamp())}.ics") - tmp_reply.write_bytes(reply_ics_bytes) - # Build email prefix = SUBJECT_PREFIX[partstat] subject = f"{prefix}: {summary}" @@ -317,17 +315,7 @@ def cmd_reply(args): if args.comment: body += f"\n\n{args.comment}" - mml = ( - f"From: {args.sender}\n" - f"To: {organizer_email}\n" - f"Subject: {subject}\n" - f"\n" - f"<#multipart type=mixed>\n" - f"<#part type=text/plain>\n" - f"{body}\n" - f"<#part type=text/calendar method=REPLY filename={tmp_reply} name=invite.ics><#/part>\n" - f"<#/multipart>" - ) + email_str = _build_calendar_email(args.sender, organizer_email, subject, body, reply_ics_bytes, method="REPLY") if args.dry_run: print("=== Original Event ===") @@ -337,9 +325,8 @@ def cmd_reply(args): print() print("=== Reply ICS ===") print(reply_ics_bytes.decode()) - print("=== MML Message ===") - print(mml) - tmp_reply.unlink(missing_ok=True) + print("=== Email Message ===") + print(email_str) if cleanup_dir: for f in cleanup_dir.iterdir(): f.unlink() @@ -347,30 +334,22 @@ def cmd_reply(args): return # Send reply - _send_mml(mml, args.account) + _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"): - tmp_fwd = Path(f"/tmp/openclaw-fwd-{int(datetime.now().timestamp())}.ics") - tmp_fwd.write_bytes(ics_path.read_bytes()) - fwd_mml = ( - f"From: {args.sender}\n" - f"To: {DEFAULT_OWNER_EMAIL}\n" - f"Subject: {prefix}: {summary}\n" - f"\n" - f"<#multipart type=mixed>\n" - f"<#part type=text/plain>\n" - f"{prefix}: {summary}\n" - f"<#part type=text/calendar method=REQUEST filename={tmp_fwd} name=invite.ics><#/part>\n" - f"<#/multipart>" + 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_mml(fwd_mml, args.account) + _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}") - tmp_fwd.unlink(missing_ok=True) # Save to / remove from local calendar if CALENDAR_DIR.is_dir(): @@ -385,7 +364,6 @@ def cmd_reply(args): _sync_calendar() # Cleanup - tmp_reply.unlink(missing_ok=True) if cleanup_dir: for f in cleanup_dir.iterdir(): f.unlink()