Closes FMG-2 Upgrades Go and every dependency to their latest revisions, and removes the `gotoolkit` dependency in favour of the standard library and the libraries it was wrapping. Supersedes #7 — identical tree, recreated under the `butterrobot` account. ## gotoolkit removal - **`encoding.NewTOMLEncoding()`** — a thin wrapper over `pelletier/go-toml/v2`. Now used directly, behind a new `smtp2shoutrrr.LoadConfig(path)` shared by both commands. Previously both `cmd/smtp2shoutrrr` and `cmd/sendmail` duplicated the open/decode/`SetDefaults` sequence, and neither closed the config file. - **`model.Server`** — a three-method interface with a single implementation. `NewSMTPServer` now returns a concrete `*Server`, and the unused `IsEnabled()` is gone. - **`service.NewService(...).Start()/WaitStop()`** — replaced by `signal.NotifyContext` in `main`. `Server.Start(ctx)` now owns its own lifecycle: it serves until the context is cancelled, then calls `Shutdown` with a 10s deadline. Behaviour matches the old service, without the indirection. ## Version bumps | | from | to | |---|---|---| | Go | 1.25.6 | 1.27.1 | | `emersion/go-smtp` | 0.24.0 | 0.25.0 | | `stretchr/testify` | 1.9.0 | 1.12.1 | | `pelletier/go-toml/v2` | 2.2.3 (indirect) | 2.4.3 (direct) | | `x/crypto/x509roots/fallback` | 2025-02-14 | 2026-09-02 | | alpine (Containerfile) | 3.20 | 3.24 | | golangci-lint | v2.8.0 | v2.13.2 | | `actions/checkout`, `actions/setup-go` | v6 | v7 | Indirects also refreshed (`x/net` 0.25.0 → 0.58.0, `x/sys`, `x/tools`, `fatih/color`, `go-cmp`, `logr`). `containrrr/shoutrrr` and `emersion/go-sasl` were already at their latest published revisions. ## Dropping `goreleaser-action` Bumping `actions/goreleaser-action` v6.4.0 → v7.2.3 broke the `goreleaser-lint` job: v7.1.0 made cosign signature verification of the action's own goreleaser download mandatory, and `ci-base` ships no cosign. Rather than pin the action back, this drops it. `ci-base` already installs goreleaser from the upstream apt repo — that is what `make build` has always used — so the action was downloading a second copy of a tool the image already had. Both workflows now invoke it directly, and `goreleaser check` moves behind a `make check` target, so every CI job runs a make target like the rest of the pipeline. ## Tests Coverage 63.1% → 75.9%. New tests cover `LoadConfig` (parsing, defaults, missing file, invalid TOML) and the replacement lifecycle — that a cancelled context shuts the server down and releases the listener, and that a bind failure propagates out of `Start`. On this exact tree (as PR #7) CI passed `format`, `lint`, `test` and `goreleaser-lint`. Locally, against Go 1.27.1: `make format` (clean diff), `make check`, `make ci-lint` (0 issues), `make test`, `make build` (6 targets). The release binary was also smoke-tested end to end: mail accepted, forwarded to a shoutrrr generic target, and `SIGTERM` shut it down cleanly with exit 0. Note: `release.yml` only runs on tags, so its goreleaser step is not exercised by this PR. The env vars it depends on (`GITEA_TOKEN`, `GORELEASER_FORCE_TOKEN`) are read by goreleaser itself rather than by the action, so invoking the binary directly is equivalent. ## Also Removed the unused `ANONYMOUS` SASL client from `cmd/sendmail` — its only caller was a commented-out line, and it was the last thing pulling `go-sasl` into that command. Reviewed-on: #8 Reviewed-by: Felipe M <me@fmartingr.com>
205 lines
6.4 KiB
Go
205 lines
6.4 KiB
Go
package smtp2shoutrrr
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// loopbackAddr rewrites the bound address to a dialable loopback address.
|
|
// The host stays "localhost" because smtp.PlainAuth refuses to authenticate
|
|
// when the dialed host differs from the one it was constructed with. It
|
|
// reports "" rather than failing the test, because it runs inside
|
|
// require.Eventually conditions, which execute on their own goroutine where
|
|
// t.FailNow would deadlock instead of reporting.
|
|
func loopbackAddr(s *Server) string {
|
|
_, port, err := net.SplitHostPort(s.Addr())
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return net.JoinHostPort("localhost", port)
|
|
}
|
|
|
|
func canDial(addr string) bool {
|
|
if addr == "" {
|
|
return false
|
|
}
|
|
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return conn.Close() == nil
|
|
}
|
|
|
|
// startServer starts srv on an ephemeral port and blocks until it is bound,
|
|
// returning the dial address and the channel Start returns on. Readiness is
|
|
// the bind rather than a probe connection: the address is set before Serve
|
|
// runs, connections queue in the backlog either way, and a probe that opens
|
|
// and drops a connection only adds accept churn and server-side log noise.
|
|
func startServer(t *testing.T, srv *Server) (string, context.CancelFunc, <-chan error) {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
stopped := make(chan error, 1)
|
|
go func() {
|
|
stopped <- srv.Start(ctx)
|
|
}()
|
|
|
|
require.Eventually(t, func() bool {
|
|
return srv.Addr() != "" || len(stopped) > 0
|
|
}, 5*time.Second, 10*time.Millisecond, "server never started listening")
|
|
|
|
// Report why Start gave up rather than letting it look like a slow bind.
|
|
select {
|
|
case err := <-stopped:
|
|
require.NoError(t, err, "server failed to start")
|
|
t.Fatal("server stopped before it began serving")
|
|
default:
|
|
}
|
|
|
|
return loopbackAddr(srv), cancel, stopped
|
|
}
|
|
|
|
func testConfig() *Config {
|
|
return &Config{Port: 0, Username: "testuser", Password: "testpass"}
|
|
}
|
|
|
|
func TestServerShutsDownOnContextCancel(t *testing.T) {
|
|
srv := NewSMTPServer(testConfig())
|
|
addr, cancel, stopped := startServer(t, srv)
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-stopped:
|
|
require.NoError(t, err)
|
|
case <-time.After(defaultShutdownTimeout + time.Second):
|
|
t.Fatal("server did not shut down after context cancellation")
|
|
}
|
|
|
|
require.False(t, canDial(addr), "listener should be released once Start returns")
|
|
}
|
|
|
|
func TestServerStartReturnsListenError(t *testing.T) {
|
|
blocker, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = blocker.Close() })
|
|
|
|
_, port, err := net.SplitHostPort(blocker.Addr().String())
|
|
require.NoError(t, err)
|
|
|
|
config := testConfig()
|
|
config.Port = mustAtoi(t, port)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
t.Cleanup(cancel)
|
|
|
|
require.Error(t, NewSMTPServer(config).Start(ctx))
|
|
}
|
|
|
|
// Regression: go-smtp's Shutdown only closes listeners Serve has registered.
|
|
// When the stop won that race, Accept kept running and Start never returned —
|
|
// a SIGTERM during startup left a process that only SIGKILL could clear.
|
|
func TestServerStartReturnsWhenCancelledBeforeListening(t *testing.T) {
|
|
srv := NewSMTPServer(testConfig())
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
stopped := make(chan error, 1)
|
|
go func() {
|
|
stopped <- srv.Start(ctx)
|
|
}()
|
|
|
|
select {
|
|
case err := <-stopped:
|
|
require.NoError(t, err)
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("Start did not return when its context was cancelled before the listener registered")
|
|
}
|
|
|
|
require.False(t, canDial(loopbackAddr(srv)), "listener should not be left accepting connections")
|
|
}
|
|
|
|
// Regression: an idle client is only dropped once its read deadline expires,
|
|
// so the drain lasts about ReadTimeout. With the shutdown budget set to the
|
|
// same value, an ordinary stop surfaced as "context deadline exceeded" and the
|
|
// command turned that into a non-zero exit.
|
|
func TestServerDrainsIdleConnectionWithoutError(t *testing.T) {
|
|
srv := NewSMTPServer(testConfig())
|
|
srv.backend.ReadTimeout = 300 * time.Millisecond
|
|
|
|
addr, cancel, stopped := startServer(t, srv)
|
|
|
|
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-stopped:
|
|
require.NoError(t, err, "an intentional shutdown must not report an error")
|
|
case <-time.After(defaultShutdownTimeout + time.Second):
|
|
t.Fatal("server did not drain the idle connection")
|
|
}
|
|
}
|
|
|
|
// The drain waits out a client's read deadline, so the budget needs real room
|
|
// above it. A strict inequality is not enough: a budget one millisecond over
|
|
// readTimeout is the same coin flip as one equal to it.
|
|
func TestShutdownBudgetLeavesMarginOverReadTimeout(t *testing.T) {
|
|
require.GreaterOrEqual(t, defaultShutdownTimeout, readTimeout+10*time.Second)
|
|
}
|
|
|
|
// Regression: overrunning the drain budget is still an intentional stop.
|
|
// Returning that deadline as an error made the command exit non-zero on a
|
|
// routine SIGTERM. Driven with a real overrun — a client that stays connected
|
|
// past a deliberately short budget — rather than asserted on the constant.
|
|
func TestServerReportsNoErrorWhenDrainOverrunsBudget(t *testing.T) {
|
|
srv := NewSMTPServer(testConfig())
|
|
// Long enough that the session outlives the budget, short enough that it
|
|
// does not outlive the test binary.
|
|
srv.backend.ReadTimeout = 3 * time.Second
|
|
srv.shutdownTimeout = 300 * time.Millisecond
|
|
|
|
addr, cancel, stopped := startServer(t, srv)
|
|
|
|
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
|
|
// Wait for the greeting so the session is registered and genuinely in
|
|
// flight when the drain starts.
|
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
buf := make([]byte, 64)
|
|
_, err = conn.Read(buf)
|
|
require.NoError(t, err)
|
|
|
|
start := time.Now()
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-stopped:
|
|
require.NoError(t, err, "a drain that outruns its budget is still an intentional stop")
|
|
case <-time.After(srv.backend.ReadTimeout + 5*time.Second):
|
|
t.Fatal("Start did not return after the drain budget expired")
|
|
}
|
|
|
|
require.Less(t, time.Since(start), srv.backend.ReadTimeout,
|
|
"Start should return on the budget, not wait out the read deadline")
|
|
}
|
|
|
|
func mustAtoi(t *testing.T, s string) int {
|
|
t.Helper()
|
|
|
|
n, err := net.LookupPort("tcp", s)
|
|
require.NoError(t, err)
|
|
|
|
return n
|
|
}
|