Skip to content

Commit fb4e3ef

Browse files
committed
introduce a way to remain below a threshold for the mail throughput
1 parent 1e65705 commit fb4e3ef

10 files changed

+202
-28
lines changed

Dockerfile

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
FROM golang:1.22-alpine AS build
2+
WORKDIR /app
3+
4+
5+
6+
COPY go.mod ./
7+
COPY go.sum ./
8+
9+
RUN apk add git
10+
11+
RUN go mod download
12+
13+
COPY *.go ./
14+
15+
RUN go build -o smtprelay
16+
17+
FROM golang:1.22-alpine
18+
19+
WORKDIR /app
20+
21+
COPY --from=build /app/smtprelay ./
22+
23+
EXPOSE 25
24+
25+
CMD ["./smtprelay", "-config", "smtprelay.ini"]

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![Go Report Card](https://goreportcard.com/badge/github.com/decke/smtprelay)](https://goreportcard.com/report/github.com/decke/smtprelay)
44

55
Simple Golang based SMTP relay/proxy server that accepts mail via SMTP
6-
and forwards it directly to another SMTP server.
6+
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.
77

88

99
## Why another SMTP server?
@@ -30,3 +30,4 @@ device which produces mail.
3030
* Forwards all mail to a smarthost (any SMTP server)
3131
* Small codebase
3232
* IPv6 support
33+
* Cache mail to avoid exceeding the rate limit per remote

go.mod

+7-3
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@ module github.com/decke/smtprelay
33
require (
44
github.com/chrj/smtpd v0.3.1
55
github.com/google/uuid v1.6.0
6+
github.com/maypok86/otter v1.2.4
67
github.com/peterbourgon/ff/v3 v3.4.0
8+
github.com/sethvargo/go-limiter v1.0.0
79
github.com/sirupsen/logrus v1.9.3
810
github.com/stretchr/testify v1.9.0
9-
golang.org/x/crypto v0.28.0
11+
golang.org/x/crypto v0.29.0
1012
)
1113

1214
require (
1315
github.com/davecgh/go-spew v1.1.1 // indirect
16+
github.com/dolthub/maphash v0.1.0 // indirect
17+
github.com/gammazero/deque v1.0.0 // indirect
1418
github.com/kr/pretty v0.3.1 // indirect
1519
github.com/pmezard/go-difflib v1.0.0 // indirect
16-
golang.org/x/sys v0.26.0 // indirect
20+
golang.org/x/sys v0.27.0 // indirect
1721
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
1822
gopkg.in/yaml.v3 v3.0.1 // indirect
1923
)
2024

21-
go 1.20
25+
go 1.22

go.sum

+20
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
44
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
55
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
66
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7+
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
8+
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
9+
github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
10+
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
711
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
812
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
913
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -13,24 +17,40 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
1317
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
1418
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
1519
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
20+
github.com/maypok86/otter v1.2.3 h1:jxyPD4ofCwtrQM5is5JNrdAs+6+JQkf/PREZd7JCVgg=
21+
github.com/maypok86/otter v1.2.3/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
22+
github.com/maypok86/otter v1.2.4-0.20241122154217-c7fa1631301b h1:OcjzyR4TevoH7W/4WIH4ymBR0RCVoRJrvRFU1bW/SmI=
23+
github.com/maypok86/otter v1.2.4-0.20241122154217-c7fa1631301b/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
24+
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
25+
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
1626
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
1727
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
1828
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
1929
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2030
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
2131
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
2232
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
33+
github.com/scalalang2/golang-fifo v1.0.2 h1:sfOJBB86iXuqB5WoLtVI7+wxn8UOEOr9SnJaTakinBA=
34+
github.com/scalalang2/golang-fifo v1.0.2/go.mod h1:TsyVkLbka5m8tmfqsWBXwJ7Om1jV/uuOuvoPulZbMmA=
35+
github.com/sethvargo/go-limiter v1.0.0 h1:JqW13eWEMn0VFv86OKn8wiYJY/m250WoXdrjRV0kLe4=
36+
github.com/sethvargo/go-limiter v1.0.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II=
2337
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
2438
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
2539
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
2640
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
2741
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
2842
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
43+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
44+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
2945
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
3046
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
47+
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
48+
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
3149
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3250
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
3351
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
52+
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
53+
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
3454
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3555
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
3656
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

main.go

+10-12
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
195195
environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_PEER", peerIP))
196196

197197
cmd := exec.Cmd{
198-
Env: environ,
198+
Env: environ,
199199
Path: *command,
200200
}
201201

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

212212
cmdLogger.Info("pipe command successful: " + stdout.String())
213213
}
214-
214+
var smtpError *smtpd.Error
215215
for _, remote := range envRemotes {
216216
logger = logger.WithField("host", remote.Addr)
217217
logger.Info("delivering mail from peer using smarthost")
@@ -223,30 +223,28 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
223223
env.Data,
224224
)
225225
if err != nil {
226-
var smtpError smtpd.Error
227-
228226
switch err := err.(type) {
229227
case *textproto.Error:
230-
smtpError = smtpd.Error{Code: err.Code, Message: err.Msg}
231-
228+
smtpError = &smtpd.Error{Code: err.Code, Message: err.Msg}
232229
logger.WithFields(logrus.Fields{
233230
"err_code": err.Code,
234231
"err_msg": err.Msg,
235232
}).Error("delivery failed")
236233
default:
237-
smtpError = smtpd.Error{Code: 554, Message: "Forwarding failed"}
238-
234+
smtpError = &smtpd.Error{Code: 554, Message: "Forwarding failed"}
239235
logger.WithError(err).
240236
Error("delivery failed")
241237
}
242-
243-
return smtpError
244238
}
245-
239+
}
240+
if smtpError == nil {
246241
logger.Debug("delivery successful")
242+
return nil
243+
} else {
244+
logger.Debug("do not direct send")
245+
return *smtpError
247246
}
248247

249-
return nil
250248
}
251249

252250
func generateUUID() string {

ratelimiter.go

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"strings"
7+
"sync"
8+
"time"
9+
10+
"github.com/maypok86/otter"
11+
)
12+
13+
var lock = &sync.Mutex{}
14+
15+
type single struct {
16+
context context.Context
17+
cache otter.Cache[string, string]
18+
r *Remote
19+
}
20+
21+
var remoteCache map[*Remote]*single = make(map[*Remote]*single)
22+
23+
func getContext(r *Remote) *single {
24+
25+
if remoteCache[r] == nil {
26+
lock.Lock()
27+
defer lock.Unlock()
28+
if remoteCache[r] == nil {
29+
remoteCache[r] = &single{}
30+
31+
remoteCache[r].context = context.Background()
32+
remoteCache[r].r = r
33+
34+
cache, err := otter.MustBuilder[string, string](10_000).
35+
CollectStats().
36+
Cost(func(key string, value string) uint32 {
37+
return 1
38+
}).DeletionListener(func(key, value string, cause otter.DeletionCause) {
39+
log.Infof("Evicted %s %s %v ", key, value, cause)
40+
41+
parts := strings.Split(value, ";")
42+
if len(parts) < 3 {
43+
log.Info("Should have had at least three parts")
44+
} else {
45+
msg, err := os.ReadFile("/tmp/" + key + ".mail")
46+
if err != nil {
47+
log.Errorf("cannot read file %s", key+".mail")
48+
49+
} else {
50+
from := parts[1]
51+
to := parts[2:]
52+
SendMail(r, from, to, msg)
53+
os.Remove("/tmp/" + key + ".mail")
54+
}
55+
}
56+
}).
57+
WithTTL(time.Minute).
58+
Build()
59+
60+
if err != nil {
61+
panic(err)
62+
}
63+
remoteCache[r].cache = cache
64+
}
65+
}
66+
return remoteCache[r]
67+
}

remotes.go

+31-7
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,23 @@ import (
44
"fmt"
55
"net/smtp"
66
"net/url"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/sethvargo/go-limiter"
12+
"github.com/sethvargo/go-limiter/memorystore"
713
)
814

915
type Remote struct {
10-
SkipVerify bool
11-
Auth smtp.Auth
12-
Scheme string
13-
Hostname string
14-
Port string
15-
Addr string
16-
Sender string
16+
SkipVerify bool
17+
Auth smtp.Auth
18+
Scheme string
19+
Hostname string
20+
Port string
21+
Addr string
22+
Sender string
23+
RateLimiter *limiter.Store
1724
}
1825

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

89+
if hasVal, rate := q.Has("rate"), q.Get("rate"); hasVal && strings.Contains(rate, "/") {
90+
i, err := strconv.ParseInt(strings.Split(rate, "/")[0], 10, 32)
91+
if err == nil {
92+
t, err := time.ParseDuration(strings.Split(rate, "/")[1])
93+
log.Infof("Configuring rate limiter %v/%v", i, t)
94+
if err == nil {
95+
store, err := memorystore.New(&memorystore.Config{
96+
Tokens: uint64(i),
97+
Interval: t,
98+
})
99+
if err == nil {
100+
r.RateLimiter = &store
101+
}
102+
}
103+
}
104+
}
105+
82106
return r, nil
83107
}

send4mail.sh

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#! /bin/bash
2+
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
3+
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
4+
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
5+
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
6+

smtp.go

+25
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import (
2626
"net"
2727
"net/smtp"
2828
"net/textproto"
29+
"os"
2930
"strings"
31+
"time"
32+
33+
"github.com/chrj/smtpd"
3034
)
3135

3236
// A Client represents a client connection to an SMTP server.
@@ -320,7 +324,28 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
320324
// attachments (see the mime/multipart package), or other mail
321325
// functionality. Higher-level packages exist outside of the standard
322326
// library.
327+
323328
func SendMail(r *Remote, from string, to []string, msg []byte) error {
329+
if r.RateLimiter != nil {
330+
// Do the background in the main
331+
tokens, remaining, _, ok, err := (*r.RateLimiter).Take(getContext(r).context, "")
332+
log.Infof("Remaining %v tokens of %v", remaining, tokens)
333+
334+
if err != nil || !ok {
335+
// return smtpd.Error{Code: 452, Message: "Rate limit reached"}
336+
theTime := time.Now()
337+
filename := theTime.Format("2006-1-2-15-4-5") + ";" + from + ";" + strings.Join(to, ";")
338+
filenameb64 := base64.URLEncoding.EncodeToString([]byte(filename))
339+
err := os.WriteFile("/tmp/"+filenameb64+".mail", msg, 0644)
340+
getContext(r).cache.Set(filenameb64, filename)
341+
if err != nil {
342+
// handle error
343+
}
344+
return smtpd.Error{Code: 452, Message: "Rate limit reached"}
345+
346+
}
347+
log.Debugf("Remaining %v tokens of %v", remaining, tokens)
348+
}
324349
if r.Sender != "" {
325350
from = r.Sender
326351
}

smtprelay.ini

+9-5
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,20 @@
88
;logfile =
99

1010
; Log format: default, plain (no timestamp), json
11-
;log_format = default
11+
log_format = default
1212

1313
; Log level: panic, fatal, error, warn, info, debug, trace
14-
;log_level = info
14+
log_level = info
1515

1616
; Hostname for this SMTP server
17-
;hostname = localhost.localdomain
17+
hostname = localhost.localdomain
1818

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

2222
; Listen on the following addresses for incoming
2323
; unencrypted connections.
24-
;listen = 127.0.0.1:25 [::1]:25
24+
listen = 127.0.0.1:1025
2525

2626
; STARTTLS and TLS are also supported but need a
2727
; SSL certificate and key.
@@ -37,7 +37,6 @@
3737
; Only use remotes where FROM EMail address in received
3838
; EMail matches remote_sender.
3939
;strict_sender = false
40-
4140
; Socket timeout for read operations
4241
; Duration string as sequence of decimal numbers,
4342
; each with optional fraction and a unit suffix.
@@ -126,5 +125,10 @@
126125
; Multiple remotes, space delimited
127126
;remotes = smtp://127.0.0.1:1025 starttls://user:[email protected]:587
128127

128+
; rate limit
129+
; remotes = smtp://127.0.0.1:2525?rate=99/21m
130+
remotes = smtp://127.0.0.1:2525?rate=1/1m smtp://127.0.0.1:2527?rate=6/1m
131+
132+
129133
; Pipe messages to external command
130134
;command = /usr/local/bin/script

0 commit comments

Comments
 (0)