package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/http"
"net/mail"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
"golang.org/x/text/encoding/unicode"
)
// Email represents a parsed email message
type Email struct {
From string
Subject string
Date time.Time
Body template.HTML
IsRead bool
ID string
FilePath string
FileName string
Subdir string
}
// ByDate implements sort.Interface for []Email based on Date field
type ByDate []Email
func (e ByDate) Len() int { return len(e) }
func (e ByDate) Less(i, j int) bool { return e[j].Date.Before(e[i].Date) }
func (e ByDate) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
// Server struct holds the maildir path
type Server struct {
maildirPath string
}
var (
scriptRe = regexp.MustCompile(`(?i)`)
styleRe = regexp.MustCompile(`(?i)`)
brRe = regexp.MustCompile(`(?i)
`)
pdivRe = regexp.MustCompile(`(?i)?(p|div)[^>]*>`)
restRe = regexp.MustCompile(`<[^>]+>`)
nlRe = regexp.MustCompile(`\n{3,}`)
)
// getEncoder returns the appropriate encoder for a charset name
func getEncoder(charset string) (encoding.Encoding, error) {
charset = strings.ToLower(strings.TrimSpace(charset))
charset = strings.Trim(charset, `"'`)
switch charset {
// UTF variants
case "utf-8", "utf8":
return encoding.Nop, nil // UTF-8 doesn't need conversion
case "utf-16", "utf16":
return unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), nil
case "utf-16be":
return unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM), nil
case "utf-16le":
return unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), nil
// ISO-8859 variants
case "iso-8859-1", "iso88591", "latin1", "latin-1", "iso_8859-1", "iso_8859_1":
return charmap.ISO8859_1, nil
case "iso-8859-2", "iso88592", "latin2", "latin-2", "iso_8859-2", "iso_8859_2":
return charmap.ISO8859_2, nil
case "iso-8859-3", "iso88593", "latin3", "latin-3", "iso_8859-3", "iso_8859_3":
return charmap.ISO8859_3, nil
case "iso-8859-4", "iso88594", "latin4", "latin-4", "iso_8859-4", "iso_8859_4":
return charmap.ISO8859_4, nil
case "iso-8859-5", "iso88595", "iso_8859-5", "iso_8859_5":
return charmap.ISO8859_5, nil
case "iso-8859-6", "iso88596", "iso_8859-6", "iso_8859_6":
return charmap.ISO8859_6, nil
case "iso-8859-7", "iso88597", "iso_8859-7", "iso_8859_7":
return charmap.ISO8859_7, nil
case "iso-8859-8", "iso88598", "iso_8859-8", "iso_8859_8":
return charmap.ISO8859_8, nil
case "iso-8859-9", "iso88599", "latin5", "latin-5", "iso_8859-9", "iso_8859_9":
return charmap.ISO8859_9, nil
case "iso-8859-10", "iso885910", "latin6", "latin-6", "iso_8859-10", "iso_8859_10":
return charmap.ISO8859_10, nil
case "iso-8859-13", "iso885913", "iso_8859-13", "iso_8859_13":
return charmap.ISO8859_13, nil
case "iso-8859-14", "iso885914", "iso_8859-14", "iso_8859_14":
return charmap.ISO8859_14, nil
case "iso-8859-15", "iso885915", "iso_8859-15", "iso_8859_15":
return charmap.ISO8859_15, nil
case "iso-8859-16", "iso885916", "iso_8859-16", "iso_8859_16":
return charmap.ISO8859_16, nil
// Windows code pages
case "windows-1250", "cp1250":
return charmap.Windows1250, nil
case "windows-1251", "cp1251":
return charmap.Windows1251, nil
case "windows-1252", "cp1252":
return charmap.Windows1252, nil
case "windows-1253", "cp1253":
return charmap.Windows1253, nil
case "windows-1254", "cp1254":
return charmap.Windows1254, nil
case "windows-1255", "cp1255":
return charmap.Windows1255, nil
case "windows-1256", "cp1256":
return charmap.Windows1256, nil
case "windows-1257", "cp1257":
return charmap.Windows1257, nil
case "windows-1258", "cp1258":
return charmap.Windows1258, nil
case "windows-874", "cp874":
return charmap.Windows874, nil
// KOI8 variants
case "koi8-r", "koi8r":
return charmap.KOI8R, nil
case "koi8-u", "koi8u":
return charmap.KOI8U, nil
// Mac encodings
case "macintosh", "mac":
return charmap.Macintosh, nil
case "mac-cyrillic", "maccyrillic":
return charmap.MacintoshCyrillic, nil
// Japanese encodings
case "shift_jis", "shift-jis", "sjis", "shiftjis", "x-sjis":
return japanese.ShiftJIS, nil
case "euc-jp", "eucjp":
return japanese.EUCJP, nil
case "iso-2022-jp", "iso2022jp":
return japanese.ISO2022JP, nil
// Chinese encodings
case "gb18030", "gb-18030":
return simplifiedchinese.GB18030, nil
case "gbk", "cp936", "windows-936":
return simplifiedchinese.GBK, nil
case "gb2312", "gb-2312", "euc-cn", "euccn":
return simplifiedchinese.HZGB2312, nil
case "big5", "big-5", "cp950":
return traditionalchinese.Big5, nil
// Korean encodings
case "euc-kr", "euckr":
return korean.EUCKR, nil
// ASCII and US-ASCII are subsets of UTF-8
case "us-ascii", "ascii":
return encoding.Nop, nil
default:
// For unknown charsets, assume UTF-8
return encoding.Nop, nil
}
}
// convertCharset converts text from the specified charset to UTF-8
func convertCharset(data []byte, charset string) (string, error) {
enc, err := getEncoder(charset)
if err != nil {
return string(data), err
}
if enc == encoding.Nop {
// Already UTF-8 or ASCII
return string(data), nil
}
decoder := enc.NewDecoder()
result, err := decoder.Bytes(data)
if err != nil {
// If conversion fails, try to return the original
return string(data), err
}
return string(result), nil
}
// decodeMIMEHeader decodes MIME encoded-word headers (RFC 2047)
func decodeMIMEHeader(header string) string {
dec := new(mime.WordDecoder)
dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
enc, err := getEncoder(charset)
if err != nil {
return input, err
}
if enc == encoding.Nop {
return input, nil
}
return enc.NewDecoder().Reader(input), nil
}
decoded, err := dec.DecodeHeader(header)
if err != nil {
return header
}
return decoded
}
// stripHTML removes HTML tags and returns plain text
func stripHTML(html string) string {
html = scriptRe.ReplaceAllString(html, "")
html = styleRe.ReplaceAllString(html, "")
html = brRe.ReplaceAllString(html, "\n")
html = pdivRe.ReplaceAllString(html, "\n")
text := restRe.ReplaceAllString(html, "")
text = strings.ReplaceAll(text, " ", " ")
text = strings.ReplaceAll(text, "<", "<")
text = strings.ReplaceAll(text, ">", ">")
text = strings.ReplaceAll(text, "&", "&")
text = strings.ReplaceAll(text, """, "\"")
text = strings.ReplaceAll(text, "'", "'")
text = strings.ReplaceAll(text, "'", "'")
text = strings.ReplaceAll(text, "/", "/")
text = nlRe.ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// extractCharset extracts the charset from a Content-Type header
func extractCharset(contentType string) string {
if contentType == "" {
return "utf-8"
}
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return "utf-8"
}
if charset, ok := params["charset"]; ok {
return charset
}
return "utf-8"
}
// parseMaildir reads and parses all emails from a Maildir directory
func (s *Server) parseMaildir() ([]Email, error) {
var emails []Email
for _, subdir := range []string{"new", "cur"} {
dirPath := filepath.Join(s.maildirPath, subdir)
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
continue
}
files, err := os.ReadDir(dirPath)
if err != nil {
log.Printf("Error reading %s: %v", dirPath, err)
continue
}
for _, file := range files {
if file.IsDir() {
continue
}
filePath := filepath.Join(dirPath, file.Name())
isRead := false
if subdir == "cur" {
if idx := strings.LastIndex(file.Name(), ":2,"); idx != -1 {
flags := file.Name()[idx+3:]
isRead = strings.Contains(flags, "S")
}
}
email, err := s.parseEmailFile(filePath, file.Name(), isRead, subdir)
if err != nil {
log.Printf("Error parsing %s: %v", filePath, err)
continue
}
emails = append(emails, email)
}
}
return emails, nil
}
// parseEmailFile reads and parses a single email file
func (s *Server) parseEmailFile(filePath, fileName string, isRead bool, subdir string) (Email, error) {
file, err := os.Open(filePath)
if err != nil {
return Email{}, err
}
defer file.Close()
msg, err := mail.ReadMessage(file)
if err != nil {
return Email{}, err
}
from := decodeMIMEHeader(msg.Header.Get("From"))
subject := decodeMIMEHeader(msg.Header.Get("Subject"))
dateStr := msg.Header.Get("Date")
var date time.Time
if dateStr != "" {
date, _ = mail.ParseDate(dateStr)
}
body, err := extractPlainTextBody(msg)
if err != nil {
body = "Error reading email body"
}
id := strings.ReplaceAll(fileName, ":", "_")
id = strings.ReplaceAll(id, ".", "_")
id = strings.ReplaceAll(id, ",", "_")
return Email{
From: from,
Subject: subject,
Date: date,
Body: template.HTML(body),
IsRead: isRead,
ID: id,
FilePath: filePath,
FileName: fileName,
Subdir: subdir,
}, nil
}
// decodeContent decodes content based on transfer encoding and charset
func decodeContent(content string, encoding string, charset string) string {
encoding = strings.ToLower(encoding)
// First, decode transfer encoding
var decoded []byte
switch encoding {
case "base64":
d, err := base64.StdEncoding.DecodeString(strings.TrimSpace(content))
if err == nil {
decoded = d
} else {
decoded = []byte(content)
}
case "quoted-printable":
reader := quotedprintable.NewReader(strings.NewReader(content))
d, err := io.ReadAll(reader)
if err == nil {
decoded = d
} else {
decoded = []byte(content)
}
default:
decoded = []byte(content)
}
// Then, convert charset to UTF-8
result, err := convertCharset(decoded, charset)
if err != nil {
log.Printf("Charset conversion error for %s: %v", charset, err)
// Return best effort
return string(decoded)
}
return result
}
// findTextPlainPart recursively searches for text/plain parts
func findTextPlainPart(mr *multipart.Reader) (string, bool) {
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
continue
}
partContentType := part.Header.Get("Content-Type")
partMediaType, params, _ := mime.ParseMediaType(partContentType)
partEncoding := part.Header.Get("Content-Transfer-Encoding")
charset := params["charset"]
if charset == "" {
charset = "utf-8"
}
if strings.HasPrefix(partMediaType, "multipart/") {
if boundary, ok := params["boundary"]; ok {
bodyBytes, err := io.ReadAll(part)
if err != nil {
continue
}
subReader := multipart.NewReader(bytes.NewReader(bodyBytes), boundary)
if text, found := findTextPlainPart(subReader); found {
return text, true
}
}
} else if partMediaType == "text/plain" {
bodyBytes, err := io.ReadAll(part)
if err != nil {
continue
}
decoded := decodeContent(string(bodyBytes), partEncoding, charset)
return decoded, true
}
}
return "", false
}
// extractPlainTextBody extracts plaintext body content
func extractPlainTextBody(msg *mail.Message) (string, error) {
contentType := msg.Header.Get("Content-Type")
transferEncoding := msg.Header.Get("Content-Transfer-Encoding")
charset := extractCharset(contentType)
if contentType == "" {
body, err := io.ReadAll(msg.Body)
if err != nil {
return "", err
}
decoded := decodeContent(string(body), transferEncoding, charset)
return escapeHTML(decoded), nil
}
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
body, _ := io.ReadAll(msg.Body)
decoded := decodeContent(string(body), transferEncoding, charset)
return escapeHTML(decoded), nil
}
// Update charset if specified in params
if c, ok := params["charset"]; ok {
charset = c
}
if strings.HasPrefix(mediaType, "multipart/") {
mr := multipart.NewReader(msg.Body, params["boundary"])
msgBodyBytes, err := io.ReadAll(msg.Body)
if err == nil {
searchReader := multipart.NewReader(bytes.NewReader(msgBodyBytes), params["boundary"])
if text, found := findTextPlainPart(searchReader); found {
return escapeHTML(text), nil
}
}
mr = multipart.NewReader(bytes.NewReader(msgBodyBytes), params["boundary"])
var htmlBody string
var htmlCharset string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
partContentType := part.Header.Get("Content-Type")
partMediaType, partParams, _ := mime.ParseMediaType(partContentType)
partEncoding := part.Header.Get("Content-Transfer-Encoding")
if partMediaType == "text/html" {
bodyBytes, err := io.ReadAll(part)
if err != nil {
continue
}
htmlCharset = partParams["charset"]
if htmlCharset == "" {
htmlCharset = "utf-8"
}
htmlBody = decodeContent(string(bodyBytes), partEncoding, htmlCharset)
}
}
if htmlBody != "" {
plainText := stripHTML(htmlBody)
return escapeHTML(plainText), nil
}
return "No text content found", nil
}
body, err := io.ReadAll(msg.Body)
if err != nil {
return "", err
}
decoded := decodeContent(string(body), transferEncoding, charset)
if mediaType == "text/html" {
plainText := stripHTML(decoded)
return escapeHTML(plainText), nil
}
return escapeHTML(decoded), nil
}
// escapeHTML performs HTML escaping for plain text
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "<")
s = strings.ReplaceAll(s, ">", ">")
s = strings.ReplaceAll(s, "\"", """)
s = strings.ReplaceAll(s, "'", "'")
s = strings.ReplaceAll(s, "\n", "
")
s = strings.ReplaceAll(s, "\r", "")
return s
}
// indexHandler serves the main HTML page
func (s *Server) indexHandler(w http.ResponseWriter, r *http.Request) {
fmt.Println(r.Header.Get("X-Forwarded-For"), "authorized access")
emails, err := s.parseMaildir()
if err != nil {
http.Error(w, "Error reading emails", http.StatusInternalServerError)
return
}
sort.Sort(ByDate(emails))
tmplStr := `