Skill: guyun94/ews-email (version 1.2.0)

--- license: MIT name: ews-email version: 1.2.0 description: "CLI to manage enterprise Outlook emails via Exchange Web Services (EWS). Use ews-mail.py to list, read, reply, forward, search, send, move, delete emails and download attachments." metadata: openclaw: emoji: "📧" requires: bins: ["python3"] pips: ["keyring", "exchangelib"] primaryEnv: "EWS_EMAIL" --- # EWS Email CLI A CLI for enterprise Exchange (EWS) email. Use when the user asks about email, inbox, messages, or mail. ## Setup ### 1. 环境变量 在 `~/.openclaw/config.yaml` 中配置： ```yaml env: EWS_SERVER: "your-exchange-server.com" EWS_EMAIL: "[REDACTED_SECRET]" ``` ### 2. 存储密码 #### macOS（自动使用 Keychain，无需额外配置） ```bash pip3 install keyring exchangelib python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py setup ``` #### Linux 云服务器（无桌面环境） 脚本会自动检测 Linux 无桌面环境，切换到 EncryptedKeyring 后端（AES 加密文件存储）。 ```bash # 安装依赖 pip3 install keyring exchangelib keyrings.alt # 设置 master password 环境变量（用于加解密 EWS 密码） # 在 ~/.openclaw/config.yaml 中添加： # env: # KEYRING_CRYPTFILE_PASSWORD: "你自己定义的一个强密码" # # 或在 systemd service / 启动脚本中 export： export KEYRING_CRYPTFILE_PASSWORD="你自己定义的一个强密码" # 存储 EWS 密码（会用 AES 加密写入本地文件） python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py setup # 验证 python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py folder-list ``` 重启后只要 `KEYRING_CRYPTFILE_PASSWORD` 环境变量还在，密码就能正常解密读取，无需重新输入。 ### 3. 验证安装 ```bash python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py folder-list ``` ## SECURITY RULES - **NEVER** attempt to read, display, or output the EWS password. - **NEVER** run commands that could expose keyring contents. - **NEVER** include passwords in any output, log, or message. - The password is managed exclusively through the `setup` command. ## IMPORTANT: Reading Email Content To read the FULL content/body of an email, you MUST follow these two steps: 1. First run `envelope-list` to get the message list (this gives you numeric IDs) 2. Then run `message-read <ID>` to get the FULL email body/content **`envelope-list` only shows subject lines and metadata. It does NOT contain the email body.** **You MUST run `message-read` to get the actual email content. NEVER guess or summarize based on subject alone.** **NEVER say you cannot read email content — you CAN, by running `message-read`.** ## Script Location `~/.openclaw/skills/ews-email/scripts/ews-mail.py` ## Commands ### List Emails (step 1 — metadata only) ```bash python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py envelope-list python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py envelope-list --page 2 --page-size 20 python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py envelope-list --folder "Sent" ``` ### Read Email Body (step 2 — REQUIRED for content) ```bash python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-read <ID> ``` ### Search Emails ```bash python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py envelope-list from [REDACTED_SECRET] python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py envelope-list subject keyword ``` ### Send / Reply / Forward ```bash python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-send --to "email" --subject "subject" --body "body" python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-send --to "[REDACTED_SECRET]" --cc "[REDACTED_SECRET]" --subject "Hi" --body "msg" python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-reply <ID> --body "reply text" python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-reply <ID> --body "reply text" --all python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-forward <ID> --to "email" --body "FYI" ``` ### Other Commands ```bash python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py folder-list python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-move <ID> "Archive" python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py message-delete <ID> python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py attachment-download <ID> --dir ~/Downloads python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py flag-add <ID> --flag seen python3 ~/.openclaw/skills/ews-email/scripts/ews-mail.py flag-remove <ID> --flag seen ``` ## Tips - Message IDs are numeric and come from the most recent `envelope-list` output. - Always run `envelope-list` first before `message-read`, `message-reply`, etc. - Long email bodies are truncated at 8000 chars. - Use `--page` and `--page-size` to navigate large inboxes.

path: scripts/ews-mail.py
content: #!/usr/bin/env python3
"""EWS Mail CLI - himalaya-compatible interface over Exchange Web Services."""
import sys, json, argparse, os, getpass, platform
from exchangelib import (
    Credentials, Account, Configuration, DELEGATE,
    Message, Mailbox, FileAttachment
)
import urllib3
urllib3.disable_warnings()

KEYRING_SERVICE = "openclaw-ews-email"

SERVER = os.environ.get("EWS_SERVER", "")
EMAIL = os.environ.get("EWS_EMAIL", "")

def _init_keyring():
    """Initialize keyring backend. On headless Linux, use EncryptedKeyring with
    master password from KEYRING_CRYPTFILE_PASSWORD env var."""
    import keyring
    if platform.system() == "Linux":
        try:
            # Test if default backend works (e.g. GNOME Keyring with D-Bus)
            keyring.get_password("__probe__", "__probe__")
        except Exception:
            # Default backend unavailable — switch to EncryptedKeyring
            try:
                from keyrings.alt.file import EncryptedKeyring
            except ImportError:
                print("Error: keyrings.alt not installed. Run: pip3 install keyrings.alt", file=sys.stderr)
                sys.exit(1)
            master_pw = os.environ.get("KEYRING_CRYPTFILE_PASSWORD")
            if not master_pw:
                print("Error: On headless Linux, set KEYRING_CRYPTFILE_PASSWORD env var as the master password.", file=sys.stderr)
                print("This master password encrypts/decrypts your stored EWS password (AES).", file=sys.stderr)
                sys.exit(1)
            kr = EncryptedKeyring()
            kr.keyring_key = master_pw
            keyring.set_keyring(kr)
    return keyring

def get_password():
    """Get password from system keyring. Never from env vars or config files."""
    try:
        kr = _init_keyring()
    except ImportError:
        print("Error: keyring module not installed. Run: pip3 install keyring", file=sys.stderr)
        sys.exit(1)
    email = EMAIL
    if not email:
        print("Error: EWS_EMAIL environment variable must be set.", file=sys.stderr)
        sys.exit(1)
    [REDACTED_SECRET], email)
    if not [REDACTED_SECRET]"Error: No password found in keyring for {email}.", file=sys.stderr)
        print(f"Run: python3 {__file__} setup", file=sys.stderr)
        sys.exit(1)
    return password

CACHE_FILE = os.path.expanduser("~/.openclaw/.ews-mail-cache.json")

def get_account():
    if not all([SERVER, EMAIL]):
        print("Error: EWS_SERVER, EWS_EMAIL environment variables must be set.", file=sys.stderr)
        sys.exit(1)
    [REDACTED_SECRET])
    creds = Credentials(username=EMAIL, [REDACTED_SECRET])
    config = Configuration(server=SERVER, credentials=creds)
    return Account(primary_smtp_address=EMAIL, config=config,
                   autodiscover=False, access_type=DELEGATE)

def get_folder(account, folder_name):
    name = (folder_name or "inbox").lower()
    mapping = {
        "inbox": account.inbox, "sent": account.sent,
        "drafts": account.drafts, "trash": account.trash, "junk": account.junk,
    }
    if name in mapping:
        return mapping[name]
    for f in account.root.walk():
        if f.name and f.name.lower() == name:
            return f
    return account.inbox

def save_cache(items_map):
    with open(CACHE_FILE, "w") as f:
        json.dump(items_map, f, ensure_ascii=False)

def load_cache():
    if os.path.exists(CACHE_FILE):
        with open(CACHE_FILE) as f:
            return json.load(f)
    return {}

def fmt_date(dt):
    return dt.strftime("%Y-%m-%d %H:%M") if dt else ""

def fmt_addr(mailbox):
    if not mailbox: return ""
    if mailbox.name: return f"{mailbox.name} <{mailbox.email_address}>"
    return mailbox.email_address or ""

def find_message(account, msg_id):
    """Find message by Exchange ID using account.fetch()."""
    from exchangelib import ItemId
    try:
        items = list(account.fetch(ids=[ItemId(id=msg_id)]))
        if items and items[0] is not None:
            return items[0]
    except Exception:
        pass
    return None

# --- setup command ---

def cmd_setup(args):
    """Interactive setup: store EWS password in system keyring."""
    try:
        kr = _init_keyring()
    except ImportError:
        print("Error: keyring module not installed. Run: pip3 install keyring", file=sys.stderr)
        sys.exit(1)

    email = EMAIL or input("EWS Email: ").strip()
    if not email:
        print("Email cannot be empty.", file=sys.stderr)
        sys.exit(1)

    [REDACTED_SECRET]"EWS Password for {email}: ")
    if not [REDACTED_SECRET]"Password cannot be empty.", file=sys.stderr)
        sys.exit(1)

    kr.set_password(KEYRING_SERVICE, email, password)
    print(f"Password saved to system keyring (service: {KEYRING_SERVICE}, account: {email})")
    print("You can now use ews-email commands. Password will never appear in config files.")

# --- commands ---

def cmd_folder_list(args):
    account = get_account()
    for f in account.root.walk():
        if f.name:
            total = getattr(f, 'total_count', '?') or '?'
            print(f"  {f.name} ({total})")

def cmd_envelope_list(args):
    account = get_account()
    folder = get_folder(account, args.folder)
    page, size = max(1, args.page), args.page_size
    offset = (page - 1) * size
    qs = folder.all().order_by('-datetime_received')

    if args.query:
        i = 0
        tokens = args.query
        while i < len(tokens):
            t = tokens[i].lower()
            if t in ("from", "subject", "to") and i + 1 < len(tokens):
                val = tokens[i + 1]
                if t == "from": qs = qs.filter(sender__email_address__contains=val)
                elif t == "subject": qs = qs.filter(subject__contains=val)
                elif t == "to": qs = qs.filter(to_recipients__contains=val)
                i += 2
            else:
                qs = qs.filter(subject__contains=tokens[i])
                i += 1

    items = list(qs[offset:offset + size])
    cache = {}
    print(f"Page {page} (showing {len(items)} messages)")
    print("-" * 100)
    for idx, item in enumerate(items):
        num = offset + idx + 1
        cache[str(num)] = item.id
        flag = "●" if not item.is_read else " "
        sender = fmt_addr(item.sender)[:30]
        subj = (item.subject or "(no subject)")[:60]
        print(f"{num:>4} {flag} {fmt_date(item.datetime_received)}  {sender:<30}  {subj}")
    save_cache(cache)
    if not items:
        print("No messages found.")

def cmd_message_read(args):
    account = get_account()
    cache = load_cache()
    msg_id = cache.get(str(args.id))
    if not msg_id:
        print(f"Message {args.id} not found in cache. Run 'envelope-list' first.", file=sys.stderr)
        sys.exit(1)

    item = find_message(account, msg_id)
    if not item:
        print(f"Message {args.id} not found on server.", file=sys.stderr)
        sys.exit(1)

    if not item.is_read:
        item.is_read = True
        item.save(update_fields=['is_read'])

    print(f"From: {fmt_addr(item.sender)}")
    print(f"To: {', '.join(fmt_addr(r) for r in (item.to_recipients or []))}")
    if item.cc_recipients:
        print(f"Cc: {', '.join(fmt_addr(r) for r in item.cc_recipients)}")
    print(f"Date: {fmt_date(item.datetime_received)}")
    print(f"Subject: {item.subject or '(no subject)'}")
    attachments = [a for a in (item.attachments or []) if isinstance(a, FileAttachment)]
    if attachments:
        print(f"Attachments: {', '.join(a.name or 'unnamed' for a in attachments)}")
    print("-" * 60)
    body = item.text_body or ""
    if not body and item.body:
        body = str(item.body)
    if len(body) > 8000:
        body = body[:8000] + f"\n\n... [truncated, total {len(body)} chars]"
    print(body)

def cmd_message_send(args):
    account = get_account()
    to_list = [Mailbox(email_address=r.strip()) for r in args.to.split(',')]
    cc_list = [Mailbox(email_address=r.strip()) for r in args.cc.split(',')] if args.cc else None
    msg = Message(account=account, subject=args.subject, body=args.body,
                  to_recipients=to_list, cc_recipients=cc_list)
    msg.send()
    print(f"Sent to {args.to}: {args.subject}")

def cmd_message_reply(args):
    account = get_account()
    cache = load_cache()
    msg_id = cache.get(str(args.id))
    if not msg_id:
        print(f"Message {args.id} not found in cache.", file=sys.stderr); sys.exit(1)
    item = find_message(account, msg_id)
    if not item:
        print(f"Message {args.id} not found.", file=sys.stderr); sys.exit(1)
    reply = item.reply_all() if args.all else item.reply()
    reply.body = args.body + "\n\n" + str(reply.body or "")
    reply.send()
    print(f"Reply sent to {fmt_addr(item.sender)}")

def cmd_message_forward(args):
    account = get_account()
    cache = load_cache()
    msg_id = cache.get(str(args.id))
    if not msg_id:
        print(f"Message {args.id} not found in cache.", file=sys.stderr); sys.exit(1)
    item = find_message(account, msg_id)
    if not item:
        print(f"Message {args.id} not found.", file=sys.stderr); sys.exit(1)
    fwd = item.forward()
    fwd.to_recipients = [Mailbox(email_address=r.strip()) for r in args.to.split(',')]
    fwd.body = (args.body or "") + "\n\n" + str(fwd.body or "")
    fwd.send()
    print(f"Forwarded to {args.to}: {item.subject}")

def cmd_message_move(args):
    account = get_account()
    cache = load_cache()
    msg_id = cache.get(str(args.id))
    if not msg_id:
        print(f"Message {args.id} not found in cache.", file=sys.stderr); sys.exit(1)
    item = find_message(account, msg_id)
    if not item:
        print(f"Message {args.id} not found.", file=sys.stderr); sys.exit(1)
    dest = get_folder(account, args.folder)
    item.move(dest)
    print(f"Moved message {args.id} to {args.folder}")

def cmd_message_delete(args):
    account = get_account()
    cache = load_cache()
    msg_id = cache.get(str(args.id))
    if not msg_id:
        print(f"Message {args.id} not found in cache.", file=sys.stderr); sys.exit(1)
    item = find_message(account, msg_id)
    if not item:
        print(f"Message {args.id} not found.", file=sys.stderr); sys.exit(1)
    item.move_to_trash()
    print(f"Deleted message {args.id}")

def cmd_flag(args):
    account = get_account()
    cache = load_cache()
    msg_id = cache.get(str(args.id))
    if not msg_id:
        print(f"Message {args.id} not found in cache.", file=sys.stderr); sys.exit(1)
    item = find_message(account, msg_id)
    if not item:
        print(f"Message {args.id} not found.", file=sys.stderr); sys.exit(1)
    flag = args.flag.lower()
    if flag in ("seen", "read"):
        item.is_read = (args.action == "add")
        item.save(update_fields=['is_read'])
    print(f"Flag '{flag}' {'added' if args.action == 'add' else 'removed'} on message {args.id}")

def cmd_attachment_download(args):
    account = get_account()
    cache = load_cache()
    msg_id = cache.get(str(args.id))
    if not msg_id:
        print(f"Message {args.id} not found in cache.", file=sys.stderr); sys.exit(1)
    item = find_message(account, msg_id)
    if not item:
        print(f"Message {args.id} not found.", file=sys.stderr); sys.exit(1)
    dest_dir = args.dir or os.getcwd()
    os.makedirs(dest_dir, exist_ok=True)
    count = 0
    for att in (item.attachments or []):
        if isinstance(att, FileAttachment) and att.content:
            path = os.path.join(dest_dir, att.name or f"attachment_{count}")
            with open(path, "wb") as f:
                f.write(att.content)
            print(f"Saved: {path}")
            count += 1
    if count == 0:
        print("No attachments found.")

def main():
    p = argparse.ArgumentParser(description="EWS Mail CLI")
    sub = p.add_subparsers(dest="cmd")

    sub.add_parser("setup")
    sub.add_parser("folder-list")

    el = sub.add_parser("envelope-list")
    el.add_argument("--folder", "-f", default="inbox")
    el.add_argument("--page", type=int, default=1)
    el.add_argument("--page-size", type=int, default=10)
    el.add_argument("query", nargs="*")

    mr = sub.add_parser("message-read")
    mr.add_argument("id")

    ms = sub.add_parser("message-send")
    ms.add_argument("--to", required=True)
    ms.add_argument("--cc", default=None)
    ms.add_argument("--subject", required=True)
    ms.add_argument("--body", required=True)

    mre = sub.add_parser("message-reply")
    mre.add_argument("id")
    mre.add_argument("--body", required=True)
    mre.add_argument("--all", action="store_true")

    mf = sub.add_parser("message-forward")
    mf.add_argument("id")
    mf.add_argument("--to", required=True)
    mf.add_argument("--body", default="")

    mm = sub.add_parser("message-move")
    mm.add_argument("id")
    mm.add_argument("folder")

    md = sub.add_parser("message-delete")
    md.add_argument("id")

    fa = sub.add_parser("flag-add")
    fa.add_argument("id")
    fa.add_argument("--flag", required=True)

    fr = sub.add_parser("flag-remove")
    fr.add_argument("id")
    fr.add_argument("--flag", required=True)

    ad = sub.add_parser("attachment-download")
    ad.add_argument("id")
    ad.add_argument("--dir", default=None)

    args = p.parse_args()
    cmds = {
        "setup": cmd_setup,
        "folder-list": cmd_folder_list, "envelope-list": cmd_envelope_list,
        "message-read": cmd_message_read, "message-send": cmd_message_send,
        "message-reply": cmd_message_reply, "message-forward": cmd_message_forward,
        "message-move": cmd_message_move, "message-delete": cmd_message_delete,
        "flag-add": lambda a: (setattr(a, 'action', 'add'), cmd_flag(a)),
        "flag-remove": lambda a: (setattr(a, 'action', 'remove'), cmd_flag(a)),
        "attachment-download": cmd_attachment_download,
    }
    fn = cmds.get(args.cmd)
    if fn: fn(args)
    else: p.print_help()

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)