The first time an app turns an inbound email into a ticket, a lead or a comment, it is handling attacker-controlled input and treating it like a form post. The From header is whatever the sender typed. A display name can hold someone else's address. "SPF pass" says nothing about who wrote the message. And half of what arrives is not from a person at all: a bounce, an out-of-office reply, a mailing list.
I hit all of this building Odden, a CRM with a helpdesk, and wrote it down as code. It is now a package: getodden/inbound-mail. MIT, on Packagist, Laravel 13.
One message, however it arrived
Amazon SES, Mailgun, Postmark, SendGrid or your own JSON all come out as the same InboundMessage: sender, subject, a plain-text body with the quoted thread cut away, headers, message id, envelope sender, spam score, an authenticated flag, thread tokens and, for a bounce, a delivery report.
$message = Normalizer::fromMailgun($request->all()); // or fromPostmark(), fromSendGrid(), fromGeneric(), fromSes()
if (! $message->hasSender()) {
return; // no way to say who wrote it
}
if ($reason = AutomaticMail::reason($message, ownAddresses: ['support@example.com'])) {
return; // a bounce, an auto-reply, a list: never answer it
}
Who wrote it: a sender parsed like a mail client parses it
The classic mistake is a regex over the From header, or picking the first thing in angle brackets. Then "victim@example.com" <attacker@evil.test> is "victim", or Pat <pat@example.com>, Lee <lee@example.com> is whichever address the code found first, and the sender gets to choose who your application believes wrote the mail. SenderAddress uses a real address parser and reads the header the way a mail client does: the address in the angle brackets is the sender, whatever the display name holds, and the name is cleaned of brackets and quotes before it is passed on. A header that names several addresses, or none valid, is refused: hasSender() is false and your code has to decide what that means, instead of quietly believing a display name or a comment.
What "authenticated" means, and what it does not
authenticated is true only when the provider vouches for the From address: an SES DMARC pass, or a provider's own statement. SPF and DKIM results from Mailgun, SendGrid and Postmark are about the envelope sender and some signing domain, not about who the message says it is from, so they never vouch for anyone. null means the provider said nothing. If you route on "this came from our customer", that flag is the one to use, and the README says exactly when each provider can set it.
Bounces, auto-replies and lists
A bounce is an RFC 3464 report. DeliveryReport reads it into recipients, actions, status codes and diagnostics, and calls a permanent failure a hard_bounce and a temporary one a soft_bounce. AutomaticMail::reason() says why a machine sent a message: bounce, auto-submitted, bulk, auto-reply, mailing list, or one of your own addresses. saysOutOfOffice() is a separate, weaker question for you to weigh. Replying to a machine is how mail loops start.
SES replaces your Message-ID
If you send through SES, your reply's In-Reply-To no longer carries the id you set, so threading by header breaks. The package finds your own reference in the quoted text instead: give it a pattern for the link or token your emails carry ('~support/tickets/([a-z0-9]{40})~i'), and threadTokens lists what it found anywhere in the message, quoted text included, before the quoted thread is cut away.
The SES endpoint (0.2)
SES stores a received message in S3 and tells an SNS topic, which posts to your app. Turn the route on, say who owns an address and what a message does, and the package does the rest: it proves the post is SNS's before reading anything in it, confirms the subscription only at an Amazon address, deduplicates by message id, reads the message only from your bucket and prefix, and answers 500 on a failure so SNS tries again.
final class Inboxes implements ResolvesRecipients { /* an address => the inbox it belongs to */ }
final class OpenTicket implements HandlesInboundMail { /* what a message does there */ }
The AWS side (MX record, verified identity, bucket, topic, receipt rule, a read-only policy) is a page in the repo, docs/aws-setup.md.
How much to trust it
Some background, and then the caveats. I built Odden because I wanted HubSpot's functionality without HubSpot's per-seat cost, and I built it from what I learned running CaskStack. I now run the marketing for both Odden and CaskStack from it, and this package is the inbound-email code of Odden Cloud, the hosted version, which is still in development (there is an early-access list).
So be sceptical in proportion. The odden workspace, our own, receives support mail through the SES endpoint, and a real message has gone through it into a ticket. The Mailgun, Postmark and SendGrid shapes are tested against their documented payloads, not live traffic, and the package does not yet check those providers' own webhook signatures. It is pre-1.0 and a minor version may change the API.
If you are building a helpdesk or a "reply by email" feature, I would like to hear which of these answers is wrong for you.
It stays MIT. Paid features, if there are any, will live in the products built on top of it, never behind a license check in the package.