Skip to content

fix(mail): site mail was undeliverable — plus make direct-MX actually deliverable - #58

Merged
nechodom merged 2 commits into
mainfrom
fix/site-mail-sendmail-flags
Aug 9, 2026
Merged

fix(mail): site mail was undeliverable — plus make direct-MX actually deliverable#58
nechodom merged 2 commits into
mainfrom
fix/site-mail-sendmail-flags

Conversation

@nechodom

@nechodom nechodom commented Aug 9, 2026

Copy link
Copy Markdown
Owner

No e-mail sent by any hosted PHP site has left any node since 45d963a — password resets, WooCommerce order confirmations, contact forms. It is in every release from v0.1.0 to v0.13.0.

What broke

That commit pointed sendmail_path at the logging wrapper:

php_admin_value[sendmail_path] = .../site-mail-wrapper -u {{ system_user }}

PHP's compiled default is /usr/sbin/sendmail -t -i, and setting the INI replaces that string whole — the flags are not merged. Nothing added them back: PHP appends only mail()'s 5th argument, and the wrapper forwards argv verbatim.

So the real sendmail was exec'd with the message on stdin and no recipient anywhere, because without -t the To: header is inert. Reproduced against Postfix's own sendmail:

$ printf 'To: b@example.com\n\nbody\n' | /usr/sbin/sendmail
sendmail: fatal: Recipient addresses must be specified on the command line or via the -t option

exit 75. Nothing is enqueued, so there is no bounce and no queue trail — only a fatal line in syslog that nobody correlates with "the customer says the reset link never arrived".

Why it survived six releases

Two things actively hid it:

  • The wrapper writes its JSONL record before forwarding, so the panel's per-hosting Emails log shows a healthy stream of messages that were never queued. The operator's own evidence says mail works.
  • Settings → Mail "send test" passes -t and skips the wrapper entirely, so the one tool used to check mail exercises a different path than production and cannot fail the way production fails.

The fix

The flags are restored in the wrapper, not only in the pool template. Pool files already on disk are not regenerated until a hosting is re-saved, while update.sh reinstalls the wrapper on every node — so this repairs existing sites on update without rewriting and reloading every FPM pool on the box. The template gets them too, so the requirement is visible where the value is set, and the wrapper skips its own pair when the caller already passed one.

Also fixed, found while verifying the above: exec discards the EXIT trap, so every outgoing message left a full copy in /tmp forever — 0600, owned by the site user, unbounded. The temp file is now unlinked before the exec and handed over on an open descriptor.

Verification

Against a stub sendmail, all three call shapes:

pool value on disk reaches sendmail as
old (-u site1) -t -i
new (-u site1 -t -i) -t -i (not doubled)
PHPMailer (-u site1 -fwp@site.cz) -t -i -fwp@site.cz

Body still arrives, JSONL line still written, /tmp empty across repeated sends.

Follow-up, not in this PR

The mail test should invoke the wrapper for a real hosting instead of calling /usr/sbin/sendmail directly, so the diagnostic exercises the production path. As it stands it is structurally incapable of catching this class of bug.

🤖 Generated with Claude Code

mkn added 2 commits August 9, 2026 12:28
…og shipped

No e-mail sent by any hosted PHP site has left any node since 45d963a —
password resets, order confirmations, contact forms. It is in every
release from v0.1.0 to v0.13.0.

That commit pointed `sendmail_path` at the logging wrapper:

    php_admin_value[sendmail_path] = .../site-mail-wrapper -u {{ system_user }}

PHP's compiled default is `/usr/sbin/sendmail -t -i`, and setting the INI
replaces that string WHOLE — the flags are not merged. Nothing added them
back: PHP appends only mail()'s 5th argument, and the wrapper forwards
argv verbatim.

So the real sendmail was exec'd with the message on stdin and no
recipient anywhere, because without `-t` the To: header is inert.
Reproduced against Postfix's own sendmail:

    $ printf 'To: b@example.com\n\nbody\n' | /usr/sbin/sendmail
    sendmail: fatal: Recipient addresses must be specified on the
              command line or via the -t option    (exit 75)

Nothing is enqueued, so there is no bounce and no queue trail — only a
fatal line in syslog that nobody correlates with "the customer says the
reset link never arrived".

Two things hid it for this long. The wrapper writes its JSONL record
BEFORE forwarding, so the panel's per-hosting Emails log shows a healthy
stream of messages that were never queued — the operator's own evidence
says mail works. And Settings -> Mail "send test" passes `-t` and skips
the wrapper entirely, so the one tool used to check mail exercises a
different path than production and cannot fail the way production fails.

The flags are restored in the wrapper, not only in the pool template,
because pool files already on disk are not regenerated until a hosting is
re-saved — while update.sh reinstalls the wrapper on every node. So this
repairs existing sites on update, without rewriting and reloading every
FPM pool on the box. The template gets them too, so the requirement is
visible where the value is set, and the wrapper skips its own pair when
the caller already passed one.

Also fixed, found while verifying the above: `exec` discards the EXIT
trap, so every outgoing message left a full copy in /tmp forever — 0600,
owned by the site user, unbounded. The temp file is now unlinked before
the exec and handed over on an open descriptor.

Verified end to end against a stub sendmail: old pool value on disk,
new pool value, and the PHPMailer `-f` case all reach sendmail as
`-t -i [-f...]`; the body still arrives; the JSONL line is still written;
/tmp stays empty across repeated sends.
Sending without an external relay already worked — direct-MX is the
default mode. What was missing is everything that decides whether the
mail ARRIVES. Five defects, found by tracing the real path rather than
reading the config.

THE PER-SITE SPF RECORD WAS NEVER CONSULTED BY ANYONE. SPF is evaluated
against the ENVELOPE sender's domain, and PHP mail() produces an envelope
of `<php-fpm user>@<node fqdn>`. So the record the panel tells the
operator to publish at their customer's domain was checked by no
receiver: they were looking up this node's own hostname, which normally
has no SPF record at all. The card went green regardless. Postfix now
rewrites the envelope onto the site's domain via `sender_canonical_maps`,
keyed on that hosting having DKIM enabled — which is exactly the point at
which the operator has demonstrably taken control of the domain's mail
DNS. `sender_canonical_classes` is pinned to `envelope_sender`: rewriting
headers would invalidate every DKIM signature this node produces, since
OpenDKIM signs the From: header.

DIRECT-MX HAD NO TLS POLICY. It was whatever the box last had. A node
switched here from a smart host keeps `smtp_tls_security_level=encrypt`
from the relay config — mandatory TLS, correct for one known relay, wrong
for public MX delivery, where it defers and then bounces every message to
a receiver offering no STARTTLS. Now set explicitly to `may`, which also
covers the box that never had the key at all. The obsolete `smtp_use_tls`
is dropped rather than left beside it.

`inet_protocols` was `all`, so a dual-stack node sends over IPv6 to any
receiver with an AAAA — from an address whose PTR and SPF cover only
IPv4. Now defaults to `ipv4`, configurable per node. And the apply path
RESTARTS postfix instead of reloading, because `inet_protocols` is read
once at master startup: a reload left a node still talking IPv6 while
main.cf claimed otherwise, so the config looked applied and the mail kept
bouncing.

`smtp_bind_address` did not exist, so on a multi-homed node the kernel
picked the source address — possibly one the PTR does not cover and no
SPF lists. Now an optional per-node setting.

THE SPF SUGGESTION COULD BE A PERMERROR. `api.ipify.org` answers over
whichever family the request went out on, so a dual-stack node got an
IPv6 literal, which was interpolated into `v=spf1 ip4:{ip} …`. An
operator pasting that published a record every receiver evaluates as
permerror — worse than no SPF — while the card kept reporting "differs"
no matter what they did, because the comparison parsed the same broken
string. The probe is pinned to `api4.` and the result is parsed and
family-checked; the IPv6 probe in the DNS preflight got the mirror check.

NOTHING CHECKED THE PTR RECORD, which is the single biggest factor in
whether direct-MX mail is accepted — receivers reject on a missing or
mismatched PTR before they look at SPF or DKIM. Settings -> Mail now
reports it against `myhostname`, with "unknown" kept strictly distinct
from "missing" so a failed lookup never sends an operator off to
re-create a record that already exists. It runs concurrently with the
port probes, so the page does not get slower.

Finally, the mail test now goes through the site-mail wrapper when it is
installed, the way a hosted site does. Testing a different path than
production is how the missing `-t` survived six releases while reporting
success.

950 tests pass.
@nechodom nechodom changed the title fix(mail): site mail has been undeliverable since the outbound-mail log shipped fix(mail): site mail was undeliverable — plus make direct-MX actually deliverable Aug 9, 2026
@nechodom
nechodom merged commit a9b576f into main Aug 9, 2026
2 checks passed
@nechodom
nechodom deleted the fix/site-mail-sendmail-flags branch August 9, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant