using MIME instead of MML

This commit is contained in:
Yanxin Lu
2026-03-18 14:36:29 -07:00
parent c4125d1145
commit 765825a8d1
3 changed files with 221 additions and 69 deletions

View File

@@ -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?**

View File

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

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