Skip to content
Skip to main content

CLI mail merge with send-time optimization

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.

outbound_list.csv:

email,name,company,title,last_subject,days_since_contact,timezone
[email protected],Ada,Acme,VP Eng,Q1 review,42,America/Los_Angeles
[email protected],Rin,Globex,CTO,Renewal,17,Europe/Berlin

The columns become variables in the template. Add as many as your personalization needs.

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 and
the ${company} team.
How does ${suggested_time} ${timezone} look?
— Sam
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN="${DRY_RUN:-true}" # safe default
TEMPLATE="$(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 throttle
done

Run it dry first:

bash personalized-send.sh

When the output looks right, ship it for real:

DRY_RUN=false bash personalized-send.sh
  • --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=true by default — sends are irreversible. Make them require an explicit opt-out.

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 11
schedule="tomorrow ${HOUR}:$((RANDOM % 60)) ${timezone}"

This produces a smooth distribution per timezone instead of a thundering herd at exactly 9:00.

Before the real run, retarget the first three rows to your own address:

head -n 4 outbound_list.csv |
awk -F, 'NR==1 || $1="[email protected]"' OFS=, > preview.csv
DRY_RUN=false ./personalized-send.sh ./preview.csv

Confirm the rendered bodies, then run the full list.

  • envsubst is 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 60 and retry.