Automating Email QA with GitHub Actions: CI/CD Patterns for Support, Sales, and Ops Teams
How to build a CI/CD pipeline for outbound email — template versioning, PR reviews, staged rollouts, and automated linting. A playbook for support, sales, and operations teams treating email as a production surface.
The Untested Deployment Path Called "Send"
Most engineering teams would never ship a code change without CI. Every push runs tests, style checks, security scans, and typically a preview build. Yet those same teams routinely send high-stakes customer emails — bug notifications, incident RCAs, pricing updates, product launches — with a workflow that amounts to "one person types it into Gmail and hits send."
That gap is where CI for email comes in. This piece is a practical playbook for support, sales, and operations teams that want to treat outbound email as a production surface — versioned in Git, reviewed in pull requests, and validated by GitHub Actions before it reaches a customer.
The Core Repo Structure
Start with a dedicated repo. Suggested layout:
```
email-ops/
├── templates/
│ ├── incident/
│ ├── billing/
│ ├── product-launch/
│ └── support/
├── data/
│ ├── audiences/
│ ├── merge-fields/
│ └── schedules/
├── scripts/
│ ├── render.js
│ ├── validate.js
│ ├── send.js
│ └── audit.js
├── .github/
│ └── workflows/
│ ├── lint.yml
│ ├── preview.yml
│ ├── send-scheduled.yml
│ └── send-manual.yml
└── README.md
```
Templates live in Markdown or MJML with YAML frontmatter for subject, sender, and audience. Audience data lives in CSVs or references your CRM's API. Scripts do the rendering, validation, and sending. Workflows tie it all together.
Workflow 1: PR Lint on Every Template Change
The most valuable single workflow is a lint that runs on every PR that touches a template. It should include:
- Frontmatter validation (subject present, sender valid, audience defined).
- Markdown syntax check.
- Merge-tag validation (every `{{var}}` in the body maps to a declared merge field).
- Link check (all URLs resolve, all internal links exist).
- Spell check with a per-repo custom dictionary.
- Compliance check (required disclaimers present for regulated audiences).
- Presend intelligence pass (voice, slop, tone, sensitivity).
A failing lint blocks merge. The PR review focuses on substance because the mechanical issues are already gone.
Workflow 2: Preview Renders in the PR
For every PR, a workflow renders each changed template against synthetic merge data and posts the rendered output as an artifact — or better, uploads it to an ephemeral URL and comments the link on the PR.
```yaml
- name: Render preview
run: node scripts/render.js --data data/synthetic.json --out ./preview
- name: Upload preview
uses: actions/upload-artifact@v4
with:
name: rendered-emails
path: ./preview/**
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const url = 'https://previews.company.com/pr-${{ github.event.number }}';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `📧 Rendered preview`
});
```
Reviewers can now see exactly what the recipient will see, with realistic (but synthetic) merge data.
Workflow 3: Staged Rollouts
Not every email should go to the full audience on the first send. Especially for high-blast-radius emails (pricing changes, incident notifications, product deprecations), a staged rollout is the safer pattern:
```yaml
name: send-staged
on: workflow_dispatch:
inputs:
template:
required: true
stage:
type: choice
options: [canary, ten-percent, full]
required: true
jobs:
send:
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.stage == 'full' && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
- name: Select audience
run: node scripts/select-audience.js --stage ${{ github.event.inputs.stage }}
- name: Render
run: node scripts/render.js
- name: Pre-send intelligence
run: npx @presend/cli check ./out/*
- name: Send
run: node scripts/send.js
env:
POSTMARK_TOKEN: ${{ secrets.POSTMARK_TOKEN }}
- name: Wait and monitor
run: node scripts/monitor.js --window 30m
```
The workflow supports three stages: canary (10 internal recipients), 10% (statistically representative sample), full (remainder). Each stage requires a separate manual trigger. If bounce rates, spam complaints, or reply sentiment exceed thresholds after a stage, the workflow blocks progression to the next stage.
Workflow 4: Scheduled Sends with Approval Gates
Recurring communications — weekly changelogs, monthly newsletters, quarterly updates — run on cron with a mandatory approval before send.
```yaml
name: monthly-newsletter
on:
schedule:
- cron: '0 15 1 * *' # 1st of month, 15:00 UTC
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- name: Gather content
run: node scripts/collect-newsletter.js
- name: Render draft
run: node scripts/render.js templates/newsletter/monthly.md
- name: Open PR with draft
run: gh pr create --title "Monthly newsletter — $(date +%Y-%m)" --body "Auto-generated draft. Review and merge to send."
```
The scheduled run does not send — it *opens a pull request* with the drafted content. A human reviews, edits, and merges. A second workflow triggered on merge does the actual send.
This pattern gives you the timing benefits of cron with the review benefits of PR-based approval.
Workflow 5: Support Auto-Response with Safety Net
For high-volume support inboxes, an LLM-drafted auto-response pipeline with a safety net:
1. Ticket arrives; helpdesk webhook fires `repository_dispatch`.
2. Workflow loads ticket text, retrieves relevant KB articles from a vector store.
3. LLM drafts a candidate reply.
4. Presend intelligence layer scores draft on voice, slop, tone, safety.
5. If score ≥ threshold, workflow sends automatically.
6. If score < threshold, workflow files a Linear ticket for human review.
7. All sends are logged with the draft, the score, the final content, and the outcome.
This gives you the scale benefits of automation with the safety net of human escalation on borderline cases.
Secrets and Environment Isolation
Every workflow that sends should:
- Use GitHub Environments to gate access to production ESP credentials.
- Store ESP tokens (Postmark, SendGrid, Resend), LLM API keys, and audit-log write credentials in encrypted secrets.
- Require named reviewers for the `production` environment.
- Emit structured logs to a durable store (S3, BigQuery) with the send content, timestamp, actor, and workflow SHA.
Observability: What to Track
At minimum:
- Send volume per workflow per day.
- Bounce rate per template.
- Spam complaint rate per template.
- Reply sentiment (positive/neutral/negative) per template, if the template invites replies.
- Time from draft PR to send.
- Number of workflow reruns per template (a proxy for template stability).
Dashboards in Grafana or Metabase, sourced from your log store. Weekly review by the ops team.
Anti-Patterns
- Sending directly from a workflow triggered on push to main. Always require a second manual step (`workflow_dispatch` or environment approval) to actually send.
- Storing PII in the repo for test data. Use synthetic data generators.
- Skipping the lint step "for hotfixes." Hotfixes are exactly when linting matters most.
- One workflow that both drafts and sends. Separate. Drafts get reviewed in PRs; sends happen in a distinct, approval-gated workflow.
- No logging. If you cannot answer "who sent what, when, to whom" in a query, you have no audit story.
The Team Culture Shift
Migrating email to GitHub Actions is not just a tooling change. It changes the team's relationship to outbound communication:
- Support teams start writing better tickets because their reply templates get PR-reviewed.
- Product managers start asking for template changes as they would ask for a feature — with an issue, a scope, and acceptance criteria.
- Marketing starts A/B testing subject lines as engineering feature flags.
The version-controlled repository becomes the source of truth for "how we communicate," and every improvement is captured, reviewed, and preserved.
Getting Started
If you have never done this, start small. Pick one template — usually the incident-notification email — and move it into a repo. Add a lint workflow. Send from the workflow with a manual dispatch. Live with it for a month. Then expand.
The end state is that your customer-facing email has the same version control, review discipline, and automated safety net as your production code. Which, given that email is often the first thing a customer reads from your company, is where it belonged all along.