---
title: Email automation as code
metaTitle: Email automation as code: sequences in git, deployed from CI
description: A lifecycle sequence is a program that decides who gets mailed. Here is what it looks like as a YAML file in your repository, reviewed in a pull request and pushed from CI.
date: 2026-07-30
author: emails.sh
tags: Architecture
---

Your onboarding sequence decides who gets mailed, when, and what it says. It has branches. It holds state per person for weeks. It runs against more people than any single deploy of your application does. And on almost every platform it lives in a canvas in a dashboard, editable by anyone with a login, with no diff, no review and no way to tell what changed last Tuesday.

That is a strange place to keep a program.

## The failure this prevents

Nobody breaks a lifecycle sequence dramatically. What happens is smaller and worse: someone shortens a wait from three days to three hours while testing and forgets to change it back. Someone points a step at the wrong template. Someone deletes a branch that looked redundant. Six weeks later the numbers are off and nobody can say what changed, because the record of what the sequence used to be does not exist.

A file in git fixes this the same way it fixes it everywhere else. You can see the change, someone else can look at it before it ships, and you can go back.

## What the file looks like

An automation is a name, a trigger, an optional filter on that trigger, and a list of steps. Each step has an id and does exactly one thing: it sends, it waits, or it branches. Steps flow into the one written underneath them, so a linear sequence needs no wiring at all and the file reads top to bottom in the order the mail goes out.

```
name: Trial nudges
description: Three touches over the first week of a trial.
trigger: contact.subscribed
when:
  audience: Trials
reentry: once
steps:
  - id: welcome
    do: send_template
    with:
      template: trial-welcome

  - id: wait_for_open
    wait:
      for: email.opened
      timeout: 3 days

  # Somebody already on a paid plan should not be nudged about the trial.
  - id: still_trialing
    if:
      all:
        - attributes.plan = trial
        - tags not_contains churned
    yes: nudge
    no: tag_converted

  - id: tag_converted
    do: add_tag
    with:
      tag: converted
    next: []

  - id: nudge
    do: send_template
    with:
      template: trial-day-3

  - id: wait_3d
    wait: 3 days

  - id: last_call
    do: send_template
    with:
      template: trial-day-7
```

A few things in there are deliberate.

**Names, not identifiers.** A step names a template by slug and an audience by name. A uuid in a diff tells a reviewer nothing, and the whole point is that a reviewer can read this.

**A condition is one line.** `attributes.plan = trial` rather than a three-key mapping. Ten mappings is unreadable in a review; ten lines is not.

**Comments survive.** Editing the flow on the canvas re-serialises the document, and the serialiser carries your comments across by key and by step id. The paragraph explaining why the third email waits five days is the most valuable thing in the file, so deleting it because somebody dragged a box would be the worst possible behaviour.

## The loop

```bash
# Pull, at any point.
curl -sS -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  https://emails.sh/v1/automations/$ID.yaml -o automations/$ID.yaml

# Push, on merge.
curl -sS --fail-with-body -X PUT \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/yaml" \
  --data-binary "@automations/$ID.yaml" \
  https://emails.sh/v1/automations/$ID.yaml
```

The property that makes this work rather than merely function is that a document you push is stored as the bytes you sent. It is parsed and validated, and then kept verbatim rather than round-tripped through the writer. If a push came back reformatted, every subsequent pull would produce a diff nobody made, and within a fortnight your team would stop reading them.

The response carries the version you are now on in a header, so CI can tell whether anybody edited the automation in the dashboard since the last push. Be aware that a push is last-write-wins: the header lets you detect drift, but the server will not refuse your write because of it.

## What this is not

It is not infrastructure as code in the Terraform sense. There is no plan step, no dependency graph, no drift reconciliation, and no CLI yet: the endpoints are the interface and curl is how you drive them. An `emails automations pull` and `push` pair is the obvious next thing and it is not written.

It is also not a general programming language. The action list is closed at eleven entries and there is no "run this code" among them, so the worst a flow pasted in from an assistant can do is send a template, tag somebody, move them between lists, or call a webhook. A new automation is created disabled whatever the file says, so pushing one cannot start mailing people on merge.

## Why an assistant makes this matter more

The reason to have a text representation used to be review. Now it is also authorship. An assistant can write a trial sequence into your repository, and you read the diff instead of watching it click through a canvas you cannot audit. Errors from the parser name the wrong thing, say what to write instead, and give a line number, which is what lets it correct itself without asking you.

## Questions

### Can I keep a sequence in git on other platforms?

Resend's CLI can create an automation from a JSON file, which is the closest anyone else comes. It documents no pull and no idempotent re-apply, so it is a create path rather than a round trip. SendGrid and Brevo have mature automations with no text representation at all, not even an export. Postmark and Mailgun have no sequences to encode, though Postmark does support templates in git through its CLI.

### What happens to people mid-sequence when I deploy a change?

They finish on the version they started on. A new version applies to runs that begin after it, so nobody three days into a seven-day sequence jumps into a different flow because a pull request merged.

### Can the same automation be edited on the canvas?

Yes. The canvas and the file are two views of one stored document, and a change made in the browser shows up in the next pull. The dashboard's code tab parses on every keystroke with the same parser the API uses, so a mistake there is a message with a line number rather than a failed save.

### How accurate are the waits?

The scheduler sweeps every five minutes, so a step happens within five minutes of its time rather than to the second. Times of day are UTC and the granularity is the hour.

## Related

- [Transactional and marketing email in one API](/blog/transactional-and-marketing-email-one-api)
- [Automations](/features/automations)
- [Automations as code](/features/automations-as-code)
