CI/CD8 min read

Test deliverability on every PR with GitHub Actions

Your PR touches an email template or a DNS record. A GitHub Action runs a placement test. A PR comment posts the per-provider result. A broken template never merges. Here’s the workflow.

Email templates get changed by the same people who change your application code: developers, designers, marketers with PR access. They make the same kinds of mistakes too — a broken placeholder, a subject line that triggers SpamAssassin, a Mailgun domain that silently stopped signing DKIM last week. Unlike application code, there is almost never a test that catches this before merge. This article fixes that with a single GitHub Actions workflow.

What you will end up with

A .github/workflows/deliverability.yml that runs on every PR touching emails/** or infra/dns/**, triggers an Inbox Check placement test, posts a comment with the per-provider result, and fails the check if the inbox rate is under 80%. Roughly 60 lines of YAML. Secrets live in GitHub Secrets.

The workflow in one screenshot

When a developer opens a PR that edits emails/welcome.mjml, the workflow kicks off within a few seconds. About two minutes later a comment appears on the PR: Gmail 10/10 inbox, Outlook 9/10, Yahoo 8/10 — overall 89% inbox, SPF/DKIM/DMARC pass, SpamAssassin 0.8. If the inbox rate drops under the threshold, the check goes red and the merge button is blocked (via branch protection rules).

The complete workflow file

Drop this into .github/workflows/deliverability.yml. Two secrets required: INBOX_CHECK_API_KEY and the default GITHUB_TOKEN.

name: Email deliverability

on:
  pull_request:
    paths:
      - 'emails/**'
      - 'infra/dns/**'
      - '.github/workflows/deliverability.yml'

jobs:
  placement:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Render template
        id: render
        run: |
          node scripts/render-template.js emails/welcome.mjml > rendered.html
          echo "html<<EOF" >> "$GITHUB_OUTPUT"
          cat rendered.html >> "$GITHUB_OUTPUT"
          echo "EOF" >> "$GITHUB_OUTPUT"

      - name: Start placement test
        id: start
        env:
          IC_KEY: ${{ secrets.INBOX_CHECK_API_KEY }}
        run: |
          ID=$(curl -sS -X POST https://check.live-direct-marketing.online/api/check \
            -H "Authorization: Bearer $IC_KEY" \
            -H "Content-Type: application/json" \
            -d @- <<JSON | jq -r '.id'
          {
            "senderDomain": "news.yourbrand.com",
            "subject":      "Welcome to YourBrand",
            "html":         $(jq -Rs . < rendered.html)
          }
          JSON
          )
          echo "id=$ID" >> "$GITHUB_OUTPUT"

      - name: Poll for result
        id: poll
        env:
          IC_KEY: ${{ secrets.INBOX_CHECK_API_KEY }}
          ID:     ${{ steps.start.outputs.id }}
        run: |
          for i in $(seq 1 40); do
            R=$(curl -sS https://check.live-direct-marketing.online/api/check/$ID \
              -H "Authorization: Bearer $IC_KEY")
            STATUS=$(echo "$R" | jq -r .status)
            if [ "$STATUS" = "complete" ]; then
              echo "$R" > result.json
              exit 0
            fi
            sleep 5
          done
          echo "Timeout" && exit 1

      - name: Compute inbox rate
        id: rate
        run: |
          RATE=$(jq '.summary.inboxCount / .summary.total * 100 | floor' result.json)
          echo "rate=$RATE" >> "$GITHUB_OUTPUT"

      - name: Post PR comment
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const r = JSON.parse(fs.readFileSync('result.json', 'utf8'));
            const rate = ${{ steps.rate.outputs.rate }};
            const body = [
              `### Email deliverability: ${rate}% inbox`,
              `- Inbox: ${r.summary.inboxCount} / ${r.summary.total}`,
              `- Spam:  ${r.summary.spamCount}`,
              `- SPF ${r.auth.spf} · DKIM ${r.auth.dkim} · DMARC ${r.auth.dmarc}`,
              `- SpamAssassin: ${r.spamAssassinScore}`,
            ].join('\n');
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo:  context.repo.repo,
              issue_number: context.issue.number,
              body,
            });

      - name: Fail if under threshold
        if: ${{ steps.rate.outputs.rate < 80 }}
        run: |
          echo "Inbox rate ${{ steps.rate.outputs.rate }}% < 80%"
          exit 1

Triggering on template path changes

The paths filter keeps the workflow cheap. You do not want a placement test on every PR — just the ones that can plausibly change deliverability. For most teams that means:

  • emails/** — MJML, Handlebars, React Email templates, whatever your stack uses.
  • infra/dns/** — Terraform, Pulumi, or raw BIND zone files for your sending domain.
  • config/mail-from.* or similar — changes to the From address, Return-Path or DKIM selector.
  • The workflow file itself, so you can iterate without a full-tree trigger.

Using GitHub Secrets for the API key

The API key is a Bearer token that starts with ic_live_. It must never be committed. Add it via Repo → Settings → Secrets and variables → Actions → New repository secret, named INBOX_CHECK_API_KEY. Reference it with ${{ secrets.INBOX_CHECK_API_KEY }}. For monorepos with multiple environments, use an environment secret instead — that way staging and production keys are scoped correctly and deploy protection rules apply.

Polling for test completion

The workflow polls GET /api/check/{id} every 5 seconds for up to ~3 minutes. SSE is nicer for interactive dashboards but unhelpful here: the runner is short-lived and pure-HTTP polling is trivial to retry. If your templates are heavy and take longer (some MJML → HTML + image-hosting pipelines do), extend the loop to 60 attempts.

Posting a PR comment with results

The actions/github-script step uses the default GITHUB_TOKEN with pull-requests: write. No additional secret required. The comment is a fresh one per run — if you prefer a sticky comment that updates in place, use marocchino/sticky-pull-request-comment instead:

      - uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: deliverability
          message: |
            ### Email deliverability: ${{ steps.rate.outputs.rate }}% inbox
            ...

Failing the check if inbox rate is under 80%

The last step exits non-zero when the rate is below 80%. Pair that with a branch protection rule requiring the placement check to pass before merge, and a broken template cannot reach main. Pick a threshold that matches your current baseline — starting at 70% on a sloppy repo, tightening to 85% after a month of cleanup.

Set the threshold below your current average

A threshold above your baseline inbox rate will red-check every PR and train developers to ignore the status. Start below and tighten over time. A check that always passes means nothing; a check that always fails means less.

Caching

Two opportunities to shave seconds. First, actions/setup-node with a cache: "npm" step if your template render depends on Node modules. Second, a hashFiles-keyed cache of the rendered HTML — if the template hash matches a prior run, reuse the previous placement result and skip the API call entirely. That saves both runner minutes and API quota on no-op PRs (README changes, for instance, that somehow trigger the paths filter).

Reusable workflow pattern for monorepos

If you have multiple apps each owning their own email templates, extract the workflow to a reusable one:

# .github/workflows/deliverability-reusable.yml
on:
  workflow_call:
    inputs:
      template_path: { required: true, type: string }
      sender_domain: { required: true, type: string }
    secrets:
      INBOX_CHECK_API_KEY: { required: true }

jobs:
  placement:
    uses: ./.github/workflows/deliverability.yml
    with:
      template_path: ${{ inputs.template_path }}
      sender_domain: ${{ inputs.sender_domain }}

Each app's workflow then calls the reusable one:

jobs:
  welcome:
    uses: org/infra/.github/workflows/deliverability-reusable.yml@main
    with:
      template_path: apps/billing/emails/welcome.mjml
      sender_domain: billing.yourbrand.com
    secrets:
      INBOX_CHECK_API_KEY: ${{ secrets.INBOX_CHECK_API_KEY }}

A reusable Marketplace action (planned)

We're packaging all of the above as inbox-check/placement-test@v1, planned for September 2026. The action will collapse the four-step workflow into a single step with inputs for sender domain, template path and threshold, and will handle sticky-comment updates and caching out of the box. The raw-YAML version above will keep working — the action is a convenience, not a replacement.

Frequently asked questions

Does this consume API quota on every PR?

Yes, one test per run. On a typical product repo the emails directory changes maybe ten times a month, so quota is a non-issue. Use the caching trick above to skip re-runs on identical rendered HTML.

Can I run this on pushes to main instead of PRs?

Yes — swap the on: block to push: with a branches filter. Better still, run on both: PRs for early feedback, main for a canary signal you can wire to an on-call alert.

What about secrets in forks?

GitHub does not expose secrets to workflow runs triggered by forked-PR events, for obvious reasons. Use pull_request_target with a manual label gate if you need deliverability checks on external contributions.

Why not just test locally?

You can — the same API call works from a pre-commit hook — but CI catches the template one branch over that you did not render locally, and the PR comment gives reviewers a visible signal without running anything themselves.
Related reading

Check your deliverability across 20+ providers

Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail and more. Real inbox screenshots, SPF/DKIM/DMARC, spam engine verdicts. Free, no signup.

Run Free Test →

Unlimited tests · 20+ seed mailboxes · Live results · No account required