ultisuite-backend/internal/mail/imap/parse.go
R3D347HR4Y cd0a80f5e8 huhu
2026-05-25 13:52:27 +02:00

252 lines
5.9 KiB
Go

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, "")
}