The account as code

Automations as YAML in your repository, the rest as Terraform, both deployed from CI with a diff a reviewer can approve.

An automation round-trips losslessly to YAML, so a lifecycle sequence lives in your git repository, gets reviewed in a pull request, and deploys from CI. This chapter is the rest of that idea: a plan command that shows what a deploy would do, a GitHub Action that runs it on a pull request and comments the diff, and a Terraform provider for the parts of the account that are not automations.

Why it is built this way: an assistant writes YAML and Terraform far more reliably than it clicks through a dashboard, and a change that arrives as a diff is one a person can actually approve before it mails anybody. A worked repository is at https://github.com/emailssh/emails.sh/tree/main/example-account-as-code.

What belongs in which

ThingManaged by
AutomationsYAML files plus the Action, or the Terraform provider. One or the other, never both on the same document.
Sending domains and their DNS recordsTerraform. The records come out as an attribute you feed to your own DNS provider.
API keysTerraform.
Webhook endpointsTerraform.
Topics and audiencesTerraform.
TemplatesNeither. They have a draft, version, and publish lifecycle that a create-or-replace resource would flatten.
Contacts, suppressions, broadcastsNeither. People are not configuration, a suppression is a record of what happened, and a broadcast is an event.

emails automations plan

The shape terraform plan has, for the same reason: what a reviewer approves is the diff, not the intention. It reads every document in a directory, reads the workspace, prints the difference, and writes nothing. Safe to run against production from a laptop.

Reads the directory and the workspace, writes nothing
npx @emails.sh/cli automations plan automations
What a plan looks like
automations against this workspace

+ create                renewal-reminder            automations/renewal-reminder.yaml
~ update                trial-nudges                automations/trial-nudges.yaml (remote v4)
        - id: wait_3d
    -     wait: 3 days
    +     wait: 5 days

  no change             onboarding-checkpoint       automations/onboarding-checkpoint.yaml (remote v2)
? not in this directory  legacy-drip                exists on the workspace at v7, no document here

Plan: 1 to create, 1 to update, 1 unchanged.

An automation is matched to a document by slug, and the slug is derived from the name key inside the file rather than from the filename. That is what the API itself does on every save, so matching any other way would drift. A file whose name does not agree with its filename is called out in the plan, because the person reading the diff is looking at a filename that is not the name of the thing being changed.

Running apply twice does nothing the second time. The API stores the exact bytes it was sent rather than a re-serialisation of them, so byte equality is an exact test for "already deployed" and a document that matches is not written again. No version is created, and your history stays a record of real edits.

Exit codeWhat it means
0Every document was read. There may or may not be changes.
1A document could not be read. Nothing would be applied.
2Under --detailed-exitcode only: read cleanly, changes pending.

Add --format markdown for the pull request comment form: a summary line, a table, and each diff in a collapsed block. That is what the Action posts.

emails automations apply

The plan, then the writes it described. It creates what is missing and updates what differs. It never deletes: an automation on the workspace with no document in the directory is reported and left alone, because a directory of files is not a statement that nothing else may exist, and somebody’s first canvas experiment should survive a CI run.

It is all or nothing as far as this API allows. Every document is parsed before anything is sent, the remote bytes of everything about to change are captured first, and a refusal partway through puts back what was already written. A half-deployed lifecycle sequence is worse than an undeployed one, because half of it will still mail people. The rollback is itself a write, so a rolled-back automation gains a version rather than losing one.

The GitHub Action

On a pull request it plans and comments. On a push it applies. One workflow, because the action reads the event and decides, and splitting them means two files to keep in step.

.github/workflows/automations.yml
name: emails.sh automations

on:
  pull_request:
    paths: ['automations/**']
  push:
    branches: [main]
    paths: ['automations/**']

permissions:
  contents: read
  pull-requests: write

jobs:
  automations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: emailssh/emails.sh/.github/actions/automations@v1
        with:
          api-key: ${{ secrets.EMAILSSH_API_KEY }}
          directory: automations

The comment is one comment per pull request, edited in place on every push, so a branch with twenty commits does not produce twenty notifications. The plan also goes to the job summary and the job log.

InputWhat it does
api-keyRequired. A key with the workspace scope is enough, and is what you want: the action never sends mail.
directoryWhere the documents are. Defaults to automations.
modeplan, apply, or auto. auto is the default and plans on a pull request, applies on anything else.
commentWhether to write the pull request comment. Defaults to true.
fail-on-changesFails the job when a plan has pending changes. Use it on a branch that is supposed to be deployed already, so drift is caught rather than reported.
cli-versionThe version of @emails.sh/cli to run. Pin it once you are past the first week.
base-urlFor a staging deployment.

A document that cannot be read fails the job with the file and the line number, and the comment carries the same. Nothing is applied, including the documents beside it that were fine. That is the guarantee: a directory deploys whole or not at all.

What plan checks locally is the top level of the document: a tab in the indentation, a key written twice, a missing or empty name, a missing trigger, a steps key with nothing under it. Everything past that is checked by the API, which owns those rules, and it answers with a code, a line number, and a sentence saying what to write instead. So a document that is well-formed at the top and wrong further down is refused at apply time rather than at plan time, and apply rolls back rather than half-deploying. The alternative was a second validator in the CLI that would drift from the real one, and a plan that passes against an apply that fails is worse than no plan.

The Terraform provider

Provider configuration
terraform {
  required_providers {
    emailssh = {
      source  = "emailssh/emailssh"
      version = "~> 0.1"
    }
  }
}

# Reads EMAILSSH_API_KEY from the environment, so the key never reaches a
# .tfvars file. Create one at https://emails.sh/dashboard/api-keys.
provider "emailssh" {}
ResourceWhat it manages
emailssh_domainA sending domain, and its DNS records as an output. Every attribute is ForceNew: the API cannot rename a domain.
emailssh_api_keyA key. The secret is readable once, at creation, so it lives in Terraform state and nowhere else.
emailssh_webhookA delivery endpoint, with full in-place updates. The signing secret is returned once.
emailssh_automationOne document, from yaml_file or an inline yaml string.
emailssh_topicA subscription group. key is ForceNew, because the API refuses to change one.
emailssh_audienceA list.

DNS records as an output

This is the part worth the whole provider. Verifying a domain is normally a screen you copy three DKIM records out of by hand, once per environment. Here the records are an attribute of the domain and an input to your own DNS provider, in one apply.

The records, created wherever your DNS lives
resource "emailssh_domain" "acme" {
  domain = "acme.com"
}

resource "cloudflare_dns_record" "emailssh" {
  for_each = {
    for r in emailssh_domain.acme.dns_records : "${r.type}/${r.name}" => r
    if !r.optional
  }

  zone_id  = var.cloudflare_zone_id
  name     = each.value.name
  type     = each.value.type
  content  = each.value.content
  priority = each.value.type == "MX" ? each.value.priority : null
  ttl      = 300
  proxied  = false
}
Field on a recordWhat it is
typeMX, TXT, or CNAME.
nameThe fully qualified record name, for example _emailssh.acme.com.
valueThe record exactly as the API states it. For MX this includes the priority inline.
contentvalue with the MX priority removed, for providers that take priority as its own argument. Identical to value for every other type.
priorityThe MX priority, or 0.
purposeDKIM, ownership, SPF, DMARC, or inbound.
noteGuidance, for example how to merge with an SPF record you already publish.
optionalTrue when the record is not needed for verification. The inbound MX on an apex domain is optional because publishing it takes delivery of mail for the whole domain.

The caveats, in full

Terraform does not create your DNS records
It creates them wherever you point it, which is a different thing. emails.sh does not control your zone and never will. If your registrar has no Terraform provider, read terraform output dns_records and paste.
DKIM records arrive a moment late
The three CNAMEs are issued when the domain is registered upstream, not when the row is created, so the first apply can return a list without them and dkim_records_pending set to true. Apply again. Until they are published, mail from the domain is unsigned and will be filtered.
Drift reverts on the next apply
terraform plan reads the workspace, so a dashboard edit shows as a diff and the next apply overwrites it. For an automation that means your file is pushed over whatever was drawn on the canvas, and the canvas edit becomes a version in the history rather than being lost: emails automations pull recovers it.
A revoked key comes back as a different key
A key secret is readable exactly once. If one is revoked outside Terraform, the plan shows it as gone and the apply mints a new one with a new secret, which every service holding the old one then needs.
Secrets live in state
API keys and webhook signing secrets are returned once and kept in the state file, because there is nowhere else for them to be. Use a remote backend that encrypts state.
Two ways to deploy an automation, and they do not mix
The Action and emailssh_automation both own a document. Managing one document with both means every apply reverts the other. Pick one per automation.
Nothing here deletes an automation
apply creates and updates. Removing a file does not remove the automation, it reports it as unmanaged. Delete deliberately, with emails automations or the dashboard.