diff --git a/server/server.go b/server/server.go index 52c1c85..9cf76de 100644 --- a/server/server.go +++ b/server/server.go @@ -769,7 +769,7 @@ func (s *Server) runSMTPServer() error { s.smtpServer.Domain = s.config.SMTPServerDomain s.smtpServer.ReadTimeout = 10 * time.Second s.smtpServer.WriteTimeout = 10 * time.Second - s.smtpServer.MaxMessageBytes = 2 * s.config.MessageLimit + s.smtpServer.MaxMessageBytes = 1024 * 1024 // Must be much larger than message size (headers, multipart, etc.) s.smtpServer.MaxRecipients = 1 s.smtpServer.AllowInsecureAuth = true return s.smtpServer.ListenAndServe() diff --git a/server/smtp_server.go b/server/smtp_server.go index df6a18b..19fcca4 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -5,16 +5,19 @@ import ( "errors" "github.com/emersion/go-smtp" "io" + "mime" + "mime/multipart" "net/mail" "strings" "sync" ) var ( - errInvalidDomain = errors.New("invalid domain") - errInvalidAddress = errors.New("invalid address") - errInvalidTopic = errors.New("invalid topic") - errTooManyRecipients = errors.New("too many recipients") + errInvalidDomain = errors.New("invalid domain") + errInvalidAddress = errors.New("invalid address") + errInvalidTopic = errors.New("invalid topic") + errTooManyRecipients = errors.New("too many recipients") + errUnsupportedContentType = errors.New("unsupported content type") ) // smtpBackend implements SMTP server methods. @@ -94,6 +97,7 @@ func (s *smtpSession) Rcpt(to string) error { func (s *smtpSession) Data(r io.Reader) error { return s.withFailCount(func() error { + conf := s.backend.config b, err := io.ReadAll(r) // Protected by MaxMessageBytes if err != nil { return err @@ -102,13 +106,21 @@ func (s *smtpSession) Data(r io.Reader) error { if err != nil { return err } - body, err := io.ReadAll(io.LimitReader(msg.Body, int64(s.backend.config.MessageLimit))) + body, err := readMailBody(msg) if err != nil { return err } - m := newDefaultMessage(s.topic, string(body)) + if len(body) > conf.MessageLimit { + body = body[:conf.MessageLimit] + } + m := newDefaultMessage(s.topic, body) subject := msg.Header.Get("Subject") if subject != "" { + dec := mime.WordDecoder{} + subject, err := dec.DecodeHeader(subject) + if err != nil { + return err + } m.Title = subject } if err := s.backend.sub(m); err != nil { @@ -140,3 +152,39 @@ func (s *smtpSession) withFailCount(fn func() error) error { } return err } + +func readMailBody(msg *mail.Message) (string, error) { + contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) + if err != nil { + return "", err + } + if contentType == "text/plain" { + body, err := io.ReadAll(msg.Body) + if err != nil { + return "", err + } + return string(body), nil + } + if strings.HasPrefix(contentType, "multipart/") { + mr := multipart.NewReader(msg.Body, params["boundary"]) + for { + part, err := mr.NextPart() + if err != nil { // may be io.EOF + return "", err + } + partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + return "", err + } + if partContentType != "text/plain" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + return "", err + } + return string(body), nil + } + } + return "", errUnsupportedContentType +} diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go new file mode 100644 index 0000000..c842365 --- /dev/null +++ b/server/smtp_server_test.go @@ -0,0 +1,158 @@ +package server + +import ( + "github.com/emersion/go-smtp" + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +func TestSmtpBackend_Multipart(t *testing.T) { + email := `MIME-Version: 1.0 +Date: Tue, 28 Dec 2021 00:30:10 +0100 +Message-ID: +Subject: and one more +From: Phil +To: ntfy-mytopic@ntfy.sh +Content-Type: multipart/alternative; boundary="000000000000f3320b05d42915c9" + +--000000000000f3320b05d42915c9 +Content-Type: text/plain; charset="UTF-8" + +what's up + +--000000000000f3320b05d42915c9 +Content-Type: text/html; charset="UTF-8" + +
what's up

+ +--000000000000f3320b05d42915c9--` + _, backend := newTestBackend(t, func(m *message) error { + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "and one more", m.Title) + require.Equal(t, "what's up\n", m.Message) + return nil + }) + session, _ := backend.AnonymousLogin(nil) + require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) + require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) + require.Nil(t, session.Data(strings.NewReader(email))) +} + +func TestSmtpBackend_Plaintext(t *testing.T) { + email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 +Message-ID: +Subject: and one more +From: Phil +To: mytopic@ntfy.sh +Content-Type: text/plain; charset="UTF-8" + +what's up +` + conf, backend := newTestBackend(t, func(m *message) error { + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "and one more", m.Title) + require.Equal(t, "what's up\n", m.Message) + return nil + }) + conf.SMTPServerAddrPrefix = "" + session, _ := backend.AnonymousLogin(nil) + require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) + require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) + require.Nil(t, session.Data(strings.NewReader(email))) +} + +func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) { + email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 +Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?= +From: Phil +To: ntfy-mytopic@ntfy.sh +Content-Type: text/plain; charset="UTF-8" + +what's up +` + _, backend := newTestBackend(t, func(m *message) error { + require.Equal(t, "Three santas 🎅🎅🎅", m.Title) + return nil + }) + session, _ := backend.AnonymousLogin(nil) + require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) + require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) + require.Nil(t, session.Data(strings.NewReader(email))) +} + +func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) { + email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 +Message-ID: +Subject: and one more +From: Phil +To: mytopic@ntfy.sh +Content-Type: text/plain; charset="UTF-8" + +you know this is a string. +it's a long string. +it's supposed to be longer than the max message length +which is 512 bytes, +which some people say is too short +but it kinda makes sense when you look at what it looks like one a phone +heck this wasn't even half of it so far. +so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +that should do it +` + conf, backend := newTestBackend(t, func(m *message) error { + expected := `you know this is a string. +it's a long string. +it's supposed to be longer than the max message length +which is 512 bytes, +which some people say is too short +but it kinda makes sense when you look at what it looks like one a phone +heck this wasn't even half of it so far. +so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +and with ` + require.Equal(t, expected, m.Message) + return nil + }) + conf.SMTPServerAddrPrefix = "" + session, _ := backend.AnonymousLogin(nil) + require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) + require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) + require.Nil(t, session.Data(strings.NewReader(email))) +} + +func TestSmtpBackend_Unsupported(t *testing.T) { + email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 +Message-ID: +Subject: and one more +From: Phil +To: mytopic@ntfy.sh +Content-Type: text/SOMETHINGELSE + +what's up +` + conf, backend := newTestBackend(t, func(m *message) error { + return nil + }) + conf.SMTPServerAddrPrefix = "" + session, _ := backend.Login(nil, "user", "pass") + require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) + require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) + require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email))) +} + +func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) { + conf := newTestConfig(t) + conf.SMTPServerListen = ":25" + conf.SMTPServerDomain = "ntfy.sh" + conf.SMTPServerAddrPrefix = "ntfy-" + backend := newMailBackend(conf, sub) + return conf, backend +}