Install
openclaw skills install mailgo-coldmail-marketingComplete cold email campaign suite for Mailgo — verify recipients, claim free mailbox, generate & optimize content, create campaigns, manage lifecycle, and view reports. All-in-one skill that handles the full outreach pipeline end-to-end. Use when a user wants to send cold emails, launch outreach campaigns, or manage existing campaigns.
openclaw skills install mailgo-coldmail-marketingOne skill, complete cold email pipeline. From recipient verification to campaign reporting — everything runs through bundled scripts with zero third-party dependencies.
Required: MAILGO_API_KEY environment variable must be set before any other step.
# Confirm the variable is set (shows first 5 characters only)
echo "${MAILGO_API_KEY:0:5}"
If output is non-empty → proceed to Step 1. If empty → follow the setup flow below.
Sub-step 0.1 — Register or Log In
New users:
Existing users:
Sub-step 0.2 — Create Personal Token
Once logged in:
SECURITY: Never paste the token into chat. It must only be set as a local environment variable.
Sub-step 0.3 — Set Environment Variable
# macOS / Linux (permanent)
echo 'export MAILGO_API_KEY="YOUR_TOKEN"' >> ~/.zshrc && source ~/.zshrc
# Windows PowerShell (permanent)
[System.Environment]::SetEnvironmentVariable('MAILGO_API_KEY', 'YOUR_TOKEN', 'User')
Replace YOUR_TOKEN with the copied value.
Sub-step 0.4 — Verify
# macOS / Linux — shows first 5 characters to confirm without exposing the token
echo "${MAILGO_API_KEY:0:5}"
# Windows PowerShell
$k = $env:MAILGO_API_KEY; if ($k) { $k.Substring(0, [Math]::Min(5, $k.Length)) } else { "" }
If output is non-empty, you're ready. If empty, run source ~/.zshrc or open a new terminal.
| Item | Value |
|---|---|
| Token source | app.mailgo.ai → Avatar → Personal Tokens |
| Env variable name | MAILGO_API_KEY |
| API header | X-API-Key: {MAILGO_API_KEY} |
| Token scope | Your Mailgo account (mailboxes, campaigns, reports) |
| Revoke token | app.mailgo.ai → Avatar → Personal Tokens → Delete |
| Issue | Fix |
|---|---|
Empty after source ~/.zshrc | Open a new terminal window |
| 401 Unauthorized | Token may be invalid or deleted — create a new one in Personal Tokens |
| 403 Forbidden | Ensure User-Agent header is set (scripts handle this automatically) |
Collect all required information in a single interaction before starting the pipeline.
Ask all questions at once. Do not ask in separate rounds. The user fills in what they know; mark anything skipped as "not provided".
When to run: Only when the user intends to create a new campaign. Skip for report/manage/replies tasks.
Ask all of these together in one message:
| # | Question | Field | Required? |
|---|---|---|---|
| 1 | What is your company name? | sender_company | Yes |
| 2 | What is your name (for the signature)? | sender_name | Yes |
| 3 | What does your company offer (products / services)? | sender_offerings | Yes |
| 4 | What is your job title? | sender_title | Recommended |
| 5 | What is your company website? | sender_website | Recommended |
| 6 | Any notable clients, certifications, or metrics to mention? | sender_proof | Optional |
Ask all of these together in the same message as Group A:
| # | Question | Field |
|---|---|---|
| 7 | Who are you sending to? Paste emails, or provide a file path (CSV / XLSX / TXT). | recipients |
| 8 | What is the goal of this campaign? (one sentence) | campaign_purpose |
| 9 | Do your recipients have names available? (column name or yes/no) | has_name |
| 10 | Do you have recipients' company names? (column name or yes/no) | has_company |
| 11 | Do you have recipients' job titles? (column name or yes/no) | has_title |
| 12 | Do you have recipients' company websites / domains? (column name or yes/no) | has_domain |
If the user provides a file, auto-detect columns after reading the file headers — then mark fields 9–12 automatically without asking again.
⚠ Header Warning: Column detection relies entirely on header names. Files without headers (e.g. a bare two-column XLSX with email + company but no row of column names) will only have
col_0,col_1, … as synthetic headers — none of which match any known field name. As a result, only the email column can be identified by content scanning; all other columns (company, name, title, domain) will be silently discarded. Whenever a user provides a headerless file and you can see that extra columns exist (e.g. "two-column spreadsheet"), proactively warn them: "Your file appears to have no header row. Only email addresses were imported — company names and other fields were not recognized. Add a header row (e.g.company,name) and re-upload to include that data."
Immediately after receiving the recipient list, check whether name data is available.
If has_name is false (no name column detected, no names provided inline):
@):
., -, _ → capitalize each segment → join with spacealice.smith@co.com → "Alice Smith", john_doe@co.com → "John Doe", jdoe@co.com → "Jdoe"session_context.recipientsrecipient_fields.name = true with source noted as "auto-derived from email prefix"This ensures #{Name} is always available as long as there are valid email addresses.
| Situation | Action |
|---|---|
| User skips a required field (1, 2, 3, 7, 8) | Ask once more specifically for that field only |
| User skips a recommended field (4, 5) | Mark as not provided, do not ask again |
| User skips an optional field (6) | Mark as not provided, move on |
| User says "I'll send to a file I haven't uploaded yet" | Ask for file path, then detect columns |
| User provides inline recipients with no extra data | Auto-derive names from email prefixes (see above); mark fields 10–12 as not provided |
| User provides a file without a header row and extra columns are present | Warn: only emails were imported; other columns were discarded. Ask user to add a header row (email, company, name, etc.) and re-upload |
Store all answers as session context. Every subsequent step uses this context directly — never ask for the same information again.
session_context = {
sender_company: "...", # or null
sender_name: "...", # or null
sender_title: "...", # or null
sender_website: "...", # or null
sender_offerings: "...", # or null
sender_proof: "...", # or null
recipients: [ # each entry always has a name (user-provided or auto-derived)
{ email: "...", name: "...", company: "...", title: "...", domain: "..." },
...
],
campaign_purpose: "...",
recipient_fields: {
name: true, # ALWAYS true — either user-provided or auto-derived from email prefix
company: true/false,
title: true/false,
domain: true/false,
}
}
Always verify before sending to >10 recipients.
source ~/.zshrc
# Option A: inline emails
python3 scripts/verify_emails.py alice@example.com bob@gmail.com
# Option B: from file (TXT/CSV/JSON)
python3 scripts/verify_emails.py --file leads.csv
# Option C: override column detection
python3 scripts/verify_emails.py --file leads.csv --email-column "Email Address"
Script flags:
| Flag | Default | Description |
|---|---|---|
emails | — | Space-separated email addresses |
--file | — | Read from TXT/CSV/JSON file |
--email-column | auto-detect | Override email column name |
--timeout | 180 | Poll timeout in seconds |
--interval | 5 | Poll interval in seconds |
Output: JSON on stdout with categorized results:
{
"total": 5,
"valid": [{"email": "...", "status": "VALID"}],
"invalid": [...],
"domain_error": [...],
"unknown": [...],
"unchecked": [...]
}
| Status | Meaning | Recommendation |
|---|---|---|
VALID | Confirmed deliverable | Send |
UNKNOWN | Catch-all or greylisting | Send with caution |
DOMAIN_ERROR | No MX records | Do not send |
INVALID | Malformed or SMTP-rejected | Do not send |
UNCHECKED | Still processing | Wait or re-poll |
Filtering guidance: Conservative = VALID only. Aggressive = VALID + UNKNOWN.
UNCHECKED entries remain (unless timeout)POST /sirius/api/biz/email/verification — body: {"emails": [...]}GET /sirius/api/biz/email/verification/task/{taskId}Run before creating any campaign. Each user gets one free mailbox (60 days, 90+ sender score).
source ~/.zshrc
# Claim (idempotent — safe to run multiple times)
python3 scripts/claim_free_mailbox.py
# JSON output for scripting
python3 scripts/claim_free_mailbox.py --json
What you get:
Note: The free mailbox uses a randomly assigned domain (e.g.
you@randomdomain.io) which may not match your company domain. If brand consistency matters, consider purchasing a dedicated domain at https://app.mailgo.ai.
After claiming, use the returned email as --sender in Step 4:
SENDER=$(python3 scripts/claim_free_mailbox.py)
python3 scripts/run_campaign.py --sender "$SENDER" ...
POST /api/biz/benefits/assign-prewarmX-API-Key: {key}{"code": 0, "data": "email@domain.com"}data: null = pool empty, contact supportThis is a knowledge-and-rewrite step. No API calls. No scripts.
Apply the 6-step optimization pipeline to user content, or generate from scratch.
All sender and recipient information was collected in Step 0.5. Do NOT ask again.
Read directly from session_context:
| Context field | Use in email |
|---|---|
sender_name | Signature name |
sender_title | Signature title (omit if null) |
sender_company | Signature company + body references |
sender_website | Signature website (omit if null) |
sender_offerings | Value bridge paragraph |
sender_proof | Proof point sentence (omit if null) |
recipient_fields.name | Always true — use #{Name} unconditionally (user-provided or auto-derived from email prefix in Step 0.5) |
recipient_fields.company | Use #{Company Name} only if true |
recipient_fields.title | Use #{Title} only if true |
recipient_fields.domain | Use #{Domain} only if true |
If
session_contextis missing (e.g. user jumped directly to Step 3), collect the missing fields inline — but only the ones not yet known.
Mailgo uses #{...} placeholders that are resolved server-side at send time:
| Placeholder | Description | Resolved from |
|---|---|---|
#{Name} | Recipient's name | contactName in leads data |
#{Company Name} | Company name | companyName in leads data |
#{Domain} | Company website | domain in leads data |
#{Title} | Job title | title in leads data |
#{Email} | Email address | contactEmail in leads data |
CRITICAL — Conditional Placeholder Rule:
#{...} placeholder if the corresponding data is confirmed available (either from the file columns or user-provided inline data).#{...} placeholder. The placeholder will resolve to empty string at send time, creating ugly output like ,您好 or 关于 的合作.#{Name} is ALWAYS available. Every recipient has a name — either provided by the user or auto-derived from the email prefix in Step 0.5. Never fall back to "您好" when you have email addresses.| Placeholder | If data available | If data NOT available |
|---|---|---|
#{Name} | #{Name},您好 | (never happens — always use #{Name}) |
#{Company Name} | 关于 #{Company Name} 的合作 | 关于贵公司的合作 / About a potential partnership |
#{Domain} | 我浏览了 #{Domain} | Omit this sentence entirely |
#{Title} | 作为 #{Title} | Omit or use generic phrasing |
Log placeholder decisions in the change summary:
Placeholders used: #{Name} (data available), #{Email} (always available)
Placeholders skipped: #{Company Name} (no data), #{Title} (no data), #{Domain} (no data)
Step 3.1 — Normalize
Step 3.2 — Spam Trigger Scan
resources/spam-triggers.mdKey replacements (most common):
| Trigger | Replacement |
|---|---|
free / free trial | included / trial period |
Click here | Learn more / See details |
Act now | When you're ready |
Limited time | While available |
Guaranteed | Proven |
Dear friend | Hi #{Name} |
| ALL CAPS words | Sentence case |
!!! / ??? | Single punctuation |
Full replacement table: resources/spam-triggers.md
Step 3.3 — HTML Cleanup
<script>, <style>, <meta>, <link>, <iframe>, <embed>, <object>onclick, onload, etc.)<style> blocks → inline styles, then remove block!important, base64 images<img> have alt attributesStep 3.4 — Structure Check
#{...} in the content against session_context.recipient_fields:
#{Name} and #{Email} → always safe, never touch#{Company Name} → if recipient_fields.company == false: replace with generic text (e.g. 贵公司 / your company); log the replacement#{Domain} → if recipient_fields.domain == false: remove the sentence containing it entirely; log the removal#{Title} → if recipient_fields.title == false: replace with generic phrasing or omit; log the replacementStep 3.5 — Link Audit
javascript: and data: URLsStep 3.6 — Final Polish
/tmp/email_optimized_<timestamp>.htmlGenerate 4 subject line variants for A/B testing. Adapt based on available data:
If #{Company Name} data IS available:
| # | Strategy | Example |
|---|---|---|
| 1 | Value proposition | How #{Company Name} can cut onboarding time by 60% |
| 2 | Industry insight | A shift I'm seeing in #{Company Name}'s space |
| 3 | Case study / metrics | How [similar company] grew pipeline 3x in 90 days |
| 4 | Curiosity / question | Quick question about #{Company Name}'s [process] |
If #{Company Name} data is NOT available:
| # | Strategy | Example |
|---|---|---|
| 1 | Value proposition | Cutting onboarding time by 60% — here's how |
| 2 | Industry insight | A shift I'm seeing in the [industry] space |
| 3 | Case study / metrics | How [similar company] grew pipeline 3x in 90 days |
| 4 | Curiosity / question | A quick question about your [process] |
Subject rules: 40-50 chars, always personalize with #{Name} (always available); use #{Company Name} only if data available. No ALL CAPS, no spam triggers, no fake Re:/Fwd:.
Body structure (adapt based on available fields):
1. Personal opener (1 sentence)
- ALWAYS use "#{Name},您好:" — #{Name} is always available (user-provided or auto-derived)
- WITH #{Company Name}: reference it in opener
- WITHOUT #{Company Name}: use generic "贵公司" / "your company" or omit
2. Value bridge (1-2 sentences) — connect their situation to your offering
3. Proof point (1 sentence) — specific metric or case study (use sender_proof if available)
4. Soft CTA (1 sentence) — question, not demand
5. Signature — use actual sender_name + sender_title + sender_company (NEVER use placeholders like [Name])
6. Soft opt-out
Body rules: under 150 words, conversational tone, one CTA. All sender info in signature must be real data, not placeholders.
Select industry template from resources/industry-templates.md based on user's offerings.
Use for plain-text wrapping or building from scratch:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0; padding:20px; font-family:Arial, sans-serif; font-size:14px; line-height:1.6; color:#333333;">
<div style="max-width:600px; margin:0 auto;">
<!-- EMAIL CONTENT HERE -->
</div>
</body>
</html>
Always add after signature if missing:
P.S. If this isn't relevant, just reply and let me know — I won't reach out again.
Email Optimization Complete
---
Industry template: SaaS / Software (matched from "AI deployment platform")
Sender info: Zhang Wei, Sales Director, TirePro Technologies (www.tirepro.com)
Recipient data available: name (auto-derived from email prefix), company (no), title (no), domain (no)
Placeholders used: #{Name} (auto-derived from email prefix — e.g. alice.smith@co.com → "Alice Smith")
Placeholders skipped: #{Company Name}, #{Title}, #{Domain} (no data — generic wording used)
Spam triggers replaced: 3
- "free trial" → "trial period"
- "Click here" → "Learn more"
- "Act now" → "When you're ready"
HTML issues fixed: 1
- Removed <script> tag
Structure: 118 words (OK)
Links: 2 (OK)
Opt-out: Added
Subjects generated: 4 variants
Output: /tmp/email_optimized_1711468800.html
→ Use with: --body-file /tmp/email_optimized_1711468800.html
| Level | Issues |
|---|---|
| Critical (must fix) | <script> tags, shortener URLs, base64 images, javascript: URLs, "This is not spam", #{Company Name}/#{Domain}/#{Title} placeholders with no corresponding recipient data |
| Warning (recommend) | Spam trigger words, image-heavy, >3 links, missing opt-out |
| Info | Word count >150, multi-punctuation, subject line suggestions |
#{...} placeholder unless the corresponding recipient_fields value is true. Use the generic alternatives from the Placeholder Rules table instead.#{...} placeholder with NO data available, MUST replace it with the generic alternative (Step 3.4 Placeholder Data Validation). Do not silently preserve it — an unresolved placeholder will appear as a blank gap to the recipient.#{Name} and #{Email} are always safe — never remove or replace them.[bracketed placeholders] like [Name], [Company], [Title] in final email output. These must be replaced with actual sender data or omitted. They are template authoring aids, not send-time variables./tmp/email_optimized_<timestamp>.html and use via --body-file.source ~/.zshrc
# Option A: inline recipients
python3 scripts/run_campaign.py \
--sender "claimed@mailbox.com" \
--subject "Quick question about #{Company Name}" \
--body-file /tmp/email_optimized_1711468800.html \
--recipients "alice@example.com,bob@test.com" \
--campaign-name "SaaS Outreach Mar 2026" \
--daily-limit 50
# Option B: recipients from file (CSV/XLSX/TXT/JSON)
python3 scripts/run_campaign.py \
--sender "claimed@mailbox.com" \
--subject "Quick question about #{Company Name}" \
--body-file /tmp/email_optimized_1711468800.html \
--recipients-file leads.xlsx \
--campaign-name "SaaS Outreach Mar 2026"
Script flags:
| Flag | Default | Description |
|---|---|---|
--sender | required | Sender email (from Step 2) |
--subject | required | Subject line |
--body | — | HTML body (or use --body-file) |
--body-file | — | Read body from file (from Step 3) |
--recipients | — | Comma-separated emails |
--recipients-file | — | CSV/XLSX/TXT/JSON file |
--email-column | auto | Override email column name |
--name-column | auto | Override name column name |
--company-column | auto | Override company column name |
--title-column | auto | Override title/job column name |
--domain-column | auto | Override domain/website column name |
--recipient-names | auto from email | Comma-separated display names |
--recipient-companies | — | Comma-separated company names |
--recipient-titles | — | Comma-separated job titles |
--recipient-domains | — | Comma-separated company websites |
--campaign-name | "Campaign" | Display name |
--timezone-id | Asia/Singapore | Timezone |
--timezone-offset | +08:00 | UTC offset |
--send-days | 1,2,3,4,5 | Days (1=Mon...7=Sun) |
--send-hours | 9,18 | Start,end hour |
--daily-limit | 50 | Daily send limit |
--no-tracking | off | Disable open/click tracking |
--dry-run | off | Create but do not activate |
Output: JSON summary with campaignId, emailContentId, lead counts, activation status.
All campaign inputs were collected in Step 0.5. Use session_context directly.
From session_context (never ask again):
session_context.recipientssession_context.campaign_purposesender_company, sender_name, sender_title, sender_website, sender_offerings, sender_proofrecipient_fields.{name, company, title, domain}Auto-inferred (never ask):
campaign_purpose (Step 3), using only placeholders with confirmed recipient_fieldsrecipient_fields availabilitycampaign_purpose + current month (e.g. "Tire Outreach Mar 2026")0.5. [Already done] All info collected upfront — use session_context throughout
1. Verify recipients (Step 1) if > 10 emails
2. Claim mailbox (Step 2) if no sender specified
3. Generate & optimize content (Step 3) — use ONLY placeholders with confirmed recipient_fields
4. Run scripts/run_campaign.py (Step 4) — pass all available recipient fields
5. Report: campaignId, name, lead count, activation status
Campaign creation is a 4-step sequential flow handled by the script:
Step 4a — Upload content: POST /campaign/api/biz/mailgo/email/content/upload
Step 4b — Create campaign: POST /campaign/api/biz/mailgo/campaign/save
Step 4c — Add leads: POST /campaign/api/biz/mailgo/leads/add
Step 4d — Activate: POST /campaign/api/biz/mailgo/campaign/operate
Fall back to raw API only for: updating existing campaigns or adding leads from contact groups.
source ~/.zshrc
# Activate / Pause / Delete
python3 scripts/campaign_control.py activate <campaignId>
python3 scripts/campaign_control.py pause <campaignId>
python3 scripts/campaign_control.py delete <campaignId> # confirm with user first!
# List campaigns
python3 scripts/campaign_control.py list # all
python3 scripts/campaign_control.py list --status 1 # active
python3 scripts/campaign_control.py list --status 0 # paused
python3 scripts/campaign_control.py list --name "outreach" # fuzzy search
# Get campaign detail
python3 scripts/campaign_control.py info <campaignId>
User says "stop/pause/resume/delete <campaign>"
1. Know campaign ID?
YES → run script directly
NO → run: scripts/campaign_control.py list --status 1,0
show results, ask user to pick one
2. If delete → confirm: "Delete campaign '{name}'? This cannot be undone."
3. Run script, report: "Campaign '{name}' (ID: {id}) is now {STATUS}."
| Code | Name | Description |
|---|---|---|
-1 | DRAFT | Not activated |
0 | PAUSE | Paused |
1 | ACTIVE | Sending |
2 | COMPLETE | All leads processed |
3 | ERROR | Error state |
State transitions: DRAFT->ACTIVE, ACTIVE<->PAUSE, any->deleted
abnormalType)| Code | Meaning | Action |
|---|---|---|
1 | Monthly quota exhausted | Upgrade plan |
2 | Some sender accounts have issues | Check account settings |
4 | All sender accounts have issues | Fix sender accounts first |
9 | Daily sending limit reached | Wait for next day |
11 | Email quota exhausted | Upgrade plan |
source ~/.zshrc
# Overview stats
python3 scripts/campaign_report.py overview <campaignId>
# Per-round breakdown
python3 scripts/campaign_report.py rounds <campaignId>
# Daily progress (requires date range)
python3 scripts/campaign_report.py daily <campaignId> --start 2026-03-25 --end 2026-03-31
# List replies (metadata only)
python3 scripts/campaign_report.py replies <campaignId>
# List replies + read plain-text body of each reply
python3 scripts/campaign_report.py replies <campaignId> \
--sender "sender@mailbox.com" \
--read-content
# Raw JSON output
python3 scripts/campaign_report.py --json overview <campaignId>
Always show rates, not raw numbers alone:
Campaign: "Q1 Outreach" (ID: 123456) Status: ACTIVE
Leads: 500 total | 250 completed | 30 in progress | 200 not yet | 20 failed
Sent: 450
Delivered: 420 (93.3%)
Opened: 180 (42.9%)
Replied: 35 ( 8.3%)
Clicked: 65 (15.5%)
Bounced: 30 ( 6.7%)
| Metric | Healthy | Critical |
|---|---|---|
| Open rate | >20% | <10% |
| Bounce rate | <2% | >5% |
| Spam complaints | <0.1% | >0.3% |
| Report | Endpoint |
|---|---|
| Overview | POST /campaign/api/biz/mailgo/stat/overview |
| Per-round | POST /campaign/api/biz/mailgo/stat/sequence-info |
| Send records | POST /campaign/api/biz/mailgo/stat/sequence-detail |
| Reply list | POST /campaign/api/biz/mailgo/stat/sequence-detail-reply |
| Reply content | POST /tp/api/hmail/proxy — func: mbox:readMessage, mid: <messageId> |
| Reply detail | POST /campaign/api/biz/mailgo/stat/sequence-detail-reply |
| Click detail | POST /campaign/api/biz/mailgo/stat/sequence-detail-click |
| Contact history | POST /campaign/api/biz/mailgo/stat/leads-send |
| Daily progress | POST /campaign/api/biz/mailgo/stat/daily-step |
sent == 0: say "No emails sent yet" — do not divide by zero.openClick == 0: tracking was disabled — say so.replied > 0 in overview: proactively suggest reading replies:
💬 X reply(ies) detected. Run the following to read them:
python3 scripts/campaign_report.py replies <campaignId> \
--sender <senderEmail> --read-content
| HTTP | Action |
|---|---|
401 | Re-run Step 0 (token expired or invalid) |
403 | Ensure User-Agent header is set |
429 | Wait 60s, retry |
500 | Retry after 10s |
502/503 | Retry after 30-60s |
urllib, json, csv, ssl). No pip install required (except optional openpyxl for .xlsx files).