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