Commit 76ffe05d authored by ale's avatar ale
Browse files

Initial commit

Pipeline #7452 passed with stages
in 1 minute and 52 seconds
image: docker:latest
- build
- release
- docker:dind
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN
stage: build
- docker build --pull -t $IMAGE_TAG .
- docker push $IMAGE_TAG
stage: release
- docker pull $IMAGE_TAG
- docker tag $IMAGE_TAG $RELEASE_TAG
- docker push $RELEASE_TAG
- master
FROM golang:latest AS build
ADD . /go/src
WORKDIR /go/src
RUN go build -tags netgo -o /smtpd smtpd.go && strip /smtpd
FROM scratch
COPY --from=build /smtpd /
A very minimal SMTP server that will only forward messages to shell
commands via UNIX pipes. Its intended usage is to provide an SMTP
endpoint for containerized services that expect to receive email over
a pipe, without having to run a full MTA.
# Usage
The daemon can be configured with a list of regular expression / shell
command-line pairs: if a message matches a regular expression, the
associated command will be executed. Messages that do not match
anything will be rejected.
Rules (regexp / command pairs) are encoded as a colon-separated string
pair (which implies that the regexp can't contain a colon). They can
be passed to the program via the command-line option *--rule*
(repeated multiple times), or via the environment variable
*SMTP_RULES*, which should be a semicolon-separated list of rules.
Regular expressions are automatically anchored, so they should match
the full recipient address, including the domain part (but there is no
need to include the *^* and *$* anchors). Submatches can be used and
referenced in the command using the `$N` syntax, e.g.:
smtpd --rule='archive+(.*) $1'
Further command-line options are available to set network timeouts and
various execution limits, see *smtpd --help* for details.
// Simple SMTP server that delivers emails to command-line processors.
// Useful to export SMTP APIs in containers, decoupling the message
// delivery from the local system-wide MTA.
package main
import (
type deliveryRule struct {
rx *regexp.Regexp
command string
type deliveryRules []deliveryRule
func (m deliveryRules) String() string {
return ""
func (m *deliveryRules) Set(value string) error {
// Split on ':'.
n := strings.Index(value, ":")
if n < 0 {
return errors.New("not in 'pattern:command' format")
pattern := value[:n]
command := strings.TrimSpace(value[n+1:])
// Auto-anchor the regexps.
rx, err := regexp.Compile(fmt.Sprintf("^%s$", pattern))
if err != nil {
return fmt.Errorf("invalid regexp: %v", err)
*m = append(*m, deliveryRule{
rx: rx,
command: command,
return nil
var (
port = flag.Int("port", getenvInt("SMTP_PORT", 3025), "smtp port")
hostname = flag.String("ehlo", os.Getenv("SMTP_EHLO"), "EHLO hostname")
guardFile = flag.String("guard-file", os.Getenv("SMTP_GUARD_FILE"), "suspend delivery (with a temporary error) if this file exists")
readTimeout = flag.Duration("read-timeout", getenvDuration("SMTP_READ_TIMEOUT", 60*time.Second), "socket read timeout")
writeTimeout = flag.Duration("write-timeout", getenvDuration("SMTP_WRITE_TIMEOUT", 60*time.Second), "socket write timeout")
dataTimeout = flag.Duration("data-timeout", getenvDuration("SMTP_DATA_TIMEOUT", 5*time.Minute), "socket timeout for DATA command")
commandTimeout = flag.Duration("command-timeout", getenvDuration("SMTP_COMMAND_TIMEOUT", 1*time.Minute), "timeout for command execution")
maxConnections = flag.Int("max-connections", getenvInt("SMTP_MAX_CONNECTIONS", 100), "max concurrent connections")
maxRecipients = flag.Int("max-recipients", getenvInt("SMTP_MAX_RECIPIENTS", 100), "max number of recipients per message")
maxMessageSize = flag.Int("max-message-size", getenvInt("SMTP_MAX_MESSAGE_SIZE", 10*1024*1024), "maximum message size, in bytes")
rules deliveryRules
func init() {
// Parse the SMTP_RULES environment variable as a
// semicolon-separated list of regex: command patterns.
for _, s := range strings.Split(os.Getenv("SMTP_RULES"), ";") {
rules.Set(s) // nolint: errcheck
flag.Var(&rules, "rule", "delivery rules (in 'regexp:command' format)")
func getenvInt(key string, dflt int) int {
if s := os.Getenv(key); s != "" {
if n, err := strconv.Atoi(s); err == nil {
return n
return dflt
func getenvDefault(key, dflt string) string {
if s := os.Getenv(key); s != "" {
return s
return dflt
func getenvDuration(key string, dflt time.Duration) time.Duration {
if s := os.Getenv(key); s != "" {
if d, err := time.ParseDuration(s); err == nil {
return d
return dflt
func isMaintenanceModeOn() bool {
if *guardFile == "" {
return false
_, err := os.Stat(*guardFile)
return !os.IsNotExist(err)
func runCommand(command string, data []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), *commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
cmd.Stdin = bytes.NewReader(data)
out, err := cmd.CombinedOutput()
if err != nil {
outStr := strings.Replace(strings.TrimSpace(string(out)), "\n", "; ", -1)
log.Printf("error: command '%s' failed: %v: %s", command, err, outStr)
return smtpd.Error{550, outStr}
return nil
func findCommandFor(rcpt string) (string, bool) {
for _, rule := range rules {
match := rule.rx.FindStringSubmatchIndex(rcpt)
if len(match) > 0 {
command := rule.rx.ExpandString(nil, rule.command, rcpt, match)
return string(command), true
return "", false
func handleMessage(peer smtpd.Peer, env smtpd.Envelope) error {
if isMaintenanceModeOn() {
return smtpd.Error{421, "Maintenance mode, retry later"}
log.Printf("received msg: %s -> %s", env.Sender, strings.Join(env.Recipients, ","))
// Confirm that all recipients are valid before proceeding.
var commands []string
for _, rcpt := range env.Recipients {
c, ok := findCommandFor(rcpt)
if !ok {
log.Printf("unknown recipient %s", rcpt)
return smtpd.Error{550, "Unknown recipient"}
commands = append(commands, c)
// Now execute all the commands (in series, because it's
// simpler).
for _, c := range commands {
if err := runCommand(c, env.Data); err != nil {
return err
return nil
func main() {
server := &smtpd.Server{
Hostname: *hostname,
//RecipientChecker: checkRecipient,
Handler: handleMessage,
//ProtocolLogger: log.New(os.Stderr, "smtp: ", 0),
ReadTimeout: *readTimeout,
WriteTimeout: *writeTimeout,
DataTimeout: *dataTimeout,
MaxConnections: *maxConnections,
MaxMessageSize: *maxMessageSize,
MaxRecipients: *maxRecipients,
// Create our own listener so we can shut down cleanly.
l, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
if err != nil {
sigCh := make(chan os.Signal)
go func() {
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
if err := server.Serve(l); err != nil {
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment