Skip to content

Feature/hybrid mx#74

Open
taxilian wants to merge 3 commits intozone-eu:masterfrom
HamStudy:feature/hybrid-mx
Open

Feature/hybrid mx#74
taxilian wants to merge 3 commits intozone-eu:masterfrom
HamStudy:feature/hybrid-mx

Conversation

@taxilian
Copy link
Copy Markdown
Contributor

Adds optional localDelivery configuration to prevent mail loops when using a hybrid mail setup with external MX services like Google Workspace.

I also added an init log message to make it easier to be sure if the wildduck plugin was loading and if it was picking up the configuration correctly.


Problem: Mail Loops with Catch-All Forwarding

Some organizations (like mine) use Google Workspace as their primary MX but want to host some addresses locally in WildDuck (to save on per-user costs or for internal services). The typical setup:

  1. MX points to Google (e.g., aspmx.l.google.com)
  2. Google Workspace handles some addresses ([email protected])
  3. Google's catch-all forwards unknown addresses to your server via SMTP relay
  4. Your WildDuck server receives forwarded mail for local addresses

The Loop:

When ZoneMTA sends to a local address like [email protected]:

  1. ZoneMTA looks up MX → finds Google
  2. Delivers to Google
  3. Google doesn't know this address (it's local-only)
  4. Google's catch-all forwards it back to your server
  5. Mail loop detected → delivery fails or is delayed

This happens because ZoneMTA has no way to know that [email protected] exists locally—it just follows the MX like any other email.

The Solution

The localDelivery feature tells ZoneMTA to check WildDuck before looking up the MX. If the recipient exists locally, it bypasses the external MX entirely and delivers directly to your internal server.

Since this could be a Bad Thing™ if used incorrectly, defaults to off and must be enabled on a per-domain basis.

["modules/@zone-eu/zonemta-wildduck".localDelivery]
enabled = true
domains = ["example.com"]
targetHost = "mail.mycompany.com"  # Catch-all server

Configuration Requirements

  • Must add "main" to contexts: The queue:route hook runs in the "main" context, so update your plugin config:
    ["modules/@zone-eu/zonemta-wildduck"]
    enabled=["receiver", "sender", "main"]
    • SPF on internal delivery: Since the mail comes from ZoneMTA's internal IP, SPF checks on the receiving server may show fail or softfail. DKIM signatures remain valid—this is usually acceptable for trusted internal delivery. If needed, configure your receiving server (e.g., Haraka) to skip SPF for private/internal IPs.
  • Address exclusivity: If a mailbox exists in BOTH Google and WildDuck, mail will always go to WildDuck.
    Changes
  • Added localDelivery hook logic in index.js (~46 lines)
  • Added startup logging to verify config is loaded correctly
  • Updated config.example.toml with detailed documentation
  • Updated README.md with hybrid setup guide and troubleshooting

Add optional localDelivery configuration to override MX routing for addresses
that exist in WildDuck. This prevents potential delivery issues when using
a hybrid mail setup (e.g., Google Workspace + local WildDuck) where the MX
points to an external service that forwards unknown addresses back.

When enabled, ZoneMTA checks if a recipient exists in WildDuck before
performing the MX lookup. If found, it sets routing.mxData to override
the MX destination, causing delivery to go directly to the configured
internal server instead of using the external MX.

Configuration options:
- enabled: enable/disable the feature (default: false)
- domains: list of domains to check for local recipients
- targetHost: internal SMTP server to use instead of MX lookup (default: 127.0.0.1)
Add info-level log message during plugin initialization that summarizes
the WildDuck plugin configuration. This helps verify that settings like
localDelivery are being loaded correctly without exposing secrets.

Logs: hostname, interfaces, localDelivery status + domains, SRS, DKIM,
ACME, MX routes count, maxRecipients, and upload settings.
@NickOvt NickOvt requested review from NickOvt and andris9 March 20, 2026 08:10
@taxilian
Copy link
Copy Markdown
Contributor Author

Also, this code has been tested as is being used in production on my systems, FWIW

@NickOvt
Copy link
Copy Markdown
Contributor

NickOvt commented Mar 24, 2026

Hello!

Thank you for your PR! This seems like a good change!
Here's a quick PR Review from AI, please check if all is good with it.

Findings

  • High: The new localDelivery override does not disable MTA-STS. The existing relay override explicitly sets skipSTS at index.js:581, but the local-delivery path only injects mx at
    index.js:620. ZoneMTA copies skipSTS into the queued delivery at node_modules/@zone-eu/zone-mta/lib/mail-queue.js:354 and uses it to disable STS enforcement at node_modules/@zone-eu/zone-
    mta/lib/sender.js:1111. For domains publishing MTA-STS, targetHost is typically not a policy-approved MX, so this can still defer/bounce instead of fixing the hybrid-MX loop.
  • Medium: targetPort is documented but never applied. The new docs tell operators to configure it at README.md:58, but the routing code only reads targetHost at index.js:618. ZoneMTA
    already supports custom ports through mxPort at node_modules/@zone-eu/zone-mta/lib/mail-queue.js:344, so any internal relay listening on a non-25 port will silently be contacted on the
    wrong port.
  • Medium: Domain matching is only normalized on the recipient side. The recipient domain is lowercased and punycoded before the comparison at index.js:595, but localDelivery.domains is used
    verbatim at index.js:604. Config entries like Example.com or Unicode domains will never match, so loop prevention quietly disables itself for those domains.

Looking at the code and at the findings I suppose they really do need fixing.

Best regards

@taxilian
Copy link
Copy Markdown
Contributor Author

Here we hit some of the limits of my understanding; I don't fully understand STS so I'll have to see if AI can help with that.

targetPort was intentionally removed from an initial implementation and the documentation updated; my thought was that this is overriding MX and MX doesn't allow overriding port, so it should just use whatever is default. Thoughts? Is this something I need to handle, or just fix the documentation to not mention targetPort?

domain matching - agreed, just didn't think about it. should be an easy fix.

@NickOvt
Copy link
Copy Markdown
Contributor

NickOvt commented Mar 31, 2026

I think that targetPort can stay, as mxconnect and zoneMta allow rewriting MX port. This is especially nice to have during local testing where you set up mx at, let's say, port 2525 instead of port 25.

Regarding MTA-STS, imo, on first glance, it seems you just have to add SkipSTS setting to the delivery in case of using local delivery.

Cheers

@taxilian
Copy link
Copy Markdown
Contributor Author

taxilian commented Apr 1, 2026

I have this mostly figured out, I think, I just keep having things come up before I have time to fully test and button things up.

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