Testing Email Deliverability: SPF, DKIM, and DMARC Validation in CI

Testing Email Deliverability: SPF, DKIM, and DMARC Validation in CI

SPF, DKIM, and DMARC misconfigurations are the most common reason transactional emails land in spam. This guide shows how to validate these DNS records in CI so you catch deliverability regressions before they reach production users. We cover automated DNS checks, DKIM signature verification, and integration with GitHub Actions.

Why Deliverability Testing Belongs in CI

Most teams test email content (does the subject render correctly?) but not email deliverability (will this email reach the inbox?). The gap is dangerous.

A single misconfigured SPF record or expired DKIM key can silently move your transactional emails—password resets, verification links, invoices—from inbox to spam folder for all users. You won't know until users start complaining that they never received the email.

The root cause is almost always a DNS record change: someone updates the SPF include set and accidentally removes a sending server, or a third-party email provider rotates DKIM keys and forgets to notify you. These changes don't show up in application code—they show up in DNS, which CI doesn't normally check.

This guide adds deliverability validation to your CI pipeline so DNS misconfigurations fail the build before they reach production.

Understanding the Three Records

SPF (Sender Policy Framework)

SPF tells receiving mail servers which IP addresses are authorized to send email on behalf of your domain. It's a TXT record:

v=spf1 include:sendgrid.net include:amazonses.com ip4:203.0.113.10 ~all

Breaking this down:

  • include:sendgrid.net — SendGrid's servers are authorized
  • include:amazonses.com — AWS SES servers are authorized
  • ip4:203.0.113.10 — this specific IP is authorized
  • ~all — all other servers are a "soft fail" (suspicious but not rejected)
  • -all — all other servers are a "hard fail" (rejected)

Common issues: forgetting to add a new sending service, exceeding the 10 DNS lookup limit, using -all before you've verified all sending paths.

DKIM (DomainKeys Identified Mail)

DKIM adds a cryptographic signature to outgoing emails that receiving servers can verify against a public key in your DNS. It proves the email wasn't modified in transit and came from an authorized sender.

# DNS record (TXT)
selector._domainkey.yourdomain.com  →  v=DKIM1; k=rsa; p=MIGfMA0GCS...

# Email header
DKIM-Signature: v=1; a=rsa-sha256; d=yourdomain.com; s=selector;
  b=base64signature; bh=bodyhash; h=from:subject:date

Common issues: key rotation without updating DNS, wrong selector in the signature header, duplicate From headers that break signature validation.

DMARC (Domain-based Message Authentication, Reporting & Conformance)

DMARC tells receiving servers what to do when SPF or DKIM checks fail, and where to send failure reports:

v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com; pct=100

Policies:

  • p=none — monitor only, take no action
  • p=quarantine — send to spam folder
  • p=reject — reject the email entirely

Common issues: policy set to none and forgotten there, no reporting address configured so failures are silent, subdomain policy not set separately.

Validating DNS Records in CI

Node.js DNS Check Script

Install the dependencies:

npm install --save-dev dns-txt checkdmarc

Create a validation script:

// scripts/check-email-dns.js
const dns = require('dns').promises;

const DOMAIN = process.env.EMAIL_DOMAIN || 'yourapp.com';
const DKIM_SELECTORS = (process.env.DKIM_SELECTORS || 'mail,sg').split(',');

async function checkSPF() {
  const records = await dns.resolveTxt(DOMAIN);
  const spfRecord = records
    .flat()
    .find(r => r.startsWith('v=spf1'));

  if (!spfRecord) {
    throw new Error(`No SPF record found for ${DOMAIN}`);
  }

  console.log(`✓ SPF: ${spfRecord}`);

  // Check for known senders
  const requiredIncludes = (process.env.REQUIRED_SPF_INCLUDES || '').split(',').filter(Boolean);
  for (const include of requiredIncludes) {
    if (!spfRecord.includes(include)) {
      throw new Error(`SPF record missing required include: ${include}`);
    }
    console.log(`  ✓ includes ${include}`);
  }

  // Warn if ~all is missing (records ending without explicit policy)
  if (!spfRecord.includes('~all') && !spfRecord.includes('-all') && !spfRecord.includes('?all') && !spfRecord.includes('+all')) {
    throw new Error('SPF record has no policy qualifier (~all, -all, etc.)');
  }

  return spfRecord;
}

async function checkDKIM() {
  const results = [];

  for (const selector of DKIM_SELECTORS) {
    const host = `${selector}._domainkey.${DOMAIN}`;
    try {
      const records = await dns.resolveTxt(host);
      const dkimRecord = records.flat().join('');
      
      if (!dkimRecord.includes('v=DKIM1')) {
        throw new Error(`DKIM record at ${host} is malformed: ${dkimRecord}`);
      }
      if (!dkimRecord.includes('p=')) {
        throw new Error(`DKIM record at ${host} missing public key (p=)`);
      }
      if (dkimRecord.includes('p=;') || dkimRecord.includes('p= ')) {
        throw new Error(`DKIM record at ${host} has empty public key — key was revoked or not yet set`);
      }

      console.log(`✓ DKIM selector ${selector}: found and valid`);
      results.push({ selector, valid: true });
    } catch (err) {
      if (err.code === 'ENOTFOUND') {
        throw new Error(`DKIM selector ${selector} not found at ${host}`);
      }
      throw err;
    }
  }

  return results;
}

async function checkDMARC() {
  const host = `_dmarc.${DOMAIN}`;
  const records = await dns.resolveTxt(host);
  const dmarcRecord = records.flat().join('');

  if (!dmarcRecord.startsWith('v=DMARC1')) {
    throw new Error(`No valid DMARC record at ${host}`);
  }

  console.log(`✓ DMARC: ${dmarcRecord}`);

  // Warn on none policy in production
  if (process.env.CI_ENVIRONMENT === 'production' && dmarcRecord.includes('p=none')) {
    console.warn('⚠ DMARC policy is none — emails will not be rejected on failure');
  }

  return dmarcRecord;
}

async function main() {
  console.log(`Checking email DNS for ${DOMAIN}...\n`);

  const failures = [];

  for (const check of [checkSPF, checkDKIM, checkDMARC]) {
    try {
      await check();
    } catch (err) {
      failures.push(err.message);
      console.error(`✗ ${err.message}`);
    }
  }

  if (failures.length > 0) {
    console.error(`\n${failures.length} check(s) failed`);
    process.exit(1);
  }

  console.log('\nAll email DNS checks passed');
}

main();

GitHub Actions Integration

# .github/workflows/email-dns-check.yml
name: Email DNS Validation

on:
  push:
    branches: [main]
  schedule:
    # Run daily at 06:00 UTC — catches external DNS changes
    - cron: '0 6 * * *'
  workflow_dispatch:

jobs:
  check-email-dns:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          
      - run: npm ci
      
      - name: Validate email DNS records
        run: node scripts/check-email-dns.js
        env:
          EMAIL_DOMAIN: ${{ vars.EMAIL_DOMAIN }}
          DKIM_SELECTORS: ${{ vars.DKIM_SELECTORS }}
          REQUIRED_SPF_INCLUDES: ${{ vars.REQUIRED_SPF_INCLUDES }}
          CI_ENVIRONMENT: production

The scheduled run is important: DNS changes happen outside your deployment pipeline (infrastructure changes, third-party service updates), so you need a check that runs independently of code changes.

DKIM Signature Verification

DNS validation confirms the public key exists, but doesn't verify the signing side. To test that your sending server is actually signing outgoing emails correctly, use a mail-testing inbox and verify the DKIM header:

// test/email-deliverability.test.js
const nodemailer = require('nodemailer');
const mailtrap = require('./helpers/mailtrap');

it('outgoing email has valid DKIM signature header', async () => {
  // This test requires your actual sending infrastructure,
  // not the sandbox SMTP — configure with real sending credentials
  const transporter = nodemailer.createTransport({
    host: process.env.SMTP_HOST,
    port: 587,
    auth: {
      user: process.env.SMTP_USER,
      pass: process.env.SMTP_PASS,
    },
  });

  await transporter.sendMail({
    from: 'test@yourapp.com',
    to: process.env.MAILTRAP_TEST_ADDRESS,
    subject: 'DKIM validation test',
    text: 'Test email for DKIM verification',
  });

  const message = await mailtrap.waitForEmail(process.env.MAILTRAP_TEST_ADDRESS, 10000);
  
  // Mailtrap includes headers in the message metadata
  expect(message.is_read).toBeDefined(); // Message was received

  // Check via Mailtrap's email analytics for DKIM pass
  const fullMessage = await mailtrap.getMessage(message.id);
  expect(fullMessage.email_size).toBeGreaterThan(0);
});

For more thorough DKIM validation, use the mailauth library:

npm install mailauth
const { authenticate } = require('mailauth');

async function verifyDKIM(rawEmailBuffer) {
  const result = await authenticate(rawEmailBuffer, {
    ip: '203.0.113.10',         // IP of the sending server
    helo: 'mail.yourapp.com',    // HELO hostname
    sender: 'test@yourapp.com',  // MAIL FROM
  });

  expect(result.dkim.status.result).toBe('pass');
  expect(result.spf.status.result).toBe('pass');
  expect(result.dmarc.status.result).toBe('pass');
}

Bounce Handling Tests

SPF/DKIM/DMARC validation prevents your emails from being flagged as spam. Bounce handling prevents your sender reputation from degrading when emails are undeliverable.

Test that your application handles bounce notifications correctly:

// test/bounce-handling.test.js
const request = require('supertest');
const app = require('../src/app');

describe('SES bounce webhook', () => {
  it('marks user email as undeliverable on hard bounce', async () => {
    const bouncePayload = {
      Type: 'Notification',
      Message: JSON.stringify({
        notificationType: 'Bounce',
        bounce: {
          bounceType: 'Permanent',
          bouncedRecipients: [{ emailAddress: 'bounce@example.com' }],
        },
      }),
    };

    const response = await request(app)
      .post('/webhooks/ses')
      .send(bouncePayload)
      .set('Content-Type', 'application/json');

    expect(response.status).toBe(200);

    // Verify user is marked as undeliverable in DB
    const user = await User.findByEmail('bounce@example.com');
    expect(user.emailDeliverable).toBe(false);
  });

  it('does not block emails on soft bounce', async () => {
    const bouncePayload = {
      Type: 'Notification',
      Message: JSON.stringify({
        notificationType: 'Bounce',
        bounce: {
          bounceType: 'Transient',
          bouncedRecipients: [{ emailAddress: 'temp@example.com' }],
        },
      }),
    };

    await request(app)
      .post('/webhooks/ses')
      .send(bouncePayload)
      .set('Content-Type', 'application/json');

    const user = await User.findByEmail('temp@example.com');
    expect(user.emailDeliverable).toBe(true); // Soft bounce = don't block
  });
});

End-to-End Deliverability Testing

Automated DNS checks and unit tests catch configuration and code-level bugs. For full-stack verification—does the actual email sent by the production sending path pass spam filters—run periodic live tests using a seed inbox from a deliverability testing service like GlockApps or Mailtrap's inbox placement feature.

These tests send real email to real inboxes across Gmail, Outlook, Yahoo, and Apple Mail, then report where each copy landed (inbox, spam, or missing). Running this after any SPF/DKIM/DMARC change confirms no regression.

Integration With Application Monitoring

For production monitoring of email delivery beyond CI, HelpMeTest health checks can monitor your email-dependent workflows continuously—sign up flow, password reset flow, notification delivery—alerting you the moment something breaks rather than waiting for user reports.

Summary

  • SPF, DKIM, and DMARC misconfigurations are the leading cause of email landing in spam
  • Add DNS validation to CI with Node.js scripts and GitHub Actions
  • Run DNS checks on a schedule, not just on code push—DNS changes happen outside your repo
  • Verify DKIM signatures with mailauth against raw email content
  • Test bounce handling at the webhook layer to protect sender reputation
  • Use Mailtrap or similar for spam score validation in pre-production environments

Read more