From d88dbbc90f28af884b7d0d989bb0fe34118c9a4e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 2 Apr 2023 13:59:26 -0400 Subject: [PATCH] WIP --- go.mod | 3 + go.sum | 6 ++ server/smtp_server.go | 77 +++++++++++++++++++---- server/smtp_server_test.go | 125 +++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index d5a9025e..10d32350 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.10.0 + github.com/microcosm-cc/bluemonday v1.0.23 github.com/prometheus/client_golang v1.14.0 github.com/stripe/stripe-go/v74 v74.14.0 ) @@ -39,6 +40,7 @@ require ( cloud.google.com/go/longrunning v0.4.1 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -50,6 +52,7 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/googleapis/gax-go/v2 v2.8.0 // indirect + github.com/gorilla/css v1.0.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8c166afd..27679caa 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -85,6 +87,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9 github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc= github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -95,6 +99,8 @@ github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwp github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= +github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI= github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= diff --git a/server/smtp_server.go b/server/smtp_server.go index 16d97328..fe7e3298 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "github.com/emersion/go-smtp" + "github.com/microcosm-cc/bluemonday" "io" "mime" "mime/multipart" @@ -13,6 +14,7 @@ import ( "net/http" "net/http/httptest" "net/mail" + "regexp" "strings" "sync" ) @@ -231,37 +233,66 @@ func readMailBody(body io.Reader, header mail.Header) (string, error) { if err != nil { return "", err } - if strings.ToLower(contentType) == "text/plain" { - return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) - } else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") { - return readMultipartMailBody(body, params, 0) + canonicalContentType := strings.ToLower(contentType) + if canonicalContentType == "text/plain" || canonicalContentType == "text/html" { + return readTextMailBody(body, canonicalContentType, header.Get("Content-Transfer-Encoding")) + } else if strings.HasPrefix(canonicalContentType, "multipart/") { + return readMultipartMailBody(body, params) } return "", errUnsupportedContentType } -func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) { +func readMultipartMailBody(body io.Reader, params map[string]string) (string, error) { + parts := make(map[string]string) + if err := readMultipartMailBodyParts(body, params, 0, parts); err != nil && err != io.EOF { + return "", err + } else if s, ok := parts["text/plain"]; ok { + return s, nil + } else if s, ok := parts["text/html"]; ok { + return s, nil + } + return "", io.EOF +} + +func readMultipartMailBodyParts(body io.Reader, params map[string]string, depth int, parts map[string]string) error { if depth >= maxMultipartDepth { - return "", errMultipartNestedTooDeep + return errMultipartNestedTooDeep } mr := multipart.NewReader(body, params["boundary"]) for { part, err := mr.NextPart() if err != nil { // may be io.EOF - return "", err + return err } partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type")) if err != nil { - return "", err + return err } - if strings.ToLower(partContentType) == "text/plain" { - return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding")) + canonicalPartContentType := strings.ToLower(partContentType) + if canonicalPartContentType == "text/plain" || canonicalPartContentType == "text/html" { + s, err := readTextMailBody(part, canonicalPartContentType, part.Header.Get("Content-Transfer-Encoding")) + if err != nil { + return err + } + parts[canonicalPartContentType] = s } else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") { - return readMultipartMailBody(part, partParams, depth+1) + if err := readMultipartMailBodyParts(part, partParams, depth+1, parts); err != nil { + return err + } } // Continue with next part } } +func readTextMailBody(reader io.Reader, contentType, transferEncoding string) (string, error) { + if contentType == "text/plain" { + return readPlainTextMailBody(reader, transferEncoding) + } else if contentType == "text/html" { + return readHTMLMailBody(reader, transferEncoding) + } + return "", fmt.Errorf("unsupported content type: %s", contentType) +} + func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) { if strings.ToLower(transferEncoding) == "base64" { reader = base64.NewDecoder(base64.StdEncoding, reader) @@ -272,3 +303,27 @@ func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, e } return string(body), nil } + +func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error) { + body, err := readPlainTextMailBody(reader, transferEncoding) + if err != nil { + return "", err + } + stripped := bluemonday. + StrictPolicy(). + AddSpaceWhenStrippingTag(true). + Sanitize(body) + return removeExtraEmptyLines(stripped), nil +} + +func removeExtraEmptyLines(str string) string { + // Replace lines that contain only spaces with empty lines + re := regexp.MustCompile(`(?m)^\s+$`) + str = re.ReplaceAllString(str, "") + + // Remove more than 2 consecutive empty lines + re = regexp.MustCompile(`\n{3,}`) + str = re.ReplaceAllString(str, "\n\n") + + return str +} diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go index 49085d79..1e504521 100644 --- a/server/smtp_server_test.go +++ b/server/smtp_server_test.go @@ -492,6 +492,131 @@ L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep") } +func TestSmtpBackend_HTMLEmail(t *testing.T) { + email := `EHLO example.com +MAIL FROM: test@mydomain.me +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Message-Id: <51610934ss4.mmailer@fritz.box> +From: +To: , + +Date: Thu, 30 Mar 2023 02:56:53 +0000 +Subject: A HTML email +Mime-Version: 1.0 +Content-Type: text/html; + charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +<=21DOCTYPE html> + + +Alerttitle + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +headertext of table + +
+
+ + + + +
+" Very important information about a change in your +home automation setup + +Now the light is on +
+
+ + + + +
+
+If you don't want to recieve this message anymore, stop the push + services in your FRITZ=21Box=2E
+Here you can see the active push services: "System > Push Service"=2E +
+
+ + + + +
+This mail has ben sent by your FRITZ=21Box automatically=2E +
+
+
+ + +. +` + + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "A HTML email", r.Header.Get("Title")) + require.Equal(t, "what's up", readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + func TestSmtpBackend_PlaintextWithToken(t *testing.T) { email := `EHLO example.com MAIL FROM: phil@example.com