mime.go raw

   1  package smtp
   2  
   3  import (
   4  	"bytes"
   5  	"fmt"
   6  	"io"
   7  	"strings"
   8  
   9  	"github.com/emersion/go-message"
  10  
  11  	// Enable full charset support for international emails
  12  	_ "github.com/emersion/go-message/charset"
  13  )
  14  
  15  // ParsedMIME represents a parsed email message.
  16  type ParsedMIME struct {
  17  	From        string
  18  	To          []string
  19  	Cc          []string
  20  	Subject     string
  21  	MessageID   string
  22  	InReplyTo   string
  23  	TextPlain   string
  24  	TextHTML    string
  25  	Attachments []Attachment
  26  }
  27  
  28  // Attachment represents an email attachment.
  29  type Attachment struct {
  30  	Filename    string
  31  	ContentType string
  32  	Data        []byte
  33  }
  34  
  35  const maxTextBytes = 64 * 1024 // 64KB text limit per spec
  36  
  37  // ParseMIME parses a raw email message into structured fields.
  38  func ParseMIME(raw []byte) (*ParsedMIME, error) {
  39  	entity, err := message.Read(bytes.NewReader(raw))
  40  	if err != nil && !message.IsUnknownCharset(err) {
  41  		return nil, fmt.Errorf("parse message: %w", err)
  42  	}
  43  
  44  	parsed := &ParsedMIME{}
  45  
  46  	// Extract headers
  47  	h := entity.Header
  48  	parsed.From, _ = h.Text("From")
  49  	parsed.Subject, _ = h.Text("Subject")
  50  	parsed.MessageID = h.Get("Message-Id")
  51  	parsed.InReplyTo = h.Get("In-Reply-To")
  52  
  53  	// Parse To addresses
  54  	if to, _ := h.Text("To"); to != "" {
  55  		parsed.To = splitAddresses(to)
  56  	}
  57  	if cc, _ := h.Text("Cc"); cc != "" {
  58  		parsed.Cc = splitAddresses(cc)
  59  	}
  60  
  61  	// Parse body
  62  	if mr := entity.MultipartReader(); mr != nil {
  63  		// Multipart message
  64  		if err := parseMultipart(mr, parsed); err != nil {
  65  			return nil, err
  66  		}
  67  	} else {
  68  		// Simple message
  69  		ct, _, _ := h.ContentType()
  70  		body, err := io.ReadAll(io.LimitReader(entity.Body, maxTextBytes))
  71  		if err != nil {
  72  			return nil, fmt.Errorf("read body: %w", err)
  73  		}
  74  
  75  		switch {
  76  		case strings.HasPrefix(ct, "text/plain"), ct == "":
  77  			parsed.TextPlain = string(body)
  78  		case strings.HasPrefix(ct, "text/html"):
  79  			parsed.TextHTML = string(body)
  80  		}
  81  	}
  82  
  83  	return parsed, nil
  84  }
  85  
  86  func parseMultipart(mr message.MultipartReader, parsed *ParsedMIME) error {
  87  	for {
  88  		part, err := mr.NextPart()
  89  		if err == io.EOF {
  90  			break
  91  		}
  92  		if err != nil {
  93  			return fmt.Errorf("next part: %w", err)
  94  		}
  95  
  96  		ct, params, _ := part.Header.ContentType()
  97  		disp, dispParams, _ := part.Header.ContentDisposition()
  98  
  99  		// Nested multipart (e.g., multipart/alternative inside multipart/mixed)
 100  		if strings.HasPrefix(ct, "multipart/") {
 101  			if nestedMR := part.MultipartReader(); nestedMR != nil {
 102  				if err := parseMultipart(nestedMR, parsed); err != nil {
 103  					return err
 104  				}
 105  				continue
 106  			}
 107  		}
 108  
 109  		// Attachment (by disposition or non-text content type)
 110  		if disp == "attachment" || (disp == "" && !strings.HasPrefix(ct, "text/")) {
 111  			data, err := io.ReadAll(part.Body)
 112  			if err != nil {
 113  				return fmt.Errorf("read attachment: %w", err)
 114  			}
 115  
 116  			filename := dispParams["filename"]
 117  			if filename == "" {
 118  				filename = params["name"]
 119  			}
 120  
 121  			parsed.Attachments = append(parsed.Attachments, Attachment{
 122  				Filename:    filename,
 123  				ContentType: ct,
 124  				Data:        data,
 125  			})
 126  			continue
 127  		}
 128  
 129  		// Text content
 130  		body, err := io.ReadAll(io.LimitReader(part.Body, maxTextBytes))
 131  		if err != nil {
 132  			return fmt.Errorf("read text part: %w", err)
 133  		}
 134  
 135  		switch {
 136  		case strings.HasPrefix(ct, "text/plain"):
 137  			if parsed.TextPlain == "" {
 138  				parsed.TextPlain = string(body)
 139  			}
 140  		case strings.HasPrefix(ct, "text/html"):
 141  			if parsed.TextHTML == "" {
 142  				parsed.TextHTML = string(body)
 143  			}
 144  		}
 145  	}
 146  
 147  	return nil
 148  }
 149  
 150  // splitAddresses splits a comma-separated address list.
 151  func splitAddresses(s string) []string {
 152  	parts := strings.Split(s, ",")
 153  	var addrs []string
 154  	for _, p := range parts {
 155  		p = strings.TrimSpace(p)
 156  		if p != "" {
 157  			addrs = append(addrs, p)
 158  		}
 159  	}
 160  	return addrs
 161  }
 162