How to Debug Email Headers and SMTP Responses
When an email fails to deliver, arrives in spam, or renders incorrectly, the answer is almost always in the headers and SMTP response codes. Knowing how to read them is a fundamental debugging skill for any developer working with transactional or notification emails.
This guide covers the structure of email headers, what SMTP response codes mean, how to trace an email's path through servers, and how to use command-line tools and SMTP sandboxes to diagnose common problems.
Email Header Fundamentals
Every email carries metadata in its headers. When you view an email in Gmail, Outlook, or any client, you are seeing the rendered body. The headers contain the operational data: who sent it, how it was routed, whether it passed authentication checks, and what content encoding was used.
Here is a simplified set of headers from a typical transactional email:
From: Acme App <[email protected]>
To: [email protected]
Subject: Your password has been reset
Date: Thu, 12 Mar 2026 14:22:08 +0000
Message-ID: <[email protected]>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="----=_Part_001"
Reply-To: [email protected]
List-Unsubscribe: <https://acme.com/unsubscribe?token=xyz>, <mailto:[email protected]>
X-Mailer: Laravel/12.x
Key Headers Explained
From -- The sender address displayed to the recipient. Must align with your domain's SPF and DKIM records. If From is [email protected] but the email is sent from a server not listed in acme.com's SPF record, receiving servers may reject or spam-fold the message.
To / CC / BCC -- Recipient addresses. BCC headers are stripped before delivery, so they should never appear in the received message. If they do, you have a bug.
Message-ID -- A globally unique identifier for the message. Format is typically <unique-string@sending-domain>. Email clients use this for threading. Duplicate Message-IDs across different emails will cause clients to group unrelated messages together.
MIME-Version -- Always 1.0. Its absence can cause parsing issues in some older clients.
Content-Type -- Defines the body format. Common values:
text/plain-- Plain text onlytext/html-- HTML onlymultipart/alternative-- Both text and HTML (recommended)multipart/mixed-- Body with attachments
Reply-To -- Where replies should be directed. If omitted, replies go to the From address. Many applications use a no-reply@ From address with a specific Reply-To for routing responses to the right team.
List-Unsubscribe -- Required by Gmail and Yahoo for bulk senders. Must include either an HTTPS URL, a mailto: address, or both. Without this header, your emails are more likely to be flagged as spam.
SMTP Response Codes
SMTP is a text-based protocol. Every command you send to an SMTP server gets a three-digit response code followed by a human-readable message. Understanding these codes is essential for debugging delivery failures.
Success Codes (2xx)
| Code | Meaning |
|---|---|
| 220 | Server ready (greeting) |
| 221 | Server closing connection |
| 235 | Authentication successful |
| 250 | Requested action completed (OK) |
| 251 | User not local; will forward |
Intermediate Codes (3xx)
| Code | Meaning |
|---|---|
| 334 | Server challenge (during AUTH) |
| 354 | Start mail input (after DATA command) |
Transient Failure Codes (4xx)
These are temporary. The sending server should retry.
| Code | Meaning |
|---|---|
| 421 | Service not available, closing connection |
| 450 | Mailbox unavailable (busy or temporarily blocked) |
| 451 | Requested action aborted: local error in processing |
| 452 | Insufficient storage |
Permanent Failure Codes (5xx)
These are final. The sending server should not retry.
| Code | Meaning |
|---|---|
| 500 | Syntax error, command not recognized |
| 501 | Syntax error in parameters |
| 502 | Command not implemented |
| 503 | Bad sequence of commands |
| 530 | Authentication required |
| 535 | Authentication credentials invalid |
| 550 | Mailbox unavailable (does not exist or policy rejection) |
| 551 | User not local |
| 552 | Message size exceeds limit |
| 553 | Mailbox name not allowed |
| 554 | Transaction failed |
The most common codes you will encounter during debugging are 250 (success), 535 (bad credentials), 550 (recipient rejected), and 421 (server temporarily unavailable).
Enhanced Status Codes
Modern SMTP servers often include enhanced status codes in the format X.Y.Z:
550 5.1.1 <[email protected]>: Recipient address rejected: User unknown
The 5.1.1 breaks down as: 5 = permanent failure, 1 = addressing, 1 = bad destination mailbox. RFC 3463 defines the full list, but the pattern is consistent: the first digit mirrors the basic response code class.
Reading Received Headers to Trace Email Path
Every mail server that handles a message prepends a Received header. These headers form a chain from the originating server to the final destination. They are read bottom-to-top (the oldest is at the bottom).
Received: from mail-frontend.example.com (mail-frontend.example.com [198.51.100.10])
by mx.recipient.com (Postfix) with ESMTPS id ABC123
for <[email protected]>; Thu, 12 Mar 2026 14:22:10 +0000
Received: from app-server.acme.com (app-server.acme.com [203.0.113.5])
by mail-frontend.example.com (Postfix) with ESMTP id DEF456
for <[email protected]>; Thu, 12 Mar 2026 14:22:09 +0000
Reading bottom-to-top:
app-server.acme.com(your application server) handed the message tomail-frontend.example.com(your outbound mail relay).mail-frontend.example.comdelivered it tomx.recipient.com(the recipient's mail server).
Each Received header includes timestamps. If you see a large gap between consecutive timestamps, that server introduced a delay -- useful for diagnosing slow delivery.
Authentication Headers: SPF, DKIM, and DMARC
Modern email authentication relies on three mechanisms. When debugging deliverability issues, these headers tell you exactly what passed and what failed.
SPF (Sender Policy Framework)
SPF verifies that the sending server's IP address is authorized to send email for the domain in the MAIL FROM (envelope sender) address.
Received-SPF: pass (google.com: domain of [email protected]
designates 203.0.113.5 as permitted sender)
Authentication-Results: mx.google.com;
spf=pass (google.com: domain of [email protected]
designates 203.0.113.5 as permitted sender) [email protected]
Common SPF failures happen when you switch email providers but forget to update your DNS TXT record, or when you send from a server IP that is not listed in your SPF record.
DKIM (DomainKeys Identified Mail)
DKIM attaches a cryptographic signature to the email headers. The receiving server verifies it against the public key published in DNS.
DKIM-Signature: v=1; a=rsa-sha256; d=acme.com; s=selector1;
h=from:to:subject:date:message-id;
bh=base64encodedHash;
b=base64encodedSignature
Authentication-Results: mx.google.com;
dkim=pass header.d=acme.com header.s=selector1
DKIM failures occur when the message body or signed headers are modified in transit (by a mailing list, a forwarding service, or a misconfigured relay).
DMARC (Domain-based Message Authentication, Reporting and Conformance)
DMARC ties SPF and DKIM together and tells receiving servers what to do when both fail.
Authentication-Results: mx.google.com;
dmarc=pass (p=REJECT sp=REJECT) header.from=acme.com
A dmarc=fail result with a p=reject policy means the email was dropped entirely. With p=quarantine, it lands in spam. With p=none, it is delivered normally but the failure is reported to the domain owner.
Debugging with Telnet and OpenSSL
For hands-on SMTP debugging, nothing beats a raw connection to the server. This lets you see exactly what the server responds at each step.
Unencrypted Connection (Testing Against a Sandbox)
telnet sandbox.sendpit.com 1025
220 SendPit SMTP Ready
EHLO myhost.local
250-SendPit
250-AUTH PLAIN LOGIN
250 OK
AUTH LOGIN
334 VXNlcm5hbWU6
bWJfZGV2X21haWxib3g=
334 UGFzc3dvcmQ6
c2VjcmV0cGFzc3dvcmQ=
235 Authentication successful
MAIL FROM:<[email protected]>
250 OK
RCPT TO:<[email protected]>
250 OK
DATA
354 Start mail input; end with <CRLF>.<CRLF>
From: [email protected]
To: [email protected]
Subject: Test from telnet
Content-Type: text/plain
This is a test message sent via raw SMTP.
.
250 OK: message queued
QUIT
221 Bye
TLS-Encrypted Connection
For servers that require TLS (port 587 or 465):
openssl s_client -connect smtp.provider.com:587 -starttls smtp
This establishes a TLS connection and then drops you into an interactive SMTP session where you can issue the same commands as above.
What to Look For
- Authentication failures (535): Wrong username/password, or the AUTH mechanism is not supported.
- Recipient rejection (550): The server does not recognize the address, or policy blocks it.
- Connection drops (421): Rate limiting, greylisting, or server overload.
- Size limits (552): Your message exceeds the server's maximum. Common with large attachments.
Using an SMTP Sandbox to Inspect Headers
When you send email through an SMTP sandbox like SendPit, every message is captured with its full headers intact. This gives you a controlled environment to inspect exactly what your application is producing.
Typical workflow:
- Configure your application to send through the sandbox.
- Trigger the email (password reset, order confirmation, etc.).
- Open the sandbox web interface and find the captured message.
- Switch to the "Headers" or "Source" view to see raw headers.
- Verify
From,Reply-To,Content-Type,List-Unsubscribe, and any custom headers.
This is significantly faster than sending through a production mail server and trying to find the message in a recipient's inbox. With a sandbox, the message is immediately available, and you can inspect it without any delivery pipeline interference.
// Send a test email and inspect it via the sandbox API
$response = Http::withToken(config('services.sendpit.api_key'))
->get('https://sendpit.com/api/v1/mailboxes/' . $mailboxId . '/messages');
$message = collect($response->json('data'))->first();
// Check headers programmatically
$headers = collect($message['headers']);
$contentType = $headers->firstWhere('name', 'Content-Type');
$replyTo = $headers->firstWhere('name', 'Reply-To');
$listUnsub = $headers->firstWhere('name', 'List-Unsubscribe');
expect($contentType['value'])->toContain('multipart/alternative');
expect($replyTo['value'])->toBe('[email protected]');
expect($listUnsub['value'])->toContain('https://acme.com/unsubscribe');
Common Header-Related Bugs
Character Encoding Issues
Subject lines and sender names with non-ASCII characters must be encoded per RFC 2047. If your mail library does not handle this, you will see raw encoded text:
Subject: =?UTF-8?Q?Ihre_Bestellung_wurde_best=C3=A4tigt?=
This is correct -- it is how the subject should look in the raw headers. If you see garbled characters in the rendered subject, the encoding declaration is wrong or missing. Most modern mail libraries handle this automatically, but custom header manipulation can break it.
Missing Multipart Boundary
If your Content-Type is multipart/alternative but the boundary parameter is missing or does not match the boundaries in the body, the email will render as raw MIME text:
Content-Type: multipart/alternative; boundary="----=_Part_001"
------=_Part_002 <-- WRONG: does not match the declared boundary
Content-Type: text/plain
Hello world
Duplicate or Missing Message-ID
Some mail libraries generate a Message-ID automatically. If you also set one manually, you may end up with duplicates. Some receiving servers reject messages with duplicate Message-ID headers. Conversely, missing Message-ID headers cause threading issues and may trigger spam filters.
Wrong Content-Type for Attachments
Setting Content-Type: application/octet-stream for every attachment technically works, but it prevents email clients from displaying inline previews. Use specific MIME types:
Content-Type: application/pdf; name="invoice.pdf"
Content-Type: image/png; name="logo.png"
Content-Type: text/csv; name="report.csv"
Reply-To Pointing Nowhere
A common oversight: setting Reply-To to an address that does not exist or is not monitored. Users hit "Reply," write a message, and it bounces. Always verify that Reply-To addresses have a working mailbox behind them.
Practical Debugging Walkthrough
Here is a real-world scenario: a user reports that password reset emails are not arriving.
Step 1: Check application logs. Look for SMTP errors in your application's log files. A 535 Authentication failed or Connection refused error tells you the problem is on the sending side.
# Laravel log
docker compose exec php grep -i "smtp\|mail\|swift" storage/logs/laravel.log | tail -20
Step 2: Verify SMTP connectivity. Can your application server reach the SMTP server at all?
docker compose exec php bash -c "echo 'QUIT' | nc -w 5 smtp.provider.com 587"
If the connection times out, it is a network issue -- firewall rules, security groups, or DNS resolution.
Step 3: Send a test email via raw SMTP. Bypass your application entirely to isolate whether the problem is in your code or the SMTP infrastructure.
openssl s_client -connect smtp.provider.com:587 -starttls smtp -quiet
Then walk through the SMTP conversation manually (EHLO, AUTH, MAIL FROM, RCPT TO, DATA, QUIT).
Step 4: Check the sandbox. If you are using an SMTP sandbox, check whether the email was captured there. If it appears in the sandbox but not in the real inbox, the issue is in your environment configuration -- you are sending to the sandbox instead of production, or the production SMTP credentials are wrong.
Step 5: Inspect headers of a delivered email. If the email was sent but landed in spam, look at the Authentication-Results header:
Authentication-Results: mx.google.com;
spf=fail (google.com: 203.0.113.5 is not permitted)
dkim=fail (signature verification failed)
dmarc=fail (p=QUARANTINE)
This tells you exactly what failed. Fix your DNS records (SPF, DKIM) and the problem resolves.
Step 6: Verify the recipient address. A 550 5.1.1 User unknown response means the email address does not exist. Check for typos in the recipient address. If the address is dynamically generated (e.g., from user input), add validation:
// Laravel validation
$request->validate([
'email' => ['required', 'email:rfc,dns'],
]);
The dns validation flag checks that the domain has MX records, catching obviously invalid domains before you attempt to send.
Summary
Email debugging is methodical. Start with SMTP response codes to understand whether the message was accepted. Read headers bottom-to-top to trace the delivery path. Check authentication headers to diagnose spam placement. Use raw SMTP connections to isolate infrastructure issues from application bugs. And use an SMTP sandbox to inspect your emails in a controlled environment before they ever touch a production mail server.
The tools are straightforward: telnet, openssl s_client, your application logs, and an SMTP sandbox like SendPit for capturing and inspecting messages. The hard part is not the tools -- it is knowing where to look. The headers will tell you everything if you know how to read them.
Nikhil Rao
Creator of SendPit. Building developer tools for email testing and SMTP infrastructure.
About SendPit →