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:
43
TOOLS.md
43
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 <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(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理)
|
||||
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
|
||||
**注意**: 发送日历邀请属于对外邮件,需先确认
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
67
skills/contacts/SKILL.md
Normal 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`.
|
||||
344
skills/contacts/scripts/contacts.py
Executable file
344
skills/contacts/scripts/contacts.py
Executable 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()
|
||||
16
skills/contacts/scripts/contacts.sh
Executable file
16
skills/contacts/scripts/contacts.sh
Executable 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" "$@"
|
||||
@@ -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)
|
||||
|
||||
195
skills/himalaya/scripts/himalaya.sh
Executable file
195
skills/himalaya/scripts/himalaya.sh
Executable 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" "$@"
|
||||
Reference in New Issue
Block a user