Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,6 +34,47 @@ 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

**⚠️ 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.

## License

European Union Public License 1.1 ([details](http://ec.europa.eu/idabc/eupl.html)) or later.
Expand Down
33 changes: 33 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,36 @@ 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.
#
# 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
# targetHost = "127.0.0.1" # Internal SMTP server (e.g., Haraka) to use instead of MX lookup
70 changes: 70 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -1415,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();
};

Expand Down