mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-17 14:37:02 +00:00
aliases: Implement aliases hooks
This patch implements two new hooks: alias-resolve and alias-exists. They are called during the aliases resolution process, to allow for more complex integration with other systems, such as storing the aliases in a database. See the included documentation for more details.
This commit is contained in:
@@ -92,7 +92,7 @@ func main() {
|
|||||||
s := smtpsrv.NewServer()
|
s := smtpsrv.NewServer()
|
||||||
s.Hostname = conf.Hostname
|
s.Hostname = conf.Hostname
|
||||||
s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
|
s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
|
||||||
s.PostDataHook = "hooks/post-data"
|
s.HookPath = "hooks/"
|
||||||
|
|
||||||
s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
|
s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
|
||||||
|
|
||||||
|
|||||||
@@ -77,5 +77,16 @@ The `chasquid-util` command-line tool can be used to check and resolve
|
|||||||
aliases.
|
aliases.
|
||||||
|
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
There are two hooks that allow more sophisticated aliases resolution:
|
||||||
|
`alias-exists` and `alias-resolve`.
|
||||||
|
|
||||||
|
If they exist, they are invoked as part of the resolution process and the
|
||||||
|
results are merged with the file-based resolution results.
|
||||||
|
|
||||||
|
See the [hooks](hooks.md) documentation for more details.
|
||||||
|
|
||||||
|
|
||||||
[chasquid]: https://blitiri.com.ar/p/chasquid
|
[chasquid]: https://blitiri.com.ar/p/chasquid
|
||||||
[email aliases]: https://en.wikipedia.org/wiki/Email_alias
|
[email aliases]: https://en.wikipedia.org/wiki/Email_alias
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
|
||||||
# Post-DATA hook
|
# Hooks
|
||||||
|
|
||||||
|
chasquid supports some functionality via hooks, which are binaries that get
|
||||||
|
executed at specific points in time during delivery.
|
||||||
|
|
||||||
|
They are optional, and will be skipped if they don't exist.
|
||||||
|
|
||||||
|
|
||||||
|
## Post-DATA hook
|
||||||
|
|
||||||
After completion of DATA, but before accepting the mail for queueing, chasquid
|
After completion of DATA, but before accepting the mail for queueing, chasquid
|
||||||
will run the command at `$config_dir/hooks/post-data`.
|
will run the command at `$config_dir/hooks/post-data`.
|
||||||
@@ -21,7 +29,7 @@ This hook can be used to block based on contents, for example to check for
|
|||||||
spam or virus. See `etc/hooks/post-data` for an example.
|
spam or virus. See `etc/hooks/post-data` for an example.
|
||||||
|
|
||||||
|
|
||||||
## Environment
|
### Environment
|
||||||
|
|
||||||
This hook will run as the chasquid user, so be careful about permissions and
|
This hook will run as the chasquid user, so be careful about permissions and
|
||||||
privileges.
|
privileges.
|
||||||
@@ -43,3 +51,34 @@ The environment will contain the following variables:
|
|||||||
There is a 1 minute timeout for hook execution.
|
There is a 1 minute timeout for hook execution.
|
||||||
It will be run at the config directory.
|
It will be run at the config directory.
|
||||||
|
|
||||||
|
|
||||||
|
## Alias resolve hook
|
||||||
|
|
||||||
|
When an alias needs to be resolved, chasquid will run the command at
|
||||||
|
`$config_dir/hooks/alias-resolve` (if the file exists).
|
||||||
|
|
||||||
|
The address to resolve will be passed as the single argument.
|
||||||
|
|
||||||
|
The output of the command will be parsed as if it was the right-hand side of
|
||||||
|
the aliases configuration file (see [Aliases](aliases.md) for more details).
|
||||||
|
Results are appended to the results of the file-based alias resolution.
|
||||||
|
|
||||||
|
If there is no alias for the address, the hook should just exit successfuly
|
||||||
|
without emitting any output.
|
||||||
|
|
||||||
|
There is a 5 second timeout for hook execution. If the hook exits with an
|
||||||
|
error, including timeout, delivery will fail.
|
||||||
|
|
||||||
|
|
||||||
|
## Alias exists hook
|
||||||
|
|
||||||
|
When chasquid needs to check whether an alias exists or not, it will run the
|
||||||
|
command at `$config_dir/hooks/alias-exists` (if the file exists).
|
||||||
|
|
||||||
|
The address to check will be passed as the single argument.
|
||||||
|
|
||||||
|
If the commands exits successfuly (exit code 0), then the alias exists; any
|
||||||
|
other exit code signals that the alias does not exist.
|
||||||
|
|
||||||
|
There is a 5 second timeout for hook execution. If the hook times out, the
|
||||||
|
alias will be assumed not to exist.
|
||||||
|
|||||||
@@ -55,14 +55,24 @@ package aliases
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"expvar"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"blitiri.com.ar/go/chasquid/internal/envelope"
|
"blitiri.com.ar/go/chasquid/internal/envelope"
|
||||||
"blitiri.com.ar/go/chasquid/internal/normalize"
|
"blitiri.com.ar/go/chasquid/internal/normalize"
|
||||||
|
"blitiri.com.ar/go/chasquid/internal/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exported variables.
|
||||||
|
var (
|
||||||
|
hookResults = expvar.NewMap("chasquid/aliases/hookResults")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recipient represents a single recipient, after resolving aliases.
|
// Recipient represents a single recipient, after resolving aliases.
|
||||||
@@ -101,6 +111,10 @@ type Resolver struct {
|
|||||||
// Characters to drop from the user part.
|
// Characters to drop from the user part.
|
||||||
DropChars string
|
DropChars string
|
||||||
|
|
||||||
|
// Path to resolve and exist hooks.
|
||||||
|
ExistsHook string
|
||||||
|
ResolveHook string
|
||||||
|
|
||||||
// Map of domain -> alias files for that domain.
|
// Map of domain -> alias files for that domain.
|
||||||
// We keep track of them for reloading purposes.
|
// We keep track of them for reloading purposes.
|
||||||
files map[string][]string
|
files map[string][]string
|
||||||
@@ -125,9 +139,6 @@ func NewResolver() *Resolver {
|
|||||||
// Resolve the given address, returning the list of corresponding recipients
|
// Resolve the given address, returning the list of corresponding recipients
|
||||||
// (if any).
|
// (if any).
|
||||||
func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
|
func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
|
||||||
v.mu.Lock()
|
|
||||||
defer v.mu.Unlock()
|
|
||||||
|
|
||||||
return v.resolve(0, addr)
|
return v.resolve(0, addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,11 +148,15 @@ func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
|
|||||||
// doesn't exist.
|
// doesn't exist.
|
||||||
func (v *Resolver) Exists(addr string) (string, bool) {
|
func (v *Resolver) Exists(addr string) (string, bool) {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
|
||||||
|
|
||||||
addr = v.cleanIfLocal(addr)
|
addr = v.cleanIfLocal(addr)
|
||||||
_, ok := v.aliases[addr]
|
_, ok := v.aliases[addr]
|
||||||
return addr, ok
|
v.mu.Unlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
return addr, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr, v.runExistsHook(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) {
|
func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) {
|
||||||
@@ -154,7 +169,18 @@ func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) {
|
|||||||
// match, which our callers can rely upon.
|
// match, which our callers can rely upon.
|
||||||
addr = v.cleanIfLocal(addr)
|
addr = v.cleanIfLocal(addr)
|
||||||
|
|
||||||
|
// Lookup in the aliases database.
|
||||||
|
v.mu.Lock()
|
||||||
rcpts := v.aliases[addr]
|
rcpts := v.aliases[addr]
|
||||||
|
v.mu.Unlock()
|
||||||
|
|
||||||
|
// Augment with the hook results.
|
||||||
|
hr, err := v.runResolveHook(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rcpts = append(rcpts, hr...)
|
||||||
|
|
||||||
if len(rcpts) == 0 {
|
if len(rcpts) == 0 {
|
||||||
return []Recipient{{addr, EMAIL}}, nil
|
return []Recipient{{addr, EMAIL}}, nil
|
||||||
}
|
}
|
||||||
@@ -305,35 +331,43 @@ func parseReader(domain string, r io.Reader) (map[string][]Recipient, error) {
|
|||||||
addr = addr + "@" + domain
|
addr = addr + "@" + domain
|
||||||
addr, _ = normalize.Addr(addr)
|
addr, _ = normalize.Addr(addr)
|
||||||
|
|
||||||
if rawalias[0] == '|' {
|
rs := parseRHS(rawalias, domain)
|
||||||
cmd := strings.TrimSpace(rawalias[1:])
|
aliases[addr] = rs
|
||||||
if cmd == "" {
|
|
||||||
// A pipe alias without a command is invalid.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
aliases[addr] = []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
|
|
||||||
}
|
|
||||||
a, _ = normalize.Addr(a)
|
|
||||||
rs = append(rs, Recipient{a, EMAIL})
|
|
||||||
}
|
|
||||||
aliases[addr] = rs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return aliases, scanner.Err()
|
return aliases, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseRHS(rawalias, domain string) []Recipient {
|
||||||
|
if len(rawalias) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rawalias[0] == '|' {
|
||||||
|
cmd := strings.TrimSpace(rawalias[1:])
|
||||||
|
if cmd == "" {
|
||||||
|
// A pipe alias without a command is invalid.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Recipient{{cmd, PIPE}}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
a, _ = normalize.Addr(a)
|
||||||
|
rs = append(rs, Recipient{a, EMAIL})
|
||||||
|
}
|
||||||
|
return rs
|
||||||
|
}
|
||||||
|
|
||||||
// removeAllAfter removes everything from s that comes after the separators,
|
// removeAllAfter removes everything from s that comes after the separators,
|
||||||
// including them.
|
// including them.
|
||||||
func removeAllAfter(s, seps string) string {
|
func removeAllAfter(s, seps string) string {
|
||||||
@@ -361,3 +395,71 @@ func removeChars(s, chars string) string {
|
|||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *Resolver) runResolveHook(addr string) ([]Recipient, error) {
|
||||||
|
if v.ResolveHook == "" {
|
||||||
|
hookResults.Add("resolve:notset", 1)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// TODO: check if the file is executable.
|
||||||
|
if _, err := os.Stat(v.ResolveHook); os.IsNotExist(err) {
|
||||||
|
hookResults.Add("resolve:skip", 1)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this should be done via a context propagated all the way through.
|
||||||
|
tr := trace.New("Hook.Alias-Resolve", addr)
|
||||||
|
defer tr.Finish()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, v.ResolveHook, addr)
|
||||||
|
|
||||||
|
outb, err := cmd.Output()
|
||||||
|
out := string(outb)
|
||||||
|
tr.Debugf("stdout: %q", out)
|
||||||
|
if err != nil {
|
||||||
|
hookResults.Add("resolve:fail", 1)
|
||||||
|
tr.Error(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract recipients from the output.
|
||||||
|
// Same format as the right hand side of aliases file, see parseRHS.
|
||||||
|
domain := envelope.DomainOf(addr)
|
||||||
|
raw := strings.TrimSpace(out)
|
||||||
|
rs := parseRHS(raw, domain)
|
||||||
|
|
||||||
|
tr.Debugf("recipients: %v", rs)
|
||||||
|
hookResults.Add("resolve:success", 1)
|
||||||
|
return rs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Resolver) runExistsHook(addr string) bool {
|
||||||
|
if v.ExistsHook == "" {
|
||||||
|
hookResults.Add("exists:notset", 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// TODO: check if the file is executable.
|
||||||
|
if _, err := os.Stat(v.ExistsHook); os.IsNotExist(err) {
|
||||||
|
hookResults.Add("exists:skip", 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this should be done via a context propagated all the way through.
|
||||||
|
tr := trace.New("Hook.Alias-Exists", addr)
|
||||||
|
defer tr.Finish()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, v.ExistsHook, addr)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
tr.Debugf("not exists: %v", err)
|
||||||
|
hookResults.Add("exists:false", 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tr.Debugf("exists")
|
||||||
|
hookResults.Add("exists:true", 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"path"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"blitiri.com.ar/go/chasquid/internal/aliases"
|
"blitiri.com.ar/go/chasquid/internal/aliases"
|
||||||
@@ -67,8 +68,8 @@ type Server struct {
|
|||||||
// Queue where we put incoming mail.
|
// Queue where we put incoming mail.
|
||||||
queue *queue.Queue
|
queue *queue.Queue
|
||||||
|
|
||||||
// Path to the Post-DATA hook.
|
// Path to the hooks.
|
||||||
PostDataHook string
|
HookPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer returns a new empty Server.
|
// NewServer returns a new empty Server.
|
||||||
@@ -130,6 +131,8 @@ func (s *Server) SetAuthFallback(be auth.Backend) {
|
|||||||
func (s *Server) SetAliasesConfig(suffixSep, dropChars string) {
|
func (s *Server) SetAliasesConfig(suffixSep, dropChars string) {
|
||||||
s.aliasesR.SuffixSep = suffixSep
|
s.aliasesR.SuffixSep = suffixSep
|
||||||
s.aliasesR.DropChars = dropChars
|
s.aliasesR.DropChars = dropChars
|
||||||
|
s.aliasesR.ResolveHook = path.Join(s.HookPath, "alias-resolve")
|
||||||
|
s.aliasesR.ExistsHook = path.Join(s.HookPath, "alias-exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDomainInfo initializes the domain info database.
|
// InitDomainInfo initializes the domain info database.
|
||||||
@@ -231,6 +234,8 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
|
|||||||
l = tls.NewListener(l, s.tlsConfig)
|
l = tls.NewListener(l, s.tlsConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pdhook := path.Join(s.HookPath, "post-data")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, err := l.Accept()
|
conn, err := l.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,7 +245,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
|
|||||||
sc := &Conn{
|
sc := &Conn{
|
||||||
hostname: s.Hostname,
|
hostname: s.Hostname,
|
||||||
maxDataSize: s.MaxDataSize,
|
maxDataSize: s.MaxDataSize,
|
||||||
postDataHook: s.PostDataHook,
|
postDataHook: pdhook,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
tc: textproto.NewConn(conn),
|
tc: textproto.NewConn(conn),
|
||||||
mode: mode,
|
mode: mode,
|
||||||
|
|||||||
15
test/t-04-aliases/alias-exists-hook
Executable file
15
test/t-04-aliases/alias-exists-hook
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
"vicuña@testserver")
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"ñandú@testserver")
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"roto@testserver")
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 1
|
||||||
14
test/t-04-aliases/alias-resolve-hook
Executable file
14
test/t-04-aliases/alias-resolve-hook
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
"vicuña@testserver")
|
||||||
|
# Test one naked, one full. These exist in the static aliases file.
|
||||||
|
echo pepe, joan@testserver
|
||||||
|
;;
|
||||||
|
"ñandú@testserver")
|
||||||
|
echo "| writemailto ../.data/pipe_alias_worked"
|
||||||
|
;;
|
||||||
|
"roto@testserver")
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -22,6 +22,10 @@ function send_and_check() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remove the hooks that could be left over from previous failed tests.
|
||||||
|
rm -f config/hooks/alias-resolve
|
||||||
|
rm -f config/hooks/alias-exists
|
||||||
|
|
||||||
# Test email aliases.
|
# Test email aliases.
|
||||||
send_and_check pepe jose
|
send_and_check pepe jose
|
||||||
send_and_check joan juan
|
send_and_check joan juan
|
||||||
@@ -39,5 +43,32 @@ run_msmtp tubo@testserver < content
|
|||||||
wait_for_file .data/pipe_alias_worked
|
wait_for_file .data/pipe_alias_worked
|
||||||
mail_diff content .data/pipe_alias_worked
|
mail_diff content .data/pipe_alias_worked
|
||||||
|
|
||||||
|
# Set up the hooks.
|
||||||
|
mkdir -p config/hooks/
|
||||||
|
cp alias-exists-hook config/hooks/alias-exists
|
||||||
|
cp alias-resolve-hook config/hooks/alias-resolve
|
||||||
|
|
||||||
|
# Test email aliases.
|
||||||
|
send_and_check vicuña juan jose
|
||||||
|
|
||||||
|
# Test the pipe alias separately.
|
||||||
|
rm -f .data/pipe_alias_worked
|
||||||
|
run_msmtp ñandú@testserver < content
|
||||||
|
wait_for_file .data/pipe_alias_worked
|
||||||
|
mail_diff content .data/pipe_alias_worked
|
||||||
|
|
||||||
|
# Test when alias-resolve exits with an error
|
||||||
|
if run_msmtp roto@testserver < content 2> .logs/msmtp.out; then
|
||||||
|
echo "expected delivery to roto@ to fail, but succeeded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test a non-existent alias.
|
||||||
|
if run_msmtp nono@testserver < content 2> .logs/msmtp.out; then
|
||||||
|
echo "expected delivery to nono@ to fail, but succeeded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove the hooks, leave a clean state.
|
||||||
|
rm -f config/hooks/alias-resolve
|
||||||
|
rm -f config/hooks/alias-exists
|
||||||
|
|
||||||
success
|
success
|
||||||
|
|||||||
Reference in New Issue
Block a user