package imap import ( "bytes" "encoding/json" "io" "mime" "mime/multipart" "net/mail" "regexp" "strings" imapTypes "github.com/emersion/go-imap/v2" "github.com/ultisuite/ulti-backend/internal/mail/threading" ) type EmailAddress struct { Name string `json:"name"` Address string `json:"address"` } var mimeBoundaryParamRE = regexp.MustCompile(`(?i)boundary\s*=\s*"?([^";\s]+)"?`) func addressesToJSON(addrs []imapTypes.Address) []byte { result := make([]EmailAddress, 0, len(addrs)) for _, a := range addrs { result = append(result, EmailAddress{ Name: a.Name, Address: a.Addr(), }) } b, _ := json.Marshal(result) return b } func parseBody(raw []byte) (text string, html string) { if len(raw) == 0 { return "", "" } text, html = parseBodyFromRFC822(raw) if text != "" || html != "" { if !looksLikeRawMIME(text) && !looksLikeRawMIME(html) { return finalizeDecodedBody(text), finalizeDecodedBody(html) } } if t, h, ok := parseEmbeddedMIME(raw); ok { return finalizeDecodedBody(t), finalizeDecodedBody(h) } if text != "" || html != "" { return finalizeDecodedBody(text), finalizeDecodedBody(html) } fallback := string(raw) return finalizeDecodedBody(fallback), "" } func finalizeDecodedBody(s string) string { s = decodeBareQuotedPrintableIfNeeded(s) return decodeBareBase64IfNeeded(s) } func parseBodyFromRFC822(raw []byte) (text string, html string) { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return "", "" } contentType := msg.Header.Get("Content-Type") if contentType == "" { contentType = "text/plain" } mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { body, _ := readDecodedBody(msg.Body, msg.Header.Get("Content-Transfer-Encoding")) return string(body), "" } if strings.HasPrefix(mediaType, "multipart/") { return parseMultipart(msg.Body, params["boundary"]) } body, _ := readDecodedBody(msg.Body, msg.Header.Get("Content-Transfer-Encoding")) if mediaType == "text/html" { return "", string(body) } outText := string(body) if looksLikeEmbeddedMIME(raw) { if t, h, ok := parseEmbeddedMIME(raw); ok { return t, h } } return outText, "" } func parseMultipart(r io.Reader, boundary string) (text string, html string) { if boundary == "" { return "", "" } mr := multipart.NewReader(r, boundary) for { part, err := mr.NextPart() if err != nil { break } partType := part.Header.Get("Content-Type") mediaType, params, _ := mime.ParseMediaType(partType) switch { case mediaType == "text/plain": body, _ := readDecodedBody(part, part.Header.Get("Content-Transfer-Encoding")) if text == "" { text = string(body) } case mediaType == "text/html": body, _ := readDecodedBody(part, part.Header.Get("Content-Transfer-Encoding")) if len(body) > 0 { html = string(body) } case strings.HasPrefix(mediaType, "multipart/"): t, h := parseMultipart(part, params["boundary"]) if text == "" { text = t } if html == "" && h != "" { html = h } } } return text, html } func readDecodedBody(r io.Reader, transferEncoding string) ([]byte, error) { data, err := io.ReadAll(r) if err != nil { return nil, err } return decodePartBody(transferEncoding, data) } func parseEmbeddedMIME(raw []byte) (text string, html string, ok bool) { if !looksLikeEmbeddedMIME(raw) { return "", "", false } boundary := boundaryFromMIMEBytes(raw) if boundary == "" { return "", "", false } text, html = parseMultipart(bytes.NewReader(raw), boundary) if text == "" && html == "" { return "", "", false } if looksLikeRawMIME(text) || looksLikeRawMIME(html) { return "", "", false } return text, html, true } func looksLikeEmbeddedMIME(raw []byte) bool { s := string(raw) if !strings.Contains(s, "Content-Type:") { return false } return strings.Contains(s, "Content-Transfer-Encoding:") || strings.Contains(strings.ToLower(s), "multipart/") || strings.Contains(s, "This is a multi-part message in MIME format") } func looksLikeRawMIME(s string) bool { if s == "" { return false } if !strings.Contains(s, "Content-Type:") { return false } return strings.Contains(s, "Content-Transfer-Encoding:") || strings.Contains(s, "--") && strings.Contains(strings.ToLower(s), "multipart") } func boundaryFromMIMEBytes(raw []byte) string { if m := mimeBoundaryParamRE.FindSubmatch(raw); len(m) >= 3 { return strings.Trim(string(m[2]), `"`) } return detectBoundaryDelimiter(raw) } func detectBoundaryDelimiter(raw []byte) string { for _, line := range bytes.Split(raw, []byte("\n")) { line = bytes.TrimSpace(line) if len(line) < 4 || line[0] != '-' || line[1] != '-' { continue } if line[len(line)-1] == '-' && line[len(line)-2] == '-' { continue } b := strings.TrimPrefix(string(line), "--") b = strings.TrimSpace(b) if b != "" && !strings.Contains(b, " ") { return b } } return "" } func parseFromHeader(raw []byte) []EmailAddress { if len(raw) == 0 { return nil } msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return nil } fromHdr := strings.TrimSpace(msg.Header.Get("From")) if fromHdr == "" { return nil } parsed, err := mail.ParseAddressList(fromHdr) if err != nil || len(parsed) == 0 { if id := threading.NormalizeMessageID(fromHdr); strings.Contains(fromHdr, "@") { addr := strings.Trim(id, "<>") return []EmailAddress{{Address: addr}} } return nil } out := make([]EmailAddress, 0, len(parsed)) for _, a := range parsed { out = append(out, EmailAddress{Name: a.Name, Address: a.Address}) } return out } func parseThreadHeaders(raw []byte) (references []string, inReplyTo string) { if len(raw) == 0 { return nil, "" } msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return nil, "" } refs := msg.Header.Get("References") irt := strings.TrimSpace(msg.Header.Get("In-Reply-To")) return threading.ParseMessageIDs(refs), threading.NormalizeMessageID(irt) } func toValidUTF8(s string) string { return strings.ToValidUTF8(s, "") }