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 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1030 insertions(+) create mode 100644 genesungswuensche.go (limited to 'genesungswuensche.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) + } +} -- cgit v1.2.3