229 lines
5.5 KiB
Go
229 lines
5.5 KiB
Go
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
|
|
}
|