From d88dbbc90f28af884b7d0d989bb0fe34118c9a4e Mon Sep 17 00:00:00 2001
From: binwiederhier <pheckel@datto.com>
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: <email@email.com>
+To: <email@email.com>,
+	<ntfy-subjectatntfy@ntfy.sh>
+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>
+<html>
+<head>
+<title>Alerttitle</title>
+<meta http-equiv=3D"content-type" content=3D"text/html;charset=3Dutf-8"/>
+</head>
+<body style=3D"color: =23000000; background-color: =23f0eee6;">
+<table width=3D"100%" align=3D"center" style=3D"border:solid 2px =23eeeeee=
+; border-collapse: collapse;">
+<tr>
+<td>
+<table style=3D"border-collapse: collapse;">
+
+
+
+
+
+
+
+<tr>
+<td style=3D"background: =23FFFFFF;">
+<table style=3D"color: =23FFFFFF; background-color: =23006EC0; border-coll=
+apse: collapse;">
+<tr>
+<td style=3D"width: 1000px; text-align: center; font-size: 18pt; font-fami=
+ly: Arial, Helvetica, sans-serif; padding: 10px;">
+
+
+headertext of table
+
+</td>
+</tr>
+</table>
+</td>
+</tr>
+
+
+
+
+
+
+
+<tr>
+<td style=3D"padding: 10px 20px; background: =23FFFFFF;">
+<table style=3D"border-collapse: collapse;">
+<tr>
+<td style=3D"width: 940px; font-size: 13pt; font-family: Arial, Helvetica,=
+ sans-serif; text-align: left;">
+" Very important information about a change in your
+home automation setup 
+
+Now the light is on
+</td>
+</tr>
+</table>
+</td>
+</tr>
+
+
+
+<tr>
+<td style=3D"padding: 10px 20px; background: =23FFFFFF;">
+<table>
+<tr>
+<td style=3D"width: 960px; font-size: 10pt; font-family: Arial, Helvetica,=
+ sans-serif; text-align: left;">
+<hr />
+If you don't want to recieve this message anymore, stop the push
+ services in your <a href=3D"https:fritzbox" target=3D"_=
+blank">FRITZ=21Box</a>=2E<br />
+Here you can see the active push services: "System > Push Service"=2E
+</td>
+</tr>
+</table>
+</td>
+</tr>
+<tr>
+<td>
+<table style=3D"color: =23FFFFFF; background-color: =23006EC0;">
+<tr>
+<td style=3D"width: 1000px; font-size: 10pt; font-family: Arial, Helvetica=
+, sans-serif; text-align: center; padding: 10px;">
+This mail has ben sent by your <a style=3D"color: =23FFFFFF;" href=3D"https:=
+//fritzbox" target=3D"_blank">FRITZ=21Box</a=
+> automatically=2E
+</td>
+</tr>
+</table>
+</td>
+</tr>
+</table>
+</td>
+</tr>
+</table>
+</body>
+</html>
+.
+`
+
+	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