table: Merge 'replace_sender', 'replace_rcpt' into 'alias'

With 'regexp' and 'static' tables, separate implementations in replace_*
are not necessary.
This commit is contained in:
fox.cpp 2020-03-06 04:19:28 +03:00
parent a5288aa27a
commit aa1804c66d
No known key found for this signature in database
GPG key ID: E76D97CCEDE90B6C
6 changed files with 131 additions and 396 deletions

View file

@ -560,118 +560,55 @@ Valid values:
Allow multiple addresses in From header field for purposes of
require_sender_match checks. Only first address will be checked, however.
# Sender/recipient replacement modules (replace_sender, replace_rcpt)
# Envelope sender / recipient rewriting (replace_sender, replace_rcpt)
These are modules that simply replace matching address value(s) with another
in either MAIL FROM or RCPT TO.
'replace_sender' and 'replace_rcpt' modules replace SMTP envelope addresses
based on the mapping defined by the table module (maddy-tables(5)). Currently,
only 1:1 mappings are supported (that is, it is not possible to specify
multiple replacements for a single address).
Matching is done either by full address string or regexp that should match
entire address (it is implicitly wrapped with ^ and $). In either case,
matching is case-insensitive.
The address is normalized before lookup (Punycode in domain-part is decoded,
Unicode is normalized to NFC, the whole string is case-folded).
Configuration is done using arguments or 'from' and 'to'
directives. See below for examples.
```
modify {
# Replace addr@example.com with addr@example.org in MAIL FROM (message
# sender).
replace_sender addr@example.com addr@example.org
# Replace addr@example.com with addr@example.org in RCPT TO (message
# recipient).
replace_rcpt addr@example.com addr@example.org
# Examples below use replace_sender but work exactly the same way for
# replace_rcpt.
# Replace any address matching /-enclosed regexp with com@example.org.
replace_sender /(.+)@example.com/ com@example.org
# You can also reference capture groups in the second argument.
replace_sender /(.+)@example.com/ $1@example.org
}
```
# Recipient aliases (alias)
This module replaces recipient addresses based on the mapping defined
by the table module (maddy-tables(5)). Currently, only 1:1 mappings are
supported (that is, it is not possible to specify multiple replacement for a
single address).
Matching is done case-insensitively. Name without '@' matches local-part with
any domain. Replacements are not applied recursively.
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.
Recipients are not deduplicated after expansion, so message may be delivered
multiple times to a single recipient. However, used delivery target can apply
such deduplication (sql storage does it).
such deduplication (imapsql storage does it).
Definition:
```
alias <table> [table arguments] {
replace_rcpt <table> [table arguments] {
[extended table config]
}
alias <top-level block name> {
table <table> [table arguments]
replace_sender <table> [table arguments] {
[extended table config]
}
```
Use examples:
```
modify {
alias file_map /etc/maddy/aliases
replace_rcpt file_map /etc/maddy/aliases
replace_rcpt static {
entry a@example.org b@example.org
}
replace_rcpt regexp "(.+)@example.net" "$1@example.org"
}
```
Possible contents of /etc/maddy/aliases in the example above:
```
file_map shared_table {
file /etc/maddy/aliases
}
# Replace 'cat' with any domain to 'dog'.
# E.g. cat@example.net -> dog@example.net
cat: dog
# ...
modify {
alias &shared_table
}
```
## Arguments
When used inline, arguments and the configuration block is processes as inline
config for a table module.
## Table usage
The address is normalized before lookup (Punycode in domain-part is decoded,
Unicode is normalized to NFC, the whole string is case-folded).
Then first the full address is looked up, if there is a replacement, it is
used. Otherwise, only local-part is looked up and only it is replaced in the
original address.
Here are examples using syntax of file_map table module:
```
# Replaces dog@domain to cat@domain for any domain.
dog: cat
# Replaces dog@domain to dog@example.com for any domain.
cat: dog@example.com
# Replaces cat@example.org to dog@example.org.
# Takes preference over any-domain alias above.
cat@example.org: dog@example.org
# Crazy madness with quoted local-part.
"a @ b"@example.org: "b @ a"@example.org
"a @ b": "b @ a"
# Postmaster alias is a special case, it should
# always use a full address as a replacement.
postmaster: foobar@example.org
# Not allowed:
#postmaster: foobar
# Replace cat@example.org with cat@example.com.
# Takes priority over the previous line.
cat@example.org: cat@example.com
```
# System command filter (command)

View file

@ -1,116 +0,0 @@
package modify
import (
"context"
"errors"
"fmt"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/internal/address"
"github.com/foxcpp/maddy/internal/buffer"
"github.com/foxcpp/maddy/internal/config"
modconfig "github.com/foxcpp/maddy/internal/config/module"
"github.com/foxcpp/maddy/internal/log"
"github.com/foxcpp/maddy/internal/module"
)
type Modifier struct {
modName string
instName string
inlineArgs []string
table module.Table
log log.Logger
}
func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
if len(inlineArgs) < 1 {
return nil, errors.New("specify the table to use")
}
return &Modifier{
modName: modName,
instName: instName,
inlineArgs: inlineArgs,
log: log.Logger{Name: modName},
}, nil
}
func (m *Modifier) Name() string {
return m.modName
}
func (m *Modifier) InstanceName() string {
return m.instName
}
func (m *Modifier) Init(cfg *config.Map) error {
return modconfig.ModuleFromNode(m.inlineArgs, cfg.Block, cfg.Globals, &m.table)
}
type state struct {
m *Modifier
}
func (m *Modifier) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) {
return state{m: m}, nil
}
func (state) RewriteSender(ctx context.Context, from string) (string, error) {
return from, nil
}
func (s state) RewriteRcpt(ctx context.Context, rcptTo string) (string, error) {
normAddr, err := address.ForLookup(rcptTo)
if err != nil {
return rcptTo, fmt.Errorf("malformed address: %v", err)
}
replacement, ok, err := s.m.table.Lookup(normAddr)
if err == nil && ok {
if !address.Valid(replacement) {
return "", fmt.Errorf("refusing to replace recipient with invalid address %s", replacement)
}
return replacement, nil
}
// Note: be careful to preserve original address case.
// Okay, then attempt to do rewriting using
// only mailbox.
mbox, domain, err := address.Split(normAddr)
if err != nil {
// If we have malformed address here, something is really wrong, but let's
// ignore it silently then anyway.
return rcptTo, nil
}
// mbox is already normalized, since it is a part of address.ForLookup
// result.
replacement, ok, err = s.m.table.Lookup(mbox)
if err == nil && ok {
if strings.Contains(replacement, "@") && !strings.HasPrefix(replacement, `"`) && !strings.HasSuffix(replacement, `"`) {
if !address.Valid(replacement) {
return "", fmt.Errorf("refusing to replace recipient with invalid address %s", replacement)
}
return replacement, nil
}
return replacement + "@" + domain, nil
}
return rcptTo, nil
}
func (state) RewriteBody(ctx context.Context, hdr *textproto.Header, body buffer.Buffer) error {
return nil
}
func (state) Close() error {
return nil
}
func init() {
module.Register("alias", New)
}

View file

@ -1,68 +0,0 @@
package modify
import (
"context"
"testing"
)
type mockTable struct {
db map[string]string
}
func (m mockTable) Lookup(a string) (string, bool, error) {
b, ok := m.db[a]
return b, ok, nil
}
func TestRewriteRcpt(t *testing.T) {
test := func(addr, expected string, aliases map[string]string) {
t.Helper()
s := state{m: &Modifier{
table: mockTable{db: aliases},
}}
actual, err := s.RewriteRcpt(context.Background(), addr)
if err != nil {
t.Fatal(err)
}
if actual != expected {
t.Errorf("want %s, got %s", expected, actual)
}
}
test("test@example.org", "test@example.org", nil)
test("postmaster", "postmaster", nil)
test("test@example.com", "test@example.org",
map[string]string{"test@example.com": "test@example.org"})
test(`"\"test @ test\""@example.com`, "test@example.org",
map[string]string{`"\"test @ test\""@example.com`: "test@example.org"})
test(`test@example.com`, `"\"test @ test\""@example.org`,
map[string]string{`test@example.com`: `"\"test @ test\""@example.org`})
test(`"\"test @ test\""@example.com`, `"\"b @ b\""@example.com`,
map[string]string{`"\"test @ test\""`: `"\"b @ b\""`})
test("TeSt@eXAMple.com", "test@example.org",
map[string]string{"test@example.com": "test@example.org"})
test("test@example.com", "test2@example.com",
map[string]string{"test": "test2"})
test("test@example.com", "test2@example.org",
map[string]string{"test": "test2@example.org"})
test("postmaster", "test2@example.org",
map[string]string{"postmaster": "test2@example.org"})
test("TeSt@examPLE.com", "test2@example.com",
map[string]string{"test": "test2"})
test("test@example.com", "test3@example.com",
map[string]string{
"test@example.com": "test3@example.com",
"test": "test2",
})
test("rcpt@E\u0301.example.com", "rcpt@foo.example.com",
map[string]string{
"rcpt@\u00E9.example.com": "rcpt@foo.example.com",
})
test("E\u0301@foo.example.com", "rcpt@foo.example.com",
map[string]string{
"\u00E9@foo.example.com": "rcpt@foo.example.com",
})
}

View file

@ -3,116 +3,45 @@ package modify
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/internal/address"
"github.com/foxcpp/maddy/internal/buffer"
"github.com/foxcpp/maddy/internal/config"
modconfig "github.com/foxcpp/maddy/internal/config/module"
"github.com/foxcpp/maddy/internal/module"
)
// replaceAddr is a simple module that replaces matching sender (or recipient) address
// in messages.
// in messages using module.Table implementation.
//
// If created with modName = "replace_sender", it will change sender address.
// If created with modName = "replace_rcpt", it will change recipient addresses.
//
// Matching is done by either regexp or plain string. Module arguments are as
// arguments for brevity..
type replaceAddr struct {
modName string
instName string
modName string
instName string
inlineArgs []string
inlineFromArg string
inlineToArg string
replaceSender bool
replaceRcpt bool
// Matchers. Only one is used.
fromString string
fromRegex *regexp.Regexp
// Replacement string
to string
table module.Table
}
func NewReplaceAddr(modName, instName string, _, inlineArgs []string) (module.Module, error) {
r := replaceAddr{
modName: modName,
instName: instName,
inlineArgs: inlineArgs,
replaceSender: modName == "replace_sender",
replaceRcpt: modName == "replace_rcpt",
}
switch len(inlineArgs) {
case 0:
// Not inline definition.
case 2:
r.inlineFromArg = inlineArgs[0]
r.inlineToArg = inlineArgs[1]
default:
return nil, fmt.Errorf("%s: invalid amount of inline arguments", modName)
}
return &r, nil
}
func (r *replaceAddr) Init(m *config.Map) error {
var fromCfg, toCfg string
m.String("from", false, false, r.inlineFromArg, &fromCfg)
m.String("to", false, false, r.inlineToArg, &toCfg)
if _, err := m.Process(); err != nil {
return err
}
if fromCfg == "" {
return fmt.Errorf("%s: missing 'from' argument or directive", r.modName)
}
if toCfg == "" {
return fmt.Errorf("%s: missing 'to' argument or directive", r.modName)
}
if strings.HasPrefix(fromCfg, "/") {
if !strings.HasSuffix(fromCfg, "/") {
return fmt.Errorf("%s: missing trailing slash in 'from' value", r.modName)
}
regex := fromCfg[1 : len(fromCfg)-1]
// Regexp should match entire string, so add anchors
// if they are not present.
if !strings.HasPrefix(regex, "^") {
regex = "^" + regex
}
if !strings.HasSuffix(regex, "$") {
regex = regex + "$"
}
regex = "(?i)" + regex
var err error
r.fromRegex, err = regexp.Compile(regex)
if err != nil {
return fmt.Errorf("%s: %v", r.modName, err)
}
}
if strings.HasSuffix(fromCfg, "/") && !strings.HasPrefix(fromCfg, "/") {
return fmt.Errorf("%s: missing leading slash in 'from' value", r.modName)
}
r.fromString = fromCfg
if strings.HasPrefix(toCfg, "/") || strings.HasSuffix(toCfg, "/") {
return fmt.Errorf("%s: can't use regexp in 'to' value", r.modName)
}
if r.fromRegex == nil && strings.Contains(toCfg, "$") {
return fmt.Errorf("%s: can't reference capture groups in 'to' if 'from' is not a regexp", r.modName)
}
r.to = toCfg
return nil
func (r *replaceAddr) Init(cfg *config.Map) error {
return modconfig.ModuleFromNode(r.inlineArgs, cfg.Block, cfg.Globals, &r.table)
}
func (r replaceAddr) Name() string {
@ -150,25 +79,46 @@ func (r replaceAddr) Close() error {
}
func (r replaceAddr) rewrite(val string) (string, error) {
if r.fromRegex == nil {
if address.Equal(r.fromString, val) {
return r.to, nil
}
return val, nil
normAddr, err := address.ForLookup(val)
if err != nil {
return val, fmt.Errorf("malformed address: %v", err)
}
normVal, err := address.ForLookup(val)
replacement, ok, err := r.table.Lookup(normAddr)
if err != nil {
// Ouch. Should not happen at this point.
return val, err
}
if ok {
if !address.Valid(replacement) {
return "", fmt.Errorf("refusing to replace recipient with the invalid address %s", replacement)
}
return replacement, nil
}
indx := r.fromRegex.FindStringSubmatchIndex(normVal)
if indx == nil {
mbox, domain, err := address.Split(normAddr)
if err != nil {
// If we have malformed address here, something is really wrong, but let's
// ignore it silently then anyway.
return val, nil
}
return string(r.fromRegex.ExpandString([]byte{}, r.to, val, indx)), nil
// mbox is already normalized, since it is a part of address.ForLookup
// result.
replacement, ok, err = r.table.Lookup(mbox)
if err != nil {
return val, err
}
if ok {
if strings.Contains(replacement, "@") && !strings.HasPrefix(replacement, `"`) && !strings.HasSuffix(replacement, `"`) {
if !address.Valid(replacement) {
return "", fmt.Errorf("refusing to replace recipient with invalid address %s", replacement)
}
return replacement, nil
}
return replacement + "@" + domain, nil
}
return val, nil
}
func init() {

View file

@ -5,45 +5,75 @@ import (
"testing"
"github.com/foxcpp/maddy/internal/config"
"github.com/foxcpp/maddy/internal/testutils"
)
func replaceAddrFromArgs(t *testing.T, modName, from, to string) *replaceAddr {
r, err := NewReplaceAddr(modName, "", nil, []string{from, to})
if err != nil {
t.Fatal(err)
}
if err := r.Init(&config.Map{Block: config.Node{}}); err != nil {
t.Fatal(err)
}
return r.(*replaceAddr)
}
func testReplaceAddr(t *testing.T, modName string, rewriter func(*replaceAddr, context.Context, string) (string, error)) {
test := func(from, to string, input, expectedOutput string) {
test := func(addr, expected string, aliases map[string]string) {
t.Helper()
r := replaceAddrFromArgs(t, modName, from, to)
output, err := rewriter(r, context.Background(), input)
mod, err := NewReplaceAddr(modName, "", nil, []string{"dummy"})
if err != nil {
t.Fatal(err)
}
if output != expectedOutput {
t.Fatalf("wrong result: %s != %s", output, expectedOutput)
m := mod.(*replaceAddr)
if err := m.Init(config.NewMap(nil, config.Node{})); err != nil {
t.Fatal(err)
}
m.table = testutils.Table{M: aliases}
var actual string
if modName == "replace_sender" {
actual, err = m.RewriteSender(context.Background(), addr)
if err != nil {
t.Fatal(err)
}
}
if modName == "replace_rcpt" {
actual, err = m.RewriteRcpt(context.Background(), addr)
if err != nil {
t.Fatal(err)
}
}
if actual != expected {
t.Errorf("want %s, got %s", expected, actual)
}
}
test("test@example.org", "test2@example.org", "test@example.org", "test2@example.org")
test("test@EXAmple.org", "test2@example.org", "teST@exaMPLe.org", "test2@example.org")
test(`/test@example\.org/`, "test2@example.org", "test@example.org", "test2@example.org")
test(`/test@EXAmple\.org/`, "test2@example.org", "teST@exaMPLe.org", "test2@example.org")
test(`/example/`, "test2@example.org", "teST@exaMPLe.org", "teST@exaMPLe.org")
test(`/(.+)@example\.org/`, "$1@example.com", "test@example.org", "test@example.com")
test(`/(.+)@example\.org/`, "$1@example.com", "teST@example.org", "teST@example.com")
test(`/(.+)@example\.org/`, "$1@example.com", "teST@example.org", "teST@example.com")
test("rcpt@\u00E9.example.com", "rcpt@foo.example.com", "rcpt@E\u0301.example.com", "rcpt@foo.example.com")
test(`/rcpt@é\.example\.com/`, "rcpt@foo.example.com", "rcpt@E\u0301.example.com", "rcpt@foo.example.com")
test("rcpt@E\u0301.example.com", "rcpt@foo.example.com", "rcpt@\u00E9.example.com", "rcpt@foo.example.com")
test("test@example.org", "test@example.org", nil)
test("postmaster", "postmaster", nil)
test("test@example.com", "test@example.org",
map[string]string{"test@example.com": "test@example.org"})
test(`"\"test @ test\""@example.com`, "test@example.org",
map[string]string{`"\"test @ test\""@example.com`: "test@example.org"})
test(`test@example.com`, `"\"test @ test\""@example.org`,
map[string]string{`test@example.com`: `"\"test @ test\""@example.org`})
test(`"\"test @ test\""@example.com`, `"\"b @ b\""@example.com`,
map[string]string{`"\"test @ test\""`: `"\"b @ b\""`})
test("TeSt@eXAMple.com", "test@example.org",
map[string]string{"test@example.com": "test@example.org"})
test("test@example.com", "test2@example.com",
map[string]string{"test": "test2"})
test("test@example.com", "test2@example.org",
map[string]string{"test": "test2@example.org"})
test("postmaster", "test2@example.org",
map[string]string{"postmaster": "test2@example.org"})
test("TeSt@examPLE.com", "test2@example.com",
map[string]string{"test": "test2"})
test("test@example.com", "test3@example.com",
map[string]string{
"test@example.com": "test3@example.com",
"test": "test2",
})
test("rcpt@E\u0301.example.com", "rcpt@foo.example.com",
map[string]string{
"rcpt@\u00E9.example.com": "rcpt@foo.example.com",
})
test("E\u0301@foo.example.com", "rcpt@foo.example.com",
map[string]string{
"\u00E9@foo.example.com": "rcpt@foo.example.com",
})
}
func TestReplaceAddr_RewriteSender(t *testing.T) {

View file

@ -75,12 +75,14 @@ modifiers local_modifiers {
# <postmaster> address without domain is the standard (RFC 5321) way
# to contact the server owner so redirect it to a real address we
# can handle.
replace_rcpt postmaster postmaster@$(primary_domain)
replace_rcpt static {
postmaster postmaster@$(primary_domain)
}
# Implement plus-address notation.
replace_rcpt /(.+)\+(.+)@(.+)/ $1@$3
# Resolve aliases using text file. See "alias" section
replace_rcpt regexp "(.+)\+(.+)@(.+)" "$1@$3"
# Resolve aliases using text file. See "replace_rcpt" section
# in maddy-filter(5) and "file_table" in maddy-tables(5) for details.
alias file_table /etc/maddy/aliases
replace_rcpt file_table /etc/maddy/aliases
}
limits outbound_limits {