78b57273e2
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
323 lines
7.8 KiB
Go
323 lines
7.8 KiB
Go
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, "<br>", "\n")
|
|
result = strings.ReplaceAll(result, "<br/>", "\n")
|
|
result = strings.ReplaceAll(result, "<p>", "\n")
|
|
result = strings.ReplaceAll(result, "</p>", "")
|
|
|
|
// 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,
|
|
})
|
|
}
|