333 lines
7.6 KiB
Go
333 lines
7.6 KiB
Go
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
|