package parser import ( "bytes" "encoding/base64" "fmt" "io" "mime" "mime/multipart" "mime/quotedprintable" "net/mail" "path/filepath" "strings" "time" "golang.org/x/net/html/charset" "internal-mail-skill/internal/model" ) func Parse(raw []byte) (model.ParsedEmail, error) { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return model.ParsedEmail{}, fmt.Errorf("read message: %w", err) } parsed := model.ParsedEmail{ MessageIDHeader: strings.TrimSpace(msg.Header.Get("Message-ID")), InReplyTo: strings.TrimSpace(msg.Header.Get("In-Reply-To")), References: strings.TrimSpace(msg.Header.Get("References")), Subject: decodeHeader(msg.Header.Get("Subject")), DateHeader: strings.TrimSpace(msg.Header.Get("Date")), } if sentAt, err := mail.ParseDate(parsed.DateHeader); err == nil { parsed.SentAt = &sentAt } if from, err := mail.ParseAddress(msg.Header.Get("From")); err == nil { parsed.SenderName = decodeHeader(from.Name) parsed.SenderEmail = strings.ToLower(from.Address) } parsed.To = parseAddressList(msg.Header.Get("To")) parsed.Cc = parseAddressList(msg.Header.Get("Cc")) if err := parsePart(mail.Header(msg.Header), msg.Body, &parsed); err != nil { return model.ParsedEmail{}, err } return parsed, nil } func parsePart(header mail.Header, body io.Reader, out *model.ParsedEmail) error { mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type")) if err != nil || mediaType == "" { mediaType = "text/plain" } mediaType = strings.ToLower(mediaType) if strings.HasPrefix(mediaType, "multipart/") { boundary := params["boundary"] if boundary == "" { return fmt.Errorf("multipart message missing boundary") } mr := multipart.NewReader(body, boundary) for { part, err := mr.NextPart() if err == io.EOF { return nil } if err != nil { return fmt.Errorf("read multipart: %w", err) } if err := parsePart(mail.Header(part.Header), part, out); err != nil { return err } } } decoded := transferDecodedReader(header, body) filename := partFilename(header) disposition := strings.ToLower(header.Get("Content-Disposition")) isAttachment := filename != "" || strings.Contains(disposition, "attachment") if !isAttachment && strings.HasPrefix(mediaType, "text/") { text, err := readText(decoded, params["charset"]) if err != nil { return fmt.Errorf("read text part: %w", err) } switch mediaType { case "text/html": appendText(&out.BodyHTML, text) default: appendText(&out.BodyText, text) } return nil } data, err := io.ReadAll(decoded) if err != nil { return fmt.Errorf("read attachment: %w", err) } if filename == "" { filename = "attachment.bin" } out.Attachments = append(out.Attachments, model.ParsedAttachment{ Filename: sanitizeFilename(filename), ContentType: mediaType, ContentID: trimAngle(header.Get("Content-ID")), SizeBytes: int64(len(data)), Data: data, }) return nil } func decodeHeader(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } decoder := &mime.WordDecoder{CharsetReader: charset.NewReaderLabel} decoded, err := decoder.DecodeHeader(value) if err != nil { return value } return decoded } func parseAddressList(value string) []model.Address { value = strings.TrimSpace(value) if value == "" { return nil } list, err := mail.ParseAddressList(value) if err != nil { decoded := decodeHeader(value) list, err = mail.ParseAddressList(decoded) if err != nil { return nil } } out := make([]model.Address, 0, len(list)) for _, addr := range list { out = append(out, model.Address{ Name: decodeHeader(addr.Name), Email: strings.ToLower(addr.Address), }) } return out } func transferDecodedReader(header mail.Header, r io.Reader) io.Reader { switch strings.ToLower(strings.TrimSpace(header.Get("Content-Transfer-Encoding"))) { case "base64": return base64.NewDecoder(base64.StdEncoding, r) case "quoted-printable": return quotedprintable.NewReader(r) default: return r } } func readText(r io.Reader, label string) (string, error) { if label != "" { cr, err := charset.NewReaderLabel(label, r) if err == nil { r = cr } } data, err := io.ReadAll(r) if err != nil { return "", err } return string(data), nil } func partFilename(header mail.Header) string { if disp := header.Get("Content-Disposition"); disp != "" { _, params, err := mime.ParseMediaType(disp) if err == nil && params["filename"] != "" { return decodeHeader(params["filename"]) } } if ct := header.Get("Content-Type"); ct != "" { _, params, err := mime.ParseMediaType(ct) if err == nil && params["name"] != "" { return decodeHeader(params["name"]) } } return "" } func appendText(dst *string, value string) { if strings.TrimSpace(value) == "" { return } if strings.TrimSpace(*dst) == "" { *dst = value return } *dst += "\n" + value } func sanitizeFilename(name string) string { name = filepath.Base(strings.TrimSpace(name)) name = strings.ReplaceAll(name, "\x00", "") name = strings.ReplaceAll(name, "/", "_") name = strings.ReplaceAll(name, "\\", "_") if name == "." || name == "" { return "attachment.bin" } return name } func trimAngle(value string) string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, "<") value = strings.TrimSuffix(value, ">") return value } func ParseTimeRFC3339(value string) *time.Time { if value == "" { return nil } t, err := time.Parse(time.RFC3339Nano, value) if err != nil { return nil } return &t }