1package data
2
3import (
4	"bytes"
5	"crypto/rand"
6	"encoding/base64"
7	"io"
8	"log"
9	"mime"
10	"strings"
11	"time"
12)
13
14// LogHandler is called for each log message. If nil, log messages will
15// be output using log.Printf instead.
16var LogHandler func(message string, args ...interface{})
17
18func logf(message string, args ...interface{}) {
19	if LogHandler != nil {
20		LogHandler(message, args...)
21	} else {
22		log.Printf(message, args...)
23	}
24}
25
26// MessageID represents the ID of an SMTP message including the hostname part
27type MessageID string
28
29// NewMessageID generates a new message ID
30func NewMessageID(hostname string) (MessageID, error) {
31	size := 32
32
33	rb := make([]byte, size)
34	_, err := rand.Read(rb)
35
36	if err != nil {
37		return MessageID(""), err
38	}
39
40	rs := base64.URLEncoding.EncodeToString(rb)
41
42	return MessageID(rs + "@" + hostname), nil
43}
44
45// Messages represents an array of Messages
46// - TODO is this even required?
47type Messages []Message
48
49// Message represents a parsed SMTP message
50type Message struct {
51	ID      MessageID
52	From    *Path
53	To      []*Path
54	Content *Content
55	Created time.Time
56	MIME    *MIMEBody // FIXME refactor to use Content.MIME
57	Raw     *SMTPMessage
58}
59
60// Path represents an SMTP forward-path or return-path
61type Path struct {
62	Relays  []string
63	Mailbox string
64	Domain  string
65	Params  string
66}
67
68// Content represents the body content of an SMTP message
69type Content struct {
70	Headers map[string][]string
71	Body    string
72	Size    int
73	MIME    *MIMEBody
74}
75
76// SMTPMessage represents a raw SMTP message
77type SMTPMessage struct {
78	From string
79	To   []string
80	Data string
81	Helo string
82}
83
84// MIMEBody represents a collection of MIME parts
85type MIMEBody struct {
86	Parts []*Content
87}
88
89// Parse converts a raw SMTP message to a parsed MIME message
90func (m *SMTPMessage) Parse(hostname string) *Message {
91	var arr []*Path
92	for _, path := range m.To {
93		arr = append(arr, PathFromString(path))
94	}
95
96	id, _ := NewMessageID(hostname)
97	msg := &Message{
98		ID:      id,
99		From:    PathFromString(m.From),
100		To:      arr,
101		Content: ContentFromString(m.Data),
102		Created: time.Now(),
103		Raw:     m,
104	}
105
106	if msg.Content.IsMIME() {
107		logf("Parsing MIME body")
108		msg.MIME = msg.Content.ParseMIMEBody()
109	}
110
111	// find headers
112	var hasMessageID bool
113	var receivedHeaderName string
114	var returnPathHeaderName string
115
116	for k := range msg.Content.Headers {
117		if strings.ToLower(k) == "message-id" {
118			hasMessageID = true
119			continue
120		}
121		if strings.ToLower(k) == "received" {
122			receivedHeaderName = k
123			continue
124		}
125		if strings.ToLower(k) == "return-path" {
126			returnPathHeaderName = k
127			continue
128		}
129	}
130
131	if !hasMessageID {
132		msg.Content.Headers["Message-ID"] = []string{string(id)}
133	}
134
135	if len(receivedHeaderName) > 0 {
136		msg.Content.Headers[receivedHeaderName] = append(msg.Content.Headers[receivedHeaderName], "from "+m.Helo+" by "+hostname+" (MailHog)\r\n          id "+string(id)+"; "+time.Now().Format(time.RFC1123Z))
137	} else {
138		msg.Content.Headers["Received"] = []string{"from " + m.Helo + " by " + hostname + " (MailHog)\r\n          id " + string(id) + "; " + time.Now().Format(time.RFC1123Z)}
139	}
140
141	if len(returnPathHeaderName) > 0 {
142		msg.Content.Headers[returnPathHeaderName] = append(msg.Content.Headers[returnPathHeaderName], "<"+m.From+">")
143	} else {
144		msg.Content.Headers["Return-Path"] = []string{"<" + m.From + ">"}
145	}
146
147	return msg
148}
149
150// Bytes returns an io.Reader containing the raw message data
151func (m *SMTPMessage) Bytes() io.Reader {
152	var b = new(bytes.Buffer)
153
154	b.WriteString("HELO:<" + m.Helo + ">\r\n")
155	b.WriteString("FROM:<" + m.From + ">\r\n")
156	for _, t := range m.To {
157		b.WriteString("TO:<" + t + ">\r\n")
158	}
159	b.WriteString("\r\n")
160	b.WriteString(m.Data)
161
162	return b
163}
164
165// FromBytes returns a SMTPMessage from raw message bytes (as output by SMTPMessage.Bytes())
166func FromBytes(b []byte) *SMTPMessage {
167	msg := &SMTPMessage{}
168	var headerDone bool
169	for _, l := range strings.Split(string(b), "\n") {
170		if !headerDone {
171			if strings.HasPrefix(l, "HELO:<") {
172				l = strings.TrimPrefix(l, "HELO:<")
173				l = strings.TrimSuffix(l, ">\r")
174				msg.Helo = l
175				continue
176			}
177			if strings.HasPrefix(l, "FROM:<") {
178				l = strings.TrimPrefix(l, "FROM:<")
179				l = strings.TrimSuffix(l, ">\r")
180				msg.From = l
181				continue
182			}
183			if strings.HasPrefix(l, "TO:<") {
184				l = strings.TrimPrefix(l, "TO:<")
185				l = strings.TrimSuffix(l, ">\r")
186				msg.To = append(msg.To, l)
187				continue
188			}
189			if strings.TrimSpace(l) == "" {
190				headerDone = true
191				continue
192			}
193		}
194		msg.Data += l + "\n"
195	}
196	return msg
197}
198
199// Bytes returns an io.Reader containing the raw message data
200func (m *Message) Bytes() io.Reader {
201	var b = new(bytes.Buffer)
202
203	for k, vs := range m.Content.Headers {
204		for _, v := range vs {
205			b.WriteString(k + ": " + v + "\r\n")
206		}
207	}
208
209	b.WriteString("\r\n")
210	b.WriteString(m.Content.Body)
211
212	return b
213}
214
215// IsMIME detects a valid MIME header
216func (content *Content) IsMIME() bool {
217	header, ok := content.Headers["Content-Type"]
218	if !ok {
219		return false
220	}
221	return strings.HasPrefix(header[0], "multipart/")
222}
223
224// ParseMIMEBody parses SMTP message content into multiple MIME parts
225func (content *Content) ParseMIMEBody() *MIMEBody {
226	var parts []*Content
227
228	if hdr, ok := content.Headers["Content-Type"]; ok {
229		if len(hdr) > 0 {
230			boundary := extractBoundary(hdr[0])
231			var p []string
232			if len(boundary) > 0 {
233				p = strings.Split(content.Body, "--"+boundary)
234				logf("Got boundary: %s", boundary)
235			} else {
236				logf("Boundary not found: %s", hdr[0])
237			}
238
239			for _, s := range p {
240				if len(s) > 0 {
241					part := ContentFromString(strings.Trim(s, "\r\n"))
242					if part.IsMIME() {
243						logf("Parsing inner MIME body")
244						part.MIME = part.ParseMIMEBody()
245					}
246					parts = append(parts, part)
247				}
248			}
249		}
250	}
251
252	return &MIMEBody{
253		Parts: parts,
254	}
255}
256
257// PathFromString parses a forward-path or reverse-path into its parts
258func PathFromString(path string) *Path {
259	var relays []string
260	email := path
261	if strings.Contains(path, ":") {
262		x := strings.SplitN(path, ":", 2)
263		r, e := x[0], x[1]
264		email = e
265		relays = strings.Split(r, ",")
266	}
267	mailbox, domain := "", ""
268	if strings.Contains(email, "@") {
269		x := strings.SplitN(email, "@", 2)
270		mailbox, domain = x[0], x[1]
271	} else {
272		mailbox = email
273	}
274
275	return &Path{
276		Relays:  relays,
277		Mailbox: mailbox,
278		Domain:  domain,
279		Params:  "", // FIXME?
280	}
281}
282
283// ContentFromString parses SMTP content into separate headers and body
284func ContentFromString(data string) *Content {
285	logf("Parsing Content from string: '%s'", data)
286	x := strings.SplitN(data, "\r\n\r\n", 2)
287	h := make(map[string][]string, 0)
288
289	// FIXME this fails if the message content has no headers - specifically,
290	// if it doesn't contain \r\n\r\n
291
292	if len(x) == 2 {
293		headers, body := x[0], x[1]
294		hdrs := strings.Split(headers, "\r\n")
295		var lastHdr = ""
296		for _, hdr := range hdrs {
297			if lastHdr != "" && (strings.HasPrefix(hdr, " ") || strings.HasPrefix(hdr, "\t")) {
298				h[lastHdr][len(h[lastHdr])-1] = h[lastHdr][len(h[lastHdr])-1] + hdr
299			} else if strings.Contains(hdr, ": ") {
300				y := strings.SplitN(hdr, ": ", 2)
301				key, value := y[0], y[1]
302				// TODO multiple header fields
303				h[key] = []string{value}
304				lastHdr = key
305			} else if len(hdr) > 0 {
306				logf("Found invalid header: '%s'", hdr)
307			}
308		}
309		return &Content{
310			Size:    len(data),
311			Headers: h,
312			Body:    body,
313		}
314	}
315	return &Content{
316		Size:    len(data),
317		Headers: h,
318		Body:    x[0],
319	}
320}
321
322// extractBoundary extract boundary string in contentType.
323// It returns empty string if no valid boundary found
324func extractBoundary(contentType string) string {
325	_, params, err := mime.ParseMediaType(contentType)
326	if err == nil {
327		return params["boundary"]
328	}
329	return ""
330}
331