Skill: xianji520/outreach-pipeline (version 1.0.0)

--- name: outreach-pipeline description: 邮件外联自动化（批量邮件外联、序列发送、模板合并、节流与送达合规）。当用户要：1) 导入收件人列表（CSV/表格）并批量发送个性化外联邮件；2) 运行简单的邮件序列与跟进；3) 通过 SMTP 或常见 ESP（SendGrid/Mailgun）发送；4) 使用 Gmail/Outlook（OAuth/App Password）发送；5) 需要反垃圾合规、节流与退订处理时，使用本 Skill。 --- # 邮件外联自动化 Skill 本 Skill 提供一套可复用的邮件外联流程与脚本： - 输入联系人（CSV） - 使用可读模板（支持 {{变量}} 合并） - 选择发送提供方（SMTP / SendGrid / Mailgun；Gmail/Outlook 支持见参考） - 节流（每分钟/每小时限速）、重试与结果日志 - 合规与送达最佳实践（退订语/域名与身份配置） 适用触发语示例（供模型触发参考） - “邮件外联自动化/发外联邮件/批量发邮件” - “把这份 CSV 的联系人发一封模板邮件” - “用 SMTP/SendGrid/Mailgun/Gmail 发外联，注意限流和退订” - “生成第一封冷启动外联模板并发送” 快速开始 1) 准备数据与模板 - CSV 格式参考：see references/csv-format.md（至少包含 email 列，建议 name/company 等） - 邮件模板：支持 {{name}}、{{company}} 等占位符（示例见 assets/templates/first-touch.txt） 2) 选择发送方式 - SMTP：配置主机、端口、用户名/密码（或 App Password），支持 STARTTLS - ESP（SendGrid/Mailgun）：准备 API Key（见 references/templates.md 中的示例与说明） - Gmail/Outlook：推荐使用 OAuth 或 App Password（见 references/tips.md） 3) 运行脚本（使用 uv 调用 Python） - 示例（SMTP）： uv run python skills/outreach-pipeline/scripts/send.py \ --csv path/to/contacts.csv \ --template skills/outreach-pipeline/assets/templates/first-touch.txt \ --subject "你好 {{name}}，关于{{company}}的合作想法" \ --from-name "你的姓名" \ --from-email "[REDACTED_SECRET]" \ --provider smtp \ --smtp-host smtp.your-domain.com \ --smtp-port 587 \ --smtp-user [REDACTED_SECRET] \ --smtp-pass "<PASSWORD>" \ --rate-limit 15 \ --out-log out/send_results.csv - 示例（SendGrid）： 设置环境变量 SENDGRID_API_KEY 后： uv run python skills/outreach-pipeline/scripts/send.py \ --csv path/to/contacts.csv \ --template skills/outreach-pipeline/assets/templates/first-touch.txt \ --subject "Hi {{name}}" \ --from-name "你的姓名" \ --from-email "[REDACTED_SECRET]" \ --provider sendgrid \ --rate-limit 25 4) 查看结果 - 发送过程与每个收件人的状态写入 --out-log 指定的 CSV（默认 send_results.csv） 输入约定 - CSV 标题至少包含：email；可选列将作为模板变量（如 name/company/title 等） - 模板文件为纯文本或轻度 Markdown，变量格式 {{var}} - 主题也支持 {{变量}} 合并 核心动作（给调用者的流程指引） - 校验输入：CSV 列、模板变量、主题变量是否匹配（缺失变量将置为空字符串） - 发送策略： - 节流：--rate-limit（每分钟最多 N 封），避免触发提供方或 ISP 限制 - 批次控制：--max-per-run 限制本次运行数量；先小批测试（如 10 封）再放大 - 重试：轻度网络错误可在脚本层面第2次尝试；硬错误记录日志并跳过 - 退订与合规： - 模板底部附上退订说明（见 references/risks.md） - 优先使用独立域名、正确的 From/Reply-To、SPF/DKIM/DMARC 合规（见 references/tips.md） 提供方说明（摘要） - SMTP：通用，适合小批量与自有域；需配置 SPF/DKIM 提升送达 - SendGrid/Mailgun：适合批量与稳定送达；使用 API Key；注意速率与信誉 - Gmail/Outlook：建议 App Password 或 OAuth；免费额度有限，节流严格 目录结构 - scripts/send.py — 主脚本（CSV 导入、模板合并、SMTP/ESP 发送、节流、日志） - references/ - csv-format.md — CSV 列格式说明与示例 - tips.md — 送达与账号配置（SPF/DKIM/DMARC、OAuth/App Password） - risks.md — 合规、反垃圾与退订建议 - templates.md — 模板与变量示例、常见序列骨架 - assets/templates/first-touch.txt — 冷启动外联模板示例 注意与限制 - 本 Skill 不提供“邮箱列表抓取/购买”；仅面向有来源与同意基础的 B2B 合法外联 - 法规合规：遵守当地法律（例如 CAN-SPAM/GDPR/各平台政策）；不可群发骚扰邮件 - 初次运行请用自己的邮箱进行自测（3–5 封）确认显示与追踪无误 示例交互 - 用户：“把这份 contacts.csv 的联系人用 SMTP 发一封冷启动外联，速率 10/分钟，主题和正文按模板。” - 代理：校验 CSV→渲染 3 封样例→提示确认→开始发送→输出日志路径与统计 扩展 - 如需序列化跟进（Day 1/3/7），可用 cron 或外部调度每次调用脚本并传入不同模板与过滤条件。

path: assets/templates/first-touch.txt
content: Hi {{name}},

I’m reaching out to introduce {{company}} and to explore potential collaborations. We offer tailored solutions that could help {{company}} achieve its goals. Would you be open to a quick call to discuss this further?

Best regards,
{{from_name}}


path: examples/contacts.csv
content: email,name,company,title
[REDACTED_SECRET],Alice Zhang,Acme Corp,CTO
[REDACTED_SECRET],Bob Li,Contoso Ltd,Head of Growth
[REDACTED_SECRET],Carol Wang,BlueSky Inc,Product Manager
[REDACTED_SECRET],David Chen,NextGen Solutions,Founder
[REDACTED_SECRET],Eve Liu,MarketReach,Marketing Director


path: references/csv-format.md
content: # CSV 格式说明

最小列：
- email（必填）

推荐列：
- name
- company
- title
- custom1/custom2（自定义字段）

示例：

email,name,company,title
[REDACTED_SECRET],Alice,Acme,CTO
[REDACTED_SECRET],Bob,Contoso,Head of Growth

注意事项：
- UTF-8 编码，逗号分隔（CSV）
- 首行是标题
- 无邮箱的行将被跳过并记录在日志中


path: references/risks.md
content: # 合规与反垃圾提示

- 法律：遵守适用法律法规（如 CAN-SPAM、GDPR、各国电子邮件法规）。尊重退订请求。
- 数据来源：不得购买或抓取未经同意的邮箱清单；优先使用客户、业务线索、或公开名片交换的 B2B 邮箱。
- 内容：避免欺骗性标题与内容；提供真实公司信息与联系地址。
- 跟踪：如使用跟踪像素或链接追踪，应在隐私声明中说明并提供选择退出方式。


path: references/tips.md
content: # 送达与账号配置提示

- 域名与身份
  - 使用独立业务域名；配置 SPF、DKIM、DMARC 提升送达
  - From 和 Reply-To 一致性，签名中包含公司/地址（合规）
- 速率与预热
  - 新域名/新 IP 从低速开始（如 5–10 封/分钟，逐步提升）
  - 控制每日总量（50–200 封/邮箱起步，逐渐增加）
- Gmail/Outlook
  - 推荐 App Password 或 OAuth；避免明文密码存文件
  - 注意免费帐号的发送阈值与限制
- 退订与合规
  - 在模板底部提供退订说明/链接（即使是 B2B 也建议提供）
  - 不发送给未经过任何同意或缺乏正当利益的个人邮箱


path: scripts/send.py
content: import csv
import os
import sys
import time
import argparse
from email.mime.text import MIMEText
from email.utils import formataddr
import smtplib

try:
    import requests
except ImportError:
    requests = None

TEMPLATE_VARS = {}

def load_csv(path):
    rows = []
    with open(path, newline='', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        for r in reader:
            if not r.get('email'):
                continue
            rows.append(r)
    return rows


def render(text, ctx):
    out = text
    for k, v in ctx.items():
        out = out.replace('{{'+k+'}}', str(v) if v is not None else '')
    # remove unreplaced vars
    # crude cleanup for patterns like {{var}}
    import re
    out = re.sub(r"\{\{[^}]+\}\}", '', out)
    return out


def read_file(path):
    with open(path, 'r', encoding='utf-8') as f:
        return f.read()


def send_smtp(args, to_email, subject, body):
    msg = MIMEText(body, 'plain', 'utf-8')
    msg['From'] = formataddr((args.from_name, args.from_email))
    msg['To'] = to_email
    msg['Subject'] = subject

    with smtplib.SMTP(args.smtp_host, args.smtp_port) as server:
        server.starttls()
        server.login(args.smtp_user, args.smtp_pass)
        server.sendmail(args.from_email, [to_email], msg.as_string())


def send_sendgrid(args, to_email, subject, body):
    if requests is None:
        raise RuntimeError('requests not installed; run: uv pip install requests')
    [REDACTED_SECRET]'SENDGRID_API_KEY')
    if not api_key:
        raise RuntimeError('SENDGRID_API_KEY not set')
    payload = {
        "personalizations": [{"to": [{"email": to_email}]}],
        "from": {"email": args.from_email, "name": args.from_name},
        "subject": subject,
        "content": [{"type": "text/plain", "value": body}]
    }
    r = requests.post('https://api.sendgrid.com/v3/mail/send', json=payload, headers={'Authorization': f'Bearer {api_key}'})
    if r.status_code >= 300:
        raise RuntimeError(f'SendGrid error: {r.status_code} {r.text}')


def send_mailgun(args, to_email, subject, body):
    if requests is None:
        raise RuntimeError('requests not installed; run: uv pip install requests')
    [REDACTED_SECRET]'MAILGUN_API_KEY')
    domain = os.environ.get('MAILGUN_DOMAIN')
    if not api_key or not domain:
        raise RuntimeError('MAILGUN_API_KEY/MAILGUN_DOMAIN not set')
    r = requests.post(f'https://api.mailgun.net/v3/{domain}/messages', auth=('api', api_key), data={
        'from': f"{args.from_name} <{args.from_email}>",
        'to': [to_email],
        'subject': subject,
        'text': body
    })
    if r.status_code >= 300:
        raise RuntimeError(f'Mailgun error: {r.status_code} {r.text}')


def main():
    p = argparse.ArgumentParser()
    p.add_argument('--csv', required=True)
    p.add_argument('--template', required=True)
    p.add_argument('--subject', required=True)
    p.add_argument('--from-name', dest='from_name', required=True)
    p.add_argument('--from-email', dest='from_email', required=True)
    p.add_argument('--provider', choices=['smtp','sendgrid','mailgun'], required=True)
    p.add_argument('--smtp-host')
    p.add_argument('--smtp-port', type=int, default=587)
    p.add_argument('--smtp-user')
    p.add_argument('--smtp-pass')
    p.add_argument('--rate-limit', type=int, default=10, help='per minute')
    p.add_argument('--max-per-run', type=int, default=0, help='0 = no limit')
    p.add_argument('--out-log', default='send_results.csv')
    args = p.parse_args()

    rows = load_csv(args.csv)
    template = read_file(args.template)
    sent = 0

    with open(args.out_log, 'w', newline='', encoding='utf-8') as outf:
        writer = csv.writer(outf)
        writer.writerow(['email','status','error'])
        start = time.time()
        interval = 60 / max(1, args.rate_limit)

        for r in rows:
            ctx = dict(r)
            ctx.setdefault('from_name', args.from_name)
            ctx.setdefault('from_email', args.from_email)
            subj = render(args.subject, ctx)
            body = render(template, ctx)
            to_email = r['email']
            try:
                if args.provider == 'smtp':
                    if not all([args.smtp_host, args.smtp_user, args.smtp_pass]):
                        raise RuntimeError('Missing SMTP config')
                    send_smtp(args, to_email, subj, body)
                elif args.provider == 'sendgrid':
                    send_sendgrid(args, to_email, subj, body)
                elif args.provider == 'mailgun':
                    send_mailgun(args, to_email, subj, body)
                writer.writerow([to_email,'sent',''])
                sent += 1
            except Exception as e:
                writer.writerow([to_email,'error',str(e)])
            # rate limiting
            time.sleep(interval)
            if args.max_per_run and sent >= args.max_per_run:
                break

    print(f'Done. Sent: {sent}. Log: {args.out_log}')

if __name__ == '__main__':
    main()


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