Guide
Personalize Outbound Email from the CLI
Most mail merge tools lock you into a GUI, charge per seat, and cannot integrate with your data pipeline. The Nylas CLI lets you send personalized email from a script — merge fields from enriched contact data, schedule by timezone, and throttle sends with built-in safety controls.
Why CLI-based outbound beats mail merge tools
Traditional mail merge tools require you to export contacts from one system, import them into another, manually map fields, and click through a wizard every time you want to send. The entire workflow breaks the moment you need to integrate with an enrichment pipeline, a CRM API, or a CI/CD job.
CLI-based outbound is scriptable. You can version-control your templates in Git, generate contact lists from any data source, and trigger sends from a cron job or a CI pipeline. There is no per-seat pricing — the same script works whether one person runs it or twenty. And because every step is a shell command, you can compose it with jq, awk, Python, or TypeScript however your pipeline requires.
Build a contact list with context
The foundation of personalized outbound is a contact list that contains more than just an email address. You need the recipient’s name, company, role, and enough context from your last interaction to write a message that feels personal — not templated. A CSV works well for small lists; JSON is better when you have nested data from an enrichment pipeline.
Here is a minimal CSV format with the fields you need for personalization:
email,name,company,title,last_subject,days_since_last
sarah@acme.com,Sarah Chen,Acme Corp,VP Engineering,API integration timeline,14
bob@globex.com,Bob Martinez,Globex Inc,Head of Platform,Q2 roadmap review,7If you followed the Enrich Contact and Company Info from Email guide, you can generate this CSV directly from your enriched contacts JSON:
# Generate from enriched contacts JSON
cat enriched_contacts.json | jq -r '
["email","name","company","title","last_subject","days_since_last"],
(.[] | [.email, .name, .company, .title, .last_subject, .days_since_last])
| @csv' > outbound_list.csvCreate email templates
Templates use shell variable syntax so they expand naturally inside a bash script. Keep them in a separate file or a heredoc — either way, you can version-control them alongside your send script.
TEMPLATE='Hi ${NAME},
I wanted to follow up on our conversation about ${LAST_SUBJECT}.
Given your role as ${TITLE} at ${COMPANY}, I think there is a good opportunity to ${CUSTOM_OPENING}.
Would you have 15 minutes this week to discuss?
Best,
Your Name'The single quotes prevent premature expansion. The variables are expanded later when you eval the template or use envsubst with exported variables.
Personalize the subject line
The subject line determines whether your email gets opened. A personalized subject that references a past conversation or the recipient’s role dramatically outperforms generic ones. Here are three patterns that work well:
# Pattern 1: Reference last conversation
SUBJECT="Re: ${LAST_SUBJECT}"
# Pattern 2: Role-based
SUBJECT="Quick question for ${COMPANY}'s ${TITLE}"
# Pattern 3: Time-based
SUBJECT="Following up — it's been ${DAYS} days"Pattern 1 works best for warm contacts you have already spoken with. Pattern 2 is effective for first-touch outreach. Pattern 3 creates urgency for follow-ups that have gone quiet.
Send personalized emails in a loop
This script reads your contact CSV, builds a personalized subject and body for each row, and sends via nylas email send. It starts in dry-run mode so you can verify the output before sending anything.
#!/bin/bash
# personalized-outbound.sh
DRY_RUN=${DRY_RUN:-true}
DELAY=5 # seconds between sends
while IFS=, read -r email name company title last_subject days; do
# Skip header row
[[ "$email" == "email" ]] && continue
subject="Following up on ${last_subject}"
body="Hi ${name},
I wanted to circle back on our conversation about ${last_subject}. As ${title} at ${company}, you mentioned some challenges that I think we can help with.
Would you have 15 minutes this week to discuss?
Best,
Your Name"
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY RUN] Would send to: ${email} | Subject: ${subject}"
else
nylas email send \
--to "$email" \
--subject "$subject" \
--body "$body" \
--yes
echo "Sent to: ${email}"
sleep "$DELAY"
fi
done < outbound_list.csvRun with dry-run first to verify personalization:
DRY_RUN=true bash personalized-outbound.shWhen you are satisfied with the output, send for real:
DRY_RUN=false bash personalized-outbound.shAdd timezone-aware scheduling
Sending all your emails at 2 AM in the recipient’s timezone is a good way to end up in spam. The --schedule flag lets you queue emails for delivery at a specific time, so you can target business hours regardless of where you are.
# Send during business hours in recipient's timezone
nylas email send \
--to sarah@acme.com \
--subject "Quick question" \
--body "..." \
--schedule "tomorrow 9am" \
--yesFor batch sends across multiple timezones, add a timezone column to your CSV and compute the schedule time per contact:
# Schedule each contact for 9am in their local timezone
nylas email send \
--to "$email" \
--subject "$subject" \
--body "$body" \
--schedule "tomorrow 9am $timezone" \
--yesRate limiting and safety
Sending personalized email at scale comes with real risks — you can damage your sender reputation, trigger spam filters, or embarrass yourself with a broken template. Follow these rules:
- Always start with
DRY_RUN=true— review every email before it leaves your outbox. - Throttle sends — minimum 5 seconds between sends. Email providers flag accounts that send dozens of messages per minute.
- Review the first 3–5 manually — send to yourself or a test account first, then review the first few real sends before batching the rest.
- Use
--scheduleto spread sends across hours — 50 emails staggered over a morning looks natural; 50 emails in 4 minutes looks like spam. - Never send more than 50 personalized emails without reviewing a sample — check for broken merge fields, missing names, and formatting issues.
Python version
This Python script does the same thing as the bash version but with better error handling, typed data structures, and cleaner template rendering using string.Template.
#!/usr/bin/env python3
"""Send personalized outbound email via Nylas CLI."""
import csv
import subprocess
import sys
import time
from string import Template
DRY_RUN = "--send" not in sys.argv
DELAY = 5 # seconds between sends
CSV_FILE = "outbound_list.csv"
SUBJECT_TEMPLATE = Template("Following up on $last_subject")
BODY_TEMPLATE = Template("""Hi $name,
I wanted to circle back on our conversation about $last_subject. \
As $title at $company, you mentioned some challenges that I think \
we can help with.
Would you have 15 minutes this week to discuss?
Best,
Your Name""")
def send_email(to: str, subject: str, body: str) -> None:
"""Send a single email via nylas CLI."""
subprocess.run(
[
"nylas", "email", "send",
"--to", to,
"--subject", subject,
"--body", body,
"--yes",
],
check=True,
)
def main() -> None:
if DRY_RUN:
print("DRY RUN — pass --send to actually send\n")
with open(CSV_FILE, newline="") as f:
reader = csv.DictReader(f)
for i, row in enumerate(reader):
subject = SUBJECT_TEMPLATE.substitute(row)
body = BODY_TEMPLATE.substitute(row)
if DRY_RUN:
print(f"[DRY RUN] To: {row['email']} | Subject: {subject}")
else:
send_email(row["email"], subject, body)
print(f"Sent ({i + 1}): {row['email']}")
time.sleep(DELAY)
print("\nDone.")
if __name__ == "__main__":
main()Run in dry-run mode, then send:
python3 personalized_outbound.py # dry run
python3 personalized_outbound.py --send # send for realTypeScript version
The TypeScript version uses a typed Contact interface and execFileSync for subprocess calls. It reads the same CSV format.
#!/usr/bin/env npx tsx
/**
* Send personalized outbound email via Nylas CLI.
* Usage: npx tsx personalized-outbound.ts # dry run
* npx tsx personalized-outbound.ts --send # send for real
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
interface Contact {
email: string;
name: string;
company: string;
title: string;
last_subject: string;
days_since_last: string;
}
const DRY_RUN = !process.argv.includes("--send");
const DELAY_MS = 5_000;
const CSV_FILE = "outbound_list.csv";
function parseCSV(path: string): Contact[] {
const lines = readFileSync(path, "utf-8").trim().split("\n");
const headers = lines[0].split(",");
return lines.slice(1).map((line) => {
const values = line.split(",");
return Object.fromEntries(
headers.map((h, i) => [h, values[i]])
) as unknown as Contact;
});
}
function renderSubject(c: Contact): string {
return `Following up on ${c.last_subject}`;
}
function renderBody(c: Contact): string {
return `Hi ${c.name},
I wanted to circle back on our conversation about ${c.last_subject}. \
As ${c.title} at ${c.company}, you mentioned some challenges that I \
think we can help with.
Would you have 15 minutes this week to discuss?
Best,
Your Name`;
}
function sendEmail(to: string, subject: string, body: string): void {
execFileSync("nylas", [
"email", "send",
"--to", to,
"--subject", subject,
"--body", body,
"--yes",
]);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const contacts = parseCSV(CSV_FILE);
if (DRY_RUN) {
console.log("DRY RUN — pass --send to actually send\n");
}
(async () => {
for (const [i, contact] of contacts.entries()) {
const subject = renderSubject(contact);
const body = renderBody(contact);
if (DRY_RUN) {
console.log(`[DRY RUN] To: ${contact.email} | Subject: ${subject}`);
} else {
sendEmail(contact.email, subject, body);
console.log(`Sent (${i + 1}): ${contact.email}`);
await sleep(DELAY_MS);
}
}
console.log("\nDone.");
})();Next steps
Now that you can send personalized outbound email at scale, explore these related guides:
- Auto-Create Email Drafts — generate drafts instead of sending immediately, so you can review each message before it goes out.
- Build Email Autocomplete — speed up manual composition with contact suggestions from your email history.
- CRM Email Workflows — the full 8-guide series for turning your inbox into CRM intelligence.