package handlers import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "github.com/go-chi/chi/v5" ) // MaildirMessage represents an email read directly from Maildir type MaildirMessage struct { UID uint32 `json:"uid"` Subject string `json:"subject"` From string `json:"from"` To []string `json:"to"` Date string `json:"date"` Body string `json:"body"` Preview string `json:"preview"` Read bool `json:"read"` Attachments int `json:"attachments"` } // getMaildirPath returns the path to the Maildir for a user func getMaildirPath(email string) string { // Try docker container path first (when running inside container) basePath := "/mail" if _, err := os.Stat(basePath); os.IsNotExist(err) { // Fallback to host path basePath = "/opt/mailu/mail" if _, err := os.Stat(basePath); os.IsNotExist(err) { // Try to find mail via docker volume basePath = "/var/lib/docker/volumes" } } return filepath.Join(basePath, email) } // parseMaildirFile parses a single mail file func parseMaildirFile(path string) (*MaildirMessage, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } content := string(data) msg := &MaildirMessage{ Read: strings.Contains(filepath.Base(path), ",S=") || !strings.Contains(path, "/new/"), Attachments: 0, } // Parse headers lines := strings.Split(content, "\n") inBody := false var bodyLines []string for _, line := range lines { if !inBody { if line == "" { inBody = true continue } if strings.HasPrefix(line, "Subject: ") { msg.Subject = strings.TrimPrefix(line, "Subject: ") } else if strings.HasPrefix(line, "From: ") { msg.From = strings.TrimPrefix(line, "From: ") } else if strings.HasPrefix(line, "To: ") { msg.To = append(msg.To, strings.TrimPrefix(line, "To: ")) } else if strings.HasPrefix(line, "Date: ") { msg.Date = strings.TrimPrefix(line, "Date: ") } } else { bodyLines = append(bodyLines, line) } } body := strings.Join(bodyLines, "\n") msg.Body = body msg.Preview = truncateString(stripHTML(body), 200) // Generate UID from filename filename := filepath.Base(path) msg.UID = hashString(filename) return msg, nil } // listMaildirMessages lists all messages in a Maildir func listMaildirMessages(maildir string, limit int) ([]MaildirMessage, error) { var messages []MaildirMessage // Read cur/ and new/ directories for _, subdir := range []string{"cur", "new"} { path := filepath.Join(maildir, subdir) entries, err := os.ReadDir(path) if err != nil { continue // Directory may not exist } for _, entry := range entries { if entry.IsDir() { continue } msg, err := parseMaildirFile(filepath.Join(path, entry.Name())) if err != nil { continue } messages = append(messages, *msg) } } // Sort by date (newest first) sort.Slice(messages, func(i, j int) bool { return messages[i].Date > messages[j].Date }) if limit > 0 && len(messages) > limit { messages = messages[:limit] } return messages, nil } // GetMailInboxDirect returns emails directly from Maildir func GetMailInboxDirect(w http.ResponseWriter, r *http.Request) { // Get user from context or use default email := "erik@landvex.com" // TODO: Get from JWT context maildir := getMaildirPath(email) if _, err := os.Stat(maildir); os.IsNotExist(err) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Maildir not found for user: " + email, }) return } limit := 50 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } messages, err := listMaildirMessages(maildir, limit) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "messages": messages, "total": len(messages), }) } // GetMailMessageDirect returns a single email from Maildir func GetMailMessageDirect(w http.ResponseWriter, r *http.Request) { uidStr := chi.URLParam(r, "uid") uid, err := strconv.ParseUint(uidStr, 10, 32) if err != nil { http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest) return } email := "erik@landvex.com" // TODO: Get from JWT context maildir := getMaildirPath(email) // Search for message with matching UID for _, subdir := range []string{"cur", "new"} { path := filepath.Join(maildir, subdir) entries, err := os.ReadDir(path) if err != nil { continue } for _, entry := range entries { if entry.IsDir() { continue } filename := filepath.Join(path, entry.Name()) if hashString(entry.Name()) == uint32(uid) { msg, err := parseMaildirFile(filename) if err != nil { http.Error(w, `{"error":"failed to read message"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": msg, }) return } } } http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) } // MarkMailAsReadDirect marks a message as read func MarkMailAsReadDirect(w http.ResponseWriter, r *http.Request) { uidStr := chi.URLParam(r, "uid") uid, err := strconv.ParseUint(uidStr, 10, 32) if err != nil { http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest) return } email := "erik@landvex.com" // TODO: Get from JWT context maildir := getMaildirPath(email) // Move from new/ to cur/ for _, entry := range []string{"new", "cur"} { path := filepath.Join(maildir, entry) entries, err := os.ReadDir(path) if err != nil { continue } for _, e := range entries { if e.IsDir() { continue } if hashString(e.Name()) == uint32(uid) { oldPath := filepath.Join(path, e.Name()) newPath := filepath.Join(maildir, "cur", e.Name()) if entry == "new" { if err := os.Rename(oldPath, newPath); err != nil { http.Error(w, `{"error":"failed to mark as read"}`, http.StatusInternalServerError) return } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, }) return } } } http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) } // Helper functions func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } func stripHTML(s string) string { // Simple HTML stripping result := strings.ReplaceAll(s, "
", "\n") result = strings.ReplaceAll(result, "
", "\n") result = strings.ReplaceAll(result, "

", "\n") result = strings.ReplaceAll(result, "

", "") // Remove tags for { start := strings.Index(result, "<") if start == -1 { break } end := strings.Index(result[start:], ">") if end == -1 { break } result = result[:start] + result[start+end+1:] } return strings.TrimSpace(result) } func hashString(s string) uint32 { var h uint32 = 5381 for i := 0; i < len(s); i++ { h = ((h << 5) + h) + uint32(s[i]) } return h } // GetMailUnreadCountDirect returns unread count func GetMailUnreadCountDirect(w http.ResponseWriter, r *http.Request) { email := "erik@landvex.com" // TODO: Get from JWT context maildir := getMaildirPath(email) count := 0 newPath := filepath.Join(maildir, "new") entries, err := os.ReadDir(newPath) if err == nil { for _, e := range entries { if !e.IsDir() { count++ } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "count": count, }) }