From 9fa729aff0a0e2f240be9fa75ce977151f09982f Mon Sep 17 00:00:00 2001 From: Richard Bateman Date: Thu, 19 Mar 2026 16:55:51 -0600 Subject: [PATCH 1/3] feat: add localDelivery option for hybrid MX setups 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) --- README.md | 33 +++++++++++++++++++++++++++++++ config.example.toml | 23 ++++++++++++++++++++++ index.js | 47 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/README.md b/README.md index 206d419..a898611 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ WildDuck actions apply only to interfaces that require authentication. * **Upload to Sent Mail folder** – sent message is automatically appended to the _Sent Mail_ folder of the user * **Recipient limiting** – limit RCPT TO calls for 24 hour period based on the _recipients_ user value * **Local delivery** – messages that are deliverable to the current WildDuck installation are routed directly to LMTP bypassing MX steps +* **Hybrid mail setup support** – optional local delivery bypass prevents mail loops when using external MX (e.g., Google Workspace) with catch-all forwarding back to your server ## Setup @@ -33,6 +34,38 @@ port=587 Then set up configuration for this plugin, see the [example config](./config.example.toml) file for details. +## Hybrid Mail Setup (e.g., Google Workspace) + +If you use an external mail service (like Google Workspace) as your primary MX but also want to host some addresses locally in WildDuck, you may encounter delivery issues: + +1. ZoneMTA sends email to `user@example.com` +2. MX lookup returns Google's servers +3. Google receives the email but the address doesn't exist there (or routing is delayed) +4. Google's catch-all forwards it back to your server +5. **Potential mail loop** — delivery may fail or be delayed + +This doesn't always fail, but it's unreliable and can cause mail to bounce unexpectedly. + +### The Solution + +Enable the `localDelivery` option. This tells ZoneMTA to check if a recipient exists in WildDuck **before** looking up the MX. If found, it **overrides the MX routing** and delivers directly to your internal mail server instead of sending to the external MX. + +```toml +["modules/@zone-eu/zonemta-wildduck".localDelivery] +enabled = true +domains = ["example.com"] +targetHost = "127.0.0.1" # Your internal SMTP server (Haraka/Postfix) +targetPort = 25 +``` + +### Important Warnings + +**⚠️ Duplicate Mailboxes**: If a mailbox exists in **both** Google Workspace AND WildDuck, this setting will cause unexpected behavior. Mail from ZoneMTA will always be delivered to WildDuck, even if the user expects it in Gmail. Only enable this if you're certain addresses are exclusive to one system or the other. + +**⚠️ Address Exclusivity**: This feature assumes addresses in the configured domains exist in WildDuck OR the external system, not both. Review your address allocation before enabling. + +See `config.example.toml` for all available options. + ## License European Union Public License 1.1 ([details](http://ec.europa.eu/idabc/eupl.html)) or later. diff --git a/config.example.toml b/config.example.toml index 1a7ee0c..40f88eb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -109,3 +109,26 @@ enabled = false #["modules/@zone-eu/zonemta-wildduck".mxRoutes] # "*.l.google.com" = "gmail" + +# Local Delivery Bypass (MX Override) +# ----------------------------------- +# Use this when running a hybrid mail setup (e.g., Google Workspace + local WildDuck) +# where the MX records point to an external service (Google) which then forwards +# unknown addresses back to your local server via a catch-all route. +# +# Without this setting, ZoneMTA would look up the MX, see Google, and deliver there. +# Google would then try to deliver back to you, potentially causing delivery delays +# or mail loops. +# +# When enabled, ZoneMTA checks if the recipient exists in WildDuck BEFORE doing +# the MX lookup. If found, it overrides the MX routing and delivers directly to +# your internal server instead. +# +# WARNING: This overrides the MX routing for local addresses. If a mailbox exists +# in BOTH Google Workspace AND WildDuck, mail will always go to WildDuck. Only use +# this if addresses are exclusive to one system or the other. +# +# ["modules/@zone-eu/zonemta-wildduck".localDelivery] +# enabled = false # Set to true to enable MX override for local addresses +# domains = ["example.com"] # List of domains to check for local recipients +# targetHost = "127.0.0.1" # Internal SMTP server (e.g., Haraka) to use instead of MX lookup diff --git a/index.js b/index.js index 71c253e..6f79067 100644 --- a/index.js +++ b/index.js @@ -587,6 +587,53 @@ module.exports.init = function (app, done) { }); }); + // Check for local delivery bypass + // This prevents mail loops when using a hybrid setup (e.g., Google Workspace + local WildDuck) + // where the MX points to an external service that forwards back to us + app.logger.info('LocalDelivery', 'DEBUG recipient=%s routing.mxData=%s config.localDelivery=%s', recipient, !!routing.mxData, JSON.stringify(app.config.localDelivery)); + if (!routing.mxData && recipient && app.config.localDelivery && app.config.localDelivery.enabled) { + let domain = recipient && recipient.substring(recipient.indexOf('@') + 1).toLowerCase().trim(); + + try { + domain = punycode.toASCII(domain); + } catch (err) { + // ignore punycode errors + } + + // Check if this domain is in the local delivery list + const localDomains = [].concat(app.config.localDelivery.domains || []); + if (localDomains.includes(domain)) { + // Check if recipient exists in WildDuck + const isLocal = await new Promise((resolve) => { + userHandler.resolveAddress(recipient, { wildcard: true }, (err, addressData) => { + if (err || !addressData) { + return resolve(false); + } + // addressData.user exists if it's a valid local address + resolve(!!addressData.user); + }); + }); + + if (isLocal) { + const targetHost = app.config.localDelivery.targetHost || '127.0.0.1'; + + routing.mxData = { + mx: [{ priority: 0, exchange: targetHost }] + }; + + app.logger.info( + 'LocalDelivery', + '%s LOCALDELIVERY recipient=%s domain=%s target=%s', + envelope.id, + recipient, + domain, + targetHost + ); + return; + } + } + } + if (deliveryZone !== 'default' || !app.config.mxRoutes) { return; } From 3b3817eabe7f2b692e36861301d596f82dc164c5 Mon Sep 17 00:00:00 2001 From: Richard Bateman Date: Thu, 19 Mar 2026 22:59:01 -0600 Subject: [PATCH 2/3] feat: add startup logging for plugin configuration 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. --- index.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/index.js b/index.js index 6f79067..5a33405 100644 --- a/index.js +++ b/index.js @@ -1462,6 +1462,29 @@ module.exports.init = function (app, done) { ); } + const localDeliveryEnabled = !!(app.config.localDelivery && app.config.localDelivery.enabled); + const localDeliveryDomains = localDeliveryEnabled && app.config.localDelivery.domains + ? app.config.localDelivery.domains.join(',') + : 'none'; + const srsEnabled = !!(app.config.srs && app.config.srs.enabled); + const dkimEnabled = !!(app.config.dkim && app.config.dkim.signTransportDomain); + const acmeEnabled = !!(app.config.acme && app.config.acme.autogenerate && app.config.acme.autogenerate.enabled); + const mxRoutesCount = app.config.mxRoutes ? Object.keys(app.config.mxRoutes).length : 0; + + app.logger.info('WildDuck', + 'Initialized hostname=%s interfaces=%s localDelivery=%s(localDomains=%s) srs=%s dkim=%s acme=%s mxRoutes=%s maxRecipients=%s uploads=%s', + app.config.hostname || 'default', + [].concat(app.config.interfaces || '*').join(','), + localDeliveryEnabled ? 'enabled' : 'disabled', + localDeliveryDomains, + srsEnabled ? 'enabled' : 'disabled', + dkimEnabled ? 'enabled' : 'disabled', + acmeEnabled ? 'enabled' : 'disabled', + mxRoutesCount, + app.config.maxRecipients || 'default', + app.config.disableUploads ? 'disabled' : (app.config.uploadAll ? 'all' : 'filtered') + ); + done(); }; From 1df584871c8cdf9d7def0e677d6c34d1aef75148 Mon Sep 17 00:00:00 2001 From: Richard Bateman Date: Fri, 20 Mar 2026 00:38:58 -0600 Subject: [PATCH 3/3] document need for "main" context and possibility of spf failures --- README.md | 9 +++++++++ config.example.toml | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index a898611..2202bc0 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,17 @@ targetPort = 25 ### Important Warnings +**⚠️ Context Requirement**: If using `localDelivery`, you **MUST** add `"main"` to the enabled contexts: +```toml +["modules/@zone-eu/zonemta-wildduck"] +enabled=["receiver", "sender", "main"] +``` +The `queue:route` hook (which enables localDelivery) runs in the "main" context. Without this, localDelivery will not work. + **⚠️ Duplicate Mailboxes**: If a mailbox exists in **both** Google Workspace AND WildDuck, this setting will cause unexpected behavior. Mail from ZoneMTA will always be delivered to WildDuck, even if the user expects it in Gmail. Only enable this if you're certain addresses are exclusive to one system or the other. +**⚠️ SPF on Internal Delivery**: When delivering locally via `targetHost`, the receiving server sees ZoneMTA's internal IP instead of the original sender IP. This can cause SPF checks to fail on the internal hop (e.g., `spf=fail` in Haraka logs). **DKIM signatures remain valid** and are unaffected. If SPF failures are problematic, configure your receiving server to skip SPF for trusted internal IPs (e.g., Haraka's private IP range). + **⚠️ Address Exclusivity**: This feature assumes addresses in the configured domains exist in WildDuck OR the external system, not both. Review your address allocation before enabling. See `config.example.toml` for all available options. diff --git a/config.example.toml b/config.example.toml index 40f88eb..bfe5de4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -128,6 +128,16 @@ enabled = false # in BOTH Google Workspace AND WildDuck, mail will always go to WildDuck. Only use # this if addresses are exclusive to one system or the other. # +# IMPORTANT: If using localDelivery, you MUST add "main" to the enabled contexts: +# enabled=["receiver", "sender", "main"] +# The queue:route hook (which enables localDelivery) runs in the "main" context. +# +# NOTE: When delivering locally via targetHost, the receiving server may see the +# internal ZoneMTA IP instead of the original sender IP. This can cause SPF checks +# to fail on the internal hop. DKIM signatures remain valid. If SPF failures +# are problematic, configure your receiving server (e.g., Haraka) to skip SPF +# for trusted internal IPs. +# # ["modules/@zone-eu/zonemta-wildduck".localDelivery] # enabled = false # Set to true to enable MX override for local addresses # domains = ["example.com"] # List of domains to check for local recipients