contacts: extract into standalone skill, add himalaya wrapper for all email sends

Refactors the contacts system from being embedded in cal_tool.py into a
standalone contacts skill that serves as the single source of truth for
recipient validation across all outbound email paths.

- New skills/contacts/ skill: list, add, delete, resolve commands
- New skills/himalaya/scripts/himalaya.sh wrapper: validates To/Cc/Bcc
  recipients against contacts for message send, template send, and
  message write commands; passes everything else through unchanged
- cal_tool.py now delegates to contacts.py resolve instead of inline logic
- TOOLS.md updated: agent should use himalaya wrapper, not raw himalaya
This commit is contained in:
Yanxin Lu
2026-03-31 11:12:08 -07:00
parent cd1ee050ed
commit f05a84d8ca
10 changed files with 703 additions and 314 deletions

View File

@@ -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 <id> # 读取邮件
himalaya message delete <id> # 删除邮件
himalaya message write # 写新邮件(交互式
HIMALAYA=~/.openclaw/workspace/skills/himalaya/scripts/himalaya.sh
$HIMALAYA envelope list --page-size 20 # 列出邮件(直接透传)
$HIMALAYA message read <id> # 读取邮件(直接透传
$HIMALAYA message delete <id> # 删除邮件(直接透传)
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邮件、vdirsyncerCalDAV 同步、khal查看日历、todoman待办管理
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
**注意**: 发送日历邀请属于对外邮件,需先确认

View File

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

View File

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

View File

@@ -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}:<type>'.\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)

View File

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

67
skills/contacts/SKILL.md Normal file
View File

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

View File

@@ -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 <name|name:type|email> # 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}:<type>'.\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()

View File

@@ -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 <name|name:type|email> # 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" "$@"

View File

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

View File

@@ -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 <email>" or bare "email" format
extract_email() {
local raw="$1"
raw="$(echo "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [[ "$raw" == *"<"*">"* ]]; then
echo "$raw" | sed 's/.*<//;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" "$@"