using MIME instead of MML
This commit is contained in:
@@ -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?**
|
||||
|
||||
170
skills/calendar-invite/TESTING.md
Normal file
170
skills/calendar-invite/TESTING.md
Normal 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/` |
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user