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 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 { log.Fatal(err) } defer db.Close() domain, exists := os.LookupEnv("MAIL_SERVER_DOMAIN") if !exists { domain = "localhost" } router := chi.NewRouter() router.Get("/", func(res http.ResponseWriter, req *http.Request) { page := index_page() page.Render(req.Context(), res) }) router.Get("/random", func(res http.ResponseWriter, req *http.Request) { inbox_name := rig.GenerateRandomInboxName() inbox_addr := fmt.Sprintf("/%s@%s", inbox_name, domain) 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 { res.WriteHeader(404) res.Write([]byte("inbox not found")) return } tx, err := db.Begin() if err != nil { res.WriteHeader(500) res.Write([]byte("internal server error")) log.Println("could not begin db transaction") return } stmt, err := tx.Prepare("SELECT mails.id, mails.arrived_at, mails.rcpt_addr, mails.from_addr, mails.data FROM mails WHERE mails.rcpt_addr = ?") if err != nil { res.WriteHeader(500) res.Write([]byte("internal server error")) log.Println("could not prepare db stmt") return } defer stmt.Close() rows, err := stmt.Query(rcpt_addr) if err != nil { res.WriteHeader(500) res.Write([]byte("internal server error")) log.Println("could not query db stmt") return } defer rows.Close() var mails []mail_obj for rows.Next() { 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) res.Write([]byte("internal server error")) log.Println("could not scan db row") return } 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) body.Render(req.Context(), res) }) var port int port_str, exists := os.LookupEnv("WEB_SERVER_PORT") if exists { port, err = strconv.Atoi(port_str) if err != nil { log.Fatal("env:MAIL_SERVER_PORT is not a number") } } else { port = 3000 } log.Println("Listening on port", port) err = http.ListenAndServe(fmt.Sprintf(":%d", port), router) if err != nil { log.Fatal(err) } }