Skill: leli1024/lel-mail (version 1.1.4)

--- name: lel-mail description: Send and read email via a combination of python and bash scripts which makes use of the main agent for reasoning and logic. This skill enables the agent to write to memory based on contents in the email and to reach out to the user either to notify them of happenings or to request inputs to respond. This skill also contains a python script to read and manage the email queue containing functionality to list pending outgoing emails and delete emails before they can be sent out. Please note that this skill enables the agent to act upon received emails by adding to agent memory and sending responses metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["python3"]}}} --- # Lel Mail ## Important Note If Running On the Cloud/VPS - Some cloud providers explicitly disable email leading to failiure of these scripts, services like tailscale will be needed to bypass these blocks ## Setup ### Setting up the Config File Create `~/.config/lel-mail/config.json`: ```json [ { "provider": "gmail", "config": { "smtp": { "server": "smtp.gmail.com", //Default url "port": 587 //Default port }, "imap": { "server": "imap.gmail.com", //Default url "port": 993 //Default port } }, "auth": { "user": "[REDACTED_SECRET]", "password": "XXXX XXXX XXXX XXXX" //Gmail Requires App Specific Password Rather Than Your Normal Password }, "can_send": true, "can_read": true } ] ``` ### Setting up cron job Set up a simple cron job which runs the following command every 5 minutes with a randomised 30-90 second delay. Confirm with user the interval to be used: ```~/.openclaw/workspace/skills/lel-mail/scripts/email_sender_daemon.sh``` ## Quick Reference ### Read Email 1. Make sure config exists, if not create it with input of user 2. Reference USERS.md (or whatever USER reference file your system uses) to see what email(s) you need to check unless user explicitly defines the email(s) 3. Run the following command ```~/.openclaw/workspace/skills/lel-mail/scripts/check_email.sh <USER_EMAIL>``` ### Send Email # Note, this script does not send the data directly but sends it to a scheduler which will automatically send it in approximately 5.5 minutes 1. Make sure you have the necessary data to send the email from the user, that includes sender, recipient and body, everything else is optional 2. Run the following command ```~/.openclaw/workspace/skills/lel-mail/scripts/email_send.sh --sender <sender> --recipient <recipient> --subject <subject> --body <body> [--cc ...] [--bcc ...]``` Note: if using BCC/CC note that CC/BCC are comma-separated lists ### 3. Manage Outgoing Queue Outgoing emails are scheduled to send with a random delay (30-90 seconds). You can list or delete these before they are sent. **List all queued emails**: ```bash python3 ~/.openclaw/workspace/skills/lel-mail/scripts/manage_queue.py --list ``` **Delete a specific email by ID**: ```bash python3 ~/.openclaw/workspace/skills/lel-mail/scripts/manage_queue.py --delete <ID> ``` ## Troubleshooting Prompt user to assist when errors occur due to missing/invalid credentials/configuration - If emails aren't sending at all check that a cron job for the daemon is running

path: scripts/check_email.sh
content: #!/usr/bin/env bash
# check_email.sh – poll IMAP for unread messages and act on them automatically.
# Uses an LLM to decide a single safe action per email.

set -euo pipefail


# --- Get Email Address ---
# If no email is provided, default to the configured address.
EMAIL_TO_CHECK="${1}"
export EMAIL_TO_CHECK="$EMAIL_TO_CHECK"

# Usage: check_email.sh <email>


# ------- Python: fetch UNSEEN messages, decide action, execute ----------
python3 - <<'PY'
import imaplib, email, os, subprocess, sys, json, textwrap, tempfile, time
import socket
import ipaddress

EMAIL_TO_CHECK = os.getenv('EMAIL_TO_CHECK')
CONFIG_FILE = os.path.expanduser("~/.config/lel-mail/config.json")

config_data = None

with open(CONFIG_FILE, "r") as f:
    config = json.load(f)

    # Expect the config to be a list of accounts.
    for item in config:
        if item.get("auth", {}).get("user") == EMAIL_TO_CHECK:
            config_data = item
            break


if (config_data == None):
    print("Configuration for this user (" + EMAIL_TO_CHECK + ") was not found at ~/.config/lel-mail/config.json")
    sys.exit(1)

if not ("imap" in config_data["config"]):
    print("IMAP configuration not found")
    sys.exit(1)

if (config_data["can_read"] == False):
    print("can_read flag is set to false, ask user for permission to change it")
    sys.exit(1)

# Allow overrides via env vars so this script can work with tunnels/proxies
# (e.g. IMAP_SERVER=127.0.0.1 IMAP_PORT=1993)
server = os.getenv("IMAP_SERVER") or config_data["config"]["imap"]["server"]
port   = int(os.getenv("IMAP_PORT") or config_data["config"]["imap"]["port"])
user   = config_data["auth"]["user"]
[REDACTED_SECRET]"auth"]["password"]


mail = imaplib.IMAP4_SSL(server, port)
mail.login(user, passwd)
mail.select('inbox')

status, data = mail.search(None, 'UNSEEN')

if status != 'OK':
    print("IMAP search failed.")
    sys.exit(0)

unseen_bytes = data[0] if data and data[0] else b""

ids = unseen_bytes.split()

if (not ids):
    print("No messages found, no need to proceed further or inform user (unless commanded otherwise)")
    sys.exit(0)

for uid in ids:
    typ, msg_data = mail.fetch(uid, '(RFC822)')

    if typ != 'OK':
        continue

    raw = msg_data[0][1]
    msg = email.message_from_bytes(raw)
    subject = msg['Subject'] or '(no subject)'
    from_addr = msg['From'] or '(unknown sender)'

    body = ''
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_type() == 'text/plain':
                body = part.get_payload(decode=True).decode(errors='ignore')
                break
    else:
        body = msg.get_payload(decode=True).decode(errors='ignore')

    body = body.strip()

    if (body is None) or body == "":
        continue

     # ----- Build LLM prompt ---------------------------------------
    prompt = f"""
        An email has just arrived. **Your ONLY possible output** is ONE json object that matches ONE of the following exact formats (no extra text, no markdown):

        {
            "action": "add_memory",
            "payload": "MEMORY TO ADD"
        }

        {
            "action": "notify_user",
            "payload": "SUMMARY OF WHAT USER NEEDS TO KNOW"
        }

        {
            "action": "to_respond",
            "payload": "SITUATION TO RESPOND TO AND INPUTS NEEDED TO RESPOND"
        }

        //If none of the above apply, output the following object ONLY
        {
            "action": "none"
        }

        Email data:
        Subject: {subject}
        From: {from_addr}
        Body:
        {body}
        """

    # ----- Call LLM (isolated sub-agent) ------------------------
    # Use 'openclaw agent' for a more reliable direct LLM invocation.
    # We use a unique session ID for this classification task and disable verbose thinking.
    session_id = f"email-classify-{uid.decode()}-{int(time.time())}"
    
    # Construct the command to run the agent with the prompt.
    cmd = [
        'openclaw', 'agent',
        f'--session-id={session_id}', # Explicitly set session ID
        '--message', prompt,
        '--thinking', 'low'         # Disable verbose thinking for speed
    ]

    llm_response_data = {
        "action": "none"
    }

    try:
        # Execute the command and capture stdout and stderr.
        # Using Popen and communicate to handle potential large outputs and errors.
        process = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, stderr = process.communicate(timeout=45) # Timeout after 45 seconds
        
        if process.returncode != 0:
            # If the command failed, print stderr and set action_line to NONE.
            print(f"Error running openclaw agent (return code {process.returncode}):")
            print(f"Stderr: {stderr.strip()}")
            continue
        else:
            # Process stdout, which should contain the LLM's action line.
            # Strip whitespace and take the first non-empty line.
            llm_response_data = json.loads(stdout.strip())

            # Uncomment the line below if you want to see the full stdout from the agent for debugging
            # print(f"Agent stdout:\n{stdout.strip()}") 
            
    except subprocess.TimeoutExpired:
        print("LLM agent call timed out.")
        continue
    except Exception as e:
        print(f"An unexpected error occurred during agent call: {e}")
        continue
    
    # Ensure we always have a valid action_line (even if NONE)
    if llm_response_data is None or llm_response_data.get("action") == "none":
        continue

    action_line = llm_response_data.get("action")

    if (action_line == "add_memory"):

        memory_to_add = llm_response_data.get("payload")

        prompt_for_agent = f"""
        You are to write in the corresponding MEMORY.md file(s) for the user with the email: {user} however if it is a general non-specific event or piece of information it is ok to write it in the general MEMORY.md or other shared files
        Before writing to said file, make sure the directory and file exist and create them if necessary. If you can't determine which user in your memory banks this is, start asking questions to clarify.
        
        The following information needs to be logged: {memory_to_add}
        """

        cmd = [
            'openclaw', 'agent',
            f'--session-id={session_id}', # Explicitly set session ID
            '--message', prompt_for_agent,
            '--thinking', 'low'
        ]

        process = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, stderr = process.communicate(timeout=120) # Timeout after 45 seconds

    elif (action_line == "notify_user"):

        summary_to_notify = llm_response_data.get("payload")

        prompt_for_agent = f"""
        You are to reach out to {user} via one of the sessions you are connected to them with. If you can't find their sessions at first, scan your memory banks and even their correspondning USER.md file which you can located via ~/.openclaw/workspace/USERS.md
        Should there still be no session, reach out to them via a new one via one of the channels you can.
        
        Reach out to them with the following information: {summary_to_notify}
        """

        cmd = [
            'openclaw', 'agent',
            f'--session-id={session_id}', # Explicitly set session ID
            '--message', prompt_for_agent,
            '--thinking', 'low'
        ]

        process = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, stderr = process.communicate(timeout=120) # Timeout after 90 seconds

    elif (action_line == "to_respond"):

        summary_to_notify = llm_response_data.get("payload")

        prompt_for_agent = f"""
        You are to reach out to {user} via one of the sessions you are connected to them with. If you can't find their sessions at first, scan your memory banks and even their correspondning USER.md file which you can located via ~/.openclaw/workspace/USERS.md
        Should there still be no session, reach out to them via a new one via one of the channels you can.
        
        Reach out to them that you need to respond to the email and what you need as inputs to proceed: {summary_to_notify}

        Afterwards send the email via the lel-mail skill
        """

        cmd = [
            'openclaw', 'agent',
            f'--session-id={session_id}', # Explicitly set session ID
            '--message', prompt_for_agent,
            '--thinking', 'high'
        ]

        process = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, stderr = process.communicate(timeout=120) # Timeout after 90 seconds
        
    mail.store(uid, '+FLAGS', '\\Seen')

mail.logout()
PY


path: scripts/email_send.sh
content: #!/usr/bin/env bash
# ~/.openclaw/workspace/skills/lel-mail/scripts/email_send.sh
#Usage: email_send.sh --recipient <recipient> --subject <subject> --body <body> [--cc ...] [--bcc ...]
#CC/BCC are comma-separated lists. Use empty values to skip.

set -euo pipefail

usage() {
echo "Usage: email_send.sh <sender> <recipient> [subject] [body] [--cc ...] [--bcc ...]"
echo " or: email_send.sh --sender <sender> --recipient <recipient> --subject <subject> --body <body> [--cc ...] [--bcc ...]"
echo "CC/BCC are comma-separated lists. Use empty values to skip."
}

# Defaults
SENDER=""
RECIPIENT=""
SUBJECT=""
BODY=""
CC=""
BCC=""

# First, check if user used named flags
if [[ "$1" == --sender || "$1" == --sender=* || "$1" == --recipient-* || "$1" == --recipient || "$1" == --subject-* || "$1" == --subject || "$1" == --body-* || "$1" == --body ]]; then
# We'll parse all flags below; ensure we start with no leftovers
SENDER=""
RECIPIENT=""
SUBJECT=""
BODY=""
shift 0
fi

# If flags are used, parse them (support both --field value and --field=value)
while [[ $# -gt 0 ]]; do
case "$1" in
--sender)
shift
SENDER="${1:-}"; shift
;;
--sender=*)
SENDER="${1#*=}"; shift
;;
--recipient)
shift
RECIPIENT="${1:-}"; shift
;;
--recipient=*)
RECIPIENT="${1#*=}"; shift
;;
--subject)
shift
SUBJECT="${1:-}"; shift
;;
--subject=*)
SUBJECT="${1#*=}"; shift
;;
--body)
shift
BODY="${1:-}"; shift
;;
--body=*)
BODY="${1#*=}"; shift
;;
--cc)
shift
CC="${CC:+$CC,}${1:-}"; shift
;;
--cc=*)
CC="${1#*=}"; shift
;;
--bcc)
shift
BCC="${BCC:+$BCC,}${1:-}"; shift
;;
--bcc=*)
BCC="${1#*=}"; shift
;;
--help|-h)
usage
exit 0
;;
*)
# If no flags were used and we haven't consumed the 3 positional args, try to read as positional
if [[ -z "$RECIPIENT" ]]; then
RECIPIENT="$1"; shift
elif [[ -z "$SUBJECT" ]]; then
SUBJECT="$1"; shift
elif [[ -z "$BODY" ]]; then
BODY="$1"; shift
else
echo "Unknown arg: $1"
usage
exit 2
fi
;;
esac
done

# Fallback: if no named flags used, try to read the first three positionals
if [[ -z "$RECIPIENT" && -n "${1-}" ]]; then
# assume original invocation was positional
RECIPIENT="$1"; shift
SUBJECT="${1:-}"; shift 2>/dev/null || true
BODY="${1:-}"
fi

# Final guard
if [[ -z "$SENDER" ]]; then
echo "Sender is required."
usage
exit 2
fi
if [[ -z "$RECIPIENT" ]]; then
echo "Recipient is required."
usage
exit 2
fi

# Normalize CC/BCC to empty strings if not set
CC="${CC:-}"
BCC="${BCC:-}"

export SENDER RECIPIENT SUBJECT BODY CC BCC

python3 - <<PY
import json
import os
from datetime import datetime

SENDER = "${SENDER}"
RECIPIENT = "${RECIPIENT}"
SUBJECT   = "${SUBJECT}"
BODY      = "${BODY}"
CC_RAW    = "${CC}"
BCC_RAW   = "${BCC}"

QUEUE_PATH = os.path.expanduser("~/.config/lel-mail/queue.json")

def parse_recipients(raw: str):
    """
    Turn a comma-separated string into a clean list of emails.
    Empty or whitespace-only strings result in [].
    """
    if not raw:
        return []
    return [addr.strip() for addr in raw.split(",") if addr.strip()]


def load_queue(path: str):
    """
    Load existing queue file or initialize a new one
    if it doesn't exist or is malformed.
    """
    if not os.path.exists(path):
        return {"last_sent": None, "remaining": []}

    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        # Basic shape validation
        if not isinstance(data, dict):
            raise ValueError("queue root must be an object")
        if "last_sent" not in data:
            data["last_sent"] = None
        if "remaining" not in data or not isinstance(data["remaining"], list):
            data["remaining"] = []
        return data
    except Exception:
        # If corrupt, start fresh but *don't* crash the enqueue
        return {"last_sent": None, "remaining": []}


def save_queue(path: str, data):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp_path = path + ".tmp"
    with open(tmp_path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
        f.write("\n")
    os.replace(tmp_path, path)


def main():
    cc_list = parse_recipients(CC_RAW)
    bcc_list = parse_recipients(BCC_RAW)

    queue = load_queue(QUEUE_PATH)

    new_entry = {
        "SENDER": SENDER,
        "RECIPIENT": RECIPIENT,
        "SUBJECT": SUBJECT,
        "BODY": BODY,
        "CC": cc_list,
        "BCC": bcc_list,
    }

    queue["remaining"].append(new_entry)

    # Do NOT change last_sent here; that’s for the sender process
    save_queue(QUEUE_PATH, queue)


if __name__ == "__main__":
    main()
PY

echo "Email scheduled successfully to $RECIPIENT"


path: scripts/email_sender_daemon.sh
content: #!/usr/bin/env bash
# /root/.openclaw/workspace/email_sender_daemon.sh

set -euo pipefail

# ----------- Send via Python’s smtplib -----------------
python3 - <<PY
import os, sys, json, ssl, smtplib
from datetime import datetime, timezone
from email.message import EmailMessage
import random
import time

QUEUE_FILE  = os.path.expanduser("~/.config/lel-mail/queue.json")
CONFIG_FILE = os.path.expanduser("~/.config/lel-mail/config.json")


def load_json(path):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def save_json_atomic(path, data):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
        f.write("\n")
    os.replace(tmp, path)


# --- Load queue ---
if not os.path.exists(QUEUE_FILE):
    print("Queue file not found, terminating")
    sys.exit(1)

QUEUE_DATA = load_json(QUEUE_FILE)

remaining = QUEUE_DATA.get("remaining") or []
if not isinstance(remaining, list) or len(remaining) == 0:
    print("Queue is empty, no emails to send for now")
    sys.exit(0)

# --- Load config accounts ---
if not os.path.exists(CONFIG_FILE):
    print("Configuration file not found, will terminate for now and check later")
    sys.exit(1)

config = load_json(CONFIG_FILE)  # expected list of accounts

def find_account_for_sender(sender_email: str):
    for item in config:
        if item.get("auth", {}).get("user") == sender_email:
            return item
    return None

# --- Pick first sendable email (skip entries with unknown sender) ---
idx_to_send = None
email_to_send = None
config_data = None

for i, entry in enumerate(remaining):
    sender = entry.get("SENDER")  # stored queue format
    if not sender:
        continue
    acct = find_account_for_sender(sender)

    if (acct.get("can_send", False) == False):
        continue

    if acct is not None:
        idx_to_send = i
        email_to_send = entry
        config_data = acct
        break

if config_data is None:
    print("No valid users found in queue (no matching SENDER in config).")
    sys.exit(1)

# --- Extract stored email fields (your stored format) ---
sender    = email_to_send.get("SENDER")
recipient = email_to_send.get("RECIPIENT")
subject   = email_to_send.get("SUBJECT", "")
body      = email_to_send.get("BODY", "")
cc_list   = email_to_send.get("CC") or []
bcc_list  = email_to_send.get("BCC") or []

if not recipient:
    print("Queued item missing recipient; skipping.")
    # remove bad entry so it doesn't block
    remaining.pop(idx_to_send)
    save_json_atomic(QUEUE_FILE, QUEUE_DATA)
    sys.exit(1)

# --- SMTP credentials come from the sender’s account config ---
server   = config_data["config"]["smtp"]["server"]
port     = int(config_data["config"]["smtp"]["port"])
user     = config_data["auth"]["user"]
[REDACTED_SECRET]"auth"]["password"]

# --- Build RFC822 message correctly ---
msg = EmailMessage()
msg["From"] = sender  # or user; usually same, but keep sender explicit
msg["To"] = recipient
msg["Subject"] = subject
if cc_list:
    msg["Cc"] = ", ".join(cc_list)
# IMPORTANT: do NOT add Bcc header (it should be hidden)
msg.set_content(body)

# SMTP envelope recipients must include To + Cc + Bcc
rcpt_to = [recipient] + list(cc_list) + list(bcc_list)

delay_seconds = random.randint(30, 90)
time.sleep(delay_seconds)

context = ssl.create_default_context()
with smtplib.SMTP(server, port) as smtp:
    smtp.starttls(context=context)
    smtp.login(user, password)
    smtp.sendmail(sender, rcpt_to, msg.as_string())

# --- Update queue after successful send ---
remaining.pop(idx_to_send)
QUEUE_DATA["last_sent"] = datetime.now(timezone.utc).isoformat()

save_json_atomic(QUEUE_FILE, QUEUE_DATA)

print(f"Sent queued email from {sender} to {recipient}. Remaining: {len(remaining)}")
PY

echo "✅ Email sent"


path: scripts/manage_queue.py
content: #!/usr/bin/env python3
import json
import os
import sys
import argparse

QUEUE_PATH = os.path.expanduser("~/.config/lel-mail/queue.json")

def load_queue(path):
    if not os.path.exists(path):
        return {"last_sent": None, "remaining": []}
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)

def save_queue(path, data):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
        f.write("\n")

def list_queue(queue):
    if not queue["remaining"]:
        print("Queue is empty.")
        return
    print(f"{'ID':<4} | {'Recipient':<30} | {'Subject'}")
    print("-" * 65)
    for i, entry in enumerate(queue["remaining"]):
        recipient = entry.get("RECIPIENT", "N/A")
        subject = entry.get("SUBJECT", "(No Subject)")
        print(f"{i:<4} | {recipient:<30} | {subject}")

def delete_from_queue(queue, index):
    try:
        removed = queue["remaining"].pop(index)
        save_queue(QUEUE_PATH, queue)
        print(f"Removed email to {removed.get('RECIPIENT')} from queue.")
    except IndexError:
        print(f"Error: No email at index {index}")

def main():
    parser = argparse.ArgumentParser(description="Manage the outgoing email queue.")
    parser.add_argument("--list", action="store_true", help="List all emails in the queue")
    parser.add_argument("--delete", type=int, help="Delete an email from the queue by its ID (index)")
    
    args = parser.parse_args()
    queue = load_queue(QUEUE_PATH)

    if args.list:
        list_queue(queue)
    elif args.delete is not None:
        delete_from_queue(queue, args.delete)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()


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