252 lines
5.9 KiB
Go
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, "")
|
|
}
|