Three Ways a Form Submission Dies Without an Error
The failure mode I keep running into in inbound-mail pipelines looks like success from the outside: the endpoint returns 200, the browser says thanks, and nothing lands in the inbox. Almost none of these are outages. The
The failure mode I keep running into in inbound-mail pipelines looks like success from the outside: the endpoint returns 200, the browser says thanks, and nothing lands in the inbox. Almost none of these are outages. They are filter decisions, each one correct on its own, each one ending in a silent early return.
Here are three shapes of that, with the mechanism in each.
1. The message is from you, so you discard it
Chatwoot issue 16578 (opened 2026-11-05) is the cleanest example. Inbound mail is dropped when the sender matches MAILER_SENDER_EMAIL, compared case-insensitively on the address part:
def notification_email_from_chatwoot?
configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <[email protected]>')).address
original_sender.to_s.casecmp?(configured_sender)
end
When that returns true, IncomingEmailValidityHelper#valid_external_email_for_active_account? returns false, so the message never becomes a conversation. Nothing appears in the UI and no error is written.
The check itself has a legitimate origin (PR #3386, "Prevent notification email loop"): a notification delivered to an address that is also an inbox creates a conversation, which sends another notification, and so on. The bug is the identity test, not the intent. "Sent from our noreply address" is being read as "is our own notification", and the two are not the same statement.
This collides with how transactional mail is actually configured. One shared [email protected] for billing, CRM, booking, web forms and monitoring means one identity to verify with the provider and one to align with SPF, DKIM and DMARC. So the address gets reused everywhere β and the messages that most need to reach a support inbox (form submissions, order alerts, monitoring) are precisely the ones emitted by those other systems. Configure that address as the mailer sender and every one of them is thrown away at the door.
If you are fixing this: do not infer origin from From. Loop prevention belongs on a header you control β Auto-Submitted: auto-generated, or an X-β¦-Notification: <id> you emit β or on a Message-ID you recorded when you sent the message. From is attacker-controlled and shared by design.
2. Refused after DATA, not at RCPT
rest-mail issue 1845 describes a two-stage disagreement. For a mailbox whose domain is not live (domains.active = false), the SMTP gateway answers 250 to RCPT TO, greylisting then defers DATA with 451, and the retry that clears the greylist is refused at the end of DATA with 550 5.1.1 Recipient not found.
Two code paths resolve "does this recipient exist" differently. The RCPT-time check uses FindReceivingMailboxByAddress only: mailbox active, receive grant held. The DATA-time filter also requires the domain to be live and treats a mailbox on a dead domain as unknown. Greylisting runs before that filter, so a recipient that will be refused is greylisted first, and the message sits in the sender's queue for the whole delay before bouncing.
RFC 5321 expects unknown recipients to be refused at RCPT for exactly this reason. Accepting and then failing after DATA costs the sender a full queue cycle, and since the final DSN goes to the envelope sender, a forged sender turns your gateway into a backscatter source. It also splits the error text across hops β 550 5.1.1 from the delivery API, 550 5.7.1 Rejected by policy on the wire β which is how a support ticket becomes unreadable.
The rule I would enforce: one recipient-resolution function, called at RCPT, and greylisting only after it passes. If a filter can still say no later, it must not be able to say no for a reason RCPT could have known.
3. The API answers 200 regardless of what happened
BilimBaga issue 125: with SMTP_FROM unset, the envelope sender is empty, every send fails with 550 Invalid syntax in MAIL command, Mailhog receives nothing β and the forgot-password endpoint still returns its neutral 200.
That 200 is deliberate: returning an error would confirm whether an account exists. It is also the reason the failure was invisible for a whole test cycle. Once a handler returns 200 unconditionally, the send path has no observer. Anything on that path needs its own counters: attempts, replies accepted by the MTA, DSNs and bounces received within some window. Without the third number, "sent" only means "handed to a library without raising".
What I check, in order
- Is there a durable row with
state=pendingbefore the send is attempted, so a crash mid-send leaves evidence? - Do I read the SMTP reply code, or only whether the client raised?
- Is any configured address used both as sender and as receiver? Grep both directions β that is the shape of failure 1.
- Does one synthetic submission per hour exist, whose absence alarms? That is the only check that catches all three shapes, because it does not depend on my idea of where the failure lives.
The common thread is not network trouble. It is a boolean that returns false and writes nothing: an early return inside a filter, a validation helper, a "no-op" branch. Exceptions get logged. Silent drops are always someone's deliberate guard clause.
When mail goes missing, I stop looking at the MTA first and go read the guards.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.