Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DONOTMERGE] Add the ability to keep mail delivery under a threshold (rate limit) and resend when the relay falls below the threshold again. #168

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM golang:1.22-alpine AS build
WORKDIR /app



COPY go.mod ./
COPY go.sum ./

RUN apk add git

RUN go mod download

COPY *.go ./

RUN go build -o smtprelay

FROM golang:1.22-alpine

WORKDIR /app

COPY --from=build /app/smtprelay ./

EXPOSE 25

CMD ["./smtprelay", "-config", "smtprelay.ini"]
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/decke/smtprelay)](https://goreportcard.com/report/github.com/decke/smtprelay)

Simple Golang based SMTP relay/proxy server that accepts mail via SMTP
and forwards it directly to another SMTP server.
and forwards it directly to another SMTP server. Fork to add the ability to cache mail that can not be sent due to rate limit. Mail are sent when the the service will not exceed the rate limit.


## Why another SMTP server?
Expand All @@ -30,3 +30,4 @@ device which produces mail.
* Forwards all mail to a smarthost (any SMTP server)
* Small codebase
* IPv6 support
* Cache mail to avoid exceeding the rate limit per remote
10 changes: 7 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@ module github.com/decke/smtprelay
require (
github.com/chrj/smtpd v0.3.1
github.com/google/uuid v1.6.0
github.com/maypok86/otter v1.2.4
github.com/peterbourgon/ff/v3 v3.4.0
github.com/sethvargo/go-limiter v1.0.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.28.0
golang.org/x/crypto v0.29.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/gammazero/deque v1.0.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/sys v0.27.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

go 1.20
go 1.22
20 changes: 20 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
Expand All @@ -13,24 +17,40 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/maypok86/otter v1.2.3 h1:jxyPD4ofCwtrQM5is5JNrdAs+6+JQkf/PREZd7JCVgg=
github.com/maypok86/otter v1.2.3/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/maypok86/otter v1.2.4-0.20241122154217-c7fa1631301b h1:OcjzyR4TevoH7W/4WIH4ymBR0RCVoRJrvRFU1bW/SmI=
github.com/maypok86/otter v1.2.4-0.20241122154217-c7fa1631301b/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/scalalang2/golang-fifo v1.0.2 h1:sfOJBB86iXuqB5WoLtVI7+wxn8UOEOr9SnJaTakinBA=
github.com/scalalang2/golang-fifo v1.0.2/go.mod h1:TsyVkLbka5m8tmfqsWBXwJ7Om1jV/uuOuvoPulZbMmA=
github.com/sethvargo/go-limiter v1.0.0 h1:JqW13eWEMn0VFv86OKn8wiYJY/m250WoXdrjRV0kLe4=
github.com/sethvargo/go-limiter v1.0.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
Expand Down
22 changes: 10 additions & 12 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_PEER", peerIP))

cmd := exec.Cmd{
Env: environ,
Env: environ,
Path: *command,
}

Expand All @@ -211,7 +211,7 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {

cmdLogger.Info("pipe command successful: " + stdout.String())
}

var smtpError *smtpd.Error
for _, remote := range envRemotes {
logger = logger.WithField("host", remote.Addr)
logger.Info("delivering mail from peer using smarthost")
Expand All @@ -223,30 +223,28 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
env.Data,
)
if err != nil {
var smtpError smtpd.Error

switch err := err.(type) {
case *textproto.Error:
smtpError = smtpd.Error{Code: err.Code, Message: err.Msg}

smtpError = &smtpd.Error{Code: err.Code, Message: err.Msg}
logger.WithFields(logrus.Fields{
"err_code": err.Code,
"err_msg": err.Msg,
}).Error("delivery failed")
default:
smtpError = smtpd.Error{Code: 554, Message: "Forwarding failed"}

smtpError = &smtpd.Error{Code: 554, Message: "Forwarding failed"}
logger.WithError(err).
Error("delivery failed")
}

return smtpError
}

}
if smtpError == nil {
logger.Debug("delivery successful")
return nil
} else {
logger.Debug("do not direct send")
return *smtpError
}

return nil
}

func generateUUID() string {
Expand Down
67 changes: 67 additions & 0 deletions ratelimiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package main

import (
"context"
"os"
"strings"
"sync"
"time"

"github.com/maypok86/otter"
)

var lock = &sync.Mutex{}

type single struct {
context context.Context
cache otter.Cache[string, string]
r *Remote
}

var remoteCache map[*Remote]*single = make(map[*Remote]*single)

func getContext(r *Remote) *single {

if remoteCache[r] == nil {
lock.Lock()
defer lock.Unlock()
if remoteCache[r] == nil {
remoteCache[r] = &single{}

remoteCache[r].context = context.Background()
remoteCache[r].r = r

cache, err := otter.MustBuilder[string, string](10_000).
CollectStats().
Cost(func(key string, value string) uint32 {
return 1
}).DeletionListener(func(key, value string, cause otter.DeletionCause) {
log.Infof("Evicted %s %s %v ", key, value, cause)

parts := strings.Split(value, ";")
if len(parts) < 3 {
log.Info("Should have had at least three parts")
} else {
msg, err := os.ReadFile("/tmp/" + key + ".mail")
if err != nil {
log.Errorf("cannot read file %s", key+".mail")

} else {
from := parts[1]
to := parts[2:]
SendMail(r, from, to, msg)
os.Remove("/tmp/" + key + ".mail")
}
}
}).
WithTTL(time.Minute).
Build()

if err != nil {
panic(err)
}
remoteCache[r].cache = cache
}
}
return remoteCache[r]
}
38 changes: 31 additions & 7 deletions remotes.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@ import (
"fmt"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"

"github.com/sethvargo/go-limiter"
"github.com/sethvargo/go-limiter/memorystore"
)

type Remote struct {
SkipVerify bool
Auth smtp.Auth
Scheme string
Hostname string
Port string
Addr string
Sender string
SkipVerify bool
Auth smtp.Auth
Scheme string
Hostname string
Port string
Addr string
Sender string
RateLimiter *limiter.Store
}

// ParseRemote creates a remote from a given url in the following format:
Expand Down Expand Up @@ -79,5 +86,22 @@ func ParseRemote(remoteURL string) (*Remote, error) {
r.Sender = u.Path[1:]
}

if hasVal, rate := q.Has("rate"), q.Get("rate"); hasVal && strings.Contains(rate, "/") {
i, err := strconv.ParseInt(strings.Split(rate, "/")[0], 10, 32)
if err == nil {
t, err := time.ParseDuration(strings.Split(rate, "/")[1])
log.Infof("Configuring rate limiter %v/%v", i, t)
if err == nil {
store, err := memorystore.New(&memorystore.Config{
Tokens: uint64(i),
Interval: t,
})
if err == nil {
r.RateLimiter = &store
}
}
}
}

return r, nil
}
6 changes: 6 additions & 0 deletions send4mail.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#! /bin/bash
swaks --to [email protected] --cc [email protected] --from [email protected] --header "Subject: Test Email" --body "This is a test email sent using swaks." --server 127.0.0.1 --port 1025
swaks --to [email protected] --cc [email protected] --from [email protected] --header "Subject: Test Email" --body "This is a test email sent using swaks." --server 127.0.0.1 --port 1025
swaks --to [email protected] --cc [email protected] --from [email protected] --header "Subject: Test Email" --body "This is a test email sent using swaks." --server 127.0.0.1 --port 1025
swaks --to [email protected] --cc [email protected] --from [email protected] --header "Subject: Test Email" --body "This is a test email sent using swaks." --server 127.0.0.1 --port 1025

25 changes: 25 additions & 0 deletions smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import (
"net"
"net/smtp"
"net/textproto"
"os"
"strings"
"time"

"github.com/chrj/smtpd"
)

// A Client represents a client connection to an SMTP server.
Expand Down Expand Up @@ -320,7 +324,28 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
// attachments (see the mime/multipart package), or other mail
// functionality. Higher-level packages exist outside of the standard
// library.

func SendMail(r *Remote, from string, to []string, msg []byte) error {
if r.RateLimiter != nil {
// Do the background in the main
tokens, remaining, _, ok, err := (*r.RateLimiter).Take(getContext(r).context, "")
log.Infof("Remaining %v tokens of %v", remaining, tokens)

if err != nil || !ok {
// return smtpd.Error{Code: 452, Message: "Rate limit reached"}
theTime := time.Now()
filename := theTime.Format("2006-1-2-15-4-5") + ";" + from + ";" + strings.Join(to, ";")
filenameb64 := base64.URLEncoding.EncodeToString([]byte(filename))
err := os.WriteFile("/tmp/"+filenameb64+".mail", msg, 0644)
getContext(r).cache.Set(filenameb64, filename)
if err != nil {
// handle error
}
return smtpd.Error{Code: 452, Message: "Rate limit reached"}

}
log.Debugf("Remaining %v tokens of %v", remaining, tokens)
}
if r.Sender != "" {
from = r.Sender
}
Expand Down
14 changes: 9 additions & 5 deletions smtprelay.ini
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@
;logfile =

; Log format: default, plain (no timestamp), json
;log_format = default
log_format = default

; Log level: panic, fatal, error, warn, info, debug, trace
;log_level = info
log_level = info

; Hostname for this SMTP server
;hostname = localhost.localdomain
hostname = localhost.localdomain

; Welcome message for clients
;welcome_msg = <hostname> ESMTP ready.

; Listen on the following addresses for incoming
; unencrypted connections.
;listen = 127.0.0.1:25 [::1]:25
listen = 127.0.0.1:1025

; STARTTLS and TLS are also supported but need a
; SSL certificate and key.
Expand All @@ -37,7 +37,6 @@
; Only use remotes where FROM EMail address in received
; EMail matches remote_sender.
;strict_sender = false

; Socket timeout for read operations
; Duration string as sequence of decimal numbers,
; each with optional fraction and a unit suffix.
Expand Down Expand Up @@ -126,5 +125,10 @@
; Multiple remotes, space delimited
;remotes = smtp://127.0.0.1:1025 starttls://user:[email protected]:587

; rate limit
; remotes = smtp://127.0.0.1:2525?rate=99/21m
remotes = smtp://127.0.0.1:2525?rate=1/1m smtp://127.0.0.1:2527?rate=6/1m


; Pipe messages to external command
;command = /usr/local/bin/script