mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-18 14:47:03 +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.Hostname = conf.Hostname
|
||||
s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
|
||||
s.PostDataHook = "hooks/post-data"
|
||||
s.HookPath = "hooks/"
|
||||
|
||||
s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
|
||||
|
||||
|
||||
@@ -77,5 +77,16 @@ The `chasquid-util` command-line tool can be used to check and resolve
|
||||
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
|
||||
[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
|
||||
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.
|
||||
|
||||
|
||||
## Environment
|
||||
### Environment
|
||||
|
||||
This hook will run as the chasquid user, so be careful about permissions and
|
||||
privileges.
|
||||
@@ -43,3 +51,34 @@ The environment will contain the following variables:
|
||||
There is a 1 minute timeout for hook execution.
|
||||
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 (
|
||||
"bufio"
|
||||
"context"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"blitiri.com.ar/go/chasquid/internal/envelope"
|
||||
"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.
|
||||
@@ -101,6 +111,10 @@ type Resolver struct {
|
||||
// Characters to drop from the user part.
|
||||
DropChars string
|
||||
|
||||
// Path to resolve and exist hooks.
|
||||
ExistsHook string
|
||||
ResolveHook string
|
||||
|
||||
// Map of domain -> alias files for that domain.
|
||||
// We keep track of them for reloading purposes.
|
||||
files map[string][]string
|
||||
@@ -125,9 +139,6 @@ func NewResolver() *Resolver {
|
||||
// Resolve the given address, returning the list of corresponding recipients
|
||||
// (if any).
|
||||
func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
return v.resolve(0, addr)
|
||||
}
|
||||
|
||||
@@ -137,11 +148,15 @@ func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
|
||||
// doesn't exist.
|
||||
func (v *Resolver) Exists(addr string) (string, bool) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
addr = v.cleanIfLocal(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) {
|
||||
@@ -154,7 +169,18 @@ func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) {
|
||||
// match, which our callers can rely upon.
|
||||
addr = v.cleanIfLocal(addr)
|
||||
|
||||
// Lookup in the aliases database.
|
||||
v.mu.Lock()
|
||||
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 {
|
||||
return []Recipient{{addr, EMAIL}}, nil
|
||||
}
|
||||
@@ -305,14 +331,26 @@ func parseReader(domain string, r io.Reader) (map[string][]Recipient, error) {
|
||||
addr = addr + "@" + domain
|
||||
addr, _ = normalize.Addr(addr)
|
||||
|
||||
rs := parseRHS(rawalias, domain)
|
||||
aliases[addr] = rs
|
||||
}
|
||||
|
||||
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.
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
aliases[addr] = []Recipient{{cmd, PIPE}}
|
||||
} else {
|
||||
return []Recipient{{cmd, PIPE}}
|
||||
}
|
||||
|
||||
rs := []Recipient{}
|
||||
for _, a := range strings.Split(rawalias, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
@@ -327,11 +365,7 @@ func parseReader(domain string, r io.Reader) (map[string][]Recipient, error) {
|
||||
a, _ = normalize.Addr(a)
|
||||
rs = append(rs, Recipient{a, EMAIL})
|
||||
}
|
||||
aliases[addr] = rs
|
||||
}
|
||||
}
|
||||
|
||||
return aliases, scanner.Err()
|
||||
return rs
|
||||
}
|
||||
|
||||
// removeAllAfter removes everything from s that comes after the separators,
|
||||
@@ -361,3 +395,71 @@ func removeChars(s, chars string) string {
|
||||
|
||||
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/http"
|
||||
"net/textproto"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"blitiri.com.ar/go/chasquid/internal/aliases"
|
||||
@@ -67,8 +68,8 @@ type Server struct {
|
||||
// Queue where we put incoming mail.
|
||||
queue *queue.Queue
|
||||
|
||||
// Path to the Post-DATA hook.
|
||||
PostDataHook string
|
||||
// Path to the hooks.
|
||||
HookPath string
|
||||
}
|
||||
|
||||
// NewServer returns a new empty Server.
|
||||
@@ -130,6 +131,8 @@ func (s *Server) SetAuthFallback(be auth.Backend) {
|
||||
func (s *Server) SetAliasesConfig(suffixSep, dropChars string) {
|
||||
s.aliasesR.SuffixSep = suffixSep
|
||||
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.
|
||||
@@ -231,6 +234,8 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
|
||||
l = tls.NewListener(l, s.tlsConfig)
|
||||
}
|
||||
|
||||
pdhook := path.Join(s.HookPath, "post-data")
|
||||
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
@@ -240,7 +245,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
|
||||
sc := &Conn{
|
||||
hostname: s.Hostname,
|
||||
maxDataSize: s.MaxDataSize,
|
||||
postDataHook: s.PostDataHook,
|
||||
postDataHook: pdhook,
|
||||
conn: conn,
|
||||
tc: textproto.NewConn(conn),
|
||||
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
|
||||
}
|
||||
|
||||
# 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.
|
||||
send_and_check pepe jose
|
||||
send_and_check joan juan
|
||||
@@ -39,5 +43,32 @@ run_msmtp tubo@testserver < content
|
||||
wait_for_file .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
|
||||
|
||||
Reference in New Issue
Block a user