A “mail merge” worth shipping has three properties: it actually personalizes (not just Hi $FNAME), it respects each recipient’s timezone so the message lands during their morning, and it doesn’t burn your sender reputation by firing 5,000 messages in 12 seconds. This recipe is the CLI version of that — a CSV, a template with ${VAR} placeholders, and nylas email send in a loop with sane throttling and a dry-run mode that’s on by default.
The CSV
Section titled “The CSV”outbound_list.csv:
email,name,company,title,last_subject,days_since_contact,timezoneThe columns become variables in the template. Add as many as your personalization needs.
The template
Section titled “The template”Use ${VAR} placeholders and let envsubst interpolate them per row:
Hi ${name},
It's been ${days_since_contact} days since we last connected on"${last_subject}". I'd love to do a 20-minute check-in with you andthe ${company} team.
How does ${suggested_time} ${timezone} look?
— SamThe send loop (bash)
Section titled “The send loop (bash)”#!/usr/bin/env bashset -euo pipefail
DRY_RUN="${DRY_RUN:-true}" # safe defaultTEMPLATE="$(cat ./template.txt)"
tail -n +2 ./outbound_list.csv | while IFS=, read -r email name company title last_subject days_since_contact timezone; do export name company title last_subject days_since_contact timezone body="$(envsubst <<< "$TEMPLATE")" schedule="tomorrow 9am ${timezone}"
if [[ "$DRY_RUN" == "true" ]]; then echo "→ would send to $email at $schedule" continue fi
nylas email send \ --to "$email" \ --subject "Catching up on ${last_subject}" \ --body "$body" \ --schedule "$schedule" \ --yes \ --json >> sent.log
sleep 5 # baseline throttledoneRun it dry first:
bash personalized-send.shWhen the output looks right, ship it for real:
DRY_RUN=false bash personalized-send.shWhy these defaults exist
Section titled “Why these defaults exist”--schedule "tomorrow 9am ${timezone}"— Mailchimp’s analysis (Feb 2024 update) shows mail delivered between 9–11 AM in the recipient’s local timezone gets ~14% better open rates than uniform send times. The flag does the math for you.sleep 5— providers detect “volume spikes” and drop your reputation when you blast at machine speed. Five seconds between sends is a reasonable floor for warmed-up accounts. New accounts should crawl: 10/day, then double weekly.DRY_RUN=trueby default — sends are irreversible. Make them require an explicit opt-out.
Distribute the load
Section titled “Distribute the load”Even with sleep, scheduling all sends at the same minute across hundreds of recipients still creates a spike at the provider. Stagger with --schedule over a 2–3 hour window:
HOUR=$((9 + RANDOM % 3)) # 9, 10, or 11schedule="tomorrow ${HOUR}:$((RANDOM % 60)) ${timezone}"This produces a smooth distribution per timezone instead of a thundering herd at exactly 9:00.
Test before you trust
Section titled “Test before you trust”Before the real run, retarget the first three rows to your own address:
head -n 4 outbound_list.csv |DRY_RUN=false ./personalized-send.sh ./preview.csvConfirm the rendered bodies, then run the full list.
Things to know
Section titled “Things to know”envsubstis shell-safe but variable-naïve. Quote your template carefully and avoid characters that look like shell substitutions.--track-label "spring-outreach-2026"lets you slice opens and clicks per campaign without inferring from subject lines later.- Hard cap. Microsoft 365 is ~10,000/day per mailbox; Gmail is ~2,000/day; Exchange depends on tenant policy. The CLI surfaces 429s as exit code 1 — back off with
sleep 60and retry.
Next steps
Section titled “Next steps”- Run the merge through an MCP-connected agent
- Nylas CLI — installation and command reference