security: Add proper authentication, RBAC, and tenant isolation
- 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
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsedEmail represents a fully parsed email with decoded body
|
||||
type ParsedEmail struct {
|
||||
Subject string
|
||||
From string
|
||||
To []string
|
||||
Date string
|
||||
Body string
|
||||
Preview string
|
||||
HTML string
|
||||
Attachments int
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// ParseEmail parses raw email content and decodes body
|
||||
func ParseEmail(raw []byte) (*ParsedEmail, error) {
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
// Fallback: simple header parsing
|
||||
return parseSimple(raw), nil
|
||||
}
|
||||
|
||||
result := &ParsedEmail{
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
result.Subject = decodeHeader(msg.Header.Get("Subject"))
|
||||
result.From = decodeHeader(msg.Header.Get("From"))
|
||||
result.Date = msg.Header.Get("Date")
|
||||
|
||||
// Parse To
|
||||
if to := msg.Header.Get("To"); to != "" {
|
||||
result.To = parseAddressList(decodeHeader(to))
|
||||
}
|
||||
|
||||
// Parse Cc
|
||||
if cc := msg.Header.Get("Cc"); cc != "" {
|
||||
result.To = append(result.To, parseAddressList(decodeHeader(cc))...)
|
||||
}
|
||||
|
||||
// Get content type
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = "text/plain"
|
||||
}
|
||||
|
||||
// Read body
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
// Handle multipart messages
|
||||
result.parseMultipart(body, params["boundary"])
|
||||
} else {
|
||||
// Single part
|
||||
result.Body = decodeBody(body, msg.Header.Get("Content-Transfer-Encoding"), params["charset"])
|
||||
result.HTML = ""
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
result.Preview = generatePreview(result.Body)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseSimple is a fallback for malformed emails
|
||||
func parseSimple(raw []byte) *ParsedEmail {
|
||||
result := &ParsedEmail{
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
inBody := false
|
||||
var bodyLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if !inBody {
|
||||
if line == "" {
|
||||
inBody = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Subject: ") {
|
||||
result.Subject = decodeHeader(strings.TrimPrefix(line, "Subject: "))
|
||||
} else if strings.HasPrefix(line, "From: ") {
|
||||
result.From = decodeHeader(strings.TrimPrefix(line, "From: "))
|
||||
} else if strings.HasPrefix(line, "To: ") {
|
||||
result.To = append(result.To, decodeHeader(strings.TrimPrefix(line, "To: ")))
|
||||
} else if strings.HasPrefix(line, "Date: ") {
|
||||
result.Date = strings.TrimPrefix(line, "Date: ")
|
||||
}
|
||||
} else {
|
||||
bodyLines = append(bodyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.Join(bodyLines, "\n")
|
||||
result.Body = decodeQuotedPrintable(body)
|
||||
result.Preview = generatePreview(result.Body)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseMultipart handles multipart MIME messages
|
||||
func (e *ParsedEmail) parseMultipart(body []byte, boundary string) {
|
||||
if boundary == "" {
|
||||
e.Body = string(body)
|
||||
return
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(strings.NewReader(string(body)), boundary)
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
partType := part.Header.Get("Content-Type")
|
||||
if partType == "" {
|
||||
partType = "text/plain"
|
||||
}
|
||||
|
||||
mediaType, params, _ := mime.ParseMediaType(partType)
|
||||
partBody, _ := io.ReadAll(part)
|
||||
|
||||
transferEncoding := part.Header.Get("Content-Transfer-Encoding")
|
||||
decoded := decodeBody(partBody, transferEncoding, params["charset"])
|
||||
|
||||
if strings.HasPrefix(mediaType, "text/plain") && e.Body == "" {
|
||||
e.Body = decoded
|
||||
} else if strings.HasPrefix(mediaType, "text/html") && e.HTML == "" {
|
||||
e.HTML = decoded
|
||||
} else if isAttachment(part) {
|
||||
e.Attachments++
|
||||
}
|
||||
}
|
||||
|
||||
// If no plain text found, try to extract from HTML
|
||||
if e.Body == "" && e.HTML != "" {
|
||||
e.Body = stripHTML(e.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeHeader decodes MIME encoded-word headers
|
||||
func decodeHeader(header string) string {
|
||||
// Use mail.AddressParser for proper decoding
|
||||
addr, err := mail.ParseAddress(header)
|
||||
if err == nil && addr.Name != "" {
|
||||
return addr.Name + " <" + addr.Address + ">"
|
||||
}
|
||||
|
||||
// Fallback: try to decode manually
|
||||
decoded := header
|
||||
// Remove =?charset?encoding?text?= patterns
|
||||
for {
|
||||
start := strings.Index(decoded, "=?")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(decoded[start:], "?=")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
end += start + 2
|
||||
|
||||
encoded := decoded[start:end]
|
||||
parts := strings.Split(encoded, "?")
|
||||
if len(parts) >= 4 {
|
||||
encoding := strings.ToUpper(parts[2])
|
||||
encodedText := parts[3]
|
||||
|
||||
var decodedText string
|
||||
if encoding == "B" {
|
||||
// Base64
|
||||
if b, err := base64.StdEncoding.DecodeString(encodedText); err == nil {
|
||||
decodedText = string(b)
|
||||
}
|
||||
} else if encoding == "Q" {
|
||||
// Quoted-printable
|
||||
decodedText = decodeQuotedPrintable(encodedText)
|
||||
}
|
||||
|
||||
if decodedText != "" {
|
||||
decoded = decoded[:start] + decodedText + decoded[end:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// decodeBody decodes body based on transfer encoding
|
||||
func decodeBody(body []byte, encoding string, charset string) string {
|
||||
var decoded []byte
|
||||
|
||||
switch strings.ToLower(encoding) {
|
||||
case "base64":
|
||||
decoded, _ = base64.StdEncoding.DecodeString(string(body))
|
||||
case "quoted-printable":
|
||||
decoded = []byte(decodeQuotedPrintable(string(body)))
|
||||
default:
|
||||
decoded = body
|
||||
}
|
||||
|
||||
// Handle charset (simplified - assumes UTF-8 or Latin-1)
|
||||
result := string(decoded)
|
||||
|
||||
// Try to convert common charsets
|
||||
if charset != "" && !strings.EqualFold(charset, "utf-8") {
|
||||
// For now, just return as-is. In production, use golang.org/x/text/encoding
|
||||
_ = charset
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// decodeQuotedPrintable decodes quoted-printable encoded text
|
||||
func decodeQuotedPrintable(input string) string {
|
||||
var result strings.Builder
|
||||
lines := strings.Split(input, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
// Remove soft line breaks (= at end of line)
|
||||
line = strings.TrimSuffix(line, "=")
|
||||
|
||||
// Decode hex sequences
|
||||
for i := 0; i < len(line); i++ {
|
||||
if i+2 < len(line) && line[i] == '=' {
|
||||
hex := line[i+1 : i+3]
|
||||
if b, err := parseHex(hex); err == nil {
|
||||
result.WriteByte(b)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.WriteByte(line[i])
|
||||
}
|
||||
result.WriteByte('\n')
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// parseHex parses a 2-character hex string
|
||||
func parseHex(s string) (byte, error) {
|
||||
if len(s) != 2 {
|
||||
return 0, fmt.Errorf("invalid hex length")
|
||||
}
|
||||
|
||||
var result byte
|
||||
for i := 0; i < 2; i++ {
|
||||
c := s[i]
|
||||
var val byte
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
val = c - '0'
|
||||
case c >= 'A' && c <= 'F':
|
||||
val = c - 'A' + 10
|
||||
case c >= 'a' && c <= 'f':
|
||||
val = c - 'a' + 10
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid hex character")
|
||||
}
|
||||
result = result<<4 | val
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isAttachment checks if a MIME part is an attachment
|
||||
func isAttachment(part *multipart.Part) bool {
|
||||
disposition := part.Header.Get("Content-Disposition")
|
||||
return strings.Contains(disposition, "attachment") ||
|
||||
part.FileName() != ""
|
||||
}
|
||||
|
||||
// parseAddressList parses a comma-separated list of email addresses
|
||||
func parseAddressList(addresses string) []string {
|
||||
var result []string
|
||||
for _, addr := range strings.Split(addresses, ",") {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr != "" {
|
||||
result = append(result, addr)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// generatePreview generates a preview from body text
|
||||
func generatePreview(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
lines := strings.Split(body, "\n")
|
||||
var preview []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
preview = append(preview, line)
|
||||
if len(preview) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(preview, " ")
|
||||
if len(result) > 120 {
|
||||
result = result[:120] + "..."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user