FMG-2: upgrade Go to 1.27.1, refresh dependencies and drop gotoolkit #7

Closed
fmartingr wants to merge 2 commits from agent/full-stack-developer/92dd9c7e35da into main
12 changed files with 298 additions and 194 deletions

View file

@ -11,8 +11,8 @@ jobs:
runs-on: docker runs-on: docker
container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
- run: make format - run: make format
@ -22,17 +22,15 @@ jobs:
runs-on: docker runs-on: docker
container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/goreleaser-action@v6.4.0 - run: make check
with:
args: check
lint: lint:
runs-on: docker runs-on: docker
container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
- run: make ci-lint - run: make ci-lint
@ -41,8 +39,8 @@ jobs:
runs-on: docker runs-on: docker
container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
- run: make test - run: make test
@ -51,8 +49,8 @@ jobs:
runs-on: docker runs-on: docker
container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
- run: make build - run: make build

View file

@ -9,12 +9,12 @@ jobs:
runs-on: docker runs-on: docker
container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Go - name: Install Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
@ -22,9 +22,7 @@ jobs:
run: echo "${{ secrets.FORGEJO_TOKEN }}" | docker login git.nakama.town -u ${{ github.actor }} --password-stdin run: echo "${{ secrets.FORGEJO_TOKEN }}" | docker login git.nakama.town -u ${{ github.actor }} --password-stdin
- name: Run GoReleaser - name: Run GoReleaser
uses: actions/goreleaser-action@v6.4.0 run: goreleaser release --clean
with:
args: release --clean
env: env:
GORELEASER_FORCE_TOKEN: gitea GORELEASER_FORCE_TOKEN: gitea
GITEA_TOKEN: ${{ secrets.FORGEJO_TOKEN }} GITEA_TOKEN: ${{ secrets.FORGEJO_TOKEN }}

View file

@ -1,5 +1,5 @@
# Build dependencies on native arch (no QEMU needed) # Build dependencies on native arch (no QEMU needed)
FROM --platform=$BUILDPLATFORM alpine:3.20 AS base FROM --platform=$BUILDPLATFORM alpine:3.24 AS base
RUN apk add --no-cache ca-certificates tzdata RUN apk add --no-cache ca-certificates tzdata
RUN addgroup -g 1000 smtp2shoutrrr && adduser -u 1000 -G smtp2shoutrrr -s /bin/sh -D smtp2shoutrrr RUN addgroup -g 1000 smtp2shoutrrr && adduser -u 1000 -G smtp2shoutrrr -s /bin/sh -D smtp2shoutrrr

View file

@ -4,7 +4,7 @@ CGO_ENABLED := 0
DIST_PATH := ./dist DIST_PATH := ./dist
TEST_OPTIONS := -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out TEST_OPTIONS := -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out
GOLANGCI_LINT_VERSION := v2.8.0 GOLANGCI_LINT_VERSION := v2.13.2
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@ -35,6 +35,11 @@ format:
go fmt ./... go fmt ./...
go mod tidy go mod tidy
## check: Validate the goreleaser configuration
.PHONY: check
check:
goreleaser check
## ci-lint: Run golangci-lint (installs if missing) ## ci-lint: Run golangci-lint (installs if missing)
.PHONY: ci-lint .PHONY: ci-lint
ci-lint: ci-lint:

View file

@ -1,41 +1,15 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"net/smtp" "net/smtp"
"os" "os"
"git.nakama.town/fmartingr/gotoolkit/encoding"
"github.com/emersion/go-sasl"
"git.nakama.town/fmartingr/smtp2shoutrrr" "git.nakama.town/fmartingr/smtp2shoutrrr"
) )
// The ANONYMOUS mechanism name.
const Anonymous = "ANONYMOUS"
type anonymousClient struct {
Trace string
}
func (c *anonymousClient) Start(si *smtp.ServerInfo) (mech string, ir []byte, err error) {
mech = Anonymous
ir = []byte(c.Trace)
return
}
func (c *anonymousClient) Next(challenge []byte, b bool) (response []byte, err error) {
return nil, sasl.ErrUnexpectedServerChallenge
}
// A client implementation of the ANONYMOUS authentication mechanism, as
// described in RFC 4505.
func NewAnonymousClient(trace string) smtp.Auth {
return &anonymousClient{trace}
}
// The PLAIN mechanism name. // The PLAIN mechanism name.
const Plain = "PLAIN" const Plain = "PLAIN"
@ -46,63 +20,57 @@ type plainClient struct {
} }
func (a *plainClient) Start(si *smtp.ServerInfo) (mech string, ir []byte, err error) { func (a *plainClient) Start(si *smtp.ServerInfo) (mech string, ir []byte, err error) {
mech = "PLAIN" mech = Plain
ir = []byte(a.Identity + "\x00" + a.Username + "\x00" + a.Password) ir = []byte(a.Identity + "\x00" + a.Username + "\x00" + a.Password)
return return
} }
func (a *plainClient) Next(challenge []byte, b bool) (response []byte, err error) { func (a *plainClient) Next(challenge []byte, b bool) (response []byte, err error) {
slog.Info("Next: %v", slog.String("challenge", string(challenge))) slog.Debug("SASL challenge received", slog.String("challenge", string(challenge)))
return nil, nil return nil, nil
} }
// A client implementation of the PLAIN authentication mechanism, as described // NewPlainClient is a client implementation of the PLAIN authentication
// in RFC 4616. Authorization identity may be left blank to indicate that it is // mechanism, as described in RFC 4616. Unlike smtp.PlainAuth it does not
// the same as the username. // require a TLS connection, which the development server does not offer.
// Authorization identity may be left blank to indicate that it is the same as
// the username.
func NewPlainClient(identity, username, password string) smtp.Auth { func NewPlainClient(identity, username, password string) smtp.Auth {
return &plainClient{identity, username, password} return &plainClient{identity, username, password}
} }
const configPath = "config.toml"
func main() { func main() {
var config smtp2shoutrrr.Config if err := run(); err != nil {
configPath := "config.toml" slog.Error("sendmail failed", slog.String("err", err.Error()))
os.Exit(1)
}
}
f, err := os.Open(configPath) func run() error {
config, err := smtp2shoutrrr.LoadConfig(configPath)
if err != nil { if err != nil {
slog.Error("Error opening config file", slog.String("err", err.Error()), slog.String("path", configPath)) return fmt.Errorf("loading configuration: %w", err)
return
} }
enc := encoding.NewTOMLEncoding() // The development server listens on localhost without TLS.
if err := enc.DecodeReader(f, &config); err != nil {
slog.Error("Error decoding config file", slog.String("err", err.Error()), slog.String("path", configPath))
return
}
config.SetDefaults()
// hostname is used by PlainAuth to validate the TLS certificate.
hostname := "localhost" hostname := "localhost"
auth := NewPlainClient("", config.Username, config.Password) auth := NewPlainClient("", config.Username, config.Password)
// auth := NewAnonymousClient("test")
slog.Info("Using first recipient configuration to send a test email") slog.Info("Using first recipient configuration to send a test email")
if len(config.Recipients) == 0 { if len(config.Recipients) == 0 {
slog.Error("No recipients found in configuration") return errors.New("no recipients found in configuration")
return
} }
if len(config.Recipients[0].Addresses) == 0 { if len(config.Recipients[0].Addresses) == 0 {
slog.Error("No email addresses found in first recipient configuration") return errors.New("no email addresses found in first recipient configuration")
return
} }
recipients := []string{config.Recipients[0].Addresses[0]} recipients := []string{config.Recipients[0].Addresses[0]}
msg := []byte("Subject: Test notification\r\n\r\nThis is a test notification") msg := []byte("Subject: Test notification\r\n\r\nThis is a test notification")
from := "hello@localhost" from := "hello@localhost"
err = smtp.SendMail(fmt.Sprintf("%s:%d", hostname, config.Port), auth, from, recipients, msg)
if err != nil { return smtp.SendMail(fmt.Sprintf("%s:%d", hostname, config.Port), auth, from, recipients, msg)
log.Fatal(err)
}
} }

View file

@ -2,54 +2,36 @@ package main
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"os" "os"
"os/signal"
"syscall"
"git.nakama.town/fmartingr/gotoolkit/encoding"
"git.nakama.town/fmartingr/gotoolkit/model"
"git.nakama.town/fmartingr/gotoolkit/service"
"git.nakama.town/fmartingr/smtp2shoutrrr" "git.nakama.town/fmartingr/smtp2shoutrrr"
_ "golang.org/x/crypto/x509roots/fallback" _ "golang.org/x/crypto/x509roots/fallback"
) )
const configPath = "config.toml"
func main() { func main() {
var config *smtp2shoutrrr.Config if err := run(); err != nil {
configPath := "config.toml" slog.Error("smtp2shoutrrr stopped", slog.String("err", err.Error()))
os.Exit(1)
}
}
f, err := os.Open(configPath) func run() error {
config, err := smtp2shoutrrr.LoadConfig(configPath)
if err != nil { if err != nil {
slog.Error("Error opening config file", slog.String("err", err.Error()), slog.String("path", configPath)) return fmt.Errorf("loading configuration: %w", err)
return
} }
enc := encoding.NewTOMLEncoding()
if err := enc.DecodeReader(f, &config); err != nil {
slog.Error("Error decoding config file", slog.String("err", err.Error()), slog.String("path", configPath))
return
}
config.SetDefaults()
slog.Info("config loaded", slog.Int("recipients", len(config.Recipients))) slog.Info("config loaded", slog.Int("recipients", len(config.Recipients)))
ctx := context.Background() ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
smtpServer := smtp2shoutrrr.NewSMTPServer(config) defer stop()
svc, err := service.NewService([]model.Server{ return smtp2shoutrrr.NewSMTPServer(config).Start(ctx)
smtpServer,
})
if err != nil {
slog.Error("Error creating service:", slog.String("err", err.Error()))
return
}
if err := svc.Start(ctx); err != nil {
slog.Error("Error starting service:", slog.String("err", err.Error()))
return
}
if err := svc.WaitStop(ctx); err != nil {
slog.Error("Error waiting for service interruption:", slog.String("err", err.Error()))
}
} }

View file

@ -1,11 +1,33 @@
package smtp2shoutrrr package smtp2shoutrrr
import ( import (
"fmt"
"log/slog" "log/slog"
"net/url" "net/url"
"os"
"strings" "strings"
"github.com/pelletier/go-toml/v2"
) )
// LoadConfig reads the TOML configuration at path and applies its defaults.
func LoadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening config file %q: %w", path, err)
}
defer func() { _ = f.Close() }()
var config Config
if err := toml.NewDecoder(f).Decode(&config); err != nil {
return nil, fmt.Errorf("decoding config file %q: %w", path, err)
}
config.SetDefaults()
return &config, nil
}
type Config struct { type Config struct {
Port int Port int
Username string Username string

78
config_test.go Normal file
View file

@ -0,0 +1,78 @@
package smtp2shoutrrr
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func writeConfig(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
return path
}
func TestLoadConfig(t *testing.T) {
path := writeConfig(t, `
Port = 2525
Username = "user"
Password = "secret"
[[Recipients]]
Addresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
[[Recipients]]
Addresses = ["legacy@example.com"]
Target = "ntfy://ntfy.sh/legacy"
[CatchAll]
Targets = ["ntfy://ntfy.sh/catch-all"]
`)
config, err := LoadConfig(path)
require.NoError(t, err)
require.Equal(t, 2525, config.Port)
require.Equal(t, "user", config.Username)
require.Equal(t, "secret", config.Password)
require.Len(t, config.Recipients, 2)
require.Equal(t, []string{"user@example.com"}, config.Recipients[0].Addresses)
require.Equal(t, []string{"ntfy://ntfy.sh/topic"}, config.Recipients[0].Targets)
require.Equal(t, "ntfy://ntfy.sh/legacy", config.Recipients[1].Target)
require.NotNil(t, config.CatchAll)
require.Equal(t, []string{"ntfy://ntfy.sh/catch-all"}, config.CatchAll.Targets)
}
func TestLoadConfigAppliesDefaults(t *testing.T) {
path := writeConfig(t, `
[[Recipients]]
Addresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
`)
config, err := LoadConfig(path)
require.NoError(t, err)
require.Equal(t, 11125, config.Port)
require.Equal(t, "username", config.Username)
require.Equal(t, "password", config.Password)
require.Nil(t, config.CatchAll)
}
func TestLoadConfigMissingFile(t *testing.T) {
_, err := LoadConfig(filepath.Join(t.TempDir(), "does-not-exist.toml"))
require.Error(t, err)
}
func TestLoadConfigInvalidTOML(t *testing.T) {
_, err := LoadConfig(writeConfig(t, "Port = "))
require.Error(t, err)
}

37
go.mod
View file

@ -1,34 +1,25 @@
module git.nakama.town/fmartingr/smtp2shoutrrr module git.nakama.town/fmartingr/smtp2shoutrrr
go 1.25.6 go 1.27.1
require ( require (
git.nakama.town/fmartingr/gotoolkit v0.2.4
github.com/containrrr/shoutrrr v0.8.0 github.com/containrrr/shoutrrr v0.8.0
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.25.0
github.com/stretchr/testify v1.9.0 github.com/pelletier/go-toml/v2 v2.4.3
golang.org/x/crypto/x509roots/fallback v0.0.0-20250214233241-911360c8a4f4 github.com/stretchr/testify v1.12.1
golang.org/x/crypto/x509roots/fallback v0.0.0-20260902180247-86efde54dc70
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/fatih/color v1.18.0 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/kr/pretty v0.3.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect golang.org/x/net v0.58.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sys v0.47.0 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect golang.org/x/tools v0.49.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
//replace git.nakama.town/fmartingr/gotoolkit => ../gotoolkit

79
go.sum
View file

@ -1,71 +1,48 @@
git.nakama.town/fmartingr/gotoolkit v0.2.4 h1:mnq15OXzHdtTjr4Ows5WMqQfqHraAnRwIhhuOtuqBlU=
git.nakama.town/fmartingr/gotoolkit v0.2.4/go.mod h1:Ak68T/qEx0xwhB/9hzE+cbQRTAiWre3Tj1VdTK9gKyo=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= github.com/emersion/go-smtp v0.25.0 h1:krfiHrme2JbJYDh0DGuSRbvPpbnQTH/v9CIfPincl1I=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/emersion/go-smtp v0.25.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= golang.org/x/crypto/x509roots/fallback v0.0.0-20260902180247-86efde54dc70 h1:VwViOGcd7C8/Gs18efVlMxvUCS0un6KRKBPFEBvpUXg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= golang.org/x/crypto/x509roots/fallback v0.0.0-20260902180247-86efde54dc70/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/crypto/x509roots/fallback v0.0.0-20250214233241-911360c8a4f4 h1:QDiVWrFJ2lyXzr3pJnIREQWR8S7jkjzuWJPJda8Ic8E= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/crypto/x509roots/fallback v0.0.0-20250214233241-911360c8a4f4/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -2,35 +2,56 @@ package smtp2shoutrrr
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
"git.nakama.town/fmartingr/gotoolkit/model"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
) )
var _ model.Server = (*smtpServer)(nil) // shutdownTimeout bounds how long in-flight sessions are given to finish once
// the server is asked to stop.
const shutdownTimeout = 10 * time.Second
type smtpServer struct { type Server struct {
backend *smtp.Server backend *smtp.Server
} }
func (s *smtpServer) IsEnabled() bool { // Start accepts connections until ctx is cancelled or Stop is called, then
return true // shuts the listener down gracefully. It returns nil on a clean shutdown.
} func (s *Server) Start(ctx context.Context) error {
func (s *smtpServer) Start(_ context.Context) error {
slog.Info("Started SMTP server", slog.String("addr", s.backend.Addr)) slog.Info("Started SMTP server", slog.String("addr", s.backend.Addr))
return s.backend.ListenAndServe()
served := make(chan error, 1)
go func() {
served <- s.backend.ListenAndServe()
}()
select {
case err := <-served:
return err
case <-ctx.Done():
}
// The incoming context is already cancelled, so the shutdown deadline has
// to be derived from a live one.
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownTimeout)
defer cancel()
if err := s.Stop(shutdownCtx); err != nil && !errors.Is(err, smtp.ErrServerClosed) {
return err
}
return <-served
} }
func (s *smtpServer) Stop(ctx context.Context) error { func (s *Server) Stop(ctx context.Context) error {
slog.Info("Stopping SMTP server") slog.Info("Stopping SMTP server")
return s.backend.Shutdown(ctx) return s.backend.Shutdown(ctx)
} }
func NewSMTPServer(config *Config) model.Server { func NewSMTPServer(config *Config) *Server {
be := &Backend{ be := &Backend{
config: config, config: config,
} }
@ -43,7 +64,7 @@ func NewSMTPServer(config *Config) model.Server {
smtpBackend.MaxRecipients = 50 smtpBackend.MaxRecipients = 50
smtpBackend.AllowInsecureAuth = true smtpBackend.AllowInsecureAuth = true
return &smtpServer{ return &Server{
backend: smtpBackend, backend: smtpBackend,
} }
} }

64
server_test.go Normal file
View file

@ -0,0 +1,64 @@
package smtp2shoutrrr
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func dialSMTP(t *testing.T, port int) (net.Conn, error) {
t.Helper()
return net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), 200*time.Millisecond)
}
func TestServerShutsDownOnContextCancel(t *testing.T) {
config := &Config{Port: 2530, Username: "testuser", Password: "testpass"}
server := NewSMTPServer(config)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
stopped := make(chan error, 1)
go func() {
stopped <- server.Start(ctx)
}()
require.Eventually(t, func() bool {
conn, err := dialSMTP(t, config.Port)
if err != nil {
return false
}
require.NoError(t, conn.Close())
return true
}, 5*time.Second, 20*time.Millisecond, "server never started listening")
cancel()
select {
case err := <-stopped:
require.NoError(t, err)
case <-time.After(shutdownTimeout + time.Second):
t.Fatal("server did not shut down after context cancellation")
}
_, err := dialSMTP(t, config.Port)
require.Error(t, err, "listener should be released once Start returns")
}
func TestServerStartReturnsListenError(t *testing.T) {
config := &Config{Port: 2531, Username: "testuser", Password: "testpass"}
blocker, err := net.Listen("tcp", fmt.Sprintf(":%d", config.Port))
require.NoError(t, err)
t.Cleanup(func() { _ = blocker.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
require.Error(t, NewSMTPServer(config).Start(ctx))
}