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)]*>[\s\S]*?`) styleRe = regexp.MustCompile(`(?i)]*>[\s\S]*?`) brRe = regexp.MustCompile(`(?i)`) pdivRe = regexp.MustCompile(`(?i)]*>`) 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 from") emails, err := s.parseMaildir() if err != nil { http.Error(w, "Error reading emails", http.StatusInternalServerError) return } sort.Sort(ByDate(emails)) tmplStr := ` Genesungswünsche

Genesungswünsche

    {{range .}}
  1. {{end}}
` tmpl, err := template.New("emails").Parse(tmplStr) if err != nil { http.Error(w, "Template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, emails) } // markReadHandler handles the API request to mark an email as read func (s *Server) markReadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Filename string `json:"filename"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if strings.ContainsAny(req.Filename, "/") { http.Error(w, "Invalid request", http.StatusBadRequest) return } sourcePath := filepath.Join(s.maildirPath, "new", req.Filename) if _, err := os.Stat(sourcePath); os.IsNotExist(err) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "Email not found in new folder", }) return } baseFilename := req.Filename if idx := strings.Index(baseFilename, ":2,"); idx != -1 { baseFilename = baseFilename[:idx] } destFilename := baseFilename + ":2,S" destPath := filepath.Join(s.maildirPath, "cur", destFilename) if err := os.Rename(sourcePath, destPath); err != nil { fmt.Println("mark read error: ", err) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": err.Error(), }) return } fmt.Println(r.Header.Get("X-Forwarded-For"), "mark read success from", sourcePath, "to", destPath) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "newFilename": destFilename, }) } // markUnreadHandler handles the API request to mark an email as unread func (s *Server) markUnreadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Filename string `json:"filename"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if strings.ContainsAny(req.Filename, "/") { http.Error(w, "Invalid request", http.StatusBadRequest) return } sourcePath := filepath.Join(s.maildirPath, "cur", req.Filename) if _, err := os.Stat(sourcePath); os.IsNotExist(err) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "Email not found in cur folder", }) return } // Remove :2,S flags when moving back to new baseFilename := req.Filename if idx := strings.Index(baseFilename, ":2,"); idx != -1 { baseFilename = baseFilename[:idx] } destPath := filepath.Join(s.maildirPath, "new", baseFilename) // Check if destination file already exists (shouldn't happen normally) if _, err := os.Stat(destPath); err == nil { // File exists, add a timestamp to make it unique timestamp := time.Now().UnixNano() baseFilename = fmt.Sprintf("%s_%d", baseFilename, timestamp) destPath = filepath.Join(s.maildirPath, "new", baseFilename) } if err := os.Rename(sourcePath, destPath); err != nil { fmt.Println("unread error: ", err) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": err.Error(), }) return } fmt.Println(r.Header.Get("X-Forwarded-For"), "unread success from", sourcePath, "to", destPath) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "newFilename": baseFilename, }) } func auth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() if !ok || user != "secret" || pass != "lair" { w.Header().Set("WWW-Authenticate", `Basic realm="Genesung"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } handler(w, r) } } func main() { if len(os.Args) < 2 { fmt.Println("Usage: go run maildir-server.go [port]") fmt.Println("Example: go run maildir-server.go ~/Maildir 4242") os.Exit(1) } maildirPath := os.Args[1] port := "4242" if len(os.Args) >= 3 { port = os.Args[2] } server := &Server{ maildirPath: maildirPath, } http.HandleFunc("/", auth(server.indexHandler)) http.HandleFunc("/genesung/api/mark-read", auth(server.markReadHandler)) http.HandleFunc("/genesung/api/mark-unread", auth(server.markUnreadHandler)) fmt.Printf("Starting server on http://localhost:%s\n", port) fmt.Printf("Serving emails from: %s\n", maildirPath) if err := http.ListenAndServe(":"+port, nil); err != nil { log.Fatal("Server failed to start:", err) } }