From 167a740e9f8fb9da33944ab1ba88df1197a15f7c Mon Sep 17 00:00:00 2001 From: Özgür Kesim Date: Tue, 19 Aug 2025 15:38:54 +0200 Subject: added auth, numbers --- genesungswuensche.go | 1030 ++++++++++++++++++++++++++++++++++++++++++ "genesungsw\303\274nsche.go" | 1008 ----------------------------------------- 2 files changed, 1030 insertions(+), 1008 deletions(-) create mode 100644 genesungswuensche.go delete mode 100644 "genesungsw\303\274nsche.go" diff --git a/genesungswuensche.go b/genesungswuensche.go new file mode 100644 index 0000000..8347d43 --- /dev/null +++ b/genesungswuensche.go @@ -0,0 +1,1030 @@ +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) { + 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. + +
  2. + {{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 + } + + 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 { + log.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 + } + + log.Println("mark read success: ", 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 + } + + 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 { + log.Println("unread error: ", err) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + log.Println("unread success: ", 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 + } + + log.Println("authorized access from", r.Header.Get("X-Forwarded-For")) + 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) + } +} diff --git "a/genesungsw\303\274nsche.go" "b/genesungsw\303\274nsche.go" deleted file mode 100644 index e9ed63a..0000000 --- "a/genesungsw\303\274nsche.go" +++ /dev/null @@ -1,1008 +0,0 @@ -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) { - log.Println("access from", r.RemoteAddr) - 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 .}} - - {{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 - } - - 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 { - log.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 - } - - log.Println("mark read success: ", 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 - } - - 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 { - log.Println("unread error: ", err) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - return - } - log.Println("unread success: ", sourcePath, "to", destPath) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "newFilename": baseFilename, - }) -} - -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 8080") - os.Exit(1) - } - - maildirPath := os.Args[1] - port := "8080" - if len(os.Args) >= 3 { - port = os.Args[2] - } - - server := &Server{ - maildirPath: maildirPath, - } - - http.HandleFunc("/", server.indexHandler) - http.HandleFunc("/api/mark-read", server.markReadHandler) - http.HandleFunc("/api/mark-unread", 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) - } -} -- cgit v1.2.3