Email Deliverability: SPF, DKIM, DMARC, and Getting Your Emails to the Inbox
Fix email deliverability with SPF, DKIM, and DMARC setup, IP warm-up, bounce handling, and list hygiene. Includes DNS records, code examples, and sender reputat
Email Deliverability: SPF, DKIM, DMARC, and Getting Your Emails to the Inbox
Email deliverability is binary from a user's perspective: either they got it or they didn't. But the systems that determine inbox vs. spam are complex, and most teams don't think about them until they're staring at a 30% delivery rate on a critical campaign.
This guide covers the full deliverability stack โ authentication records, sender reputation, bounce handling, and list hygiene โ with the specific configurations that move emails from spam to inbox.
Why Emails Go to Spam
Gmail, Outlook, and other providers use a combination of signals to score every incoming email:
| Signal Category | Examples | Weight |
|---|---|---|
| Authentication | SPF pass/fail, DKIM signature, DMARC policy | Very High |
| Sender reputation | IP reputation, domain age, complaint history | Very High |
| Engagement | Open rate, click rate, reply rate, not-spam reports | High |
| Content | Spam trigger words, HTML quality, text-to-image ratio | Medium |
| List quality | Bounce rate, unsubscribe rate, spam traps | Medium |
| Volume patterns | Sudden volume spikes, sending from new IPs | Medium |
Authentication failures (missing SPF, no DKIM) are the fastest path to the spam folder and the easiest to fix.
Step 1: SPF (Sender Policy Framework)
SPF tells receiving mail servers which IP addresses are allowed to send email for your domain.
DNS TXT record for yourdomain.com:
; Allow Sendgrid, AWS SES, and your own mail server to send on behalf of your domain
v=spf1 include:sendgrid.net include:amazonses.com ip4:203.0.113.10 -all
Components:
v=spf1โ SPF versioninclude:sendgrid.netโ authorize SendGrid's IP rangesinclude:amazonses.comโ authorize AWS SES IP rangesip4:203.0.113.10โ authorize a specific IP (your own mail server)-allโ reject all other senders (hard fail)- Use
~all(soft fail) initially while testing โ less likely to accidentally block legitimate email
- Use
SPF limits: Maximum 10 DNS lookups per SPF record. Many include: statements push you over this. Use tools like dmarcian SPF Surveyor to check your lookup count. If you exceed 10, use an SPF flattening service.
Verify with:
dig TXT yourdomain.com | grep spf
# or
nslookup -type=TXT yourdomain.com
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Step 2: DKIM (DomainKeys Identified Mail)
DKIM adds a cryptographic signature to every outgoing email. The receiving server verifies the signature against a public key published in DNS, proving the email wasn't modified in transit and actually came from your domain.
How it works:
- Your email provider generates an RSA key pair
- The private key signs each email's header + body hash
- The public key is published in DNS
- Receiving server fetches the DNS record and verifies the signature
DNS TXT record (provided by your email provider):
; selector._domainkey.yourdomain.com
; "selector" is provided by your ESP (e.g., "s1" for SendGrid, "amazonses" for SES)
s1._domainkey.yourdomain.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."
Each email provider generates the key pair and gives you the DNS record to add. You never handle the private key directly.
DKIM for multiple providers (SendGrid for marketing, SES for transactional):
; SendGrid
s1._domainkey.yourdomain.com. TXT "v=DKIM1; k=rsa; p=..."
; AWS SES (each region gets its own selector)
amazonses._domainkey.yourdomain.com. CNAME amazonses._domainkey.us-east-1.amazonses.com.
Verify DKIM:
# Check if DKIM record is published
dig TXT s1._domainkey.yourdomain.com
# Test with a real email โ send to check-auth@verifier.port25.com
# You'll receive a detailed deliverability report
Step 3: DMARC (Domain-based Message Authentication)
DMARC tells receiving servers what to do with emails that fail SPF or DKIM โ and critically, it generates reports so you can monitor authentication failures.
Start in monitor mode (collect reports, take no action):
; _dmarc.yourdomain.com
_dmarc.yourdomain.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com; ruf=mailto:dmarc-forensic@yourdomain.com; fo=1"
Move to quarantine after 30 days of clean reports:
_dmarc.yourdomain.com. TXT "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@yourdomain.com"
pct=25โ only apply the policy to 25% of failing emails initially
Full enforcement:
_dmarc.yourdomain.com. TXT "v=DMARC1; p=reject; rua=mailto:dmarc-reports@yourdomain.com; adkim=s; aspf=s"
p=rejectโ reject emails that fail authenticationadkim=sโ strict DKIM alignment (from domain must match DKIM signing domain)aspf=sโ strict SPF alignment
DMARC report parsing: The reports are XML and hard to read manually. Use a free DMARC report parser: DMARC Analyzer, MXToolbox, or Postmark's DMARC Digests.
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Step 4: Transactional Email Setup (AWS SES Example)
// lib/email.ts
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
const ses = new SESClient({ region: 'us-east-1' });
interface SendEmailOptions {
to: string | string[];
subject: string;
htmlBody: string;
textBody: string;
replyTo?: string;
configurationSet?: string; // For tracking opens/clicks/bounces
}
export async function sendEmail(opts: SendEmailOptions): Promise<string> {
const toAddresses = Array.isArray(opts.to) ? opts.to : [opts.to];
const command = new SendEmailCommand({
Source: `Viprasol <noreply@mail.yourdomain.com>`,
Destination: {
ToAddresses: toAddresses,
},
Message: {
Subject: { Data: opts.subject, Charset: 'UTF-8' },
Body: {
Html: { Data: opts.htmlBody, Charset: 'UTF-8' },
Text: { Data: opts.textBody, Charset: 'UTF-8' },
},
},
ReplyToAddresses: opts.replyTo ? [opts.replyTo] : undefined,
ConfigurationSetName: opts.configurationSet ?? 'default',
});
const result = await ses.send(command);
return result.MessageId!;
}
SES bounce and complaint handling (critical for deliverability):
// api/webhooks/ses.ts โ Handle SES notifications via SNS โ SQS โ your endpoint
interface SESNotification {
notificationType: 'Bounce' | 'Complaint' | 'Delivery';
bounce?: {
bounceType: 'Permanent' | 'Transient' | 'Undetermined';
bouncedRecipients: Array<{ emailAddress: string; diagnosticCode: string }>;
};
complaint?: {
complainedRecipients: Array<{ emailAddress: string }>;
complaintFeedbackType: 'abuse' | 'fraud' | 'other';
};
}
export async function handleSESNotification(notification: SESNotification) {
switch (notification.notificationType) {
case 'Bounce':
if (notification.bounce?.bounceType === 'Permanent') {
// Hard bounce โ never send to this address again
for (const recipient of notification.bounce.bouncedRecipients) {
await db.emailSuppression.upsert({
where: { email: recipient.emailAddress },
create: {
email: recipient.emailAddress,
reason: 'hard_bounce',
diagnosticCode: recipient.diagnosticCode,
},
update: { reason: 'hard_bounce', updatedAt: new Date() },
});
}
}
// Soft bounces: log but don't suppress (might be temporary)
break;
case 'Complaint':
// User marked as spam โ suppress immediately
for (const recipient of notification.complaint!.complainedRecipients) {
await db.emailSuppression.upsert({
where: { email: recipient.emailAddress },
create: { email: recipient.emailAddress, reason: 'spam_complaint' },
update: { reason: 'spam_complaint', updatedAt: new Date() },
});
// Unsubscribe from all marketing lists
await db.user.updateMany({
where: { email: recipient.emailAddress },
data: { marketingEmailsOptIn: false },
});
}
break;
}
}
// Check suppression before every send
export async function canSendTo(email: string): Promise<boolean> {
const suppressed = await db.emailSuppression.findUnique({
where: { email },
});
return !suppressed;
}
Step 5: IP Warm-Up
Sending from a new IP address to a large list immediately will destroy your deliverability. ISPs see an unknown IP suddenly sending thousands of emails and assume it's spam.
Warm-up schedule:
| Day | Daily Volume | Segment |
|---|---|---|
| 1โ3 | 50โ100 | Most engaged (recent openers) |
| 4โ7 | 500โ1,000 | Engaged last 30 days |
| 8โ14 | 2,000โ5,000 | Engaged last 60 days |
| 15โ21 | 10,000โ20,000 | Engaged last 90 days |
| 22โ30 | 50,000+ | Full list |
Rules during warm-up:
- Never send to addresses that haven't engaged in 6+ months during warm-up
- Monitor spam rates โ if above 0.3% (Gmail), pause and investigate
- Monitor bounce rates โ if above 2%, pause and clean the list
- Send at consistent times (not random spikes)
List Hygiene
A clean list is as important as authentication. Sending to old, inactive, or invalid addresses damages your sender score.
// Clean list before bulk send
async function cleanEmailList(emails: string[]): Promise<string[]> {
// 1. Remove suppressions (bounces, complaints, unsubscribes)
const suppressions = await db.emailSuppression.findMany({
where: { email: { in: emails } },
select: { email: true },
});
const suppressedSet = new Set(suppressions.map(s => s.email));
// 2. Validate format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 3. Filter inactive users (haven't opened in 12 months)
const inactive = await db.user.findMany({
where: {
email: { in: emails },
lastEmailOpenAt: { lt: subMonths(new Date(), 12) },
},
select: { email: true },
});
const inactiveSet = new Set(inactive.map(u => u.email));
return emails.filter(email =>
!suppressedSet.has(email) &&
emailRegex.test(email) &&
!inactiveSet.has(email)
);
}
Deliverability Monitoring
| Metric | Good | Warning | Critical |
|---|---|---|---|
| Delivery rate | > 98% | 95โ98% | < 95% |
| Bounce rate | < 0.5% | 0.5โ2% | > 2% |
| Spam complaint rate | < 0.1% | 0.1โ0.3% | > 0.3% |
| Open rate (engaged list) | > 25% | 15โ25% | < 15% |
Tools: Google Postmaster Tools (free, shows Gmail domain reputation), MXToolbox, Mail-Tester (test a specific email), 250ok (enterprise monitoring).
Working With Viprasol
We set up transactional email infrastructure โ SPF/DKIM/DMARC, SES configuration, bounce handling, suppression lists, and warm-up schedules โ as part of SaaS product builds. Getting email deliverability right at launch is far easier than fixing reputation damage later.
โ Talk to our team about your email infrastructure.
See Also
- SaaS Churn Reduction โ email sequences for re-engagement
- Webhook Design Patterns โ processing SES bounce/complaint events
- API Security Best Practices โ securing email webhooks
- SaaS Onboarding Best Practices โ onboarding email sequences
- Web Development Services โ transactional email infrastructure
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.