diff --git a/TOOLS.md b/TOOLS.md index 9143865..335b1c4 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -28,21 +28,44 @@ Skills define _how_ tools work. This file is for _your_ specifics — the stuff **本地配置:** - 二进制:`~/.local/bin/himalaya` +- **安全包装器**:`~/.openclaw/workspace/skills/himalaya/scripts/himalaya.sh`(验证收件人) - 配置:`~/.config/himalaya/config.toml` - 文档:`~/.openclaw/workspace/skills/himalaya/SKILL.md` -**核心用法:** +**重要:发送邮件时必须使用包装器,不要直接调用 himalaya** + ```bash -himalaya envelope list --page-size 20 # 列出邮件 -himalaya message read # 读取邮件 -himalaya message delete # 删除邮件 -himalaya message write # 写新邮件(交互式) +HIMALAYA=~/.openclaw/workspace/skills/himalaya/scripts/himalaya.sh + +$HIMALAYA envelope list --page-size 20 # 列出邮件(直接透传) +$HIMALAYA message read # 读取邮件(直接透传) +$HIMALAYA message delete # 删除邮件(直接透传) +cat msg.txt | $HIMALAYA template send # 发送邮件(校验收件人) ``` **邮件发送规则:** +- **所有发送命令通过包装器**,自动校验收件人在通讯录中 - **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认(用户 SimpleLogin 别名) - 其他所有对外邮件: 仍需确认 +### 通讯录(contacts) + +**目录**: `~/.openclaw/workspace/skills/contacts/` +**数据**: `~/.openclaw/workspace/contacts/default/`(vCard .vcf 文件,CardDAV 同步) + +```bash +CONTACTS=~/.openclaw/workspace/skills/contacts/scripts/contacts.sh + +$CONTACTS list # 列出所有联系人 +$CONTACTS add --name "小橘子" --email "x@y.com" --type work # 添加联系人 +$CONTACTS delete --name "小橘子" # 删除联系人 +$CONTACTS resolve "小橘子:work" # 解析为邮箱地址 +``` + +**安全规则**: +- **添加联系人和发送邮件是独立操作**,不要在同一次请求中先 add 再 send +- 所有邮件发送(himalaya 和日历邀请)都会校验收件人在通讯录中 + ### 🌐 网页操作 - 工具选择决策表 | 场景 | 首选 | 次选 | @@ -105,19 +128,13 @@ agent-browser close **目录**: `~/.openclaw/workspace/skills/calendar/` **默认发件人**: youlu@luyanxin.com **默认时区**: America/Los_Angeles -**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办)、`contacts/default/`(通讯录) +**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办) **运行方式**: `uv run`(依赖 `icalendar` 库) **核心用法**: ```bash SKILL_DIR=~/.openclaw/workspace/skills/calendar -# 通讯录管理(send 命令会校验收件人必须在通讯录中) -$SKILL_DIR/scripts/calendar.sh contact list -$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "Erica.Jiang@anderson.ucla.edu" --type work -$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "xueweijiang0313@gmail.com" --type home -$SKILL_DIR/scripts/calendar.sh contact delete --name "小橘子" - # 发送日历邀请(--to 用通讯录名称,不要直接写邮箱地址) $SKILL_DIR/scripts/calendar.sh send \ --to "小橘子:work" \ @@ -154,7 +171,7 @@ $SKILL_DIR/scripts/calendar.sh todo delete --match "报销" $SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron) ``` -**支持操作**: 通讯录 (`contact list/add/delete`)、发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete --date/--all`)、待办管理 (`todo add/list/edit/complete/delete/check`) +**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete --date/--all`)、待办管理 (`todo add/list/edit/complete/delete/check`) **依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理) **同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步 **注意**: 发送日历邀请属于对外邮件,需先确认 diff --git a/skills/calendar/SKILL.md b/skills/calendar/SKILL.md index 81fea0d..0169c22 100644 --- a/skills/calendar/SKILL.md +++ b/skills/calendar/SKILL.md @@ -52,11 +52,6 @@ $SKILL_DIR/scripts/calendar.sh reply [options] $SKILL_DIR/scripts/calendar.sh event list [options] $SKILL_DIR/scripts/calendar.sh event delete [options] -# Manage contacts (used for recipient validation) -$SKILL_DIR/scripts/calendar.sh contact list -$SKILL_DIR/scripts/calendar.sh contact add [options] -$SKILL_DIR/scripts/calendar.sh contact delete [options] - # Manage todos $SKILL_DIR/scripts/calendar.sh todo add [options] $SKILL_DIR/scripts/calendar.sh todo list [options] @@ -197,28 +192,9 @@ $SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --all --- -## Managing Contacts +## Recipient Resolution -Contacts are stored as vCard (.vcf) files in `~/.openclaw/workspace/contacts/default/` and synced to Migadu CardDAV via vdirsyncer. The `send` command validates recipients against this contact list. - -```bash -# List all contacts -$SKILL_DIR/scripts/calendar.sh contact list - -# Add a contact (single email) -$SKILL_DIR/scripts/calendar.sh contact add --name "小鹿" --email "mail@luyx.org" - -# Add with email type and nickname -$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "Erica.Jiang@anderson.ucla.edu" --type work --nickname "小橘子" - -# Add a second email to an existing contact -$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "xueweijiang0313@gmail.com" --type home - -# Delete a contact -$SKILL_DIR/scripts/calendar.sh contact delete --name "小橘子" -``` - -### Sending to Contacts +The `send --to` flag delegates to the **contacts skill** (`skills/contacts/`) for address resolution. See the contacts skill SKILL.md for full documentation on adding/managing contacts. ```bash # By name (works when contact has a single email) diff --git a/skills/calendar/TESTING.md b/skills/calendar/TESTING.md index de25e0a..de09cfe 100644 --- a/skills/calendar/TESTING.md +++ b/skills/calendar/TESTING.md @@ -6,6 +6,7 @@ End-to-end tests for contacts, send, reply, todo, calendar sync, and local calen ```bash SKILL_DIR=~/.openclaw/workspace/skills/calendar +CONTACTS_DIR=~/.openclaw/workspace/skills/contacts # Use a date 3 days from now for test events TEST_DATE=$(date -d "+3 days" +%Y-%m-%d) @@ -19,19 +20,19 @@ Set up test contacts needed for send tests. ```bash # Add a contact with a single email -$SKILL_DIR/scripts/calendar.sh contact add --name "测试用户" --email "mail@luyx.org" +$CONTACTS_DIR/scripts/contacts.sh add --name "测试用户" --email "mail@luyx.org" # List contacts -$SKILL_DIR/scripts/calendar.sh contact list +$CONTACTS_DIR/scripts/contacts.sh list # Add a contact with typed email and nickname -$SKILL_DIR/scripts/calendar.sh contact add --name "测试多邮箱" --email "work@example.com" --type work --nickname "多邮箱" +$CONTACTS_DIR/scripts/contacts.sh add --name "测试多邮箱" --email "work@example.com" --type work --nickname "多邮箱" # Add a second email to the same contact -$SKILL_DIR/scripts/calendar.sh contact add --name "测试多邮箱" --email "home@example.com" --type home +$CONTACTS_DIR/scripts/contacts.sh add --name "测试多邮箱" --email "home@example.com" --type home # List again — should show both emails -$SKILL_DIR/scripts/calendar.sh contact list +$CONTACTS_DIR/scripts/contacts.sh list ``` **Verify:** @@ -105,13 +106,13 @@ $SKILL_DIR/scripts/calendar.sh send \ ```bash # Delete the multi-email test contact -$SKILL_DIR/scripts/calendar.sh contact delete --name "测试多邮箱" +$CONTACTS_DIR/scripts/contacts.sh delete --name "测试多邮箱" # Verify it's gone -$SKILL_DIR/scripts/calendar.sh contact list +$CONTACTS_DIR/scripts/contacts.sh list # Delete by nickname — should fail (contact already deleted) -$SKILL_DIR/scripts/calendar.sh contact delete --name "多邮箱" +$CONTACTS_DIR/scripts/contacts.sh delete --name "多邮箱" ``` **Verify:** @@ -621,6 +622,6 @@ todo list | SMTP rate limit / EOF error | Too many sends too fast. Wait 10+ seconds between sends (Migadu limit) | | Events disappeared after cleanup | **Never use `rm *.ics`** on calendar dirs. Use `event delete --match` instead | | Recurring series deleted when cancelling one date | Use `--date YYYY-MM-DD` to add EXDATE, not bare `event delete` (which requires `--all` for recurring) | -| `send` rejects email address | Address not in contacts. Add with `contact add` first (separate from send) | +| `send` rejects email address | Address not in contacts. Add with `contacts.sh add` first (separate from send) | | `send` says "has multiple emails" | Contact has work+home emails. Use `name:type` syntax (e.g. `小橘子:work`) | | Contacts dir empty after sync | Check vdirsyncer CardDAV pair is configured for `contacts/default/` | diff --git a/skills/calendar/scripts/cal_tool.py b/skills/calendar/scripts/cal_tool.py index d362d25..ac50eb9 100644 --- a/skills/calendar/scripts/cal_tool.py +++ b/skills/calendar/scripts/cal_tool.py @@ -10,9 +10,6 @@ Subcommands: 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 contact list # list all contacts - python calendar.py contact add [options] # add a contact - python calendar.py contact delete [options] # delete a contact 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 @@ -42,8 +39,8 @@ from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText DEFAULT_TIMEZONE = "America/Los_Angeles" DEFAULT_FROM = "youlu@luyanxin.com" CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home" -CONTACTS_DIR = Path.home() / ".openclaw" / "workspace" / "contacts" / "default" TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks" +CONTACTS_SCRIPT = Path(__file__).resolve().parent.parent.parent / "contacts" / "scripts" / "contacts.py" PRODID = "-//OpenClaw//Calendar//EN" # RFC 5545 priority mapping @@ -166,244 +163,25 @@ def _validate_rrule_dtstart(rrule_dict, dtstart): # --------------------------------------------------------------------------- -# Contacts (vCard / CardDAV) +# Contacts (delegates to contacts skill) # --------------------------------------------------------------------------- -def _parse_vcf(path): - """Parse a .vcf file into a dict with fn, nickname, emails, uid.""" - text = path.read_text(encoding="utf-8", errors="replace") - result = {"fn": "", "nickname": "", "emails": [], "uid": "", "path": path} - for line in text.splitlines(): - line_upper = line.upper() - if line_upper.startswith("FN:"): - result["fn"] = line.split(":", 1)[1].strip() - elif line_upper.startswith("NICKNAME:"): - result["nickname"] = line.split(":", 1)[1].strip() - elif line_upper.startswith("UID:"): - result["uid"] = line.split(":", 1)[1].strip() - elif "EMAIL" in line_upper and ":" in line: - # Handle EMAIL;TYPE=WORK:addr and EMAIL:addr - parts = line.split(":", 1) - email_addr = parts[1].strip() - email_type = "" - param_part = parts[0].upper() - if "TYPE=" in param_part: - for param in param_part.split(";"): - if param.startswith("TYPE="): - email_type = param.split("=", 1)[1].lower() - result["emails"].append({"address": email_addr, "type": email_type}) - return result - - -def _load_contacts(): - """Load all contacts from CONTACTS_DIR. Returns list of parsed contact dicts.""" - if not CONTACTS_DIR.is_dir(): - return [] - contacts = [] - for vcf_path in sorted(CONTACTS_DIR.glob("*.vcf")): - try: - contact = _parse_vcf(vcf_path) - if contact["fn"] or contact["emails"]: - contacts.append(contact) - except Exception: - continue - return contacts - - def _resolve_recipient(to_str): - """Resolve a --to value to an email address via contacts lookup. + """Resolve a --to value to an email address via the contacts skill. - Supported formats: - --to "小橘子" → match by FN or NICKNAME, use sole email or error if multiple - --to "小橘子:work" → match by name, select email by TYPE - --to "user@example.com" → match by email address in contacts - - Returns the resolved email address string, or exits with error + available contacts. + Delegates to contacts.py resolve, which validates against the contact list. + Exits with error if the address is not in contacts. """ - contacts = _load_contacts() - - # Format: "name:type" - if ":" in to_str and "@" not in to_str: - name_part, type_part = to_str.rsplit(":", 1) - type_part = type_part.lower() - else: - name_part = to_str - type_part = "" - - # If it looks like a raw email, search contacts for it - if "@" in name_part: - for c in contacts: - for e in c["emails"]: - if e["address"].lower() == name_part.lower(): - return e["address"] - _print_contact_error(f"Email '{name_part}' not found in contacts.", contacts) - sys.exit(1) - - # Search by FN or NICKNAME (case-insensitive) - matches = [] - for c in contacts: - if (c["fn"].lower() == name_part.lower() or - c["nickname"].lower() == name_part.lower()): - matches.append(c) - - if not matches: - _print_contact_error(f"Contact '{name_part}' not found.", contacts) - sys.exit(1) - if len(matches) > 1: - _print_contact_error(f"Multiple contacts match '{name_part}'.", contacts) - sys.exit(1) - - contact = matches[0] - emails = contact["emails"] - - if not emails: - print(f"Error: Contact '{contact['fn']}' has no email addresses.", file=sys.stderr) - sys.exit(1) - - # If type specified, filter by type - if type_part: - typed = [e for e in emails if e["type"] == type_part] - if not typed: - avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails) - print(f"Error: No '{type_part}' email for '{contact['fn']}'. Available: {avail}", file=sys.stderr) - sys.exit(1) - return typed[0]["address"] - - # No type specified - if len(emails) == 1: - return emails[0]["address"] - - # Multiple emails, require type qualifier - avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails) - print( - f"Error: '{contact['fn']}' has multiple emails. Specify type with '{name_part}:'.\n" - f" Available: {avail}", - file=sys.stderr, + result = subprocess.run( + [sys.executable, str(CONTACTS_SCRIPT), "resolve", to_str], + capture_output=True, text=True, ) - sys.exit(1) - - -def _print_contact_error(message, contacts): - """Print error with available contacts list.""" - print(f"Error: {message}", file=sys.stderr) - if contacts: - print("Available contacts:", file=sys.stderr) - for c in contacts: - name = c["fn"] - nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else "" - for e in c["emails"]: - label = f" [{e['type']}]" if e["type"] else "" - print(f" {name}{nick}{label} <{e['address']}>", file=sys.stderr) - else: - print("No contacts found. Add contacts with: calendar.sh contact add", file=sys.stderr) - - -def _build_vcf(fn, emails, nickname="", uid=""): - """Build a vCard 3.0 string.""" - uid = uid or f"{uuid.uuid4()}@openclaw" - lines = [ - "BEGIN:VCARD", - "VERSION:3.0", - f"PRODID:{PRODID}", - f"UID:{uid}", - f"FN:{fn}", - ] - if nickname: - lines.append(f"NICKNAME:{nickname}") - for e in emails: - if e.get("type"): - lines.append(f"EMAIL;TYPE={e['type'].upper()}:{e['address']}") - else: - lines.append(f"EMAIL:{e['address']}") - lines.append("END:VCARD") - return "\n".join(lines) + "\n" - - -# --------------------------------------------------------------------------- -# Contact subcommands -# --------------------------------------------------------------------------- - - -def cmd_contact_list(args): - """List all contacts.""" - contacts = _load_contacts() - if not contacts: - print("No contacts found. Add contacts with: calendar.sh contact add") - return - for c in contacts: - name = c["fn"] - nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else "" - for e in c["emails"]: - label = f" [{e['type']}]" if e["type"] else "" - print(f" {name}{nick}{label} <{e['address']}>") - - -def cmd_contact_add(args): - """Add a new contact (creates or updates a .vcf file).""" - CONTACTS_DIR.mkdir(parents=True, exist_ok=True) - - emails = [{"address": args.email, "type": args.type or ""}] - nickname = args.nickname or "" - - # Check if contact with same name already exists (update it) - existing = _load_contacts() - for c in existing: - if c["fn"].lower() == args.name.lower(): - # Add email to existing contact if not duplicate - existing_addrs = {e["address"].lower() for e in c["emails"]} - if args.email.lower() in existing_addrs: - print(f"Contact '{args.name}' already has email {args.email}") - return - # Re-read and update - emails = c["emails"] + emails - nickname = nickname or c["nickname"] - vcf_str = _build_vcf(args.name, emails, nickname, c["uid"]) - c["path"].write_text(vcf_str, encoding="utf-8") - print(f"Updated contact: {args.name} — added {args.email}") - _sync_calendar() - return - - # New contact - uid = f"{uuid.uuid4()}@openclaw" - vcf_str = _build_vcf(args.name, emails, nickname, uid) - dest = CONTACTS_DIR / f"{uid}.vcf" - dest.write_text(vcf_str, encoding="utf-8") - print(f"Added contact: {args.name} <{args.email}>") - _sync_calendar() - - -def cmd_contact_delete(args): - """Delete a contact.""" - contacts = _load_contacts() - if not contacts: - print("No contacts found.", file=sys.stderr) + if result.returncode != 0: + # Print the contacts script's error output directly + print(result.stderr, end="", file=sys.stderr) sys.exit(1) - - matches = [] - for c in contacts: - if args.name: - if (c["fn"].lower() == args.name.lower() or - c["nickname"].lower() == args.name.lower()): - matches.append(c) - elif args.uid: - if args.uid in c["uid"]: - matches.append(c) - - if not matches: - target = args.name or args.uid - _print_contact_error(f"No contact matching '{target}'.", contacts) - sys.exit(1) - if len(matches) > 1: - print(f"Error: Multiple contacts match:", file=sys.stderr) - for c in matches: - print(f" {c['fn']} (uid: {c['uid']})", file=sys.stderr) - sys.exit(1) - - contact = matches[0] - contact["path"].unlink() - print(f"Deleted contact: {contact['fn']}") - _sync_calendar() + return result.stdout.strip() # --------------------------------------------------------------------------- @@ -1147,25 +925,6 @@ def main(): edel_p.add_argument("--date", default="", help="Cancel single occurrence on this date (YYYY-MM-DD, for recurring events)") edel_p.add_argument("--all", action="store_true", help="Delete entire recurring series (safety flag)") - # --- contact --- - contact_p = subparsers.add_parser("contact", help="Manage contacts") - contact_sub = contact_p.add_subparsers(dest="contact_command", required=True) - - # contact list - contact_sub.add_parser("list", help="List all contacts") - - # contact add - cadd_p = contact_sub.add_parser("add", help="Add a contact") - cadd_p.add_argument("--name", required=True, help="Display name (e.g. 小橘子)") - cadd_p.add_argument("--email", required=True, help="Email address") - cadd_p.add_argument("--nickname", default="", help="Nickname for lookup") - cadd_p.add_argument("--type", default="", help="Email type (work, home)") - - # contact delete - cdel_p = contact_sub.add_parser("delete", help="Delete a contact") - cdel_p.add_argument("--name", default="", help="Contact name or nickname") - cdel_p.add_argument("--uid", default="", help="Contact UID") - # --- todo --- todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks") todo_sub = todo_p.add_subparsers(dest="todo_command", required=True) @@ -1214,13 +973,6 @@ def main(): cmd_event_list(args) elif args.event_command == "delete": cmd_event_delete(args) - elif args.command == "contact": - if args.contact_command == "list": - cmd_contact_list(args) - elif args.contact_command == "add": - cmd_contact_add(args) - elif args.contact_command == "delete": - cmd_contact_delete(args) elif args.command == "todo": if args.todo_command == "add": cmd_todo_add(args) diff --git a/skills/calendar/scripts/calendar.sh b/skills/calendar/scripts/calendar.sh index 1c216d5..88ff26a 100755 --- a/skills/calendar/scripts/calendar.sh +++ b/skills/calendar/scripts/calendar.sh @@ -4,12 +4,9 @@ # Usage: # ./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 contact list # list all contacts -# ./calendar.sh contact add [options] # add a contact -# ./calendar.sh contact delete [options] # delete a contact -# ./calendar.sh todo add [options] # create a todo +# ./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 # ./calendar.sh todo complete [options] # mark todo as done diff --git a/skills/contacts/SKILL.md b/skills/contacts/SKILL.md new file mode 100644 index 0000000..8ca2588 --- /dev/null +++ b/skills/contacts/SKILL.md @@ -0,0 +1,67 @@ +--- +name: contacts +description: "Contact management with CardDAV sync. Validates email recipients for calendar invites and himalaya email sending. Prevents hallucinated addresses." +metadata: {"clawdbot":{"emoji":"📇","requires":{"bins":["python3","vdirsyncer"]}}} +--- + +# Contacts + +Manage a local vCard contact list synced to Migadu CardDAV via vdirsyncer. Used by the calendar tool and himalaya wrapper to validate recipient addresses before sending. + +## Why This Exists + +LLMs can hallucinate email addresses — inventing plausible-looking addresses from context instead of looking up the correct one. This contact list serves as a **tool-level allowlist**: outbound emails can only go to addresses that exist in the contacts. + +**Adding contacts and sending emails are separate operations.** Never add a contact and send to it in the same request. + +## Prerequisites + +- Python 3 (no external dependencies) +- `vdirsyncer` configured with a CardDAV pair for contacts sync +- Contacts directory: `~/.openclaw/workspace/contacts/default/` + +## Usage + +```bash +SKILL_DIR=~/.openclaw/workspace/skills/contacts + +# List all contacts +$SKILL_DIR/scripts/contacts.sh list + +# Add a contact (single email) +$SKILL_DIR/scripts/contacts.sh add --name "小鹿" --email "mail@luyx.org" + +# Add with email type and nickname +$SKILL_DIR/scripts/contacts.sh add --name "小橘子" --email "Erica.Jiang@anderson.ucla.edu" --type work --nickname "小橘子" + +# Add a second email to an existing contact +$SKILL_DIR/scripts/contacts.sh add --name "小橘子" --email "xueweijiang0313@gmail.com" --type home + +# Resolve a name to email address (used by other tools) +$SKILL_DIR/scripts/contacts.sh resolve "小橘子:work" +# Output: Erica.Jiang@anderson.ucla.edu + +# Delete a contact +$SKILL_DIR/scripts/contacts.sh delete --name "小橘子" +``` + +## Resolve Formats + +The `resolve` command accepts three formats: + +| Format | Example | Behavior | +|--------|---------|----------| +| Name | `小橘子` | Match by FN or NICKNAME. Error if multiple emails (use type). | +| Name:type | `小橘子:work` | Match by name, select email by type. | +| Raw email | `user@example.com` | Accept only if the email exists in contacts. | + +Unknown addresses are **rejected** with the available contacts list shown. + +## Integration + +- **Calendar tool** (`cal_tool.py`): `send --to` calls `contacts.py resolve` before sending invites +- **Himalaya wrapper** (`himalaya.sh`): validates To/Cc/Bcc headers before passing to himalaya for email delivery + +## Data + +Contacts are stored as vCard 3.0 `.vcf` files in `~/.openclaw/workspace/contacts/default/`, synced to Migadu CardDAV via vdirsyncer. Filenames are `{uid}.vcf`. diff --git a/skills/contacts/scripts/contacts.py b/skills/contacts/scripts/contacts.py new file mode 100755 index 0000000..5f012a3 --- /dev/null +++ b/skills/contacts/scripts/contacts.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +""" +Contacts — Manage a local vCard contact list synced via CardDAV. + +Used by the himalaya wrapper and calendar tool to validate recipient +addresses before sending. Prevents hallucinated email addresses. + +Subcommands: + contacts.py list # list all contacts + contacts.py add [options] # add a contact + contacts.py delete [options] # delete a contact + contacts.py resolve # resolve to email address +""" + +import argparse +import subprocess +import sys +import uuid +from pathlib import Path + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +CONTACTS_DIR = Path.home() / ".openclaw" / "workspace" / "contacts" / "default" +PRODID = "-//OpenClaw//Contacts//EN" + +# --------------------------------------------------------------------------- +# vCard parsing +# --------------------------------------------------------------------------- + + +def _parse_vcf(path): + """Parse a .vcf file into a dict with fn, nickname, emails, uid.""" + text = path.read_text(encoding="utf-8", errors="replace") + result = {"fn": "", "nickname": "", "emails": [], "uid": "", "path": path} + for line in text.splitlines(): + line_upper = line.upper() + if line_upper.startswith("FN:"): + result["fn"] = line.split(":", 1)[1].strip() + elif line_upper.startswith("NICKNAME:"): + result["nickname"] = line.split(":", 1)[1].strip() + elif line_upper.startswith("UID:"): + result["uid"] = line.split(":", 1)[1].strip() + elif "EMAIL" in line_upper and ":" in line: + # Handle EMAIL;TYPE=WORK:addr and EMAIL:addr + parts = line.split(":", 1) + email_addr = parts[1].strip() + email_type = "" + param_part = parts[0].upper() + if "TYPE=" in param_part: + for param in param_part.split(";"): + if param.startswith("TYPE="): + email_type = param.split("=", 1)[1].lower() + result["emails"].append({"address": email_addr, "type": email_type}) + return result + + +def _load_contacts(): + """Load all contacts from CONTACTS_DIR. Returns list of parsed contact dicts.""" + if not CONTACTS_DIR.is_dir(): + return [] + contacts = [] + for vcf_path in sorted(CONTACTS_DIR.glob("*.vcf")): + try: + contact = _parse_vcf(vcf_path) + if contact["fn"] or contact["emails"]: + contacts.append(contact) + except Exception: + continue + return contacts + + +def _build_vcf(fn, emails, nickname="", uid=""): + """Build a vCard 3.0 string.""" + uid = uid or f"{uuid.uuid4()}@openclaw" + lines = [ + "BEGIN:VCARD", + "VERSION:3.0", + f"PRODID:{PRODID}", + f"UID:{uid}", + f"FN:{fn}", + ] + if nickname: + lines.append(f"NICKNAME:{nickname}") + for e in emails: + if e.get("type"): + lines.append(f"EMAIL;TYPE={e['type'].upper()}:{e['address']}") + else: + lines.append(f"EMAIL:{e['address']}") + lines.append("END:VCARD") + return "\n".join(lines) + "\n" + + +def _print_contact_error(message, contacts): + """Print error with available contacts list.""" + print(f"Error: {message}", file=sys.stderr) + if contacts: + print("Available contacts:", file=sys.stderr) + for c in contacts: + name = c["fn"] + nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else "" + for e in c["emails"]: + label = f" [{e['type']}]" if e["type"] else "" + print(f" {name}{nick}{label} <{e['address']}>", file=sys.stderr) + else: + print("No contacts found. Add contacts with: contacts.sh add", file=sys.stderr) + + +def _format_contact(c): + """Format a contact for display.""" + name = c["fn"] + nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else "" + lines = [] + for e in c["emails"]: + label = f" [{e['type']}]" if e["type"] else "" + lines.append(f" {name}{nick}{label} <{e['address']}>") + return "\n".join(lines) + + +def _sync_contacts(): + """Sync contacts to CardDAV server via vdirsyncer.""" + try: + subprocess.run( + ["vdirsyncer", "sync"], + capture_output=True, text=True, check=True, + ) + print("Synced to CardDAV server") + except (subprocess.CalledProcessError, FileNotFoundError): + print("Warning: CardDAV sync failed (will retry on next heartbeat)") + + +# --------------------------------------------------------------------------- +# Resolve (used by himalaya wrapper and calendar tool) +# --------------------------------------------------------------------------- + + +def resolve(to_str): + """Resolve a --to value to an email address via contacts lookup. + + Supported formats: + "小橘子" -> match by FN or NICKNAME, use sole email or error if multiple + "小橘子:work" -> match by name, select email by TYPE + "user@example.com" -> match by email address in contacts + + Returns the resolved email address string, or exits with error. + """ + contacts = _load_contacts() + + # Format: "name:type" + if ":" in to_str and "@" not in to_str: + name_part, type_part = to_str.rsplit(":", 1) + type_part = type_part.lower() + else: + name_part = to_str + type_part = "" + + # If it looks like a raw email, search contacts for it + if "@" in name_part: + for c in contacts: + for e in c["emails"]: + if e["address"].lower() == name_part.lower(): + return e["address"] + _print_contact_error(f"Email '{name_part}' not found in contacts.", contacts) + sys.exit(1) + + # Search by FN or NICKNAME (case-insensitive) + matches = [] + for c in contacts: + if (c["fn"].lower() == name_part.lower() or + c["nickname"].lower() == name_part.lower()): + matches.append(c) + + if not matches: + _print_contact_error(f"Contact '{name_part}' not found.", contacts) + sys.exit(1) + if len(matches) > 1: + _print_contact_error(f"Multiple contacts match '{name_part}'.", contacts) + sys.exit(1) + + contact = matches[0] + emails = contact["emails"] + + if not emails: + print(f"Error: Contact '{contact['fn']}' has no email addresses.", file=sys.stderr) + sys.exit(1) + + # If type specified, filter by type + if type_part: + typed = [e for e in emails if e["type"] == type_part] + if not typed: + avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails) + print(f"Error: No '{type_part}' email for '{contact['fn']}'. Available: {avail}", file=sys.stderr) + sys.exit(1) + return typed[0]["address"] + + # No type specified + if len(emails) == 1: + return emails[0]["address"] + + # Multiple emails, require type qualifier + avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails) + print( + f"Error: '{contact['fn']}' has multiple emails. Specify type with '{name_part}:'.\n" + f" Available: {avail}", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def cmd_list(args): + """List all contacts.""" + contacts = _load_contacts() + if not contacts: + print("No contacts found. Add contacts with: contacts.sh add") + return + for c in contacts: + print(_format_contact(c)) + + +def cmd_add(args): + """Add a new contact (creates or updates a .vcf file).""" + CONTACTS_DIR.mkdir(parents=True, exist_ok=True) + + emails = [{"address": args.email, "type": args.type or ""}] + nickname = args.nickname or "" + + # Check if contact with same name already exists (update it) + existing = _load_contacts() + for c in existing: + if c["fn"].lower() == args.name.lower(): + # Add email to existing contact if not duplicate + existing_addrs = {e["address"].lower() for e in c["emails"]} + if args.email.lower() in existing_addrs: + print(f"Contact '{args.name}' already has email {args.email}") + return + # Re-read and update + emails = c["emails"] + emails + nickname = nickname or c["nickname"] + vcf_str = _build_vcf(args.name, emails, nickname, c["uid"]) + c["path"].write_text(vcf_str, encoding="utf-8") + print(f"Updated contact: {args.name} — added {args.email}") + _sync_contacts() + return + + # New contact + uid = f"{uuid.uuid4()}@openclaw" + vcf_str = _build_vcf(args.name, emails, nickname, uid) + dest = CONTACTS_DIR / f"{uid}.vcf" + dest.write_text(vcf_str, encoding="utf-8") + print(f"Added contact: {args.name} <{args.email}>") + _sync_contacts() + + +def cmd_delete(args): + """Delete a contact.""" + if not args.name and not args.uid: + print("Error: --name or --uid is required", file=sys.stderr) + sys.exit(1) + + contacts = _load_contacts() + if not contacts: + print("No contacts found.", file=sys.stderr) + sys.exit(1) + + matches = [] + for c in contacts: + if args.name: + if (c["fn"].lower() == args.name.lower() or + c["nickname"].lower() == args.name.lower()): + matches.append(c) + elif args.uid: + if args.uid in c["uid"]: + matches.append(c) + + if not matches: + target = args.name or args.uid + _print_contact_error(f"No contact matching '{target}'.", contacts) + sys.exit(1) + if len(matches) > 1: + print(f"Error: Multiple contacts match:", file=sys.stderr) + for c in matches: + print(f" {c['fn']} (uid: {c['uid']})", file=sys.stderr) + sys.exit(1) + + contact = matches[0] + contact["path"].unlink() + print(f"Deleted contact: {contact['fn']}") + _sync_contacts() + + +def cmd_resolve(args): + """Resolve a name/alias to an email address. Prints the email to stdout.""" + email = resolve(args.query) + print(email) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser(description="Contact management with CardDAV sync") + subparsers = parser.add_subparsers(dest="command", required=True) + + # list + subparsers.add_parser("list", help="List all contacts") + + # add + add_p = subparsers.add_parser("add", help="Add a contact") + add_p.add_argument("--name", required=True, help="Display name (e.g. 小橘子)") + add_p.add_argument("--email", required=True, help="Email address") + add_p.add_argument("--nickname", default="", help="Nickname for lookup") + add_p.add_argument("--type", default="", help="Email type (work, home)") + + # delete + del_p = subparsers.add_parser("delete", help="Delete a contact") + del_p.add_argument("--name", default="", help="Contact name or nickname") + del_p.add_argument("--uid", default="", help="Contact UID") + + # resolve + res_p = subparsers.add_parser("resolve", help="Resolve name to email address") + res_p.add_argument("query", help="Contact name, name:type, or email to resolve") + + args = parser.parse_args() + + if args.command == "list": + cmd_list(args) + elif args.command == "add": + cmd_add(args) + elif args.command == "delete": + cmd_delete(args) + elif args.command == "resolve": + cmd_resolve(args) + + +if __name__ == "__main__": + main() diff --git a/skills/contacts/scripts/contacts.sh b/skills/contacts/scripts/contacts.sh new file mode 100755 index 0000000..f69e1ea --- /dev/null +++ b/skills/contacts/scripts/contacts.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# contacts — manage the local contact list (vCard / CardDAV). +# +# Usage: +# ./contacts.sh list # list all contacts +# ./contacts.sh add [options] # add a contact +# ./contacts.sh delete [options] # delete a contact +# ./contacts.sh resolve # resolve to email address +# +# No external dependencies (pure Python 3). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +exec python3 "$SCRIPT_DIR/contacts.py" "$@" diff --git a/skills/himalaya/SKILL.md b/skills/himalaya/SKILL.md index 77a513d..7faa8e4 100644 --- a/skills/himalaya/SKILL.md +++ b/skills/himalaya/SKILL.md @@ -9,6 +9,30 @@ metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install" Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends. +## Recipient-Safe Wrapper + +Use the wrapper script (`scripts/himalaya.sh`) instead of calling `himalaya` directly. It validates outbound email recipients against the contacts list (see `skills/contacts/`) before sending. + +**Gated commands** (recipients validated before sending): +- `message send` — parses To/Cc/Bcc from MIME headers on stdin +- `template send` — parses To/Cc/Bcc from MML headers on stdin +- `message write` — parses `-H` header flags for To/Cc/Bcc + +**Pass-through commands** (no validation needed): +- Everything else: `envelope list`, `message read`, `message delete`, `folder`, `flag`, `attachment`, `account`, etc. + +```bash +HIMALAYA=~/.openclaw/workspace/skills/himalaya/scripts/himalaya.sh + +# All commands work the same as `himalaya` +$HIMALAYA envelope list +$HIMALAYA message read 42 + +# Sending commands validate recipients first +cat message.txt | $HIMALAYA template send # validates To/Cc/Bcc +$HIMALAYA message write -H "To:小橘子:work" -H "Subject:Test" "body" +``` + ## References - `references/configuration.md` (config file setup + IMAP/SMTP authentication) diff --git a/skills/himalaya/scripts/himalaya.sh b/skills/himalaya/scripts/himalaya.sh new file mode 100755 index 0000000..a8f320c --- /dev/null +++ b/skills/himalaya/scripts/himalaya.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# himalaya wrapper — validates outbound email recipients against the contacts list. +# +# Drop-in replacement for himalaya. All commands pass through unchanged except +# those that send email, which first validate To/Cc/Bcc recipients. +# +# Gated commands: +# message send — parses MIME headers from stdin +# template send — parses MML headers from stdin +# message write — parses -H header flags from args +# +# All other commands (envelope list, message read, message delete, folder, +# flag, attachment, account, etc.) pass through directly. +# +# Usage: use this script wherever you would use `himalaya`. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +# Find the real himalaya binary (skip this script if it's in PATH) +SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +HIMALAYA="" +while IFS= read -r candidate; do + resolved="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" + if [[ "$resolved" != "$SCRIPT_PATH" ]]; then + HIMALAYA="$candidate" + break + fi +done < <(which -a himalaya 2>/dev/null || true) + +if [[ -z "$HIMALAYA" ]]; then + # Fallback: check common locations + for path in "$HOME/.local/bin/himalaya" /usr/local/bin/himalaya /usr/bin/himalaya; do + if [[ -x "$path" ]]; then + HIMALAYA="$path" + break + fi + done +fi + +if [[ -z "$HIMALAYA" ]]; then + echo "Error: himalaya binary not found" >&2 + exit 1 +fi + +CONTACTS="$(cd "$(dirname "$0")/../../contacts/scripts" && pwd)/contacts.py" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +validate_address() { + local addr="$1" + # Skip empty addresses + [[ -z "$addr" ]] && return 0 + # Validate against contacts + python3 "$CONTACTS" resolve "$addr" > /dev/null 2>&1 + return $? +} + +# Extract email address from "Display Name " or bare "email" format +extract_email() { + local raw="$1" + raw="$(echo "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + if [[ "$raw" == *"<"*">"* ]]; then + echo "$raw" | sed 's/.*.*//' + else + echo "$raw" + fi +} + +# Validate a comma-separated list of addresses. Prints errors to stderr. +# Returns 0 if all valid, 1 if any invalid. +validate_address_list() { + local header_value="$1" + local all_valid=0 + + # Split on commas + while IFS= read -r addr; do + addr="$(extract_email "$addr")" + [[ -z "$addr" ]] && continue + if ! validate_address "$addr"; then + all_valid=1 + fi + done < <(echo "$header_value" | tr ',' '\n') + + return $all_valid +} + +# Parse To/Cc/Bcc from MIME/MML headers in a file. +# Headers end at the first blank line. +validate_stdin_headers() { + local tmpfile="$1" + local failed=0 + + # Extract header block (everything before first blank line) + while IFS= read -r line; do + # Stop at blank line (end of headers) + [[ -z "$line" ]] && break + + # Match To:, Cc:, Bcc: headers (case-insensitive) + if echo "$line" | grep -iqE '^(to|cc|bcc):'; then + local value + value="$(echo "$line" | sed 's/^[^:]*:[[:space:]]*//')" + if ! validate_address_list "$value"; then + failed=1 + fi + fi + done < "$tmpfile" + + return $failed +} + +# --------------------------------------------------------------------------- +# Detect sending commands +# --------------------------------------------------------------------------- + +# Collect all args into a string for pattern matching +ALL_ARGS="$*" + +# Check if this is a sending command +is_stdin_send=false +is_write_send=false + +# "message send" or "template send" — reads from stdin +if echo "$ALL_ARGS" | grep -qE '(message|template)[[:space:]]+send'; then + is_stdin_send=true +fi + +# "message write" — may have -H flags with recipients +if echo "$ALL_ARGS" | grep -qE 'message[[:space:]]+write'; then + is_write_send=true +fi + +# --------------------------------------------------------------------------- +# Handle stdin-based sends (message send, template send) +# --------------------------------------------------------------------------- + +if $is_stdin_send; then + # Read stdin into temp file + tmpfile="$(mktemp)" + trap 'rm -f "$tmpfile"' EXIT + cat > "$tmpfile" + + # Validate recipients from headers + if ! validate_stdin_headers "$tmpfile"; then + exit 1 + fi + + # Pass through to real himalaya + cat "$tmpfile" | exec "$HIMALAYA" "$@" + exit $? +fi + +# --------------------------------------------------------------------------- +# Handle message write with -H flags +# --------------------------------------------------------------------------- + +if $is_write_send; then + # Parse -H flags for To/Cc/Bcc without consuming args + failed=0 + original_args=("$@") + while [[ $# -gt 0 ]]; do + case "$1" in + -H) + shift + if [[ $# -gt 0 ]]; then + header="$1" + if echo "$header" | grep -iqE '^(to|cc|bcc):'; then + value="$(echo "$header" | sed 's/^[^:]*:[[:space:]]*//')" + if ! validate_address_list "$value"; then + failed=1 + fi + fi + fi + ;; + esac + shift + done + + if [[ $failed -ne 0 ]]; then + exit 1 + fi + + exec "$HIMALAYA" "${original_args[@]}" +fi + +# --------------------------------------------------------------------------- +# Pass through everything else +# --------------------------------------------------------------------------- + +exec "$HIMALAYA" "$@"