summaryrefslogtreecommitdiff
path: root/genesungswuensche.go
diff options
context:
space:
mode:
authorÖzgür Kesim <oec@codeblau.de>2025-08-19 15:38:54 +0200
committerÖzgür Kesim <oec@codeblau.de>2025-08-19 15:38:54 +0200
commit167a740e9f8fb9da33944ab1ba88df1197a15f7c (patch)
treebcbd5a0a193df54e0f6e1062ae221f6d29424195 /genesungswuensche.go
parentb8b3496807f7fa187f15f291daab50a00ebe9f37 (diff)
added auth, numbers
Diffstat (limited to 'genesungswuensche.go')
-rw-r--r--genesungswuensche.go1030
1 files changed, 1030 insertions, 0 deletions
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)<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, "&nbsp;", " ")
+ text = strings.ReplaceAll(text, "&lt;", "<")
+ text = strings.ReplaceAll(text, "&gt;", ">")
+ text = strings.ReplaceAll(text, "&amp;", "&")
+ text = strings.ReplaceAll(text, "&quot;", "\"")
+ text = strings.ReplaceAll(text, "&#39;", "'")
+ text = strings.ReplaceAll(text, "&#x27;", "'")
+ text = strings.ReplaceAll(text, "&#x2F;", "/")
+
+ 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, "&", "&amp;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ s = strings.ReplaceAll(s, "\"", "&quot;")
+ s = strings.ReplaceAll(s, "'", "&#39;")
+ 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) {
+ 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;
+ }
+
+ ol li {
+ color: #333;
+ }
+
+ @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">
+ <ol reversed>
+ {{range .}}
+ <li>
+ <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>
+ </li>
+ {{end}}
+ </ol>
+ </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('/genesung/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('/genesung/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 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 <maildir-path> [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)
+ }
+}