Compare commits

...

20 commits
v0.1.0 ... main

Author SHA1 Message Date
103742ed35
chore: remove forgejo workflows
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-03-26 17:37:58 +01:00
98f7448d49
Revert "refactor: change base url to codeberg.org"
Some checks failed
ci/woodpecker/push/ci Pipeline failed
CI / test (push) Has been cancelled
Release / release (push) Has been cancelled
This reverts commit a15223a5c2.
2025-03-26 17:37:13 +01:00
7c588033c9
ci: makefile commands to test, lint and format
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
CI / test (push) Has been cancelled
Release / release (push) Has been cancelled
2025-02-21 10:12:43 +01:00
ba23c2816c
deps: upgrade go 1.24 2025-02-21 09:59:45 +01:00
8a684e9ec1
test: added some tests 2025-02-21 09:59:12 +01:00
a15223a5c2
refactor: change base url to codeberg.org 2025-02-21 08:14:39 +01:00
361175d6af
chore: ignore .DS_Store files
Some checks are pending
Release / release (push) Waiting to run
2025-02-21 07:58:08 +01:00
b20d2d9735
ci: added forgejo action for codeberg.org 2025-02-21 07:53:52 +01:00
304e343a65
deps: go-toolkit to v0.1.0 2025-02-21 07:52:59 +01:00
4d7abab4d4
fix: bundle ca-certs directly in the binary
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-01-27 21:12:50 +01:00
bd8a3395e9
docs: Add catch-all recipient configuration to README 2024-12-02 16:33:48 +01:00
aa1fd99ffd
chore: unused vars in makefile 2024-12-02 16:32:49 +01:00
a00f64e43c
chore: add some disclaimer to containerfile 2024-12-02 16:32:40 +01:00
57acca0621
chore: ignore aider files 2024-12-02 16:32:32 +01:00
787b0f99fc
fix: ignore recipient if no target is set (aider fix) 2024-12-02 16:32:11 +01:00
4ace8af18b
fix: missing import (aider fix) 2024-12-02 16:31:58 +01:00
a2b696471c
feat: Add catch-all recipient support for unmatched email addresses 2024-12-02 16:30:43 +01:00
cfebaf09e3
feat: Add catch-all recipient configuration to Config struct 2024-12-02 16:29:03 +01:00
31f2102031
refactor: Reorganize SMTP2Shoutrrr code into separate files 2024-12-02 16:23:49 +01:00
9625807106
chore: updated readme with links to shoutrrr docs 2024-11-24 13:56:12 +01:00
13 changed files with 456 additions and 258 deletions

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ dist/
# smtp2shoutrrr # smtp2shoutrrr
config.toml config.toml
.aider*
.DS_Store

23
.woodpecker/ci.yml Normal file
View file

@ -0,0 +1,23 @@
when:
event:
- push
- pull_request
branch:
- main
steps:
format:
image: golang:1.24
commands:
- make format
- git diff --exit-code # Fail if files were changed
lint:
image: golang:1.24
commands:
- make ci-lint
test:
image: golang:1.24
commands:
- make test

View file

@ -1,3 +1,6 @@
# This file is used directly by the goreleaser build
# It is used to build the final container image
FROM scratch FROM scratch
WORKDIR /
COPY /smtp2shoutrrr /usr/bin/smtp2shoutrrr COPY /smtp2shoutrrr /usr/bin/smtp2shoutrrr
ENTRYPOINT ["/usr/bin/smtp2shoutrrr"] ENTRYPOINT ["/usr/bin/smtp2shoutrrr"]

View file

@ -5,6 +5,8 @@ SOURCE_FILES ?=./...
TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out
TEST_TIMEOUT ?=1m TEST_TIMEOUT ?=1m
GOLANGCI_LINT_VERSION ?= v1.64.5
CLEAN_OPTIONS ?=-modcache -testcache CLEAN_OPTIONS ?=-modcache -testcache
CGO_ENABLED := 0 CGO_ENABLED := 0
@ -12,7 +14,6 @@ CGO_ENABLED := 0
BUILDS_PATH := ./dist BUILDS_PATH := ./dist
FROM_MAKEFILE := y FROM_MAKEFILE := y
CONTAINER_RUNTIME := podman
CONTAINERFILE_NAME := Containerfile CONTAINERFILE_NAME := Containerfile
CONTAINER_ALPINE_VERSION := 3.20 CONTAINER_ALPINE_VERSION := 3.20
CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}" CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}"
@ -31,7 +32,6 @@ export TEST_OPTIONS
export TEST_TIMEOUT export TEST_TIMEOUT
export BUILDS_PATH export BUILDS_PATH
export CONTAINER_RUNTIME
export CONTAINERFILE_NAME export CONTAINERFILE_NAME
export CONTAINER_ALPINE_VERSION export CONTAINER_ALPINE_VERSION
export CONTAINER_SOURCE_URL export CONTAINER_SOURCE_URL
@ -80,13 +80,21 @@ run: ### Executes the project build locally
.PHONY: format .PHONY: format
format: ### Executes the formatting pipeline on the project format: ### Executes the formatting pipeline on the project
$(info: Make: Format) $(info: Make: Format)
@bash scripts/format.sh @go fmt ./...
@go mod tidy
.PHONY: ci-lint
ci-lint: ### Check the project for errors
$(info: Make: Lint)
@go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}
@golangci-lint run ./...
.PHONY: lint .PHONY: lint
lint: ### Check the project for errors lint: ### Check the project for errors
$(info: Make: Lint) $(info: Make: Lint)
@bash scripts/lint.sh @golangci-lint run ./...
.PHONY: test .PHONY: test
test: ### Runs the test suite test: ### Runs the test suite
@bash scripts/test.sh $(info: Make: Test)
CGO_ENABLED=1 go test ${TEST_OPTIONS} -timeout=${TEST_TIMEOUT} ${SOURCE_FILES}

View file

@ -1,6 +1,6 @@
# smtp2Shoutrrr # smtp2Shoutrrr
A simple SMTP server that forwards incoming emails to a Shoutrrr service. A simple SMTP server that forwards incoming emails to a [Shoutrrr supported service](https://containrrr.dev/shoutrrr/).
## Installing ## Installing
@ -20,9 +20,15 @@ Password = "nometokens"
# Email addresses to forward emails from # Email addresses to forward emails from
Addresses = ["some@email.com"] Addresses = ["some@email.com"]
# Shoutrrr service to forward emails to # Shoutrrr service to forward emails to
# See shoutrrr documentation: https://containrrr.dev/shoutrrr/
Target = "ntfy://ntfy.sh/my-ntfy-topic?tags=thing" Target = "ntfy://ntfy.sh/my-ntfy-topic?tags=thing"
# Note: Repeat [[Recipients]] as needed # Note: Repeat [[Recipients]] as needed
# Optional: Configure a catch-all recipient for unmatched email addresses
[CatchAll]
# Shoutrrr service to forward unmatched emails to
Target = "ntfy://ntfy.sh/catch-all-topic?tags=unmatched"
``` ```
### From releases ### From releases

141
backend.go Normal file
View file

@ -0,0 +1,141 @@
package smtp2shoutrrr
import (
"fmt"
"io"
"log/slog"
"net/mail"
"net/url"
"slices"
"strings"
"github.com/containrrr/shoutrrr"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
)
type Backend struct {
config *Config
}
func (bkd *Backend) sendNotification(recipient ConfigRecipient, email ReceivedEmail) error {
if recipient.Target == "" {
slog.Warn("no target provided for recipient", slog.String("recipient", strings.Join(recipient.Addresses, ",")))
return nil
}
urlParams := url.Values{
"title": {email.Msg.Header.Get("Subject")},
}
destinationURL := recipient.GetTargetURL()
destinationURL.RawQuery = urlParams.Encode()
body, err := email.Body()
if err != nil {
slog.Error("Error getting email body", slog.String("err", err.Error()))
return fmt.Errorf("failed to get email body: %w", err)
}
if err := shoutrrr.Send(destinationURL.String(), body); err != nil {
slog.Error("Error sending message", slog.String("err", err.Error()))
return fmt.Errorf("failed to send notification: %w", err)
}
return nil
}
func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
return &Session{
forwarderFunc: bkd.forwardEmail,
config: bkd.config,
}, nil
}
func (bkd *Backend) forwardEmail(email ReceivedEmail) error {
slog.Info("forwading message", slog.String("to", strings.Join(email.Recipients, ",")))
// Try to match configured recipients first
matched := false
for _, r := range bkd.config.Recipients {
for _, a := range email.Recipients {
if slices.Contains(r.Addresses, a) {
if err := bkd.sendNotification(r, email); err != nil {
return err
}
matched = true
break
}
}
if matched {
break
}
}
// If no recipient matched and catch-all is configured, use it
if !matched && bkd.config.CatchAll != nil {
slog.Info("using catch-all recipient for unmatched email")
if err := bkd.sendNotification(*bkd.config.CatchAll, email); err != nil {
return err
}
}
return nil
}
type Session struct {
addresses []string
config *Config
forwarderFunc func(ReceivedEmail) error
}
func (s *Session) AuthMechanisms() []string {
return []string{sasl.Plain}
}
func (s *Session) Auth(mech string) (sasl.Server, error) {
return sasl.NewPlainServer(func(identity, username, password string) error {
if username != s.config.Username && password != s.config.Password {
return fmt.Errorf("invalid credentials")
}
return nil
}), nil
}
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
slog.Debug("Mail from", slog.String("from", from))
return nil
}
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
slog.Debug("Rcpt to", slog.String("to", to))
s.addresses = append(s.addresses, to)
return nil
}
func (s *Session) Data(r io.Reader) error {
msg, err := mail.ReadMessage(r)
if err != nil {
slog.Error("Error reading data", slog.String("err", err.Error()))
return fmt.Errorf("Error reading data: %w", err)
}
slog.Info("Received email", slog.String("destination", strings.Join(s.addresses, ",")))
if err := s.forwarderFunc(ReceivedEmail{
Recipients: s.addresses,
Msg: msg,
}); err != nil {
slog.Error("Error forwarding email", slog.String("err", err.Error()))
}
return nil
}
func (s *Session) Reset() {}
func (s *Session) Logout() error {
return nil
}

85
backend_test.go Normal file
View file

@ -0,0 +1,85 @@
package smtp2shoutrrr
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/smtp"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestEmailForwarding(t *testing.T) {
// Start mock ntfy server
notifications := make([]string, 0)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Read body and log
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
notifications = append(notifications, string(body))
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
// Configure the SMTP server
config := &Config{
Port: 2525,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Target: "generic+" + mockServer.URL + "/?template=json",
},
},
}
ctx := context.Background()
smtpServer := NewSMTPServer(config)
// Start the server
go func() {
if err := smtpServer.Start(ctx); err != nil {
t.Errorf("failed to start server: %v", err)
}
}()
// Give the server time to start
time.Sleep(100 * time.Millisecond)
defer func() {
if err := smtpServer.Stop(ctx); err != nil {
t.Errorf("failed to stop server: %v", err)
}
}()
// Send test email
auth := smtp.PlainAuth("", config.Username, config.Password, "localhost")
err := smtp.SendMail(
fmt.Sprintf("localhost:%d", config.Port),
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Test Email\r\n\r\nThis is a test email body"),
)
require.NoError(t, err)
// Give some time for the notification to be processed
time.Sleep(100 * time.Millisecond)
// Verify the notification was received
require.Len(t, notifications, 1)
require.Contains(t, notifications[0], "This is a test email body")
}

View file

@ -1,236 +1,18 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt"
"io"
"log"
"log/slog" "log/slog"
"mime"
"mime/multipart"
"net/mail"
"net/url"
"os" "os"
"slices"
"strings"
"time"
"git.nakama.town/fmartingr/gotoolkit/encoding" "git.nakama.town/fmartingr/gotoolkit/encoding"
"git.nakama.town/fmartingr/gotoolkit/model" "git.nakama.town/fmartingr/gotoolkit/model"
"git.nakama.town/fmartingr/gotoolkit/service" "git.nakama.town/fmartingr/gotoolkit/service"
"github.com/containrrr/shoutrrr"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"git.nakama.town/fmartingr/smtp2shoutrrr" "git.nakama.town/fmartingr/smtp2shoutrrr"
_ "golang.org/x/crypto/x509roots/fallback"
) )
type ReceivedEmail struct {
Recipients []string
Msg *mail.Message
body string
}
func (re *ReceivedEmail) Body() (string, error) {
if re.body == "" {
// Get the Content-Type header
contentType := re.Msg.Header.Get("Content-Type")
if contentType == "" {
body, err := io.ReadAll(re.Msg.Body)
if err != nil {
return "", fmt.Errorf("failed to read email body: %w", err)
}
re.body = string(body)
} else {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
log.Fatalf("Failed to parse Content-Type: %v", err)
}
if strings.HasPrefix(mediaType, "multipart/alternative") {
// Parse the multipart message
mr := multipart.NewReader(re.Msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break // End of parts
}
defer part.Close()
// Print part headers
fmt.Printf("Part Content-Type: %s\n", part.Header.Get("Content-Type"))
// Set body if this is the text/plain part
if strings.HasPrefix(part.Header.Get("Content-Type"), "text/plain") {
// Read the part's body
body := new(bytes.Buffer)
_, err = body.ReadFrom(part)
if err != nil {
slog.Error("Failed to read part body", slog.String("err", err.Error()))
return "", fmt.Errorf("failed to read part body: %w", err)
}
re.body = body.String()
break
}
}
}
}
}
return re.body, nil
}
var _ model.Server = (*smtpServer)(nil)
type smtpServer struct {
backend *smtp.Server
config smtp2shoutrrr.Config
}
func (s *smtpServer) IsEnabled() bool {
return true
}
func (s *smtpServer) Start(_ context.Context) error {
slog.Info("Started SMTP server", slog.String("addr", s.backend.Addr))
return s.backend.ListenAndServe()
}
func (s *smtpServer) Stop(ctx context.Context) error {
slog.Info("Stopping SMTP server")
return s.backend.Shutdown(ctx)
}
func NewSMTPServer(config *smtp2shoutrrr.Config) model.Server {
be := &Backend{
config: config,
}
smtpBackend := smtp.NewServer(be)
smtpBackend.Addr = fmt.Sprintf(":%d", config.Port)
// smtpBackend.Domain = "localhost"
smtpBackend.WriteTimeout = 10 * time.Second
smtpBackend.ReadTimeout = 10 * time.Second
smtpBackend.MaxMessageBytes = 1024 * 1024
smtpBackend.MaxRecipients = 50
smtpBackend.AllowInsecureAuth = true
return &smtpServer{
backend: smtpBackend,
}
}
// The Backend implements SMTP server methods.
type Backend struct {
config *smtp2shoutrrr.Config
}
// NewSession is called after client greeting (EHLO, HELO).
func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
return &Session{
forwarderFunc: bkd.forwardEmail,
config: bkd.config,
}, nil
}
func (bkd *Backend) forwardEmail(email ReceivedEmail) error {
slog.Info("forwading message", slog.String("to", strings.Join(email.Recipients, ",")))
for _, r := range bkd.config.Recipients {
for _, a := range email.Recipients {
if slices.Contains(r.Addresses, a) {
urlParams := url.Values{
"title": {email.Msg.Header.Get("Subject")},
}
destinationURL := r.GetTargetURL()
destinationURL.RawQuery = urlParams.Encode()
body, err := email.Body()
if err != nil {
slog.Error("Error getting email body", slog.String("err", err.Error()))
continue
}
if err := shoutrrr.Send(destinationURL.String(), body); err != nil {
slog.Error("Error sending message", slog.String("err", err.Error()))
continue
}
// Already sent via this configuration, go to the next
break
}
}
}
return nil
}
// A Session is returned after successful login.
type Session struct {
addresses []string
body string
config *smtp2shoutrrr.Config
forwarderFunc func(ReceivedEmail) error
}
// AuthMechanisms returns a slice of available auth mechanisms; only PLAIN is
// supported in this example.
func (s *Session) AuthMechanisms() []string {
return []string{sasl.Plain}
}
// Auth is the handler for supported authenticators.
func (s *Session) Auth(mech string) (sasl.Server, error) {
return sasl.NewPlainServer(func(identity, username, password string) error {
if username != s.config.Username && password != s.config.Password {
return fmt.Errorf("invalid credentials")
}
return nil
}), nil
}
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
slog.Debug("Mail from", slog.String("from", from))
return nil
}
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
slog.Debug("Rcpt to", slog.String("to", to))
s.addresses = append(s.addresses, to)
return nil
}
func (s *Session) Data(r io.Reader) error {
msg, err := mail.ReadMessage(r)
if err != nil {
slog.Error("Error reading data", slog.String("err", err.Error()))
return fmt.Errorf("Error reading data: %w", err)
}
slog.Info("Received email", slog.String("destination", strings.Join(s.addresses, ",")))
if err := s.forwarderFunc(ReceivedEmail{
Recipients: s.addresses,
Msg: msg,
}); err != nil {
slog.Error("Error forwarding email", slog.String("err", err.Error()))
}
return nil
}
func (s *Session) Reset() {}
func (s *Session) Logout() error {
return nil
}
func main() { func main() {
var config *smtp2shoutrrr.Config var config *smtp2shoutrrr.Config
configPath := "config.toml" configPath := "config.toml"
@ -252,7 +34,7 @@ func main() {
slog.Info("config loaded", slog.Int("recipients", len(config.Recipients))) slog.Info("config loaded", slog.Int("recipients", len(config.Recipients)))
ctx := context.Background() ctx := context.Background()
smtpServer := NewSMTPServer(config) smtpServer := smtp2shoutrrr.NewSMTPServer(config)
svc, err := service.NewService([]model.Server{ svc, err := service.NewService([]model.Server{
smtpServer, smtpServer,

View file

@ -10,6 +10,7 @@ type Config struct {
Username string Username string
Password string Password string
Recipients []ConfigRecipient Recipients []ConfigRecipient
CatchAll *ConfigRecipient
} }
func (c *Config) SetDefaults() { func (c *Config) SetDefaults() {

65
email.go Normal file
View file

@ -0,0 +1,65 @@
package smtp2shoutrrr
import (
"bytes"
"fmt"
"io"
"log"
"log/slog"
"mime"
"mime/multipart"
"net/mail"
"strings"
)
type ReceivedEmail struct {
Recipients []string
Msg *mail.Message
body string
}
func (re *ReceivedEmail) Body() (string, error) {
if re.body == "" {
contentType := re.Msg.Header.Get("Content-Type")
if contentType == "" {
body, err := io.ReadAll(re.Msg.Body)
if err != nil {
return "", fmt.Errorf("failed to read email body: %w", err)
}
re.body = string(body)
} else {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
log.Fatalf("Failed to parse Content-Type: %v", err)
}
if strings.HasPrefix(mediaType, "multipart/alternative") {
mr := multipart.NewReader(re.Msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
defer part.Close()
fmt.Printf("Part Content-Type: %s\n", part.Header.Get("Content-Type"))
if strings.HasPrefix(part.Header.Get("Content-Type"), "text/plain") {
body := new(bytes.Buffer)
_, err = body.ReadFrom(part)
if err != nil {
slog.Error("Failed to read part body", slog.String("err", err.Error()))
return "", fmt.Errorf("failed to read part body: %w", err)
}
re.body = body.String()
break
}
}
}
}
}
return re.body, nil
}

26
go.mod
View file

@ -1,20 +1,34 @@
module git.nakama.town/fmartingr/smtp2shoutrrr module git.nakama.town/fmartingr/smtp2shoutrrr
go 1.23.3 go 1.24
require ( require (
git.nakama.town/fmartingr/gotoolkit v0.0.0-20241123184121-ef80892aa542 git.nakama.town/fmartingr/gotoolkit v0.1.0
github.com/containrrr/shoutrrr v0.8.0 github.com/containrrr/shoutrrr v0.8.0
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.21.3 github.com/emersion/go-smtp v0.21.3
github.com/stretchr/testify v1.9.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20250214233241-911360c8a4f4
) )
require ( require (
github.com/fatih/color v1.15.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
golang.org/x/sys v0.22.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.30.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 //replace git.nakama.town/fmartingr/gotoolkit => ../gotoolkit

69
go.sum
View file

@ -1,30 +1,39 @@
git.nakama.town/fmartingr/gotoolkit v0.0.0-20241123184121-ef80892aa542 h1:y1LuMKkVUvrPDOuHmhHTGpNFu+S34NwxgAU3cHN/ihk= git.nakama.town/fmartingr/gotoolkit v0.1.0 h1:qZcoF+L/x5dTyhBSmo3siEYTy2Vlnlu1R6OvdF7jFsY=
git.nakama.town/fmartingr/gotoolkit v0.0.0-20241123184121-ef80892aa542/go.mod h1:wT4a0weU051koADRquRKWQeUsNeOyLDm7lqSqVl16Z8= git.nakama.town/fmartingr/gotoolkit v0.1.0/go.mod h1:wT4a0weU051koADRquRKWQeUsNeOyLDm7lqSqVl16Z8=
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
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-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1/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.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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=
@ -33,21 +42,31 @@ 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.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 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.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/crypto/x509roots/fallback v0.0.0-20250214233241-911360c8a4f4 h1:QDiVWrFJ2lyXzr3pJnIREQWR8S7jkjzuWJPJda8Ic8E=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/crypto/x509roots/fallback v0.0.0-20250214233241-911360c8a4f4/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 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=

49
server.go Normal file
View file

@ -0,0 +1,49 @@
package smtp2shoutrrr
import (
"context"
"fmt"
"log/slog"
"time"
"git.nakama.town/fmartingr/gotoolkit/model"
"github.com/emersion/go-smtp"
)
var _ model.Server = (*smtpServer)(nil)
type smtpServer struct {
backend *smtp.Server
}
func (s *smtpServer) IsEnabled() bool {
return true
}
func (s *smtpServer) Start(_ context.Context) error {
slog.Info("Started SMTP server", slog.String("addr", s.backend.Addr))
return s.backend.ListenAndServe()
}
func (s *smtpServer) Stop(ctx context.Context) error {
slog.Info("Stopping SMTP server")
return s.backend.Shutdown(ctx)
}
func NewSMTPServer(config *Config) model.Server {
be := &Backend{
config: config,
}
smtpBackend := smtp.NewServer(be)
smtpBackend.Addr = fmt.Sprintf(":%d", config.Port)
smtpBackend.WriteTimeout = 10 * time.Second
smtpBackend.ReadTimeout = 10 * time.Second
smtpBackend.MaxMessageBytes = 1024 * 1024
smtpBackend.MaxRecipients = 50
smtpBackend.AllowInsecureAuth = true
return &smtpServer{
backend: smtpBackend,
}
}