Implement internal mail skill MVP
This commit is contained in:
228
internal/parser/parser.go
Normal file
228
internal/parser/parser.go
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user