diff --git a/cmd/sendmail/main.go b/cmd/sendmail/main.go index 8240473..155481e 100644 --- a/cmd/sendmail/main.go +++ b/cmd/sendmail/main.go @@ -63,8 +63,8 @@ func main() { hostname := "localhost" auth := NewPlainClient("", "username", "password") // auth := NewAnonymousClient("test") - recipients := []string{"test@test.com"} - msg := []byte("hello!") + recipients := []string{"something@something.com", "caca@caca.com"} + msg := []byte("\r\nwithout title") from := "hello@localhost" err := smtp.SendMail(hostname+":11025", auth, from, recipients, msg) if err != nil { diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..6591802 --- /dev/null +++ b/config.toml @@ -0,0 +1,7 @@ +[[Recipients]] +Addresses = ["something@something.com"] +Target = "ntfy://ntfy.sh/fmartingr-dev" + +[[Recipients]] +Addresses = ["caca@caca.com"] +Target = "ntfy://ntfy.sh/fmartingr-dev" diff --git a/go.mod b/go.mod index 854f86a..d8eee2b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,17 @@ module git.nakama.town/fmartingr/smtp2shoutrrr go 1.23.3 require ( + git.nakama.town/fmartingr/gotoolkit v0.0.0-00010101000000-000000000000 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-smtp v0.21.3 ) + +require ( + github.com/containrrr/shoutrrr v0.8.0 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.22.0 // indirect +) + +replace git.nakama.town/fmartingr/gotoolkit => ../gotoolkit diff --git a/go.sum b/go.sum index e12e95e..cf6d65f 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,17 @@ +github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= +github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= 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-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= 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.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/main.go b/main.go index bc415f1..ed11bee 100644 --- a/main.go +++ b/main.go @@ -1,27 +1,142 @@ package main import ( + "context" "errors" + "fmt" "io" - "log" "log/slog" - "net/http" + "net/mail" + "net/url" + "slices" + "strings" "time" + "git.nakama.town/fmartingr/gotoolkit/model" + "git.nakama.town/fmartingr/gotoolkit/service" + "github.com/containrrr/shoutrrr" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" ) +type ReceivedEmail struct { + Recipients []string + Msg *mail.Message + body string +} + +func (re *ReceivedEmail) String() string { + return fmt.Sprintf(`FROM: %s + TO: %s, + BODY: %s`, re.Msg.Header.Get("From"), re.Recipients, re.Body()) +} + +func (re *ReceivedEmail) Body() string { + if re.body == "" { + body, err := io.ReadAll(re.Msg.Body) + if err != nil { + slog.Error("failed to read email body", slog.String("err", err.Error())) + } + re.body = string(body) + } + + return re.body +} + +type Config struct { + Recipients []ConfigRecipient +} + +type ConfigRecipient struct { + Addresses []string // email addresses + target string // shoutrrr address + targetURL *url.URL +} + +func (cr ConfigRecipient) Target() *url.URL { + if cr.targetURL == nil { + var err error + cr.targetURL, err = url.Parse(cr.target) + if err != nil { + slog.Error("failed to parse shoutrrr target URL", slog.String("target", cr.target), slog.String("err", err.Error())) + } + } + return cr.targetURL +} + +var _ model.Server = (*smtpServer)(nil) + +type smtpServer struct { + backend *smtp.Server + config 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(backend *smtp.Server) model.Server { + return &smtpServer{ + backend: backend, + } +} + // The Backend implements SMTP server methods. -type Backend struct{} +type Backend struct { + config *Config +} // NewSession is called after client greeting (EHLO, HELO). func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) { - return &Session{}, nil + return &Session{ + forwarderFunc: bkd.forwardEmail, + }, nil +} + +func (bkd *Backend) forwardEmail(email ReceivedEmail) error { + slog.Info("forwading message", slog.String("email", email.String())) + + 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.Target() + destinationURL.RawQuery = urlParams.Encode() + + if err := shoutrrr.Send(destinationURL.String(), email.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{} +type Session struct { + addresses []string + body string + + forwarderFunc func(ReceivedEmail) error +} // AuthMechanisms returns a slice of available auth mechanisms; only PLAIN is // supported in this example. @@ -40,20 +155,32 @@ func (s *Session) Auth(mech string) (sasl.Server, error) { } func (s *Session) Mail(from string, opts *smtp.MailOptions) error { - log.Println("Mail from:", from, opts) + slog.Debug("Mail from", slog.String("from", from)) return nil } func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { - log.Println("Rcpt to:", to) + slog.Debug("Rcpt to", slog.String("to", to)) + s.addresses = append(s.addresses, to) return nil } func (s *Session) Data(r io.Reader) error { - _, err := http.Post("https://ntfy.sh/fmartingr-dev", "text/plain", r) + msg, err := mail.ReadMessage(r) if err != nil { - slog.Error("Error sending message:", slog.String("err", err.Error())) + 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 } @@ -63,34 +190,48 @@ func (s *Session) Logout() error { return nil } -// ExampleServer runs an example SMTP server. -// -// It can be tested manually with e.g. netcat: -// -// > netcat -C localhost 1025 -// EHLO localhost -// AUTH PLAIN -// AHVzZXJuYW1lAHBhc3N3b3Jk -// MAIL FROM: -// RCPT TO: -// DATA -// Hey <3 -// . func main() { - be := &Backend{} + config := &Config{ + Recipients: []ConfigRecipient{{ + Addresses: []string{"test@fmartingr.com", "caca@caca.com"}, + target: "ntfy://ntfy.sh/fmartingr-dev", + }, { + Addresses: []string{"something@something.com"}, + target: "ntfy://ntfy.sh/fmartingr-dev", + }}, + } - s := smtp.NewServer(be) + be := &Backend{ + config: config, + } - s.Addr = "localhost:11025" - s.Domain = "localhost" - s.WriteTimeout = 10 * time.Second - s.ReadTimeout = 10 * time.Second - s.MaxMessageBytes = 1024 * 1024 - s.MaxRecipients = 50 - s.AllowInsecureAuth = true + smtpBackend := smtp.NewServer(be) - log.Println("Starting server at", s.Addr) - if err := s.ListenAndServe(); err != nil { - log.Fatal(err) + smtpBackend.Addr = "localhost:11025" + smtpBackend.Domain = "localhost" + smtpBackend.WriteTimeout = 10 * time.Second + smtpBackend.ReadTimeout = 10 * time.Second + smtpBackend.MaxMessageBytes = 1024 * 1024 + smtpBackend.MaxRecipients = 50 + smtpBackend.AllowInsecureAuth = true + + ctx := context.Background() + smtpServer := NewSMTPServer(smtpBackend) + + svc, err := service.NewService([]model.Server{ + 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())) } }