mirror of
https://gitlab.com/ric_harvey/MailHog.git
synced 2024-12-18 10:27:16 +00:00
324 lines
7.4 KiB
Go
324 lines
7.4 KiB
Go
|
package data
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"crypto/rand"
|
||
|
"encoding/base64"
|
||
|
"io"
|
||
|
"log"
|
||
|
"mime"
|
||
|
"strings"
|
||
|
"time"
|
||
|
)
|
||
|
|
||
|
// LogHandler is called for each log message. If nil, log messages will
|
||
|
// be output using log.Printf instead.
|
||
|
var LogHandler func(message string, args ...interface{})
|
||
|
|
||
|
func logf(message string, args ...interface{}) {
|
||
|
if LogHandler != nil {
|
||
|
LogHandler(message, args...)
|
||
|
} else {
|
||
|
log.Printf(message, args...)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// MessageID represents the ID of an SMTP message including the hostname part
|
||
|
type MessageID string
|
||
|
|
||
|
// NewMessageID generates a new message ID
|
||
|
func NewMessageID(hostname string) (MessageID, error) {
|
||
|
size := 32
|
||
|
|
||
|
rb := make([]byte, size)
|
||
|
_, err := rand.Read(rb)
|
||
|
|
||
|
if err != nil {
|
||
|
return MessageID(""), err
|
||
|
}
|
||
|
|
||
|
rs := base64.URLEncoding.EncodeToString(rb)
|
||
|
|
||
|
return MessageID(rs + "@" + hostname), nil
|
||
|
}
|
||
|
|
||
|
// Messages represents an array of Messages
|
||
|
// - TODO is this even required?
|
||
|
type Messages []Message
|
||
|
|
||
|
// Message represents a parsed SMTP message
|
||
|
type Message struct {
|
||
|
ID MessageID
|
||
|
From *Path
|
||
|
To []*Path
|
||
|
Content *Content
|
||
|
Created time.Time
|
||
|
MIME *MIMEBody // FIXME refactor to use Content.MIME
|
||
|
Raw *SMTPMessage
|
||
|
}
|
||
|
|
||
|
// Path represents an SMTP forward-path or return-path
|
||
|
type Path struct {
|
||
|
Relays []string
|
||
|
Mailbox string
|
||
|
Domain string
|
||
|
Params string
|
||
|
}
|
||
|
|
||
|
// Content represents the body content of an SMTP message
|
||
|
type Content struct {
|
||
|
Headers map[string][]string
|
||
|
Body string
|
||
|
Size int
|
||
|
MIME *MIMEBody
|
||
|
}
|
||
|
|
||
|
// SMTPMessage represents a raw SMTP message
|
||
|
type SMTPMessage struct {
|
||
|
From string
|
||
|
To []string
|
||
|
Data string
|
||
|
Helo string
|
||
|
}
|
||
|
|
||
|
// MIMEBody represents a collection of MIME parts
|
||
|
type MIMEBody struct {
|
||
|
Parts []*Content
|
||
|
}
|
||
|
|
||
|
// Parse converts a raw SMTP message to a parsed MIME message
|
||
|
func (m *SMTPMessage) Parse(hostname string) *Message {
|
||
|
var arr []*Path
|
||
|
for _, path := range m.To {
|
||
|
arr = append(arr, PathFromString(path))
|
||
|
}
|
||
|
|
||
|
id, _ := NewMessageID(hostname)
|
||
|
msg := &Message{
|
||
|
ID: id,
|
||
|
From: PathFromString(m.From),
|
||
|
To: arr,
|
||
|
Content: ContentFromString(m.Data),
|
||
|
Created: time.Now(),
|
||
|
Raw: m,
|
||
|
}
|
||
|
|
||
|
if msg.Content.IsMIME() {
|
||
|
logf("Parsing MIME body")
|
||
|
msg.MIME = msg.Content.ParseMIMEBody()
|
||
|
}
|
||
|
|
||
|
// find headers
|
||
|
var hasMessageID bool
|
||
|
var receivedHeaderName string
|
||
|
var returnPathHeaderName string
|
||
|
|
||
|
for k := range msg.Content.Headers {
|
||
|
if strings.ToLower(k) == "message-id" {
|
||
|
hasMessageID = true
|
||
|
continue
|
||
|
}
|
||
|
if strings.ToLower(k) == "received" {
|
||
|
receivedHeaderName = k
|
||
|
continue
|
||
|
}
|
||
|
if strings.ToLower(k) == "return-path" {
|
||
|
returnPathHeaderName = k
|
||
|
continue
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if !hasMessageID {
|
||
|
msg.Content.Headers["Message-ID"] = []string{string(id)}
|
||
|
}
|
||
|
|
||
|
if len(receivedHeaderName) > 0 {
|
||
|
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))
|
||
|
} else {
|
||
|
msg.Content.Headers["Received"] = []string{"from " + m.Helo + " by " + hostname + " (MailHog)\r\n id " + string(id) + "; " + time.Now().Format(time.RFC1123Z)}
|
||
|
}
|
||
|
|
||
|
if len(returnPathHeaderName) > 0 {
|
||
|
msg.Content.Headers[returnPathHeaderName] = append(msg.Content.Headers[returnPathHeaderName], "<"+m.From+">")
|
||
|
} else {
|
||
|
msg.Content.Headers["Return-Path"] = []string{"<" + m.From + ">"}
|
||
|
}
|
||
|
|
||
|
return msg
|
||
|
}
|
||
|
|
||
|
// Bytes returns an io.Reader containing the raw message data
|
||
|
func (m *SMTPMessage) Bytes() io.Reader {
|
||
|
var b = new(bytes.Buffer)
|
||
|
|
||
|
b.WriteString("HELO:<" + m.Helo + ">\r\n")
|
||
|
b.WriteString("FROM:<" + m.From + ">\r\n")
|
||
|
for _, t := range m.To {
|
||
|
b.WriteString("TO:<" + t + ">\r\n")
|
||
|
}
|
||
|
b.WriteString("\r\n")
|
||
|
b.WriteString(m.Data)
|
||
|
|
||
|
return b
|
||
|
}
|
||
|
|
||
|
// FromBytes returns a SMTPMessage from raw message bytes (as output by SMTPMessage.Bytes())
|
||
|
func FromBytes(b []byte) *SMTPMessage {
|
||
|
msg := &SMTPMessage{}
|
||
|
for _, l := range strings.Split(string(b), "\n") {
|
||
|
if strings.HasPrefix(l, "HELO:<") {
|
||
|
l = strings.TrimPrefix(l, "HELO:<")
|
||
|
l = strings.TrimSuffix(l, ">\r")
|
||
|
msg.Helo = l
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(l, "FROM:<") {
|
||
|
l = strings.TrimPrefix(l, "FROM:<")
|
||
|
l = strings.TrimSuffix(l, ">\r")
|
||
|
msg.From = l
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(l, "TO:<") {
|
||
|
l = strings.TrimPrefix(l, "TO:<")
|
||
|
l = strings.TrimSuffix(l, ">\r")
|
||
|
msg.To = append(msg.To, l)
|
||
|
continue
|
||
|
}
|
||
|
msg.Data += l + "\n"
|
||
|
}
|
||
|
return msg
|
||
|
}
|
||
|
|
||
|
// Bytes returns an io.Reader containing the raw message data
|
||
|
func (m *Message) Bytes() io.Reader {
|
||
|
var b = new(bytes.Buffer)
|
||
|
|
||
|
for k, vs := range m.Content.Headers {
|
||
|
for _, v := range vs {
|
||
|
b.WriteString(k + ": " + v + "\r\n")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
b.WriteString("\r\n")
|
||
|
b.WriteString(m.Content.Body)
|
||
|
|
||
|
return b
|
||
|
}
|
||
|
|
||
|
// IsMIME detects a valid MIME header
|
||
|
func (content *Content) IsMIME() bool {
|
||
|
header, ok := content.Headers["Content-Type"]
|
||
|
if !ok {
|
||
|
return false
|
||
|
}
|
||
|
return strings.HasPrefix(header[0], "multipart/")
|
||
|
}
|
||
|
|
||
|
// ParseMIMEBody parses SMTP message content into multiple MIME parts
|
||
|
func (content *Content) ParseMIMEBody() *MIMEBody {
|
||
|
var parts []*Content
|
||
|
|
||
|
if hdr, ok := content.Headers["Content-Type"]; ok {
|
||
|
if len(hdr) > 0 {
|
||
|
boundary := extractBoundary(hdr[0])
|
||
|
var p []string
|
||
|
if len(boundary) > 0 {
|
||
|
p = strings.Split(content.Body, "--"+boundary)
|
||
|
logf("Got boundary: %s", boundary)
|
||
|
} else {
|
||
|
logf("Boundary not found: %s", hdr[0])
|
||
|
}
|
||
|
|
||
|
for _, s := range p {
|
||
|
if len(s) > 0 {
|
||
|
part := ContentFromString(strings.Trim(s, "\r\n"))
|
||
|
if part.IsMIME() {
|
||
|
logf("Parsing inner MIME body")
|
||
|
part.MIME = part.ParseMIMEBody()
|
||
|
}
|
||
|
parts = append(parts, part)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return &MIMEBody{
|
||
|
Parts: parts,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// PathFromString parses a forward-path or reverse-path into its parts
|
||
|
func PathFromString(path string) *Path {
|
||
|
var relays []string
|
||
|
email := path
|
||
|
if strings.Contains(path, ":") {
|
||
|
x := strings.SplitN(path, ":", 2)
|
||
|
r, e := x[0], x[1]
|
||
|
email = e
|
||
|
relays = strings.Split(r, ",")
|
||
|
}
|
||
|
mailbox, domain := "", ""
|
||
|
if strings.Contains(email, "@") {
|
||
|
x := strings.SplitN(email, "@", 2)
|
||
|
mailbox, domain = x[0], x[1]
|
||
|
} else {
|
||
|
mailbox = email
|
||
|
}
|
||
|
|
||
|
return &Path{
|
||
|
Relays: relays,
|
||
|
Mailbox: mailbox,
|
||
|
Domain: domain,
|
||
|
Params: "", // FIXME?
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// ContentFromString parses SMTP content into separate headers and body
|
||
|
func ContentFromString(data string) *Content {
|
||
|
logf("Parsing Content from string: '%s'", data)
|
||
|
x := strings.SplitN(data, "\r\n\r\n", 2)
|
||
|
h := make(map[string][]string, 0)
|
||
|
|
||
|
// FIXME this fails if the message content has no headers - specifically,
|
||
|
// if it doesn't contain \r\n\r\n
|
||
|
|
||
|
if len(x) == 2 {
|
||
|
headers, body := x[0], x[1]
|
||
|
hdrs := strings.Split(headers, "\r\n")
|
||
|
var lastHdr = ""
|
||
|
for _, hdr := range hdrs {
|
||
|
if lastHdr != "" && (strings.HasPrefix(hdr, " ") || strings.HasPrefix(hdr, "\t")) {
|
||
|
h[lastHdr][len(h[lastHdr])-1] = h[lastHdr][len(h[lastHdr])-1] + hdr
|
||
|
} else if strings.Contains(hdr, ": ") {
|
||
|
y := strings.SplitN(hdr, ": ", 2)
|
||
|
key, value := y[0], y[1]
|
||
|
// TODO multiple header fields
|
||
|
h[key] = []string{value}
|
||
|
lastHdr = key
|
||
|
} else if len(hdr) > 0 {
|
||
|
logf("Found invalid header: '%s'", hdr)
|
||
|
}
|
||
|
}
|
||
|
return &Content{
|
||
|
Size: len(data),
|
||
|
Headers: h,
|
||
|
Body: body,
|
||
|
}
|
||
|
}
|
||
|
return &Content{
|
||
|
Size: len(data),
|
||
|
Headers: h,
|
||
|
Body: x[0],
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// extractBoundary extract boundary string in contentType.
|
||
|
// It returns empty string if no valid boundary found
|
||
|
func extractBoundary(contentType string) string {
|
||
|
_, params, err := mime.ParseMediaType(contentType)
|
||
|
if err == nil {
|
||
|
return params["boundary"]
|
||
|
}
|
||
|
return ""
|
||
|
}
|