diff options
author | Özgür Kesim <oec@codeblau.de> | 2025-08-19 14:54:43 +0200 |
---|---|---|
committer | Özgür Kesim <oec@codeblau.de> | 2025-08-19 14:54:43 +0200 |
commit | b8b3496807f7fa187f15f291daab50a00ebe9f37 (patch) | |
tree | 5cf000d51756402e3ea5f2457f12f9e342964ad2 /genesungswünsche.go |
init
Diffstat (limited to 'genesungswünsche.go')
-rw-r--r-- | genesungswünsche.go | 1008 |
1 files changed, 1008 insertions, 0 deletions
diff --git a/genesungswünsche.go b/genesungswünsche.go new file mode 100644 index 0000000..e9ed63a --- /dev/null +++ b/genesungswünsche.go @@ -0,0 +1,1008 @@ +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)<script[^>]*>[\s\S]*?</script>`) + styleRe = regexp.MustCompile(`(?i)<style[^>]*>[\s\S]*?</style>`) + brRe = regexp.MustCompile(`(?i)<br\s*/?>`) + 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", "<br>") + 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 := `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Genesungswünsche</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + background: #f5f5f5; + zoom: 1.5; + } + + h1 { + color: #333; + border-bottom: 2px solid #ddd; + padding-bottom: 10px; + } + + .email-item { + background: white; + border: 1px solid #ddd; + border-radius: 5px; + margin-bottom: 10px; + overflow: hidden; + } + + .email-toggle { + display: none; + } + + .email-header { + padding: 15px; + cursor: pointer; + user-select: none; + transition: background 0.2s; + display: block; + } + + .email-header:hover { + background: #f9f9f9; + } + + .email-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + + .from { + color: #666; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 15px; + } + + .date { + color: #999; + font-size: 14px; + flex-shrink: 0; + } + + .subject-line { + display: flex; + align-items: center; + justify-content: space-between; + } + + .subject-wrapper { + display: flex; + align-items: center; + flex: 1; + overflow: hidden; + } + + .arrow { + flex-shrink: 0; + transition: transform 0.3s; + font-size: 12px; + color: #666; + margin-right: 8px; + } + + .subject { + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .email-item:not(.read) .subject { + font-weight: bold; + color: #333; + } + + .email-item.read .subject { + font-weight: normal; + color: #666; + } + + .read-toggle { + flex-shrink: 0; + margin-left: 0; + margin-top: 1em; + padding: 4px 8px; + font-size: 12px; + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + transition: all 0.1s; + color: #666; + } + + .read-toggle:hover { + background: #e0e0e0; + border-color: #bbb; + } + + .email-item:not(.read) .read-toggle { + background: #4CAF50; + color: white; + border-color: #45a049; + } + + .email-item:not(.read) .read-toggle:hover { + background: #45a049; + } + + .email-body { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out, padding 0.3s ease-out; + padding: 0 15px; + border-top: 1px solid #eee; + } + + .email-toggle:checked ~ .email-body { + max-height: 2000px; + padding: 15px; + } + + .email-toggle:checked ~ .email-header .arrow { + transform: rotate(90deg); + } + + .email-content { + line-height: 1.6; + color: #444; + overflow-wrap: break-word; + white-space: pre-wrap; + } + + @media (max-width: 768px) { + .from { + font-size: 13px; + } + + .subject { + font-size: 14px; + } + + .date { + font-size: 12px; + } + + .read-toggle { + font-size: 11px; + padding: 3px 6px; + } + } + </style> +</head> +<body> + <h1>Genesungswünsche</h1> + <div id="email-list"> + {{range .}} + <div class="email-item {{if .IsRead}}read{{end}}" data-id="{{.ID}}" data-filename="{{.FileName}}" data-subdir="{{.Subdir}}"> + <input type="checkbox" class="email-toggle" id="toggle-{{.ID}}"> + <label for="toggle-{{.ID}}" class="email-header"> + <div class="email-meta"> + <span class="from">{{.From}}</span> + <span class="date">{{.Date.Format "2006-01-02 15:04"}}</span> + </div> + <div class="subject-line"> + <div class="subject-wrapper"> + <span class="arrow">▶</span> + <span class="subject">{{if .Subject}}{{.Subject}}{{else}}(No Subject){{end}}</span> + </div> + </div> + </label> + <div class="email-body"> + <div class="email-content">{{.Body}}</div> + <button class="read-toggle" onclick="toggleReadStatus(event, '{{.ID}}', '{{.FileName}}', '{{.Subdir}}', {{.IsRead}})"> + {{if .IsRead}}Mark Unread{{else}}Mark Read{{end}} + </button> + </div> + </div> + {{end}} + </div> + + <script> + document.addEventListener('DOMContentLoaded', function() { + const checkboxes = document.querySelectorAll('.email-toggle'); + + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', function() { + if (this.checked) { + const emailItem = this.closest('.email-item'); + const filename = emailItem.dataset.filename; + const subdir = emailItem.dataset.subdir; + + // Auto-mark as read when expanding unread emails + if (subdir === 'new' && !emailItem.classList.contains('read')) { + markAsRead(filename, emailItem, true); + } + } + }); + }); + }); + + function toggleReadStatus(event, id, filename, subdir, isRead) { + event.stopPropagation(); + event.preventDefault(); + + const emailItem = document.querySelector(` + "`[data-id=\"${id}\"]`);" + ` + const button = event.target; + + if (isRead) { + // Mark as unread + markAsUnread(filename, emailItem, button); + } else { + // Mark as read + markAsRead(filename, emailItem, false, button); + } + } + + function markAsRead(filename, emailItem, autoExpand = false, button = null) { + fetch('/api/mark-read', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filename: filename + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + emailItem.classList.add('read'); + emailItem.dataset.subdir = 'cur'; + emailItem.dataset.filename = data.newFilename; + + // Update button if it exists + if (button) { + button.textContent = 'Mark Unread'; + button.setAttribute('onclick', + ` + "`toggleReadStatus(event, '${emailItem.dataset.id}', '${data.newFilename}', 'cur', true)`);" + ` + } else { + // Find and update the button + const btn = emailItem.querySelector('.read-toggle'); + if (btn) { + btn.textContent = 'Mark Unread'; + btn.setAttribute('onclick', + ` + "`toggleReadStatus(event, '${emailItem.dataset.id}', '${data.newFilename}', 'cur', true)`);" + ` + } + } + } else { + console.error('Failed to mark email as read:', data.error); + } + }) + .catch(error => { + console.error('Error marking email as read:', error); + }); + } + + function markAsUnread(filename, emailItem, button) { + fetch('/api/mark-unread', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filename: filename + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + emailItem.classList.remove('read'); + emailItem.dataset.subdir = 'new'; + emailItem.dataset.filename = data.newFilename; + + // Update button + button.textContent = 'Mark Read'; + button.setAttribute('onclick', + ` + "`toggleReadStatus(event, '${emailItem.dataset.id}', '${data.newFilename}', 'new', false)`);" + ` + } else { + console.error('Failed to mark email as unread:', data.error); + } + }) + .catch(error => { + console.error('Error marking email as unread:', error); + }); + } + </script> +</body> +</html>` + + 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 <maildir-path> [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) + } +} |