Source: https://cli.nylas.com/guides/build-reliable-email-automation

# Build Reliable Email Automation: 5 Patterns

Your nightly report script ran fine for six months. Then an OAuth token expired and it failed silently for three weeks before anyone noticed the reports stopped. This guide covers five patterns that make terminal email automation reliable: checking exit codes, retrying with bounds, deduplicating sends with idempotency checks, staging risky sends as drafts, and keeping an audit trail you can debug from.

Written by [Pouya Sanooei](https://cli.nylas.com/authors/pouya-sanooei) Software Engineer

Updated June 5, 2026

> **TL;DR:** The CLI exits non-zero on any failed command, so `set -euo pipefail` plus a bounded retry loop handles transient failures. Tag every automated send with `--metadata run_id=...` and check the Sent folder before retrying to prevent duplicates. Stage risky sends with `nylas email drafts create`, and enable `nylas audit init --enable` so every run leaves a trail with request IDs.

Command references used in this guide: `nylas email send`, `nylas email list`, `nylas email drafts`, and `nylas audit logs show`.

## Why does email automation fail silently?

Email automation fails silently because the failure modes are intermittent and the script has no one watching its output. OAuth access tokens expire after 3,600 seconds; provider rate limits throttle bursts; DNS hiccups kill a single request out of hundreds. A cron job swallows stderr unless you redirect it, so a failing script and a succeeding script look identical until someone misses an email.

The platform handles the biggest cause for you: token refresh. The CLI refreshes OAuth tokens automatically, which removed the single most common breakage in scripts that talked to Gmail and Outlook directly, especially after [Google wound down "less secure app" passwords](https://workspaceupdates.googleblog.com/2023/09/winding-down-google-sync-and-less-secure-apps-support.html) in September 2024. The remaining failure modes (network, rate limits, bad input) are yours to handle, and the 5 patterns below cover them in about 30 lines of shell.

## How do you make an email script fail loud?

Every CLI command returns exit code 0 on success and non-zero on failure, which makes failure detection a one-line check. Without it, bash keeps executing after a failed send and your script reports success it didn't earn. Per the [bash manual](https://man7.org/linux/man-pages/man1/bash.1.html), `set -e` exits on any command returning non-zero status, and adding `-u` and `-o pipefail` extends that to undefined variables and broken pipes.

```bash
#!/usr/bin/env bash
set -euo pipefail

# Fails loud: script exits non-zero if the send fails
nylas email send \
  --to ops@example.com \
  --subject "Nightly backup report" \
  --body "$(cat /tmp/backup-summary.txt)" \
  --yes

# Or handle the failure explicitly
if ! nylas email send --to ops@example.com --subject "Report" --body "..." --yes; then
  echo "send failed at $(date -u +%FT%TZ)" >> /var/log/email-automation.log
  exit 1
fi
```

The `--yes` flag skips the interactive confirmation prompt, which matters because cron jobs run without a TTY and would otherwise hang forever waiting for input. Logging the failure timestamp before exiting gives you a search target when you debug later.

## How do you retry a failed send without looping forever?

A bounded retry loop re-runs a failed send a fixed number of times with a pause between attempts, then gives up loudly. 3 attempts spaced 30 seconds apart cover most transient network failures, which typically clear in seconds. Unbounded retries are worse than none: they hammer a rate-limited endpoint and turn one failure into hundreds.

```bash
# Retry up to 3 times, 30 seconds apart
ATTEMPTS=0
MAX_ATTEMPTS=3
until nylas email send --to ops@example.com --subject "Report" --body "..." --yes; do
  ATTEMPTS=$((ATTEMPTS + 1))
  if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
    echo "giving up after $MAX_ATTEMPTS attempts" >&2
    exit 1
  fi
  sleep 30
done
```

Keep the retry count low for email specifically. Unlike an idempotent GET, a send that times out client-side may still have gone through server-side, so each retry risks a duplicate. That risk is why the next pattern exists. For platform-wide outages rather than single failed requests, switch to the exponential backoff and queueing patterns in the [outage handling guide](https://cli.nylas.com/guides/handle-email-api-outages).

## How do you prevent duplicate emails when retrying?

Idempotent sending means running the same script twice produces one email, not two. It's the same idea behind the idempotency keys that [Stripe's engineering blog](https://stripe.com/blog/idempotency) made standard practice for payment APIs, applied to email. The pattern has two parts: check the Sent folder for a date-stamped subject before sending, and tag the send with a unique run ID using `--metadata` as the durable record. The `--metadata key=value` flag attaches custom key-value pairs to the message without altering what the recipient sees.

```bash
# Derive a stable run ID from the job name and date
RUN_ID="nightly-report-$(date -u +%F)"

# Skip the send if this run already produced an email
# (if the list fails, ALREADY_SENT falls back to 0: a duplicate beats silence)
ALREADY_SENT=$(nylas email list --folder Sent --limit 50 --json | \
  jq --arg subj "Nightly report $(date -u +%F)" \
  '[.[] | select(.subject == $subj)] | length' || echo 0)
ALREADY_SENT=${ALREADY_SENT:-0}

if [ "$ALREADY_SENT" -gt 0 ]; then
  echo "already sent for $(date -u +%F), skipping"
  exit 0
fi

nylas email send \
  --to ops@example.com \
  --subject "Nightly report $(date -u +%F)" \
  --body "$(cat /tmp/report.txt)" \
  --metadata run_id="$RUN_ID" \
  --yes
```

Putting the date in the subject makes the dedupe check a simple string match in `jq`, and checking the 50 most recent Sent emails covers well over 30 days of daily jobs. The metadata tag is the durable record: it travels with the message, so a different machine or a rewritten script can still recognize past sends from the same job.

## How do you use drafts as a safety net?

A draft-first workflow splits sending into two steps: the automation creates a draft, and a separate step (human or scheduled) reviews and sends it. The `nylas email drafts create` command stages the message in your real mailbox where any client can inspect it, and `nylas email drafts send` dispatches it by ID. For automation that emails customers rather than your own ops channel, this two-step pattern converts a script bug from an incident into a non-event.

```bash
# Stage the message as a draft instead of sending
nylas email drafts create \
  --to customer@example.com \
  --subject "Your June invoice" \
  --body "$(cat /tmp/invoice-email.txt)"

# List staged drafts with their IDs
nylas email drafts list --json | jq '.[] | {id, subject, to}'

# Send a reviewed draft by ID
nylas email drafts send DRAFT_ID
```

The review step costs you delivery latency: a draft waits until something sends it, and the staging itself adds just 1 extra command. Use drafts for low-volume, high-stakes mail (invoices, customer notices, anything legal) and direct `send` for high-volume internal alerts where a duplicate is annoying rather than damaging.

## How do you trace an automation failure after the fact?

The fifth pattern is a local audit trail: each CLI command gets recorded with its timestamp, status, and the Nylas request ID the API returned. Enable it once with `nylas audit init --enable` and every subsequent run of every script leaves a queryable trail. When a report didn't arrive 3 days ago, the log answers whether the script ran, whether the send succeeded, and which request ID to reference if you open a support ticket.

```bash
# One-time setup
nylas audit init --enable

# Did the email commands succeed this week?
nylas audit logs show --command email --since 2026-06-01

# Show only failures
nylas audit logs show --status error

# Pull the exact entry for a request ID from a support thread
nylas audit logs show --request-id req_abc123
```

The request ID is the link between your terminal and the platform's logs: one identifier that both sides can look up. That single field routinely cuts a debugging session from hours of guesswork to a 5-minute lookup. The [audit logging guide](https://cli.nylas.com/guides/audit-ai-agent-activity) covers anomaly detection on the same data.

## Which pattern prevents which failure?

The 5 patterns stack: each one catches a failure mode the previous ones miss, and all five together fit in roughly 30 lines of shell around a single send command. The table below maps each pattern to the failure it prevents and what it costs to adopt.

| Pattern | Failure it prevents | Cost |
| --- | --- | --- |
| Exit-code checks | Silent failure, false success reports | 2 lines |
| Bounded retries | Transient network and rate-limit errors | 8 lines |
| Idempotency check | Duplicate emails on retry or re-run | 10 lines + 1 extra API call |
| Draft safety net | Bad content reaching customers | Delivery latency until review |
| Audit trail | Undebuggable historical failures | One-time setup command |

Adopt them in that order. Exit-code checks and retries pay off on day one; idempotency matters once a job runs unattended; drafts and audit logs matter once the mail is customer-facing or the team is bigger than one person.

## Next steps

- [Cron job email without Postfix](https://cli.nylas.com/guides/cron-job-email-without-postfix) — the crontab setup these patterns harden
- [Handle email API outages gracefully](https://cli.nylas.com/guides/handle-email-api-outages) — backoff and queueing when the platform itself is down
- [Monitor email integration health](https://cli.nylas.com/guides/monitor-email-integration-health-cli) — scheduled health checks that catch failures before users do
- [Automate email reports from the terminal](https://cli.nylas.com/guides/automate-email-reports-terminal) — the reporting pipeline worth making reliable
- [Full command reference](https://cli.nylas.com/docs/commands) — every flag and subcommand documented
