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