From b2a85dd3f89c4df006ffec8b3dd8590893bf5d5a Mon Sep 17 00:00:00 2001 From: Guilherme Rugai Freire Date: Sat, 16 Mar 2024 18:12:06 -0300 Subject: [PATCH] add MIME email rendering This commits adds support for rendering email in text/html, text/markdown and text/plain inside a MIME/multipart mail. Bluemonday was added as a dependency and initialized but it is still not used because the styling of the email is "discarted" too much. But this needs to be fixed before going to production. --- cmd/web_server/inbox.templ | 64 ++++++++++- cmd/web_server/main.go | 223 ++++++++++++++++++++++++++++++++++++- go.mod | 7 +- go.sum | 12 ++ 4 files changed, 296 insertions(+), 10 deletions(-) diff --git a/cmd/web_server/inbox.templ b/cmd/web_server/inbox.templ index 9f0b7e2..57576b6 100644 --- a/cmd/web_server/inbox.templ +++ b/cmd/web_server/inbox.templ @@ -1,8 +1,10 @@ package main -import "time" +import ( + "github.com/russross/blackfriday/v2" +) -templ inbox_body(rcpt_addr string, ms []mail) { +templ inbox_body(rcpt_addr string, ms []mail_obj) { @@ -24,7 +26,59 @@ templ inbox_body(rcpt_addr string, ms []mail) { } -templ mail_comp(m mail) { -

From: <{ m.From_addr }> at { time.Unix(m.Arrived_at, 0).Format(time.DateTime) }

-

{ string(m.Data) }

+templ mail_comp(m mail_obj) { +

From: { m.From } at { m.Date }

+ if m.To != "" { +

To: { m.To }

+ } + if m.Bcc != "" { +

Bcc: { m.Bcc }

+ } +

Subject: { m.Subject }

+ if m.MediaType == NotMultipart { + for _, b := range m.Body { + @body_plain(b.Data) + } + } else { + if m.MediaType == Mixed { + for _, b := range m.Body { + switch b.MimeType { + case PlainText: + @body_plain(b.Data) + case Html: + @body_html(b.Data) + case Markdown: + @body_plain(b.Data) + default: + @body_plain(b.Data) + } + } + } else { + switch m.Body[m.PreferedBodyIndex].MimeType { + case PlainText: + @body_plain(m.Body[m.PreferedBodyIndex].Data) + case Html: + @body_html(m.Body[m.PreferedBodyIndex].Data) + case Markdown: + @body_markdown(m.Body[m.PreferedBodyIndex].Data) + default: + @body_plain(m.Body[m.PreferedBodyIndex].Data) + } + } + } +} + +templ body_plain(s string) { +

{ s }

+} + +templ body_html(s string) { +

+ @templ.Raw(s) +

+} + +templ body_markdown(s string) { + @body_html(string(blackfriday.Run([]byte(s)))) + ) } diff --git a/cmd/web_server/main.go b/cmd/web_server/main.go index 81aebe1..db2982e 100644 --- a/cmd/web_server/main.go +++ b/cmd/web_server/main.go @@ -1,25 +1,225 @@ package main import ( + "bytes" "database/sql" + "encoding/base64" + "errors" "fmt" + "io" "log" + "mime" + "mime/multipart" + "mime/quotedprintable" "net/http" + "net/mail" "os" "strconv" + "strings" "github.com/GRFreire/nthmail/pkg/rig" "github.com/go-chi/chi" _ "github.com/mattn/go-sqlite3" + "github.com/microcosm-cc/bluemonday" ) -type mail struct { +type db_mail struct { Id int Arrived_at int64 Rcpt_addr, From_addr string Data []byte } +type MIMEType uint8 +type MediaType uint8 + +const ( + PlainText MIMEType = iota + Html + Markdown +) + +const ( + NotMultipart MediaType = iota + Alternative + Mixed +) + +type mail_body struct { + MimeType MIMEType + Data string +} + +type mail_obj struct { + From string + Date string + To string + Bcc string + Subject string + + Body []mail_body + MediaType + PreferedBodyIndex int +} + +func parse_mail(dbm db_mail, policy *bluemonday.Policy) (mail_obj, error) { + var m mail_obj + + mail_msg, err := mail.ReadMessage(bytes.NewReader(dbm.Data)) + if err != nil { + return m, errors.New("Could not read message") + } + + // HEADERS + dec := new(mime.WordDecoder) + m.From, _ = dec.DecodeHeader(mail_msg.Header.Get("From")) + m.Date, _ = dec.DecodeHeader(mail_msg.Header.Get("Date")) + m.To, _ = dec.DecodeHeader(mail_msg.Header.Get("To")) + m.Bcc, _ = dec.DecodeHeader(mail_msg.Header.Get("Bcc")) + m.Subject, _ = dec.DecodeHeader(mail_msg.Header.Get("Subject")) + + content_type := mail_msg.Header.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(content_type) + if err != nil { + return m, err + } + + if content_type == "" || !strings.HasPrefix(mediaType, "multipart/") { + var txt []byte + _, err := mail_msg.Body.Read(txt) + if err != nil { + return m, err + } + + var body mail_body + body.MimeType = PlainText + body.Data = string(txt) + + m.MediaType = NotMultipart + m.Body = append(m.Body, body) + + return m, nil + } + + if mediaType == "multipart/mixed" { + m.MediaType = Mixed + } else if mediaType == "multipart/alternative" { + m.MediaType = Alternative + } else { + return m, errors.New("Not supported multipart type") + } + + body, err := parse_mail_part(mail_msg.Body, params["boundary"]) + if err != nil { + return m, err + } + + m.Body = body + m.PreferedBodyIndex = -1 + for i, b := range m.Body { + if m.PreferedBodyIndex == -1 { + m.PreferedBodyIndex = i + continue + } + + if b.MimeType == Html { + m.PreferedBodyIndex = i + continue + } + + if b.MimeType == Markdown { + if m.Body[i].MimeType != Html { + m.PreferedBodyIndex = i + } + } + } + + return m, nil +} + +func parse_mail_part(mime_data io.Reader, boundary string) ([]mail_body, error) { + var body []mail_body + + reader := multipart.NewReader(mime_data, boundary) + if reader == nil { + return body, nil + } + + for { + new_part, err := reader.NextPart() + if err == io.EOF { + break + } + + if err != nil { + return body, err + } + + mediaType, params, err := mime.ParseMediaType(new_part.Header.Get("Content-Type")) + + if err != nil { + return body, err + } + + if strings.HasPrefix(mediaType, "multipart/") { + body_part, err := parse_mail_part(new_part, params["boundary"]) + if err != nil { + return body, err + } + + body = append(body, body_part...) + + } else { + + part_data, err := io.ReadAll(new_part) + if err != nil { + return body, err + } + content_transfer_encoding := new_part.Header.Get("Content-Transfer-Encoding") + content_type := new_part.Header.Get("Content-Type") + + var part_body mail_body + + switch { + case strings.HasPrefix(content_type, "text/plain"): + part_body.MimeType = PlainText + case strings.HasPrefix(content_type, "text/markdown"): + part_body.MimeType = Markdown + case strings.HasPrefix(content_type, "text/html"): + part_body.MimeType = Html + default: + return body, errors.New("Content type not supported: " + content_type) + } + + switch { + case strings.Compare(content_transfer_encoding, "BASE64") == 0: + decoded_content, err := base64.StdEncoding.DecodeString(string(part_data)) + if err != nil { + return body, err + } + + part_body.Data = string(decoded_content) + + case strings.Compare(content_transfer_encoding, "QUOTED-PRINTABLE") == 0: + decoded_content, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(part_data))) + if err != nil { + return body, err + } + + part_body.Data = string(decoded_content) + + default: + part_body.Data = string(part_data) + } + + body = append(body, part_body) + + } + } + + return body, nil +} + func main() { db, err := sql.Open("sqlite3", "./db.db") if err != nil { @@ -46,6 +246,11 @@ func main() { http.Redirect(res, req, inbox_addr, 307) }) + p := bluemonday.UGCPolicy() + p.AllowStyles() + p.AllowStyling() + p.AllowElements("style") + p.AllowUnsafe(true) router.Get("/{rcpt-addr}", func(res http.ResponseWriter, req *http.Request) { rcpt_addr := chi.URLParam(req, "rcpt-addr") if len(rcpt_addr) == 0 { @@ -83,9 +288,9 @@ func main() { } defer rows.Close() - var mails []mail + var mails []mail_obj for rows.Next() { - var m mail + var m db_mail err = rows.Scan(&m.Id, &m.Arrived_at, &m.Rcpt_addr, &m.From_addr, &m.Data) if err != nil { res.WriteHeader(500) @@ -95,7 +300,17 @@ func main() { return } - mails = append(mails, m) + mail_obj, err := parse_mail(m, p) + if err != nil { + res.WriteHeader(500) + res.Write([]byte("internal server error")) + + log.Println("could not parse mail") + log.Println(err) + return + } + + mails = append(mails, mail_obj) } body := inbox_body(rcpt_addr, mails) diff --git a/go.mod b/go.mod index 653022b..e5ebe38 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,14 @@ module github.com/GRFreire/nthmail go 1.21.6 require ( - github.com/a-h/templ v0.2.543 // indirect + github.com/a-h/templ v0.2.598 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/emersion/go-smtp v0.20.2 // indirect github.com/go-chi/chi v1.5.5 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/microcosm-cc/bluemonday v1.0.26 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + golang.org/x/net v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index f8a1cec..e801284 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,22 @@ github.com/a-h/templ v0.2.543 h1:8YyLvyUtf0/IE2nIwZ62Z/m2o2NqwhnMynzOL78Lzbk= github.com/a-h/templ v0.2.543/go.mod h1:jP908DQCwI08IrnTalhzSEH9WJqG/Q94+EODQcJGFUA= +github.com/a-h/templ v0.2.598 h1:6jMIHv6wQZvdPxTuv87erW4RqN/FPU0wk7ZHN5wVuuo= +github.com/a-h/templ v0.2.598/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8= +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/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.20.2 h1:peX42Qnh5Q0q3vrAnRy43R/JwTnnv75AebxbkTL7Ia4= github.com/emersion/go-smtp v0.20.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= +github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=