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, }) }