@clawhub-mguozhen-8f4d31fbc1
DingTalk group chat bridge for Claude Code. Send markdown/text messages to DingTalk groups, receive @mentions and auto-execute via Claude CLI, run a 24/7 Str...
---
name: dingtalk-bridge
description: "DingTalk group chat bridge for Claude Code. Send markdown/text messages to DingTalk groups, receive @mentions and auto-execute via Claude CLI, run a 24/7 Stream bot. Triggers: dingtalk, send dingtalk, dingtalk bot, dingtalk message, send group message, 钉钉, 发群消息, 钉钉机器人"
allowed-tools: Bash
---
# DingTalk Bridge Skill
Bridges Claude Code with DingTalk group chat via the DingTalk Stream SDK + OpenAPI.
## Capabilities
| Feature | Description |
|---------|-------------|
| **Send Markdown** | Send rich markdown messages to a DingTalk group |
| **Send Text** | Send plain text messages |
| **Stream Bot** | 24/7 listener: receive @mentions, execute via `claude -p`, reply with results |
| **Auto Conv Discovery** | Conversation ID auto-saved on first @mention |
| **Keepalive** | 20s WebSocket ping prevents DingTalk 30-min timeout |
| **Crash Recovery** | Exponential backoff (5s to 60s) on disconnect |
## Quick Start
### 1. Install
```bash
bash "$CLAUDE_SKILL_DIR/scripts/install.sh"
```
The script will:
- Install Python dependencies (`dingtalk_stream`, `websockets`)
- Prompt for your DingTalk App Key & Secret (or read from env)
- Generate `config.json`
- Create `data/` directory for conversation state
- Optionally create a macOS LaunchAgent for 24/7 operation
### 2. Configure
Set credentials via **environment variables** (recommended):
```bash
export DINGTALK_APP_KEY="your_app_key"
export DINGTALK_APP_SECRET="your_app_secret"
export DINGTALK_WORKDIR="/path/to/your/project" # optional
```
Or edit `config.json` in the skill directory (see `config.example.json`).
### 3. Get Conversation ID
The bot needs a conversation ID to send messages. Two ways:
**Auto (recommended):** Start the Stream bot, then @mention it in a DingTalk group. The conv ID is saved automatically.
**Manual:** If you already have the `openConversationId` and `robotCode`:
```bash
mkdir -p "$CLAUDE_SKILL_DIR/data"
echo '{"openConversationId":"YOUR_ID","robotCode":"YOUR_ROBOT_CODE"}' > "$CLAUDE_SKILL_DIR/data/conv.json"
```
## Commands
### Send a message
```bash
# Markdown (default)
python3 "$CLAUDE_SKILL_DIR/src/send.py" "**Bold** message with markdown"
# With custom title
python3 "$CLAUDE_SKILL_DIR/src/send.py" --title "Alert" "Server is down!"
# Plain text
python3 "$CLAUDE_SKILL_DIR/src/send.py" --text "Plain text message"
```
### Start the Stream Bot
```bash
python3 "$CLAUDE_SKILL_DIR/src/stream_bot.py"
```
The bot will:
1. Connect to DingTalk via Stream protocol
2. Listen for all @mentions in groups where the bot is added
3. Execute the message content via `claude -p "<message>" --continue`
4. Reply with the result as a markdown message
### Use as Python module
```python
import sys
sys.path.insert(0, "/path/to/dingtalk-bridge/src")
from send import send_markdown, send_text
send_markdown("Daily Report", "**Sent:** 50\n**Opened:** 18\n**Clicked:** 7")
send_text("Simple notification")
```
## Configuration Reference
| Config Key | Env Var | Default | Description |
|-----------|---------|---------|-------------|
| `app_key` | `DINGTALK_APP_KEY` | (required) | DingTalk App Key |
| `app_secret` | `DINGTALK_APP_SECRET` | (required) | DingTalk App Secret |
| `conv_file` | `DINGTALK_CONV_FILE` | `<skill>/data/conv.json` | Conversation metadata path |
| `workdir` | `DINGTALK_WORKDIR` | cwd | Working directory for claude CLI |
| `claude_bin` | `DINGTALK_CLAUDE_BIN` | `claude` | Path to claude binary |
| `max_reply` | `DINGTALK_MAX_REPLY` | `3000` | Max reply length (chars) |
| `keepalive` | `DINGTALK_KEEPALIVE` | `20` | WebSocket keepalive interval (seconds) |
## DingTalk App Setup (Prerequisites)
1. Go to [DingTalk Open Platform](https://open-dev.dingtalk.com/)
2. Create an **Enterprise Internal App** (企业内部应用)
3. Enable **Robot** capability (机器人)
4. Set message receive mode to **Stream** (Stream 模式)
5. Copy the App Key and App Secret
6. Add the bot to a group chat
## Running Tests
```bash
python3 "$CLAUDE_SKILL_DIR/tests/test_dingtalk.py"
```
## Architecture
```
dingtalk-bridge/
├── SKILL.md # This file
├── config.example.json # Example configuration
├── config.json # Your config (gitignored)
├── data/
│ └── conv.json # Auto-saved conversation metadata
├── src/
│ ├── __init__.py
│ ├── config.py # Config loader (env > file > defaults)
│ ├── send.py # Send messages (OpenAPI)
│ └── stream_bot.py # Stream bot (receive + execute + reply)
├── scripts/
│ └── install.sh # One-command setup
└── tests/
└── test_dingtalk.py # Regression tests
```
FILE:README.md
<p align="center">
<img src="https://img.alicdn.com/imgextra/i3/O1CN01Kpgs1i1duSbfODzCm_!!6000000003797-2-tps-240-240.png" width="80" alt="DingTalk Bridge">
</p>
<h1 align="center">DingTalk Bridge</h1>
<p align="center">
<strong>Connect any Claude Code agent to DingTalk group chat in 60 seconds.</strong>
</p>
<p align="center">
<a href="#quick-start"><img src="https://img.shields.io/badge/setup-60s-brightgreen?style=flat-square" alt="60s Setup"></a>
<a href="https://agentskills.io"><img src="https://img.shields.io/badge/Agent%20Skills-compatible-blue?style=flat-square" alt="Agent Skills"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT License"></a>
<a href="https://code.claude.com"><img src="https://img.shields.io/badge/Claude%20Code-skill-8B5CF6?style=flat-square" alt="Claude Code Skill"></a>
<img src="https://img.shields.io/badge/python-3.9+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.9+">
<img src="https://img.shields.io/badge/tests-27%20passed-brightgreen?style=flat-square" alt="27 Tests Passed">
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> •
<a href="#features">Features</a> •
<a href="#commands">Commands</a> •
<a href="#configuration">Configuration</a> •
<a href="#architecture">Architecture</a> •
<a href="#faq">FAQ</a>
</p>
---
## What is this?
**DingTalk Bridge** is a [Claude Code skill](https://code.claude.com/docs/en/skills) that bridges your AI coding agent with [DingTalk](https://www.dingtalk.com/) (Alibaba's enterprise messaging platform). Once installed, your Claude Code agent can:
- **Send** rich Markdown or plain text messages to any DingTalk group
- **Receive** @mentions and auto-execute them via `claude -p`
- **Run 24/7** as a Stream bot with crash recovery and keepalive
Think of it as giving your Claude Code agent a DingTalk phone number.
## Features
| Feature | Description |
|---------|-------------|
| **Send Messages** | Markdown and plain text via DingTalk OpenAPI |
| **Stream Bot** | Persistent WebSocket listener for real-time @mention handling |
| **Auto-Execute** | Incoming messages run as Claude Code prompts, results posted back |
| **Crash Recovery** | Exponential backoff (5s to 60s) on disconnect, auto-restart via LaunchAgent |
| **20s Keepalive** | Custom WebSocket ping prevents DingTalk's 30-min timeout |
| **Zero Hardcoded Secrets** | Config via env vars or `config.json`, never in source code |
| **One-Command Install** | `bash install.sh` handles deps, credentials, and optional LaunchAgent |
| **27 Regression Tests** | Full test coverage with mocked API calls, no network required |
## Quick Start
### 1. Install the skill
```bash
# Clone into your Claude Code skills directory
git clone https://github.com/mguozhen/dingtalk-bridge.git ~/.claude/skills/dingtalk-bridge
# Run the installer
bash ~/.claude/skills/dingtalk-bridge/scripts/install.sh
```
The installer will:
- Install Python dependencies (`dingtalk_stream`, `websockets`)
- Prompt for your DingTalk App Key & Secret
- Generate `config.json` (chmod 600)
- Optionally create a macOS LaunchAgent for 24/7 operation
### 2. Prerequisites: Create a DingTalk App
1. Go to [DingTalk Open Platform](https://open-dev.dingtalk.com/)
2. Create an **Enterprise Internal App** (企业内部应用)
3. Enable **Robot** capability (机器人)
4. Set message receive mode to **Stream** (Stream 模式)
5. Copy the **App Key** and **App Secret**
6. Add the bot to a group chat
### 3. Start the bot
```bash
python3 ~/.claude/skills/dingtalk-bridge/src/stream_bot.py
```
@mention the bot in your group. It will auto-save the conversation ID and start responding.
### 4. Send messages from Claude Code
Once the skill is installed, Claude Code auto-discovers it. Just say:
> "Send a DingTalk message: today's build passed all tests"
Or use it programmatically:
```bash
python3 ~/.claude/skills/dingtalk-bridge/src/send.py "**Build Status**: All 142 tests passed"
```
## Commands
### CLI
```bash
# Send markdown message (default)
python3 src/send.py "**Bold** message with _markdown_"
# Send with custom title
python3 src/send.py --title "Deploy Alert" "Production deploy complete"
# Send plain text
python3 src/send.py --text "Simple notification"
# Start the Stream bot
python3 src/stream_bot.py
```
### Python Module
```python
from dingtalk_bridge.src.send import send_markdown, send_text
# Rich markdown
send_markdown("Daily Report", """
**Sent:** 50
**Opened:** 18 (36%)
**Clicked:** 7 (14%)
""")
# Plain text
send_text("Deployment complete.")
```
## Configuration
Config priority: **Environment Variables** > **config.json** > **Defaults**
| Config Key | Env Var | Default | Description |
|-----------|---------|---------|-------------|
| `app_key` | `DINGTALK_APP_KEY` | *(required)* | DingTalk App Key |
| `app_secret` | `DINGTALK_APP_SECRET` | *(required)* | DingTalk App Secret |
| `conv_file` | `DINGTALK_CONV_FILE` | `data/conv.json` | Conversation metadata path |
| `workdir` | `DINGTALK_WORKDIR` | cwd | Working directory for `claude` CLI |
| `claude_bin` | `DINGTALK_CLAUDE_BIN` | `claude` | Path to Claude binary |
| `max_reply` | `DINGTALK_MAX_REPLY` | `3000` | Max reply length (chars) |
| `keepalive` | `DINGTALK_KEEPALIVE` | `20` | WebSocket keepalive interval (sec) |
### Example: Environment Variables
```bash
export DINGTALK_APP_KEY="your_key_here"
export DINGTALK_APP_SECRET="your_secret_here"
export DINGTALK_WORKDIR="$HOME/my-project"
```
### Example: config.json
```json
{
"app_key": "your_key_here",
"app_secret": "your_secret_here",
"workdir": "/home/user/my-project"
}
```
## Architecture
```
dingtalk-bridge/
├── SKILL.md # Claude Code skill definition
├── README.md # This file
├── LICENSE # MIT License
├── config.example.json # Config template
├── marketplace.json # Skill registry metadata
├── data/
│ └── conv.json # Auto-saved conversation state
├── src/
│ ├── __init__.py
│ ├── config.py # Config loader (env > file > defaults)
│ ├── send.py # DingTalk OpenAPI messaging
│ └── stream_bot.py # Stream bot (listen + execute + reply)
├── scripts/
│ └── install.sh # One-command setup
└── tests/
└── test_dingtalk.py # 27 regression tests
```
### Message Flow
```
You (@mention bot in DingTalk group)
|
v
DingTalk Stream API (WebSocket)
|
v
stream_bot.py: BridgeHandler.process()
|
+-- save_conv_info() --> data/conv.json
|
+-- threading.Thread(execute_prompt)
|
+-- reply("Processing...") via webhook
|
+-- subprocess: claude -p "<your message>" --continue
|
+-- send_markdown("Done", result) via OpenAPI
|
v
DingTalk Group (result displayed)
```
## Running Tests
```bash
python3 tests/test_dingtalk.py
```
```
test_env_var_takes_priority ... ok
test_file_config_fallback ... ok
test_require_raises_on_missing ... ok
test_send_markdown_payload ... ok
test_reply_via_webhook ... ok
test_execute_prompt_success ... ok
test_execute_prompt_timeout ... ok
test_handler_process_spawns_thread ... ok
test_no_hardcoded_credentials ... ok
...
----------------------------------------------------------------------
Ran 27 tests in 0.15s
OK
```
All tests run offline with mocked API calls. No DingTalk credentials required.
## FAQ
<details>
<summary><strong>How do I get the conversation ID?</strong></summary>
Start the Stream bot, then @mention it in any DingTalk group. The conversation ID is automatically saved to `data/conv.json`. You only need to do this once per group.
</details>
<details>
<summary><strong>Can I use this with multiple groups?</strong></summary>
The current implementation saves one conversation at a time. For multiple groups, you can manually set the `conv_file` config to different paths per group, or extend `send.py` to accept a conversation ID parameter.
</details>
<details>
<summary><strong>What happens if the bot crashes?</strong></summary>
The bot has built-in exponential backoff (5s to 60s) and will auto-reconnect. If you set up the LaunchAgent via `install.sh`, macOS will also restart the process if it exits.
</details>
<details>
<summary><strong>Does this work on Linux?</strong></summary>
Yes. The Python code is cross-platform. The LaunchAgent setup is macOS-only, but you can use systemd on Linux instead. A systemd unit file example:
```ini
[Unit]
Description=DingTalk Bridge Bot
After=network.target
[Service]
ExecStart=/usr/bin/python3 /path/to/dingtalk-bridge/src/stream_bot.py
Restart=always
RestartSec=5
Environment=DINGTALK_APP_KEY=your_key
Environment=DINGTALK_APP_SECRET=your_secret
Environment=DINGTALK_WORKDIR=/path/to/project
[Install]
WantedBy=multi-user.target
```
</details>
<details>
<summary><strong>Is this an official Anthropic product?</strong></summary>
No. This is a community skill that follows the open <a href="https://agentskills.io">Agent Skills</a> specification. It works with Claude Code but is independently maintained.
</details>
## Contributing
PRs welcome. Please ensure all 27 tests pass before submitting:
```bash
python3 tests/test_dingtalk.py
```
## License
[MIT](LICENSE)
---
<p align="center">
Built for <a href="https://code.claude.com">Claude Code</a> • Powered by <a href="https://open.dingtalk.com">DingTalk Open Platform</a>
</p>
FILE:config.example.json
{
"app_key": "YOUR_DINGTALK_APP_KEY",
"app_secret": "YOUR_DINGTALK_APP_SECRET",
"workdir": "/path/to/your/project",
"claude_bin": "claude",
"max_reply": "3000",
"keepalive": "20"
}
FILE:marketplace.json
{
"$schema": "https://cdn.jsdelivr.net/gh/anthropics/claude-code@main/packages/plugin-marketplace/marketplace-schema.json",
"name": "DingTalk Bridge",
"description": "Connect Claude Code to DingTalk group chat. Send messages, receive @mentions, auto-execute prompts.",
"plugins": [
{
"name": "dingtalk-bridge",
"description": "DingTalk group chat bridge for Claude Code. Send markdown/text messages, receive @mentions, run a 24/7 Stream bot that auto-executes via Claude CLI.",
"source": {
"type": "git",
"url": "https://github.com/mguozhen/dingtalk-bridge.git"
},
"skills": ["dingtalk-bridge"],
"version": "1.0.0",
"author": "Bo Yuan",
"license": "MIT",
"tags": ["messaging", "dingtalk", "chat", "notification", "bot", "china"],
"platforms": ["macos", "linux"]
}
]
}
FILE:scripts/install.sh
#!/usr/bin/env bash
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "=== DingTalk Bridge Installer ==="
echo "Skill dir: $SKILL_DIR"
echo
# --- 1. Python deps ---
echo "[1/4] Installing Python dependencies..."
pip3 install --user dingtalk_stream websockets 2>/dev/null || pip3 install dingtalk_stream websockets
echo " Done."
echo
# --- 2. Credentials ---
echo "[2/4] Configuring credentials..."
CONFIG_FILE="$SKILL_DIR/config.json"
if [ -f "$CONFIG_FILE" ]; then
echo " config.json already exists, skipping."
else
APP_KEY="-"
APP_SECRET="-"
if [ -z "$APP_KEY" ]; then
read -rp " DingTalk App Key: " APP_KEY
else
echo " Using DINGTALK_APP_KEY from env."
fi
if [ -z "$APP_SECRET" ]; then
read -rsp " DingTalk App Secret: " APP_SECRET
echo
else
echo " Using DINGTALK_APP_SECRET from env."
fi
WORKDIR="-$(pwd)"
read -rp " Working directory for claude CLI [$WORKDIR]: " INPUT_WORKDIR
WORKDIR="-$WORKDIR"
cat > "$CONFIG_FILE" <<EOCFG
{
"app_key": "$APP_KEY",
"app_secret": "$APP_SECRET",
"workdir": "$WORKDIR"
}
EOCFG
chmod 600 "$CONFIG_FILE"
echo " Saved to $CONFIG_FILE (chmod 600)"
fi
echo
# --- 3. Data directory ---
echo "[3/4] Creating data directory..."
mkdir -p "$SKILL_DIR/data"
echo " Done."
echo
# --- 4. Optional LaunchAgent ---
echo "[4/4] LaunchAgent setup (macOS only, optional)..."
if [[ "$(uname)" == "Darwin" ]]; then
read -rp " Create LaunchAgent for 24/7 bot? (y/N): " CREATE_AGENT
if [[ "$CREATE_AGENT" =~ ^[Yy] ]]; then
PLIST_NAME="com.dingtalk-bridge.bot"
PLIST_PATH="$HOME/Library/LaunchAgents/PLIST_NAME.plist"
PYTHON3="$(which python3)"
BOT_SCRIPT="$SKILL_DIR/src/stream_bot.py"
LOG_FILE="$SKILL_DIR/data/bot.log"
cat > "$PLIST_PATH" <<EOPLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>PLIST_NAME</string>
<key>ProgramArguments</key>
<array>
<string>PYTHON3</string>
<string>-u</string>
<string>BOT_SCRIPT</string>
</array>
<key>WorkingDirectory</key>
<string>-$(pwd)</string>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>LOG_FILE</string>
<key>StandardErrorPath</key>
<string>LOG_FILE</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
EOPLIST
launchctl load "$PLIST_PATH" 2>/dev/null || true
echo " Created: $PLIST_PATH"
echo " Bot will start automatically. Log: $LOG_FILE"
echo " Control: launchctl start/stop $PLIST_NAME"
else
echo " Skipped."
fi
else
echo " Not macOS, skipped."
fi
echo
echo "=== Setup complete ==="
echo
echo "Next steps:"
echo " 1. Add the bot to a DingTalk group"
echo " 2. Start the bot: python3 $SKILL_DIR/src/stream_bot.py"
echo " 3. @mention the bot in the group to auto-save conversation ID"
echo " 4. Send messages: python3 $SKILL_DIR/src/send.py 'Hello from Claude!'"
echo
echo "Run tests: python3 $SKILL_DIR/tests/test_dingtalk.py"
FILE:src/__init__.py
FILE:src/config.py
#!/usr/bin/env python3
"""Configuration loader for dingtalk-bridge skill.
Priority: environment variables > config.json > defaults.
Required:
DINGTALK_APP_KEY or config.json "app_key"
DINGTALK_APP_SECRET or config.json "app_secret"
Optional:
DINGTALK_CONV_FILE — path to conversation metadata JSON (default: <skill>/data/conv.json)
DINGTALK_WORKDIR — working directory for claude CLI (default: cwd)
DINGTALK_CLAUDE_BIN — path to claude binary (default: claude)
DINGTALK_MAX_REPLY — max reply length in chars (default: 3000)
DINGTALK_KEEPALIVE — WebSocket keepalive interval in seconds (default: 20)
"""
import json
import os
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_FILE = SKILL_DIR / "config.json"
DEFAULT_CONV_FILE = SKILL_DIR / "data" / "conv.json"
def _load_file_config():
if CONFIG_FILE.exists():
return json.loads(CONFIG_FILE.read_text())
return {}
def get(key, default=None):
env_key = f"DINGTALK_{key.upper()}"
val = os.environ.get(env_key)
if val is not None:
return val
file_cfg = _load_file_config()
if key in file_cfg:
return file_cfg[key]
return default
def require(key):
val = get(key)
if val is None:
env_key = f"DINGTALK_{key.upper()}"
raise RuntimeError(
f"Missing required config: set {env_key} env var or add '{key}' to {CONFIG_FILE}"
)
return val
def app_key():
return require("app_key")
def app_secret():
return require("app_secret")
def conv_file():
return Path(get("conv_file", str(DEFAULT_CONV_FILE)))
def workdir():
return get("workdir", os.getcwd())
def claude_bin():
return get("claude_bin", "claude")
def max_reply_len():
return int(get("max_reply", "3000"))
def keepalive_interval():
return int(get("keepalive", "20"))
FILE:src/send.py
#!/usr/bin/env python3
"""DingTalk group messaging via OpenAPI.
Usage as CLI:
python3 send.py "Markdown message"
python3 send.py --title "Title" "Markdown message"
python3 send.py --text "Plain text message"
Usage as module:
from dingtalk_bridge.src.send import send_markdown, send_text, save_conv_info
"""
import json
import os
import sys
import urllib.request
from pathlib import Path
# Allow running standalone or as module
try:
from . import config
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import config
def _get_access_token():
payload = json.dumps({
"appKey": config.app_key(),
"appSecret": config.app_secret(),
}).encode()
req = urllib.request.Request(
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
data=payload,
headers={"Content-Type": "application/json"},
)
resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
return resp["accessToken"]
def _get_conv_info():
conv_path = config.conv_file()
if not conv_path.exists():
raise FileNotFoundError(
f"No conversation file at {conv_path}. "
"Send a message to the bot in a DingTalk group first "
"so it can auto-save the conversation ID."
)
return json.loads(conv_path.read_text())
def save_conv_info(open_conversation_id, robot_code):
conv_path = config.conv_file()
conv_path.parent.mkdir(parents=True, exist_ok=True)
data = {"openConversationId": open_conversation_id, "robotCode": robot_code}
conv_path.write_text(json.dumps(data, indent=2))
print(f"[DingTalk] Conv info saved: {conv_path}")
def send_markdown(title, text):
conv = _get_conv_info()
token = _get_access_token()
payload = json.dumps({
"robotCode": conv["robotCode"],
"openConversationId": conv["openConversationId"],
"msgKey": "sampleMarkdown",
"msgParam": json.dumps({"title": title, "text": text}),
}).encode()
req = urllib.request.Request(
"https://api.dingtalk.com/v1.0/robot/groupMessages/send",
data=payload,
headers={
"Content-Type": "application/json",
"x-acs-dingtalk-access-token": token,
},
)
return json.loads(urllib.request.urlopen(req, timeout=10).read())
def send_text(text):
conv = _get_conv_info()
token = _get_access_token()
payload = json.dumps({
"robotCode": conv["robotCode"],
"openConversationId": conv["openConversationId"],
"msgKey": "sampleText",
"msgParam": json.dumps({"content": text}),
}).encode()
req = urllib.request.Request(
"https://api.dingtalk.com/v1.0/robot/groupMessages/send",
data=payload,
headers={
"Content-Type": "application/json",
"x-acs-dingtalk-access-token": token,
},
)
return json.loads(urllib.request.urlopen(req, timeout=10).read())
def reply_via_webhook(webhook_url, title, content):
payload = json.dumps({
"msgtype": "markdown",
"markdown": {"title": title, "text": content},
}).encode()
req = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=10)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Send message to DingTalk group")
parser.add_argument("message", nargs="*")
parser.add_argument("--title", default="Notification")
parser.add_argument("--text", action="store_true", help="Send as plain text instead of markdown")
args = parser.parse_args()
msg = " ".join(args.message) if args.message else "(empty)"
if args.text:
send_text(msg)
else:
send_markdown(args.title, msg)
print("Sent.")
FILE:src/stream_bot.py
#!/usr/bin/env python3
"""DingTalk Stream Bot — bridges DingTalk group chat to Claude Code CLI.
Receives @mentions in group, executes via `claude -p`, replies with result.
Features:
- 20s keepalive ping (prevents DingTalk 30-min WebSocket timeout)
- Exponential backoff on crash (5s -> 60s)
- Webhook + OpenAPI dual reply path
- Threaded execution (non-blocking)
Run:
python3 -m dingtalk_bridge.src.stream_bot
# or
python3 stream_bot.py
"""
import asyncio
import json
import os
import subprocess
import sys
import threading
import time
import types
from pathlib import Path
# Allow running standalone or as module
try:
from . import config
from .send import save_conv_info, send_markdown, reply_via_webhook
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import config
from send import save_conv_info, send_markdown, reply_via_webhook
import dingtalk_stream
from dingtalk_stream import AckMessage
def reply(webhook_url, title, content):
try:
if webhook_url:
reply_via_webhook(webhook_url, title, content)
else:
send_markdown(title, content)
except Exception:
try:
send_markdown(title, content)
except Exception as e2:
print(f"[Bot] Reply failed: {e2}", flush=True)
def execute_prompt(prompt, webhook_url):
claude_bin = config.claude_bin()
workdir = config.workdir()
max_len = config.max_reply_len()
try:
print(f"[Bot] Executing: {prompt[:80]}...", flush=True)
reply(webhook_url, "Executing", f"Processing: **{prompt[:100]}**\n\nPlease wait...")
result = subprocess.run(
[
claude_bin, "-p", prompt,
"--continue",
"--output-format", "text",
"--dangerously-skip-permissions",
],
capture_output=True,
text=True,
timeout=300,
cwd=workdir,
env=os.environ,
)
output = result.stdout.strip()
stderr = result.stderr.strip()
if result.returncode == 0 and output:
if len(output) > max_len:
output = output[:max_len] + "\n\n... (truncated)"
send_markdown("Done", f"**Prompt:** {prompt[:100]}\n\n---\n\n{output}")
else:
error_info = stderr[:1000] if stderr else "(no error output)"
send_markdown("Failed", f"**Prompt:** {prompt[:100]}\n\nExit: {result.returncode}\n\n{error_info}")
print(f"[Bot] Done (exit={result.returncode})", flush=True)
except subprocess.TimeoutExpired:
send_markdown("Timeout", f"Exceeded 5 minutes.\n\n**Prompt:** {prompt[:100]}")
except Exception as e:
print(f"[Bot] Exec error: {e}", flush=True)
try:
send_markdown("Error", f"**Prompt:** {prompt[:100]}\n\n**Error:** {e}")
except Exception:
pass
class BridgeHandler(dingtalk_stream.ChatbotHandler):
async def process(self, callback):
try:
data = callback.data
text = data.get("text", {}).get("content", "").strip()
conversation_id = data.get("conversationId", "")
robot_code = data.get("robotCode", "")
webhook_url = data.get("sessionWebhook", "")
print(f"[Bot] Received: '{text[:80]}' (conv={conversation_id[:16]}...)", flush=True)
if conversation_id and robot_code:
save_conv_info(conversation_id, robot_code)
if not text:
return AckMessage.STATUS_OK, "OK"
threading.Thread(
target=execute_prompt,
args=(text, webhook_url),
daemon=True,
).start()
except Exception as e:
print(f"[Bot] Handler error: {e}", flush=True)
return AckMessage.STATUS_OK, "OK"
def _make_client():
credential = dingtalk_stream.Credential(config.app_key(), config.app_secret())
client = dingtalk_stream.DingTalkStreamClient(credential)
keepalive_sec = config.keepalive_interval()
try:
import websockets
async def _keepalive(self, ws, ping_interval=keepalive_sec):
while True:
await asyncio.sleep(ping_interval)
try:
await ws.ping()
except websockets.exceptions.ConnectionClosed:
break
client.keepalive = types.MethodType(_keepalive, client)
except ImportError:
pass
client.register_callback_handler(
dingtalk_stream.chatbot.ChatbotMessage.TOPIC,
BridgeHandler(),
)
return client
def main():
workdir = config.workdir()
os.chdir(workdir)
print(f"[DingTalk Bridge] Bot starting", flush=True)
print(f"[DingTalk Bridge] PID: {os.getpid()}", flush=True)
print(f"[DingTalk Bridge] CWD: {workdir}", flush=True)
print(f"[DingTalk Bridge] APP_KEY: {config.app_key()[:10]}...", flush=True)
print(f"[DingTalk Bridge] Keepalive: {config.keepalive_interval()}s", flush=True)
backoff = 5
while True:
try:
_make_client().start_forever()
except (KeyboardInterrupt, SystemExit):
print("[DingTalk Bridge] Shutting down", flush=True)
break
except BaseException as e:
print(f"[DingTalk Bridge] Crashed: {type(e).__name__}: {e}, retry in {backoff}s...", flush=True)
time.sleep(backoff)
backoff = min(backoff * 2, 60)
else:
backoff = 5
if __name__ == "__main__":
main()
FILE:tests/test_dingtalk.py
#!/usr/bin/env python3
"""Regression tests for dingtalk-bridge skill.
Run: python3 test_dingtalk.py
No network access required — all API calls are mocked.
"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
# Add src to path
SKILL_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SKILL_DIR / "src"))
class TestConfig(unittest.TestCase):
"""Test config.py: env vars > config.json > defaults."""
def setUp(self):
self.env_patcher = patch.dict(os.environ, {}, clear=False)
self.env_patcher.start()
# Force reload of config module each test
if "config" in sys.modules:
del sys.modules["config"]
import config
self.config = config
def tearDown(self):
self.env_patcher.stop()
def test_env_var_takes_priority(self):
os.environ["DINGTALK_APP_KEY"] = "env_key_123"
with patch.object(self.config, "_load_file_config", return_value={"app_key": "file_key"}):
self.assertEqual(self.config.app_key(), "env_key_123")
def test_file_config_fallback(self):
os.environ.pop("DINGTALK_APP_KEY", None)
with patch.object(self.config, "_load_file_config", return_value={"app_key": "file_key_456"}):
self.assertEqual(self.config.app_key(), "file_key_456")
def test_require_raises_on_missing(self):
os.environ.pop("DINGTALK_APP_KEY", None)
with patch.object(self.config, "_load_file_config", return_value={}):
with self.assertRaises(RuntimeError) as ctx:
self.config.require("app_key")
self.assertIn("DINGTALK_APP_KEY", str(ctx.exception))
def test_defaults(self):
self.assertEqual(self.config.max_reply_len(), 3000)
self.assertEqual(self.config.keepalive_interval(), 20)
self.assertEqual(self.config.claude_bin(), "claude")
def test_env_override_numeric(self):
os.environ["DINGTALK_MAX_REPLY"] = "5000"
self.assertEqual(self.config.max_reply_len(), 5000)
def test_conv_file_default(self):
path = self.config.conv_file()
self.assertTrue(str(path).endswith("data/conv.json"))
def test_conv_file_env_override(self):
os.environ["DINGTALK_CONV_FILE"] = "/tmp/my_conv.json"
self.assertEqual(str(self.config.conv_file()), "/tmp/my_conv.json")
class TestSend(unittest.TestCase):
"""Test send.py: message formatting and API call structure."""
def setUp(self):
# Ensure config is available with test values
os.environ["DINGTALK_APP_KEY"] = "test_key"
os.environ["DINGTALK_APP_SECRET"] = "test_secret"
if "config" in sys.modules:
del sys.modules["config"]
if "send" in sys.modules:
del sys.modules["send"]
import send
self.send = send
def tearDown(self):
os.environ.pop("DINGTALK_APP_KEY", None)
os.environ.pop("DINGTALK_APP_SECRET", None)
@patch("send.urllib.request.urlopen")
def test_get_access_token(self, mock_urlopen):
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps({"accessToken": "tok_abc"}).encode()
mock_urlopen.return_value = mock_resp
token = self.send._get_access_token()
self.assertEqual(token, "tok_abc")
# Verify the request payload
call_args = mock_urlopen.call_args
req = call_args[0][0]
self.assertIn("oauth2/accessToken", req.full_url)
body = json.loads(req.data)
self.assertEqual(body["appKey"], "test_key")
self.assertEqual(body["appSecret"], "test_secret")
def test_save_conv_info(self):
with tempfile.TemporaryDirectory() as tmpdir:
conv_path = Path(tmpdir) / "sub" / "conv.json"
with patch("send.config") as mock_cfg:
mock_cfg.conv_file.return_value = conv_path
self.send.save_conv_info("conv_123", "robot_456")
self.assertTrue(conv_path.exists())
data = json.loads(conv_path.read_text())
self.assertEqual(data["openConversationId"], "conv_123")
self.assertEqual(data["robotCode"], "robot_456")
@patch("send.urllib.request.urlopen")
@patch("send._get_access_token", return_value="tok_xyz")
@patch("send._get_conv_info", return_value={"openConversationId": "conv1", "robotCode": "bot1"})
def test_send_markdown_payload(self, mock_conv, mock_token, mock_urlopen):
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"processQueryKey":"ok"}'
mock_urlopen.return_value = mock_resp
self.send.send_markdown("Test Title", "**Hello** world")
req = mock_urlopen.call_args[0][0]
self.assertIn("groupMessages/send", req.full_url)
body = json.loads(req.data)
self.assertEqual(body["robotCode"], "bot1")
self.assertEqual(body["openConversationId"], "conv1")
self.assertEqual(body["msgKey"], "sampleMarkdown")
param = json.loads(body["msgParam"])
self.assertEqual(param["title"], "Test Title")
self.assertEqual(param["text"], "**Hello** world")
@patch("send.urllib.request.urlopen")
@patch("send._get_access_token", return_value="tok_xyz")
@patch("send._get_conv_info", return_value={"openConversationId": "conv1", "robotCode": "bot1"})
def test_send_text_payload(self, mock_conv, mock_token, mock_urlopen):
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"processQueryKey":"ok"}'
mock_urlopen.return_value = mock_resp
self.send.send_text("plain message")
req = mock_urlopen.call_args[0][0]
body = json.loads(req.data)
self.assertEqual(body["msgKey"], "sampleText")
param = json.loads(body["msgParam"])
self.assertEqual(param["content"], "plain message")
@patch("send.urllib.request.urlopen")
def test_reply_via_webhook(self, mock_urlopen):
mock_resp = MagicMock()
mock_urlopen.return_value = mock_resp
self.send.reply_via_webhook("https://hook.example.com/xxx", "Title", "Content")
req = mock_urlopen.call_args[0][0]
self.assertEqual(req.full_url, "https://hook.example.com/xxx")
body = json.loads(req.data)
self.assertEqual(body["msgtype"], "markdown")
self.assertEqual(body["markdown"]["title"], "Title")
self.assertEqual(body["markdown"]["text"], "Content")
def test_get_conv_info_missing_file(self):
with patch("send.config") as mock_cfg:
mock_cfg.conv_file.return_value = Path("/nonexistent/conv.json")
with self.assertRaises(FileNotFoundError):
self.send._get_conv_info()
class TestStreamBot(unittest.TestCase):
"""Test stream_bot.py: handler logic and execute_prompt."""
def setUp(self):
os.environ["DINGTALK_APP_KEY"] = "test_key"
os.environ["DINGTALK_APP_SECRET"] = "test_secret"
os.environ["DINGTALK_WORKDIR"] = tempfile.gettempdir()
for mod in ["config", "send", "stream_bot"]:
if mod in sys.modules:
del sys.modules[mod]
def tearDown(self):
for key in ["DINGTALK_APP_KEY", "DINGTALK_APP_SECRET", "DINGTALK_WORKDIR"]:
os.environ.pop(key, None)
@patch("stream_bot.send_markdown")
@patch("stream_bot.subprocess.run")
@patch("stream_bot.reply")
def test_execute_prompt_success(self, mock_reply, mock_run, mock_send):
import stream_bot
mock_run.return_value = MagicMock(
returncode=0,
stdout="The answer is 42.",
stderr="",
)
stream_bot.execute_prompt("What is the answer?", "https://hook.test")
# Should have called reply for "Processing" status
mock_reply.assert_called_once()
self.assertIn("Processing", mock_reply.call_args[0][2])
# Should have called send_markdown with result
mock_send.assert_called_once()
args = mock_send.call_args[0]
self.assertEqual(args[0], "Done")
self.assertIn("The answer is 42", args[1])
@patch("stream_bot.send_markdown")
@patch("stream_bot.subprocess.run")
@patch("stream_bot.reply")
def test_execute_prompt_failure(self, mock_reply, mock_run, mock_send):
import stream_bot
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Error: something broke",
)
stream_bot.execute_prompt("bad prompt", None)
mock_send.assert_called()
args = mock_send.call_args[0]
self.assertEqual(args[0], "Failed")
self.assertIn("something broke", args[1])
@patch("stream_bot.send_markdown")
@patch("stream_bot.subprocess.run")
@patch("stream_bot.reply")
def test_execute_prompt_truncation(self, mock_reply, mock_run, mock_send):
import stream_bot
long_output = "x" * 5000
mock_run.return_value = MagicMock(
returncode=0,
stdout=long_output,
stderr="",
)
stream_bot.execute_prompt("generate long text", None)
args = mock_send.call_args[0]
self.assertIn("truncated", args[1])
# The total message should respect max_reply_len
self.assertLess(len(args[1]), 5000)
@patch("stream_bot.send_markdown")
@patch("stream_bot.subprocess.run", side_effect=__import__("subprocess").TimeoutExpired(cmd="claude", timeout=300))
@patch("stream_bot.reply")
def test_execute_prompt_timeout(self, mock_reply, mock_run, mock_send):
import stream_bot
stream_bot.execute_prompt("slow prompt", None)
args = mock_send.call_args[0]
self.assertEqual(args[0], "Timeout")
def test_handler_process_empty_text(self):
import stream_bot
import asyncio
handler = stream_bot.BridgeHandler()
callback = MagicMock()
callback.data = {
"text": {"content": " "},
"conversationId": "conv_1",
"robotCode": "bot_1",
"sessionWebhook": "",
}
loop = asyncio.new_event_loop()
with patch("stream_bot.save_conv_info"):
status, msg = loop.run_until_complete(handler.process(callback))
loop.close()
from dingtalk_stream import AckMessage
self.assertEqual(status, AckMessage.STATUS_OK)
def test_handler_process_spawns_thread(self):
import stream_bot
import asyncio
handler = stream_bot.BridgeHandler()
callback = MagicMock()
callback.data = {
"text": {"content": "hello bot"},
"conversationId": "conv_1",
"robotCode": "bot_1",
"sessionWebhook": "https://hook.test",
}
loop = asyncio.new_event_loop()
with patch("stream_bot.save_conv_info"), \
patch("stream_bot.execute_prompt") as mock_exec, \
patch("stream_bot.threading.Thread") as mock_thread:
mock_thread_inst = MagicMock()
mock_thread.return_value = mock_thread_inst
status, msg = loop.run_until_complete(handler.process(callback))
loop.close()
mock_thread.assert_called_once()
call_kwargs = mock_thread.call_args
self.assertEqual(call_kwargs.kwargs["args"], ("hello bot", "https://hook.test"))
self.assertTrue(call_kwargs.kwargs["daemon"])
mock_thread_inst.start.assert_called_once()
class TestIntegration(unittest.TestCase):
"""Integration tests: config file round-trip, conv save/load."""
def test_config_file_roundtrip(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = Path(tmpdir) / "config.json"
cfg_path.write_text(json.dumps({
"app_key": "roundtrip_key",
"app_secret": "roundtrip_secret",
"workdir": "/tmp",
}))
# Remove env vars so file config is used
env = {k: v for k, v in os.environ.items()
if not k.startswith("DINGTALK_")}
with patch.dict(os.environ, env, clear=True):
if "config" in sys.modules:
del sys.modules["config"]
import config
with patch.object(config, "CONFIG_FILE", cfg_path):
self.assertEqual(config.app_key(), "roundtrip_key")
self.assertEqual(config.app_secret(), "roundtrip_secret")
def test_conv_save_and_load(self):
os.environ["DINGTALK_APP_KEY"] = "k"
os.environ["DINGTALK_APP_SECRET"] = "s"
with tempfile.TemporaryDirectory() as tmpdir:
conv_path = Path(tmpdir) / "conv.json"
os.environ["DINGTALK_CONV_FILE"] = str(conv_path)
for mod in ["config", "send"]:
if mod in sys.modules:
del sys.modules[mod]
import send
send.save_conv_info("test_conv_id", "test_robot_code")
loaded = send._get_conv_info()
self.assertEqual(loaded["openConversationId"], "test_conv_id")
self.assertEqual(loaded["robotCode"], "test_robot_code")
os.environ.pop("DINGTALK_CONV_FILE", None)
os.environ.pop("DINGTALK_APP_KEY", None)
os.environ.pop("DINGTALK_APP_SECRET", None)
class TestSkillFiles(unittest.TestCase):
"""Verify skill structure completeness."""
def test_skill_md_exists(self):
self.assertTrue((SKILL_DIR / "SKILL.md").exists())
def test_skill_md_has_frontmatter(self):
content = (SKILL_DIR / "SKILL.md").read_text()
self.assertTrue(content.startswith("---"))
self.assertIn("name: dingtalk-bridge", content)
def test_required_files_exist(self):
required = [
"SKILL.md",
"config.example.json",
"src/__init__.py",
"src/config.py",
"src/send.py",
"src/stream_bot.py",
"scripts/install.sh",
"tests/test_dingtalk.py",
]
for f in required:
self.assertTrue(
(SKILL_DIR / f).exists(),
f"Missing required file: {f}",
)
def test_install_script_executable(self):
script = SKILL_DIR / "scripts" / "install.sh"
# Just check it's a bash script
content = script.read_text()
self.assertTrue(content.startswith("#!/usr/bin/env bash"))
def test_config_example_valid_json(self):
content = (SKILL_DIR / "config.example.json").read_text()
data = json.loads(content)
self.assertIn("app_key", data)
self.assertIn("app_secret", data)
def test_no_hardcoded_credentials(self):
"""Ensure no real credentials are hardcoded in source files."""
for src_file in (SKILL_DIR / "src").glob("*.py"):
content = src_file.read_text()
# Should not contain the old hardcoded key pattern
self.assertNotIn("dingvz5dxr6e40nvhxgj", content,
f"Hardcoded credential found in {src_file.name}")
if __name__ == "__main__":
unittest.main(verbosity=2)
Automates joining and engaging in local Facebook groups to post recommendation requests, analyze replies, and generate lead outreach messages.
---
name: fb-local-lead-sniper
description:
Facebook local group lead generation skill. Automates joining local groups, warm-up engagement (likes, comments, life posts), posting bait/recommendation requests, analyzing replies to find top-recommended providers, and generating outreach DM scripts.
Triggers: facebook groups, local leads, group warm-up, bait post, recommendation mining, facebook outreach, lead sniper, local marketing, facebook automation
metadata:
author: mguozhen
version: "1.0.0"
requires: web-access
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Agent
---
# fb-local-lead-sniper
Facebook Local Group Lead Generation — find, engage, and convert local service providers through community recommendations.
## Overview
This skill automates a 5-step lead generation funnel on Facebook local groups:
1. **Join** — Search and join local community groups by city
2. **Engage** — Warm up the account with likes, comments, and life posts
3. **Bait** — Post recommendation requests to surface top providers
4. **Analyze** — Parse replies to rank the most-recommended businesses
5. **Pitch** — Generate personalized DM scripts for outreach
## Prerequisites
- **web-access skill** must be installed and CDP proxy running (`localhost:3456`)
- Chrome must have **remote debugging** enabled (`chrome://inspect/#remote-debugging`)
- Must be logged into Facebook in Chrome
### Quick Check
```bash
curl -s http://localhost:3456/targets | head -1
```
If this returns a JSON array, you're ready. If not, run:
```bash
CLAUDE_SKILL_DIR=~/.claude/skills/web-access node ~/.claude/skills/web-access/scripts/check-deps.mjs
```
## Usage
All commands use the main entry point:
```bash
bash "$CLAUDE_SKILL_DIR/scripts/fb-ops.sh" <action> [options]
```
### Actions
| Action | Description | Key Options |
|--------|-------------|-------------|
| `join` | Join local Facebook groups | `--city`, `--count`, `--query` |
| `engage` | Like + comment in joined groups | `--likes`, `--comments` |
| `post` | Post a life update on profile | `--text` (or auto-generate) |
| `bait` | Post a recommendation request in a group | `--group`, `--trade`, `--template` |
| `analyze` | Analyze replies to find top providers | `--url` (post URL) |
| `warm` | Full warm-up cycle (join + engage + post) | `--city`, `--intensity` |
| `status` | Check account status and post replies | (none) |
### Examples
```bash
# Join 5 Austin groups
bash "$CLAUDE_SKILL_DIR/scripts/fb-ops.sh" join --city Austin --count 5
# Full warm-up: 20 likes, 8 comments, 5 groups, 1 life post
bash "$CLAUDE_SKILL_DIR/scripts/fb-ops.sh" warm --city Austin --intensity double
# Post a bait in a group asking for plumber recommendations
bash "$CLAUDE_SKILL_DIR/scripts/fb-ops.sh" bait --group "South Austin Neighbors" --trade plumber --template complaint
# Analyze replies on a bait post
bash "$CLAUDE_SKILL_DIR/scripts/fb-ops.sh" analyze --url "https://www.facebook.com/groups/xxx/posts/yyy"
# Check what's happening — pending posts, replies, account health
bash "$CLAUDE_SKILL_DIR/scripts/fb-ops.sh" status
```
## Strategy Guide
### Account Warm-up (Days 1-7)
New or dormant accounts need warm-up before posting bait. The algorithm:
| Day | Likes | Comments | Groups Joined | Life Posts |
|-----|-------|----------|---------------|------------|
| 1-2 | 10 | 4 | 5 | 1 |
| 3-4 | 15 | 6 | 5 | 1 |
| 5-7 | 20 | 8 | 5 | 1 |
Run `warm` action 2-4 times per day with random intervals. Avoid patterns.
### Rate Limit Awareness
- **Group joins**: Max 5 per batch, 30-60s between each. If rate-limited, stop and wait 24h.
- **Comments**: 8-12 per session, 8-15s between each. Vary the text.
- **Posts**: Max 2-3 bait posts per day across different groups.
- **Session length**: Keep each session under 15 minutes.
### Bait Post Templates
Five proven templates available via `--template`:
| Template | Style | Best For |
|----------|-------|----------|
| `urgent` | "Emergency! Need [trade] ASAP" | High urgency, fast replies |
| `research` | "Doing research, who's the best..." | Neutral, many recommendations |
| `newcomer` | "Just moved to [city], need..." | Sympathetic, welcoming replies |
| `complaint` | "Had terrible experience, need someone better" | Emotional, specific recommendations |
| `poll` | "Who's your go-to [trade]?" | Engagement-style, many tags |
### Analyzing Replies
The `analyze` action parses comments for:
- **@mentions** and tagged business pages
- **"I recommend X"** / **"Call X"** / **"Use X"** patterns
- **Phone numbers** and business names (Title Case detection)
- **Frequency ranking** — most-mentioned = highest priority lead
### Outreach DM Templates
After identifying top providers, generate personalized DMs:
- **Warm intro**: Reference the group + original post
- **Value prop**: Mention their frequent recommendations
- **CTA**: Offer free trial / consultation
- **Follow-up**: 3-day and 7-day scripts
## Architecture
```
fb-local-lead-sniper/
├── SKILL.md # This file — skill definition
├── README.md # User documentation
├── scripts/
│ ├── fb-ops.sh # Main entry point + CLI parser
│ ├── cdp-helpers.sh # CDP proxy utility functions
│ ├── join.sh # Group joining logic
│ ├── engage.sh # Likes + comments
│ ├── post.sh # Life posts + bait posts
│ └── analyze.sh # Reply analysis
├── templates/
│ ├── bait-posts.json # Bait post templates by trade
│ ├── comments.json # Engagement comment pool
│ ├── life-posts.json # Life update post pool
│ └── dm-scripts.json # Outreach DM templates
└── tests/
└── test_basic.sh # Unit tests
```
## Important Notes
- This skill operates on the user's real Chrome browser via CDP. All actions are visible to Facebook.
- Facebook actively detects automation. Built-in delays and randomization reduce risk but cannot eliminate it.
- Always warm up accounts before posting bait. Cold accounts posting requests get flagged.
- Respect group rules — some groups prohibit self-promotion or solicitation.
- The skill creates and closes its own browser tabs. It does not touch existing user tabs.
FILE:README.md
# fb-local-lead-sniper
Facebook Local Group Lead Generation — automated community engagement and recommendation mining.
## What it does
Automates a 5-step lead generation funnel through Facebook local groups:
1. **Join** — Search and join community groups by city
2. **Engage** — Warm up with likes, comments, and life posts
3. **Bait** — Post recommendation requests to surface top providers
4. **Analyze** — Parse replies to rank most-recommended businesses
5. **Pitch** — Generate personalized outreach DM scripts
## How it works
Uses [web-access](https://github.com/eze-is/web-access) skill's CDP (Chrome DevTools Protocol) proxy to control your real Chrome browser. No separate browser instance, no cookie extraction — operates directly in your logged-in Chrome session.
## Requirements
- [Claude Code](https://claude.ai/claude-code) with skills support
- [web-access](https://github.com/eze-is/web-access) skill installed
- Chrome with remote debugging enabled (`chrome://inspect/#remote-debugging`)
- Logged into Facebook in Chrome
## Install
```bash
# Install via clawhub
clawhub install mguozhen/fb-local-lead-sniper
# Or manually
git clone https://github.com/mguozhen/fb-local-lead-sniper.git ~/.claude/skills/fb-local-lead-sniper
```
## Quick Start
```bash
# Join 5 local groups
bash scripts/fb-ops.sh join --city Austin --count 5
# Full warm-up cycle (likes + comments + groups + life post)
bash scripts/fb-ops.sh warm --city Austin
# Double intensity warm-up
bash scripts/fb-ops.sh warm --city Austin --intensity double
# Post bait in a group
bash scripts/fb-ops.sh bait --group "https://facebook.com/groups/xxx" --trade plumber
# Analyze replies
bash scripts/fb-ops.sh analyze --url "https://facebook.com/groups/xxx/posts/yyy"
```
## Supported Trades
plumber, electrician, hvac, roofer, handyman, landscaper, cleaner — plus a `general` template that works for any trade.
## Bait Templates
| Template | Style | Use Case |
|----------|-------|----------|
| `urgent` | "Emergency! Need help ASAP" | Fast replies |
| `research` | "Who's the best..." | Many recommendations |
| `newcomer` | "Just moved, need..." | Sympathetic replies |
| `complaint` | "Had terrible experience..." | Specific recommendations |
| `poll` | "Who's your go-to?" | High engagement |
## Rate Limit Safety
Built-in protections:
- 30-60s delays between group joins
- 8-15s delays between comments
- Auto-detection of Facebook rate limiting
- Randomized delays to avoid patterns
- Session length under 15 minutes
## License
MIT
FILE:scripts/analyze.sh
#!/bin/bash
# Analyze Facebook post replies to find top-recommended providers
# Usage: source cdp-helpers.sh first, then call do_analyze
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
source "$SCRIPT_DIR/cdp-helpers.sh"
do_analyze() {
local post_url="$1"
log "Analyzing replies on: $post_url"
fb_navigate "$post_url"
sleep 5
fb_dismiss_dialogs
# Scroll to load all comments
for i in 1 2 3 4 5; do
fb_scroll 1500
sleep 2
# Click "View more comments" if present
fb_eval '(function(){var btns=document.querySelectorAll("[role=\"button\"]");for(var b of btns){if(b.innerText.includes("View more comments")||b.innerText.includes("View all")){b.click();return"expanded"}}return"none"})()' > /dev/null 2>&1
sleep 2
done
# Extract all comment text
local comments
comments=$(fb_eval_value '(function(){
var articles=document.querySelectorAll("[role=\"article\"]");
var comments=[];
for(var a of articles){
var text=a.innerText||"";
if(text.length>10 && text.length<2000){
comments.push(text.substring(0,500));
}
}
return JSON.stringify(comments);
})()')
if [ -z "$comments" ] || [ "$comments" = "[]" ]; then
log "No comments found on this post"
return 1
fi
# Parse with Python for recommendations
python3 << 'PYEOF'
import json, re, sys
from collections import Counter
raw = '''COMMENTS_PLACEHOLDER'''
try:
comments = json.loads(raw)
except:
comments = [raw]
mentions = Counter()
phones = []
patterns = [
r'(?:I recommend|recommend|try|call|use|contact|hit up|reach out to)\s+([A-Z][a-zA-Z\s&\']+?)(?:\.|,|!|\n|$)',
r'@([A-Za-z][A-Za-z\s]+)',
r'([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\s+(?:is|was|does|did|has)',
]
phone_re = r'(\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4})'
for c in comments:
for pat in patterns:
for match in re.finditer(pat, c):
name = match.group(1).strip()
if 2 < len(name) < 50:
mentions[name] += 1
for phone in re.finditer(phone_re, c):
phones.append(phone.group(1))
print("\n=== TOP RECOMMENDED PROVIDERS ===\n")
if mentions:
for name, count in mentions.most_common(15):
print(f" {count}x {name}")
else:
print(" No specific recommendations found")
if phones:
print(f"\n=== PHONE NUMBERS FOUND ({len(phones)}) ===\n")
for p in set(phones):
print(f" {p}")
print(f"\n=== STATS ===")
print(f" Total comments analyzed: {len(comments)}")
print(f" Unique providers mentioned: {len(mentions)}")
print(f" Phone numbers found: {len(phones)}")
PYEOF
log "Analysis complete"
}
do_status() {
log "Checking account status..."
# Check profile
fb_navigate "https://www.facebook.com/me"
sleep 4
local name
name=$(fb_eval_value '(function(){var h=document.querySelector("h1");return h?h.innerText:"unknown"})()')
log "Profile: $name"
# Check groups
fb_navigate "https://www.facebook.com/groups/joins/"
sleep 4
local group_count
group_count=$(fb_eval_value '(function(){var links=document.querySelectorAll("a[href*=\"/groups/\"]");return links.length})()')
log "Visible joined groups: $group_count"
# Screenshot
fb_screenshot "/tmp/fb_status.png"
log "Status screenshot: /tmp/fb_status.png"
}
# Run if called directly
if [ "BASH_SOURCE[0]" = "$0" ]; then
cdp_check || exit 1
fb_open || exit 1
trap fb_close EXIT
action="-status"
shift || true
case "$action" in
analyze) do_analyze "$@" ;;
status) do_status ;;
*) echo "Usage: $0 analyze|status [url]" ;;
esac
fi
FILE:scripts/cdp-helpers.sh
#!/bin/bash
# CDP proxy helper functions for Facebook operations
# Requires: web-access CDP proxy running on localhost:3456
CDP="-http://localhost:3456"
FB_TAB=""
log() { echo "[$(date +%H:%M:%S)] $*"; }
# Check CDP proxy is running and responsive
cdp_check() {
local targets
targets=$(curl -s --max-time 5 "$CDP/targets" 2>/dev/null || echo "")
if echo "$targets" | python3 -c "import sys,json;json.load(sys.stdin)" 2>/dev/null; then
return 0
fi
log "[FAIL] CDP proxy not available at $CDP"
log " Make sure web-access skill is installed and Chrome has remote debugging enabled"
log " Run: CLAUDE_SKILL_DIR=~/.claude/skills/web-access node ~/.claude/skills/web-access/scripts/check-deps.mjs"
return 1
}
# Open a new Facebook tab, verify login
fb_open() {
local url="-https://www.facebook.com/"
local result
result=$(curl -s --max-time 15 "$CDP/new?url=$url" 2>/dev/null)
FB_TAB=$(echo "$result" | python3 -c "import sys,json;print(json.load(sys.stdin).get('targetId',''))" 2>/dev/null)
if [ -z "$FB_TAB" ]; then
log "[FAIL] Could not open Facebook tab"
return 1
fi
sleep 4
local title
title=$(curl -s "$CDP/info?target=$FB_TAB" | python3 -c "import sys,json;print(json.load(sys.stdin).get('title',''))" 2>/dev/null)
if echo "$title" | grep -iq "log in"; then
log "[FAIL] Not logged into Facebook. Please log in via Chrome first."
fb_close
return 1
fi
log "[OK] Facebook ready (tab: 0:8...)"
return 0
}
# Close the Facebook tab
fb_close() {
[ -n "-" ] && curl -s "$CDP/close?target=$FB_TAB" > /dev/null 2>&1
FB_TAB=""
}
# Navigate to URL in current tab
fb_navigate() {
local url="$1"
curl -s "$CDP/navigate?target=$FB_TAB&url=$url" > /dev/null 2>&1
sleep 4
}
# Scroll down
fb_scroll() {
local amount="-1000"
curl -s "$CDP/scroll?target=$FB_TAB&y=$amount" > /dev/null 2>&1
sleep 2
}
# Execute JS and return value
fb_eval() {
local js="$1"
curl -s -X POST "$CDP/eval?target=$FB_TAB" -d "$js" 2>/dev/null
}
# Execute JS and extract value field
fb_eval_value() {
local js="$1"
fb_eval "$js" | python3 -c "import sys,json;print(json.load(sys.stdin).get('value',''))" 2>/dev/null
}
# Click element by CSS selector
fb_click() {
local selector="$1"
curl -s -X POST "$CDP/click?target=$FB_TAB" -d "$selector" > /dev/null 2>&1
}
# Take screenshot
fb_screenshot() {
local file="-/tmp/fb_screenshot.png"
curl -s "$CDP/screenshot?target=$FB_TAB&file=$file" > /dev/null 2>&1
echo "$file"
}
# Dismiss common Facebook popups/dialogs
fb_dismiss_dialogs() {
fb_eval '(function(){
var c=document.querySelector("[aria-label=\"Close\"]");
if(c)c.click();
var ds=document.querySelectorAll("[role=\"dialog\"]");
for(var d of ds){
var txt=d.innerText||"";
if(txt.includes("Got it")||txt.includes("Continue")||txt.includes("Not Now")){
var btn=d.querySelector("[role=\"button\"]");
if(btn)btn.click();
}
}
return "ok";
})()' > /dev/null 2>&1
sleep 1
}
# Check if rate-limited
fb_check_rate_limit() {
local result
result=$(fb_eval_value '(function(){
var ds=document.querySelectorAll("[role=\"dialog\"]");
for(var d of ds){
var t=d.innerText||"";
if((t.includes("Can")||t.includes("can"))&&t.includes("Feature"))return"LIMITED";
}
return"ok";
})()')
[ "$result" = "LIMITED" ]
}
# Random delay between min and max seconds
human_delay() {
local min="-2" max="-5"
sleep $(( RANDOM % (max - min + 1) + min ))
}
FILE:scripts/engage.sh
#!/bin/bash
# Facebook engagement — likes and comments
# Usage: source cdp-helpers.sh first, then call do_like / do_comment
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
source "$SCRIPT_DIR/cdp-helpers.sh"
# Load comments from template file
load_comments() {
local tpl="$SCRIPT_DIR/../templates/comments.json"
if [ -f "$tpl" ]; then
python3 -c "import json;[print(c) for c in json.load(open('$tpl'))]" 2>/dev/null
else
# Fallback defaults
echo "This is awesome, thanks for sharing!"
echo "Love this! Great community"
echo "Really helpful, appreciate the post!"
echo "Wow thats great, thanks!"
echo "So cool! Love seeing this"
echo "Thanks for posting, very useful!"
echo "Great stuff, appreciate it!"
echo "So helpful, thank you!"
echo "Amazing, thanks for sharing this"
echo "Good to know, thanks for the heads up!"
echo "Really nice, love this community"
echo "Awesome post, thanks!"
fi
}
do_like() {
local target_count="-20"
local groups_str="-"
log "Liking posts (target: $target_count)..."
local total=0
# Like on main feed first
fb_navigate "https://www.facebook.com/"
sleep 2
for round in $(seq 1 5); do
fb_scroll 1200
sleep 3
local count
count=$(fb_eval_value '(function(){var b=document.querySelectorAll("[aria-label=\"Like\"]"),n=0;for(var i=0;i<b.length;i++){try{b[i].click();n++}catch(e){}}return n})()')
total=$((total + -0))
human_delay 2 4
done
# Like in groups
local groups
if [ -n "$groups_str" ]; then
IFS=',' read -ra groups <<< "$groups_str"
else
groups=("EventsAustin" "austintexasgroup" "SouthAustinTxNeighbors")
fi
for g in "groups[@]"; do
[ $total -ge $target_count ] && break
fb_navigate "https://www.facebook.com/groups/$g/"
sleep 3
fb_dismiss_dialogs
for r in 1 2 3; do
fb_scroll 1000
sleep 2
local count
count=$(fb_eval_value '(function(){var b=document.querySelectorAll("[aria-label=\"Like\"]"),n=0;for(var i=0;i<b.length;i++){try{b[i].click();n++}catch(e){}}return n})()')
total=$((total + -0))
human_delay 1 3
done
human_delay 3 6
done
log "Likes complete: $total"
echo "$total"
}
do_comment() {
local target_count="-8"
local groups_str="-"
log "Commenting on posts (target: $target_count)..."
# Load comment pool
local -a comment_pool
mapfile -t comment_pool < <(load_comments)
local pool_size=#comment_pool[@]
local total=0
local groups
if [ -n "$groups_str" ]; then
IFS=',' read -ra groups <<< "$groups_str"
else
groups=("EventsAustin" "austintexasgroup" "SouthAustinTxNeighbors")
fi
for g in "groups[@]"; do
[ $total -ge $target_count ] && break
fb_navigate "https://www.facebook.com/groups/$g/"
sleep 4
fb_dismiss_dialogs
fb_scroll 600
sleep 3
local per_group=$(( (target_count - total + #groups[@] - 1) / #groups[@] ))
[ $per_group -gt 3 ] && per_group=3
for ci in $(seq 0 $((per_group - 1))); do
[ $total -ge $target_count ] && break
local comment="comment_pool[$(( RANDOM % pool_size ))]"
# Click Comment button
local clicked
clicked=$(fb_eval_value "(function(){var b=Array.from(document.querySelectorAll('[aria-label=\"Leave a comment\"],[aria-label=\"Comment\"],[aria-label=\"Write a comment\"]'));if(b.length<=$ci)return'none';b[$ci].click();return'ok'})()")
[ "$clicked" = "none" ] && break
sleep 2
# Type comment
fb_eval "(function(){var eds=document.querySelectorAll('[role=\"textbox\"][contenteditable=\"true\"]');for(var e of eds){var l=(e.getAttribute('aria-label')||'').toLowerCase();if(l.includes('comment')||l.includes('reply')){e.focus();document.execCommand('insertText',false,'$comment');return'ok'}}return'no'})()" > /dev/null 2>&1
sleep 1
# Press Enter to send
fb_eval '(function(){var eds=document.querySelectorAll("[role=\"textbox\"][contenteditable=\"true\"]");for(var e of eds){var l=(e.getAttribute("aria-label")||"").toLowerCase();if(l.includes("comment")||l.includes("reply")){e.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",code:"Enter",keyCode:13,bubbles:true}));return"ok"}}return"no"})()' > /dev/null 2>&1
total=$((total + 1))
log " Comment #$total: $comment"
human_delay 8 15
done
human_delay 3 6
done
log "Comments complete: $total"
echo "$total"
}
# Run if called directly
if [ "BASH_SOURCE[0]" = "$0" ]; then
cdp_check || exit 1
fb_open || exit 1
trap fb_close EXIT
action="-like"
shift || true
case "$action" in
like) do_like "$@" ;;
comment) do_comment "$@" ;;
*) echo "Usage: $0 like|comment [count] [groups]" ;;
esac
fi
FILE:scripts/fb-ops.sh
#!/bin/bash
# fb-local-lead-sniper — Main entry point
# Usage: fb-ops.sh <action> [options]
# Actions: join, engage, post, bait, analyze, warm, status
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
source "$SCRIPT_DIR/cdp-helpers.sh"
source "$SCRIPT_DIR/join.sh"
source "$SCRIPT_DIR/engage.sh"
source "$SCRIPT_DIR/post.sh"
source "$SCRIPT_DIR/analyze.sh"
usage() {
cat << 'EOF'
fb-local-lead-sniper — Facebook Local Group Lead Generation
Usage: fb-ops.sh <action> [options]
Actions:
join Join local groups
--city CITY Target city (default: Austin)
--count N Number of groups (default: 5)
--query Q Custom search query
engage Like and comment on posts
--likes N Number of likes (default: 20)
--comments N Number of comments (default: 8)
--groups G Comma-separated group slugs
post Post a life update on profile
--text TEXT Post text (or auto from templates)
bait Post a recommendation request in a group
--group URL Group URL
--trade TRADE Trade type (plumber, electrician, etc.)
--template TPL Template: urgent|research|newcomer|complaint|poll
analyze Analyze replies on a bait post
--url URL Post URL to analyze
warm Full warm-up cycle
--city CITY Target city
--intensity I normal (default) or double
status Check account status and pending posts
Examples:
fb-ops.sh join --city Austin --count 5
fb-ops.sh warm --city Houston --intensity double
fb-ops.sh bait --group "https://facebook.com/groups/xxx" --trade plumber
fb-ops.sh analyze --url "https://facebook.com/groups/xxx/posts/yyy"
EOF
}
# Parse named arguments
parse_args() {
CITY="Austin"; COUNT=5; QUERY=""; LIKES=20; COMMENTS=8; GROUPS=""
TEXT=""; GROUP_URL=""; TRADE="plumber"; TEMPLATE="research"
POST_URL=""; INTENSITY="normal"
while [ $# -gt 0 ]; do
case "$1" in
--city) CITY="$2"; shift 2 ;;
--count) COUNT="$2"; shift 2 ;;
--query) QUERY="$2"; shift 2 ;;
--likes) LIKES="$2"; shift 2 ;;
--comments) COMMENTS="$2"; shift 2 ;;
--groups) GROUPS="$2"; shift 2 ;;
--text) TEXT="$2"; shift 2 ;;
--group) GROUP_URL="$2"; shift 2 ;;
--trade) TRADE="$2"; shift 2 ;;
--template) TEMPLATE="$2"; shift 2 ;;
--url) POST_URL="$2"; shift 2 ;;
--intensity) INTENSITY="$2"; shift 2 ;;
*) shift ;;
esac
done
}
# Main
ACTION="-help"
shift || true
parse_args "$@"
case "$ACTION" in
help|-h|--help)
usage
exit 0
;;
join|engage|post|bait|analyze|warm|status)
cdp_check || exit 1
fb_open || exit 1
trap fb_close EXIT
;;
*)
echo "Unknown action: $ACTION"
usage
exit 1
;;
esac
case "$ACTION" in
join)
do_join "$CITY" "$COUNT" "$QUERY"
;;
engage)
do_like "$LIKES" "$GROUPS"
human_delay 5 10
do_comment "$COMMENTS" "$GROUPS"
;;
post)
do_life_post "$TEXT"
;;
bait)
if [ -z "$GROUP_URL" ]; then
echo "Error: --group URL required for bait action"
exit 1
fi
do_bait_post "$GROUP_URL" "$TRADE" "$TEMPLATE"
;;
analyze)
if [ -z "$POST_URL" ]; then
echo "Error: --url required for analyze action"
exit 1
fi
do_analyze "$POST_URL"
;;
warm)
if [ "$INTENSITY" = "double" ]; then
LIKES=40; COMMENTS=16; COUNT=10
fi
do_like "$LIKES" "$GROUPS"
human_delay 5 10
do_comment "$COMMENTS" "$GROUPS"
human_delay 5 10
do_join "$CITY" "$COUNT" "$QUERY"
# Post life update in afternoon/evening
HOUR=$(date +%H)
if [ "$HOUR" -ge 15 ] || [ "$HOUR" -le 9 ]; then
human_delay 5 10
do_life_post "$TEXT"
fi
;;
status)
do_status
;;
esac
log "Done."
FILE:scripts/join.sh
#!/bin/bash
# Join Facebook local groups by city
# Usage: source cdp-helpers.sh first, then call do_join
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
source "$SCRIPT_DIR/cdp-helpers.sh"
do_join() {
local city="-Austin"
local count="-5"
local query="-"
log "Joining $count groups in $city..."
# Build search query
if [ -z "$query" ]; then
local queries=(
"$city+community+group"
"$city+neighbors+recommendations"
"$city+local+business"
"$city+homeowners+residents"
"$city+moms+families"
)
query="queries[$(( RANDOM % ${#queries[@] ))]}"
fi
fb_navigate "https://www.facebook.com/search/groups/?q=$(echo "$query" | sed 's/ /+/g')"
sleep 3
local total=0
for i in $(seq 1 "$count"); do
local result
result=$(fb_eval_value '(function(){
var btns=Array.from(document.querySelectorAll("[role=\"button\"]")).filter(function(b){
return b.innerText.trim()==="Join"&&b.offsetParent!==null;
});
if(btns[0]){btns[0].click();return"joined";}
return"none";
})()')
if [ "$result" = "none" ]; then
log " No more Join buttons found"
# Try scrolling for more
fb_scroll 1500
sleep 2
result=$(fb_eval_value '(function(){
var btns=Array.from(document.querySelectorAll("[role=\"button\"]")).filter(function(b){
return b.innerText.trim()==="Join"&&b.offsetParent!==null;
});
if(btns[0]){btns[0].click();return"joined";}
return"none";
})()')
[ "$result" = "none" ] && break
fi
sleep 3
# Handle questionnaire dialogs
fb_eval '(function(){
var ds=document.querySelectorAll("[role=\"dialog\"]");
for(var d of ds){
var t=d.innerText||"";
if(t.includes("Answer")||t.includes("question")){
var c=d.querySelector("[aria-label=\"Close\"]");
if(c)c.click();
}
}
return"ok";
})()' > /dev/null 2>&1
# Check rate limit
if fb_check_rate_limit; then
log " Rate limited! Stopping joins."
break
fi
total=$((total + 1))
log " Joined #$total"
# Human-like delay 30-60s between joins
local delay=$(( RANDOM % 31 + 30 ))
[ $i -lt "$count" ] && sleep $delay
done
log "Join complete: $total groups"
echo "$total"
}
# Run if called directly
if [ "BASH_SOURCE[0]" = "$0" ]; then
cdp_check || exit 1
fb_open || exit 1
trap fb_close EXIT
do_join "$@"
fi
FILE:scripts/post.sh
#!/bin/bash
# Facebook posting — life updates and bait posts
# Usage: source cdp-helpers.sh first, then call do_life_post / do_bait_post
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
source "$SCRIPT_DIR/cdp-helpers.sh"
# Post a life update on personal profile
do_life_post() {
local text="-"
log "Posting life update..."
if [ -z "$text" ]; then
local tpl="$SCRIPT_DIR/../templates/life-posts.json"
if [ -f "$tpl" ]; then
text=$(python3 -c "import json,random;posts=json.load(open('$tpl'));print(random.choice(posts))" 2>/dev/null)
fi
fi
if [ -z "$text" ]; then
log "[FAIL] No post text provided and no templates found"
return 1
fi
fb_navigate "https://www.facebook.com/me"
sleep 4
# Click "What's on your mind"
fb_eval '(function(){var b=document.querySelectorAll("[role=\"button\"]");for(var x of b){if(x.innerText.includes("on your mind")){x.click();return"ok"}}return"none"})()' > /dev/null 2>&1
sleep 3
# Type the post — escape for JS
local js_text
js_text=$(python3 -c "import json;print(json.dumps('$text')[1:-1])" 2>/dev/null || echo "$text")
fb_eval "(function(){var t=\"$js_text\";var eds=document.querySelectorAll('[role=\"textbox\"][contenteditable=\"true\"]');for(var e of eds){var l=(e.getAttribute('aria-label')||'').toLowerCase()+(e.getAttribute('aria-placeholder')||'').toLowerCase();if((l.includes('on your mind')||l.includes('create')||l.includes('post'))&&!l.includes('comment')){e.focus();document.execCommand('insertText',false,t);return'ok'}}if(eds.length>0){eds[eds.length-1].focus();document.execCommand('insertText',false,t);return'fb'}return'none'})()" > /dev/null 2>&1
sleep 2
# Click Post button
fb_eval '(function(){var b=Array.from(document.querySelectorAll("[role=\"button\"]")).filter(function(x){return x.innerText.trim()==="Post"});if(b.length>0){b[b.length-1].click();return"posted"}return"none"})()' > /dev/null 2>&1
sleep 5
log "Life post published: 0:60..."
}
# Post a bait/recommendation request in a group
do_bait_post() {
local group_url="$1"
local trade="-plumber"
local template="-research"
log "Posting bait in group: $group_url (trade: $trade, template: $template)..."
# Load bait template
local tpl_file="$SCRIPT_DIR/../templates/bait-posts.json"
local text
if [ -f "$tpl_file" ]; then
text=$(python3 -c "
import json
data=json.load(open('$tpl_file'))
trade_data=data.get('$trade', data.get('general',{}))
templates=trade_data.get('$template', trade_data.get('research',[]))
if isinstance(templates, list) and templates:
import random; print(random.choice(templates))
elif isinstance(templates, str):
print(templates)
else:
print('')
" 2>/dev/null)
fi
if [ -z "$text" ]; then
log "[FAIL] No bait template found for trade=$trade template=$template"
return 1
fi
fb_navigate "$group_url"
sleep 5
fb_dismiss_dialogs
# Click "Write something"
fb_eval '(function(){var b=document.querySelectorAll("[role=\"button\"]");for(var x of b){if(x.innerText.includes("Write something")||x.innerText.includes("on your mind")){x.click();return"ok"}}return"none"})()' > /dev/null 2>&1
sleep 3
# Type the bait post
local js_text
js_text=$(python3 -c "import json;print(json.dumps('''$text''')[1:-1])" 2>/dev/null || echo "$text")
fb_eval "(function(){var t=\"$js_text\";var eds=document.querySelectorAll('[role=\"textbox\"][contenteditable=\"true\"]');for(var e of eds){var l=(e.getAttribute('aria-label')||'').toLowerCase()+(e.getAttribute('aria-placeholder')||'').toLowerCase();if((l.includes('create')||l.includes('post')||l.includes('write'))&&!l.includes('comment')){e.focus();document.execCommand('insertText',false,t);return'ok'}}if(eds.length>0){eds[eds.length-1].focus();document.execCommand('insertText',false,t);return'fb'}return'none'})()" > /dev/null 2>&1
sleep 2
# Click Post
fb_eval '(function(){var b=Array.from(document.querySelectorAll("[role=\"button\"]")).filter(function(x){return x.innerText.trim()==="Post"});if(b.length>0){b[b.length-1].click();return"posted"}return"none"})()' > /dev/null 2>&1
sleep 5
log "Bait post published in group"
}
# Run if called directly
if [ "BASH_SOURCE[0]" = "$0" ]; then
cdp_check || exit 1
fb_open || exit 1
trap fb_close EXIT
action="-life"
shift || true
case "$action" in
life) do_life_post "$@" ;;
bait) do_bait_post "$@" ;;
*) echo "Usage: $0 life|bait [args...]" ;;
esac
fi
FILE:templates/bait-posts.json
{
"general": {
"urgent": [
"HELP! I have an emergency at my house and need a good {trade} ASAP. Water everywhere / electrical issue / AC down. Who do you all trust? Please share their number if you can!",
"Urgent situation - need a reliable {trade} today or tomorrow. Had one scheduled but they just canceled on me. Any recommendations? Willing to pay for quality work!"
],
"research": [
"Hey neighbors! I'm putting together a list of trusted local service providers. Who's your go-to {trade}? Looking for someone reliable, fairly priced, and does quality work. Would love to hear your experiences!",
"Does anyone have a great {trade} they'd recommend? I've got some work that needs doing and I'd rather go with someone the community trusts than pick randomly online."
],
"newcomer": [
"Hi everyone! Just moved to the area and I'm trying to find reliable home service providers. Can anyone recommend a good {trade}? Still getting to know the local businesses and would love your input!",
"New to the neighborhood! Need to find a trustworthy {trade}. Back in my old city I had an amazing one but obviously need someone local now. Who do you all use?"
],
"complaint": [
"Ugh. Just had the WORST experience with a {trade}. Showed up 3 hours late, quoted me double what they said on the phone, and the work was sloppy. Can anyone recommend a {trade} who is actually professional and does quality work? I dont mind paying fair price, I just want someone reliable.",
"So frustrated right now. Hired a {trade} from an online listing and they completely botched the job. Now I need someone to fix what they messed up. Please recommend someone you've actually used and trust!"
],
"poll": [
"Quick poll for the group - who's the BEST {trade} in our area? Drop their name below! Trying to find someone the community actually stands behind.",
"Let's help each other out! Who's your most trusted {trade}? I know a lot of us need these services. Tag them so we can all reach out!"
]
},
"plumber": {
"urgent": [
"HELP! Pipe burst under my kitchen sink and water is everywhere. Need an emergency plumber RIGHT NOW. Who can you recommend that does same-day service?",
"My water heater just died - no hot water and there's a puddle forming in the garage. Anyone know a plumber who can come out today?"
],
"research": [
"Looking for a reliable plumber for some ongoing bathroom renovation work. Need someone who does quality work and shows up when they say they will. Who do you trust?",
"Need a plumber for a few different jobs - kitchen faucet replacement, slow drain in the bathroom, and checking the water pressure. Who's your go-to?"
],
"complaint": [
"Ugh. Just had the WORST experience with a plumber. Showed up 3 hours late, quoted me double what he said on the phone, and the work was sloppy. I need someone to fix what he messed up (leak under the kitchen sink). Can anyone recommend a plumber who is actually professional?",
"Paid a plumber $800 to fix a leak and it started leaking again two days later. Now he won't return my calls. Need a REAL plumber who stands behind their work. Recommendations?"
]
},
"electrician": {
"urgent": [
"Half my house just lost power and the breaker won't reset. Need an electrician urgently! Who do you recommend for emergency electrical work?",
"Lights flickering and I smell something burning near the panel. Need a licensed electrician ASAP. Who should I call?"
],
"research": [
"Need an electrician for some work - adding outlets, upgrading the panel, and installing a ceiling fan. Who does good electrical work in our area?",
"Looking for a licensed electrician who's reasonably priced. Need some rewiring done in an older home. Any recommendations?"
]
},
"hvac": {
"urgent": [
"AC just died and it's supposed to be over 100 this week. Need an HVAC tech who can come out fast. Who do you all use?",
"Furnace stopped working and it's freezing. Need emergency HVAC repair. Any recommendations for someone who does same-day service?"
],
"research": [
"Time for annual AC maintenance. Who's your trusted HVAC company? Looking for honest pricing and good service.",
"Thinking about replacing my 15-year-old AC system. Who do you recommend for HVAC installation? Want someone who won't try to oversell me."
]
},
"roofer": {
"research": [
"Need a roofer for an inspection and some repair work. A few shingles blew off in the last storm. Who's reliable and fairly priced?",
"Looking to get a roof replacement quote. Who's the best roofer in our area? Want quality work that'll last."
]
},
"handyman": {
"research": [
"Need a good handyman for a bunch of small jobs - hanging shelves, fixing a door that won't close right, patching some drywall. Who do you recommend?",
"Looking for a reliable handyman who can do a little bit of everything. Got a honey-do list that's getting out of control. Any suggestions?"
]
},
"landscaper": {
"research": [
"Need a landscaper for regular lawn maintenance plus some cleanup work. Who keeps your yard looking great without breaking the bank?",
"Looking to redo my backyard - new sod, some plants, maybe a small patio. Who does good landscaping work in our area?"
]
},
"cleaner": {
"research": [
"Looking for a reliable house cleaner. Biweekly deep cleaning for a 3BR house. Who do you trust in your home?",
"Need a cleaning service for a move-out clean. Has to be thorough. Who's the best cleaning company you've used?"
]
}
}
FILE:templates/comments.json
[
"This is awesome, thanks for sharing!",
"Love this! Great community",
"Really helpful, appreciate the post!",
"Wow thats great, thanks!",
"So cool! Love seeing this",
"Thanks for posting, very useful!",
"Great stuff, appreciate it!",
"So helpful, thank you!",
"Amazing, thanks for sharing this",
"Good to know, thanks for the heads up!",
"Really nice, love this community",
"Awesome post, thanks!",
"This is so useful, bookmarking this",
"Great tip, appreciate you sharing!",
"Love our neighborhood! Thanks for this",
"Super helpful, thank you so much!",
"This is exactly what I needed to know",
"Awesome, thanks for the recommendation!",
"Great info, saving this for later!",
"Thanks for looking out for the community!"
]
FILE:templates/dm-scripts.json
{
"warm_intro": [
"Hey {name}! I saw your name come up multiple times in {group_name} when people were asking for {trade} recommendations. That kind of reputation is hard to earn — clearly you do great work!",
"Hi {name}! I noticed you kept getting recommended in {group_name} for {trade} work. When that many people vouch for someone, it says a lot!"
],
"value_prop": [
"I actually help local service pros like you capture more of those leads. A lot of the people recommending you are calling but getting voicemail — and studies show 80% of callers won't leave a message, they just call the next person on the list.",
"Quick question — when those recommendations turn into calls, how many do you think go to voicemail? Industry data says you might be missing 30-40% of incoming calls. That's real money walking away."
],
"cta": [
"I'd love to offer you a free 7-day trial of our phone answering service — real humans pick up your calls 24/7, book appointments, and text you the details. Zero risk, cancel anytime. Would you be open to trying it?",
"We offer a free 7-day pilot — professional receptionists answer your calls, qualify the leads, and book them right into your schedule. No commitment, no credit card. Interested in giving it a shot?"
],
"follow_up_3day": [
"Hey {name}, just following up! I know you're busy. The free trial is still available whenever you're ready. No pressure at all — just don't want you to miss out on those calls that could be turning into paying jobs!",
"Hi {name}! Circling back on the phone answering trial. A few other {trade}s in {city} just started and are already seeing 3-5 extra bookings per week from calls they used to miss. Happy to set you up whenever."
],
"follow_up_7day": [
"Last note from me, {name}! Just wanted to make sure you saw my earlier messages. The offer for a free week of phone answering stands — figured with all those recommendations you're getting, you could use some help keeping up with the calls. Let me know if you'd like to chat!",
"Hey {name}, one last reach out. I totally get if the timing isn't right. Just know that when you're ready to stop missing calls and start converting more of those referrals, we're here. All the best with the business!"
]
}
FILE:templates/life-posts.json
[
"Perfect weather today! Took a long walk around the neighborhood. Nothing beats getting outside on a day like this.",
"Just discovered an amazing coffee shop nearby. The cold brew is next level. Love finding hidden gems!",
"Weekend farmers market haul - fresh produce, local honey, and breakfast tacos. Love supporting local businesses!",
"Caught an incredible sunset tonight. After all this time, this view still takes my breath away.",
"Finally tried that BBQ place everyone keeps talking about. The wait was worth it. Life changing brisket.",
"Morning run was perfect today. Saw a family of deer near the trail. Nature never disappoints!",
"Game night with neighbors tonight! Love how friendly everyone is here. Best community ever.",
"Spent the afternoon at the park. The weather was perfect. This is why I love living here.",
"Explored some street art in the neighborhood today. So much creativity in this city. Every mural tells a story.",
"Grilled some steaks tonight with a view from the backyard. Living the dream!",
"Tried a new hiking trail this morning. The wildflowers are absolutely stunning right now!",
"Lazy Sunday with a good book and homemade lemonade. Sometimes the simple things are the best.",
"Found the cutest little bookstore downtown. Spent way too long browsing but left with some great finds.",
"Beautiful morning for a bike ride. This city has some of the best trails!",
"Made homemade pizza tonight from scratch. Turned out amazing! Who knew it was this easy?",
"Road trip to the countryside this weekend. The rolling hills and blue sky were everything.",
"Tried paddleboarding for the first time today. Way harder than it looks but so much fun!",
"Nothing like a cold beer on the porch after a long week. Cheers to the weekend!",
"Volunteered at the community garden this morning. Love giving back to the neighborhood!",
"Sunday brunch at that new spot downtown. The eggs benedict was incredible. Highly recommend!"
]
FILE:tests/test_basic.sh
#!/bin/bash
# Unit tests for fb-local-lead-sniper
# Tests script loading, argument parsing, template loading, and helper functions
# Does NOT require CDP or Facebook (mocked where needed)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")/../scripts" && pwd)"
TEMPLATE_DIR="$(cd "$(dirname "BASH_SOURCE[0]")/../templates" && pwd)"
PASS=0
FAIL=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc (expected='$expected', got='$actual')"
FAIL=$((FAIL + 1))
fi
}
assert_contains() {
local desc="$1" needle="$2" haystack="$3"
if echo "$haystack" | grep -q "$needle"; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc (expected to contain '$needle')"
FAIL=$((FAIL + 1))
fi
}
assert_file_exists() {
local desc="$1" path="$2"
if [ -f "$path" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc (file not found: $path)"
FAIL=$((FAIL + 1))
fi
}
assert_nonzero() {
local desc="$1" value="$2"
if [ -n "$value" ] && [ "$value" != "0" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc (value is empty or zero)"
FAIL=$((FAIL + 1))
fi
}
# ========== Test 1: File structure ==========
echo "=== Test: File Structure ==="
assert_file_exists "SKILL.md exists" "$(dirname "$SCRIPT_DIR")/SKILL.md"
assert_file_exists "fb-ops.sh exists" "$SCRIPT_DIR/fb-ops.sh"
assert_file_exists "cdp-helpers.sh exists" "$SCRIPT_DIR/cdp-helpers.sh"
assert_file_exists "join.sh exists" "$SCRIPT_DIR/join.sh"
assert_file_exists "engage.sh exists" "$SCRIPT_DIR/engage.sh"
assert_file_exists "post.sh exists" "$SCRIPT_DIR/post.sh"
assert_file_exists "analyze.sh exists" "$SCRIPT_DIR/analyze.sh"
# ========== Test 2: Templates valid JSON ==========
echo ""
echo "=== Test: Template JSON Validity ==="
for tpl in comments.json life-posts.json bait-posts.json dm-scripts.json; do
assert_file_exists "$tpl exists" "$TEMPLATE_DIR/$tpl"
if python3 -c "import json;json.load(open('$TEMPLATE_DIR/$tpl'))" 2>/dev/null; then
echo " PASS: $tpl is valid JSON"
PASS=$((PASS + 1))
else
echo " FAIL: $tpl is invalid JSON"
FAIL=$((FAIL + 1))
fi
done
# ========== Test 3: Template content ==========
echo ""
echo "=== Test: Template Content ==="
comment_count=$(python3 -c "import json;print(len(json.load(open('$TEMPLATE_DIR/comments.json'))))")
assert_nonzero "comments.json has entries" "$comment_count"
life_count=$(python3 -c "import json;print(len(json.load(open('$TEMPLATE_DIR/life-posts.json'))))")
assert_nonzero "life-posts.json has entries" "$life_count"
bait_trades=$(python3 -c "import json;d=json.load(open('$TEMPLATE_DIR/bait-posts.json'));print(len(d))")
assert_nonzero "bait-posts.json has trade categories" "$bait_trades"
bait_has_general=$(python3 -c "import json;d=json.load(open('$TEMPLATE_DIR/bait-posts.json'));print('yes' if 'general' in d else 'no')")
assert_eq "bait-posts.json has 'general' category" "yes" "$bait_has_general"
bait_templates=$(python3 -c "import json;d=json.load(open('$TEMPLATE_DIR/bait-posts.json'));print(len(d.get('general',{})))")
assert_nonzero "general has template types" "$bait_templates"
dm_keys=$(python3 -c "import json;d=json.load(open('$TEMPLATE_DIR/dm-scripts.json'));print(','.join(sorted(d.keys())))")
assert_contains "dm-scripts has warm_intro" "warm_intro" "$dm_keys"
assert_contains "dm-scripts has cta" "cta" "$dm_keys"
assert_contains "dm-scripts has follow_up" "follow_up" "$dm_keys"
# ========== Test 4: Script sourcing (syntax check) ==========
echo ""
echo "=== Test: Script Syntax ==="
for script in cdp-helpers.sh join.sh engage.sh post.sh analyze.sh; do
if bash -n "$SCRIPT_DIR/$script" 2>/dev/null; then
echo " PASS: $script syntax OK"
PASS=$((PASS + 1))
else
echo " FAIL: $script has syntax errors"
FAIL=$((FAIL + 1))
fi
done
# fb-ops.sh needs args to not error, just check syntax
if bash -n "$SCRIPT_DIR/fb-ops.sh" 2>/dev/null; then
echo " PASS: fb-ops.sh syntax OK"
PASS=$((PASS + 1))
else
echo " FAIL: fb-ops.sh has syntax errors"
FAIL=$((FAIL + 1))
fi
# ========== Test 5: Help output ==========
echo ""
echo "=== Test: CLI Help ==="
help_output=$(bash "$SCRIPT_DIR/fb-ops.sh" help 2>&1 || true)
assert_contains "help shows usage" "Usage" "$help_output"
assert_contains "help shows join action" "join" "$help_output"
assert_contains "help shows warm action" "warm" "$help_output"
assert_contains "help shows bait action" "bait" "$help_output"
assert_contains "help shows analyze action" "analyze" "$help_output"
# ========== Test 6: cdp-helpers function definitions ==========
echo ""
echo "=== Test: Helper Functions ==="
source "$SCRIPT_DIR/cdp-helpers.sh"
# Test human_delay function exists
if type human_delay &>/dev/null; then
echo " PASS: human_delay function defined"
PASS=$((PASS + 1))
else
echo " FAIL: human_delay function not defined"
FAIL=$((FAIL + 1))
fi
# Test log function
log_output=$(log "test message" 2>&1)
assert_contains "log includes timestamp" ":" "$log_output"
assert_contains "log includes message" "test message" "$log_output"
# Test fb_check_rate_limit exists
if type fb_check_rate_limit &>/dev/null; then
echo " PASS: fb_check_rate_limit function defined"
PASS=$((PASS + 1))
else
echo " FAIL: fb_check_rate_limit function not defined"
FAIL=$((FAIL + 1))
fi
# ========== Test 7: Bait template rendering ==========
echo ""
echo "=== Test: Bait Template Rendering ==="
rendered=$(python3 -c "
import json, random
data = json.load(open('$TEMPLATE_DIR/bait-posts.json'))
tpl = data['general']['urgent'][0]
print(tpl.replace('{trade}', 'plumber'))
" 2>/dev/null)
assert_contains "rendered bait has trade name" "plumber" "$rendered"
assert_contains "rendered bait is a question" "?" "$rendered"
# ========== Test 8: SKILL.md metadata ==========
echo ""
echo "=== Test: SKILL.md Metadata ==="
skill_content=$(cat "$(dirname "$SCRIPT_DIR")/SKILL.md")
assert_contains "SKILL.md has name" "fb-local-lead-sniper" "$skill_content"
assert_contains "SKILL.md has description" "Facebook local group" "$skill_content"
assert_contains "SKILL.md has version" "1.0.0" "$skill_content"
assert_contains "SKILL.md has web-access requirement" "web-access" "$skill_content"
# ========== Summary ==========
echo ""
echo "================================"
echo " Results: $PASS passed, $FAIL failed"
echo "================================"
[ $FAIL -eq 0 ] && exit 0 || exit 1
Expert PR and media relations guidance for earned media, press coverage, and reputation building. Use when writing press releases, crafting media pitches, de...
---
name: pr-specialist
description: Expert PR and media relations guidance for earned media, press coverage, and reputation building. Use when writing press releases, crafting media pitches, developing journalist relationships, planning embargo strategies, managing crisis communication, placing thought leadership, building analyst relations, submitting award applications, or measuring PR coverage. Use for media outreach, press kits, speaking opportunities, and earned media strategy.
---
# PR Specialist
Expert public relations guidance for earned media strategy, media relations, and reputation building — from press releases to crisis communication.
## Philosophy
Great PR is earned, not bought:
1. **Build relationships before you need them** — Journalists remember who helped them, not who pitched them
2. **Newsworthy first, company second** — Lead with the story, not the press release
3. **Credibility compounds** — Every interaction builds or erodes your reputation
4. **Measure what matters** — Coverage quality beats clip counting
## How This Skill Works
When invoked, apply the guidelines in `rules/` organized by:
- `media-*` — Media relations, journalist outreach, relationship building
- `content-*` — Press releases, media pitches, press kits
- `strategy-*` — Embargo strategies, exclusives, launch timing
- `crisis-*` — Crisis communication, reputation management
- `thought-*` — Thought leadership placement, bylines, speaking
- `analyst-*` — Analyst relations and briefings
- `awards-*` — Award submissions and recognition programs
- `measurement-*` — Coverage tracking, share of voice, PR metrics
## Core Frameworks
### The Newsworthiness Test
| Factor | Question | Weight |
|--------|----------|--------|
| **Timeliness** | Is it happening now? | High |
| **Impact** | Who does this affect and how many? | High |
| **Proximity** | Is it relevant to this audience? | Medium |
| **Prominence** | Are notable people/companies involved? | Medium |
| **Novelty** | Is this a first, biggest, or unexpected? | High |
| **Conflict** | Does it challenge convention? | Medium |
| **Human Interest** | Is there an emotional story? | Medium |
### The PR Funnel
```
┌─────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ AWARENESS│───▶│ INTEREST │───▶│ COVERAGE │ │
│ │ (Pitch) │ │ (Story) │ │ (Publish)│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ▲ │ │
│ │ ┌──────────┐ │ │
│ └──────────│ AMPLIFY │◀─────────┘ │
│ │ (Share) │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Media Tier Framework
| Tier | Examples | Characteristics | Approach |
|------|----------|-----------------|----------|
| **Tier 1** | TechCrunch, WSJ, NYT | Highest reach, hardest to get | Exclusives, major news only |
| **Tier 2** | VentureBeat, Forbes contributor | Strong reach, more accessible | Embargoes, regular pitching |
| **Tier 3** | Industry publications, podcasts | Niche but influential | Consistent relationship building |
| **Tier 4** | Blogs, newsletters, substacks | Targeted, often undervalued | Direct relationships, content |
### Pitch Response Matrix
| Response | Meaning | Next Action |
|----------|---------|-------------|
| **No response** | Wrong timing, topic, or journalist | Try different angle or wait |
| **"Not for me"** | Wrong beat or outlet | Ask for referral |
| **"Send more info"** | Interest, needs validation | Provide what's asked, quickly |
| **"Not now, maybe later"** | Good relationship, wrong timing | Add to follow-up calendar |
| **"Let's talk"** | Strong interest | Prepare thoroughly, respond fast |
## Relationship Building Principles
### The 10:1 Rule
For every pitch, provide 10 value-adds:
- Share their articles
- Send relevant tips (not for you)
- Make introductions
- Respond to their requests
- Engage meaningfully on social
### Coverage Quality Hierarchy
| Level | Description | Value |
|-------|-------------|-------|
| **Feature story** | In-depth coverage, multiple sources | Highest |
| **News mention** | Coverage of your announcement | High |
| **Expert quote** | Your exec quoted in trend story | Medium-High |
| **Product mention** | Brief mention in roundup/list | Medium |
| **Backlink only** | Link without context | Low |
## Anti-Patterns
- **Spray and pray** — Mass emailing the same pitch to 500 journalists
- **Pitching your press release** — "Please publish our press release" is not news
- **Forgetting the journalist** — They write for readers, not for your company
- **Embargo abuse** — Breaking embargoes burns bridges permanently
- **Metrics theater** — Counting clips instead of measuring impact
- **Crisis silence** — No comment is a comment (usually bad)
- **One-and-done outreach** — PR is relationships, not transactions
- **Overvaluing Tier 1** — Niche coverage often converts better
FILE:rules/analyst-relations.md
---
title: Analyst Relations
impact: MEDIUM-HIGH
tags: analyst-relations, gartner, forrester, industry-analysts
---
## Analyst Relations
**Impact: MEDIUM-HIGH**
Industry analysts influence enterprise buying decisions. A Gartner Magic Quadrant position or Forrester Wave placement can accelerate (or stall) your sales pipeline.
### Major Analyst Firms
| Firm | Focus | Key Outputs | Influence |
|------|-------|-------------|-----------|
| **Gartner** | Enterprise tech, broad | Magic Quadrant, Hype Cycle, Market Guide | Highest for enterprise |
| **Forrester** | Customer-focused tech | Forrester Wave, Total Economic Impact | High, methodology-driven |
| **IDC** | Market sizing, trends | MarketScape, Market Share reports | Strong for market data |
| **451 Research** | Deep tech, emerging | Market Insight, Impact reports | Strong in specific domains |
| **Constellation Research** | Business strategy | ShortList, research notes | Growing influence |
| **Omdia** | Telecom, media, tech | Decision Matrix, forecasts | Domain-specific |
### The AR Program Structure
```
Annual AR Program:
Tier 1: Core analysts (6-10)
├── Quarterly briefings minimum
├── Inquiry access (if client)
├── Report participation priority
└── Event/summit attendance
Tier 2: Secondary analysts (10-15)
├── Semi-annual briefings
├── Major announcement updates
├── Opportunistic engagement
└── Track their coverage
Tier 3: Watchlist (15-20+)
├── Annual or opportunistic briefings
├── Monitor their research
├── Engage when relevant
└── Build for future relevance
```
### Analyst Interaction Types
| Type | What It Is | When to Use | Cost |
|------|------------|-------------|------|
| **Briefing** | You present to them | New product, major news | Free |
| **Inquiry** | You ask them questions | Strategy validation, market intel | Subscription |
| **Advisory** | Deep strategic session | Major decisions, positioning | Premium |
| **Custom research** | Commissioned study | TEI, custom research | $$$ |
| **Speaking** | Present at their events | Visibility, credibility | Event fee |
### Briefing Best Practices
**Before the briefing:**
- Research the analyst's recent coverage
- Review their vendor coverage methodology
- Prepare questions for them (they expect dialogue)
- Time-box your presentation (leave 50%+ for discussion)
**During the briefing:**
- Lead with the customer problem, not your product
- Use specific customer examples and metrics
- Be honest about limitations and roadmap
- Ask about market trends they're seeing
- Listen more than you talk
**After the briefing:**
- Send thank you and follow-up materials
- Ask if they need anything else
- Inquire about upcoming research opportunities
- Log notes and next actions
### Good Briefing Structure (30 minutes)
```
Minutes 1-5: Context
├── Market problem you're solving
├── Why it matters now
└── Your approach (differentiated)
Minutes 5-15: Substance
├── Product/solution overview
├── Customer examples (named if possible)
├── Key metrics and outcomes
└── Roadmap highlights
Minutes 15-25: Discussion
├── Their questions
├── Your questions for them
├── Market dynamics
└── Feedback on positioning
Minutes 25-30: Next steps
├── Upcoming announcements
├── Research participation
└── Follow-up items
```
### Good Briefing Practices
```
✓ "We're seeing customers struggle with [specific problem]. Here's how
we're approaching it differently, and the results we're seeing."
→ Customer-centric, evidence-based
✓ "What trends are you seeing in this space that we should be aware of?"
→ Shows humility, values their expertise
✓ "Here's where we're strong and where we're still developing. Our
roadmap addresses [gap] in Q3."
→ Honest about limitations
✓ "Could you share how you're thinking about the market segmentation
for your upcoming report?"
→ Strategic question, shows engagement
```
### Bad Briefing Practices
```
✗ 45-minute PowerPoint with no time for questions
→ Analysts want dialogue, not lectures
✗ "We're the industry leader in [category]" without proof
→ Unsupported claims damage credibility
✗ Refusing to discuss roadmap or limitations
→ Analysts see through evasiveness
✗ "We don't have any competitors"
→ Naive, instantly loses credibility
✗ Reading slides word-for-word
→ Suggests you don't know your own story
```
### Magic Quadrant / Wave Preparation
For major comparative research:
| Phase | Timeline | Actions |
|-------|----------|---------|
| **Pre-cycle** | 6+ months out | Regular briefings, relationship building |
| **RFI received** | Response deadline | Complete accurately, provide evidence |
| **Demo/briefing** | Scheduled slot | Prepare extensively, practice |
| **Draft review** | Limited window | Check facts, submit corrections |
| **Publication** | Release date | Amplify appropriately, plan responses |
### RFI Best Practices
| Do | Don't |
|----|-------|
| Answer exactly what's asked | Provide tangential information |
| Use specific metrics and evidence | Make vague claims |
| Name reference customers (with permission) | Say "many customers" |
| Acknowledge gaps honestly | Overstate capabilities |
| Submit by deadline | Ask for extensions routinely |
| Proofread carefully | Submit rushed, error-filled responses |
### Positioning for Analyst Reports
| Position | What It Means | Strategy |
|----------|---------------|----------|
| **Leader** | Strong execution + vision | Maintain, don't get complacent |
| **Challenger** | Strong execution, evolving vision | Invest in innovation story |
| **Visionary** | Strong vision, building execution | Focus on proof points |
| **Niche Player** | Focused, specific segment | Own your niche, expand strategically |
### Inquiry Best Practices
When using inquiry time:
```
Good inquiry questions:
✓ "How should we position against [competitor] for enterprise deals?"
✓ "What's missing from our messaging for [buyer persona]?"
✓ "What objections are you hearing about vendors in our space?"
✓ "How is the market thinking about [emerging trend]?"
Bad inquiry questions:
✗ "Will you include us in the Magic Quadrant?"
✗ "Can you tell us what our competitors are doing?"
✗ "Will you be a reference for us?"
✗ "Can you write about us?"
```
### Building Analyst Relationships
| Action | Frequency | Purpose |
|--------|-----------|---------|
| **Regular briefings** | Quarterly | Stay top of mind |
| **Quick updates** | As needed | Major news, not everything |
| **Event engagement** | Annual | Face time, deeper conversation |
| **Customer introductions** | When relevant | Proof points for their research |
| **Thought leadership share** | Monthly | Demonstrate expertise |
### Measuring AR Success
| Metric | What It Indicates |
|--------|-------------------|
| **Report inclusion** | Market recognition |
| **Position improvement** | Execution on strategy |
| **Inquiry mentions** | Consideration in deals |
| **Quote requests** | Thought leadership recognition |
| **Win rate lift** | Sales impact |
| **Briefing requests (inbound)** | Analyst interest |
### Working with Sales on AR
| AR Provides Sales | Sales Provides AR |
|-------------------|-------------------|
| Report summaries and talking points | Competitive intel from deals |
| Analyst quotes for proposals | Customer reference candidates |
| Guidance on positioning | Feedback on analyst influence |
| Alert on negative coverage | Win/loss context |
### Anti-Patterns
- **Spray and brief** — Briefing every analyst wastes everyone's time
- **Pay to play expectations** — Subscriptions don't guarantee coverage
- **Arguing with analysts** — Disagreement is fine, hostility backfires
- **One and done** — Single briefing before report won't move position
- **Ignoring negative feedback** — Analysts remember who listens
- **Overselling to analysts** — They fact-check, exaggeration hurts
- **Treating inquiry as sales pitch** — They're advising, not buying
- **Silence after reports** — Continue relationship regardless of outcome
FILE:rules/awards-submissions.md
---
title: Award Submissions
impact: MEDIUM
tags: awards, recognition, credibility, social-proof
---
## Award Submissions
**Impact: MEDIUM**
Awards provide third-party validation, content for marketing, and employee pride. Strategic award participation maximizes return on the effort invested.
### Award Categories
| Category | Examples | Value | Effort |
|----------|----------|-------|--------|
| **Industry awards** | SaaS Awards, Cloud 100, Deloitte Fast 500 | High credibility | High |
| **Publication awards** | Inc 5000, Forbes lists, Fast Company | High visibility | Medium-High |
| **Best workplace** | Great Place to Work, Glassdoor | Employer brand, recruiting | Medium |
| **Product awards** | G2 Best Of, Capterra Top, TrustRadius | Sales enablement | Low-Medium |
| **Regional/local** | Local business awards, tech council | Community presence | Low |
| **Executive awards** | 40 Under 40, Women in Tech | Personal brand | Medium |
### Award Selection Framework
Score each potential award:
| Factor | Weight | Questions |
|--------|--------|-----------|
| **Relevance** | High | Does our target audience recognize this award? |
| **Credibility** | High | Is this a respected program? |
| **Visibility** | Medium | How is the award promoted and covered? |
| **Effort** | Medium | What's required to submit and win? |
| **Cost** | Low | Entry fees, event attendance, etc. |
| **Competition** | Low | Who else is competing? |
### Annual Award Calendar
```
Q1 Planning:
├── Identify target awards for the year
├── Map deadlines and requirements
├── Assign owners and reviewers
└── Gather baseline materials
Q2-Q3 Execution:
├── Submit applications on schedule
├── Prepare customer references
├── Track submission status
└── Plan win/loss announcements
Q4 Review:
├── Assess win rate and impact
├── Gather feedback on losses
├── Update materials library
└── Plan next year's targets
```
### Building an Award-Ready Library
Maintain ready-to-use components:
| Asset | Purpose | Update Frequency |
|-------|---------|------------------|
| **Company boilerplate** | Standard description | Quarterly |
| **Key metrics** | Revenue growth, customer count, etc. | Quarterly |
| **Customer quotes** | Testimonials by use case | Ongoing |
| **Case studies** | Detailed success stories | Quarterly |
| **Executive bios** | For individual awards | Annually |
| **Product differentiators** | What makes you unique | Semi-annually |
| **Awards won** | List of past recognition | Ongoing |
### Writing Winning Submissions
| Element | Good | Bad |
|---------|------|-----|
| **Opening** | Specific hook, immediate differentiation | Generic company description |
| **Metrics** | "147% YoY growth, 500+ customers" | "Significant growth" |
| **Differentiation** | Specific, provable claims | "Industry-leading," "best-in-class" |
| **Customer impact** | Named customers, specific outcomes | "Customers love us" |
| **Innovation** | What's new, why it matters | "We use AI" |
| **Proof points** | Third-party validation, data | Unsupported claims |
### Good Submission Example
```
[Opening]
When CircuitBoard launched in 2021, enterprise security teams spent an
average of 47 hours per week manually reviewing access logs. Today, our
500+ customers have reclaimed 2.3 million hours annually—time now spent
on strategic security work instead of log analysis.
[Differentiation]
Unlike traditional SIEM tools that require months of configuration,
CircuitBoard deploys in under an hour and begins surfacing actionable
insights immediately. Our patent-pending behavioral analysis identifies
threats that rule-based systems miss, reducing false positives by 89%
compared to legacy solutions.
[Customer Impact]
"CircuitBoard caught an insider threat that had bypassed our other tools
for six months. The ROI was immediate." — CISO, Fortune 500 Financial
Services Company
[Metrics]
• 147% year-over-year revenue growth
• 500+ enterprise customers including 23 Fortune 500 companies
• $0 spent on paid acquisition (100% organic + referral growth)
• 98% customer retention rate
```
### Bad Submission Example
```
CircuitBoard is a leading provider of innovative security solutions
for the modern enterprise. Our cutting-edge platform leverages advanced
AI and machine learning to deliver best-in-class threat detection.
We are committed to excellence and customer success. Our team of world-
class engineers has built a revolutionary product that is transforming
the security industry.
Many customers have experienced significant improvements after
implementing our solution. We are proud of our rapid growth and
industry recognition.
```
### What's Wrong With the Bad Example
| Problem | Fix |
|---------|-----|
| "Leading provider" | Prove it with data |
| "Innovative," "cutting-edge," "revolutionary" | Show, don't tell |
| "Advanced AI" | What does it actually do? |
| "Committed to excellence" | Meaningless |
| "Many customers" | How many? Name them |
| "Significant improvements" | Quantify the improvement |
| No specific metrics | Add numbers everywhere possible |
### Customer References for Awards
When requesting customer support:
```
Template:
"Hi [Name],
We're applying for [Award Name], which recognizes [criteria]. Given the
results you've achieved with [Product], would you be willing to:
[ ] Provide a brief quote (I can draft for your review)
[ ] Be listed as a reference customer
[ ] Participate in a brief judge interview (if we're shortlisted)
The time commitment is minimal—I'd handle all the heavy lifting. This
helps us build credibility that benefits all our customers.
Let me know if you're open to it!
Best,
[Name]"
```
### Award Announcement Strategy
**If you win:**
| Channel | Content | Timing |
|---------|---------|--------|
| **Press release** | Formal announcement | Day of or next day |
| **Social media** | Celebration post with badge | Same day |
| **Email to customers** | Thank them for contribution | Same week |
| **Website** | Add logo to homepage, awards page | Same week |
| **Sales enablement** | Update decks, one-pagers | Same week |
| **Internal** | Company-wide celebration | Same day |
**If you're a finalist (but don't win):**
- Still announce finalist status (valuable)
- Engage at awards event
- Network with other finalists
- Note the recognition in marketing
**If you lose:**
- Request feedback if available
- Note lessons for next submission
- No public mention needed
- Improve and resubmit next cycle
### Award ROI Calculation
| Cost Component | Typical Range |
|----------------|---------------|
| Entry fee | $0 - $500 |
| Staff time (submission) | 4-20 hours |
| Event attendance | $500 - $5,000 |
| Additional materials | $0 - $1,000 |
| Value Component | How to Measure |
|-----------------|----------------|
| PR coverage | Media mentions, reach |
| Social proof | Use in sales materials |
| Employee morale | Engagement, pride |
| Recruiting | Employer brand lift |
| Website credibility | Trust signals |
### Award Logos and Usage
| Do | Don't |
|----|-------|
| Use official provided logos | Modify award logos |
| Follow usage guidelines | Claim awards you didn't win |
| Include the year | Use outdated awards prominently |
| Link to verification | Misrepresent category or level |
| Display appropriately sized | Overshadow your own brand |
### Timeline for Major Awards
| Award Type | Typical Timeline |
|------------|------------------|
| **Revenue-based lists** | Nomination Q4, announced Q1 |
| **Product awards** | Various, often quarterly cycles |
| **Workplace awards** | Survey-based, multi-month process |
| **Industry awards** | Often tied to conferences |
| **Publication lists** | Annual cycle, varied deadlines |
### Anti-Patterns
- **Applying to everything** — Low-value awards dilute the wins that matter
- **Pay-to-play "awards"** — Verify legitimacy before entering
- **Exaggerating in submissions** — Judges fact-check, lies backfire
- **Missing deadlines** — No extensions, mark calendars early
- **No follow-through** — Winning without announcing wastes the win
- **Ignoring finalist status** — Being shortlisted is still valuable
- **Same submission everywhere** — Tailor to each award's criteria
- **Forgetting internal celebration** — Awards boost morale when shared
FILE:rules/content-press-release.md
---
title: Press Release Writing
impact: CRITICAL
tags: press-release, content, announcements, news
---
## Press Release Writing
**Impact: CRITICAL**
A press release is a formatted announcement, not a story. Its job is to give journalists the facts they need to decide if there's a story worth writing.
### Press Release Structure
| Section | Purpose | Length |
|---------|---------|--------|
| **Headline** | Capture attention, convey news | 10-15 words |
| **Subheadline** | Add context or key detail | 15-20 words |
| **Dateline** | Location and date | City, State — Month Day, Year |
| **Lead paragraph** | Who, what, when, where, why | 2-3 sentences |
| **Body paragraphs** | Details, context, significance | 2-4 paragraphs |
| **Quote 1** | Executive perspective (vision) | 2-3 sentences |
| **Quote 2** | Customer/partner validation | 2-3 sentences |
| **Boilerplate** | Company description | 3-4 sentences |
| **Media contact** | PR contact information | Name, email, phone |
### The Lead Paragraph Formula
```
[Company name] today announced [what happened], enabling [who benefits]
to [key benefit]. The [product/partnership/milestone] represents [why
it matters] and will be available [when/how].
```
### Good Press Release Headlines
```
✓ "Acme Raises $50M Series B to Expand AI-Powered DevOps Platform"
→ Clear news (funding), specific amount, what it enables
✓ "Acme Launches Open-Source Alternative to Terraform, Backed by 10,000 GitHub Stars"
→ News (launch), differentiation (open-source), social proof (stars)
✓ "Acme Partners with AWS to Bring Zero-Trust Security to Enterprise Cloud"
→ Notable partner, specific technology, target market
✓ "Acme Report: 73% of Engineering Teams Lack Visibility Into CI/CD Costs"
→ Data-driven, specific percentage, clear topic
```
### Bad Press Release Headlines
```
✗ "Acme Announces Exciting New Product"
→ Vague, no news value, "exciting" is opinion
✗ "Acme: Revolutionizing the Future of Work with Innovative Solutions"
→ Buzzword soup, says nothing specific
✗ "Press Release: Acme Inc."
→ Literally no information
✗ "Acme Continues to Lead the Industry"
→ Self-congratulatory, not news
```
### Writing Effective Quotes
Quotes should sound human, not like legal approved them into oblivion.
| Good Quote Characteristics | Bad Quote Characteristics |
|---------------------------|--------------------------|
| Adds perspective not in body | Repeats what's already written |
| Sounds like spoken words | Reads like a legal document |
| Expresses vision or emotion | Generic corporate platitudes |
| Specific to this announcement | Could apply to any company |
### Good Quote Examples
```
✓ "When we started Acme, developers told us they spent 30% of their time
on infrastructure instead of building products. This funding lets us
finally solve that at scale."
→ Specific, origin story, connects to news
✓ "We evaluated six vendors, and Acme was the only one that could handle
our 10,000+ microservices without requiring a dedicated team."
→ Third-party validation, specific proof point
✓ "This isn't just a product launch—it's our answer to the security
theater we've all been putting up with for a decade."
→ Opinionated, memorable, positions against status quo
```
### Bad Quote Examples
```
✗ "We are thrilled to announce this exciting partnership that will
deliver tremendous value to our customers."
→ Generic, could be any company, any announcement
✗ "Acme is committed to innovation and excellence in everything we do."
→ Empty platitude, not specific to news
✗ "This strategic initiative aligns with our mission to leverage
synergies and drive digital transformation."
→ Buzzword bingo, meaningless
```
### Boilerplate Template
```
About [Company Name]
[Company] [what you do in one sentence]. Founded in [year], the company
[key differentiation or approach]. [Company] is trusted by [customer
proof: names, numbers, or categories] and has [notable achievement:
funding, growth, recognition]. [Company] is headquartered in [location]
with [team size or office presence]. For more information, visit
[website] or follow [@handle] on [platform].
```
### News Value Checklist
Before sending a press release, verify:
- [ ] Is this actually news? (Something happened, not "we exist")
- [ ] Would a journalist's reader care about this?
- [ ] Is there a specific number, date, or name?
- [ ] Is this timely? (Happening now or very soon)
- [ ] Is there a credible third-party voice?
- [ ] Does the headline convey the news?
- [ ] Can I explain why this matters in one sentence?
### Distribution Timing
| Day | Effectiveness | Why |
|-----|---------------|-----|
| **Tuesday** | Best | Journalists past Monday backlog |
| **Wednesday** | Good | Solid mid-week attention |
| **Thursday** | Moderate | Friday approaches, attention fades |
| **Monday** | Low | Inbox overload from weekend |
| **Friday** | Avoid | Weekend burial unless intentional |
**Time:** 6-8am ET for business press, allows full news day
### Press Release Types
| Type | When to Use | Key Elements |
|------|-------------|--------------|
| **Product launch** | New product or major feature | Demo access, pricing, availability |
| **Funding** | Investment round closes | Amount, investors, use of funds |
| **Partnership** | Strategic alliance | Both parties quoted, joint value |
| **Executive hire** | C-suite addition | Background, why they joined |
| **Milestone** | Users, revenue, growth metric | Specific numbers, context |
| **Research/data** | Original findings | Methodology, key stats, full report |
| **Acquisition** | M&A announcement | Terms (if disclosed), integration |
| **Award/recognition** | Industry recognition | Criteria, competition, quote from org |
### Anti-Patterns
- **Burying the lead** — News in paragraph 3, fluff in paragraph 1
- **Quote-as-announcement** — Putting the actual news only in a quote
- **Jargon overload** — "Leveraging AI-powered blockchain synergies"
- **Length without substance** — 1,000 words when 400 would do
- **Missing the "so what"** — What happened, but not why it matters
- **Fake exclusivity** — "Industry-first" when it's not
- **Quote-stuffing** — Five executives all saying the same thing
- **Embargo-only distribution** — No follow-up after embargo lifts
FILE:rules/crisis-communication.md
---
title: Crisis Communication
impact: CRITICAL
tags: crisis, reputation, response, damage-control
---
## Crisis Communication
**Impact: CRITICAL**
In a crisis, you have hours—not days—to shape the narrative. How you respond in the first 24 hours determines whether you recover or become a cautionary tale.
### Crisis Severity Levels
| Level | Examples | Response Time | Who Leads |
|-------|----------|---------------|-----------|
| **Level 1: Low** | Minor customer complaint, small factual error | 24-48 hours | PR team |
| **Level 2: Medium** | Product bug, service outage, employee incident | 4-24 hours | PR + Leadership |
| **Level 3: High** | Security breach, exec misconduct, legal action | 1-4 hours | C-suite + Legal + PR |
| **Level 4: Severe** | Death/injury, regulatory action, existential threat | Immediate | CEO + Board + Crisis team |
### The First 4 Hours
```
Hour 1: Assess
├── What happened? (facts only, no speculation)
├── Who is affected?
├── What's the current state?
├── What don't we know yet?
└── Who needs to be in the room?
Hour 2: Align
├── Brief leadership and legal
├── Determine what can be said now
├── Identify spokesperson
├── Draft initial holding statement
└── Prepare for employee communication
Hour 3: Act
├── Publish holding statement
├── Respond to journalist inquiries
├── Activate social monitoring
├── Brief customer-facing teams
└── Document everything
Hour 4: Adjust
├── Gather new information
├── Update statement if needed
├── Track coverage and sentiment
├── Plan next communication cycle
└── Begin root cause investigation
```
### The Holding Statement
When you need to respond but don't have full information:
```
Template:
"We're aware of [situation] and are actively investigating. [What you're
doing about it]. We take this seriously and will provide updates as we
learn more. [If customers affected: Here's what you should do / how to
reach us.]"
Example:
"We're aware of reports that customer data may have been accessed through
our API. We're actively investigating with our security team. We take
data security extremely seriously and will provide a full update within
24 hours. Affected customers can reach our support team at [contact] for
immediate assistance."
```
### Good Crisis Response
```
✓ "We made a mistake. Here's what happened, what we're doing to fix it,
and how we're making sure it doesn't happen again."
→ Takes responsibility, shows action, commits to change
✓ "We're aware and investigating. We don't have all the facts yet, but
we'll share what we learn within [timeframe]."
→ Honest about uncertainty, commits to timeline
✓ "To our customers: [Specific apology]. Here's exactly what this means
for you and what we're doing about it."
→ Addresses affected parties directly
```
### Bad Crisis Response
```
✗ "We can neither confirm nor deny..."
→ Sounds like you're hiding something
✗ "This was an isolated incident"
→ Minimizing before you know the full scope
✗ "We take privacy seriously" [with no specifics]
→ Empty phrase that means nothing
✗ Complete silence hoping it blows over
→ Vacuum fills with speculation
✗ "Our lawyers advised us not to comment"
→ You just commented, and badly
✗ Blaming the victim/customer
→ Never, ever do this
```
### Crisis Communication Principles
| Principle | Application |
|-----------|-------------|
| **Speed beats perfection** | A good statement now beats a perfect one tomorrow |
| **Own the narrative** | You tell the story, or someone else will |
| **Transparency builds trust** | Admit what you know and don't know |
| **Actions over words** | What you do matters more than what you say |
| **One voice** | Designate spokesperson, align all communications |
| **Think long-term** | How will this response look in 6 months? |
### Statement Structure
| Section | Purpose | Example |
|---------|---------|---------|
| **Acknowledgment** | Show you're aware | "We're aware that..." |
| **Facts** | What happened (confirmed only) | "On [date], [specific event]" |
| **Impact** | Who's affected, how | "[X] customers may be impacted" |
| **Response** | What you're doing | "We've [actions taken]" |
| **Commitment** | What happens next | "We will [future actions]" |
| **Resources** | How to get help/info | "Contact [support] or visit [page]" |
### Spokesperson Guidelines
| Do | Don't |
|----|-------|
| Speak in first person ("we") | Deflect to "they" or "the company" |
| Acknowledge the human impact | Focus only on business impact |
| Stay calm and measured | Get defensive or emotional |
| Say "I don't know yet" if true | Speculate or guess |
| Commit to follow-up timing | Make promises you can't keep |
| Prepare for tough questions | Wing it |
### Media Inquiry Response Matrix
| Inquiry Type | Response Approach |
|--------------|-------------------|
| **Breaking news** | Holding statement, promise timeline |
| **Follow-up questions** | Answer what you can, "investigating" for rest |
| **Requests for interview** | CEO for Level 3-4, spokesperson for 1-2 |
| **Hostile/gotcha** | Stick to facts, don't take bait |
| **Background request** | Help journalists understand context |
### Internal vs External Communication
| Audience | Timing | Content |
|----------|--------|---------|
| **Employees** | Before or same time as external | Full context, what to say if asked |
| **Customers** | Immediately if affected | Impact, what to do, how to reach you |
| **Media** | Within hours | Statement, spokesperson availability |
| **Investors** | Same day for material issues | Business impact, response plan |
| **Partners** | Same day | How it affects them, what you're doing |
### Social Media During Crisis
| Platform | Approach |
|----------|----------|
| **Twitter/X** | Monitor constantly, respond to direct questions |
| **LinkedIn** | Post official statement, limit engagement |
| **Comments** | Acknowledge, don't argue, point to official statement |
| **DMs** | Route to official channels |
### Crisis Timeline Template
```
T+0: Incident occurs
├── T+30min: Internal alert, war room assembled
├── T+1hr: Leadership briefed, initial assessment
├── T+2hr: Holding statement published
├── T+4hr: First detailed update (if available)
├── T+24hr: Full statement with root cause (if known)
├── T+48hr: Remediation update
├── T+1wk: Post-mortem or detailed report
└── T+30d: Review and process improvements
```
### Post-Crisis Actions
| Timeframe | Actions |
|-----------|---------|
| **Week 1** | Full post-mortem, document learnings |
| **Week 2-4** | Implement immediate changes, update processes |
| **Month 2-3** | Rebuild affected relationships, proactive outreach |
| **Month 6** | Audit changes, assess reputation recovery |
### Crisis Preparedness Checklist
Before a crisis happens:
- [ ] Crisis communication plan documented
- [ ] Spokesperson trained on media interviews
- [ ] Holding statement templates ready
- [ ] Escalation matrix defined
- [ ] Legal review process established
- [ ] Social monitoring tools active
- [ ] Employee communication channels tested
- [ ] Key journalist relationships maintained
- [ ] Dark site or status page ready to activate
### Anti-Patterns
- **No comment** — Always reads as guilty
- **Deleting evidence** — Screenshots exist, it will get worse
- **Blame shifting** — "The vendor" or "a rogue employee"
- **Lawyer-speak** — Legal CYA language sounds evasive
- **Over-promising** — "This will never happen again"
- **Under-communicating** — One statement, then silence
- **Defensive posture** — Treating journalists as enemies
- **Declaring victory early** — "Crisis is over" while it's ongoing
- **Forgetting stakeholders** — Employees learn from news
- **No post-mortem** — Missing the learning opportunity
FILE:rules/measurement-pr-metrics.md
---
title: PR Measurement and Metrics
impact: HIGH
tags: measurement, metrics, analytics, roi, coverage-tracking
---
## PR Measurement and Metrics
**Impact: HIGH**
"You can't manage what you don't measure" applies to PR—but measuring the wrong things leads to the wrong priorities. Focus on metrics that connect to business outcomes.
### The PR Measurement Framework
| Level | Metrics | What It Tells You |
|-------|---------|-------------------|
| **Output** | Press releases sent, pitches made | Activity (effort) |
| **Outtake** | Coverage volume, reach | Media response |
| **Outcome** | Traffic, leads, sentiment | Business impact |
| **Impact** | Revenue, brand value | Ultimate value |
### Coverage Metrics
| Metric | Definition | When It Matters |
|--------|------------|-----------------|
| **Coverage volume** | Number of articles/mentions | Awareness campaigns |
| **Coverage quality** | Tier/reach of outlets | Brand building |
| **Share of voice** | Your mentions vs competitors | Competitive position |
| **Message pull-through** | Key messages in coverage | Message strategy |
| **Spokesperson quotes** | Exec quoted in articles | Thought leadership |
| **Sentiment** | Positive/neutral/negative tone | Reputation health |
### Coverage Quality Scoring
| Quality Factor | Score | Criteria |
|----------------|-------|----------|
| **Publication tier** | 1-5 | Tier 1 = 5, Tier 4 = 1 |
| **Article prominence** | 1-3 | Feature = 3, Mention = 1 |
| **Message inclusion** | 1-3 | All key messages = 3 |
| **Spokesperson quote** | 0-2 | Named quote = 2, No quote = 0 |
| **Sentiment** | -2 to +2 | Positive = +2, Negative = -2 |
**Coverage Quality Score** = Sum of factors × Estimated reach weight
### Good Metrics Reporting
```
Q3 PR Report
Coverage Summary:
├── 47 total placements (up 23% QoQ)
├── 12 Tier 1 placements (including TechCrunch, VentureBeat)
├── 89% positive/neutral sentiment
└── 73% message pull-through rate
Share of Voice:
├── Category SOV: 34% (up from 28% in Q2)
├── Competitor A: 29%
├── Competitor B: 22%
└── Others: 15%
Business Impact:
├── 14,200 referral visits from coverage
├── 340 trial signups attributed to PR
├── 3 enterprise opportunities sourced from coverage
└── Estimated media value: $892,000
Key Wins:
├── TechCrunch exclusive on Series B (12k visits)
├── Forbes contributor piece on CEO (thought leadership)
└── WSJ quote in industry trend piece
```
### Bad Metrics Reporting
```
✗ "We got 50 pieces of coverage this quarter!"
→ Volume without quality is meaningless
✗ "Our AVE (Advertising Value Equivalency) was $5M"
→ Discredited metric, doesn't reflect actual value
✗ "We sent 200 pitches this month"
→ Activity metric, not results metric
✗ "Sentiment was 60% positive"
→ Compared to what? What drove negative?
```
### Share of Voice (SOV) Tracking
```
SOV Calculation:
Your brand mentions / Total category mentions × 100
Tracking approach:
├── Define competitive set (5-10 companies)
├── Set monitoring keywords (brand names, variations)
├── Choose time period (monthly, quarterly)
├── Filter for relevant coverage (exclude noise)
└── Calculate and trend over time
SOV by segment:
├── Overall SOV: All coverage
├── Tier 1 SOV: Top-tier publications only
├── Topic SOV: Specific themes (e.g., "AI security")
└── Geography SOV: Regional breakdown
```
### Message Pull-Through Analysis
Tracking whether coverage includes your key messages:
| Key Message | Definition | Q3 Pull-Through |
|-------------|------------|-----------------|
| "Zero-trust native" | References our ZT architecture | 67% |
| "Enterprise scale" | Mentions Fortune 500 or enterprise | 54% |
| "Developer-first" | Highlights developer experience | 78% |
| "Fastest deployment" | Mentions speed/time to value | 43% |
**Action:** "Fastest deployment" underperforming—prioritize in pitches.
### Sentiment Tracking
| Sentiment | Indicators | Response |
|-----------|------------|----------|
| **Positive** | Praise, recommendations, success stories | Amplify, repurpose |
| **Neutral** | Factual coverage, balanced reporting | Acceptable baseline |
| **Negative** | Criticism, problems, complaints | Investigate, respond |
| **Mixed** | Both positive and negative elements | Analyze what's driving each |
### Attribution: Connecting PR to Business
```
Attribution methods:
Direct attribution:
├── UTM-tagged links in coverage
├── Dedicated landing pages mentioned in articles
├── "How did you hear about us?" surveys
└── First-touch attribution in CRM
Correlation analysis:
├── Traffic spikes aligned with coverage dates
├── Brand search volume increases
├── Social mention velocity
└── Pipeline creation timing
```
### PR Dashboard Components
| Section | Metrics | Update Frequency |
|---------|---------|------------------|
| **Coverage overview** | Volume, tier breakdown, trend | Weekly |
| **Share of voice** | SOV vs competitors | Monthly |
| **Sentiment summary** | Positive/neutral/negative split | Weekly |
| **Message pull-through** | Key message % in coverage | Monthly |
| **Business impact** | Traffic, leads, pipeline | Monthly |
| **Activity** | Pitches sent, briefings held | Weekly |
### Tools for PR Measurement
| Category | Tools | Use Case |
|----------|-------|----------|
| **Media monitoring** | Meltwater, Cision, Mention | Coverage tracking |
| **Social listening** | Brandwatch, Sprout Social | Sentiment, SOV |
| **Web analytics** | Google Analytics, Mixpanel | Traffic attribution |
| **CRM** | Salesforce, HubSpot | Pipeline attribution |
| **Custom tracking** | UTM + spreadsheets | Low-cost attribution |
### Benchmarks for PR Metrics
| Metric | Average | Good | Excellent |
|--------|---------|------|-----------|
| **Pitch response rate** | 5-10% | 15-20% | 25%+ |
| **Coverage conversion** | 2-5% | 8-12% | 15%+ |
| **Positive sentiment** | 60-70% | 75-85% | 90%+ |
| **Message pull-through** | 40-50% | 60-70% | 80%+ |
| **Tier 1 coverage** | 10-15% | 20-30% | 35%+ |
### Monthly PR Report Template
```
[Month] PR Performance Report
Executive Summary:
[2-3 sentences on key performance and notable coverage]
Coverage Metrics:
• Total placements: [X]
• Tier 1/2/3 breakdown: [X/X/X]
• Share of voice: [X]% (vs [X]% last month)
• Sentiment: [X]% positive
Key Coverage Highlights:
1. [Outlet]: [Headline] - [Why it matters]
2. [Outlet]: [Headline] - [Why it matters]
3. [Outlet]: [Headline] - [Why it matters]
Business Impact:
• Referral traffic from coverage: [X] visits
• PR-attributed signups/leads: [X]
• Pipeline influence: [X opportunities]
Activity Summary:
• Pitches sent: [X]
• Briefings conducted: [X]
• Press releases issued: [X]
Upcoming:
• [Planned announcement/launch]
• [Target coverage opportunities]
```
### What NOT to Measure
| Vanity Metric | Why It's Misleading |
|---------------|---------------------|
| **AVE (Ad Value Equivalency)** | Discredited—editorial isn't advertising |
| **Raw impression counts** | Usually inflated, not meaningful |
| **Total potential reach** | "Could have" seen, not "did" see |
| **Pitch volume** | Activity without results |
| **Social follower counts** | Vanity without engagement context |
### Proving PR ROI
For executive reporting:
```
PR Investment: $X (team + tools + agency)
Measurable Returns:
├── [X] enterprise leads attributed to coverage
├── $[X]M pipeline influenced by PR touchpoints
├── [X]% increase in brand search volume
├── [X]% positive shift in analyst sentiment
└── [X] speaking opportunities generated
Estimated Media Value: $[X] (with caveats about methodology)
Qualitative Value:
├── Market positioning as category leader
├── Competitive differentiation in deals
├── Executive visibility and credibility
└── Recruiting advantage
```
### Anti-Patterns
- **Counting clips only** — 100 low-quality mentions < 5 Tier 1 features
- **AVE obsession** — Industry has abandoned this metric
- **Ignoring negative coverage** — Hiding problems doesn't fix them
- **No baseline** — Can't show improvement without starting point
- **Activity over outcomes** — "We pitched 500 journalists" isn't success
- **Delayed reporting** — Monthly metrics should be ready within a week
- **No competitive context** — Your numbers only matter relative to market
- **Vanity dashboards** — Pretty charts that don't drive decisions
FILE:rules/media-journalist-relationships.md
---
title: Journalist Relationship Building
impact: HIGH
tags: media-relations, relationships, networking, sources
---
## Journalist Relationship Building
**Impact: HIGH**
The best PR doesn't come from pitching—it comes from being the person journalists call when they need a source. Relationships are built before you need them.
### The Relationship Spectrum
| Level | Characteristics | Your Value to Them |
|-------|-----------------|-------------------|
| **Cold** | Never interacted | None yet |
| **Warm** | Engaged with their content | Audience member |
| **Connected** | Exchanged messages | Potential source |
| **Trusted** | Provided useful info/sources | Reliable resource |
| **Go-to** | They reach out proactively | Expert on speed dial |
### The 10:1 Value Ratio
For every pitch you send, provide 10 value-adds:
| Value-Add | Example | Effort |
|-----------|---------|--------|
| Share their article | Thoughtful LinkedIn share with commentary | Low |
| Send relevant tip | "Saw this, thought of your beat" | Low |
| Make introduction | Connect them to a source they need | Medium |
| Respond to requests | HARO, Twitter asks, source requests | Medium |
| Provide background | Off-record context for their story | Medium |
| Early access | Let them try products before launch | Medium |
| Exclusive data | Share research before it's public | High |
| Source for others | Connect when you're not the story | High |
### Building Relationships Before You Need Them
```
Timeline: 3-6 months before you have news
Month 1-2:
├── Identify 20-30 journalists covering your space
├── Follow them on Twitter/LinkedIn
├── Read their recent articles (actually read them)
└── Start engaging genuinely with their content
Month 2-3:
├── Share their work with thoughtful commentary
├── Respond to source requests (even if not for you)
├── Send occasional tips without asking for anything
└── Begin light DM/email conversations
Month 3-6:
├── Offer to be a background source
├── Make introductions to people they should know
├── Build genuine rapport around shared interests
└── You're now someone they recognize and might respond to
```
### Good Relationship Building
```
✓ "Saw your thread on AI governance—this Senate hearing transcript
might be useful for your next piece. No need to credit, just
thought you'd want to see it."
→ Provides value, no ask, shows you pay attention
✓ "Re: your request for CISO sources—I'm not the right fit but my
friend [Name] at [Company] deals with this daily. Want an intro?"
→ Helpful even when you can't benefit
✓ "Your piece on [topic] changed how I think about [X]. Specifically
[detailed observation]. Looking forward to the follow-up you
mentioned."
→ Shows genuine engagement, not just flattery
```
### Bad Relationship Building
```
✗ "Loved your article! Would you like to write about my company?"
→ Transparent flattery-to-pitch pipeline
✗ "I've been following your work for years..." [first interaction]
→ Obvious lie, credibility destroyed
✗ "Let me know if I can ever be helpful" [no specific offer]
→ Vague, puts burden on them
✗ Liking every tweet without ever adding substance
→ Looks automated, provides no value
```
### Journalist Communication Preferences
| Platform | Best For | Etiquette |
|----------|----------|-----------|
| **Email** | Formal pitches, detailed info | Professional, concise |
| **Twitter/X DM** | Quick tips, casual connection | Brief, low pressure |
| **LinkedIn** | Professional connection | Don't pitch immediately |
| **Signal/Text** | Breaking news, urgent tips | Only if relationship warrants |
| **In-person** | Events, conferences | Respect their time |
### The Background Source Strategy
Offering to be a "background source" builds trust without requiring coverage.
```
"I'm not pitching anything—just offering to be a background source
if you ever need context on [topic]. Happy to talk off the record
about industry dynamics, explain technical concepts, or point you
toward the right people to interview."
```
Benefits:
- Builds relationship without transaction
- Positions you as expert
- Creates natural opportunities for future coverage
- Journalists remember who helped them understand complex topics
### Responding to Source Requests
HARO (Help a Reporter Out), Twitter requests, and journalist queries:
| Do | Don't |
|----|-------|
| Respond within hours, not days | Wait until it's no longer relevant |
| Answer exactly what's asked | Provide tangential information |
| Be quotable and specific | Give vague, hedge-filled responses |
| Include credentials briefly | Send full bio and company overview |
| Offer to elaborate if helpful | Demand to review quotes |
### Good Source Response
```
Subject: Re: Source needed: DevOps security trends
Hi Alex,
For your DevOps security piece:
Key insight: "The biggest shift we're seeing is secrets management
moving from 'we'll figure it out' to board-level concern. After the
CircleCI breach, every CISO I talk to has it on their 2024 priority
list."
Supporting data: 73% of our enterprise customers implemented secrets
rotation in 2023, up from 31% in 2022.
Happy to elaborate or provide customer references if helpful.
[Name], CEO @ Acme (YC '22, ex-Google Security)
```
### Maintaining Relationships After Coverage
| Timing | Action |
|--------|--------|
| **Same day** | Thank them (not effusively, professionally) |
| **Same week** | Share the article with your audience, tag them |
| **Ongoing** | Continue providing value unrelated to your company |
| **Never** | Ask them to change something after publication |
### Relationship Tracking
Maintain a simple system:
| Field | Purpose |
|-------|---------|
| Name | Journalist name |
| Outlet | Current publication |
| Beat | What they cover |
| Last interaction | When you last connected |
| Notes | Personal details, preferences |
| Relationship level | Cold/Warm/Connected/Trusted |
| Next action | What value can you provide? |
### When Journalists Move
Journalists change jobs frequently. When they do:
```
✓ "Congrats on the new role at [outlet]! Looking forward to your
coverage there. Let me know if I can ever be useful on
[relevant topic]."
→ Brief, genuine, maintains relationship
✗ "Now that you're at [bigger outlet], would you be interested in
covering [your company]?"
→ Opportunistic, obvious motive
```
### Anti-Patterns
- **Transactional only** — Only reaching out when you need something
- **Fake familiarity** — "It's been too long!" when you've never met
- **Guilt tripping** — "I've shared so much, can you cover us?"
- **Bridging to pitch** — Pretending to offer value, then pivoting to ask
- **Ignoring their beat** — Pitching AI to a healthcare writer
- **Public pressure** — Tagging journalists asking why they haven't covered you
- **Over-communication** — More than 1-2 touches per month without reason
- **Forgetting the human** — Journalists are people with interests beyond their beat
FILE:rules/media-pitch-crafting.md
---
title: Media Pitch Crafting
impact: CRITICAL
tags: pitching, media-outreach, journalist, email
---
## Media Pitch Crafting
**Impact: CRITICAL**
Journalists receive 50-200 pitches daily. Most get deleted in seconds. A great pitch earns a response in 3-5 seconds of scanning.
### Pitch Anatomy
| Component | Purpose | Character Limit |
|-----------|---------|-----------------|
| **Subject line** | Earn the open | 50-60 chars |
| **Hook** | Why you, why now | 1-2 sentences |
| **The story** | What's newsworthy | 2-3 sentences |
| **The ask** | What you want | 1 sentence |
| **Credentials** | Why you're credible | 1-2 sentences |
| **Sign-off** | Professional close | 1 sentence |
### Subject Line Formulas
| Formula | Example | When to Use |
|---------|---------|-------------|
| **Exclusive offer** | "Exclusive: First look at [X]" | Tier 1, breaking news |
| **Data hook** | "New data: 73% of CISOs say [X]" | Research, trends |
| **Timely tie-in** | "For your [event] coverage: [angle]" | News cycle, events |
| **Contrarian** | "The case against [popular thing]" | Opinion, trend pieces |
| **Question** | "Why are VCs suddenly backing [X]?" | Trend stories |
| **Name drop** | "[Notable person] joins [company]" | Executive news |
### Good Subject Lines
```
✓ "Exclusive: Stripe-backed startup launches open-source Plaid alternative"
→ Exclusive flag, notable backer, clear differentiation
✓ "For your AI coverage: Why 80% of enterprise AI projects fail (new data)"
→ Beat-relevant, specific stat, fresh data
✓ "Quick question about your secrets management piece"
→ Personal, references their work, low commitment
✓ "Source: Ex-Google engineer on why LLMs can't replace search"
→ Expert source, contrarian angle, timely topic
```
### Bad Subject Lines
```
✗ "Press Release: Acme Inc. Announces New Product"
→ Screams "mass pitch," no news value
✗ "Following up on my previous email"
→ No reason to open, no new value
✗ "URGENT: Major news from Acme"
→ Fake urgency destroys trust
✗ "Partnership Opportunity"
→ Sounds like spam, vague
```
### The 3-Sentence Pitch
```
[Hook - why this matters to their readers]
[The story - what's happening, with proof point]
[The ask - specific, easy to say yes to]
```
### Good Pitch Example
```
Subject: For your fintech beat: YC startup replacing SWIFT for B2B payments
Hi Sarah,
Your recent piece on cross-border payment friction resonated with our
customers—we've heard the same complaints from 200+ finance teams.
We just closed $15M from Andreessen to build SWIFT's replacement for B2B.
Our beta users (including Notion and Linear) are seeing 3-day payments
become same-day at 70% lower fees.
Would our CEO be helpful for your payments infrastructure coverage? Happy
to share customer data and early access.
Best,
[Name]
```
### Bad Pitch Example
```
Subject: Press Release: Acme Revolutionizes Payments
Dear Journalist,
I hope this email finds you well! I wanted to reach out to share some
exciting news about Acme, the leading provider of innovative payment
solutions for the modern enterprise.
Acme is thrilled to announce the launch of our revolutionary new
platform that leverages cutting-edge technology to transform the
payments landscape. Our solution offers best-in-class features and
unparalleled customer service.
I've attached our press release for your convenience. Please let me
know if you'd like to schedule an interview with our CEO to discuss
this exciting announcement.
Thank you for your time and consideration!
Best regards,
[Name]
```
### What's Wrong With the Bad Example
| Problem | Fix |
|---------|-----|
| Generic greeting | Use their name |
| "Hope this finds you well" | Delete it |
| No hook to their beat | Reference recent work |
| Buzzwords (revolutionary, innovative) | Specific claims |
| "Leading provider" without proof | Credibility via numbers |
| "Attached press release" | Key info in email body |
| No specific ask | Clear, easy next step |
| No reason to respond now | Timely hook |
### Personalization That Works
| Level | Example | Impact |
|-------|---------|--------|
| **None** | "Dear Journalist" | Deleted immediately |
| **Name only** | "Hi Sarah" | Still feels mass |
| **Beat mention** | "For your fintech coverage" | Better, shows research |
| **Article reference** | "Your piece on [X] resonated" | Shows genuine reading |
| **Insight addition** | "Building on your [X] thesis..." | High effort, high response |
### Follow-Up Cadence
| Touch | Timing | Content |
|-------|--------|---------|
| **Initial pitch** | Day 0 | Full pitch |
| **Follow-up 1** | Day 3-4 | New angle or data point |
| **Follow-up 2** | Day 7-8 | Different hook, same story |
| **Move on** | Day 10+ | Add to nurture, try later |
### Good Follow-Up
```
Subject: Re: For your fintech beat: YC startup replacing SWIFT
Hi Sarah,
Quick update since my last note—we just crossed 500 customers and
processed our millionth transaction.
If the SWIFT angle doesn't fit, I've also got a customer (CFO of
Linear) who can speak to hidden costs of cross-border payments.
Either useful for upcoming coverage?
Best,
[Name]
```
### Bad Follow-Up
```
✗ "Just checking in to see if you got my email"
→ Provides zero new value
✗ "I know you're busy, but..."
→ Apologetic, wastes words
✗ "Bumping this to the top of your inbox"
→ Presumptuous, annoying
✗ Forwarding original email with "?"
→ Lazy, signals desperation
```
### Pitch Timing Matrix
| Journalist Type | Best Pitch Window | Avoid |
|-----------------|-------------------|-------|
| **Daily news** | 6-9am their time | After noon |
| **Weekly publication** | Monday-Tuesday | Thursday-Friday |
| **Freelance** | Varies, test | Mass pitch times |
| **Podcast host** | Weekday mornings | When recording |
### The "Not For Me" Response
When a journalist says it's not for them:
```
✓ "Totally understand. Anyone on your team who might be a better fit
for dev tools coverage?"
→ Asks for referral politely
✓ "Thanks for letting me know. Is there a different angle on
[topic] that would be more relevant to your readers?"
→ Learns their needs
✗ "But this is really big news that your readers need to know about"
→ Argumentative, burns bridge
```
### Anti-Patterns
- **Mass BCC pitches** — Obvious, disrespectful, ineffective
- **Attaching press release** — Key info should be in email body
- **"Per my last email"** — Passive aggressive, relationship killer
- **Pitching the wrong beat** — Security story to a fintech writer
- **No specific ask** — What do you actually want them to do?
- **Burying the hook** — News in paragraph 3
- **Over-following up** — More than 2-3 follow-ups is harassment
- **Pitch during breaking news** — Check the news before hitting send
FILE:rules/media-press-kit.md
---
title: Press Kit and Media Assets
impact: MEDIUM-HIGH
tags: press-kit, media-assets, newsroom, brand-assets
---
## Press Kit and Media Assets
**Impact: MEDIUM-HIGH**
A well-organized press kit removes friction from coverage. When journalists can easily find what they need, they're more likely to write about you—and get the details right.
### Press Kit Components
| Component | Purpose | Format |
|-----------|---------|--------|
| **Company overview** | Quick background for journalists | PDF, web page |
| **Fact sheet** | Key stats at a glance | PDF, web page |
| **Executive bios** | Background on spokespeople | PDF, web page |
| **Executive headshots** | For article illustrations | High-res JPG/PNG |
| **Logos** | For publication use | SVG, PNG, various sizes |
| **Product screenshots** | Visual context | High-res PNG |
| **Press releases** | Historical announcements | PDF archive |
| **Media contact** | How to reach PR | Email, phone |
| **Brand guidelines** | Usage rules (optional) | PDF |
| **B-roll/video** | For broadcast/video | MP4, downloadable |
### Company Overview One-Pager
```
[COMPANY NAME]
What We Do
[One sentence: Company + what you do + for whom]
Example: "Acme helps enterprise security teams detect and respond to
threats 10x faster using behavioral AI analysis."
The Problem We Solve
[2-3 sentences on the market problem]
Our Approach
[2-3 sentences on how you're different]
Key Facts
• Founded: [Year]
• Headquarters: [Location]
• Employees: [Range or number]
• Funding: [Total raised, key investors]
• Customers: [Number and/or notable names]
Leadership
• [CEO Name], CEO — [One line background]
• [CTO Name], CTO — [One line background]
• [Other notable execs]
Notable Milestones
• [Date]: [Milestone 1]
• [Date]: [Milestone 2]
• [Date]: [Milestone 3]
Media Contact
[Name], [Title]
[Email] | [Phone]
```
### Fact Sheet Template
```
[COMPANY NAME] FACT SHEET
Last updated: [Date]
Company
├── Founded: [Year]
├── Headquarters: [City, State]
├── Employees: [Number/range]
└── Website: [URL]
Funding
├── Total raised: $[X]M
├── Latest round: Series [X], $[X]M, [Date]
├── Lead investors: [Names]
└── Other investors: [Names]
Product
├── Category: [Market category]
├── Platform: [Cloud/on-prem/hybrid]
├── Key capabilities: [3-5 bullets]
└── Pricing model: [Subscription/usage/etc.]
Customers
├── Total customers: [Number]
├── Enterprise customers: [Number]
├── Notable customers: [Names, with permission]
└── Industries: [Key verticals]
Traction
├── [Growth metric 1]: [Number]
├── [Growth metric 2]: [Number]
├── [Other proof point]: [Number]
└── [Award/recognition]: [Name]
Leadership
├── CEO: [Name] — [Previous company/role]
├── CTO: [Name] — [Previous company/role]
└── [Other exec]: [Name] — [Previous company/role]
Contact
├── Media: [Name], [email], [phone]
├── Analysts: [Name], [email]
└── General: [email]
```
### Executive Bio Structure
```
[EXECUTIVE NAME]
[Title], [Company]
[Paragraph 1: Current role]
[Name] is the [Title] of [Company], where [he/she/they] [key
responsibility]. [One sentence on notable achievement in role].
[Paragraph 2: Background]
Prior to [Company], [Name] [relevant previous experience]. [Notable
accomplishment from past roles].
[Paragraph 3: Personal/credibility]
[Name] [education, board positions, publications, awards, or relevant
personal details]. [He/she/they] is based in [location].
Social/Contact:
LinkedIn: [URL]
Twitter: [Handle] (if active)
```
### Good Executive Bio
```
Sarah Chen
CEO, Acme Security
Sarah Chen is the CEO and co-founder of Acme Security, where she leads
the company's mission to make enterprise security accessible to teams
of any size. Under her leadership, Acme has grown from a two-person
startup to serving over 500 enterprise customers including Stripe,
Notion, and three Fortune 100 companies.
Prior to founding Acme, Sarah spent eight years at Google, most recently
as Director of Cloud Security Engineering. She led the team that built
Google's internal secrets management infrastructure, serving over 50,000
engineers.
Sarah holds a BS in Computer Science from MIT and an MBA from Stanford.
She serves on the board of Women in Security and Privacy (WISP) and has
been named to Forbes 30 Under 30 and Fortune's 40 Under 40. She lives
in San Francisco with her family.
LinkedIn: linkedin.com/in/sarahchen
Twitter: @sarahchen
```
### Bad Executive Bio
```
✗ "John is a visionary leader passionate about innovation"
→ Empty adjectives, no substance
✗ "John has over 20 years of experience in the industry"
→ Vague, doesn't say what experience
✗ Three paragraphs of job title history
→ Resume, not bio
✗ "John enjoys hiking and spending time with family"
→ Unless relevant to their role, skip personal filler
```
### Logo and Asset Guidelines
| Asset | Formats to Provide | Guidelines |
|-------|--------------------|------------|
| **Primary logo** | SVG, PNG (light/dark bg) | Minimum size, clear space |
| **Logo mark** | SVG, PNG | When to use vs full logo |
| **Wordmark** | SVG, PNG | Text-only version |
| **Product screenshots** | PNG, 2x resolution | Annotated vs clean |
| **Headshots** | JPG, minimum 1000px | Consistent style across execs |
### Press Kit Page Structure
```
[Company] Press Kit
[Brief intro and media contact at top]
Quick Links:
├── Download all assets (ZIP)
├── Company overview (PDF)
├── Fact sheet (PDF)
└── Latest press release
Company Overview
[Embedded or linked one-pager]
Executive Team
├── [CEO] — Bio | High-res headshot
├── [CTO] — Bio | High-res headshot
└── [Other execs]
Logos & Brand Assets
├── Logo package (ZIP)
├── Brand guidelines
└── Usage notes
Product Images
├── Screenshot gallery
├── Product demo video
└── Infographics (if applicable)
Press Releases
├── [Date]: [Headline]
├── [Date]: [Headline]
└── View all releases
In the News
├── [Publication]: [Headline]
├── [Publication]: [Headline]
└── View all coverage
Media Contact
[Name]
[Title]
[Email] | [Phone]
```
### Newsroom Best Practices
| Do | Don't |
|----|-------|
| Keep assets current | Let headshots become outdated |
| Provide multiple formats | Force journalists to request basics |
| Make downloads easy | Require registration to access |
| Date all materials | Let undated content confuse timelines |
| Include media contact prominently | Bury contact information |
| Offer high-resolution options | Provide only low-res images |
### Updating Your Press Kit
| Trigger | Action |
|---------|--------|
| New funding round | Update fact sheet, boilerplate, milestones |
| Executive hire | Add bio and headshot |
| Executive departure | Remove from press kit, archive bio |
| Major product launch | Add screenshots, update capabilities |
| Quarterly | Verify all facts, links, and images current |
| Rebrand | Replace all brand assets |
### Making Assets Journalist-Friendly
```
✓ Clear file naming
Logo_Acme_Primary_RGB.svg
Headshot_SarahChen_CEO_2024.jpg
✓ Multiple sizes/formats
Logo_Acme_Primary_RGB_1000px.png
Logo_Acme_Primary_RGB_500px.png
Logo_Acme_Primary_CMYK.eps
✓ Easy download options
One-click ZIP of all assets
Individual asset downloads
✓ Clear usage guidance
"Logo minimum size: 100px wide"
"Please don't alter logo colors"
```
### Journalist Resource Requests
Common requests to prepare for:
| Request | Have Ready |
|---------|------------|
| "Send me your logo" | Logo package link |
| "Do you have a headshot of [exec]?" | Direct download link |
| "What's the correct name/title?" | Fact sheet with spellings |
| "Can I get product screenshots?" | Screenshot gallery link |
| "Who should I contact?" | Media contact info |
| "What's your boilerplate?" | Company overview link |
### Anti-Patterns
- **Registration walls** — Journalists won't fill out forms for assets
- **Outdated photos** — Exec from 5 years ago creates confusion
- **Missing formats** — No SVG forces bad logo reproduction
- **Hidden contact info** — Media contact should be prominent
- **PDF-only everything** — Web pages are easier to reference
- **No ZIP download** — Journalists often need multiple assets
- **Stale information** — Employee count from two years ago
- **Generic stock photos** — Use real product images
FILE:rules/strategy-embargo-exclusive.md
---
title: Embargo and Exclusive Strategy
impact: HIGH
tags: embargo, exclusive, launch-strategy, media-strategy
---
## Embargo and Exclusive Strategy
**Impact: HIGH**
Embargoes and exclusives are powerful tools when used correctly, and relationship-destroying weapons when misused. Understanding when and how to use each is critical PR strategy.
### Embargo vs Exclusive: Key Differences
| Aspect | Embargo | Exclusive |
|--------|---------|-----------|
| **Definition** | Multiple outlets, same release time | One outlet, first to publish |
| **Journalist commitment** | Agree not to publish until lift | Agree to publish by deadline |
| **Your commitment** | Don't give to non-embargoed outlets | Don't pitch to anyone else |
| **Coverage quantity** | Multiple stories at once | One story, maybe follow-on |
| **Coverage quality** | Standard coverage | Often deeper, feature-length |
| **Best for** | Maximum same-day impact | Major news, deeper story |
### When to Use Each
| Strategy | Use When | Don't Use When |
|----------|----------|----------------|
| **Embargo** | Multiple outlets will cover, want coordinated splash | News isn't strong enough for broad coverage |
| **Exclusive** | Tier 1 outlet, want feature treatment | News is time-sensitive and must go wide |
| **Neither** | News is moderate, relationship building | You have major news warranting coordination |
| **Soft exclusive** | "First to publish" but others can follow | You need guaranteed coverage |
### The Embargo Process
```
Timeline: 2-3 weeks before announcement
Week -3 to -2:
├── Draft press release and materials
├── Identify target journalists (usually 5-15)
├── Confirm news warrants embargo
└── Prepare embargo agreement language
Week -2 to -1:
├── Send embargo pitches to Tier 1 first
├── Get explicit confirmation of embargo acceptance
├── Schedule briefings/interviews
└── Send materials to confirmed participants
Week -1:
├── Expand to Tier 2-3 if appropriate
├── Conduct briefings
├── Answer journalist questions
└── Confirm everyone has what they need
Day of lift:
├── Confirm timing with all participants
├── Distribute press release at lift time
└── Be available for day-of questions
└── Monitor coverage, thank journalists
```
### Embargo Pitch Template
```
Subject: [Exclusive] Embargo opportunity: [Headline]
Hi [Name],
[One sentence on why this is relevant to their beat]
We're announcing [brief description of news] on [date]. Given your
coverage of [beat], I'd like to offer you an embargo briefing.
Embargo lifts: [Day], [Date], [Time] [Timezone]
Key points:
• [Bullet 1]
• [Bullet 2]
• [Bullet 3]
Interested in a briefing? I can also provide [data/demo/exec access].
If this isn't for you, please let me know and I'll remove you from
embargo communications.
Best,
[Name]
```
### Embargo Rules of Engagement
| Rule | Explanation |
|------|-------------|
| **Get explicit acceptance** | "Yes, I accept the embargo" in writing |
| **Confirm timing precisely** | Include timezone, never ambiguous |
| **Honor commitments** | If you offer exclusive data, deliver it |
| **Don't over-invite** | 5-15 journalists, not 50 |
| **Prepare for breaks** | Have a plan if someone publishes early |
| **Respect "no"** | If they decline, don't share embargo info |
### Good Embargo Practices
```
✓ "Please confirm you accept the embargo before I send materials."
→ Explicit acceptance required
✓ "Embargo lifts Wednesday, March 15 at 6:00am ET / 3:00am PT"
→ No ambiguity on timing
✓ "If this isn't relevant to your coverage, no need to respond—
I'll just remove you from this embargo list."
→ Easy out, no pressure
✓ Sending embargo reminder 24 hours before lift
→ Ensures everyone is aligned
```
### Bad Embargo Practices
```
✗ "Sending you this under embargo" [without asking for acceptance]
→ They never agreed, not bound
✗ "Embargo lifts Wednesday morning"
→ Morning where? What timezone?
✗ Adding journalists to embargo after some have already published
→ Creates confusion and unfairness
✗ Offering the same "exclusive" to multiple outlets
→ Fastest way to destroy trust
```
### The Exclusive Process
```
Step 1: Identify the right outlet
├── Is this story big enough for them?
├── Do they cover this beat deeply?
├── What's your relationship with them?
└── Can they publish when you need?
Step 2: Make the offer
├── Clearly state it's an exclusive
├── Define what "exclusive" means (time period, scope)
├── Provide timeline expectations
└── Get commitment before sharing details
Step 3: Deliver exceptional access
├── Full exec interview time
├── Customer references
├── Data and visuals
├── Anything they need for a great story
Step 4: After publication
├── Share and amplify their story
├── Hold off other pitches as agreed
├── Don't undermine with competitive coverage
└── Thank them and maintain relationship
```
### Exclusive Offer Template
```
Subject: Exclusive for [Outlet]: [One line hook]
Hi [Name],
I have an exclusive I think would fit your [beat] coverage.
[Company] is [one sentence news]. This would give [Outlet] the first
look at [why it matters], including:
• [Unique access point 1]
• [Unique access point 2]
• [Data/customer/exec access]
Timeline: We're hoping to have this publish by [date]. Does that work
for your schedule?
If this is interesting, I can send full details and set up a call with
our [CEO/relevant exec].
Best,
[Name]
```
### Exclusive vs Embargo Decision Matrix
| Factor | Favors Exclusive | Favors Embargo |
|--------|------------------|----------------|
| Story complexity | High (needs depth) | Low-medium |
| Relationship | Strong with one outlet | Strong with many |
| Desired coverage | One deep feature | Many simultaneous mentions |
| Time sensitivity | Can wait for their schedule | Need specific date |
| News significance | Major (funding, acquisition) | Important but not major |
| Follow-on potential | Want cascade of coverage | Want one big moment |
### Timing Considerations
| Announcement Type | Typical Embargo Lead Time |
|-------------------|--------------------------|
| Funding round | 1-2 weeks |
| Product launch | 2-3 weeks |
| Partnership | 1-2 weeks |
| Executive hire | 1 week |
| Acquisition | 1-3 weeks (often complex) |
| Research/report | 2-4 weeks |
### Handling Embargo Breaks
If someone publishes before the embargo lifts:
```
Immediate:
├── Confirm the break (is it really your story?)
├── Contact the breaking outlet (may be accidental)
├── Notify all embargoed journalists
└── Release embargo immediately if significant break
Communication to embargoed journalists:
"Unfortunately, [outlet] broke embargo. We're releasing immediately
so you can publish at your discretion. Apologies for the disruption—
we're addressing this with the outlet directly."
```
### Protecting Against Breaks
- Only embargo journalists you trust
- Get explicit written acceptance
- Watermark sensitive documents
- Stagger information sharing (basics first, details closer to lift)
- Have a rapid-release plan ready
### After the Embargo Lifts
| Timing | Action |
|--------|--------|
| **Lift time** | Wide distribution of press release |
| **First hour** | Available for journalist questions |
| **Same day** | Monitor coverage, share/engage |
| **Day 2** | Follow up with non-covering journalists |
| **Week 1** | Pitch secondary angles to non-covered |
### Anti-Patterns
- **Fake exclusives** — Offering same "exclusive" to competitors
- **Infinite embargo** — Journalists waiting weeks with no lift date
- **Embargo everything** — Not all news warrants coordination
- **Last-minute additions** — Adding journalists day before lift
- **No break plan** — Scrambling when someone publishes early
- **Retaliation** — Punishing outlets that couldn't cover
- **Scope creep** — Expanding story during embargo confuses journalists
- **Silent treatment** — Not confirming lift or providing updates
FILE:rules/strategy-launch-timing.md
---
title: Launch Timing and News Calendar
impact: HIGH
tags: timing, launch-strategy, news-cycle, calendar
---
## Launch Timing and News Calendar
**Impact: HIGH**
The best announcement on the wrong day gets buried. Strategic timing maximizes coverage and impact—understanding the news cycle is as important as the news itself.
### News Cycle Fundamentals
| Time Period | Characteristics | Strategy |
|-------------|-----------------|----------|
| **Morning (6-10am ET)** | Journalists setting daily agenda | Best for breaking news |
| **Midday (10am-2pm ET)** | Story development, interviews | Good for follow-up availability |
| **Afternoon (2-6pm ET)** | Deadline crunch, filing stories | Hard to get attention |
| **Evening/overnight** | Limited coverage, next-day pickup | Avoid unless strategic |
### Day of Week Analysis
| Day | Effectiveness | Best For | Avoid |
|-----|---------------|----------|-------|
| **Monday** | Low-Medium | Week-long campaigns | Major breaking news |
| **Tuesday** | High | Most announcements | — |
| **Wednesday** | High | Product launches, research | — |
| **Thursday** | Medium | Feature pieces, embargoes | Time-sensitive news |
| **Friday** | Low | Burying bad news (not recommended) | Anything you want covered |
| **Saturday/Sunday** | Very Low | Emergency only | Everything else |
### Optimal Timing by Announcement Type
| Announcement | Best Day | Best Time | Embargo Lift |
|--------------|----------|-----------|--------------|
| **Funding** | Tuesday-Wednesday | 6am ET | Yes |
| **Product launch** | Tuesday-Wednesday | 6am ET | Yes |
| **Partnership** | Tuesday-Thursday | 6am ET | Usually |
| **Executive hire** | Tuesday-Thursday | 6am ET | Optional |
| **Research/report** | Wednesday | 6am ET | Yes |
| **Acquisition** | Tuesday-Wednesday | 6am ET | Sometimes |
| **Earnings/results** | After market close | 4pm+ ET | No |
### Calendar Considerations
**Avoid these periods:**
| Period | Why |
|--------|-----|
| **Major holidays** | Skeleton newsroom crews |
| **Between Christmas-New Year** | News desert |
| **Summer Fridays** | Reduced attention |
| **Election days** | All attention elsewhere |
| **Major industry events** | Competing for attention |
| **Earnings weeks** | Financial press distracted |
| **Breaking news days** | Unpredictable, monitor and delay |
**Leverage these periods:**
| Period | Opportunity |
|--------|-------------|
| **Industry conferences** | Concentrated audience, press present |
| **Earnings calls (of big players)** | Draft off market attention |
| **January** | Fresh budgets, "year ahead" stories |
| **September** | Post-summer return, planning season |
### Building Your Announcement Calendar
```
Annual Planning:
Q1:
├── January: New Year trends, predictions
├── February: Post-earnings, budget season
└── March: Q1 launches before quarter end
Q2:
├── April: Spring launches
├── May: Pre-summer pushes
└── June: Early summer (slower)
Q3:
├── July: Slowest month (avoid major news)
├── August: Pre-back-to-business
└── September: Back-to-business surge
Q4:
├── October: Fall campaigns
├── November: Pre-holiday (before Thanksgiving)
└── December: Year-end recaps, avoid major launches
```
### Competitive Timing
```
Competitor announcement imminent?
Option A: Go before
├── Beat them to market
├── Forces them to respond to you
└── Risk: Rushing, quality suffers
Option B: Go same day (counter-programming)
├── Draft off their attention
├── Provide alternative story
└── Risk: Gets lost in their coverage
Option C: Wait
├── Let their news cycle fade
├── Stand alone with your story
└── Risk: Perception of being second
Decision factors:
├── Newsworthiness of your announcement
├── How directly competitive is the news?
├── Your relationship with key journalists
└── Quality of your announcement readiness
```
### Coordinating with Business Calendar
| Business Event | PR Timing Strategy |
|----------------|-------------------|
| **Product launch** | PR embargo lifts at or before GA |
| **Conference keynote** | Embargo lifts during/after keynote |
| **Funding close** | Announce within 1-2 weeks of close |
| **Executive start date** | Announce on or near first day |
| **Quarterly earnings** | Coordinate with investor relations |
| **Major customer win** | After contract signed, customer approved |
### News Cycle Disruptions
When breaking news dominates the cycle:
```
Assessment:
├── Is this a 1-hour story or multi-day?
├── Does it compete for same journalists?
├── Is there any connection to your news?
└── Can you delay 24-48 hours?
Options:
├── Delay: Safest if your news can wait
├── Proceed: If news is time-sensitive or unrelated
├── Pivot: If you can connect to breaking story
└── Cancel: If news is no longer relevant
```
### Good Timing Decisions
```
✓ Funding announcement on Tuesday at 6am ET with 48-hour embargo
→ Peak day, time for west coast, journalist prep time
✓ Delaying launch by one day due to major industry acquisition news
→ Prioritizes coverage over arbitrary date
✓ Aligning product launch with industry conference keynote
→ Captive audience, concentrated press
✓ Scheduling exec interview for 10am ET on announcement day
→ Available when journalists are writing
```
### Bad Timing Decisions
```
✗ Major product launch on Friday before Labor Day weekend
→ Minimal coverage, lost in holiday news desert
✗ Funding announcement same day as Apple keynote
→ Competing with overwhelming tech coverage
✗ Breaking embargo to beat competitor by 2 hours
→ Damages journalist relationships permanently
✗ Announcing at 5pm ET "to make tomorrow's news"
→ Missed today's cycle, may miss tomorrow's too
```
### Timezone Coordination
| Target Audience | Embargo Lift Time | Rationale |
|-----------------|-------------------|-----------|
| **US national** | 6am ET | Full news day coverage |
| **US tech** | 6am PT (9am ET) | West coast journalist friendly |
| **Global** | 6am ET | US + Europe afternoon |
| **Europe-focused** | 6am GMT | European news cycle |
| **Asia-focused** | 6am local | Regional coverage priority |
### Contingency Planning
Build flexibility into your timing:
```
Primary date: Tuesday, March 15, 6am ET
Backup date: Wednesday, March 16, 6am ET
Fallback date: Monday, March 21, 6am ET
Triggers for backup:
├── Major breaking news dominates cycle
├── Key journalist unavailable
├── Technical/legal delay
├── Competitor timing conflict
└── Customer reference backing out
Communication plan:
├── Pre-draft delay notice to embargoed journalists
├── Internal stakeholder notification process
├── Customer/partner coordination
└── Social/marketing asset flexibility
```
### Announcement Sequencing
For multiple related announcements:
```
Option A: Single big bang
├── All news in one release
├── Maximum single-day impact
└── Risk: Muddled message, too much to cover
Option B: Staggered announcements
├── Space 1-2 weeks apart
├── Multiple coverage opportunities
└── Risk: Fatigue, later news seems smaller
Option C: Primary + follow-on
├── Major news first, supporting news follows
├── Builds narrative over time
└── Works well for product launch + customer wins
Sequencing factors:
├── How related is the news?
├── Can each piece stand alone?
├── What story arc do you want to tell?
└── What coverage cadence can you sustain?
```
### Real-Time Monitoring
On announcement day:
| Time | Activity |
|------|----------|
| **-24 hours** | Final check, embargo reminder sent |
| **-1 hour** | Verify release ready, team on standby |
| **0 (embargo lift)** | Wire distribution, monitoring begins |
| **+1-2 hours** | Initial coverage lands, share/engage |
| **+4 hours** | Assess coverage, thank journalists |
| **+24 hours** | Follow-up pitches to non-covered outlets |
| **+1 week** | Second wave pitches, different angles |
### Anti-Patterns
- **Date worship** — Arbitrary dates shouldn't override smart timing
- **Friday dumps** — Only for news you don't want covered
- **Holiday ambition** — Nobody's reading on Christmas Day
- **Competitor panic** — Racing to beat them compromises quality
- **Ignoring breaking news** — Check the news before hitting send
- **One-shot thinking** — Good news can have multiple waves
- **Timezone blindness** — 6am PT is 9am ET, know your audience
- **No contingency** — Always have a backup date
FILE:rules/thought-leadership-placement.md
---
title: Thought Leadership Placement
impact: MEDIUM-HIGH
tags: thought-leadership, bylines, op-eds, speaking, expert-positioning
---
## Thought Leadership Placement
**Impact: MEDIUM-HIGH**
Thought leadership turns executives into recognized experts. It's not about promoting your company—it's about sharing insights that make people want to hear more from you.
### Thought Leadership Formats
| Format | Reach | Effort | Best For |
|--------|-------|--------|----------|
| **Bylined article** | High | High | Deep expertise, major publications |
| **Op-ed** | High | Medium-High | Timely takes, policy/industry issues |
| **Guest post** | Medium | Medium | Niche audiences, SEO value |
| **Podcast guest** | Medium | Low | Conversational experts, long-form |
| **Conference talk** | Medium | High | Networking, demos, presence |
| **Webinar** | Low-Medium | Medium | Lead generation, deep dives |
| **Newsletter feature** | Varies | Low | Targeted audiences, relationship |
### The Byline Placement Process
```
Step 1: Identify target publications
├── Where does your audience read?
├── What publications does your industry respect?
├── Who accepts contributed content?
└── What's realistic given your brand recognition?
Step 2: Find the right angle
├── What's your unique perspective?
├── What's timely or trending?
├── What will resonate with that publication's readers?
└── What can only you say (based on experience)?
Step 3: Pitch the editor
├── Subject: Clear topic, not "byline submission"
├── Hook: Why this, why now, why you?
├── Outline: What the piece will cover
├── Credentials: Why you're qualified to write this
Step 4: Write and submit
├── Follow publication guidelines exactly
├── Match their style and tone
├── Provide original value (not company promotion)
└── Be responsive to editor feedback
Step 5: Amplify after publication
├── Share across personal and company channels
├── Thank the editor publicly
├── Repurpose into social content
└── Track performance and pitch follow-ups
```
### Good Byline Topics
```
✓ "Why the Security Industry's Obsession with 'Zero Trust' Is Missing the Point"
→ Challenges conventional wisdom, offers alternative framework
✓ "I've Interviewed 200 CTOs About AI Adoption—Here's What They're Getting Wrong"
→ Original research, specific number, contrarian insight
✓ "The Hidden Cost of Technical Debt: A CFO's Perspective"
→ Unexpected angle (CFO on tech), business relevance
✓ "What the EU AI Act Actually Means for American Startups"
→ Timely, practical, specific audience
```
### Bad Byline Topics
```
✗ "Why [Your Company] Is Transforming the Industry"
→ Advertorial, will be rejected
✗ "5 Tips for Better Cybersecurity"
→ Generic, adds nothing new
✗ "The Future of AI" [by non-AI expert]
→ Off-topic, lacks credibility
✗ "How to Choose a [Your Product Category] Vendor"
→ Transparent sales pitch
```
### Byline Pitch Template
```
Subject: Byline pitch: [Specific topic angle]
Hi [Editor name],
[One sentence on timely hook or why this matters now]
I'd like to propose a piece for [publication]: "[Working title]"
The angle: [2-3 sentences describing your unique take]
Why me: [One sentence on your relevant experience/credentials]
Outline:
• [Section 1]
• [Section 2]
• [Section 3]
• [Key takeaway]
Target length: [X] words. I can deliver within [timeframe].
Happy to adjust the angle based on what would work best for your readers.
Best,
[Name]
[Title, Company]
[Brief credential]
```
### Publication Tiers for Tech B2B
| Tier | Examples | Typical Requirements |
|------|----------|---------------------|
| **Tier 1** | Harvard Business Review, WSJ, NYT | Major thought leader, unique data |
| **Tier 2** | Forbes, Fast Company, Inc | Established exec, strong angle |
| **Tier 3** | VentureBeat, TechCrunch guest | Industry relevance, timely take |
| **Trade** | InfoSecurity, DevOps.com | Domain expertise, practical value |
| **Emerging** | Substacks, industry newsletters | Relationship, niche expertise |
### Podcast Guest Strategy
```
Finding the right podcasts:
├── Search "top [industry] podcasts"
├── Look at where competitors/peers have guested
├── Check Listen Notes, Chartable for discovery
└── Start niche, work up to larger shows
Pitch approach:
├── Listen to 2-3 episodes first
├── Reference specific episode in pitch
├── Offer 3 potential topics they haven't covered
├── Include speaking sample or previous appearance
Interview preparation:
├── Research host and recent guests
├── Prepare 3-5 stories/anecdotes
├── Have data points ready to cite
├── Test audio setup before recording
```
### Good Podcast Pitch
```
Subject: Guest pitch: [Specific topic]
Hi [Host name],
Loved your episode with [recent guest]—especially the discussion about
[specific point]. It got me thinking about [related angle].
I'm [Name], [Title] at [Company]. We [one sentence about what you do].
Three topics I could bring to your show:
1. [Topic with unique angle] — I've [relevant experience]
2. [Different topic] — Based on [data/experience]
3. [Third option] — This connects to your recent [theme]
I've previously appeared on [podcast names] and can send clips if helpful.
Worth a conversation?
Best,
[Name]
```
### Conference Speaking Opportunities
| Opportunity Type | How to Get It | Effort to Reward |
|------------------|---------------|------------------|
| **Keynote** | Industry recognition, invited | Highest |
| **Breakout session** | CFP submission, relationship | High |
| **Panel** | Moderator invitation, networking | Medium |
| **Fireside chat** | Host relationship, relevance | Medium |
| **Workshop** | CFP, demonstrated expertise | High |
| **Lightning talk** | Lower barrier CFPs | Low-Medium |
### CFP (Call for Papers) Best Practices
| Element | Good | Bad |
|---------|------|-----|
| **Title** | "How We Reduced Deploy Time by 90% Without Breaking Production" | "DevOps Best Practices" |
| **Abstract** | Specific problem, clear takeaways, evidence | Vague promises, buzzwords |
| **Bio** | Relevant experience, previous talks | Full career history |
| **Angle** | Specific, data-backed, practical | Generic, theoretical |
### Building Speaking Credentials
```
Path from zero to recognized speaker:
Level 1: Local/virtual
├── Meetups and user groups
├── Company webinars
├── Virtual events
└── Podcasts as guest
Level 2: Regional/niche
├── Regional conferences
├── Industry-specific events
├── Workshop facilitation
└── Panel participation
Level 3: National/major
├── Major industry conferences
├── Keynote opportunities
├── Published speaker with clips
└── Invited by reputation
Key accelerators:
├── Video of previous talks
├── Original research or data
├── Unique perspective or story
├── Social proof (engagement, following)
```
### Thought Leadership Calendar
| Frequency | Activity |
|-----------|----------|
| **Weekly** | LinkedIn posts, engage with industry content |
| **Monthly** | Podcast appearance or guest post |
| **Quarterly** | Major bylined article |
| **Semi-annual** | Conference speaking |
| **Annual** | Original research or report |
### Measuring Thought Leadership Impact
| Metric | What It Indicates |
|--------|-------------------|
| **Inbound speaking requests** | Market recognition |
| **Journalist source requests** | Expert credibility |
| **Social engagement** | Audience resonance |
| **Byline acceptance rate** | Content quality |
| **Pipeline from TL content** | Business impact |
| **Share of voice** | Category ownership |
### Anti-Patterns
- **Company promotion in bylines** — Editors will reject, readers will tune out
- **Ghostwriting without voice** — Generic content defeats the purpose
- **Quantity over quality** — 10 weak posts < 1 strong article
- **Ignoring editor feedback** — Collaboration makes pieces better
- **Not amplifying** — Publishing without promotion wastes effort
- **Mismatched venues** — Enterprise exec writing for startup blog
- **No original insight** — Summarizing what others say
- **Inconsistent presence** — One article per year doesn't build authority
Amazon Review Intelligence — input an ASIN, automatically fetch product reviews via VOC.AI and run Claude AI analysis. Outputs structured VOC report: sentime...
---
name: amazon-review-scraper
description: "Amazon Review Intelligence — input an ASIN, automatically fetch product reviews via VOC.AI and run Claude AI analysis. Outputs structured VOC report: sentiment breakdown, top pain points, key selling points, listing optimization suggestions, SEO keywords. No scraping needed — uses VOC.AI API. Free tier: 8 reviews per ASIN. Triggers: amazon review, asin analysis, voc analysis, voice of customer, listing optimization, pain points, review scraper, amazon fba research, product research, review analysis, amazon seller"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/amazon-review-scraper
---
# Amazon Review Scraper — VOC Intelligence
> Input an ASIN → fetch reviews via VOC.AI → Claude AI analysis → structured report.
> No browser, no scraping. Powered by VOC.AI API.
## Quick Start
```bash
# Basic analysis (8 reviews free, English report)
bash voc.sh B08N5WRWNW
# Chinese report
bash voc.sh B08N5WRWNW --lang zh
# More reviews (requires VOC.AI Team plan token)
bash voc.sh B08N5WRWNW --token YOUR_TOKEN --limit 50
# Save to file
bash voc.sh B08N5WRWNW --output report.md
# Fetch only (no AI, saves JSON)
bash voc.sh B08N5WRWNW --scrape-only --output reviews.json
# Analyze existing JSON
bash voc.sh --analyze reviews.json --asin B08N5WRWNW --lang zh
```
## API Token
- **Free tier**: 8 reviews per ASIN — no token needed
- **More reviews**: Get a VOC.AI token at https://voc.ai/pricing (Team/Enterprise plan)
- Pass via `--token YOUR_TOKEN` or `export VOC_TOKEN=YOUR_TOKEN`
## Sample Output
```
╔══════════════════════════════════════════════════════╗
║ Amazon Review Intelligence Report ║
║ ASIN: B08N5WRWNW │ Reviews: 8 │ amazon.com ║
║ Generated: 2026-04-01 10:30 ║
╚══════════════════════════════════════════════════════╝
📊 Sentiment Distribution
Positive ████████████░░░░ 74% (6 reviews)
Neutral ███░░░░░░░░░░░░░ 13% (1 reviews)
Negative ██░░░░░░░░░░░░░░ 13% (1 reviews)
Verified ████████████████ 89% verified purchases
⭐ Rating Distribution
5★ ███████████████ 5
4★ ████░░░░░░░░░░░ 1
3★ ██░░░░░░░░░░░░░ 1
2★ █░░░░░░░░░░░░░░ 0
1★ █░░░░░░░░░░░░░░ 1
Average Rating: ████ 4.2/5.0
────────────────────────────────────────────────────────
## 🔴 Top 5 Pain Points
1. **Short battery life** (3 mentions)
> "Battery drained in 2 days, very disappointed"
- Customers expect longer standby time vs. what's advertised
## 🟢 Top 5 Selling Points
1. **Excellent sound quality** (5 mentions)
> "Amazing bass and crystal clear highs for the price"
## 💡 Listing Optimization
...
```
## Requirements
- Python 3.8+
- `requests` (auto-installed on first run)
- `claude` CLI for AI analysis (optional — scrape-only mode works without it)
- VOC.AI API token for more than 8 reviews
## Notes
- Default free tier: 8 reviews per ASIN
- Want more? Upgrade to VOC.AI Team plan (https://voc.ai/pricing) — uses credits
- Each AI analysis uses ~2,000–5,000 Claude tokens (~$0.01–$0.03)
- Supports all Amazon marketplaces via VOC.AI
FILE:analyze.py
#!/usr/bin/env python3
"""
Amazon Review AI Analyzer — powered by Claude
Reads scraped reviews JSON and generates a structured VOC report.
Usage:
python3 analyze.py <reviews.json> [--asin ASIN] [--output report.md] [--lang zh|en]
"""
import json, sys, subprocess, argparse, statistics
from pathlib import Path
from collections import Counter
from datetime import datetime
try:
import requests
except ImportError:
pass
# ── Stats ─────────────────────────────────────────────────────────────────────
def compute_stats(reviews: list[dict]) -> dict:
ratings = [r["rating"] for r in reviews if r.get("rating")]
if not ratings:
return {}
dist = Counter(ratings)
total = len(ratings)
avg = statistics.mean(ratings)
pos = sum(1 for r in ratings if r >= 4)
neu = sum(1 for r in ratings if r == 3)
neg = sum(1 for r in ratings if r <= 2)
verified_count = sum(1 for r in reviews if r.get("verified"))
return {
"total": total,
"avg_rating": round(avg, 2),
"positive_pct": round(pos / total * 100),
"neutral_pct": round(neu / total * 100),
"negative_pct": round(neg / total * 100),
"verified_pct": round(verified_count / total * 100),
"distribution": {str(k): dist.get(k, 0) for k in range(5, 0, -1)},
}
def bar(pct: int, width: int = 20) -> str:
filled = round(pct / 100 * width)
return "█" * filled + "░" * (width - filled)
# ── Claude analysis ───────────────────────────────────────────────────────────
def analyze_with_claude(reviews: list[dict], asin: str, lang: str = "en") -> str:
"""Call claude --print to analyze reviews."""
# Sample up to 80 reviews to keep token usage reasonable
sample = reviews[:80]
reviews_text = "\n\n".join(
f"[{r.get('rating', '?')}★] {r.get('title', '')}\n{r.get('body', '')}"
for r in sample
)
if lang == "zh":
prompt = f"""你是亚马逊选品和运营专家。分析以下 {len(sample)} 条产品评论,生成结构化 VOC 报告。
产品 ASIN: {asin}
评论数据:
{reviews_text[:12000]}
请输出以下内容(使用中文,格式清晰):
## 🔴 Top 5 Pain Points(痛点)
每条格式:序号. **痛点名称**(X次提及)
> "典型用户原话"
- 一句话分析
## 🟢 Top 5 Selling Points(卖点)
每条格式:序号. **卖点名称**(X次提及)
> "典型用户原话"
## 💡 Listing优化建议
按 Title / Bullet Points / A+ Content / 客服回复 分类,给出3-5条具体建议
## 🎯 竞品机会
基于差评,指出竞品可以改进的方向(产品改进或定位机会)
## 📌 关键词挖掘
从评论中提取高频用户语言,用于 SEO 关键词优化(10-15个词)"""
else:
prompt = f"""You are an Amazon product research and listing optimization expert.
Analyze these {len(sample)} product reviews and generate a structured VOC report.
Product ASIN: {asin}
Reviews:
{reviews_text[:12000]}
Output the following sections:
## 🔴 Top 5 Pain Points
Format: N. **Pain Point Name** (X mentions)
> "Verbatim customer quote"
- One-line analysis
## 🟢 Top 5 Selling Points
Format: N. **Selling Point** (X mentions)
> "Verbatim customer quote"
## 💡 Listing Optimization
Specific suggestions for: Title / Bullet Points / A+ Content / Customer Response
## 🎯 Competitive Opportunities
Based on negative reviews, identify product improvement or positioning opportunities
## 📌 SEO Keywords from Reviews
High-frequency user language for keyword optimization (10-15 terms)"""
result = subprocess.run(
["claude", "--print", "--dangerously-skip-permissions", prompt],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0 or not result.stdout.strip():
return f"⚠️ Claude analysis failed: {result.stderr[:200]}"
return result.stdout.strip()
# ── Report ────────────────────────────────────────────────────────────────────
def format_report(asin: str, stats: dict, analysis: str, market: str) -> str:
total = stats.get("total", 0)
avg = stats.get("avg_rating", 0)
pos = stats.get("positive_pct", 0)
neu = stats.get("neutral_pct", 0)
neg = stats.get("negative_pct", 0)
ver = stats.get("verified_pct", 0)
dist = stats.get("distribution", {})
lines = [
"╔══════════════════════════════════════════════════════╗",
"║ Amazon Review Intelligence Report ║",
f"║ ASIN: {asin:<10} │ Reviews: {total:<6} │ {market:<14} ║",
f"║ Generated: {datetime.now().strftime('%Y-%m-%d %H:%M'):<38}║",
"╚══════════════════════════════════════════════════════╝",
"",
"📊 Sentiment Distribution",
f" Positive {bar(pos)} {pos}% ({round(total*pos/100)} reviews)",
f" Neutral {bar(neu)} {neu}% ({round(total*neu/100)} reviews)",
f" Negative {bar(neg)} {neg}% ({round(total*neg/100)} reviews)",
f" Verified {bar(ver)} {ver}% verified purchases",
"",
"⭐ Rating Distribution",
]
for star in range(5, 0, -1):
count = dist.get(str(star), 0)
pct = round(count / total * 100) if total else 0
lines.append(f" {star}★ {bar(pct, 15)} {count}")
lines += ["", f" Average Rating: {'★' * round(avg)} {avg}/5.0", "", "─" * 56, ""]
lines.append(analysis)
return "\n".join(lines)
# ── CLI ────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Amazon Review AI Analyzer")
parser.add_argument("reviews_file", help="JSON file from scraper.py")
parser.add_argument("--asin", default="", help="Product ASIN (for display)")
parser.add_argument("--market", default="amazon.com")
parser.add_argument("--output", help="Save report to .md file")
parser.add_argument("--lang", choices=["en", "zh"], default="en", help="Report language")
args = parser.parse_args()
reviews_path = Path(args.reviews_file)
if not reviews_path.exists():
print(f"❌ File not found: {args.reviews_file}", file=sys.stderr)
sys.exit(1)
reviews = json.loads(reviews_path.read_text())
if not reviews:
print("❌ No reviews to analyze", file=sys.stderr)
sys.exit(1)
print(f"📊 Analyzing {len(reviews)} reviews...", file=sys.stderr)
stats = compute_stats(reviews)
analysis = analyze_with_claude(reviews, args.asin or reviews_path.stem, args.lang)
report = format_report(args.asin or reviews_path.stem, stats, analysis, args.market)
if args.output:
Path(args.output).write_text(report)
print(f"💾 Report saved to: {args.output}", file=sys.stderr)
else:
print(report)
if __name__ == "__main__":
main()
FILE:scraper.py
#!/usr/bin/env python3
"""
Amazon Review Fetcher — powered by VOC.AI API
Fetches product reviews via VOC.AI's data API.
Usage:
python3 scraper.py <ASIN> [--limit 8] [--token TOKEN] [--country US] [--output out.json]
Requirements:
VOC.AI API token (X-Token). Get one at https://voc.ai/pricing
Free/Pro users: up to 8 reviews per call.
Team/Enterprise users: more reviews available (uses credits).
"""
import re, json, time, sys, argparse
from pathlib import Path
try:
import requests
except ImportError:
print("Installing dependencies...")
import subprocess
subprocess.run([sys.executable, "-m", "pip", "install", "requests", "-q"])
import requests
# ── Constants ──────────────────────────────────────────────────────────────────
FREE_LIMIT = 8
UPSELL_MSG = (
"\n💡 Want more than 8 reviews?\n"
" Upgrade to VOC.AI Team plan (uses credits): https://voc.ai/pricing\n"
" Pass your token via --token or VOC_TOKEN env variable."
)
API_BASE = "https://apps.voc.ai/api_v2/datahub/voc"
POLL_INTERVAL = 2 # seconds between polls
MAX_POLLS = 30 # max 60 seconds wait
# ── API Client ────────────────────────────────────────────────────────────────
def fetch_reviews(asin: str, country: str = "US", token: str = "",
limit: int = FREE_LIMIT) -> list[dict]:
"""
Fetch reviews from VOC.AI API for a given ASIN.
Returns list of review dicts.
"""
url = f"{API_BASE}/{asin}/reviews"
headers = {"Content-Type": "application/json"}
if token:
headers["X-Token"] = token
payload = {"countryCode": country.upper()}
print(f"🔍 Fetching reviews: ASIN={asin} | Country={country.upper()}", file=sys.stderr)
try:
resp = requests.post(url, json=payload, headers=headers, timeout=30)
except Exception as e:
print(f"❌ Request failed: {e}", file=sys.stderr)
return []
if resp.status_code == 401:
print("❌ Invalid or missing API token. Get one at https://voc.ai/pricing", file=sys.stderr)
return []
if resp.status_code == 403:
print("❌ Access denied. This endpoint requires a VOC.AI Team/Enterprise plan.", file=sys.stderr)
print(" Upgrade at https://voc.ai/pricing", file=sys.stderr)
return []
if not resp.ok:
print(f"❌ API error {resp.status_code}: {resp.text[:200]}", file=sys.stderr)
return []
data = resp.json()
# Handle async response — poll until finished
polls = 0
while not data.get("data", {}).get("finish", True):
if polls >= MAX_POLLS:
print("⚠️ Timed out waiting for VOC.AI to process request.", file=sys.stderr)
break
print(f" ⏳ Processing... (attempt {polls + 1})", file=sys.stderr, end="\r")
time.sleep(POLL_INTERVAL)
polls += 1
try:
resp = requests.post(url, json=payload, headers=headers, timeout=30)
data = resp.json()
except Exception:
break
# Extract reviews from response
reviews_raw = (
data.get("data", {}).get("reviews") or
data.get("data", {}).get("list") or
data.get("reviews") or
[]
)
if not reviews_raw:
# Try to surface any message from API
msg = data.get("message") or data.get("msg") or ""
if msg:
print(f"⚠️ API message: {msg}", file=sys.stderr)
print(f"⚠️ No reviews found. ASIN may be invalid or have no reviews.", file=sys.stderr)
return []
# Normalize review structure
reviews = []
for r in reviews_raw:
review = {
"rating": _extract_int(r, ["rating", "star", "stars", "score"]),
"title": _extract_str(r, ["title", "reviewTitle", "review_title"]),
"body": _extract_str(r, ["body", "content", "text", "reviewContent", "review_content"]),
"date": _extract_str(r, ["date", "reviewDate", "review_date", "createdAt"]),
"verified": bool(r.get("verified") or r.get("verifiedPurchase") or r.get("verified_purchase")),
"helpful": _extract_str(r, ["helpful", "helpfulVotes", "helpful_votes", "helpfulCount"]),
"reviewer": _extract_str(r, ["reviewer", "authorName", "author_name", "userName"]),
}
if review["body"]:
reviews.append(review)
total_available = len(reviews)
reviews = reviews[:limit]
print(f"✅ Got {len(reviews)} reviews (total available: {total_available})", file=sys.stderr)
if total_available <= FREE_LIMIT and not token:
print(UPSELL_MSG, file=sys.stderr)
elif len(reviews) < total_available:
print(f"\n💡 {total_available - len(reviews)} more reviews available. "
f"Increase --limit or upgrade at https://voc.ai/pricing", file=sys.stderr)
return reviews
def _extract_str(obj: dict, keys: list) -> str:
for k in keys:
v = obj.get(k)
if v and isinstance(v, str):
return v.strip()
return ""
def _extract_int(obj: dict, keys: list) -> int | None:
for k in keys:
v = obj.get(k)
if v is not None:
try:
return int(float(str(v)))
except (ValueError, TypeError):
pass
return None
# ── CLI ────────────────────────────────────────────────────────────────────────
def main():
import os
parser = argparse.ArgumentParser(
description="Amazon Review Fetcher (powered by VOC.AI)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 scraper.py B08N5WRWNW
python3 scraper.py B08N5WRWNW --limit 8 --token YOUR_TOKEN
python3 scraper.py B08N5WRWNW --country UK --output reviews.json
API Token:
Get your VOC.AI token at https://voc.ai/pricing
Pass via --token flag or VOC_TOKEN environment variable.
Default: 8 reviews (free). More reviews require Team/Enterprise plan.
"""
)
parser.add_argument("asin", help="Product ASIN (e.g. B08N5WRWNW)")
parser.add_argument("--limit", type=int, default=FREE_LIMIT,
help=f"Max reviews to return (default: {FREE_LIMIT})")
parser.add_argument("--token", default="",
help="VOC.AI API token (or set VOC_TOKEN env var)")
parser.add_argument("--country", default="US",
help="Country code (default: US)")
parser.add_argument("--output", help="Save JSON output to file")
args = parser.parse_args()
token = args.token or os.environ.get("VOC_TOKEN", "")
if not token:
print("⚠️ No API token provided. Attempting without authentication.", file=sys.stderr)
print(" Set VOC_TOKEN env var or use --token for authenticated access.", file=sys.stderr)
reviews = fetch_reviews(args.asin, country=args.country, token=token, limit=args.limit)
if not reviews:
sys.exit(1)
result = json.dumps(reviews, ensure_ascii=False, indent=2)
if args.output:
Path(args.output).write_text(result)
print(f"💾 Saved {len(reviews)} reviews to: {args.output}", file=sys.stderr)
else:
print(result)
if __name__ == "__main__":
main()
FILE:voc.sh
#!/usr/bin/env bash
# Amazon Review Intelligence — main entry point
# Usage: voc.sh <ASIN> [--limit N] [--market amazon.com] [--lang en|zh] [--stars 1-5] [--output report.md]
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
# ── Colors ─────────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
banner() {
echo -e "BLUEBOLD"
cat <<'EOF'
██████╗ ███████╗██╗ ██╗██╗███████╗██╗ ██╗
██╔══██╗██╔════╝██║ ██║██║██╔════╝██║ ██║
██████╔╝█████╗ ██║ ██║██║█████╗ ██║ █╗ ██║
██╔══██╗██╔══╝ ╚██╗ ██╔╝██║██╔══╝ ██║███╗██║
██║ ██║███████╗ ╚████╔╝ ██║███████╗╚███╔███╔╝
╚═╝ ╚═╝╚══════╝ ╚═══╝ ╚═╝╚══════╝ ╚══╝╚══╝
EOF
echo -e "NC Amazon Review Intelligence · mguozhen/amazon-review-scraper"
echo -e " ─────────────────────────────────────────────────────────────\n"
}
usage() {
echo "Usage: voc.sh <ASIN> [options]"
echo ""
echo "Options:"
echo " --limit N Reviews to fetch (default: 8, free tier)"
echo " --token TOKEN VOC.AI API token (or set VOC_TOKEN env var)"
echo " --lang en|zh Report language (default: en)"
echo " --output FILE Save report to file"
echo " --scrape-only Only fetch reviews, skip AI analysis"
echo " --analyze FILE Analyze existing JSON file"
echo " --help Show this help"
echo ""
echo "Examples:"
echo " voc.sh B08N5WRWNW"
echo " voc.sh B08N5WRWNW --lang zh"
echo " voc.sh B08N5WRWNW --token YOUR_TOKEN --limit 50 --output report.md"
echo " voc.sh --analyze reviews.json --asin B08N5WRWNW"
echo ""
echo "API Token:"
echo " Get free token at https://voc.ai/pricing"
echo " Default: 8 reviews (free). More requires Team/Enterprise plan."
exit 0
}
# ── Parse args ─────────────────────────────────────────────────────────────────
ASIN=""; LIMIT=8; MARKET="amazon.com"; LANG="en"
OUTPUT=""; SCRAPE_ONLY=0; ANALYZE_FILE=""
TOKEN="-"
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h) usage ;;
--limit) LIMIT="$2"; shift 2 ;;
--market) MARKET="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
--lang) LANG="$2"; shift 2 ;;
--output) OUTPUT="$2"; shift 2 ;;
--scrape-only) SCRAPE_ONLY=1; shift ;;
--analyze) ANALYZE_FILE="$2"; shift 2 ;;
-*) echo -e "REDUnknown option: $1NC"; usage ;;
*) ASIN="$1"; shift ;;
esac
done
banner
# ── Analyze existing file ──────────────────────────────────────────────────────
if [[ -n "$ANALYZE_FILE" ]]; then
ASIN_ARG=""
[[ -n "$ASIN" ]] && ASIN_ARG="--asin $ASIN"
LANG_ARG="--lang $LANG"
OUTPUT_ARG=""
[[ -n "$OUTPUT" ]] && OUTPUT_ARG="--output $OUTPUT"
python3 "$SKILL_DIR/analyze.py" "$ANALYZE_FILE" $ASIN_ARG $LANG_ARG $OUTPUT_ARG
exit 0
fi
# ── Validate ASIN ──────────────────────────────────────────────────────────────
if [[ -z "$ASIN" ]]; then
echo -e "RED❌ ASIN requiredNC"; usage
fi
if ! echo "$ASIN" | grep -qE '^[A-Z0-9]{10}$'; then
echo -e "YELLOW⚠️ ASIN format warning (expected 10 alphanumeric chars like B08N5WRWNW)NC"
fi
# ── Check deps ─────────────────────────────────────────────────────────────────
if ! command -v python3 &>/dev/null; then
echo -e "RED❌ python3 not foundNC"; exit 1
fi
if [[ $SCRAPE_ONLY -eq 0 ]] && ! command -v claude &>/dev/null; then
echo -e "YELLOW⚠️ claude CLI not found — will scrape only (install Claude Code CLI for AI analysis)NC"
SCRAPE_ONLY=1
fi
# ── Step 1: Scrape ─────────────────────────────────────────────────────────────
TEMP_JSON=$(mktemp /tmp/amazon_reviews_XXXXXX.json)
trap "rm -f $TEMP_JSON" EXIT
echo -e "BLUE[1/2] Scraping reviews...NC"
SCRAPE_ARGS="$ASIN --limit $LIMIT"
[[ -n "$TOKEN" ]] && SCRAPE_ARGS="$SCRAPE_ARGS --token $TOKEN"
if [[ -n "$OUTPUT" && $SCRAPE_ONLY -eq 1 ]]; then
python3 "$SKILL_DIR/scraper.py" $SCRAPE_ARGS --output "$OUTPUT"
else
python3 "$SKILL_DIR/scraper.py" $SCRAPE_ARGS --output "$TEMP_JSON"
fi
COUNT=$(python3 -c "import json; print(len(json.load(open('$TEMP_JSON'))))" 2>/dev/null || echo "0")
if [[ "$COUNT" -eq 0 ]]; then
echo -e "RED❌ No reviews collected. Amazon may be blocking requests.NC"
echo -e " Try again in a few minutes, or use a different network."
exit 1
fi
echo -e "GREEN✓ Collected $COUNT reviewsNC\n"
[[ $SCRAPE_ONLY -eq 1 ]] && exit 0
# ── Step 2: Analyze ────────────────────────────────────────────────────────────
echo -e "BLUE[2/2] AI analysis (Claude)...NC"
ANALYZE_ARGS="$TEMP_JSON --asin $ASIN --market $MARKET --lang $LANG"
[[ -n "$OUTPUT" ]] && ANALYZE_ARGS="$ANALYZE_ARGS --output $OUTPUT"
python3 "$SKILL_DIR/analyze.py" $ANALYZE_ARGS
echo -e "\nGREENBOLD✅ Done!NC"
[[ -n "$OUTPUT" ]] && echo -e " Report saved to: YELLOW$OUTPUTNC"
Email account manager with IMAP/SMTP support and local database. Manage multiple email accounts, sync inbox, send emails, search, set filters, and generate d...
--- name: email-manager-with-db description: "Email account manager with IMAP/SMTP support and local database. Manage multiple email accounts, sync inbox, send emails, search, set filters, and generate daily send reports. Triggers: email manager, manage email, send email, check inbox, email account, imap smtp, email report, email filter" allowed-tools: Bash --- # Email Manager Skill This skill manages email accounts and interacts with them via IMAP and SMTP. ## Commands ### `account` Manage email accounts. - **`add`**: Add a new email account. - `node cli.js account add --email <email> --password <app-password> [--imap-host <host>] [--imap-port <port>] [--smtp-host <host>] [--smtp-port <port>]` - **`list`**: List all configured email accounts. - `node cli.js account list` - **`remove`**: Remove an email account. - `node cli.js account remove <account-id>` ### `test` Test the IMAP and SMTP connection for an account. `node cli.js test <account-id>` ### `sync` Sync emails from the server. `node cli.js sync <account-id> [--folder <folder-name>] [--limit <number>]` ### `inbox` List emails in the inbox. `node cli.js inbox <account-id> [--limit <number>] [--unread] [--no-filtered]` ### `read` Read the content of a specific email. `node cli.js read <email-id>` ### `send` Send an email. `node cli.js send <account-id> --to <recipient> --subject "<subject>" --body "<body>"` ### `search` Search for emails. `node cli.js search <account-id> --query "<query>"` ### `folders` List all folders for an account. `node cli.js folders <account-id>` ### `filter` Manage email filters. - **`list`**: List all filter rules. - `node cli.js filter list [account-id]` - **`add`**: Add a new filter rule. - `node cli.js filter add --field <from|to|subject> --pattern "<pattern>" [--account-id <id>]` - **`remove`**: Remove a filter rule. - `node cli.js filter remove <rule-id>` ### `stats` Show statistics about emails. `node cli.js stats [account-id]` ### `report` Daily send report: how many emails were sent and how many failed. Defaults to today. Use `--date` to specify a date, or `--days` for a multi-day range. `node cli.js report [account-id] [--date YYYY-MM-DD] [--days <number>]` - No flags: today's report - `--date 2026-03-31`: report for a specific date - `--days 7`: report for the last 7 days (broken down by day) Output includes: total / sent / failed counts, success rate, and recent failure details (recipient, subject, error message). FILE:README.md <p align="center"> <img src="https://cdn-icons-png.flaticon.com/512/732/732200.png" width="80" alt="Email Manager"> </p> <h1 align="center">Email Manager with DB</h1> <p align="center"> <strong>Multi-account IMAP/SMTP email manager with local SQLite, RFC 8058 one-click unsubscribe, and suppression list — built for cold email outreach that doesn't get blacklisted.</strong> </p> <p align="center"> <a href="#quick-start"><img src="https://img.shields.io/badge/setup-2min-brightgreen?style=flat-square" alt="2min Setup"></a> <a href="https://agentskills.io"><img src="https://img.shields.io/badge/Agent%20Skills-compatible-blue?style=flat-square" alt="Agent Skills"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT License"></a> <a href="https://datatracker.ietf.org/doc/html/rfc8058"><img src="https://img.shields.io/badge/RFC%208058-compliant-purple?style=flat-square" alt="RFC 8058"></a> <img src="https://img.shields.io/badge/node-18+-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node 18+"> </p> <p align="center"> <a href="#what-makes-this-different">Why</a> • <a href="#quick-start">Quick Start</a> • <a href="#commands">Commands</a> • <a href="#unsubscribe-compliance">Unsubscribe</a> • <a href="#architecture">Architecture</a> </p> --- ## What makes this different Most email libraries just wrap nodemailer. This one adds the stuff that keeps you out of spam folder: - **Multi-account rotation** — switch between Gmail/Outlook/custom SMTP accounts to spread load - **RFC 8058 One-Click Unsubscribe** — Hotmail and Gmail both require this for bulk mail; without it, your delivery tanks - **HMAC-signed unsubscribe tokens** — recipients can't forge unsub requests for other emails - **Automatic suppression list** — unsubscribed addresses are blocked from future sends (shared SQLite) - **Local inbox sync** — IMAP pull into SQLite for fast search, filter, read - **Filter rules** — gmail-style filters that auto-label/archive incoming mail - **Daily send reports** — per-account stats for volume tracking and warmup monitoring ## Quick Start ```bash # Clone into your Claude Code skills directory git clone https://github.com/mguozhen/email-manager-with-db.git \ ~/.claude/skills/email-manager-with-db cd ~/.claude/skills/email-manager-with-db npm install # Add your first account (Gmail with App Password) node cli.js account add \ --email [email protected] \ --password "xxxx xxxx xxxx xxxx" \ --imap-host imap.gmail.com \ --smtp-host smtp.gmail.com ``` That's it. Now you can send, receive, search, filter. ## Commands ### Accounts ```bash node cli.js account add --email <email> --password <app-password> node cli.js account list node cli.js account remove <id> node cli.js test <account-id> # verify IMAP+SMTP work ``` ### Sending ```bash node cli.js send <account-id> \ --to [email protected] \ --subject "Hello" \ --body "Plain text body" # HTML body with automatic List-Unsubscribe headers node cli.js send <account-id> \ --to [email protected] \ --subject "Campaign" \ --html-file campaign.html ``` ### Inbox ```bash node cli.js sync <account-id> # IMAP sync to local DB node cli.js inbox <account-id> --unread # list unread node cli.js read <email-id> # read email content node cli.js search <query> # full-text search ``` ### Suppression list (unsubscribe) ```bash node cli.js unsub list # view all suppressed addresses node cli.js unsub add <email> # manually suppress node cli.js unsub remove <email> # remove from list ``` ### Reports ```bash node cli.js report <account-id> # today's send stats ``` ## Unsubscribe Compliance This is the part most libraries skip. ### What it does Every outbound email automatically gets these headers: ``` List-Unsubscribe: <https://track.yourdomain.com/unsubscribe?e=...&t=HMAC>, <mailto:[email protected]> List-Unsubscribe-Post: List-Unsubscribe=One-Click Precedence: bulk ``` ### Why dual HTTPS + mailto matters - **Gmail / Yahoo** prefer the HTTPS link (one-click button in UI) - **Hotmail / Outlook** require `mailto:` fallback — **without it, one-click button won't render and you go to spam** - **RFC 8058** says mail clients should POST to the HTTPS URL; our server handles both GET (browser) and POST (one-click) ### HMAC tokens ```javascript token = hmac_sha256(secret, email.toLowerCase()).slice(0, 16) ``` Recipients can't forge unsub requests for other emails. If someone tries `[email protected]&t=stolen_token`, the verification fails and returns 400. ### Suppression list enforcement Once a recipient clicks unsubscribe: 1. Their email is added to `suppressions` table 2. Next time you call `sendEmail(to: [email protected])`, it throws `SUPPRESSED` error **before** hitting SMTP 3. No accidental re-sends, no compliance violations ### Bypass flags (for transactional mail) ```javascript await sendEmail(accountId, { to: recipient, subject: 'Password reset', text: '...', skipUnsubHeader: true, // Don't add List-Unsubscribe (1:1 transactional) skipSuppressionCheck: true, // Send even if suppressed (account-critical) }); ``` ## Configuration ### Environment variables | Variable | Required | Description | |----------|----------|-------------| | `UNSUB_SECRET` | Recommended | HMAC secret for unsub tokens. Generate: `openssl rand -hex 32` | | `UNSUB_BASE_URL` | Yes (for headers) | Public URL of your tracking/unsub server | | `UNSUB_MAILTO_DOMAIN` | Optional | Domain for `mailto:` fallback (default: `solvea.cx`) | | `TRACKING_DB_PATH` | Optional | Path to shared suppression SQLite | | `EMAIL_DB_PATH` | Optional | Path to main email DB (default: `emails.db` in cwd) | ### Unsubscribe server (Python) You need to run an HTTP server that handles `GET/POST /unsubscribe`. A reference implementation that shares the same SQLite + HMAC secret: ```python # Core logic (see examples/email_tracker.py for full server) def _unsub_token(email): return hmac.new( UNSUB_SECRET.encode(), email.lower().strip().encode(), hashlib.sha256, ).hexdigest()[:16] def _verify_unsub_token(email, token): return hmac.compare_digest(_unsub_token(email), token or "") # GET /unsubscribe?e=<email>&t=<token> → HTML confirmation page # POST /unsubscribe (RFC 8058 one-click) → 200 "unsubscribed" ``` ## Architecture ``` email-manager-with-db/ ├── SKILL.md # Claude Code / Agent Skills definition ├── cli.js # CLI entry point ├── src/ │ ├── accounts.js # Account CRUD │ ├── db.js # SQLite schema + init │ ├── smtp.js # Send email (with auto unsub headers) │ ├── imap.js # IMAP sync │ ├── filters.js # Gmail-style filter rules │ ├── html-to-text.js # Plain-text fallback from HTML │ └── unsubscribe.js # HMAC tokens, suppression list, header building ├── examples/ │ └── email_tracker.py # Reference Python server for /unsubscribe └── tests/ └── test_unsubscribe.js # 24 regression tests ``` ### Data model ```sql -- Main email DB CREATE TABLE accounts (id, email, username, app_password, smtp_host, ...); CREATE TABLE sent_emails (id, account_id, to_addr, subject, body, status, error, sent_at); CREATE TABLE inbox_emails (id, account_id, from_addr, subject, body, unread, ...); CREATE TABLE filters (id, account_id, rule, action, enabled); -- Shared suppression DB (usually tracking.db) CREATE TABLE suppressions (email PRIMARY KEY, reason, source, created_at); ``` ## Running tests ```bash node tests/test_unsubscribe.js ``` ``` === Token generation & verification === ✓ makeToken returns deterministic 16-char hex ✓ makeToken is case-insensitive + trims ✓ different emails produce different tokens ✓ verifyToken accepts valid token ✓ verifyToken rejects invalid token ✓ verifyToken rejects cross-email token === Header building === ✓ buildHeaders includes HTTPS + mailto + One-Click ✓ HTTPS link contains URL-encoded email ✓ mailto link includes HMAC token === Suppression list === ✓ isSuppressed returns null for unknown email ✓ suppress + isSuppressed round-trip ✓ suppress is case-insensitive ✓ unsuppress removes entry ✓ listSuppressions returns array === sendEmail integration === ✓ List-Unsubscribe header injected ✓ List-Unsubscribe-Post header present ✓ mailto: fallback in header (Hotmail compat) ✓ skipUnsubHeader bypasses injection ✓ Suppression blocks send with SUPPRESSED error code ✓ skipSuppressionCheck bypasses block 20 passed, 0 failed ``` ## FAQ <details> <summary><strong>Why SQLite instead of Postgres?</strong></summary> Listmonk's architecture inspired this — single binary, single file DB, no daemon to manage. For under 1M emails, SQLite with WAL mode is faster and operationally simpler than Postgres. </details> <details> <summary><strong>Does this work with Gmail App Passwords?</strong></summary> Yes. Enable 2FA on your Google account, generate an App Password at myaccount.google.com/apppasswords, and use that as the `password`. Standard Gmail SMTP: `smtp.gmail.com:587` or `smtp.gmail.com:465`. </details> <details> <summary><strong>Can I use this without a public unsub server?</strong></summary> The `List-Unsubscribe` header will be omitted if `UNSUB_BASE_URL` is not configured. You can still use the library, but your emails will look more like bulk/spam to ISPs. A Cloudflare tunnel (free) to localhost works fine for low volume. </details> <details> <summary><strong>Is this replacement for Mailgun/SendGrid?</strong></summary> For outbound, yes — if you have your own SMTP reputation (warmed Gmail accounts, custom domain with SPF/DKIM/DMARC). For transactional at scale, no — use a proper ESP. This tool is for cold outreach and small-batch campaigns. </details> ## Contributing PRs welcome. All changes must pass `node tests/test_unsubscribe.js`. ## License [MIT](LICENSE) --- <p align="center"> Built for <a href="https://code.claude.com">Claude Code</a> • Inspired by <a href="https://listmonk.app">listmonk</a> and <a href="https://www.rfc-editor.org/rfc/rfc8058.html">RFC 8058</a> </p> FILE:cli.js const { addAccount, listAccounts, removeAccount, getAccount } = require('./src/accounts'); const { syncInbox, listFolders, testConnection, moveMessage } = require('./src/imap'); const { sendEmail, testSmtp } = require('./src/smtp'); const { shouldFilter, addRule, listRules, removeRule } = require('./src/filters'); const { getDb } = require('./src/db'); const args = process.argv.slice(2); const cmd = args[0]; const subcmd = args[1]; function getFlag(name, defaultVal = null) { const idx = args.indexOf(`--name`); if (idx === -1) return defaultVal; return args[idx + 1] || defaultVal; } function hasFlag(name) { return args.includes(`--name`); } function json(obj) { console.log(JSON.stringify(obj, null, 2)); } async function main() { try { switch (cmd) { case 'account': { switch (subcmd) { case 'add': { const email = getFlag('email'); const password = getFlag('password'); const imapHost = getFlag('imap-host', 'imap.gmail.com'); const imapPort = parseInt(getFlag('imap-port', '993')); const smtpHost = getFlag('smtp-host', 'smtp.gmail.com'); const smtpPort = parseInt(getFlag('smtp-port', '465')); if (!email || !password) { console.error('Required: --email and --password'); process.exit(1); } const result = addAccount({ email, appPassword: password, imapHost, imapPort, smtpHost, smtpPort }); json({ ok: true, ...result }); break; } case 'list': { json(listAccounts()); break; } case 'remove': { const id = args[2]; json(removeAccount(id)); break; } default: console.error('account subcommands: add, list, remove'); } break; } case 'test': { const accountId = args[1]; const account = getAccount(accountId); console.log(account) if (!account) { console.error('Account not found'); process.exit(1); } console.log('Testing IMAP...'); const imapResult = await testConnection(account); console.log('IMAP:', JSON.stringify(imapResult)); console.log('Testing SMTP...'); const smtpResult = await testSmtp(account); console.log('SMTP:', JSON.stringify(smtpResult)); json({ imap: imapResult, smtp: smtpResult }); break; } case 'sync': { const accountId = args[1]; const folder = getFlag('folder', 'INBOX'); const limit = parseInt(getFlag('limit', '50')); const refilterAll = hasFlag('refilter-all'); const result = await syncInbox(accountId, folder, limit, refilterAll); json(result); break; } case 'inbox': { const accountId = args[1]; const limit = parseInt(getFlag('limit', '20')); const unreadOnly = hasFlag('unread'); const showFiltered = !hasFlag('no-filtered'); const showTrash = !hasFlag('no-trash'); const db = getDb(); let query = 'SELECT id, uid, from_addr, from_name, subject, date, snippet, is_read, is_filtered, filter_reason FROM emails WHERE account_id = ?'; const params = [accountId]; if (unreadOnly) { query += ' AND is_read = 0'; } if (!showFiltered) { query += ' AND is_filtered = 0'; } if (!showTrash) { query += ` AND folder NOT LIKE '%Trash%' AND folder NOT LIKE '%Bin%'`; } query += ' ORDER BY date DESC LIMIT ?'; params.push(limit); const emails = db.prepare(query).all(...params); json({ count: emails.length, emails }); break; } case 'move': { const emailId = args[1]; const destination = args[2]; if (!emailId || !destination) { console.error('Usage: move <email-id> <destination-folder>'); process.exit(1); } const db = getDb(); const email = db.prepare('SELECT account_id, uid, folder FROM emails WHERE id = ?').get(emailId); if (!email) { console.error('Email not found'); process.exit(1); } const result = await moveMessage(email.account_id, email.uid, email.folder, destination); if (result.ok) { // Also update the local db db.prepare('UPDATE emails SET folder = ? WHERE id = ?').run(destination, emailId); console.log(`Email emailId moved to destination`); } json(result); break; } case 'read': { const emailId = args[1]; const db = getDb(); const email = db.prepare('SELECT * FROM emails WHERE id = ?').get(emailId); if (!email) { console.error('Email not found'); process.exit(1); } // Don't dump huge HTML, prefer text if (email.body_html && email.body_html.length > 5000) { email.body_html = email.body_html.substring(0, 5000) + '... [truncated]'; } json(email); break; } case 'send': { const accountId = args[1]; const to = getFlag('to'); const cc = getFlag('cc'); const bcc = getFlag('bcc'); const subject = getFlag('subject'); const body = getFlag('body'); const inReplyTo = getFlag('reply-to'); if (!to || !subject) { console.error('Required: --to and --subject'); process.exit(1); } const result = await sendEmail(accountId, { to, cc, bcc, subject, text: body, inReplyTo }); json({ ok: true, ...result }); break; } case 'search': { const accountId = args[1]; const query = getFlag('query', ''); const limit = parseInt(getFlag('limit', '20')); const db = getDb(); const emails = db.prepare(` SELECT id, uid, from_addr, from_name, subject, date, snippet, is_read, is_filtered FROM emails WHERE account_id = ? AND (subject LIKE ? OR from_addr LIKE ? OR from_name LIKE ? OR body_text LIKE ?) ORDER BY date DESC LIMIT ? `).all(accountId, `%query%`, `%query%`, `%query%`, `%query%`, limit); json({ count: emails.length, emails }); break; } case 'folders': { const accountId = args[1]; const folders = await listFolders(accountId); json(folders); break; } case 'filter': { switch (subcmd) { case 'list': { const accountId = args[2] || null; json(listRules(accountId)); break; } case 'add': { const field = getFlag('field', 'from'); const pattern = getFlag('pattern'); const accountId = getFlag('account-id'); if (!pattern) { console.error('Required: --pattern'); process.exit(1); } addRule(accountId, field, pattern); json({ ok: true, field, pattern }); break; } case 'remove': { removeRule(parseInt(args[2])); json({ ok: true }); break; } default: console.error('filter subcommands: list, add, remove'); } break; } case 'report': { // Daily send report: defaults to today, supports --date YYYY-MM-DD and --days N const db = getDb(); const accountId = args[1] && !args[1].startsWith('--') ? args[1] : null; const dateFlag = getFlag('date'); const daysBack = parseInt(getFlag('days', '1')); // Build date range let startDate, endDate; if (dateFlag) { startDate = dateFlag; endDate = dateFlag; } else if (daysBack > 1) { // Last N days const end = new Date(); const start = new Date(); start.setDate(start.getDate() - (daysBack - 1)); startDate = start.toISOString().slice(0, 10); endDate = end.toISOString().slice(0, 10); } else { // Today startDate = new Date().toISOString().slice(0, 10); endDate = startDate; } const accountFilter = accountId ? 'AND account_id = ?' : ''; const baseParams = accountId ? [startDate, endDate, accountId] : [startDate, endDate]; const sent = db.prepare(` SELECT COUNT(*) as cnt FROM sent_emails WHERE date(sent_at) BETWEEN date(?) AND date(?) AND status = 'sent' accountFilter `).get(...baseParams).cnt; const failed = db.prepare(` SELECT COUNT(*) as cnt FROM sent_emails WHERE date(sent_at) BETWEEN date(?) AND date(?) AND status = 'failed' accountFilter `).get(...baseParams).cnt; const total = sent + failed; // Break down by day if range > 1 let byDay = null; if (startDate !== endDate) { byDay = db.prepare(` SELECT date(sent_at) as day, SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) as sent, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, COUNT(*) as total FROM sent_emails WHERE date(sent_at) BETWEEN date(?) AND date(?) accountFilter GROUP BY day ORDER BY day DESC `).all(...baseParams); } // Recent failures const failureParams = accountId ? [startDate, endDate, accountId, 5] : [startDate, endDate, 5]; const recentFailures = db.prepare(` SELECT sent_at, to_addr, subject, error, account_id FROM sent_emails WHERE date(sent_at) BETWEEN date(?) AND date(?) AND status = 'failed' accountFilter ORDER BY sent_at DESC LIMIT ? `).all(...failureParams); const report = { period: startDate === endDate ? startDate : `startDate ~ endDate`, summary: { total, sent, failed, success_rate: total > 0 ? `((sent / total) * 100).toFixed(1)%` : 'N/A' }, ...(byDay ? { by_day: byDay } : {}), ...(recentFailures.length > 0 ? { recent_failures: recentFailures } : {}) }; json(report); break; } case 'stats': { const accountId = args[1]; const db = getDb(); let where = ''; const params = []; if (accountId) { where = 'WHERE account_id = ?'; params.push(accountId); } const total = db.prepare(`SELECT COUNT(*) as cnt FROM emails where`).get(...params).cnt; const unread = db.prepare(`SELECT COUNT(*) as cnt FROM emails 'WHERE' is_read = 0`).get(...params).cnt; const filtered = db.prepare(`SELECT COUNT(*) as cnt FROM emails 'WHERE' is_filtered = 1`).get(...params).cnt; const sent = db.prepare(`SELECT COUNT(*) as cnt FROM sent_emails ''`).get(...params).cnt; json({ total, unread, filtered, sent, accounts: listAccounts() }); break; } default: console.log(` Email Manager CLI Commands: account add --email <email> --password <app-password> account list account remove <id> test <account-id> Test IMAP + SMTP connection sync <account-id> Fetch new emails from server inbox <account-id> List emails (--limit, --unread, --no-filtered) read <email-id> Read full email move <email-id> <folder> Move email to folder send <account-id> Send email (--to, --subject, --body) search <account-id> Search emails (--query) folders <account-id> List mailbox folders filter list [account-id] Show filter rules filter add Add rule (--field, --pattern) filter remove <rule-id> Remove a filter rule stats [account-id] Show statistics report [account-id] Daily send report (--date YYYY-MM-DD, --days N) `); } } catch (err) { console.error('Error:', err.message); process.exit(1); } } main(); FILE:examples/email_tracker.py #!/usr/bin/env python3 """ Solvea Email Tracking Server — open rate & click rate Serves: GET /sent?id=EMAIL_ID&to=ADDR&subject=S → logs send event GET /open?id=EMAIL_ID → 1x1 pixel, logs open event (with bot filter) GET /click?id=EMAIL_ID&url=URL → redirect + logs click event (with bot filter) GET /stats → JSON stats (raw + bot-filtered) GET /hot-leads → JSON list of leads that opened/clicked (real opens only) GET /health → JSON health check Reads real client IP from CF-Connecting-IP / X-Forwarded-For / X-Real-IP headers (cloudflared tunnel terminates locally, so RemoteAddr is always 127.0.0.1). """ import sqlite3 import base64 import hashlib import hmac import os import urllib.parse import json import re import sys from datetime import datetime, timedelta from http.server import HTTPServer, BaseHTTPRequestHandler from pathlib import Path from datetime import date DB_PATH = Path("/Users/guozhen/MailOutbound/tracking.db") PORT = 7788 UNSUB_SECRET = os.environ.get("UNSUB_SECRET", "solvea-default-secret-change-me") def _unsub_token(email: str) -> str: """Generate HMAC-SHA256 token for recipient email. 16 hex chars = 64-bit security.""" return hmac.new( UNSUB_SECRET.encode(), email.lower().strip().encode(), hashlib.sha256, ).hexdigest()[:16] def _verify_unsub_token(email: str, token: str) -> bool: return hmac.compare_digest(_unsub_token(email), token or "") PIXEL = base64.b64decode( "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" ) TRUSTED_LOCAL_IPS = {"127.0.0.1", "::1", "localhost"} BOT_UA_PATTERNS = re.compile( r"(googleimageproxy|ggpht|gmailimageproxy|" r"yahoomailproxy|bingpreview|" r"mimecast|proofpoint|barracuda|" r"microsoft|outlook|exchange|symantec|messagelabs|" r"forcepoint|trendmicro|sophos|" r"cloudmark|agari|abuse\.ch|urlscan|virustotal|" r"headlesschrome|phantomjs|puppeteer|playwright|" r"python-requests|curl|wget|go-http-client|java/|okhttp)", re.IGNORECASE, ) def get_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS sent ( id INTEGER PRIMARY KEY AUTOINCREMENT, email_id TEXT NOT NULL, to_addr TEXT, subject TEXT, ts TEXT NOT NULL DEFAULT (datetime('now')), delivered_at TEXT ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS opens ( id INTEGER PRIMARY KEY AUTOINCREMENT, email_id TEXT NOT NULL, ts TEXT NOT NULL DEFAULT (datetime('now')), ip TEXT, user_agent TEXT, is_bot INTEGER NOT NULL DEFAULT 0, bot_reason TEXT ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS clicks ( id INTEGER PRIMARY KEY AUTOINCREMENT, email_id TEXT NOT NULL, url TEXT, ts TEXT NOT NULL DEFAULT (datetime('now')), ip TEXT, user_agent TEXT, is_bot INTEGER NOT NULL DEFAULT 0, bot_reason TEXT ) """) for ddl in [ "ALTER TABLE sent ADD COLUMN delivered_at TEXT", "ALTER TABLE opens ADD COLUMN user_agent TEXT", "ALTER TABLE opens ADD COLUMN is_bot INTEGER NOT NULL DEFAULT 0", "ALTER TABLE opens ADD COLUMN bot_reason TEXT", "ALTER TABLE clicks ADD COLUMN user_agent TEXT", "ALTER TABLE clicks ADD COLUMN is_bot INTEGER NOT NULL DEFAULT 0", "ALTER TABLE clicks ADD COLUMN bot_reason TEXT", ]: try: conn.execute(ddl) except sqlite3.OperationalError: pass conn.execute(""" CREATE TABLE IF NOT EXISTS suppressions ( email TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT 'unsubscribe', source TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_suppressions_email ON suppressions(email)") conn.execute("CREATE INDEX IF NOT EXISTS idx_opens_email ON opens(email_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_opens_bot ON opens(email_id, is_bot)") conn.execute("CREATE INDEX IF NOT EXISTS idx_clicks_email ON clicks(email_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_clicks_bot ON clicks(email_id, is_bot)") conn.execute("CREATE INDEX IF NOT EXISTS idx_sent_ts ON sent(ts)") conn.commit() return conn class TrackingHandler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): sys.stdout.write( f"[{datetime.utcnow().isoformat()}Z] " f"{self._real_ip()} " f"{self.command} {self.path} " f"ua=\"{(self.headers.get('User-Agent') or '-')[:80]}\"\n" ) sys.stdout.flush() def _real_ip(self): remote = self.client_address[0] if self.client_address else "unknown" if remote not in TRUSTED_LOCAL_IPS: return remote cf = (self.headers.get("CF-Connecting-IP") or "").strip() if cf: return cf xff = (self.headers.get("X-Forwarded-For") or "").strip() if xff: return xff.split(",")[0].strip() xr = (self.headers.get("X-Real-IP") or "").strip() if xr: return xr return remote def _classify_bot(self, email_id, ua, conn, event_type="open"): if ua and BOT_UA_PATTERNS.search(ua): return "ua_blacklist" if not ua or not ua.strip(): return "empty_ua" row = conn.execute( "SELECT COALESCE(delivered_at, ts) AS t FROM sent WHERE email_id = ? ORDER BY id DESC LIMIT 1", (email_id,), ).fetchone() if row and row[0]: try: sent_ts = datetime.fromisoformat(row[0].replace("Z", "")) if (datetime.utcnow() - sent_ts).total_seconds() < 60: return "too_fast" except ValueError: pass recent = conn.execute( f"SELECT COUNT(*) FROM {'opens' if event_type=='open' else 'clicks'} " f"WHERE email_id = ? AND ts > datetime('now', '-2 seconds')", (email_id,), ).fetchone()[0] if recent > 0: return "rapid_replay" return None def do_HEAD(self): self.do_GET() def do_POST(self): """RFC 8058 One-Click Unsubscribe: Mail clients POST to List-Unsubscribe URL.""" parsed = urllib.parse.urlparse(self.path) if parsed.path != "/unsubscribe": self.send_response(404) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"not found") return # Parse query string + body (one-click sends both) params = dict(urllib.parse.parse_qsl(parsed.query)) try: length = int(self.headers.get("Content-Length") or 0) if length > 0: body = self.rfile.read(length).decode("utf-8", errors="ignore") params.update(dict(urllib.parse.parse_qsl(body))) except Exception: pass email = (params.get("e") or "").strip().lower() token = (params.get("t") or "").strip() if not email or not token or not _verify_unsub_token(email, token): self.send_response(400) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"invalid token") return with get_db() as conn: conn.execute( "INSERT OR REPLACE INTO suppressions (email, reason, source, created_at) " "VALUES (?, 'unsubscribe', 'one-click', datetime('now'))", (email,), ) print(f"[Unsub] One-click: {email}", flush=True) self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"unsubscribed") def do_GET(self): parsed = urllib.parse.urlparse(self.path) params = dict(urllib.parse.parse_qsl(parsed.query)) ip = self._real_ip() ua = self.headers.get("User-Agent", "") or "" if parsed.path == "/sent": email_id = params.get("id", "unknown") to_addr = params.get("to", "") subject = params.get("subject", "") with get_db() as conn: conn.execute( "INSERT INTO sent (email_id, to_addr, subject) VALUES (?, ?, ?)", (email_id, to_addr, subject), ) self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Cache-Control", "no-store, private, max-age=0") self.end_headers() if self.command != "HEAD": self.wfile.write(b"ok") elif parsed.path == "/delivered": email_id = params.get("id", "unknown") with get_db() as conn: conn.execute( "UPDATE sent SET delivered_at = datetime('now') " "WHERE email_id = ? AND delivered_at IS NULL", (email_id,), ) self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Cache-Control", "no-store") self.end_headers() if self.command != "HEAD": self.wfile.write(b"ok") elif parsed.path == "/open": email_id = params.get("id", "unknown") with get_db() as conn: reason = self._classify_bot(email_id, ua, conn, event_type="open") conn.execute( "INSERT INTO opens (email_id, ip, user_agent, is_bot, bot_reason) " "VALUES (?, ?, ?, ?, ?)", (email_id, ip, ua, 1 if reason else 0, reason), ) self.send_response(200) self.send_header("Content-Type", "image/gif") self.send_header("Cache-Control", "no-store, private, max-age=0") self.send_header("Pragma", "no-cache") self.end_headers() if self.command != "HEAD": self.wfile.write(PIXEL) elif parsed.path == "/click": email_id = params.get("id", "unknown") url = params.get("url", "https://solvea.cx") with get_db() as conn: reason = self._classify_bot(email_id, ua, conn, event_type="click") conn.execute( "INSERT INTO clicks (email_id, url, ip, user_agent, is_bot, bot_reason) " "VALUES (?, ?, ?, ?, ?, ?)", (email_id, url, ip, ua, 1 if reason else 0, reason), ) self.send_response(302) self.send_header("Location", url) self.send_header("Cache-Control", "no-store, private, max-age=0") self.send_header("Pragma", "no-cache") self.end_headers() elif parsed.path == "/hot-leads": with get_db() as conn: rows = conn.execute(""" SELECT s.to_addr, s.subject, s.email_id, s.ts AS sent_ts, (SELECT COUNT(*) FROM opens o WHERE o.email_id = s.email_id AND o.is_bot = 0) AS open_count, (SELECT COUNT(*) FROM clicks c WHERE c.email_id = s.email_id AND c.is_bot = 0) AS click_count FROM sent s WHERE s.to_addr IS NOT NULL AND s.to_addr != '' AND (EXISTS (SELECT 1 FROM opens o WHERE o.email_id = s.email_id AND o.is_bot = 0) OR EXISTS (SELECT 1 FROM clicks c WHERE c.email_id = s.email_id AND c.is_bot = 0)) ORDER BY click_count DESC, open_count DESC """).fetchall() leads = [ {"to": r[0], "subject": r[1], "tracking_id": r[2], "sent_at": r[3], "opens": r[4], "clicks": r[5]} for r in rows ] body = json.dumps(leads).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Cache-Control", "no-store") self.end_headers() if self.command != "HEAD": self.wfile.write(body) elif parsed.path == "/stats": today_iso = date.today().isoformat() with get_db() as conn: total_sent = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM sent" ).fetchone()[0] today_sent = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM sent WHERE ts LIKE ?", (f"{today_iso}%",), ).fetchone()[0] total_opens_raw = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM opens" ).fetchone()[0] total_opens_real = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM opens WHERE is_bot = 0" ).fetchone()[0] today_opens_real = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM opens " "WHERE is_bot = 0 AND ts LIKE ?", (f"{today_iso}%",), ).fetchone()[0] total_clicks_raw = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM clicks" ).fetchone()[0] total_clicks_real = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM clicks WHERE is_bot = 0" ).fetchone()[0] today_clicks_real = conn.execute( "SELECT COUNT(DISTINCT email_id) FROM clicks " "WHERE is_bot = 0 AND ts LIKE ?", (f"{today_iso}%",), ).fetchone()[0] bot_reasons = { r[0]: r[1] for r in conn.execute( "SELECT bot_reason, COUNT(*) FROM opens " "WHERE is_bot = 1 GROUP BY bot_reason" ).fetchall() } stats = { "today": today_iso, "sent": {"today": today_sent, "total": total_sent}, "opens": { "today_real": today_opens_real, "total_real": total_opens_real, "total_raw": total_opens_raw, "bot_filtered": total_opens_raw - total_opens_real, }, "clicks": { "today_real": today_clicks_real, "total_real": total_clicks_real, "total_raw": total_clicks_raw, "bot_filtered": total_clicks_raw - total_clicks_real, }, "bot_reasons": bot_reasons, "rates": { "open_rate_raw": f"{total_opens_raw/total_sent*100:.1f}%" if total_sent else "N/A", "open_rate_real": f"{total_opens_real/total_sent*100:.1f}%" if total_sent else "N/A", "click_rate_real": f"{total_clicks_real/total_sent*100:.1f}%" if total_sent else "N/A", }, } body = json.dumps(stats, indent=2).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Cache-Control", "no-store") self.end_headers() if self.command != "HEAD": self.wfile.write(body) elif parsed.path == "/unsubscribe": email = (params.get("e") or "").strip().lower() token = (params.get("t") or "").strip() if not email or not token or not _verify_unsub_token(email, token): self.send_response(400) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() if self.command != "HEAD": self.wfile.write(b"<h1>Invalid unsubscribe link</h1><p>This link is invalid or has been tampered with.</p>") return with get_db() as conn: conn.execute( "INSERT OR REPLACE INTO suppressions (email, reason, source, created_at) " "VALUES (?, 'unsubscribe', 'browser', datetime('now'))", (email,), ) print(f"[Unsub] Browser: {email}", flush=True) html = f"""<!DOCTYPE html> <html><head><meta charset="utf-8"><title>Unsubscribed</title> <style>body{{font-family:-apple-system,sans-serif;max-width:500px;margin:80px auto;padding:0 20px;color:#333}} h1{{color:#0a7d3b}}.email{{background:#f5f5f5;padding:8px 12px;border-radius:4px;display:inline-block;font-family:monospace}}</style> </head><body> <h1>You have been unsubscribed</h1> <p>The email address <span class="email">{email}</span> has been removed from our mailing list.</p> <p>You will no longer receive marketing emails from us. We are sorry to see you go.</p> <p style="color:#888;font-size:13px;margin-top:40px">If this was a mistake, contact us at [email protected] to resubscribe.</p> </body></html>""" self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Cache-Control", "no-store") self.end_headers() if self.command != "HEAD": self.wfile.write(html.encode()) elif parsed.path == "/health": body = json.dumps({"ok": True, "ts": datetime.utcnow().isoformat()}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Cache-Control", "no-store") self.end_headers() if self.command != "HEAD": self.wfile.write(body) else: self.send_response(404) self.send_header("Content-Type", "text/plain") self.end_headers() if self.command != "HEAD": self.wfile.write(b"not found") def run(): server = HTTPServer(("0.0.0.0", PORT), TrackingHandler) print(f"Tracking server on :{PORT}") print(f"DB: {DB_PATH}") server.serve_forever() if __name__ == "__main__": run() FILE:package-lock.json { "name": "email-manager", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "email-manager", "version": "1.0.0", "license": "ISC", "dependencies": { "better-sqlite3": "^12.8.0", "dotenv": "^17.3.1", "imap-simple": "^5.1.0", "imapflow": "^1.2.18", "mailparser": "^3.9.6", "mime-types": "^3.0.2", "nodemailer": "^8.0.4" } }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", "license": "MIT", "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" }, "funding": { "url": "https://ko-fi.com/killymxi" } }, "node_modules/@zone-eu/mailsplit": { "version": "5.4.8", "resolved": "https://registry.npmmirror.com/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", "license": "(MIT OR EUPL-1.1+)", "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", "engines": { "node": ">=8.0.0" } }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT" }, "node_modules/better-sqlite3": { "version": "12.8.0", "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.8.0.tgz", "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" }, "engines": { "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", "dependencies": { "file-uri-to-path": "1.0.0" } }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "node_modules/bl/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" }, "engines": { "node": ">= 6" } }, "node_modules/bl/node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "engines": { "node": ">=4.0.0" } }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" }, "funding": { "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/fb55" } ], "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, "engines": { "node": ">= 4" }, "funding": { "url": "https://github.com/fb55/domhandler?sponsor=1" } }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" }, "funding": { "url": "https://github.com/fb55/domutils?sponsor=1" } }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", "engines": { "node": ">=12" }, "funding": { "url": "https://dotenvx.com" } }, "node_modules/encoding-japanese": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz", "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", "license": "MIT", "engines": { "node": ">=8.10.0" } }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" } }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "license": "MIT", "bin": { "he": "bin/he" } }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-9.0.5.tgz", "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", "license": "MIT", "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" }, "engines": { "node": ">=14" } }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-8.0.2.tgz", "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { "type": "github", "url": "https://github.com/sponsors/fb55" } ], "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, "engines": { "node": ">=0.10.0" } }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "BSD-3-Clause" }, "node_modules/imap": { "version": "0.8.19", "resolved": "https://registry.npmmirror.com/imap/-/imap-0.8.19.tgz", "integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==", "dependencies": { "readable-stream": "1.1.x", "utf7": ">=1.0.2" }, "engines": { "node": ">=0.8.0" } }, "node_modules/imap-simple": { "version": "5.1.0", "resolved": "https://registry.npmmirror.com/imap-simple/-/imap-simple-5.1.0.tgz", "integrity": "sha512-FLZm1v38C5ekN46l/9X5gBRNMQNVc5TSLYQ3Hsq3xBLvKwt1i5fcuShyth8MYMPuvId1R46oaPNrH92hFGHr/g==", "license": "MIT", "dependencies": { "iconv-lite": "~0.4.13", "imap": "^0.8.18", "nodeify": "^1.0.0", "quoted-printable": "^1.0.0", "utf8": "^2.1.1", "uuencode": "0.0.4" }, "engines": { "node": ">=6" } }, "node_modules/imapflow": { "version": "1.2.18", "resolved": "https://registry.npmmirror.com/imapflow/-/imapflow-1.2.18.tgz", "integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==", "license": "MIT", "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.2", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "8.0.4", "pino": "10.3.1", "socks": "2.8.7" } }, "node_modules/imapflow/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" } }, "node_modules/is-promise": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-1.0.1.tgz", "integrity": "sha512-mjWH5XxnhMA8cFnDchr6qRP9S/kLntKuEfIYku+PaN1CnS8v+OG9O/BKpRCVRJvpIkgAZm0Pf5Is3iSSOILlcg==", "license": "MIT" }, "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/isarray/-/isarray-0.0.1.tgz", "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT" }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmmirror.com/leac/-/leac-0.6.0.tgz", "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", "license": "MIT", "funding": { "url": "https://ko-fi.com/killymxi" } }, "node_modules/libbase64": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/libbase64/-/libbase64-1.3.0.tgz", "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", "license": "MIT" }, "node_modules/libmime": { "version": "5.3.7", "resolved": "https://registry.npmmirror.com/libmime/-/libmime-5.3.7.tgz", "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", "license": "MIT", "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "node_modules/libmime/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/libqp": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz", "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT" }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } }, "node_modules/mailparser": { "version": "3.9.6", "resolved": "https://registry.npmmirror.com/mailparser/-/mailparser-3.9.6.tgz", "integrity": "sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==", "license": "MIT", "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "8.0.4", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "node_modules/mailparser/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { "node": ">=18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "license": "MIT" }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.89.0.tgz", "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "license": "MIT", "dependencies": { "semver": "^7.3.5" }, "engines": { "node": ">=10" } }, "node_modules/nodeify": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/nodeify/-/nodeify-1.0.1.tgz", "integrity": "sha512-n7C2NyEze8GCo/z73KdbjRsBiLbv6eBn1FxwYKQ23IqGo7pQY3mhQan61Sv7eEDJCiyUjTVrVkXTzJCo1dW7Aw==", "license": "MIT", "dependencies": { "is-promise": "~1.0.0", "promise": "~1.3.0" } }, "node_modules/nodemailer": { "version": "8.0.4", "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-8.0.4.tgz", "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { "wrappy": "1" } }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.12.1.tgz", "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", "license": "MIT", "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" }, "funding": { "url": "https://ko-fi.com/killymxi" } }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.9.0.tgz", "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", "license": "MIT", "funding": { "url": "https://ko-fi.com/killymxi" } }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", "dependencies": { "split2": "^4.0.0" } }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, "node_modules/prebuild-install": { "version": "7.1.2", "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.2.tgz", "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^1.0.1", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" }, "engines": { "node": ">=10" } }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/fastify" }, { "type": "opencollective", "url": "https://opencollective.com/fastify" } ], "license": "MIT" }, "node_modules/promise": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/promise/-/promise-1.3.0.tgz", "integrity": "sha512-R9WrbTF3EPkVtWjp7B7umQGVndpsi+rsDAfrR4xAALQpFLa/+2OriecLhawxzvii2gd9+DZFwROWDuUUaqS5yA==", "license": "MIT", "dependencies": { "is-promise": "~1" } }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, "node_modules/quoted-printable": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/quoted-printable/-/quoted-printable-1.0.1.tgz", "integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==", "license": "MIT", "dependencies": { "utf8": "^2.1.0" }, "bin": { "quoted-printable": "bin/quoted-printable" } }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "cli.js" } }, "node_modules/readable-stream": { "version": "1.1.14", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-1.1.14.tgz", "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "license": "MIT", "engines": { "node": ">= 12.13.0" } }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT" }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.11.0.tgz", "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", "license": "MIT", "dependencies": { "parseley": "^0.12.0" }, "funding": { "url": "https://ko-fi.com/killymxi" } }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT", "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" } }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { "node": ">= 10.x" } }, "node_modules/string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT" }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" }, "engines": { "node": ">=6" } }, "node_modules/tar-stream/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" }, "engines": { "node": ">= 6" } }, "node_modules/tar-stream/node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.0.0.tgz", "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" }, "engines": { "node": ">=20" } }, "node_modules/tlds": { "version": "1.261.0", "resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.261.0.tgz", "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", "license": "MIT", "bin": { "tlds": "bin.js" } }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" }, "engines": { "node": "*" } }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, "node_modules/utf7": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/utf7/-/utf7-1.0.2.tgz", "integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==", "dependencies": { "semver": "~5.3.0" } }, "node_modules/utf7/node_modules/semver": { "version": "5.3.0", "resolved": "https://registry.npmmirror.com/semver/-/semver-5.3.0.tgz", "integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==", "license": "ISC", "bin": { "semver": "bin/semver" } }, "node_modules/utf8": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/utf8/-/utf8-2.1.2.tgz", "integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==", "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuencode": { "version": "0.0.4", "resolved": "https://registry.npmmirror.com/uuencode/-/uuencode-0.0.4.tgz", "integrity": "sha512-yEEhCuCi5wRV7Z5ZVf9iV2gWMvUZqKJhAs1ecFdKJ0qzbyaVelmsE3QjYAamehfp9FKLiZbKldd+jklG3O0LfA==" }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" } } } FILE:package.json { "name": "email-manager-with-db", "version": "1.1.0", "description": "Multi-account IMAP/SMTP email manager with RFC 8058 one-click unsubscribe and suppression list", "main": "cli.js", "bin": { "email-manager": "./cli.js" }, "scripts": { "test": "node tests/test_unsubscribe.js" }, "keywords": [ "email", "imap", "smtp", "cold-email", "unsubscribe", "rfc-8058", "suppression-list", "claude-code", "agent-skills" ], "author": "Bo Yuan", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/mguozhen/email-manager-with-db.git" }, "dependencies": { "better-sqlite3": "^12.8.0", "dotenv": "^17.3.1", "imap-simple": "^5.1.0", "imapflow": "^1.2.18", "mailparser": "^3.9.6", "mime-types": "^3.0.2", "nodemailer": "^8.0.4" } } FILE:src/accounts.js const { getDb } = require('./db'); const crypto = require('crypto'); function generateId(email) { // e.g. [email protected] → liya-solvea const [user, domain] = email.split('@'); const domainBase = domain.split('.')[0]; return `user-domainBase`; } function addAccount({ email, appPassword, imapHost = 'imap.gmail.com', imapPort = 993, smtpHost = 'smtp.gmail.com', smtpPort = 465, username = null }) { const db = getDb(); const id = generateId(email); const user = username || email; db.prepare(` INSERT INTO accounts (id, email, imap_host, imap_port, smtp_host, smtp_port, username, app_password) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(email) DO UPDATE SET imap_host = ?, imap_port = ?, smtp_host = ?, smtp_port = ?, username = ?, app_password = ?, updated_at = datetime('now') `).run(id, email, imapHost, imapPort, smtpHost, smtpPort, user, appPassword, imapHost, imapPort, smtpHost, smtpPort, user, appPassword); return { id, email }; } function getAccount(idOrEmail) { const db = getDb(); return db.prepare('SELECT * FROM accounts WHERE id = ? OR email = ?').get(idOrEmail, idOrEmail); } function listAccounts() { const db = getDb(); return db.prepare('SELECT id, email, imap_host, smtp_host, active, created_at FROM accounts').all(); } function removeAccount(idOrEmail) { const db = getDb(); const account = getAccount(idOrEmail); if (!account) throw new Error(`Account not found: idOrEmail`); db.prepare('DELETE FROM accounts WHERE id = ?').run(account.id); return { removed: account.id }; } module.exports = { addAccount, getAccount, listAccounts, removeAccount }; FILE:src/db.js const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); const DATA_DIR = path.join(__dirname, '..', 'data'); fs.mkdirSync(DATA_DIR, { recursive: true }); let _db = null; function getDb() { if (_db) return _db; _db = new Database(path.join(DATA_DIR, 'emails.db')); _db.pragma('journal_mode = WAL'); _db.pragma('foreign_keys = ON'); initSchema(_db); return _db; } function initSchema(db) { db.exec(` CREATE TABLE IF NOT EXISTS accounts ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, imap_host TEXT NOT NULL DEFAULT 'imap.gmail.com', imap_port INTEGER NOT NULL DEFAULT 993, smtp_host TEXT NOT NULL DEFAULT 'smtp.gmail.com', smtp_port INTEGER NOT NULL DEFAULT 465, username TEXT NOT NULL, app_password TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS emails ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id TEXT NOT NULL REFERENCES accounts(id), message_id TEXT, uid INTEGER, folder TEXT NOT NULL DEFAULT 'INBOX', from_addr TEXT, from_name TEXT, to_addr TEXT, subject TEXT, date TEXT, body_text TEXT, body_html TEXT, snippet TEXT, is_read INTEGER NOT NULL DEFAULT 0, is_starred INTEGER NOT NULL DEFAULT 0, is_filtered INTEGER NOT NULL DEFAULT 0, filter_reason TEXT, raw_headers TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_emails_account_uid_folder ON emails(account_id, uid, folder); CREATE INDEX IF NOT EXISTS idx_emails_account_date ON emails(account_id, date DESC); CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_addr); CREATE INDEX IF NOT EXISTS idx_emails_filtered ON emails(is_filtered); CREATE TABLE IF NOT EXISTS filter_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id TEXT REFERENCES accounts(id), field TEXT NOT NULL DEFAULT 'from', pattern TEXT NOT NULL, action TEXT NOT NULL DEFAULT 'filter', is_global INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS sent_emails ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id TEXT NOT NULL REFERENCES accounts(id), to_addr TEXT NOT NULL, cc TEXT, bcc TEXT, subject TEXT, body_text TEXT, body_html TEXT, in_reply_to TEXT, sent_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS sync_state ( account_id TEXT NOT NULL, folder TEXT NOT NULL DEFAULT 'INBOX', last_uid INTEGER NOT NULL DEFAULT 0, last_sync TEXT, PRIMARY KEY (account_id, folder) ); `); // Migrations: add columns introduced after initial schema const migrations = [ 'ALTER TABLE accounts ADD COLUMN smtp_ok INTEGER DEFAULT NULL', 'ALTER TABLE accounts ADD COLUMN smtp_checked_at TEXT DEFAULT NULL', 'ALTER TABLE emails ADD COLUMN cc TEXT DEFAULT NULL', "ALTER TABLE sent_emails ADD COLUMN status TEXT NOT NULL DEFAULT 'sent'", 'ALTER TABLE sent_emails ADD COLUMN error TEXT DEFAULT NULL', ]; for (const sql of migrations) { try { db.exec(sql); } catch (_) { /* column already exists */ } } // Seed default global filter rules const existing = db.prepare('SELECT COUNT(*) as cnt FROM filter_rules WHERE is_global = 1').get(); if (existing.cnt === 0) { const insert = db.prepare('INSERT INTO filter_rules (field, pattern, action, is_global) VALUES (?, ?, ?, 1)'); insert.run('from', 'noreply', 'filter'); insert.run('from', 'no-reply', 'filter'); } } module.exports = { getDb }; FILE:src/filters.js const { getDb } = require('./db'); /** * Check if an email should be filtered out. * Returns { filtered: boolean, reason: string|null } */ function shouldFilter(accountId, email) { const db = getDb(); const rules = db.prepare(` SELECT * FROM filter_rules WHERE is_global = 1 OR account_id = ? `).all(accountId); const fromAddr = (email.from_addr || '').toLowerCase(); const fromName = (email.from_name || '').toLowerCase(); const subject = (email.subject || '').toLowerCase(); for (const rule of rules) { const pattern = rule.pattern.toLowerCase(); let target = ''; switch (rule.field) { case 'from': target = fromAddr + ' ' + fromName; break; case 'subject': target = subject; break; case 'to': target = (email.to_addr || '').toLowerCase(); break; case 'body': target = (email.body_text || '').toLowerCase() + ' ' + (email.body_html || '').toLowerCase(); break; case 'body_regex': target = (email.body_text || '') + ' ' + (email.body_html || ''); break; default: target = fromAddr; } if (rule.field === 'body_regex') { try { const re = new RegExp(rule.pattern); if (re.test(target)) { return { filtered: true, reason: `Rule: body matches regex /rule.pattern/` }; } } catch (e) { // invalid regex, skip } } else if (target.includes(pattern)) { return { filtered: true, reason: `Rule: rule.field contains "rule.pattern"` }; } } return { filtered: false, reason: null }; } function addRule(accountId, field, pattern, action = 'filter') { const db = getDb(); db.prepare(` INSERT INTO filter_rules (account_id, field, pattern, action, is_global) VALUES (?, ?, ?, ?, ?) `).run(accountId, field, pattern, action, accountId ? 0 : 1); } function listRules(accountId) { const db = getDb(); return db.prepare(` SELECT * FROM filter_rules WHERE is_global = 1 OR account_id = ? `).all(accountId || ''); } function removeRule(ruleId) { const db = getDb(); db.prepare('DELETE FROM filter_rules WHERE id = ?').run(ruleId); } module.exports = { shouldFilter, addRule, listRules, removeRule }; FILE:src/html-to-text.js 'use strict'; /** * Convert HTML email body to clean markdown-style plain text. * * Rules: * - <svg> → removed entirely * - <img src="https?"> →  * - <img> other src → removed (base64, cid:, relative, etc.) * - <blockquote> → lines prefixed with "> " (nesting adds "> > ") * - Links, bold, italic → markdown equivalents * - Non-inline attachments appended as "📎 filename" */ function htmlToMarkdown(html, attachments) { if (!html || !html.trim()) return ''; let text = html; // Strip style / script blocks text = text.replace(/<style\b[\s\S]*?<\/style>/gi, ''); text = text.replace(/<script\b[\s\S]*?<\/script>/gi, ''); // Strip SVG (including inline) text = text.replace(/<svg\b[\s\S]*?<\/svg>/gi, ''); // Images: URL src → markdown image, everything else → removed text = text.replace(/<img\b([^>]*?)(?:\s*\/?>)/gi, (_, attrs) => { const src = extractAttr(attrs, 'src'); const alt = extractAttr(attrs, 'alt') || ''; if (src && /^https?:\/\//i.test(src)) { return ``; } return ''; }); // Horizontal rules text = text.replace(/<hr\b[^>]*?\/?>/gi, '\n\n---\n\n'); // Headings for (let i = 6; i >= 1; i--) { const hashes = '#'.repeat(i); text = text.replace( new RegExp(`<hi\\b[^>]*?>([\\s\\S]*?)<\\/hi>`, 'gi'), (_, c) => `\nhashes innerText(c).trim()\n` ); } // Links text = text.replace( /<a\b[^>]*?href=["']([^"']*)["'][^>]*?>([\s\S]*?)<\/a>/gi, (_, href, inner) => { const linkText = innerText(inner).trim(); if (!linkText) return href || ''; if (linkText === href) return href; return `[linkText](href)`; } ); // Bold / italic (after links so nested markup resolves cleanly) text = text.replace(/<(?:b|strong)\b[^>]*?>([\s\S]*?)<\/(?:b|strong)>/gi, (_, c) => { const t = innerText(c).trim(); return t ? `**t**` : ''; }); text = text.replace(/<(?:i|em)\b[^>]*?>([\s\S]*?)<\/(?:i|em)>/gi, (_, c) => { const t = innerText(c).trim(); return t ? `*t*` : ''; }); // List items text = text.replace(/<li\b[^>]*?>([\s\S]*?)<\/li>/gi, (_, c) => `\n- innerText(c).trim()`); text = text.replace(/<\/?[uo]l\b[^>]*?>/gi, '\n'); // Blockquotes: process inside-out using \x02 sentinel per indentation level. // The negative-lookahead pattern ensures we always match the innermost one first. { let prev, iterations = 0; do { prev = text; text = text.replace( /<blockquote\b[^>]*?>((?:(?!<\/?blockquote)[\s\S])*?)<\/blockquote>/gi, (_, content) => '\n\x02' + content.replace(/\n/g, '\n\x02') + '\n' ); } while (text !== prev && ++iterations < 10); } // Block-level elements → newlines text = text.replace(/<br\s*\/?>/gi, '\n'); text = text.replace(/<\/(?:p|div|tr|li|pre)>/gi, '\n'); text = text.replace(/<(?:p|div|pre)\b[^>]*?>/gi, '\n'); text = text.replace(/<\/(?:table|ul|ol)>/gi, '\n'); // Strip all remaining tags text = text.replace(/<[^>]+>/g, ''); // Decode HTML entities text = decodeEntities(text); // Convert \x02 sentinels to "> " blockquote prefixes (each \x02 = one level) text = text.split('\n').map(line => { let depth = 0; while (line.startsWith('\x02')) { depth++; line = line.slice(1); } return depth > 0 ? '> '.repeat(depth) + line : line; }).join('\n'); // Collapse 3+ blank lines → max 2 text = text.replace(/\n{3,}/g, '\n\n'); text = text.trim(); // Append non-inline attachments const attLines = buildAttachmentLines(attachments); if (attLines.length > 0) { text += '\n\n---\n**Attachments:**\n' + attLines.join('\n'); } return text; } // ── helpers ────────────────────────────────────────────────────────────────── function extractAttr(attrs, name) { const m = attrs.match(new RegExp(`name\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')); if (!m) return ''; return m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : (m[3] || ''); } function innerText(html) { return decodeEntities(html.replace(/<[^>]+>/g, '')); } function decodeEntities(text) { return text .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'|'/gi, "'") .replace(/ /g, ' ') .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n)) .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16))); } function buildAttachmentLines(attachments) { if (!attachments || !attachments.length) return []; return attachments .filter(a => a.contentDisposition !== 'inline' && !a.contentId) .map(a => `- 📎 a.filename || a.name || 'attachment'`); } module.exports = { htmlToMarkdown }; FILE:src/imap.js const { ImapFlow } = require('imapflow'); const { simpleParser } = require('mailparser'); const { getDb } = require('./db'); const { shouldFilter } = require('./filters'); const { htmlToMarkdown } = require('./html-to-text'); const { testSmtp } = require('./smtp'); /** * Create an IMAP client for the given account config */ function createClient(account) { return new ImapFlow({ host: account.imap_host, port: account.imap_port, secure: true, auth: { user: account.username, pass: account.app_password }, socketTimeout: 30000, // 30-second timeout for the socket logger: false }); } /** * Fetch new emails since last synced UID */ async function syncInbox(accountId, folder = 'INBOX', limit = 50, refilterAll = false) { const db = getDb(); const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId); if (!account) throw new Error(`Account accountId not found`); const syncState = db.prepare( 'SELECT * FROM sync_state WHERE account_id = ? AND folder = ?' ).get(accountId, folder); const lastUid = syncState ? syncState.last_uid : 0; const client = createClient(account); const results = { fetched: 0, filtered: 0, errors: [] }; try { await client.connect(); const lock = await client.getMailboxLock(folder); try { // Fetch messages newer than last known UID const range = lastUid > 0 ? `lastUid + 1:*` : `1:*`; let count = 0; let maxUid = lastUid; const insertEmail = db.prepare(` INSERT OR IGNORE INTO emails (account_id, message_id, uid, folder, from_addr, from_name, to_addr, cc, subject, date, body_text, body_html, snippet, is_read, is_filtered, filter_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for await (const message of client.fetch(range, { uid: true, envelope: true, source: true, flags: true })) { if (count >= limit) break; try { const parsed = await simpleParser(message.source); const fromAddr = parsed.from?.value?.[0]?.address || ''; const fromName = parsed.from?.value?.[0]?.name || ''; const toAddr = parsed.to?.value?.map(v => v.address).join(', ') || ''; const ccAddr = parsed.cc?.value?.map(v => v.address).join(', ') || ''; const bodyHtml = parsed.html || ''; const bodyText = bodyHtml ? htmlToMarkdown(bodyHtml, parsed.attachments) : (parsed.text || ''); const snippet = bodyText.substring(0, 200).replace(/\n/g, ' ').trim(); const isRead = message.flags?.has('\\Seen') ? 1 : 0; const emailData = { from_addr: fromAddr, from_name: fromName, to_addr: toAddr, cc: ccAddr, subject: parsed.subject || '(no subject)', body_text: bodyText, body_html: bodyHtml }; const filterResult = shouldFilter(accountId, emailData); insertEmail.run( accountId, parsed.messageId || null, message.uid, folder, fromAddr, fromName, toAddr, ccAddr, parsed.subject || '(no subject)', parsed.date ? parsed.date.toISOString() : null, bodyText, bodyHtml, snippet, isRead, filterResult.filtered ? 1 : 0, filterResult.reason ); if (message.uid > maxUid) maxUid = message.uid; count++; results.fetched++; if (filterResult.filtered) results.filtered++; } catch (parseErr) { results.errors.push(`UID message.uid: parseErr.message`); } } // Update sync state if (maxUid > lastUid) { db.prepare(` INSERT INTO sync_state (account_id, folder, last_uid, last_sync) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(account_id, folder) DO UPDATE SET last_uid = ?, last_sync = datetime('now') `).run(accountId, folder, maxUid, maxUid); } } finally { lock.release(); } } finally { await client.logout().catch(() => {}); } // -- SMTP VERIFICATION -- const smtpResult = await testSmtp(account); db.prepare(` UPDATE accounts SET smtp_ok = ?, smtp_checked_at = datetime('now') WHERE id = ? `).run(smtpResult.ok ? 1 : 0, accountId); results.smtp = smtpResult; // -- RE-FILTERING STEP -- if (refilterAll) { const allUnfiltered = db.prepare('SELECT * FROM emails WHERE account_id = ? AND is_filtered = 0').all(accountId); const updateStmt = db.prepare('UPDATE emails SET is_filtered = 1, filter_reason = ? WHERE id = ?'); let refilteredCount = 0; for (const email of allUnfiltered) { const filterResult = shouldFilter(accountId, email); if (filterResult.filtered) { updateStmt.run(filterResult.reason, email.id); refilteredCount++; } } if (refilteredCount > 0) { results.refiltered = refilteredCount; } } return results; } /** * List mailbox folders */ async function listFolders(accountId) { const db = getDb(); const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId); if (!account) throw new Error(`Account accountId not found`); const client = createClient(account); try { await client.connect(); const folders = await client.list(); return folders.map(f => ({ name: f.name, path: f.path, flags: [...(f.flags || [])] })); } finally { await client.logout().catch(() => {}); } } /** * Test IMAP connection */ async function testConnection(account) { const client = createClient(account); try { await client.connect(); const status = await client.status('INBOX', { messages: true, unseen: true }); await client.logout(); return { ok: true, messages: status.messages, unseen: status.unseen }; } catch (err) { return { ok: false, error: err.message }; } } async function moveMessage(accountId, uid, fromFolder, toFolder) { const db = getDb(); const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId); if (!account) throw new Error(`Account accountId not found`); const client = createClient(account); try { await client.connect(); const lock = await client.getMailboxLock(fromFolder); try { await client.messageMove(uid.toString(), toFolder, { uid: true }); return { ok: true, moved: uid }; } finally { lock.release(); } } catch (err) { return { ok: false, error: err.message }; } finally { await client.logout().catch(() => {}); } } module.exports = { syncInbox, listFolders, testConnection, moveMessage }; FILE:src/smtp.js const nodemailer = require('nodemailer'); const { getDb } = require('./db'); const unsubscribe = require('./unsubscribe'); /** * Create SMTP transporter for account */ function createTransporter(account) { return nodemailer.createTransport({ host: account.smtp_host, port: account.smtp_port, secure: account.smtp_port === 465, auth: { user: account.username, pass: account.app_password } }); } /** * Send an email. * * Automatically: * - Checks suppression list (blocks resend to unsubscribed recipients) * - Injects RFC 2369 + RFC 8058 List-Unsubscribe headers * * Options: * skipSuppressionCheck Set true to bypass suppression check (e.g. transactional replies) * skipUnsubHeader Set true to omit List-Unsubscribe (e.g. 1:1 human conversations) * extraHeaders Additional headers to merge */ async function sendEmail(accountId, { to, cc, bcc, subject, text, html, inReplyTo, references, skipSuppressionCheck = false, skipUnsubHeader = false, extraHeaders = {}, }) { const db = getDb(); const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId); if (!account) throw new Error(`Account accountId not found`); // Suppression check if (!skipSuppressionCheck && to) { const suppressReason = unsubscribe.isSuppressed(to); if (suppressReason) { db.prepare(` INSERT INTO sent_emails (account_id, to_addr, cc, bcc, subject, body_text, body_html, in_reply_to, status, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'suppressed', ?) `).run(accountId, to, cc || null, bcc || null, subject, text || null, html || null, inReplyTo || null, `suppressed: suppressReason`); const err = new Error(`Recipient to is on suppression list (suppressReason)`); err.code = 'SUPPRESSED'; err.suppressReason = suppressReason; throw err; } } // Build headers const headers = { ...extraHeaders }; if (!skipUnsubHeader && to) { const unsubHeaders = unsubscribe.buildHeaders(to); if (unsubHeaders) Object.assign(headers, unsubHeaders); } const transporter = createTransporter(account); const mailOptions = { from: `account.email`, to, cc: cc || undefined, bcc: bcc || undefined, subject, text: text || undefined, html: html || undefined, inReplyTo: inReplyTo || undefined, references: references || undefined, headers: Object.keys(headers).length ? headers : undefined, }; let info; try { info = await transporter.sendMail(mailOptions); } catch (err) { db.prepare(` INSERT INTO sent_emails (account_id, to_addr, cc, bcc, subject, body_text, body_html, in_reply_to, status, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'failed', ?) `).run(accountId, to, cc || null, bcc || null, subject, text || null, html || null, inReplyTo || null, err.message); throw err; } // Log to DB db.prepare(` INSERT INTO sent_emails (account_id, to_addr, cc, bcc, subject, body_text, body_html, in_reply_to, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'sent') `).run(accountId, to, cc || null, bcc || null, subject, text || null, html || null, inReplyTo || null); return { messageId: info.messageId, accepted: info.accepted }; } /** * Test SMTP connection */ async function testSmtp(account) { const transporter = createTransporter(account); try { await transporter.verify(); return { ok: true }; } catch (err) { return { ok: false, error: err.message }; } } module.exports = { sendEmail, testSmtp, unsubscribe }; FILE:src/unsubscribe.js /** * RFC 2369 + RFC 8058 List-Unsubscribe header generation. * * - Generates HMAC-signed token per recipient email (can't forge) * - Returns headers object to merge into nodemailer mailOptions * - Reads/writes suppression list in tracking.db (shared with Python tracker) * * Env vars: * UNSUB_SECRET HMAC secret (required, fallback "solvea-default-secret") * UNSUB_BASE_URL Public URL of tracking server (required, fallback reads .tracking_config) * UNSUB_MAILTO_DOMAIN Domain for mailto: fallback (default "solvea.cx") * TRACKING_DB_PATH Path to tracking.db (default "/Users/guozhen/MailOutbound/tracking.db") */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const Database = require('better-sqlite3'); const DEFAULT_TRACKING_DB = '/Users/guozhen/MailOutbound/tracking.db'; const DEFAULT_TRACKING_CFG = '/Users/guozhen/MailOutbound/.tracking_config'; const DEFAULT_SECRET = 'solvea-default-secret-change-me'; const DEFAULT_MAILTO_DOMAIN = 'solvea.cx'; let _db = null; function getDb() { if (_db) return _db; const dbPath = process.env.TRACKING_DB_PATH || DEFAULT_TRACKING_DB; _db = new Database(dbPath); _db.exec(` CREATE TABLE IF NOT EXISTS suppressions ( email TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT 'unsubscribe', source TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); _db.exec(`CREATE INDEX IF NOT EXISTS idx_suppressions_email ON suppressions(email)`); return _db; } function getBaseUrl() { if (process.env.UNSUB_BASE_URL) return process.env.UNSUB_BASE_URL; try { const cfg = fs.readFileSync(DEFAULT_TRACKING_CFG, 'utf8'); const m = cfg.match(/^TRACKING_URL=(\S+)/m); if (m) return m[1]; } catch {} return null; } function getSecret() { return process.env.UNSUB_SECRET || DEFAULT_SECRET; } function getMailtoDomain() { return process.env.UNSUB_MAILTO_DOMAIN || DEFAULT_MAILTO_DOMAIN; } /** * Generate HMAC-SHA256 token for recipient. First 16 hex chars = 64-bit security. */ function makeToken(email) { return crypto .createHmac('sha256', getSecret()) .update(email.toLowerCase().trim()) .digest('hex') .slice(0, 16); } function verifyToken(email, token) { const expected = makeToken(email); if (expected.length !== token.length) return false; return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(token)); } /** * Check if email is suppressed. Returns reason string or null. */ function isSuppressed(email) { const row = getDb() .prepare('SELECT reason FROM suppressions WHERE email = ? LIMIT 1') .get(email.toLowerCase().trim()); return row ? row.reason : null; } /** * Record an unsubscribe in the suppression list. */ function suppress(email, reason = 'unsubscribe', source = 'api') { getDb() .prepare( `INSERT OR REPLACE INTO suppressions (email, reason, source, created_at) VALUES (?, ?, ?, datetime('now'))` ) .run(email.toLowerCase().trim(), reason, source); } function unsuppress(email) { const info = getDb() .prepare('DELETE FROM suppressions WHERE email = ?') .run(email.toLowerCase().trim()); return info.changes > 0; } function listSuppressions(limit = 100) { return getDb() .prepare('SELECT email, reason, source, created_at FROM suppressions ORDER BY created_at DESC LIMIT ?') .all(limit); } /** * Build RFC 2369 + RFC 8058 unsubscribe headers for a given recipient. * Returns null if no base URL configured (so caller can decide). */ function buildHeaders(toEmail) { const baseUrl = getBaseUrl(); if (!baseUrl) return null; const token = makeToken(toEmail); const encoded = encodeURIComponent(toEmail); const httpsLink = `baseUrl/unsubscribe?e=encoded&t=token`; const mailtoLink = `unsubscribe+token@getMailtoDomain()`; return { 'List-Unsubscribe': `<httpsLink>, <mailto:mailtoLink?subject=unsubscribe&body=encoded>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 'Precedence': 'bulk', }; } module.exports = { makeToken, verifyToken, isSuppressed, suppress, unsuppress, listSuppressions, buildHeaders, getBaseUrl, }; FILE:tests/test_unsubscribe.js #!/usr/bin/env node /** * End-to-end regression test for List-Unsubscribe flow. * Tests: token generation, header building, HMAC verification, suppression check. */ const path = require('path'); const assert = require('assert'); const crypto = require('crypto'); const http = require('http'); // Use test-isolated DB + secret const TEST_DB = '/tmp/test_tracking_unsub.db'; process.env.TRACKING_DB_PATH = TEST_DB; process.env.UNSUB_SECRET = 'test-secret-xyz'; process.env.UNSUB_BASE_URL = 'http://localhost:7788'; process.env.UNSUB_MAILTO_DOMAIN = 'test.example.com'; try { require('fs').unlinkSync(TEST_DB); } catch {} const unsub = require(path.resolve(__dirname, '../src/unsubscribe.js')); let passed = 0, failed = 0; function test(name, fn) { try { fn(); console.log(` ✓ name`); passed++; } catch (e) { console.log(` ✗ name\n e.message`); failed++; } } console.log('\n=== Token generation & verification ==='); test('makeToken returns deterministic 16-char hex', () => { const t = unsub.makeToken('[email protected]'); assert.strictEqual(t.length, 16); assert.match(t, /^[0-9a-f]+$/); assert.strictEqual(unsub.makeToken('[email protected]'), t); }); test('makeToken is case-insensitive + trims', () => { assert.strictEqual( unsub.makeToken('[email protected]'), unsub.makeToken(' [email protected] ') ); }); test('different emails produce different tokens', () => { assert.notStrictEqual( unsub.makeToken('[email protected]'), unsub.makeToken('[email protected]') ); }); test('verifyToken accepts valid token', () => { const t = unsub.makeToken('[email protected]'); assert.strictEqual(unsub.verifyToken('[email protected]', t), true); }); test('verifyToken rejects invalid token', () => { assert.strictEqual(unsub.verifyToken('[email protected]', 'deadbeefdeadbeef'), false); }); test('verifyToken rejects cross-email token', () => { const t = unsub.makeToken('[email protected]'); assert.strictEqual(unsub.verifyToken('[email protected]', t), false); }); console.log('\n=== Header building ==='); test('buildHeaders includes HTTPS + mailto + One-Click', () => { const h = unsub.buildHeaders('[email protected]'); assert.ok(h, 'headers should be non-null'); assert.match(h['List-Unsubscribe'], /^<http/); assert.match(h['List-Unsubscribe'], /<mailto:/); assert.strictEqual(h['List-Unsubscribe-Post'], 'List-Unsubscribe=One-Click'); assert.strictEqual(h['Precedence'], 'bulk'); }); test('HTTPS link contains URL-encoded email', () => { const h = unsub.buildHeaders('[email protected]'); assert.ok(h['List-Unsubscribe'].includes('user%2Btag%40test.com')); }); test('mailto link includes HMAC token', () => { const h = unsub.buildHeaders('[email protected]'); const token = unsub.makeToken('[email protected]'); assert.ok(h['List-Unsubscribe'].includes(`unsubscribe+token@test.example.com`)); }); console.log('\n=== Suppression list ==='); test('isSuppressed returns null for unknown email', () => { assert.strictEqual(unsub.isSuppressed('[email protected]'), null); }); test('suppress + isSuppressed round-trip', () => { unsub.suppress('[email protected]', 'unsubscribe', 'test'); assert.strictEqual(unsub.isSuppressed('[email protected]'), 'unsubscribe'); }); test('suppress is case-insensitive', () => { unsub.suppress('[email protected]'); assert.strictEqual(unsub.isSuppressed('[email protected]'), 'unsubscribe'); }); test('unsuppress removes entry', () => { unsub.suppress('[email protected]'); assert.ok(unsub.unsuppress('[email protected]')); assert.strictEqual(unsub.isSuppressed('[email protected]'), null); }); test('listSuppressions returns array', () => { const list = unsub.listSuppressions(10); assert.ok(Array.isArray(list)); assert.ok(list.length > 0); }); // Skip live HTTP tests in CI/default runs — requires the Python tracker running. // Run with SKIP_LIVE=1 to skip; SKIP_LIVE=0 or unset to attempt. if (process.env.SKIP_LIVE === '1') { console.log(`\npassed passed, failed failed (live HTTP tests skipped)`); process.exit(failed === 0 ? 0 : 1); } console.log('\n=== Live HTTP endpoints (tracker must be running on :7788) ==='); function httpGet(url) { return new Promise((resolve, reject) => { http.get(url, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => resolve({ status: res.statusCode, body: data })); }).on('error', reject); }); } function httpPost(url, body) { return new Promise((resolve, reject) => { const u = new URL(url); const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }, }, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => resolve({ status: res.statusCode, body: data })); }); req.on('error', reject); req.write(body); req.end(); }); } (async () => { // The tracker uses its own UNSUB_SECRET env - need to check what it has // For live test, we generate token using tracker's secret (default) const email = '[email protected]'; // Re-derive token using tracker's default secret const trackerSecret = 'solvea-default-secret-change-me'; const token = crypto.createHmac('sha256', trackerSecret).update(email.toLowerCase()).digest('hex').slice(0, 16); test('GET /unsubscribe with invalid token → 400', async () => { const r = await httpGet('http://localhost:7788/[email protected]&t=deadbeefdeadbeef'); assert.strictEqual(r.status, 400); }); try { const r1 = await httpGet(`http://localhost:7788/unsubscribe?e=encodeURIComponent(email)&t=token`); if (r1.status === 200 && r1.body.includes('unsubscribed')) { console.log(' ✓ GET /unsubscribe with valid token → 200 + HTML'); passed++; } else { console.log(` ✗ GET /unsubscribe with valid token → status r1.status`); failed++; } } catch (e) { console.log(` ✗ GET /unsubscribe: e.message`); failed++; } try { const email2 = '[email protected]'; const token2 = crypto.createHmac('sha256', trackerSecret).update(email2.toLowerCase()).digest('hex').slice(0, 16); const r2 = await httpPost( 'http://localhost:7788/unsubscribe', `e=encodeURIComponent(email2)&t=token2&List-Unsubscribe=One-Click` ); if (r2.status === 200 && r2.body.trim() === 'unsubscribed') { console.log(' ✓ POST /unsubscribe (RFC 8058 one-click) → 200 unsubscribed'); passed++; } else { console.log(` ✗ POST /unsubscribe: r2.status r2.body`); failed++; } } catch (e) { console.log(` ✗ POST /unsubscribe: e.message`); failed++; } try { const r3 = await httpPost( 'http://localhost:7788/unsubscribe', '[email protected]&t=wrongtoken1234' ); if (r3.status === 400) { console.log(' ✓ POST /unsubscribe with bad token → 400'); passed++; } else { console.log(` ✗ POST /unsubscribe bad token: r3.status`); failed++; } } catch (e) { console.log(` ✗ POST /unsubscribe bad: e.message`); failed++; } console.log(`\npassed passed, failed failed`); process.exit(failed === 0 ? 0 : 1); })();
Master supplier negotiation tactics for Shopify store owners to reduce COGS, improve terms, and build better supplier relationships. Triggers: supplier negot...
---
name: shopify-supplier-negotiation
description: "Master supplier negotiation tactics for Shopify store owners to reduce COGS, improve terms, and build better supplier relationships. Triggers: supplier negotiation, negotiate with suppliers, reduce product cost, supplier terms, sourcing negotiation"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-supplier-negotiation
---
# Shopify Supplier Negotiation Tactics
A complete supplier negotiation playbook for Shopify merchants. This skill develops negotiation scripts, leverage strategies, and relationship-building frameworks to help store owners reduce product costs, improve payment terms, and build reliable supply chains with preferred supplier partnerships.
## Usage
```
supplier negotiation strategy: <product category>
negotiate better terms: <current situation>
reduce supplier costs: <store niche>
sourcing negotiation plan: <product type>
```
## What You Get
1. **Negotiation Preparation Framework** — Research, leverage points, and BATNA analysis
2. **Cost Reduction Tactics** — Volume commitments, payment terms, and exclusivity trade-offs
3. **Negotiation Scripts** — Email and call scripts for common negotiation scenarios
4. **MOQ Reduction Strategies** — How to negotiate lower minimum orders
5. **Payment Terms Optimization** — Net 30/60, deposits, and letter of credit options
6. **Supplier Relationship Building** — Long-term partnership strategy for preferred status
7. **Quality & Compliance Negotiation** — Getting quality guarantees and audit rights
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-supplier-negotiation — Supplier negotiation tactics for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: supplier negotiation strategy: <product category>"
exit 1
fi
SESSION_ID="shopify-supplier-neg-$(date +%s)"
PROMPT="You are a Shopify supply chain and supplier negotiation expert specializing in COGS reduction, payment terms optimization, and supplier relationship management for ecommerce brands. Build a complete supplier negotiation strategy for: INPUT
Produce a complete supplier negotiation playbook with these sections:
## 1. Pre-Negotiation Preparation
- Market intelligence: understanding what competitors pay for similar products
- BATNA (Best Alternative to Negotiated Agreement): know your walkaway point
- Supplier leverage analysis: how dependent are they on your business?
- Total cost of supplier switching: when to push hard vs maintain relationship
- Negotiation checklist: what to prepare before any supplier meeting
## 2. Price Reduction Tactics
- Volume commitment strategy: commit to 3x volume for 15% price reduction
- Annual purchase agreement: lock in pricing in exchange for commitment
- Payment acceleration: pay faster (net 7 vs net 60) for discount
- Multi-year contract leverage: trading certainty for lower unit cost
- Competitor quote strategy: using alternative supplier quotes ethically
## 3. Negotiation Scripts & Templates
- Opening email: requesting price review with diplomatic framing
- Phone/video call script: how to open price negotiation professionally
- Counteroffer script: responding to a supplier's initial quote
- Final negotiation close: sealing the deal and confirming in writing
- Objection handling: when supplier says they can't go lower
## 4. MOQ Reduction Strategies
- Shared container strategy: splitting MOQ with another buyer
- Phased delivery: commit to full MOQ but take delivery in stages
- SKU consolidation: fewer colors/variants to hit MOQ on fewer SKUs
- New supplier introduction pricing: first order MOQ concessions
## 5. Payment Terms & Cash Flow Optimization
- Standard terms by supplier type: China factory, domestic distributor, wholesale
- Letter of credit vs wire transfer vs PayPal: cost and risk comparison
- Net 30/60/90 negotiation: impact on your cash conversion cycle
- Early payment discount: calculating if it's worth taking
## 6. Supplier Relationship & Partnership Building
- Preferred buyer status: how to earn priority treatment and better terms
- Communication cadence: monthly check-ins, feedback sharing, forecast sharing
- Site visit strategy: visiting supplier builds trust and reveals quality
- Gift-giving and cultural considerations for international suppliers
## 7. Implementation & Ongoing Negotiation Plan
- Immediate actions (week 1): audit current supplier terms, calculate total COGS by supplier
- Short-term (month 1): schedule negotiation meetings with top 3 suppliers
- Long-term (month 3+): annual renegotiation cycle, supplier scorecard, backup supplier development
Include specific negotiation outcomes: typical price reduction achievable (5-20% with volume), payment term improvements, and case examples of successful supplier negotiations for Shopify brands."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Build a PR and press coverage strategy for Shopify stores to earn media mentions, backlinks, and brand authority. Triggers: pr strategy, press coverage, medi...
---
name: shopify-pr-strategy
description: "Build a PR and press coverage strategy for Shopify stores to earn media mentions, backlinks, and brand authority. Triggers: pr strategy, press coverage, media outreach, get press mentions, public relations shopify"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-pr-strategy
---
# Shopify PR & Press Coverage Strategy
A complete public relations strategy for Shopify merchants to earn media coverage, build brand authority, and generate high-quality backlinks. This skill develops pitchable story angles, media target lists, press release templates, and relationship-building tactics with journalists and bloggers.
## Usage
```
PR strategy for: <store niche or URL>
press coverage plan: <brand description>
media outreach for: <product category>
get press for my store: <store type>
```
## What You Get
1. **Story Angle Development** — 10 pitchable narratives for your brand
2. **Media Target List** — Publications, blogs, and journalists to target by tier
3. **Pitch Templates** — Email outreach scripts with high response rates
4. **Press Release Framework** — Template and distribution strategy
5. **Journalist Relationship Building** — Long-term media relationship tactics
6. **HARO & Reactive PR** — Leveraging media opportunities as they arise
7. **PR Measurement** — Tracking coverage impact on traffic, SEO, and brand
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-pr-strategy — PR and press coverage strategy for Shopify stores
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: PR strategy for: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-pr-$(date +%s)"
PROMPT="You are a Shopify brand PR and media relations expert specializing in earning press coverage, building brand authority, and securing high-quality backlinks for ecommerce stores. Build a complete PR strategy for: INPUT
Produce a complete PR and press strategy report with these sections:
## 1. Story Angle Development
- 10 specific pitchable story angles for this brand (beyond product announcement)
- Data story: original research or survey that journalists will want to cover
- Founder story: personal narrative hooks that resonate with business media
- Trend jacking: how to attach to existing media trends in this niche
- Impact story: community, sustainability, or social mission angles
## 2. Media Target List & Prioritization
- Tier 1 targets: major publications (Forbes, Business Insider, Vogue, etc. for niche)
- Tier 2 targets: niche-specific blogs, trade publications, and industry sites
- Tier 3 targets: local media, podcasts, and micro-publications
- 20 specific publication names with their audience focus for this niche
## 3. Journalist Outreach & Pitch Templates
- Subject line formulas that journalists actually open (5 options)
- Cold pitch email template: 150 words max, story-first structure
- Follow-up sequence: 2-touch, 1-week apart
- How to find the right journalist's email (tools: Hunter.io, LinkedIn)
## 4. Press Release Writing Framework
- Standard press release format with all required elements
- Headline formula: newsworthy and SEO-optimized
- Boilerplate company description template
- Distribution strategy: PR Newswire vs free wire services vs direct outreach
## 5. HARO & Reactive PR Tactics
- HARO (Help a Reporter Out) setup: categories to subscribe to
- How to write a compelling HARO response in under 200 words
- Response template for journalist queries
- Alternative platforms: Qwoted, SourceBottle, ProfNet
## 6. Long-Term Media Relationship Building
- How to become a go-to source for journalists in your niche
- Building a media kit: what to include (brand assets, stats, founder bio)
- Gifting product to journalists: strategy and follow-up
- Social media engagement with target journalists before pitching
## 7. PR Measurement & ROI Plan
- Immediate actions (week 1): media list building, story angle finalization, pitch drafting
- Short-term (month 1): first 10 pitches sent, HARO responses daily, 1 press release
- Long-term (month 3+): 5+ media placements, SEO backlink impact, brand mention tracking
Include expected results: backlink quality from press mentions, referral traffic from Tier 1 vs Tier 2 coverage, and brand search lift from earned media."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Identify profitable product niches for Shopify stores using market demand data, competition analysis, and profitability assessment. Triggers: niche finder, f...
---
name: shopify-niche-finder
description: "Identify profitable product niches for Shopify stores using market demand data, competition analysis, and profitability assessment. Triggers: niche finder, find profitable niche, shopify niche, product niche research, ecommerce niche"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-niche-finder
---
# Shopify Profitable Niche Identifier
A systematic niche identification framework for aspiring and expanding Shopify store owners. This skill analyzes market demand, competition levels, profit margins, and trend trajectory to identify high-potential product niches with viable paths to $10K+ monthly revenue.
## Usage
```
find profitable niche: <interest area or budget>
niche research for Shopify: <product ideas>
evaluate niche: <specific niche>
best niches to sell: <budget and skills>
```
## What You Get
1. **Niche Evaluation Criteria** — Demand, competition, margin, and passion scoring
2. **Market Research Process** — Tools and methods to validate niche potential
3. **Competition Analysis Framework** — How to assess competitive landscape
4. **Profit Margin Assessment** — COGS, pricing, and margin projection by niche
5. **Trend Analysis** — Growing vs declining vs evergreen niches
6. **Top 10 Niche Recommendations** — Specific validated opportunities
7. **Validation Checklist** — How to test before fully committing
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-niche-finder — Profitable niche identification for Shopify stores
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: find profitable niche: <interest area or budget>"
exit 1
fi
SESSION_ID="shopify-niche-$(date +%s)"
PROMPT="You are a Shopify ecommerce niche research expert specializing in market demand analysis, competition assessment, and profitability modeling for product niches. Help identify and evaluate profitable Shopify niches for: INPUT
Produce a complete niche identification and evaluation report with these sections:
## 1. Niche Evaluation Framework
- 5 dimensions of a good niche: demand, competition, margin, passion, and defensibility
- Scoring matrix: rate each dimension 1-10 for any niche candidate
- Minimum viable scores to pursue a niche (avoid low-margin, high-competition traps)
- Red flags: commoditized niches, dominated by Amazon, low-ticket, price wars
## 2. Market Research Process & Tools
- Google Trends: how to analyze search volume trends and seasonality
- Amazon Best Sellers and Movers & Shakers: what ranks mean for Shopify viability
- Keyword research: search volume, CPC, and buyer intent for niche keywords
- Facebook Audience Insights and TikTok Creative Center for demand signals
## 3. Competition Analysis Framework
- Shopify store discovery: how to find competitors in any niche
- Alexa, SimilarWeb, and SEMrush for competitor traffic analysis
- Amazon review mining: understanding what customers love and hate
- Differentiation opportunities: what the market is missing
## 4. Profit Margin Modeling
- COGS estimation: product cost, shipping, duties, and fulfillment
- Pricing strategy: positioning above commodity, below premium
- Gross margin targets: minimum 40% for healthy Shopify DTC margins
- Customer acquisition cost modeling: what CAC can you afford at this margin?
## 5. Trend & Longevity Analysis
- Google Trends 5-year trajectory: growing, declining, or stable
- TikTok viral product risk: hot today, dead tomorrow
- Evergreen niches vs trend-driven: strategic consideration
- Adjacent product opportunities for catalog expansion
## 6. Top 10 Niche Recommendations
- Based on the input, 10 specific niche ideas with brief evaluations
- For each: estimated demand level, competition level, typical margin range
- Quick win niches (easier entry) vs premium niches (higher reward, more effort)
- Sub-niche opportunities within broader categories
## 7. Niche Validation Checklist & Launch Plan
- Immediate actions (week 1): 3 validation methods — Etsy listings, Instagram hashtags, keyword tools
- Short-term (month 1): build MVP store, run small paid traffic test, measure conversion
- Long-term (month 3+): full product catalog, established positioning, scaling
Include specific niche examples that are profitable in today's market, typical ROAS expectations by niche type, and common mistakes new Shopify store owners make in niche selection."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Optimize profit margins for Shopify stores by analyzing COGS, operating costs, and revenue levers to improve profitability. Triggers: margin optimizer, profi...
---
name: shopify-margin-optimizer
description: "Optimize profit margins for Shopify stores by analyzing COGS, operating costs, and revenue levers to improve profitability. Triggers: margin optimizer, profit margins, improve profitability, cost reduction shopify, gross margin improvement"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-margin-optimizer
---
# Shopify Margin & Profitability Optimizer
A complete profitability analysis and optimization framework for Shopify merchants. This skill builds a full P&L picture, identifies margin leaks, and develops a systematic plan to improve gross and net margins through COGS reduction, pricing optimization, and operational efficiency gains.
## Usage
```
margin optimizer for: <store niche or URL>
improve store profitability: <store description>
reduce costs and increase margins: <product category>
profitability analysis: <revenue level>
```
## What You Get
1. **P&L Framework** — Full ecommerce profit and loss structure for Shopify
2. **COGS Breakdown** — Product cost, shipping, packaging, and fulfillment analysis
3. **Margin Leak Identification** — Where profit is being lost in your operation
4. **COGS Reduction Tactics** — Supplier negotiation, volume buying, and substitution
5. **Revenue-Side Margin Improvement** — Pricing and mix optimization
6. **Operating Expense Optimization** — App stack, ad spend efficiency, team costs
7. **Profitability Improvement Roadmap** — 90-day plan to improve margins
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-margin-optimizer — Margin and profitability optimizer for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: margin optimizer for: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-margin-$(date +%s)"
PROMPT="You are a Shopify profitability and financial optimization expert specializing in ecommerce P&L analysis, COGS reduction, and margin improvement strategies for DTC brands. Build a complete margin optimization plan for: INPUT
Produce a complete profitability optimization report with these sections:
## 1. Ecommerce P&L Framework
- Revenue line items: gross sales, discounts, returns, net revenue
- COGS components: product cost, inbound freight, duties, packaging
- Gross margin calculation and industry benchmarks for this niche
- Contribution margin: gross profit minus variable marketing and fulfillment costs
- Net margin: after fixed operating expenses
## 2. COGS Component Analysis
- Product cost as % of revenue: target ranges by category
- Shipping cost optimization: carrier comparison, dimensional weight, zone skipping
- Packaging cost audit: opportunities to right-size and reduce material costs
- Fulfillment cost analysis: in-house vs 3PL comparison for this volume
## 3. Margin Leak Identification
- Top 5 margin leaks common in this product category
- Returns rate impact on gross margin (each 1% return rate = X% margin hit)
- Discount depth vs frequency analysis: are promotions killing margin?
- Payment processing fee optimization: Shopify Payments vs alternatives
- App subscription costs: audit against actual value delivered
## 4. COGS Reduction Tactics
- Supplier negotiation leverage: volume commitments, longer payment terms, exclusivity
- Material substitution: same quality, lower cost alternatives
- Manufacturing process improvements: where waste can be reduced
- Private label vs branded: margin comparison for resellers
## 5. Revenue-Side Margin Improvement
- Product mix optimization: shift sales toward higher-margin SKUs
- Price increase strategy: 5% increase with minimal demand impact tactics
- Bundle design for higher-margin combinations
- AOV increases: upsell and cross-sell impact on contribution margin
## 6. Operating Expense Reduction
- Marketing efficiency: CAC vs LTV ratio and paid media efficiency targets
- Team and contractor cost optimization
- Technology and app stack audit: 10 common unnecessary Shopify apps
- Shipping carrier rate negotiation thresholds and tactics
## 7. 90-Day Margin Improvement Roadmap
- Immediate actions (week 1): full P&L build, margin leak identification, app audit
- Short-term (month 1): top 3 COGS reduction initiatives, price increase on 20% of catalog
- Long-term (month 3+): supplier renegotiation, shipping optimization, 3-5 point margin improvement
Include specific margin benchmarks: healthy DTC gross margins (40-70% by category), contribution margin targets (20-30%), and what top quartile Shopify brands achieve for net margins."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Optimize shipping and logistics operations for Shopify stores to reduce costs, improve delivery times, and delight customers. Triggers: logistics optimizer,...
---
name: shopify-logistics-optimizer
description: "Optimize shipping and logistics operations for Shopify stores to reduce costs, improve delivery times, and delight customers. Triggers: logistics optimizer, shipping optimization, reduce shipping costs, shopify shipping strategy, fulfillment costs"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-logistics-optimizer
---
# Shopify Shipping & Logistics Optimizer
A complete logistics optimization framework for Shopify merchants. This skill analyzes your current shipping setup and builds a comprehensive strategy to reduce carrier costs, improve delivery speed, minimize dimensional weight charges, and create a better post-purchase shipping experience.
## Usage
```
logistics optimization: <store niche or URL>
shipping cost reduction: <current shipping setup>
optimize fulfillment: <order volume>
shipping strategy for: <product category>
```
## What You Get
1. **Shipping Cost Audit** — Full breakdown of current shipping spend and waste
2. **Carrier Comparison & Negotiation** — UPS, FedEx, USPS, DHL rate optimization
3. **Dimensional Weight Reduction** — Packaging strategies to reduce DIM weight charges
4. **Zone Skipping & Distributed Inventory** — Strategic warehouse placement
5. **Free Shipping Threshold Strategy** — Maximizing AOV while covering shipping costs
6. **International Shipping Setup** — Cost-effective cross-border shipping solutions
7. **Customer-Facing Shipping UX** — Setting and exceeding delivery expectations
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-logistics-optimizer — Shipping and logistics optimization for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: logistics optimization: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-logistics-$(date +%s)"
PROMPT="You are a Shopify logistics and shipping optimization expert specializing in carrier management, fulfillment cost reduction, and delivery experience design for ecommerce stores. Build a complete logistics optimization strategy for: INPUT
Produce a complete logistics optimization report with these sections:
## 1. Shipping Cost Audit Framework
- Shipping cost as % of revenue: current vs benchmark for this product category
- Cost breakdown by carrier, service level, and zone
- Dimensional weight analysis: how DIM weight affects your bills
- Surcharge audit: fuel, residential, address correction, peak season charges
## 2. Carrier Selection & Rate Optimization
- USPS vs UPS vs FedEx vs DHL: strengths and weaknesses by package type and zone
- Shopify Shipping discounts: pre-negotiated rates available in Shopify
- Volume discount thresholds: when to negotiate directly vs use a broker
- Regional carriers: OnTrac, LSO, Spee-Dee for zone 2-4 advantages
## 3. Dimensional Weight & Packaging Optimization
- DIM weight formula and how to calculate it
- Right-sizing packaging: 5 steps to audit and optimize box selection
- Poly mailer upgrade: when to switch from boxes for specific product types
- Packaging weight reduction: material substitutions for lighter fulfillment
## 4. Distributed Fulfillment & Zone Skipping
- Customer zip code analysis: where are 80% of your customers located?
- 2-warehouse strategy: East and West coast for next-day to 90% of US
- Zone skipping explanation: pre-sorting bulk shipments closer to customers
- 3PL selection for distributed fulfillment: ShipBob, Deliverr, ShipHero
## 5. Free Shipping Strategy
- Free shipping threshold calculation: where to set the minimum for AOV lift
- Free shipping cost absorption: when it makes sense and when it hurts
- Free shipping promotion A/B test methodology
- Conditional free shipping: member/loyalty exclusive vs public offer
## 6. International Shipping Setup
- DDP (Delivered Duty Paid) vs DDU: customer experience trade-off
- International carrier comparison by destination market
- Customs documentation automation in Shopify
- International return strategy and cost management
## 7. Customer Delivery Experience Optimization
- Immediate actions (week 1): shipping cost audit, carrier rate comparison, packaging review
- Short-term (month 1): implement cheapest carrier for each package type, free shipping threshold test
- Long-term (month 3+): 3PL evaluation, distributed inventory, 20%+ shipping cost reduction
Include specific savings benchmarks: average shipping cost reduction from right-sizing (15-25%), zone skipping savings (20-35%), and negotiated rate improvements for merchants shipping 500+ packages/month."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Localize a Shopify store for international markets with translated content, local payment methods, and culturally adapted marketing. Triggers: store localiza...
---
name: shopify-localization
description: "Localize a Shopify store for international markets with translated content, local payment methods, and culturally adapted marketing. Triggers: store localization, translate shopify, localize for market, shopify language setup, cultural adaptation"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-localization
---
# Shopify Store Localization Strategy
A complete store localization playbook for Shopify merchants entering international markets. This skill covers language translation workflows, cultural content adaptation, local payment integration, SEO localization, and market-specific marketing to deliver a native shopping experience for international customers.
## Usage
```
localize store for: <target market or country>
translate Shopify store: <languages needed>
localization strategy: <store niche and target market>
adapt store for: <country or region>
```
## What You Get
1. **Translation Workflow** — Machine vs professional translation strategy and tools
2. **Cultural Adaptation Guide** — Colors, imagery, messaging by market
3. **Local Payment Integration** — Must-have payment methods by country
4. **SEO Localization** — Hreflang, translated keywords, and local backlinks
5. **Currency & Pricing Strategy** — Psychological pricing by market
6. **Customer Service Localization** — Support in local language setup
7. **Marketing Localization** — Social media channels and ad platforms by market
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-localization — Store localization strategy for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: localize store for: <target market or country>"
exit 1
fi
SESSION_ID="shopify-local-$(date +%s)"
PROMPT="You are a Shopify localization expert specializing in cultural adaptation, multilingual store setup, local payment integration, and market-specific marketing for international ecommerce. Build a complete localization strategy for: INPUT
Produce a complete store localization report with these sections:
## 1. Translation Strategy & Workflow
- Translation scope: product pages, collections, checkout, emails, legal pages
- Machine translation tools: DeepL, Google Translate — accuracy and limitations
- Professional translation: when to invest, cost per word benchmarks
- Shopify translation apps: Weglot, Langify, Transcy — comparison and recommendation
- Translation management workflow and quality review process
## 2. Cultural Adaptation for Target Market
- Color psychology differences: colors to avoid or embrace in target market
- Imagery and lifestyle photography: representation and cultural relevance
- Tone of voice differences: formal vs casual communication norms
- Social proof format: how testimonials and reviews are consumed in this market
- Local holidays, events, and cultural moments for marketing calendar
## 3. Local Payment Method Integration
- Mandatory payment methods for this target market with adoption rates
- Shopify Payments availability and alternatives in target market
- BNPL options popular in this market (Klarna, Afterpay, local providers)
- Bank transfer and local wallet options setup
## 4. SEO Localization
- Hreflang tag implementation for international SEO
- Keyword research in local language: tools and process
- URL structure for localized content (subdirectory vs subdomain)
- Local backlink strategy: directories, media, and partner sites
- Google My Business setup if applicable for local presence
## 5. Pricing & Currency Strategy
- Psychological pricing in target market (odd pricing norms, price points)
- Currency display: automatic conversion vs fixed local pricing
- Tax-inclusive vs tax-exclusive pricing convention in this market
- Promotional discount norms: what percentage off resonates
## 6. Localized Customer Service
- Primary customer service channel preference in this market
- Language support options: hire local, use translation AI, or partner
- Local business hours and response time expectations
- Return and refund norms and legal requirements in target market
## 7. Market-Specific Marketing Plan
- Immediate actions (week 1): translation scope, payment setup, SEO hreflang
- Short-term (month 1): first localized campaigns, local influencer outreach
- Long-term (month 3+): market-specific ad campaigns, local PR, community building
Include localization ROI data: conversion rate lift from localized stores vs English-only for non-English markets, and cost benchmarks for full store localization."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Build a new product launch playbook for Shopify stores with pre-launch hype, launch day tactics, and post-launch momentum. Triggers: product launch strategy,...
---
name: shopify-launch-strategy
description: "Build a new product launch playbook for Shopify stores with pre-launch hype, launch day tactics, and post-launch momentum. Triggers: product launch strategy, launch playbook, new product launch, shopify launch plan, product launch campaign"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-launch-strategy
---
# Shopify New Product Launch Playbook
A complete product launch strategy for Shopify merchants covering pre-launch buzz building, launch day execution, and post-launch momentum. This skill generates a 30-60-90 day launch roadmap with email sequences, social campaigns, paid ad strategies, and influencer activation plans.
## Usage
```
product launch strategy: <product and niche>
launch playbook for: <product description>
new product launch plan: <store type>
launch my product: <product category>
```
## What You Get
1. **Pre-Launch Hype Building** — 30-day countdown strategy to build anticipation
2. **Launch Day Execution** — Hour-by-hour launch day campaign plan
3. **Email Launch Sequence** — 8-email series from teaser to post-launch
4. **Social Media Launch Calendar** — Platform-specific content for launch month
5. **Paid Ad Launch Strategy** — Campaign structure and budget for launch period
6. **Influencer Launch Activation** — Coordinated seeding and content drops
7. **Post-Launch Momentum** — Sustaining sales velocity after initial launch spike
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-launch-strategy — New product launch playbook for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: product launch strategy: <product and niche>"
exit 1
fi
SESSION_ID="shopify-launch-$(date +%s)"
PROMPT="You are a Shopify product launch expert specializing in pre-launch buzz building, coordinated multi-channel launch campaigns, and post-launch momentum strategies for ecommerce brands. Build a complete product launch playbook for: INPUT
Produce a complete product launch strategy report with these sections:
## 1. Pre-Launch Strategy (30-Day Countdown)
- Week 4 before launch: teaser campaign, waitlist setup, influencer seeding
- Week 3: behind-the-scenes content, origin story release, email list segmentation
- Week 2: product previews, early access offer for existing customers, ad pixel warming
- Week 1: countdown content, press outreach, influencer content embargo lift
## 2. Launch Day Campaign Plan
- Launch time selection: best time of day and day of week for this audience
- Hour 1: email blast #1, social posts go live, ads activate
- Hours 2-6: influencer posts publish, PR pitches sent, community announcement
- End of day: email blast #2 (if goals not met), day-1 results snapshot
- Launch day contingency: what to do if sales are below target
## 3. Email Launch Sequence (8-Email Series)
- Email 1 (day -30): teaser — something exciting is coming
- Email 2 (day -14): product reveal — show it, explain why it exists
- Email 3 (day -7): early access offer — exclusive for subscribers
- Email 4 (day -3): countdown reminder — 3 days left for early access
- Email 5 (launch day AM): it's live — full product reveal with CTA
- Email 6 (launch day PM): social proof and first customer reactions
- Email 7 (day +3): selling scarcity or featuring customer photos
- Email 8 (day +7): last chance / post-launch momentum story
## 4. Social Media Launch Calendar
- Instagram: feed posts, Stories, and Reels schedule for launch month
- TikTok: content series — problem, tease, reveal, launch, testimonials
- Pinterest: launch pin strategy and board creation
- Platform-specific content angles and caption templates
## 5. Paid Advertising Launch Plan
- Pre-launch: awareness campaign to warm up audiences (1-2 weeks before)
- Launch day: conversion campaign with launch-specific creative
- Budget allocation: pre-launch vs launch day vs post-launch
- Retargeting: capturing launch traffic that didn't convert immediately
## 6. Influencer Launch Coordination
- Seeding timeline: product delivery to influencers 3-4 weeks before launch
- Content embargo: coordinating simultaneous publish timing
- Gifting vs paid: strategy for launch partnerships
- Micro-influencer coordination: managing 10-20 smaller creators
## 7. Post-Launch Momentum Plan
- Immediate actions (launch day): monitor, respond, amplify organic posts
- Short-term (week 1): leverage launch reviews and UGC, retargeting campaigns
- Long-term (month 1+): transition from launch to evergreen advertising, restock planning
Include launch success benchmarks: first-day revenue targets, email open rates for launch sequences (typically 40-60%), and how to define a successful product launch for different store sizes."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Design high-converting landing pages for Shopify stores with optimized layouts, copy frameworks, and CRO best practices. Triggers: landing page builder, high...
---
name: shopify-landing-page-builder
description: "Design high-converting landing pages for Shopify stores with optimized layouts, copy frameworks, and CRO best practices. Triggers: landing page builder, high converting landing page, shopify landing page, landing page design, conversion page"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-landing-page-builder
---
# Shopify High-Converting Landing Page Builder
A complete landing page strategy and copy framework for Shopify stores. This skill generates landing page wireframes, copy structures, CTA hierarchies, and A/B testing plans to maximize conversion rates for product launches, ad campaigns, and sales events.
## Usage
```
landing page strategy: <store niche or URL>
build converting landing page: <product or campaign>
landing page copy framework: <product description>
optimize my landing page: <store URL>
```
## What You Get
1. **Page Architecture** — Above-the-fold layout, section order, and visual hierarchy
2. **Headline & Copy Framework** — Hook, subhead, and body copy formulas
3. **Social Proof Integration** — Reviews, trust badges, and testimonial placement
4. **CTA Optimization** — Button copy, placement, color, and urgency triggers
5. **Mobile-First Design** — Mobile layout considerations and thumb zone optimization
6. **Speed & Technical SEO** — Page speed requirements and meta tag strategy
7. **A/B Testing Roadmap** — Elements to test and statistical significance guidelines
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-landing-page-builder — High-converting landing page strategy for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: landing page strategy: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-landing-$(date +%s)"
PROMPT="You are a Shopify conversion rate optimization expert specializing in high-converting landing page design, copywriting, and A/B testing for ecommerce stores. Build a complete landing page strategy and copy framework for: INPUT
Produce a complete landing page optimization report with these sections:
## 1. Page Architecture & Layout
- Above-the-fold requirements: hero image, headline, subhead, CTA, trust signals
- Recommended section order for maximum conversion flow
- Visual hierarchy principles for this product category
- Exit intent and sticky elements placement
## 2. Headline & Copy Framework
- 5 headline formulas with specific examples for this niche
- Value proposition statement formula (for whom, what outcome, how different)
- Bullet point copy structure: feature → benefit → emotional payoff
- Objection handling section placement and copy
## 3. Social Proof & Trust Architecture
- Review placement strategy: stars near CTA, detailed reviews below fold
- Trust badge types and placement (secure checkout, guarantee, certifications)
- Number proof: customers served, units sold, satisfaction rate
- Press mentions and media logo bar setup
## 4. CTA Optimization Strategy
- Primary CTA button copy variations to test (5 options with psychology)
- Button size, color contrast, and whitespace guidelines
- Multi-CTA strategy: primary, secondary, and micro-conversions
- Urgency and scarcity elements: countdown timers, stock indicators
## 5. Mobile-First Design Requirements
- Mobile layout differences from desktop
- Thumb zone optimization for CTA placement
- Mobile image sizing and load optimization
- Tap target sizing and form field best practices
## 6. Page Speed & Technical Foundations
- Target load time and Core Web Vitals scores
- Image optimization checklist (WebP, lazy loading, sizing)
- App and script audit: what to remove for speed
- Shopify theme vs page builder comparison (GemPages, Shogun, Pagefly)
## 7. A/B Testing & Optimization Roadmap
- Immediate actions (week 1): launch page, baseline metrics, heatmap setup
- Short-term (month 1): first A/B test (headline), second test (CTA copy)
- Long-term (month 3+): multivariate testing, personalization, segment-specific pages
Include conversion rate benchmarks by industry, average order value impact from page improvements, and specific tools for heatmaps, session recording, and A/B testing on Shopify."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Plan international market expansion for Shopify stores including market selection, localization, logistics, and compliance. Triggers: international expansion...
---
name: shopify-international-expansion
description: "Plan international market expansion for Shopify stores including market selection, localization, logistics, and compliance. Triggers: international expansion, global ecommerce, sell internationally, shopify markets, expand globally"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-international-expansion
---
# Shopify International Market Expansion
A complete international expansion strategy for Shopify merchants. This skill guides store owners through market selection, localization requirements, payment and logistics setup, compliance considerations, and marketing launch plans for entering new global markets profitably.
## Usage
```
international expansion plan: <store niche or URL>
global expansion strategy: <current market and goals>
sell internationally: <product category>
enter new market: <target country or region>
```
## What You Get
1. **Market Selection Framework** — Data-driven criteria for choosing international markets
2. **Market Entry Analysis** — Deep-dive on top 3 target markets
3. **Shopify Markets Configuration** — Technical setup for multi-currency, multi-language
4. **Localization Checklist** — Language, currency, images, and cultural adaptation
5. **International Payments & Tax** — Payment methods by market, VAT/GST compliance
6. **Cross-Border Logistics** — Shipping options, duties, and fulfillment strategies
7. **Go-to-Market Plan** — Launching in each new market with local marketing tactics
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-international-expansion — International market expansion for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: international expansion plan: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-international-$(date +%s)"
PROMPT="You are a Shopify international expansion expert specializing in global ecommerce market entry, cross-border logistics, localization, and multi-market marketing strategy. Build a complete international expansion plan for: INPUT
Produce a complete international expansion report with these sections:
## 1. Market Selection Framework
- Criteria for evaluating international markets: market size, competition, logistics, regulation
- Top 5 recommended markets for this product category with data rationale
- Quick wins: markets with lowest barrier to entry for this niche
- Markets to avoid or defer: high complexity or low opportunity
## 2. Top 3 Market Deep-Dives
For each of the top 3 markets:
- Market size and ecommerce penetration data
- Top competitors and their positioning
- Consumer behavior and purchasing preferences
- Cultural considerations that affect marketing and product presentation
- Estimated market entry cost and timeline
## 3. Shopify Markets & Technical Configuration
- Shopify Markets setup: enabling international markets, currency, and language
- Domain strategy: subdomain (.com/en-gb) vs country-code TLD (.co.uk)
- Geolocation redirect configuration and selector widget
- Automatic currency conversion vs fixed local pricing strategy
## 4. Localization Requirements Checklist
- Language translation: machine translation vs professional translator
- Currency display and pricing psychology per market
- Product images and lifestyle photography cultural adaptation
- Date formats, measurement units, and address format adjustments
## 5. International Payments & Tax Compliance
- Preferred payment methods by market (iDEAL in Netherlands, PayNow in Singapore, etc.)
- VAT/GST registration thresholds by country
- Duties and import tax handling: DDP vs DDU shipping
- International fraud prevention considerations
## 6. Cross-Border Logistics Strategy
- Shipping carriers by region: DHL, FedEx, local carriers comparison
- Fulfillment options: ship from home country vs 3PL in target market
- Delivery time benchmarks by market and customer expectations
- Returns strategy for international orders
## 7. Go-to-Market Launch Plan
- Immediate actions (week 1): market selection, Shopify Markets setup, first translations
- Short-term (month 1): first international orders, logistics tested, localized ads
- Long-term (month 3+): full localization, local marketing investment, market-specific influencers
Include specific market entry cost estimates, expected CAC in new markets vs home market, and revenue ramp timelines for Shopify international expansion."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Plan and execute influencer marketing campaigns for Shopify stores including outreach, briefs, and ROI tracking. Triggers: influencer campaign, influencer ma...
---
name: shopify-influencer-campaign
description: "Plan and execute influencer marketing campaigns for Shopify stores including outreach, briefs, and ROI tracking. Triggers: influencer campaign, influencer marketing plan, creator partnerships, influencer outreach, brand collaborations"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-influencer-campaign
---
# Shopify Influencer Campaign Manager
A complete influencer marketing campaign framework for Shopify merchants. This skill builds a full campaign strategy from influencer identification and tiering to outreach scripts, creative briefs, contract terms, and ROI measurement.
## Usage
```
influencer campaign plan: <store niche or URL>
creator marketing strategy: <brand and budget>
influencer outreach for: <product category>
plan my influencer campaign: <niche and goals>
```
## What You Get
1. **Influencer Tiering Strategy** — Nano, micro, macro, and mega influencer mix
2. **Discovery & Vetting Framework** — How to find and qualify creators for your niche
3. **Outreach Templates** — Cold DM and email scripts with high response rates
4. **Campaign Brief Template** — Deliverables, talking points, and creative guidelines
5. **Compensation Models** — Gifting, flat fee, affiliate, and hybrid structures
6. **Legal & Contracts** — Key contract clauses and FTC disclosure requirements
7. **Tracking & ROI Measurement** — UTM links, promo codes, and performance benchmarks
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-influencer-campaign — Influencer campaign management for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: influencer campaign plan: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-influencer-campaign-$(date +%s)"
PROMPT="You are a Shopify influencer marketing expert specializing in campaign management, creator partnerships, and ROI-driven influencer programs for ecommerce brands. Build a complete influencer campaign strategy for: INPUT
Produce a complete influencer campaign report with these sections:
## 1. Influencer Tiering & Mix Strategy
- Recommended tier breakdown: nano (1K-10K), micro (10K-100K), macro (100K-1M), mega (1M+)
- Budget allocation percentages across tiers
- Platform priorities: Instagram, TikTok, YouTube, Pinterest by niche
- Engagement rate benchmarks by tier and platform
## 2. Creator Discovery & Vetting
- Top hashtags and search terms to find creators in this niche
- Vetting checklist: engagement rate, audience demographics, fake follower signals
- Tools and platforms for influencer discovery (free and paid options)
- Red flags to avoid: engagement pods, purchased followers, brand misalignment
## 3. Outreach Strategy & Templates
- Cold DM template (Instagram/TikTok) with personalization hooks
- Email outreach template for larger creators
- Follow-up sequence (3-touch cadence)
- Subject lines with high open rates for creator emails
## 4. Campaign Brief & Creative Direction
- One-page campaign brief template with all required fields
- Key talking points and messaging guidelines
- Dos and don'ts for content creation
- Content approval process and revision policy
## 5. Compensation & Deal Structures
- Market rate pricing by tier, platform, and content type
- Gifting-only strategy: when it works and how to pitch
- Affiliate + flat fee hybrid model setup
- Performance bonuses tied to sales milestones
## 6. Legal Requirements & Contracts
- FTC disclosure requirements and hashtag guidelines
- Key contract clauses: exclusivity, usage rights, revision rounds
- Content ownership and licensing terms
- Payment schedule and deliverable timeline
## 7. Tracking, Reporting & ROI Plan
- Immediate actions (week 1): outreach list, brief creation, tracking setup
- Short-term (month 1): launch first wave, monitor performance, collect content
- Long-term (month 3+): build ambassador roster, performance-based renewals, attribution
Include specific CPM, EMV, and cost-per-acquisition benchmarks. Provide actionable steps and templates tailored to Shopify store owners."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Evaluate and plan a headless commerce strategy for Shopify stores to achieve custom frontend experiences and performance gains. Triggers: headless commerce,...
---
name: shopify-headless-commerce
description: "Evaluate and plan a headless commerce strategy for Shopify stores to achieve custom frontend experiences and performance gains. Triggers: headless commerce, headless shopify, shopify plus headless, custom storefront, composable commerce"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-headless-commerce
---
# Shopify Headless Commerce Strategy
A complete headless commerce evaluation and implementation guide for Shopify merchants. This skill assesses whether headless is right for your store, compares frontend frameworks, calculates ROI, and builds a migration roadmap for merchants considering a custom storefront architecture.
## Usage
```
headless commerce strategy: <store niche or URL>
should I go headless: <current store size>
headless Shopify plan: <store goals>
custom storefront strategy: <tech requirements>
```
## What You Get
1. **Headless Assessment** — Is headless right for your store? Decision framework
2. **Architecture Options** — Hydrogen, Next.js, Gatsby, and Remix comparisons
3. **Performance Gains Analysis** — Core Web Vitals and conversion impact
4. **Cost & Resource Requirements** — Development cost, maintenance, and team needs
5. **Migration Roadmap** — Phased approach from monolithic to headless
6. **SEO Considerations** — How to maintain and improve SEO during migration
7. **ROI Calculator** — Performance improvement vs investment analysis
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-headless-commerce — Headless commerce strategy for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: headless commerce strategy: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-headless-$(date +%s)"
PROMPT="You are a Shopify headless commerce architect specializing in custom storefront implementations, Shopify Storefront API, and composable commerce strategies for scaling ecommerce brands. Build a complete headless commerce evaluation and strategy for: INPUT
Produce a complete headless commerce strategy report with these sections:
## 1. Headless Readiness Assessment
- When headless makes sense: traffic thresholds, customization needs, performance requirements
- When to stay on traditional Shopify: cost vs benefit analysis for smaller stores
- Decision framework: 10 questions to determine if headless is right now
- Current Shopify theme limitations that headless solves
## 2. Architecture & Framework Options
- Shopify Hydrogen: native React-based framework, pros, cons, use cases
- Next.js + Shopify Storefront API: flexibility, SEO-friendly, large ecosystem
- Remix + Shopify: newer option, performance advantages, learning curve
- Gatsby: static generation approach, best use cases
- Recommendation matrix: which framework for which store profile
## 3. Performance & Conversion Impact
- Core Web Vitals improvement: LCP, FID, CLS targets for headless stores
- Page speed benchmarks: headless vs traditional Shopify themes
- Conversion rate impact: each 100ms page speed improvement = X% conversion lift
- Mobile performance gains: specific improvements for mobile-first stores
## 4. Development Cost & Team Requirements
- Development cost ranges: agency vs in-house by framework
- Ongoing maintenance requirements and time commitments
- Skill requirements: React, GraphQL, Shopify Storefront API
- Shopify Hydrogen vs third-party: total cost of ownership comparison
## 5. SEO Migration Strategy
- URL structure preservation during migration
- Redirect mapping strategy for all existing URLs
- Sitemap regeneration and submission process
- Core Web Vitals monitoring before, during, and after migration
- Content migration from Shopify Liquid to new frontend
## 6. Phased Migration Roadmap
- Phase 1: Audit, architecture decision, and development environment setup
- Phase 2: Build product and collection pages, maintain existing checkout
- Phase 3: Homepage, landing pages, and marketing pages migration
- Phase 4: Full cutover, monitoring, and performance validation
## 7. ROI Analysis & Decision Plan
- Immediate actions (week 1): performance audit, traffic analysis, headless decision
- Short-term (month 1): architecture selection, agency/developer sourcing
- Long-term (month 3+): development, staging, migration, and go-live
Include specific ROI calculations: if conversion rate improves by 0.5% on $1M annual revenue, the gain is $5,000/month — compare to development cost. Provide realistic timelines and budget ranges for headless implementations."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Build and optimize Google Ads and Shopping campaigns for Shopify stores to drive qualified traffic and sales. Triggers: google ads, shopping campaigns, googl...
---
name: shopify-google-ads
description: "Build and optimize Google Ads and Shopping campaigns for Shopify stores to drive qualified traffic and sales. Triggers: google ads, shopping campaigns, google shopping, ppc strategy, google ads shopify"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-google-ads
---
# Shopify Google Ads & Shopping Campaigns
A comprehensive Google Ads strategy engine for Shopify store owners. This skill analyzes your niche and builds a complete Google Ads and Shopping campaign framework covering campaign structure, bidding strategies, audience targeting, and performance optimization.
## Usage
```
Google Ads strategy: <store niche or URL>
Shopping campaign plan: <product category>
google ads setup for: <store description>
optimize my google ads: <niche and budget>
```
## What You Get
1. **Campaign Architecture** — Search, Shopping, and Performance Max campaign structure
2. **Keyword Strategy** — High-intent keyword lists, match types, and negative keywords
3. **Shopping Feed Optimization** — Product title, description, and attribute best practices
4. **Bidding Strategy** — Smart bidding vs manual CPC recommendations by stage
5. **Audience Targeting** — Remarketing lists, customer match, and in-market segments
6. **Ad Copy Framework** — RSA headlines, descriptions, and extensions for each campaign type
7. **Performance Benchmarks** — ROAS targets, CPA goals, and optimization schedule
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-google-ads — Google Ads & Shopping campaigns strategy for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: google ads strategy: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-google-ads-$(date +%s)"
PROMPT="You are a Shopify Google Ads expert specializing in Search, Shopping, and Performance Max campaigns for ecommerce. Analyze and build a complete Google Ads strategy for: INPUT
Produce a complete Google Ads strategy report with these sections:
## 1. Campaign Architecture
- Recommended campaign types (Search, Shopping, PMax, Display)
- Budget allocation percentages across campaigns
- Campaign naming convention and structure
- Geographic and language targeting recommendations
## 2. Keyword Strategy
- Top 20 high-intent keywords with estimated CPC and volume
- Match type recommendations (broad, phrase, exact) by funnel stage
- Negative keyword list (30+ terms to exclude)
- Competitor keyword opportunities
## 3. Google Shopping Feed Optimization
- Product title formula for maximum visibility
- Required and recommended attributes checklist
- Image requirements and best practices
- Feed update frequency and error prevention
## 4. Bidding & Budget Strategy
- Starting bid strategy (manual CPC vs tCPA vs tROAS)
- Recommended daily budgets by campaign
- Bid adjustments for device, location, time of day
- When to switch to Smart Bidding and conversion thresholds needed
## 5. Audience Targeting & Remarketing
- Remarketing list setup (site visitors, cart abandoners, purchasers)
- Customer match strategy using email lists
- In-market and affinity audience overlays
- Similar audience expansion tactics
## 6. Ad Copy & Extensions
- 15 RSA headline options with keyword insertion tips
- 4 description line templates
- Must-have extensions: sitelinks, callouts, structured snippets, price
- A/B testing framework for ad copy
## 7. 90-Day Launch & Optimization Plan
- Immediate actions (week 1): account setup, feed submission, campaign launch
- Short-term (month 1): data collection, bid adjustments, search term mining
- Long-term (month 3+): ROAS scaling, PMax asset testing, competitor conquesting
Include specific ROAS benchmarks by niche, average CPC ranges, and conversion rate expectations. Be specific with numbers and actionable steps tailored to Shopify stores."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Choose and optimize between 3PL, in-house, and hybrid fulfillment models for Shopify stores based on volume, cost, and growth stage. Triggers: fulfillment st...
---
name: shopify-fulfillment-strategy
description: "Choose and optimize between 3PL, in-house, and hybrid fulfillment models for Shopify stores based on volume, cost, and growth stage. Triggers: fulfillment strategy, 3pl vs in-house, shopify fulfillment, outsource fulfillment, fulfillment center"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-fulfillment-strategy
---
# Shopify Fulfillment Strategy Optimizer
A complete fulfillment model evaluation and optimization guide for Shopify merchants. This skill analyzes the trade-offs between in-house, 3PL, and hybrid fulfillment to recommend the right model for your order volume, SKU complexity, growth stage, and customer expectations.
## Usage
```
fulfillment strategy for: <store niche or URL>
3PL vs in-house: <current order volume>
outsource fulfillment: <store description>
choose fulfillment model: <order volume and SKUs>
```
## What You Get
1. **Fulfillment Model Comparison** — In-house vs 3PL vs hybrid cost-benefit analysis
2. **When to Switch to 3PL** — Volume and complexity thresholds for outsourcing
3. **3PL Selection Framework** — How to evaluate and choose the right partner
4. **3PL Contract Negotiation** — Key terms, rates, and SLA requirements
5. **Onboarding & Integration** — How to migrate to a new fulfillment provider
6. **Performance Monitoring** — 3PL KPIs and accountability frameworks
7. **Scaling Fulfillment** — Planning capacity for growth and peak seasons
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-fulfillment-strategy — 3PL vs in-house fulfillment strategy for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: fulfillment strategy for: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-fulfillment-$(date +%s)"
PROMPT="You are a Shopify fulfillment strategy expert specializing in 3PL selection, in-house operations, and fulfillment model optimization for ecommerce businesses at various growth stages. Build a complete fulfillment strategy for: INPUT
Produce a complete fulfillment strategy report with these sections:
## 1. Fulfillment Model Comparison
- In-house fulfillment: cost structure, labor, space, control advantages
- 3PL (third-party logistics): cost structure, scalability, technology advantages
- Hybrid model: which operations to keep in-house vs outsource
- Decision matrix: when each model wins based on volume, SKU count, and growth rate
## 2. In-House Fulfillment Optimization
- Space requirements: typical sq ft needed per daily order volume
- Labor requirements: staff-to-order ratio benchmarks
- Technology stack: WMS, barcode scanning, packing station setup
- When to outgrow in-house: the inflection point signals to watch for
## 3. 3PL Market Overview & Selection Criteria
- Major 3PL options for Shopify: ShipBob, ShipHero, Deliverr, Whiplash, Red Stag
- Selection criteria: minimum order volumes, SKU limits, integration quality
- Specialty 3PLs: temperature-controlled, hazmat, apparel-specialized, DTC-focused
- RFQ process: what to send and what to compare
## 4. 3PL Cost Structure Breakdown
- Receiving fees: per pallet, per item, labor rates
- Storage fees: per bin, per pallet per month
- Pick and pack fees: per order, per item, packing materials
- Outbound shipping: carrier rates, dimensional weight practices
- Setup and integration fees: one-time costs
## 5. 3PL Contract Key Terms
- SLA requirements: order processing time, accuracy rate (99%+), damage rate
- Inventory accuracy: cycle count frequency and liability for losses
- Exit clauses: minimum contract length, termination notice, inventory return
- Liability and insurance: coverage for lost or damaged inventory
## 6. Migration & Integration Process
- 90-day transition plan: overlap period, inventory transfer, parallel testing
- Shopify integration: API connections, inventory sync, order routing
- Team communication: customer service alignment on new processes
- Peak season timing: when NOT to switch fulfillment providers
## 7. Performance Monitoring & Scaling Plan
- Immediate actions (week 1): fulfillment cost audit, 3PL shortlist, RFQ preparation
- Short-term (month 1): 3PL evaluation, contract negotiation, integration testing
- Long-term (month 3+): go-live, performance monitoring, quarterly business reviews
Include specific volume thresholds: when 3PL becomes cost-competitive with in-house (typically 50-100 orders/day), and average cost per order comparison across fulfillment models."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Plan and run a crowdfunding campaign on Kickstarter or Indiegogo to validate and fund new Shopify products before launch. Triggers: crowdfunding strategy, ki...
---
name: shopify-crowdfunding
description: "Plan and run a crowdfunding campaign on Kickstarter or Indiegogo to validate and fund new Shopify products before launch. Triggers: crowdfunding strategy, kickstarter campaign, indiegogo plan, crowdfunding for product, product crowdfunding"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-crowdfunding
---
# Shopify Crowdfunding Campaign Strategy
A complete crowdfunding campaign planner for Shopify merchants launching new products on Kickstarter or Indiegogo. This skill covers pre-campaign audience building, campaign page optimization, reward tier design, backer communication, and post-campaign Shopify store transition strategy.
## Usage
```
crowdfunding campaign plan: <product idea>
Kickstarter strategy for: <product description>
crowdfunding launch plan: <niche>
fund my product launch: <product category>
```
## What You Get
1. **Platform Selection** — Kickstarter vs Indiegogo vs Backerkit comparison
2. **Pre-Campaign Audience Building** — Building a backer list before launch
3. **Campaign Page Blueprint** — Story, video, rewards, and social proof
4. **Reward Tier Architecture** — Pricing and fulfillment for backer rewards
5. **Launch Day & Momentum Tactics** — First 48-hour critical mass strategy
6. **Backer Communication Plan** — Update schedule and community management
7. **Post-Campaign Shopify Transition** — Converting crowdfunding momentum to DTC
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-crowdfunding — Crowdfunding campaign strategy for Shopify product launches
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: crowdfunding campaign plan: <product idea>"
exit 1
fi
SESSION_ID="shopify-crowdfunding-$(date +%s)"
PROMPT="You are a Shopify crowdfunding and product launch expert specializing in Kickstarter and Indiegogo campaigns, backer community building, and post-campaign DTC transition strategies. Build a complete crowdfunding strategy for: INPUT
Produce a complete crowdfunding campaign plan with these sections:
## 1. Platform Selection & Strategy
- Kickstarter: all-or-nothing funding model, discovery advantages, category fit
- Indiegogo: flexible funding, InDemand post-campaign, less discovery
- Backerkit: pre-launch reservation system and post-campaign store
- Recommendation for this product type with specific reasoning
## 2. Pre-Campaign Audience Building (60-Day Pre-Launch)
- Email list building goal: target 1,000 interested backers before launch
- Lead magnet strategy: what to offer for email sign-ups
- Facebook Group or community for early supporters
- Social media content strategy: problem storytelling and product teasers
- PR and media outreach: relevant tech/product blogs and journalists
## 3. Campaign Page Blueprint
- Campaign title formula: what it is, who it's for, key benefit
- Campaign video structure: problem, solution, product demo, team, call to action
- Story section: narrative arc that makes people root for you to succeed
- Social proof elements: prototype testing results, advisor quotes, beta user feedback
- FAQ section: top 10 questions to address pre-emptively
## 4. Reward Tier Architecture
- Early bird tier: significant discount for first 24 hours to create urgency
- Standard tier: main product with realistic fulfillment pricing
- Deluxe tier: product + extras for higher-value backers
- Corporate/bulk tier: multiple units for business customers
- Stretch goal tie-in rewards: how to motivate shares and funding growth
## 5. Launch Day & Momentum Strategy
- Day 1 goal: reach 30-40% of funding target within first 24 hours
- Backer mobilization: personal outreach to first 50 backers
- Social media blitz: coordinated posts across all channels at launch
- Press coverage timing: embargo lifts at midnight of launch day
- Stretch goal cadence: reveal new goals as each is hit
## 6. Backer Communication & Community
- Update frequency: weekly during campaign, bi-weekly post-campaign
- Update structure: progress, milestone celebration, next steps
- Community management: responding to comments within 24 hours
- Delay communication: honest, proactive updates if timeline changes
## 7. Post-Campaign Shopify Transition
- Immediate actions (launch day): campaign live, email to full pre-launch list
- Short-term (month 1): reach funding goal, stretch goals, media coverage
- Long-term (post-campaign): InDemand or Shopify store launch, backer fulfillment, DTC scaling
Include Kickstarter success benchmarks: average funding amounts, category success rates, importance of the first 48 hours, and how successful crowdfunding campaigns translate to Shopify store success."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Build a content marketing and SEO blog strategy for Shopify stores to drive organic traffic, build authority, and convert readers to buyers. Triggers: conten...
---
name: shopify-content-marketing
description: "Build a content marketing and SEO blog strategy for Shopify stores to drive organic traffic, build authority, and convert readers to buyers. Triggers: content marketing, seo blog strategy, shopify blog, content strategy, organic traffic shopify"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-content-marketing
---
# Shopify Content Marketing & SEO Blog Strategy
A complete content marketing strategy for Shopify merchants to build organic search traffic, brand authority, and a loyal audience that converts to customers. This skill develops a keyword-driven blog strategy, content calendar, and distribution plan that compounds in value over time.
## Usage
```
content marketing strategy: <store niche or URL>
SEO blog plan for: <product category>
content strategy for Shopify: <brand description>
drive organic traffic: <store type>
```
## What You Get
1. **Keyword Research & Content Pillars** — Topic clusters and keyword targeting strategy
2. **Content Calendar** — 12-month editorial calendar with topic ideas
3. **Blog Post Formats** — High-performing content types for ecommerce
4. **SEO Optimization Guide** — On-page SEO for every blog post
5. **Content Distribution Strategy** — Amplifying each post across channels
6. **Internal Linking Architecture** — Connecting content to product pages
7. **Measurement & Content ROI** — Tracking organic traffic and revenue attribution
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-content-marketing — Content marketing and SEO blog strategy for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: content marketing strategy: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-content-mkt-$(date +%s)"
PROMPT="You are a Shopify content marketing and SEO expert specializing in blog strategy, keyword-driven content creation, and organic traffic growth for ecommerce stores. Build a complete content marketing strategy for: INPUT
Produce a complete content marketing and SEO strategy report with these sections:
## 1. Keyword Research & Content Pillar Strategy
- Content pillar identification: 5 broad topic pillars for this niche
- Pillar page (10x content) topics: comprehensive guides that anchor each pillar
- Cluster content: 10 supporting articles per pillar
- Keyword difficulty balance: mix of quick wins (low KD) and long-term plays (high KD)
- Featured snippet opportunities: question-based keywords to target
## 2. Top 30 Blog Post Ideas
- 10 informational posts: how-to, guides, educational content
- 10 commercial investigation posts: best X, reviews, comparisons
- 10 transactional posts: product-linked content close to purchase
- Priority ranking: which to write first for fastest organic traffic impact
## 3. Content Calendar Structure
- Publishing frequency recommendation: 2-4 posts/month minimum for this niche
- Editorial calendar template with monthly themes
- Seasonal content: which months need specific topics
- Evergreen vs trending content ratio recommendation
## 4. Blog Post SEO Optimization Framework
- Title formula: primary keyword + power word + number/year
- Meta description template: hook + keyword + CTA (under 155 characters)
- Header structure: H1, H2, H3 hierarchy guidelines
- Image optimization: alt text formula, file names, compression targets
- Word count targets by content type: guide (2000+), comparison (1500+), how-to (1000+)
## 5. Content Distribution & Amplification
- Email newsletter integration: promoting each post to subscriber list
- Social media repurposing: turning one blog post into 10 social assets
- Pinterest strategy: creating pins for every blog post
- Link building: outreach strategy to earn backlinks to pillar pages
- Internal linking: connecting blog posts to product and collection pages
## 6. Conversion Architecture in Content
- Product recommendation placement within articles
- Email opt-in placement: inline, pop-up, and end-of-post CTAs
- Social proof integration: reviews and photos within relevant posts
- Bottom-of-funnel content: buying guides linked directly to product pages
## 7. Content Performance Measurement Plan
- Immediate actions (week 1): keyword research, first 4 blog posts outlined
- Short-term (month 1): first 4 posts published, Google Search Console setup
- Long-term (month 3+): 12+ posts live, organic traffic growing, revenue attribution tracking
Include content marketing benchmarks: average time to rank in top 10 for low-difficulty keywords (3-6 months), organic traffic value calculations, and content ROI examples from successful Shopify content programs."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Design and launch a brand ambassador program for Shopify stores to build a network of authentic advocates who drive sales and awareness. Triggers: ambassador...
---
name: shopify-ambassador-program
description: "Design and launch a brand ambassador program for Shopify stores to build a network of authentic advocates who drive sales and awareness. Triggers: ambassador program, brand ambassadors, brand advocates, ambassador marketing, customer ambassador"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-ambassador-program
---
# Shopify Brand Ambassador Program
A complete brand ambassador program design for Shopify merchants. This skill builds an ambassador recruitment, onboarding, activation, and retention system that turns passionate customers and content creators into a long-term network of authentic brand advocates driving word-of-mouth and sales.
## Usage
```
ambassador program design: <store niche or URL>
brand ambassador strategy: <product category>
launch ambassador program: <brand description>
build brand advocates: <store type>
```
## What You Get
1. **Ambassador Program Architecture** — Tiers, requirements, and program identity
2. **Recruitment & Application Process** — Finding and selecting the right ambassadors
3. **Onboarding & Training** — Setting ambassadors up for success
4. **Activation & Content Framework** — What ambassadors do and how to guide them
5. **Compensation & Incentives** — Sustainable reward structures that motivate
6. **Communication & Community** — Keeping ambassadors engaged long-term
7. **Performance Measurement** — Tracking ambassador-driven revenue and reach
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-ambassador-program — Brand ambassador program for Shopify stores
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: ambassador program design: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-ambassador-$(date +%s)"
PROMPT="You are a Shopify brand ambassador and community marketing expert specializing in ambassador program design, advocate activation, and word-of-mouth growth strategies for ecommerce brands. Build a complete ambassador program for: INPUT
Produce a complete brand ambassador program blueprint with these sections:
## 1. Ambassador Program Architecture
- Program name and identity: creative name aligned with brand
- Ambassador definition: who qualifies (customer vs creator vs both)
- Tier structure: Campus Ambassador, Brand Rep, Elite Ambassador
- Program scale: how many ambassadors to aim for in year 1
- Ambassador vs influencer vs affiliate: how this program differs
## 2. Recruitment & Application Strategy
- Ideal ambassador profile: characteristics, follower count range, content quality
- Recruitment channels: email list, organic social, UGC hunters, community
- Application form design: screening questions and portfolio requirements
- Selection criteria: alignment, authenticity, audience fit, communication skills
- How to run rolling recruitment vs batch cohorts
## 3. Ambassador Onboarding & Training
- Welcome package: physical kit + digital welcome guide
- Brand education: voice, messaging, products, dos and don'ts
- Content training: what types of content to create, examples of great ambassador posts
- Technical setup: affiliate link, discount code, brand asset library access
- First assignment: low-stakes task to get them started quickly
## 4. Activation, Content & Monthly Expectations
- Monthly content quota: minimum posts, stories, and engagement activities
- Content variety: lifestyle, product reviews, tutorials, unboxing
- Campaign activation: how to brief ambassadors on specific campaigns
- Content review process: approval workflow and usage rights
- Hashtag and tagging guidelines for tracking
## 5. Compensation & Incentive Design
- Base compensation: free product, commission (10-20%), exclusive discount
- Performance bonuses: top monthly ambassador recognition and bonus
- Milestone rewards: program longevity bonuses at 3, 6, 12 months
- Exclusive experiences: early access, brand events, founder calls
- Equity-like rewards: for highest performers — named collaboration products
## 6. Community, Communication & Retention
- Ambassador community platform: private Facebook Group, Discord, or Slack
- Monthly ambassador newsletter: brand updates, content ideas, recognition
- Quarterly all-hands call: brand updates, Q&A, top performer spotlights
- Re-engagement plan for inactive ambassadors: 90-day check-in
## 7. Performance Measurement & Scaling Plan
- Immediate actions (week 1): program design, application form, first 10 invitations
- Short-term (month 1): 10 active ambassadors, first content live, tracking setup
- Long-term (month 3+): 50+ ambassador network, measurable sales attribution, program refinement
Include ambassador program benchmarks: average revenue per ambassador per month, ambassador content engagement rates vs brand content, and CAC comparison for ambassador-acquired customers vs paid social."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Plan and execute a migration from Amazon to Shopify DTC or build a dual-channel strategy to reduce platform dependency. Triggers: amazon to shopify, migrate...
---
name: shopify-amazon-shopify
description: "Plan and execute a migration from Amazon to Shopify DTC or build a dual-channel strategy to reduce platform dependency. Triggers: amazon to shopify, migrate from amazon, amazon shopify dual channel, reduce amazon dependency, shopify from amazon"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-amazon-shopify
---
# Amazon to Shopify Migration & Dual-Channel Strategy
A complete strategy for Amazon sellers looking to migrate to or add Shopify as a direct-to-consumer channel. This skill covers brand building, customer data ownership, migration planning, traffic acquisition, and dual-channel management to reduce Amazon dependency while growing profitably on Shopify.
## Usage
```
Amazon to Shopify strategy: <product category>
migrate from Amazon to DTC: <brand description>
dual channel Amazon Shopify: <current situation>
reduce Amazon dependency: <store niche>
```
## What You Get
1. **Migration Assessment** — Full migration vs dual-channel vs Shopify-first analysis
2. **Brand Building for DTC** — How Amazon sellers build a brand for direct sales
3. **Traffic Acquisition Plan** — Replacing Amazon traffic with owned channels
4. **Email List Building from Amazon** — Legal ways to capture Amazon customer emails
5. **Shopify Store Foundation** — Must-have setup for ex-Amazon sellers
6. **Dual-Channel Management** — Running Amazon and Shopify simultaneously
7. **90-Day Migration Roadmap** — Phased transition with revenue protection
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-amazon-shopify — Amazon to Shopify migration and dual-channel strategy
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: Amazon to Shopify strategy: <product category>"
exit 1
fi
SESSION_ID="shopify-amz-migration-$(date +%s)"
PROMPT="You are a Shopify ecommerce expert specializing in Amazon-to-Shopify migrations, DTC brand building for marketplace sellers, and dual-channel commerce strategy. Build a complete Amazon-to-Shopify strategy for: INPUT
Produce a complete migration and dual-channel strategy report with these sections:
## 1. Strategic Assessment: Migration Options
- Full migration: moving all sales to Shopify — pros, cons, risk analysis
- Dual-channel: running Amazon and Shopify simultaneously — management complexity
- Shopify-first strategy: prioritizing DTC growth while maintaining Amazon
- Decision framework: revenue threshold, brand equity, and margin analysis
## 2. DTC Brand Building for Amazon Sellers
- Differentiating from generic Amazon listings: brand story, design, voice
- Packaging and unboxing experience design to delight DTC customers
- Product photography upgrade: lifestyle vs white background comparison
- Building brand identity elements: logo, colors, typography for Shopify
## 3. Traffic Acquisition Strategy
- Paid social (Meta, TikTok): cold traffic campaigns for brand awareness
- SEO and content: product-focused blog strategy for organic traffic
- Email marketing from day one: list building priority for new Shopify store
- Google Ads: Shopping campaigns for product discovery
## 4. Amazon Customer Email Capture (Legal Methods)
- Insert card strategy: driving Amazon buyers to Shopify with incentive
- Product registration flow: warranty or bonus content email capture
- Review request email sequences (compliant with Amazon TOS) with DTC offer
- Brand story inserts that build awareness of your direct store
## 5. Shopify Store Foundation for Ex-Amazon Sellers
- Store design priorities: trust signals that Amazon provides automatically
- Review collection: importing Amazon reviews and building new ones
- Checkout optimization: Amazon buyers expect seamless checkout
- Shipping expectations: Prime-like delivery communication
## 6. Dual-Channel Operations Management
- Inventory allocation: percentage split between Amazon and Shopify
- Pricing strategy: MAP enforcement and channel-specific pricing
- Fulfillment routing: FBA for Amazon, 3PL or in-house for Shopify
- Analytics: measuring each channel's contribution and true margin
## 7. 90-Day Migration Roadmap
- Immediate actions (week 1): Shopify store setup, domain, first products live
- Short-term (month 1): first paid traffic, email list building, DTC order experience
- Long-term (month 3+): DTC at 20%+ of revenue, loyalty program, brand asset building
Include specific metrics: Amazon vs Shopify margin comparison (Amazon takes 15%+ fees), LTV advantage of owning customer data, and typical 6-month revenue ramp for Amazon sellers launching Shopify."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Generate SEO-optimized, high-converting AI product descriptions for any Shopify store niche with proven copy frameworks. Triggers: product description genera...
---
name: shopify-ai-product-description
description: "Generate SEO-optimized, high-converting AI product descriptions for any Shopify store niche with proven copy frameworks. Triggers: product description generator, ai product copy, write product descriptions, shopify product copy, product listing copy"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-ai-product-description
---
# Shopify AI Product Description Generator
An AI-powered product description framework for Shopify merchants. This skill generates compelling, SEO-optimized product descriptions using proven copywriting formulas, keyword integration strategies, and conversion-focused language tailored to your specific product category and target audience.
## Usage
```
write product descriptions: <product name and details>
AI product copy for: <product category>
generate product listing: <product features>
optimize product descriptions: <store niche>
```
## What You Get
1. **Description Framework Selection** — AIDA, PAS, FAB, and hybrid formulas
2. **SEO Keyword Integration** — Primary, secondary, and long-tail keyword placement
3. **Bullet Point Optimization** — Feature-to-benefit transformation framework
4. **Tone & Brand Voice Guide** — Matching copy style to your brand persona
5. **Mobile-Optimized Format** — Scannable structure for small screens
6. **A/B Testing Variants** — 2 description versions to test per product
7. **Scale & Workflow Guide** — How to efficiently describe large catalogs with AI
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-ai-product-description — AI product description generator for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: write product descriptions: <product name and details>"
exit 1
fi
SESSION_ID="shopify-ai-desc-$(date +%s)"
PROMPT="You are a Shopify product copywriting expert specializing in AI-assisted product description generation, SEO optimization, and conversion-focused copy for ecommerce stores. Generate a complete product description framework for: INPUT
Produce a complete product description strategy and samples with these sections:
## 1. Copy Framework Selection
- AIDA formula (Attention, Interest, Desire, Action) — when to use it
- PAS formula (Problem, Agitation, Solution) — best for problem-solving products
- FAB formula (Features, Advantages, Benefits) — best for technical products
- Hybrid formula for this specific product type with example
## 2. SEO Keyword Integration Strategy
- Primary keyword placement: title, first sentence, H2 headers
- Secondary keywords: bullet points and body copy
- Long-tail keyword opportunities for this product category
- Schema markup recommendation for product rich snippets
## 3. 3 Complete Product Description Samples
- Version A: AIDA-style narrative description (150-200 words)
- Version B: Benefit-led bullet point format (10 bullets)
- Version C: Short punchy version for mobile/ads (50-75 words)
- All three versions written specifically for the input product/niche
## 4. Bullet Point Optimization Framework
- Feature-to-benefit transformation formula: [Feature] so you can [Benefit]
- Sensory language for physical products: how it feels, sounds, smells
- Specificity rule: numbers and dimensions over vague claims
- 10 bullet point templates for common product attributes in this niche
## 5. Brand Voice & Tone Guidelines
- Casual vs professional tone selection by customer avatar
- Power words for this niche: 30 conversion-driving adjectives and verbs
- Words to avoid: clichés, overused phrases, and weak filler words
- Consistency checklist for maintaining voice across all products
## 6. AI Prompt Templates for Catalog Scale
- Master prompt template for generating descriptions at scale
- Input variables: product name, key features, target customer, brand voice
- Quality control checklist after AI generation
- Edit time estimate: 5-10 minutes per description for human polish
## 7. Implementation & Testing Plan
- Immediate actions (week 1): audit current descriptions, prioritize top products
- Short-term (month 1): rewrite top 50 products, track conversion impact
- Long-term (month 3+): full catalog updated, A/B test winners identified
Include conversion rate lift data from optimized product descriptions (typically 10-30% improvement) and SEO traffic impact from keyword-optimized copy."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"
Set up AI-powered customer service for Shopify stores to automate support, reduce tickets, and improve customer satisfaction. Triggers: ai customer service,...
---
name: shopify-ai-customer-service
description: "Set up AI-powered customer service for Shopify stores to automate support, reduce tickets, and improve customer satisfaction. Triggers: ai customer service, chatbot setup, automate support, shopify customer service ai, ai helpdesk"
allowed-tools: Bash
metadata:
openclaw:
homepage: https://github.com/mguozhen/shopify-ai-customer-service
---
# Shopify AI Customer Service Setup
A complete AI customer service implementation guide for Shopify merchants. This skill covers chatbot selection, knowledge base building, automation flow design, and human escalation paths to deliver 24/7 customer support while reducing support costs and improving response times.
## Usage
```
AI customer service setup: <store niche or URL>
chatbot for Shopify: <store type>
automate customer support: <product category>
setup AI helpdesk: <brand description>
```
## What You Get
1. **Platform Selection Guide** — AI customer service tools compared for Shopify
2. **Knowledge Base Architecture** — FAQ structure and content for AI training
3. **Automation Flow Design** — Order tracking, returns, product questions, shipping
4. **Human Escalation Protocol** — When and how to transfer to human agents
5. **Tone & Persona Configuration** — Training the AI to sound like your brand
6. **Performance Metrics** — CSAT, resolution rate, and deflection rate targets
7. **Continuous Improvement Loop** — How to train and improve the AI over time
FILE:analyze.sh
#!/usr/bin/env bash
# shopify-ai-customer-service — AI customer service setup for Shopify
set -euo pipefail
INPUT="-"
if [ -z "$INPUT" ]; then
echo "Usage: AI customer service setup: <store niche or URL>"
exit 1
fi
SESSION_ID="shopify-ai-cs-$(date +%s)"
PROMPT="You are a Shopify AI customer service expert specializing in chatbot implementation, helpdesk automation, and AI-assisted support workflows for ecommerce stores. Build a complete AI customer service strategy for: INPUT
Produce a complete AI customer service setup guide with these sections:
## 1. AI Customer Service Platform Comparison
- Gorgias: best for Shopify integration, pricing, pros and cons
- Tidio: best for small stores, chatbot features, pricing
- Reamaze: multi-channel support, AI features
- Shopify Inbox: native solution, limitations and strengths
- Zendesk AI: enterprise option, when it makes sense
- Recommendation for this store type with justification
## 2. Knowledge Base Architecture
- Top 20 FAQs for this niche (specific questions and answers)
- Order tracking and status inquiry automation
- Return and refund policy communication templates
- Shipping FAQ: delivery times, international shipping, lost packages
- Product-specific FAQ templates for common questions in this category
## 3. Automation Flow Design
- Flow 1: Order tracking — customer inputs order number → status displayed
- Flow 2: Return initiation — step-by-step return process via chat
- Flow 3: Product recommendations — quiz-style discovery flow
- Flow 4: Discount and promotions inquiry — current offers and codes
- Flow 5: Out-of-stock notification — waitlist sign-up automation
## 4. Human Escalation Protocol
- Escalation triggers: frustrated tone, complex complaints, high-value orders
- Sentiment analysis configuration to detect angry customers
- Escalation message templates: smooth transition to human agent
- After-hours handling: set expectations, promise response time
## 5. Brand Voice & AI Persona
- Chatbot name and personality guidelines
- Tone calibration: friendly vs professional vs playful
- Prohibited phrases and responses (what the bot should never say)
- Response length guidelines by query type
## 6. Performance Metrics & Targets
- Deflection rate target: 40-60% of tickets resolved by AI
- CSAT score benchmarks for AI vs human support
- First response time targets: under 30 seconds for AI, under 2 hours for human
- Escalation rate monitoring and thresholds for concern
## 7. Implementation & Training Plan
- Immediate actions (week 1): platform setup, knowledge base upload, first flows
- Short-term (month 1): live chatbot, team training, performance review
- Long-term (month 3+): AI learning from resolved tickets, expanding automation coverage
Include ROI calculations: cost savings from AI deflection (agent time saved), customer satisfaction impact, and support cost benchmarks for Shopify stores at different sizes."
openclaw agent --local --message "PROMPT" --session "SESSION_ID"