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.
This commit is contained in:
Guilherme Rugai Freire 2024-03-16 18:12:06 -03:00
parent f4407d71f8
commit b2a85dd3f8
No known key found for this signature in database
GPG Key ID: AC1D9B6E48E16AC1
4 changed files with 296 additions and 10 deletions

View File

@ -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) {
<!DOCTYPE html>
<html lang="en">
<head>
@ -24,7 +26,59 @@ templ inbox_body(rcpt_addr string, ms []mail) {
</html>
}
templ mail_comp(m mail) {
<h3>From: &lt{ m.From_addr }&gt at { time.Unix(m.Arrived_at, 0).Format(time.DateTime) }</h3>
<p>{ string(m.Data) }</p>
templ mail_comp(m mail_obj) {
<h3>From: { m.From } at { m.Date }</h3>
if m.To != "" {
<h3>To: { m.To }</h3>
}
if m.Bcc != "" {
<h3>Bcc: { m.Bcc }</h3>
}
<h3>Subject: { m.Subject }</h3>
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) {
<p>{ s }</p>
}
templ body_html(s string) {
<p>
@templ.Raw(s)
</p>
}
templ body_markdown(s string) {
@body_html(string(blackfriday.Run([]byte(s))))
)
}

View File

@ -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)

7
go.mod
View File

@ -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
)

12
go.sum
View File

@ -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=