Files
boc/backend/handlers/mail_parser.go
T

333 lines
7.6 KiB
Go
Raw Normal View History

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
}