One 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.
bash
# 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
bash
# 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
bash
# 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.
ItemValueToken sourceapp.mailgo.ai → Avatar → Personal TokensEnv variable nameMAILGO_API_KEYAPI headerX-API-Key: {MAILGO_API_KEY}Token scopeYour Mailgo account (mailboxes, campaigns, reports)Revoke tokenapp.mailgo.ai → Avatar → Personal Tokens → Delete
IssueFixEmpty after source ~/.zshrcOpen a new terminal window401 UnauthorizedToken may be invalid or deleted — create a new one in Personal Tokens403 ForbiddenEnsure 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:
#QuestionFieldRequired?1What is your company name?sender_companyYes2What is your name (for the signature)?sender_nameYes3What does your company offer (products / services)?sender_offeringsYes4What is your job title?sender_titleRecommended5What is your company website?sender_websiteRecommended6Any notable clients, certifications, or metrics to mention?sender_proofOptional
Ask all of these together in the same message as Group A:
#QuestionField7Who are you sending to? Paste emails, or provide a file path (CSV / XLSX / TXT).recipients8What is the goal of this campaign? (one sentence)campaign_purpose9Do your recipients have names available? (column name or yes/no)has_name10Do you have recipients' company names? (column name or yes/no)has_company11Do you have recipients' job titles? (column name or yes/no)has_title12Do 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):
Extract name from each email's local-part (the portion before @):
., -, _ → capitalize each segment → join with spacealice.smith@co.com → "Alice Smith", john_doe@co.com → "John Doe", jdoe@co.com → "Jdoe"Store the derived name into each recipient entry in session_context.recipients
Set recipient_fields.name = true with source noted as "auto-derived from email prefix"
Do this silently — do not ask the user, do not announce it; just note it in the Step 3 change summary
This ensures #{Name} is always available as long as there are valid email addresses.
SituationActionUser skips a required field (1, 2, 3, 7, 8)Ask once more specifically for that field onlyUser skips a recommended field (4, 5)Mark as not provided, do not ask againUser skips an optional field (6)Mark as not provided, move onUser says "I'll send to a file I haven't uploaded yet"Ask for file path, then detect columnsUser provides inline recipients with no extra dataAuto-derive names from email prefixes (see above); mark fields 10–12 as not providedUser provides a file without a header row and extra columns are presentWarn: 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.
text
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.
bash
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:
FlagDefaultDescriptionemails—Space-separated email addresses--file—Read from TXT/CSV/JSON file--email-columnauto-detectOverride email column name--timeout180Poll timeout in seconds--interval5Poll interval in seconds
Output: JSON on stdout with categorized results:
json
{
"total": 5,
"valid": [{"email": "...", "status": "VALID"}],
"invalid": [...],
"domain_error": [...],
"unknown": [...],
"unchecked": [...]
}
StatusMeaningRecommendationVALIDConfirmed deliverableSendUNKNOWNCatch-all or greylistingSend with cautionDOMAIN_ERRORNo MX recordsDo not sendINVALIDMalformed or SMTP-rejectedDo not sendUNCHECKEDStill processingWait 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).
bash
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:
bash
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 fieldUse in emailsender_nameSignature namesender_titleSignature title (omit if null)sender_companySignature company + body referencessender_websiteSignature website (omit if null)sender_offeringsValue bridge paragraphsender_proofProof point sentence (omit if null)recipient_fields.nameAlways true — use #{Name} unconditionally (user-provided or auto-derived from email prefix in Step 0.5)recipient_fields.companyUse #{Company Name} only if true``recipient_fields.titleUse #{Title} only if true``recipient_fields.domainUse #{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:
PlaceholderDescriptionResolved from#{Name}Recipient's namecontactName in leads data#{Company Name}Company namecompanyName in leads data#{Domain}Company websitedomain in leads data#{Title}Job titletitle in leads data#{Email}Email addresscontactEmail 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.PlaceholderIf data availableIf 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:
text
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):
TriggerReplacementfree / 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 wordsSentence 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
Text-to-image ratio > 60% text (warn if image-heavy)
Cold email target: < 150 words (flag if > 200)
One CTA only (suggest consolidation if multiple)
Placeholder Data Validation — scan all #{...} 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:
#StrategyExample1Value propositionHow #{Company Name} can cut onboarding time by 60%2Industry insightA shift I'm seeing in #{Company Name}'s space3Case study / metricsHow [similar company] grew pipeline 3x in 90 days4Curiosity / questionQuick question about #{Company Name}'s [process]
If #{Company Name} data is NOT available:
#StrategyExample1Value propositionCutting onboarding time by 60% — here's how2Industry insightA shift I'm seeing in the [industry] space3Case study / metricsHow [similar company] grew pipeline 3x in 90 days4Curiosity / questionA 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):
text
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:
html
<!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:
text
P.S. If this isn't relevant, just reply and let me know — I won't reach out again.
text
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
LevelIssuesCritical (must fix)<script> tags, shortener URLs, base64 images, javascript: URLs, "This is not spam", #{Company Name}/ #{Domain}/ #{Title} placeholders with no corresponding recipient dataWarning (recommend)Spam trigger words, image-heavy, >3 links, missing opt-outInfoWord 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.bash
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:
FlagDefaultDescription--senderrequiredSender email (from Step 2)--subjectrequiredSubject 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-columnautoOverride email column name--name-columnautoOverride name column name--company-columnautoOverride company column name--title-columnautoOverride title/job column name--domain-columnautoOverride domain/website column name--recipient-namesauto from emailComma-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-idAsia/SingaporeTimezone--timezone-offset+08:00UTC offset--send-days1,2,3,4,5Days (1=Mon...7=Sun)--send-hours9,18Start,end hour--daily-limit50Daily send limit--no-trackingoffDisable open/click tracking--dry-runoffCreate 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")text
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:
text
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.
bash
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>
text
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}."
CodeNameDescription-1DRAFTNot activated0PAUSEPaused1ACTIVESending2COMPLETEAll leads processed3ERRORError state
State transitions: DRAFT->ACTIVE, ACTIVE<->PAUSE, any->deleted
abnormalType)#CodeMeaningAction1Monthly quota exhaustedUpgrade plan2Some sender accounts have issuesCheck account settings4All sender accounts have issuesFix sender accounts first9Daily sending limit reachedWait for next day11Email quota exhaustedUpgrade plan
bash
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:
text
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%)
MetricHealthyCriticalOpen rate>20%<10%Bounce rate<2%>5%Spam complaints<0.1%>0.3%
ReportEndpointOverviewPOST /campaign/api/biz/mailgo/stat/overviewPer-roundPOST /campaign/api/biz/mailgo/stat/sequence-infoSend recordsPOST /campaign/api/biz/mailgo/stat/sequence-detailReply listPOST /campaign/api/biz/mailgo/stat/sequence-detail-replyReply contentPOST /tp/api/hmail/proxy — func: mbox:readMessage, mid: <messageId>Reply detailPOST /campaign/api/biz/mailgo/stat/sequence-detail-replyClick detailPOST /campaign/api/biz/mailgo/stat/sequence-detail-clickContact historyPOST /campaign/api/biz/mailgo/stat/leads-sendDaily progressPOST /campaign/api/biz/mailgo/stat/daily-step
Default to overview. Drill into details only on explicit request.
Always show rates alongside raw counts.
If sent == 0: say "No emails sent yet" — do not divide by zero.
If no campaign ID: list campaigns first, offer to drill into one.
If openClick == 0: tracking was disabled — say so.
If replied > 0 in overview: proactively suggest reading replies:
text
💬 X reply(ies) detected. Run the following to read them:
python3 scripts/campaign_report.py replies <campaignId> \
--sender <senderEmail> --read-content
HTTPAction401Re-run Step 0 (token expired or invalid)403Ensure User-Agent header is set429Wait 60s, retry500Retry after 10s502/503Retry after 30-60s
urllib, json, csv, ssl). No pip install required (except optional openpyxl for .xlsx files).