| name | gmail-outreach |
| description | Outbound email actions — periodic relationship touchbase messages and batch unsubscribe from newsletters/marketing. Combines gmail-touchbase + gmail-unsubscribe into one skill. |
| version | 1.0.0 |
| author | vamsee |
| tags | ["email","gmail","outreach","touchbase","unsubscribe","automation"] |
| related_skills | ["gmail-multi-account","contact-manager","gmail-triage"] |
| metadata | {"hermes":{"tags":["email","gmail","outreach"],"related_skills":["gmail-multi-account","contact-manager"]}} |
Gmail Outreach — Touchbase + Unsubscribe
Outbound email management: maintain relationships and remove noise. Combines two functions into one skill.
Prerequisites
- OAuth tokens in
~/.gmail-{account}/credentials.json
- Client credentials in
~/.gmail-mcp/oauth-env.json
- Contact CSVs normalized (see
contact-manager skill)
gmail-multi-account skill configured
Part 1: Touchbase — Relationship Maintenance
Identify contacts due for periodic outreach and draft check-in messages.
Contact Cadence Rules
| Contact Category | Cadence | Trigger |
|---|
| Active clients | 30 days | Last email exchange > 30 days ago |
| GTM prospects | 21 days | No engagement in 21 days |
| Important vendors | 90 days | No interaction in 90 days |
| Alumni network | 180 days | No contact in 6 months |
| Recruiters | 90 days | No response in 90 days |
| Family/personal | 30 days | No contact in 30 days |
Touchbase Workflow
- Load contact CSV for the account
- Check last interaction date (search Gmail for sent-to + received-from)
- Filter contacts past their cadence threshold
- Sort by priority: clients > prospects > vendors > network
- Draft personalized check-in messages using communication style profiles (#1986)
Draft Templates
Client check-in:
{FirstName},
Hope all is well on your end. It's been a few weeks since we last connected — just wanted to check in and see if there's anything I can help with on the [project] front.
Let me know if you'd like to catch up briefly.
Thank you,
Vamsee
Touchbase with GTM prospect:
{FirstName},
I wanted to follow up on our earlier conversation about [topic]. I've been working on [recent project/capability] and thought it might be relevant to what you're doing at [Company].
Would love to catch up when you have a moment.
Thank you,
Vamsee Achanta, P.E.
Network check-in:
Hey {FirstName},
Long time no talk. Hope [work/family/life] has been treating you well.
Let's catch up when you get a chance.
Vamsee
Per-Account Style Profiles
Load the full style profile for the sending account before drafting any message:
config/email/ace-style.yaml — professional-technical, concise (2-5 sentences)
config/email/personal-style.yaml — casual-terse, ultra-short (1-2 sentences)
config/email/skestates-style.yaml — business-formal-warm, medium (3-6 sentences)
Each profile defines greeting, closing, signature, tone, formality rules, and
calibration examples. Check formality_rules to adjust for context (new client
vs ongoing thread, vendor follow-up vs family matters). See config/email/README.md
for the full usage guide. Reference: #1986.
Quick reference (see YAML files for complete rules):
| Account | Tone | Greeting | Closing | Signature |
|---|
| ace | Professional-technical | "{FirstName}," | "Thank you," | "Vamsee Achanta, P.E." (formal) / "Vamsee" (casual) |
| personal | Casual/abbreviated | "{FirstName}," or none | "Vamsee" or none | "Vamsee" |
| skestates | Business-formal-warm | "{FirstName}," | "Thank you very much," | "Vamsee" (on behalf of "the owners" / "SKEstates Inc") |
Execution
import json, urllib.request, urllib.parse, os
from datetime import datetime, timedelta
def search_gmail_for_contact(email, account, token):
"""Check for last interaction with an email address"""
query = urllib.parse.quote(f"from:{email} OR to:{email}")
result = gmail_get(f"users/me/messages?q={query}&maxResults=1", token)
if result.get("messages"):
msg = gmail_get(f"users/me/messages/{result['messages'][0]['id']}?format=metadata", token)
hdrs = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
return hdrs.get("Date", "")
return None
def get_touchbase_candidates(account, contacts_csv, cadence_days=30):
"""Get contacts due for touchbase"""
import csv
cutoff = datetime.now() - timedelta(days=cadence_days)
token = refresh_token(account)
candidates = []
with open(contacts_csv) as f:
reader = csv.DictReader(f)
for row in reader:
email = row.get("email", "")
category = row.get("category", "")
last_contact = search_gmail_for_contact(email, account, token)
last_contact:
contact_date = datetime.strptime(last_contact[:], )
contact_date < cutoff:
candidates.append({
: email,
: row.get(, ),
: category,
: last_contact,
})
candidates
Part 2: Unsubscribe — Batch Noise Removal
Identify emails with List-Unsubscribe headers and batch process unsubscribes.
Unsubscribe Detection
def find_unsubscribe_candidates(account, max_results=200):
"""Scan inbox for emails with List-Unsubscribe headers"""
token = refresh_token(account)
search = gmail_get(f"users/me/messages?maxResults={max_results}", token)
candidates = []
for msg_stab in search.get("messages", []):
detail = gmail_get(
f"users/me/messages/{msg_stab['id']}?format=metadata&metadataHeaders=List-Unsubscribe&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date&metadataHeaders=List-Unsubscribe-Post",
token
)
hdrs = {h["name"]: h["value"] for h in detail.get("payload", {}).get("headers", [])}
if hdrs.get("List-Unsubscribe"):
method = "http" if hdrs["List-Unsubscribe"].startswith("http") else "mailto"
one_click = bool(hdrs.get("List-Unsubscribe-Post", ""))
candidates.append({
"msg_id": msg_stab["id"],
"from": hdrs.get("From", ""),
"subject": hdrs.get("Subject", ""),
"date": hdrs.get("Date", ""),
"unsubscribe_url": hdrs.get("List-Unsubscribe", ),
: method,
: one_click,
})
candidates
Unsubscribe Execution
HTTP/one-click unsubscribe (POST to the URL):
def execute_unsubscribe(url, method="http"):
"""Execute unsubscribe via HTTP POST or mailto"""
if method == "http" and url.startswith("http"):
req = urllib.request.Request(url, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return {"status": resp.status, "success": True}
except Exception as e:
return {"error": str(e), "success": False}
elif method == "mailto":
mailto_addr = url.replace("<mailto:", "").replace(">", "").split(",")[0]
return {"method": "mailto", "address": mailto_addr}
return {"error": "unsupported method", "success": False}
Unsubscribe + Delete Pipeline
def unsubscribe_and_delete(account, domains_to_remove, max_per_domain=500, dry_run=False):
"""Unsubscribe from domains and delete matching emails"""
token = refresh_token(account)
stats = {"unsubscribed": 0, "deleted": 0, "skipped": 0}
for domain in domains_to_remove:
query = f"from:{domain} has:List-Unsubscribe"
search = gmail_get(f"users/me/messages?q={urllib.parse.quote(query)}&maxResults={max_per_domain}", token)
for msg_stab in search.get("messages", []):
detail = gmail_get(f"users/me/messages/{msg_stab['id']}?format=metadata", token)
hdrs = {h["name"]: h["value"] for h in detail.get("payload", {}).get("headers", [])}
unsub_url = hdrs.get("List-Unsubscribe", "")
if unsub_url:
method = "http" if unsub_url.startswith("http") else "mailto"
if not dry_run:
execute_unsubscribe(unsub_url, method)
gmail_post(f"users/me/messages/{msg_stab['id']}/trash", token, {})
stats["unsubscribed"] +=
stats[] +=
:
stats[] +=
stats
Safety Rules
- ALWAYS show unsubscribe candidates to user before executing
- First run: execute in dry_run mode and show what would happen
- Never unsubscribe domains marked as REVIEW in email-routing.yaml
- Touchbase drafts: ALWAYS require user approval before sending
- Batch unsubscribe: max 20 domains per batch to avoid Gmail rate limits
- Log all unsubscribe actions — track what was unsubscribed and when
- Unsubscribe list is maintained in
~/.hermes/unsubscribe-log.yaml
Usage Examples
Find touchbase candidates for ace account
cd /mnt/local-analysis/workspace-hub
Find unsubscribe candidates
candidates = find_unsubscribe_candidates("personal", max_results=200)
Execute batch unsubscribe (after user approval)
domains = ["promote.weebly.com", "e.swimoutlet.com", "lists.wikimedia.org"]
stats = unsubscribe_and_delete("personal", domains, dry_run=False)
Unsubscribe Watchlist (from email-routing.yaml)
CONFIRMED DELETE (safe to unsubscribe + delete):
collide.io, skylineseven.ccsend.com, promote.weebly.com
e.swimoutlet.com, email.myflighthub.com, mail.urbanairparks.com
e.stantonoptical.com, lists.wikimedia.org, jongordon.com
atticbuddies.com, email.theparkingspot.com, marketing.goindigo.in
deeplearning.ai, gamemail.com, suzeorman.com
REVIEW FIRST (do not auto-unsubscribe):
substack.com, info.marineinsight.com, indianstarllc.ccsend.com
rigzonemail.com, m.learn.coursera.org, irctc.co.in
info.dpam.com, blueskysfund.com, cincsystems.net
Deprecation Notice
This skill replaces:
gmail-touchbase — functionality merged into this skill
gmail-unsubscribe — functionality merged into this skill
Pitfalls
- one-click unsubscribe (List-Unsubscribe-Post header) requires POST to the URL with
List-Unsubscribe header set to "1"
- Some unsubscribe URLs are POST-only and require specific headers — test before batch
- Touchbase cadence: use Gmail search for last interaction, not local timestamps (more reliable)
- Don't touchbase with contacts who explicitly opted out — check "do_not_contact" column in contact CSV
- Gmail API rate limits — batch requests with 0.15s delays between calls
- Touchbase emails should feel personal — avoid obvious template language
- Always cross-reference against email-routing.yaml REVIEW list before auto-unsubscribing