diff --git a/docs/reference/modifiers/envelope.md b/docs/reference/modifiers/envelope.md index 0e101cf6..fa9d4a85 100644 --- a/docs/reference/modifiers/envelope.md +++ b/docs/reference/modifiers/envelope.md @@ -7,10 +7,13 @@ to specify 1:N mappings. This allows, for example, implementing mailing lists. The address is normalized before lookup (Punycode in domain-part is decoded, Unicode is normalized to NFC, the whole string is case-folded). -First, the whole address is looked up. If there is no replacement, local-part -of the address is looked up separately and is replaced in the address while -keeping the domain part intact. Replacements are not applied recursively, that -is, lookup is not repeated for the replacement. +First, the whole address is looked up. If there is no replacement, or the +lookup returns exactly one value equal to that normalized address, the +local-part is looked up separately and replaced while the domain part is kept +intact. An explicit identity mapping of the whole address is that same +unchanged result, so it cannot suppress a relative alias. Any other exact +result is used as-is. Replacements are not applied recursively, that is, +lookup is not repeated for the replacement. Recipients are not deduplicated after expansion, so message may be delivered multiple times to a single recipient. However, used delivery target can apply diff --git a/internal/modify/replace_addr.go b/internal/modify/replace_addr.go index 7b3e9842..db226576 100644 --- a/internal/modify/replace_addr.go +++ b/internal/modify/replace_addr.go @@ -110,6 +110,17 @@ func (r *replaceAddr) rewrite(ctx context.Context, val string) ([]string, error) if err != nil { return []string{val}, err } + + mbox, domain, splitErr := address.Split(normAddr) + // An unchanged singleton is not an exact replacement. table.chain optional + // steps echo the lookup key on a miss, and an explicit identity mapping of + // the whole address is the same result; either way a relative alias still + // applies. Any other exact result is returned as-is and is not rewritten + // again. Domain-less postmaster is its own local-part, so that echo stays + // on the exact path instead of being joined with an empty domain. + if splitErr == nil && domain != "" && len(replacements) == 1 && replacements[0] == normAddr { + replacements = nil + } if len(replacements) > 0 { for _, replacement := range replacements { if !address.Valid(replacement) { @@ -119,8 +130,7 @@ func (r *replaceAddr) rewrite(ctx context.Context, val string) ([]string, error) return replacements, nil } - mbox, domain, err := address.Split(normAddr) - if err != nil { + if splitErr != nil { // If we have malformed address here, something is really wrong, but let's // ignore it silently then anyway. return []string{val}, nil diff --git a/internal/modify/replace_addr_test.go b/internal/modify/replace_addr_test.go index 35ebbb2e..989900fc 100644 --- a/internal/modify/replace_addr_test.go +++ b/internal/modify/replace_addr_test.go @@ -97,6 +97,13 @@ func testReplaceAddr(t *testing.T, modName string) { "\u00E9@foo.example.com": []string{"rcpt@foo.example.com"}, }) + // An unchanged exact result still allows a relative alias. + test("cat@example.org", []string{"dog@example.org"}, + map[string][]string{ + "cat@example.org": []string{"cat@example.org"}, + "cat": []string{"dog"}, + }) + if modName == "modify.replace_rcpt" { //multiple aliases test("test@example.com", []string{"test@example.org", "test@example.net"},