author | Alberto Bertogli
<albertito@blitiri.com.ar> 2016-09-19 21:45:58 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2016-10-09 23:51:04 UTC |
parent | aacf8ffea72d1dce7f6e9c12598df773a9fd8785 |
internal/aliases/aliases.go | +304 | -0 |
internal/aliases/aliases_test.go | +247 | -0 |
diff --git a/internal/aliases/aliases.go b/internal/aliases/aliases.go new file mode 100644 index 0000000..cafce0c --- /dev/null +++ b/internal/aliases/aliases.go @@ -0,0 +1,304 @@ +// Package aliases implements an email aliases resolver. +// +// The resolver can parse many files for different domains, and perform +// lookups to resolve the aliases. +// +// +// File format +// +// It generally follows the traditional aliases format used by sendmail and +// exim. +// +// The file can contain lines of the form: +// +// user: address, address +// user: | command +// +// Lines starting with "#" are ignored, as well as empty lines. +// User names cannot contain spaces, ":" or commas, for parsing reasons. This +// is a tradeoff between flexibility and keeping the file format easy to edit +// for people. +// +// Usually there will be one database per domain, and there's no need to +// include the "@" in the user (in this case, "@" will be forbidden). +// +// +// Recipients +// +// Recipients can be of different types: +// - Email: the usual user@domain we all know and love, this is the default. +// - Pipe: if the right side starts with "| ", the rest of the line specifies +// a command to pipe the email through. +// Command and arguments are space separated. No quoting, escaping, or +// replacements of any kind. +// +// +// Lookups +// +// The resolver will perform lookups recursively, until it finds all the final +// recipients. +// +// There are recursion limits to avoid alias loops. If the limit is reached, +// theat entire resolution will fail. +// +// +// Suffix removal +// +// The resolver can also remove suffixes from emails, and drop characters +// completely. This can be used to turn "user+blah@domain" into "user@domain", +// and "us.er@domain" into "user@domain". +// +// Both are optional, and the characters configurable globally. +package aliases + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + + "blitiri.com.ar/go/chasquid/internal/envelope" +) + +// Recipient represents a single recipient, after resolving aliases. +// They don't have any special interface, the callers will do a type switch +// anyway. +type Recipient struct { + Addr string + Type RType +} + +type RType int + +const ( + EMAIL RType = iota + PIPE +) + +var ( + ErrRecursionLimitExceeded = fmt.Errorf("recursion limit exceeded") + + // How many levels of recursions we allow during lookups. + // We don't expect much recursion, so keeping this low to catch errors + // quickly. + recursionLimit = 10 +) + +// Resolver represents the aliases resolver. +type Resolver struct { + // Suffix separator, to perform suffix removal. + SuffixSep string + + // Characters to drop from the user part. + DropChars string + + // Map of domain -> alias files for that domain. + // We keep track of them for reloading purposes. + files map[string][]string + domains map[string]bool + + // Map of address -> aliases. + aliases map[string][]Recipient + + // Mutex protecting the structure. + mu sync.Mutex +} + +func NewResolver() *Resolver { + return &Resolver{ + files: map[string][]string{}, + domains: map[string]bool{}, + aliases: map[string][]Recipient{}, + } +} + +func (v *Resolver) Resolve(addr string) ([]Recipient, error) { + v.mu.Lock() + defer v.mu.Unlock() + + return v.resolve(0, addr) +} + +func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) { + if rcount >= recursionLimit { + return nil, ErrRecursionLimitExceeded + } + + // Drop suffixes and chars to get the "clean" address before resolving. + // This also means that we will return the clean version if there's no + // match, which our callers can rely upon. + addr = v.cleanIfLocal(addr) + + rcpts := v.aliases[addr] + if len(rcpts) == 0 { + return []Recipient{Recipient{addr, EMAIL}}, nil + } + + ret := []Recipient{} + for _, r := range rcpts { + // Only recurse for email recipients. + if r.Type != EMAIL { + ret = append(ret, r) + continue + } + + ar, err := v.resolve(rcount+1, r.Addr) + if err != nil { + return nil, err + } + + ret = append(ret, ar...) + } + + return ret, nil +} + +func (v *Resolver) cleanIfLocal(addr string) string { + user, domain := envelope.Split(addr) + + if !v.domains[domain] { + return addr + } + + user = removeAllAfter(user, v.SuffixSep) + user = removeChars(user, v.DropChars) + return user + "@" + domain +} + +func (v *Resolver) AddDomain(domain string) { + v.mu.Lock() + v.domains[domain] = true + v.mu.Unlock() +} + +func (v *Resolver) AddAliasesFile(domain, path string) error { + aliases, err := parseFile(domain, path) + if err != nil { + return err + } + + v.mu.Lock() + v.files[domain] = append(v.files[domain], path) + v.domains[domain] = true + + // Add the aliases to the resolver, overriding any previous values. + for addr, rs := range aliases { + v.aliases[addr] = rs + } + v.mu.Unlock() + + return nil +} + +func (v *Resolver) Reload() error { + newAliases := map[string][]Recipient{} + + for domain, paths := range v.files { + for _, path := range paths { + aliases, err := parseFile(domain, path) + if err != nil { + return fmt.Errorf("Error parsing %q: %v", path, err) + } + + // Add the aliases to the resolver, overriding any previous values. + for addr, rs := range aliases { + newAliases[addr] = rs + } + } + } + + v.mu.Lock() + v.aliases = newAliases + v.mu.Unlock() + + return nil +} + +func parseFile(domain, path string) (map[string][]Recipient, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + aliases := map[string][]Recipient{} + + scanner := bufio.NewScanner(f) + for i := 1; scanner.Scan(); i++ { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") { + continue + } + + sp := strings.SplitN(line, ":", 2) + if len(sp) != 2 { + continue + } + + addr, rawalias := strings.TrimSpace(sp[0]), strings.TrimSpace(sp[1]) + if len(addr) == 0 || len(rawalias) == 0 { + continue + } + + if strings.Contains(addr, "@") { + // It's invalid for lhs addresses to contain @ (for now). + continue + } + + addr = addr + "@" + domain + + if rawalias[0] == '|' { + cmd := strings.TrimSpace(rawalias[1:]) + aliases[addr] = []Recipient{Recipient{cmd, PIPE}} + } else { + rs := []Recipient{} + for _, a := range strings.Split(rawalias, ",") { + a = strings.TrimSpace(a) + if a == "" { + continue + } + // Addresses with no domain get the current one added, so it's + // easier to share alias files. + if !strings.Contains(a, "@") { + a = a + "@" + domain + } + rs = append(rs, Recipient{a, EMAIL}) + } + aliases[addr] = rs + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading %q: %v", path, err) + } + + return aliases, nil +} + +// removeAllAfter removes everything from s that comes after the separators, +// including them. +func removeAllAfter(s, seps string) string { + for _, c := range strings.Split(seps, "") { + if c == "" { + continue + } + + i := strings.Index(s, c) + if i == -1 { + continue + } + + s = s[:i] + } + + return s +} + +// removeChars removes the runes in "chars" from s. +func removeChars(s, chars string) string { + for _, c := range strings.Split(chars, "") { + s = strings.Replace(s, c, "", -1) + } + + return s +} diff --git a/internal/aliases/aliases_test.go b/internal/aliases/aliases_test.go new file mode 100644 index 0000000..3f8991a --- /dev/null +++ b/internal/aliases/aliases_test.go @@ -0,0 +1,247 @@ +package aliases + +import ( + "io/ioutil" + "os" + "reflect" + "testing" +) + +type Cases []struct { + addr string + expect []Recipient +} + +func (cases Cases) check(t *testing.T, r *Resolver) { + for _, c := range cases { + got, err := r.Resolve(c.addr) + if err != nil { + t.Errorf("case %q, got error: %v", c.addr, err) + continue + } + if !reflect.DeepEqual(got, c.expect) { + t.Errorf("case %q, got %+v, expected %+v", c.addr, got, c.expect) + } + } +} + +func TestBasic(t *testing.T) { + resolver := NewResolver() + resolver.aliases = map[string][]Recipient{ + "a@b": {{"c@d", EMAIL}, {"e@f", EMAIL}}, + "e@f": {{"cmd", PIPE}}, + "cmd": {{"x@y", EMAIL}}, // it's a trap! + } + + cases := Cases{ + {"a@b", []Recipient{{"c@d", EMAIL}, {"cmd", PIPE}}}, + {"e@f", []Recipient{{"cmd", PIPE}}}, + {"x@y", []Recipient{{"x@y", EMAIL}}}, + } + cases.check(t, resolver) +} + +func TestAddrRewrite(t *testing.T) { + resolver := NewResolver() + resolver.AddDomain("def") + resolver.AddDomain("p-q.com") + resolver.aliases = map[string][]Recipient{ + "abc@def": {{"x@y", EMAIL}}, + "ñoño@def": {{"x@y", EMAIL}}, + "recu@def": {{"ab+cd@p-q.com", EMAIL}}, + } + resolver.DropChars = ".~" + resolver.SuffixSep = "-+" + + cases := Cases{ + {"abc@def", []Recipient{{"x@y", EMAIL}}}, + {"a.b.c@def", []Recipient{{"x@y", EMAIL}}}, + {"a~b~c@def", []Recipient{{"x@y", EMAIL}}}, + {"a.b~c@def", []Recipient{{"x@y", EMAIL}}}, + {"abc-ñaca@def", []Recipient{{"x@y", EMAIL}}}, + {"abc-ñaca@def", []Recipient{{"x@y", EMAIL}}}, + {"abc-xyz@def", []Recipient{{"x@y", EMAIL}}}, + {"abc+xyz@def", []Recipient{{"x@y", EMAIL}}}, + {"abc-x.y+z@def", []Recipient{{"x@y", EMAIL}}}, + + {"ñ.o~ño-ñaca@def", []Recipient{{"x@y", EMAIL}}}, + + // Don't mess with the domain, even if it's known. + {"a.bc-ñaca@p-q.com", []Recipient{{"abc@p-q.com", EMAIL}}}, + + // Clean the right hand side too (if it's a local domain). + {"recu+blah@def", []Recipient{{"ab@p-q.com", EMAIL}}}, + + // We should not mess with emails for domains we don't know. + {"xy@z.com", []Recipient{{"xy@z.com", EMAIL}}}, + {"x.y@z.com", []Recipient{{"x.y@z.com", EMAIL}}}, + {"x-@y-z.com", []Recipient{{"x-@y-z.com", EMAIL}}}, + {"x+blah@y", []Recipient{{"x+blah@y", EMAIL}}}, + } + cases.check(t, resolver) +} + +func TestTooMuchRecursion(t *testing.T) { + resolver := Resolver{} + resolver.aliases = map[string][]Recipient{ + "a@b": {{"c@d", EMAIL}}, + "c@d": {{"a@b", EMAIL}}, + } + + rs, err := resolver.Resolve("a@b") + if err != ErrRecursionLimitExceeded { + t.Errorf("expected ErrRecursionLimitExceeded, got %v", err) + } + + if rs != nil { + t.Errorf("expected nil recipients, got %+v", rs) + } +} + +func mustWriteFile(t *testing.T, content string) string { + f, err := ioutil.TempFile("", "aliases_test") + if err != nil { + t.Fatalf("failed to get temp file: %v", err) + } + defer f.Close() + + _, err = f.WriteString(content) + if err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + return f.Name() +} + +func TestAddFile(t *testing.T) { + cases := []struct { + contents string + expected []Recipient + }{ + {"\n", []Recipient{{"a@dom", EMAIL}}}, + {" # Comment\n", []Recipient{{"a@dom", EMAIL}}}, + {":\n", []Recipient{{"a@dom", EMAIL}}}, + {"a: \n", []Recipient{{"a@dom", EMAIL}}}, + {"a@dom: b@c \n", []Recipient{{"a@dom", EMAIL}}}, + + {"a: b\n", []Recipient{{"b@dom", EMAIL}}}, + {"a:b\n", []Recipient{{"b@dom", EMAIL}}}, + {"a : b \n", []Recipient{{"b@dom", EMAIL}}}, + {"a : b, \n", []Recipient{{"b@dom", EMAIL}}}, + + {"a: |cmd\n", []Recipient{{"cmd", PIPE}}}, + {"a:|cmd\n", []Recipient{{"cmd", PIPE}}}, + {"a:| cmd \n", []Recipient{{"cmd", PIPE}}}, + {"a :| cmd \n", []Recipient{{"cmd", PIPE}}}, + {"a: | cmd arg1 arg2\n", []Recipient{{"cmd arg1 arg2", PIPE}}}, + + {"a: c@d, e@f, g\n", + []Recipient{{"c@d", EMAIL}, {"e@f", EMAIL}, {"g@dom", EMAIL}}}, + } + + for _, c := range cases { + fname := mustWriteFile(t, c.contents) + defer os.Remove(fname) + + resolver := NewResolver() + err := resolver.AddAliasesFile("dom", fname) + if err != nil { + t.Fatalf("error adding file: %v", err) + } + + got, err := resolver.Resolve("a@dom") + if err != nil { + t.Errorf("case %q, got error: %v", c.contents, err) + continue + } + if !reflect.DeepEqual(got, c.expected) { + t.Errorf("case %q, got %v, expected %v", c.contents, got, c.expected) + } + } +} + +const richFileContents = ` +# This is a "complex" alias file, with a few tricky situations. +# It is used in TestRichFile. + +# First some valid cases. +a: b +c: d@e, f, +x: | command + +# The following is invalid, should be ignored. +a@dom: x@dom + +# Overrides. +o1: a +o1: b + +# Finally one to make the file NOT end in \n: +y: z` + +func TestRichFile(t *testing.T) { + fname := mustWriteFile(t, richFileContents) + defer os.Remove(fname) + + resolver := NewResolver() + err := resolver.AddAliasesFile("dom", fname) + if err != nil { + t.Fatalf("failed to add file: %v", err) + } + + cases := Cases{ + {"a@dom", []Recipient{{"b@dom", EMAIL}}}, + {"c@dom", []Recipient{{"d@e", EMAIL}, {"f@dom", EMAIL}}}, + {"x@dom", []Recipient{{"command", PIPE}}}, + {"o1@dom", []Recipient{{"b@dom", EMAIL}}}, + {"y@dom", []Recipient{{"z@dom", EMAIL}}}, + } + cases.check(t, resolver) +} + +func TestManyFiles(t *testing.T) { + files := map[string]string{ + "d1": mustWriteFile(t, "a: b\nc:d@e"), + "domain2": mustWriteFile(t, "a: b\nc:d@e"), + "dom3": mustWriteFile(t, "x: y, z"), + "dom4": mustWriteFile(t, "a: |cmd"), + + // Cross-domain. + "xd1": mustWriteFile(t, "a: b@xd2"), + "xd2": mustWriteFile(t, "b: |cmd"), + } + for _, fname := range files { + defer os.Remove(fname) + } + + resolver := NewResolver() + for domain, fname := range files { + err := resolver.AddAliasesFile(domain, fname) + if err != nil { + t.Fatalf("failed to add file: %v", err) + } + } + + check := func() { + cases := Cases{ + {"a@d1", []Recipient{{"b@d1", EMAIL}}}, + {"c@d1", []Recipient{{"d@e", EMAIL}}}, + {"x@d1", []Recipient{{"x@d1", EMAIL}}}, + {"a@domain2", []Recipient{{"b@domain2", EMAIL}}}, + {"c@domain2", []Recipient{{"d@e", EMAIL}}}, + {"x@dom3", []Recipient{{"y@dom3", EMAIL}, {"z@dom3", EMAIL}}}, + {"a@dom4", []Recipient{{"cmd", PIPE}}}, + {"a@xd1", []Recipient{{"cmd", PIPE}}}, + } + cases.check(t, resolver) + } + + check() + + // Reload, and check again just in case. + if err := resolver.Reload(); err != nil { + t.Fatalf("failed to reload: %v", err) + } + + check() +}