Skip to content

feat: implement address preference for IP address family selection in SMTP client - #3640

Open
c41ms0n wants to merge 1 commit into
postalserver:mainfrom
c41ms0n:feat/smtp-address-preference-v2
Open

c41ms0n wants to merge 1 commit into
postalserver:mainfrom
c41ms0n:feat/smtp-address-preference-v2

Conversation

@c41ms0n

@c41ms0n c41ms0n commented Sep 18, 2026

Copy link
Copy Markdown

Implement address preference for IP address family selection in SMTP client

Problem

Outgoing mail is delivered over IPv6 whenever a recipient's host publishes an AAAA
record. For senders whose IPv6 address has no matching PTR record, this fails
reverse-DNS / FCrDNS checks — e.g. Google rejects the connection because the IPv6
address does not resolve back to the sending hostname.

Disabling IPv6 on the host (or disabling it "only in the email service") does not
help: the SMTP client resolves AAAA records itself and connects to the literal IP,
so the OS-level setting is bypassed. Disabling IPv6 everywhere is also invasive and
slow to roll out.

Solution

Let the SMTP client decide which address family to use when a remote host has both
AAAA and A records, via a new setting: smtp_client.address_preference.

SMTPClient::Server#endpoints now orders/selects the resolved endpoints according
to that preference. Because Net::SMTP connects to a literal IP, the endpoint
right order fully determines which family is used — no OS changes required.

Configuration

address_preference Model Behaviour
ipv6 (default) prefer IPv6 endpoints first, IPv4 used as a fallback
ipv4 prefer IPv4 endpoints first, IPv6 used as a fallback
ipv6_only strict only IPv6 endpoints (A records ignored)
ipv4_only strict only IPv4 endpoints (AAAA records ignored)
  • Default is ipv6, so existing installations are unaffected.
  • Unknown values fall back to ipv6 and log a warning once, rather than breaking
    deliveries.

Set via environment variable:

SMTP_CLIENT_ADDRESS_PREFERENCE=ipv4_only   # force IPv4, no IPv6 attempts
# or
SMTP_CLIENT_ADDRESS_PREFERENCE=ipv4        # prefer IPv4, keep IPv6 as fallback

...or in postal.yml:

smtp_client:
  address_preference: ipv4_only

Note: this change alone does not alter behaviour. To resolve the FCrDNS issue the
setting must be configured on the email/worker service.

Changes

  • app/lib/smtp_client/address_preferences.rb (new) — SMTPClient::AddressPreferences
    exposes current, prefer_ipv4? and fallback? for the two models (prefer / only)
    across both families.
  • app/lib/smtp_client/server.rb — #endpoints orders the resolved endpoints by
    the preferred family and, in strict modes, drops the other family. Adds a private
    ordered_endpoints helper.
  • lib/postal/config_schema.rb — adds string :address_preference to the
    smtp_client group.
  • doc/config/yaml.yml, doc/config/environment-variables.md — regenerated
    config documentation.
  • spec/lib/smtp_client/address_preferences_spec.rb (new) — covers .current
    (all four values, case/whitespace, invalid), .prefer_ipv4? and .fallback?.
  • spec/lib/smtp_client/server_spec.rb — covers endpoint order/selection for all
    four modes, the invalid-value fallback, and the strict-mode edge where the preferred
    family has no records (returns []).

Testing

CI runs the suite against the ci image via Docker Compose, so it was reproduced
locally the same way — Docker only, no host Ruby.

Full suite

docker build --target ci -t postal:ci .
POSTAL_IMAGE=postal:ci docker compose run --rm postal sh -c 'bundle exec rspec'

Result: 831 examples, 2 failures. Both failures are in
spec/lib/dns_resolver_spec.rb and depend on the runner's external DNS behaviour:

  • DNSResolver#a ... raises an error when the timeout is exceeded — expects a
    resolver timeout that did not fire in this environment.
  • DNSResolver when using a resolver for a domain will not return domains that are not hosted on that server — expects example.com to resolve to nothing, but the
    sandbox resolved it to live Cloudflare addresses (104.20.23.154, 172.66.147.243).

Neither file is touched by this change: both depend on external DNS behaviour and are
unrelated to the SMTP client, so they fail independently of the address-preference
work.

Targeted specs (same image)

POSTAL_IMAGE=postal:ci docker compose run --rm postal sh -c \
  'bundle exec rspec spec/lib/smtp_client/address_preferences_spec.rb \
                    spec/lib/smtp_client/server_spec.rb \
                    spec/senders/smtp_sender_spec.rb'

Result: 65 examples, 0 failures.

Lint

RuboCop pinned to the lockfile version (1.48.1):

rubocop app/lib/smtp_client lib/postal/config_schema.rb spec/lib/smtp_client

No offences in the new/changed code. (The two existing offences in
lib/postal/config_schema.rb are present on main too.)

Behaviour

All four modes were exercised against a dual-stack hostname:

Preference Returned endpoint order
ipv6 IPv6, IPv6, IPv4, IPv4
ipv4 IPv4, IPv4, IPv6, IPv6
ipv6_only IPv6, IPv6
ipv4_only IPv4, IPv4
banana IPv6-first default (+ 1 warning) for tests

Strict mode with the preferred family absent (ipv4_only + AAAA-only, and
ipv6_only + A-only) returns no endpoints, which makes SMTPSender#start soft-fail
as intended.

Existing behaviour is preserved: the default remains IPv6-first, and
spec/senders/smtp_sender_spec.rb is unchanged.

Backward compatibility

  • No behaviour change unless smtp_client.address_preference is configured.
  • The new schema attribute is additive; legacy config files keep working and simply
    use the default.

Checklist

  • Tests added for all four modes and edge cases
  • Config documented (doc/config/yaml.yml, doc/config/environment-variables.md)
  • Backward compatible (default preserves existing IPv6-first behaviour)
  • Full test suite run in CI, Docker only (831 examples, 2 unrelated DNS/environment failures — see Testing)

… SMTP client

Controls which IP address family is used for outgoing SMTP connections when a
remote host publishes both AAAA and A records. smtp_client.address_preference
accepts ipv6 (default), ipv4 (preferred first, other family used as a fallback),
ipv6_only or ipv4_only (only that family is used). This allows avoiding IPv6 for
outgoing mail where the sending IP has no matching PTR record (e.g. Google's
FCrDNS check) without disabling IPv6 on the host.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants