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>
156 lines
4.3 KiB
Go
156 lines
4.3 KiB
Go
package smtp2shoutrrr
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/emersion/go-smtp"
|
|
)
|
|
|
|
const (
|
|
readTimeout = 10 * time.Second
|
|
writeTimeout = 10 * time.Second
|
|
|
|
// defaultShutdownTimeout bounds the drain of in-flight sessions. It must
|
|
// stay well clear of readTimeout: an idle client is only dropped once its
|
|
// read deadline expires, so a budget near readTimeout would report an
|
|
// ordinary drain as a failed shutdown.
|
|
defaultShutdownTimeout = readTimeout + 20*time.Second
|
|
)
|
|
|
|
type Server struct {
|
|
backend *smtp.Server
|
|
|
|
// shutdownTimeout is a field rather than the constant so tests can drive a
|
|
// real budget overrun without waiting out readTimeout.
|
|
shutdownTimeout time.Duration
|
|
|
|
mu sync.Mutex
|
|
listener net.Listener
|
|
addr string
|
|
}
|
|
|
|
// Addr reports the address the server is bound to, which is only known after
|
|
// Start has bound it — relevant when the configured port is 0.
|
|
func (s *Server) Addr() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.addr
|
|
}
|
|
|
|
func (s *Server) setListener(l net.Listener) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.listener = l
|
|
s.addr = l.Addr().String()
|
|
}
|
|
|
|
func (s *Server) closeListener() {
|
|
s.mu.Lock()
|
|
l := s.listener
|
|
s.listener = nil
|
|
s.mu.Unlock()
|
|
|
|
if l != nil {
|
|
_ = l.Close()
|
|
}
|
|
}
|
|
|
|
// Start binds the configured address and serves connections until ctx is
|
|
// cancelled or Stop is called, then drains in-flight sessions. It returns nil
|
|
// on a clean shutdown.
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
// Binding here rather than inside ListenAndServe keeps the listener under
|
|
// our control: go-smtp's Shutdown only closes listeners Serve has already
|
|
// registered, so a stop that arrives first would otherwise leave Accept
|
|
// running forever.
|
|
l, err := net.Listen("tcp", s.backend.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("listen on %q: %w", s.backend.Addr, err)
|
|
}
|
|
s.setListener(l)
|
|
|
|
slog.Info("Started SMTP server", slog.String("addr", l.Addr().String()))
|
|
|
|
served := make(chan error, 1)
|
|
go func() {
|
|
served <- s.backend.Serve(l)
|
|
}()
|
|
|
|
select {
|
|
case err := <-served:
|
|
// Serve gave up on its own; still release the listener and drain any
|
|
// sessions it left running before reporting why.
|
|
s.closeListener()
|
|
s.drain(context.WithoutCancel(ctx))
|
|
return err
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
// Stop accepting and let Serve return before draining. go-smtp registers
|
|
// each accepted session on a WaitGroup that Shutdown waits on from another
|
|
// goroutine, so an Accept still in flight would Add to that WaitGroup
|
|
// concurrently with the Wait — a data race, and a session the drain then
|
|
// fails to wait for.
|
|
s.closeListener()
|
|
if err := <-served; err != nil && !errors.Is(err, net.ErrClosed) {
|
|
return err
|
|
}
|
|
|
|
s.drain(context.WithoutCancel(ctx))
|
|
|
|
return nil
|
|
}
|
|
|
|
// drain stops the backend and waits for in-flight sessions to finish. Running
|
|
// past the budget is still an intentional stop, so it is logged rather than
|
|
// reported: turning it into an error exits the process non-zero on a routine
|
|
// signal.
|
|
func (s *Server) drain(ctx context.Context) {
|
|
shutdownCtx, cancel := context.WithTimeout(ctx, s.shutdownTimeout)
|
|
defer cancel()
|
|
|
|
// Shutdown also closes the listener, which we have already closed, so its
|
|
// close error is expected rather than a sign of a session still running.
|
|
if err := s.Stop(shutdownCtx); err != nil &&
|
|
!errors.Is(err, smtp.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
|
|
slog.Warn("SMTP server stopped with sessions still in flight",
|
|
slog.String("err", err.Error()))
|
|
}
|
|
}
|
|
|
|
// Stop shuts the server down and releases its listener. A Server is single
|
|
// use: the backend cannot be restarted once stopped, so a later Start binds
|
|
// the address and then immediately gives it up.
|
|
func (s *Server) Stop(ctx context.Context) error {
|
|
slog.Info("Stopping SMTP server")
|
|
|
|
err := s.backend.Shutdown(ctx)
|
|
s.closeListener()
|
|
|
|
return err
|
|
}
|
|
|
|
func NewSMTPServer(config *Config) *Server {
|
|
be := &Backend{
|
|
config: config,
|
|
}
|
|
|
|
smtpBackend := smtp.NewServer(be)
|
|
smtpBackend.Addr = fmt.Sprintf(":%d", config.Port)
|
|
smtpBackend.WriteTimeout = writeTimeout
|
|
smtpBackend.ReadTimeout = readTimeout
|
|
smtpBackend.MaxMessageBytes = 1024 * 1024
|
|
smtpBackend.MaxRecipients = 50
|
|
smtpBackend.AllowInsecureAuth = true
|
|
|
|
return &Server{
|
|
backend: smtpBackend,
|
|
shutdownTimeout: defaultShutdownTimeout,
|
|
}
|
|
}
|