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 }