Skip to content

feat: rewrite forwarded mail sender to pass SPF checks - #298

Draft
DavidePrincipi wants to merge 3 commits into
mainfrom
feat-7741
Draft

feat: rewrite forwarded mail sender to pass SPF checks#298
DavidePrincipi wants to merge 3 commits into
mainfrom
feat-7741

Conversation

@DavidePrincipi

@DavidePrincipi DavidePrincipi commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary

When a mailbox forwards a message to an external address, the message still shows the original sender in its envelope. Many mail servers reject or discard it, because the forwarding server is not allowed to send mail for the original sender's domain (SPF check).

This change adds postsrsd to the Postfix container:

  • Forwarded messages get their envelope sender rewritten to an address that passes the destination's SPF check.
  • A bounce/DSN sent back to that rewritten address is verified against postsrsd's own secret and accepted at the door (check_recipient_access combined with pipemap), then decoded back to the real original sender — otherwise it would be silently rejected as an unknown recipient before ever reaching the reverse rewrite.
  • postsrsd has no live-reload capability of its own, so reload-config owns its whole lifecycle: it starts postsrsd once a mail domain is configured, stops it when SRS is disabled, and restarts it whenever the domains list changes. The container now relies on podman's --init for signal handling and process reaping.
  • SRS can be disabled with POSTFIX_SRS=0, and its current state is reported through get-facts.

Related issue

NethServer/dev#7741

How to test

  1. Set up a mailbox forward to an external address.
  2. Send a message, from an outside address, to the mailbox that has the forward.
  3. Check that the forwarded copy's envelope sender is rewritten (an SRS0=... address), instead of the original sender.
  4. Send a message directly to that rewritten SRS0=... address (simulating a bounce) and check it is accepted, not rejected as an unknown recipient.
  5. Set POSTFIX_SRS=0 in the module's state/environment file, reload Postfix, and check the rewrite no longer happens.

Automated coverage: tests/50__smtp/00__userforwards.robot, tests/50__smtp/00__srs_reverse.robot.

🤖 Generated with Claude Code

@DavidePrincipi DavidePrincipi self-assigned this Sep 4, 2026
@DavidePrincipi
DavidePrincipi force-pushed the feat-7741 branch 2 times, most recently from eb606be to f8c0952 Compare September 4, 2026 17:16
@DavidePrincipi DavidePrincipi changed the title Rewrite forwarded mail sender to pass SPF checks feat: rewrite forwarded mail sender to pass SPF checks Sep 4, 2026
#
sender_canonical_maps = socketmap:unix:/var/lib/postsrsd/srs.sock:forward
sender_canonical_classes = envelope_sender
recipient_canonical_maps = socketmap:unix:/var/lib/postsrsd/srs.sock:reverse

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work -- the outbound side looks right, and the test covers it.

I have a doubt about the return path.

Bounces come back to SRS0=...@<first mail domain>. But recipient_canonical_maps runs in cleanup, while smtpd accepts or refuses the recipient at RCPT TO -- before that.

The SRS domain is in virtual_alias_domains, and smtpd_reject_unlisted_recipient defaults to yes. I could not find anything in virtual_alias_maps that matches an SRS0= local part.

On a running instance, two alias domains, no catchall:

# swaks --server 127.0.0.1 --helo test.local --from postmaster@example.net \
    --to 'SRS0=abcd=TT=inbound.test=u1@example.com' --quit-after RCPT
<** 550 5.1.1 <SRS0=abcd=TT=inbound.test=u1@example.com>: Recipient address
    rejected: User unknown in virtual alias table

No PR applied there, but the PR does not touch smtpd_recipient_restrictions, virtual_alias_maps or smtpd_reject_unlisted_recipient. Am I missing a path where the reverse map fires earlier?

If I'm right, DSNs and vacation replies from the forward destination get refused at our edge. Today a failed forward bounces to the original sender. After this change it would fail silently.

Would a generated regexp: entry in virtual_alias_maps be enough, just to satisfy the unlisted check? The image has regexp, not pcre.

Happy to be wrong. A robot test doing a RCPT TO on an SRS address would settle it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, nice catch! there's a missing piece of Postfix configuration.

ldap_schema=${POSTFIX_LDAP_SCHEMA:?}

start_postsrsd() {
postsrsd -D -C /etc/postsrsd/postsrsd.conf -p /run/postsrsd.pid || :

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate from the above: start_postsrsd ends with || :, and postsrsd.cf is already in main.cf by then.

If postsrsd fails to start, or dies later with nothing supervising it, sender_canonical_maps points at a missing socket and cleanup defers every message -- not just forwards.

With syslog = off and postsrsd launched from a transient podman exec, I don't think anything would show up in journald either.

Would failing the reload be safer?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this is ugly. I'd rebase this PR on Alpine 3.24, which provides a modern version of postsrsd supporting reload and inotify.

@DavidePrincipi
DavidePrincipi marked this pull request as draft September 10, 2026 15:50
Forwarded mail keeps the original external sender in the envelope,
so destination servers reject it on SPF grounds. Wire Postfix's
sender/recipient canonical maps to postsrsd's socketmap so forwards
are rewritten to pass SPF at the receiving end, and add a
check_recipient_access pipemap rule so a genuine SRS0 bounce is
verified against postsrsd's own secret and accepted at RCPT TO,
instead of being rejected as an unlisted recipient before it ever
reaches that rewrite.

postsrsd has no configuration reload capability of its own, so
reload-config manages its whole lifecycle directly: it starts it
(in its own daemon mode) once a mail domain is configured, stops it
when SRS is disabled, and kills and restarts it whenever the
domains list actually changes, waiting out the old process first.
The container itself now relies on podman's --init for signal
handling and reaping, so entrypoint.sh just execs Postfix in the
foreground as before this change.

SRS can be turned off with POSTFIX_SRS=0. The signing secret is
bootstrapped once into a new postsrsd/ subdir of the existing
postfix-queue volume, so no new volume is needed; it is
intentionally left out of the module backup, since restore-time
regeneration only risks losing bounce notifications for a narrow
window around the restore.

Assisted-by: Claude:claude-sonnet-5
Extend the existing external-forward test case with a check that
the envelope sender was rewritten to a postsrsd SRS0 address, using
postfix/qmgr's own journal as ground truth like the sibling
delivery/relay assertions already do.

Add a dedicated suite covering the reverse path: a genuine SRS0
recipient (its signature verified against postsrsd's own secret) is
accepted and routed back to the real original sender, while an
address that only looks like one is still rejected as unlisted.

Assisted-by: Claude:claude-sonnet-5
Expose whether postsrsd (NethServer/dev#7741) is currently active,
mirroring reload-config's own POSTFIX_SRS parsing.

Assisted-by: Claude:claude-sonnet-5
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.

2 participants