Refactor with new parsing package
@@ -0,0 +1,29 @@
+.POSIX: + +VERSION=0.1.0 + +PREFIX?=/usr/local +_INSTDIR=$(DESTDIR)$(PREFIX) +BINDIR?=$(_INSTDIR)/bin +GO?=go +GOFLAGS?= + +GOSRC!=find . -name '*.go' +GOSRC+=go.mod go.sum + +clist: $(GOSRC) + $(GO) build $(GOFLAGS) \ + -ldflags "-X main.Prefix=$(PREFIX) \ + -X main.ShareDir=$(SHAREDIR) \ + -X main.Version=$(VERSION)" \ + -o $@ + +all: clist + +# Exists in GNUMake but not in NetBSD make and others. +RM?=rm -f + +clean: + $(RM) aerc + +.DEFAULT_GOAL := all
@@ -1,626 +1,458 @@
-// Package email is designed to provide an "email interface for humans." -// Designed to be robust and flexible, the email package aims to make sending email easy without getting in the way. -package email +package parsemail import ( - "bufio" "bytes" - "crypto/rand" - "crypto/tls" "encoding/base64" - "errors" "fmt" "io" - "math" - "math/big" + "io/ioutil" "mime" "mime/multipart" - "mime/quotedprintable" "net/mail" - "net/smtp" - "net/textproto" - "os" - "path/filepath" "strings" "time" - "unicode" ) -const ( - MaxLineLength = 76 // MaxLineLength is the maximum line length per RFC 2045 - defaultContentType = "text/plain; charset=us-ascii" // defaultContentType is the default Content-Type according to RFC 2045, section 5.2 -) +const contentTypeMultipartMixed = "multipart/mixed" +const contentTypeMultipartAlternative = "multipart/alternative" +const contentTypeMultipartRelated = "multipart/related" +const contentTypeTextHtml = "text/html" +const contentTypeTextPlain = "text/plain" -// ErrMissingBoundary is returned when there is no boundary given for a multipart entity -var ErrMissingBoundary = errors.New("No boundary found for multipart entity") +// Parse an email message read from io.Reader into parsemail.Email struct +func Parse(r io.Reader) (email Email, err error) { + msg, err := mail.ReadMessage(r) + if err != nil { + return + } -// ErrMissingContentType is returned when there is no "Content-Type" header for a MIME entity -var ErrMissingContentType = errors.New("No Content-Type found for MIME entity") + email, err = createEmailFromHeader(msg.Header) + if err != nil { + return + } -// Email is the type used for email messages -type Email struct { - ReplyTo []string - From string - To []string - Recipients []string - Bcc []string - Cc []string - Subject string - Text []byte // Plaintext message (optional) - HTML []byte // Html message (optional) - Sender string // override From as SMTP envelope sender (optional) - Headers textproto.MIMEHeader - Attachments []*Attachment - ReadReceipt []string -} + contentType, params, err := parseContentType(msg.Header.Get("Content-Type")) + if err != nil { + return + } -// part is a copyable representation of a multipart.Part -type part struct { - header textproto.MIMEHeader - body []byte + switch contentType { + case contentTypeMultipartMixed: + email.TextBody, email.HTMLBody, email.Attachments, email.EmbeddedFiles, err = parseMultipartMixed(msg.Body, params["boundary"]) + case contentTypeMultipartAlternative: + email.TextBody, email.HTMLBody, email.EmbeddedFiles, err = parseMultipartAlternative(msg.Body, params["boundary"]) + case contentTypeTextPlain: + message, _ := ioutil.ReadAll(msg.Body) + email.TextBody = strings.TrimSuffix(string(message[:]), "\n") + case contentTypeTextHtml: + message, _ := ioutil.ReadAll(msg.Body) + email.HTMLBody = strings.TrimSuffix(string(message[:]), "\n") + default: + err = fmt.Errorf("Unknown top level mime type: %s", contentType) + } + + return } -// NewEmail creates an Email, and returns the pointer to it. -func NewEmail() *Email { - return &Email{Headers: textproto.MIMEHeader{}} -} +func createEmailFromHeader(header mail.Header) (email Email, err error) { + hp := headerParser{header: &header} -// trimReader is a custom io.Reader that will trim any leading -// whitespace, as this can cause email imports to fail. -type trimReader struct { - rd io.Reader -} + email.Subject = decodeMimeSentence(header.Get("Subject")) + email.From = hp.parseAddressList(header.Get("From")) + email.Sender = hp.parseAddress(header.Get("Sender")) + email.ReplyTo = hp.parseAddressList(header.Get("Reply-To")) + email.To = hp.parseAddressList(header.Get("To")) + email.Cc = hp.parseAddressList(header.Get("Cc")) + email.Bcc = hp.parseAddressList(header.Get("Bcc")) + email.Date = hp.parseTime(header.Get("Date")) + email.ResentFrom = hp.parseAddressList(header.Get("Resent-From")) + email.ResentSender = hp.parseAddress(header.Get("Resent-Sender")) + email.ResentTo = hp.parseAddressList(header.Get("Resent-To")) + email.ResentCc = hp.parseAddressList(header.Get("Resent-Cc")) + email.ResentBcc = hp.parseAddressList(header.Get("Resent-Bcc")) + email.ResentMessageID = hp.parseMessageId(header.Get("Resent-Message-ID")) + email.MessageID = hp.parseMessageId(header.Get("Message-ID")) + email.InReplyTo = hp.parseMessageIdList(header.Get("In-Reply-To")) + email.References = hp.parseMessageIdList(header.Get("References")) + email.ResentDate = hp.parseTime(header.Get("Resent-Date")) -// Read trims off any unicode whitespace from the originating reader -func (tr trimReader) Read(buf []byte) (int, error) { - n, err := tr.rd.Read(buf) - t := bytes.TrimLeftFunc(buf[:n], unicode.IsSpace) - n = copy(buf, t) - return n, err -} + if hp.err != nil { + err = hp.err + return + } -// NewEmailFromReader reads a stream of bytes from an io.Reader, r, -// and returns an email struct containing the parsed data. -// This function expects the data in RFC 5322 format. -func NewEmailFromReader(r io.Reader) (*Email, error) { - e := NewEmail() - s := trimReader{rd: r} - tp := textproto.NewReader(bufio.NewReader(s)) - // Parse the main headers - hdrs, err := tp.ReadMIMEHeader() + //decode whole header for easier access to extra fields + //todo: should we decode? aren't only standard fields mime encoded? + email.Header, err = decodeHeaderMime(header) if err != nil { - return e, err + return } - // Set the subject, to, cc, bcc, and from - for h, v := range hdrs { - switch { - case h == "Subject": - e.Subject = v[0] - subj, err := (&mime.WordDecoder{}).DecodeHeader(e.Subject) - if err == nil && len(subj) > 0 { - e.Subject = subj - } - delete(hdrs, h) - case h == "To": - tt, err := (&mime.WordDecoder{}).DecodeHeader(v[0]) - if err == nil { - e.To = strings.Split(tt, ",") - } else { - e.To = strings.Split(v[0], ",") - } - delete(hdrs, h) - case h == "Cc": - tcc, err := (&mime.WordDecoder{}).DecodeHeader(v[0]) - if err == nil { - e.Cc = strings.Split(tcc, ",") - } else { - e.Cc = strings.Split(v[0], ",") - } - delete(hdrs, h) - case h == "Bcc": - tbcc, err := (&mime.WordDecoder{}).DecodeHeader(v[0]) - if err == nil { - e.Bcc = strings.Split(tbcc, ",") - } else { - e.Bcc = strings.Split(v[0], ",") - } - delete(hdrs, h) - case h == "From": - e.From = v[0] - fr, err := (&mime.WordDecoder{}).DecodeHeader(e.From) - if err == nil && len(fr) > 0 { - e.From = fr - } - delete(hdrs, h) - } + + return +} + +func parseContentType(contentTypeHeader string) (contentType string, params map[string]string, err error) { + if contentTypeHeader == "" { + contentType = contentTypeTextPlain + return } - e.Headers = hdrs - body := tp.R - // Recursively parse the MIME parts - ps, err := parseMIMEParts(e.Headers, body) - if err != nil { - return e, err - } - for _, p := range ps { - if ct := p.header.Get("Content-Type"); ct == "" { - return e, ErrMissingContentType + + return mime.ParseMediaType(contentTypeHeader) +} + +func parseMultipartRelated(msg io.Reader, boundary string) (textBody, htmlBody string, embeddedFiles []EmbeddedFile, err error) { + pmr := multipart.NewReader(msg, boundary) + for { + part, err := pmr.NextPart() + + if err == io.EOF { + break + } else if err != nil { + return textBody, htmlBody, embeddedFiles, err } - ct, _, err := mime.ParseMediaType(p.header.Get("Content-Type")) + + contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) if err != nil { - return e, err + return textBody, htmlBody, embeddedFiles, err } - switch { - case ct == "text/plain": - body := p.body - if p.header.Get("Content-Transfer-Encoding") == "base64" { - body, err = base64.StdEncoding.DecodeString(string(p.body)) + + switch contentType { + case contentTypeTextPlain: + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + textBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeTextHtml: + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeMultipartAlternative: + tb, hb, ef, err := parseMultipartAlternative(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + htmlBody += hb + textBody += tb + embeddedFiles = append(embeddedFiles, ef...) + default: + if isEmbeddedFile(part) { + ef, err := decodeEmbeddedFile(part) if err != nil { - return e, err + return textBody, htmlBody, embeddedFiles, err } + + embeddedFiles = append(embeddedFiles, ef) + } else { + return textBody, htmlBody, embeddedFiles, fmt.Errorf("Can't process multipart/related inner mime type: %s", contentType) } - e.Text = body - case ct == "text/html": - e.HTML = p.body } } - return e, nil + + return textBody, htmlBody, embeddedFiles, err } -// parseMIMEParts will recursively walk a MIME entity and return a []mime.Part containing -// each (flattened) mime.Part found. -// It is important to note that there are no limits to the number of recursions, so be -// careful when parsing unknown MIME structures! -func parseMIMEParts(hs textproto.MIMEHeader, b io.Reader) ([]*part, error) { - var ps []*part - // If no content type is given, set it to the default - if _, ok := hs["Content-Type"]; !ok { - hs.Set("Content-Type", defaultContentType) - } - ct, params, err := mime.ParseMediaType(hs.Get("Content-Type")) - if err != nil { - return ps, err - } - // If it's a multipart email, recursively parse the parts - if strings.HasPrefix(ct, "multipart/") { - if _, ok := params["boundary"]; !ok { - return ps, ErrMissingBoundary +func parseMultipartAlternative(msg io.Reader, boundary string) (textBody, htmlBody string, embeddedFiles []EmbeddedFile, err error) { + pmr := multipart.NewReader(msg, boundary) + for { + part, err := pmr.NextPart() + + if err == io.EOF { + break + } else if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + return textBody, htmlBody, embeddedFiles, err } - mr := multipart.NewReader(b, params["boundary"]) - for { - var buf bytes.Buffer - p, err := mr.NextPart() - if err == io.EOF { - break - } + + switch contentType { + case contentTypeTextPlain: + ppContent, err := ioutil.ReadAll(part) if err != nil { - return ps, err + return textBody, htmlBody, embeddedFiles, err } - if _, ok := p.Header["Content-Type"]; !ok { - p.Header.Set("Content-Type", defaultContentType) + + textBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeTextHtml: + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err } - subct, _, err := mime.ParseMediaType(p.Header.Get("Content-Type")) + + htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeMultipartRelated: + tb, hb, ef, err := parseMultipartRelated(part, params["boundary"]) if err != nil { - return ps, err + return textBody, htmlBody, embeddedFiles, err } - if strings.HasPrefix(subct, "multipart/") { - sps, err := parseMIMEParts(p.Header, p) + + htmlBody += hb + textBody += tb + embeddedFiles = append(embeddedFiles, ef...) + default: + if isEmbeddedFile(part) { + ef, err := decodeEmbeddedFile(part) if err != nil { - return ps, err + return textBody, htmlBody, embeddedFiles, err } - ps = append(ps, sps...) + + embeddedFiles = append(embeddedFiles, ef) } else { - var reader io.Reader - reader = p - const cte = "Content-Transfer-Encoding" - if p.Header.Get(cte) == "base64" { - reader = base64.NewDecoder(base64.StdEncoding, reader) - } - // Otherwise, just append the part to the list - // Copy the part data into the buffer - if _, err := io.Copy(&buf, reader); err != nil { - return ps, err - } - ps = append(ps, &part{body: buf.Bytes(), header: p.Header}) + return textBody, htmlBody, embeddedFiles, fmt.Errorf("Can't process multipart/alternative inner mime type: %s", contentType) } } - } else { - // If it is not a multipart email, parse the body content as a single "part" - var buf bytes.Buffer - if _, err := io.Copy(&buf, b); err != nil { - return ps, err - } - ps = append(ps, &part{body: buf.Bytes(), header: hs}) } - return ps, nil -} -// Attach is used to attach content from an io.Reader to the email. -// Required parameters include an io.Reader, the desired filename for the attachment, and the Content-Type -// The function will return the created Attachment for reference, as well as nil for the error, if successful. -func (e *Email) Attach(r io.Reader, filename string, c string) (a *Attachment, err error) { - var buffer bytes.Buffer - if _, err = io.Copy(&buffer, r); err != nil { - return - } - at := &Attachment{ - Filename: filename, - Header: textproto.MIMEHeader{}, - Content: buffer.Bytes(), - } - // Get the Content-Type to be used in the MIMEHeader - if c != "" { - at.Header.Set("Content-Type", c) - } else { - // If the Content-Type is blank, set the Content-Type to "application/octet-stream" - at.Header.Set("Content-Type", "application/octet-stream") - } - at.Header.Set("Content-Disposition", fmt.Sprintf("attachment;\r\n filename=\"%s\"", filename)) - at.Header.Set("Content-ID", fmt.Sprintf("<%s>", filename)) - at.Header.Set("Content-Transfer-Encoding", "base64") - e.Attachments = append(e.Attachments, at) - return at, nil + return textBody, htmlBody, embeddedFiles, err } -// AttachFile is used to attach content to the email. -// It attempts to open the file referenced by filename and, if successful, creates an Attachment. -// This Attachment is then appended to the slice of Email.Attachments. -// The function will then return the Attachment for reference, as well as nil for the error, if successful. -func (e *Email) AttachFile(filename string) (a *Attachment, err error) { - f, err := os.Open(filename) - if err != nil { - return - } - defer f.Close() - - ct := mime.TypeByExtension(filepath.Ext(filename)) - basename := filepath.Base(filename) - return e.Attach(f, basename, ct) -} - -// msgHeaders merges the Email's various fields and custom headers together in a -// standards compliant way to create a MIMEHeader to be used in the resulting -// message. It does not alter e.Headers. -// -// "e"'s fields To, Cc, From, Subject will be used unless they are present in -// e.Headers. Unless set in e.Headers, "Date" will filled with the current time. -func (e *Email) msgHeaders() (textproto.MIMEHeader, error) { - res := make(textproto.MIMEHeader, len(e.Headers)+4) - if e.Headers != nil { - for _, h := range []string{"Reply-To", "To", "Cc", "From", "Subject", "Date", "Message-Id", "MIME-Version"} { - if v, ok := e.Headers[h]; ok { - res[h] = v - } +func parseMultipartMixed(msg io.Reader, boundary string) (textBody, htmlBody string, attachments []Attachment, embeddedFiles []EmbeddedFile, err error) { + mr := multipart.NewReader(msg, boundary) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err } - } - // Set headers if there are values. - if _, ok := res["Reply-To"]; !ok && len(e.ReplyTo) > 0 { - res.Set("Reply-To", strings.Join(e.ReplyTo, ", ")) - } - if _, ok := res["To"]; !ok && len(e.To) > 0 { - res.Set("To", strings.Join(e.To, ", ")) - } - if _, ok := res["Cc"]; !ok && len(e.Cc) > 0 { - res.Set("Cc", strings.Join(e.Cc, ", ")) - } - if _, ok := res["Subject"]; !ok && e.Subject != "" { - res.Set("Subject", e.Subject) - } - if _, ok := res["Message-Id"]; !ok { - id, err := generateMessageID() + + contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) if err != nil { - return nil, err + return textBody, htmlBody, attachments, embeddedFiles, err } - res.Set("Message-Id", id) - } - // Date and From are required headers. - if _, ok := res["From"]; !ok { - res.Set("From", e.From) - } - if _, ok := res["Date"]; !ok { - res.Set("Date", time.Now().Format(time.RFC1123Z)) - } - if _, ok := res["MIME-Version"]; !ok { - res.Set("MIME-Version", "1.0") - } - for field, vals := range e.Headers { - if _, ok := res[field]; !ok { - res[field] = vals + + if contentType == contentTypeMultipartAlternative { + textBody, htmlBody, embeddedFiles, err = parseMultipartAlternative(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + } else if contentType == contentTypeMultipartRelated { + textBody, htmlBody, embeddedFiles, err = parseMultipartRelated(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + } else if isAttachment(part) { + at, err := decodeAttachment(part) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + + attachments = append(attachments, at) + } else { + return textBody, htmlBody, attachments, embeddedFiles, fmt.Errorf("Unknown multipart/mixed nested mime type: %s", contentType) } } - return res, nil + + return textBody, htmlBody, attachments, embeddedFiles, err } -func writeMessage(buff io.Writer, msg []byte, multipart bool, mediaType string, w *multipart.Writer) error { - if multipart { - header := textproto.MIMEHeader{ - "Content-Type": {mediaType + "; charset=UTF-8"}, - "Content-Transfer-Encoding": {"quoted-printable"}, - } - if _, err := w.CreatePart(header); err != nil { - return err +func decodeMimeSentence(s string) string { + result := []string{} + ss := strings.Split(s, " ") + + for _, word := range ss { + dec := new(mime.WordDecoder) + w, err := dec.Decode(word) + if err != nil { + if len(result) == 0 { + w = word + } else { + w = " " + word + } } - } - qp := quotedprintable.NewWriter(buff) - // Write the text - if _, err := qp.Write(msg); err != nil { - return err + result = append(result, w) } - return qp.Close() + + return strings.Join(result, "") } -// Bytes converts the Email object to a []byte representation, including all needed MIMEHeaders, boundaries, etc. -func (e *Email) Bytes() ([]byte, error) { - // TODO: better guess buffer size - buff := bytes.NewBuffer(make([]byte, 0, 4096)) +func decodeHeaderMime(header mail.Header) (mail.Header, error) { + parsedHeader := map[string][]string{} - headers, err := e.msgHeaders() - if err != nil { - return nil, err - } + for headerName, headerData := range header { - var ( - isMixed = len(e.Attachments) > 0 - isAlternative = len(e.Text) > 0 && len(e.HTML) > 0 - ) + parsedHeaderData := []string{} + for _, headerValue := range headerData { + parsedHeaderData = append(parsedHeaderData, decodeMimeSentence(headerValue)) + } - var w *multipart.Writer - if isMixed || isAlternative { - w = multipart.NewWriter(buff) + parsedHeader[headerName] = parsedHeaderData } - switch { - case isMixed: - headers.Set("Content-Type", "multipart/mixed;\r\n boundary="+w.Boundary()) - case isAlternative: - headers.Set("Content-Type", "multipart/alternative;\r\n boundary="+w.Boundary()) - case len(e.HTML) > 0: - headers.Set("Content-Type", "text/html; charset=UTF-8") - headers.Set("Content-Transfer-Encoding", "quoted-printable") - default: - headers.Set("Content-Type", "text/plain; charset=UTF-8") - headers.Set("Content-Transfer-Encoding", "quoted-printable") - } - headerToBytes(buff, headers) - _, err = io.WriteString(buff, "\r\n") - if err != nil { - return nil, err - } + + return mail.Header(parsedHeader), nil +} - // Check to see if there is a Text or HTML field - if len(e.Text) > 0 || len(e.HTML) > 0 { - var subWriter *multipart.Writer +func decodePartData(part *multipart.Part) (io.Reader, error) { + encoding := part.Header.Get("Content-Transfer-Encoding") - if isMixed && isAlternative { - // Create the multipart alternative part - subWriter = multipart.NewWriter(buff) - header := textproto.MIMEHeader{ - "Content-Type": {"multipart/alternative;\r\n boundary=" + subWriter.Boundary()}, - } - if _, err := w.CreatePart(header); err != nil { - return nil, err - } - } else { - subWriter = w - } - // Create the body sections - if len(e.Text) > 0 { - // Write the text - if err := writeMessage(buff, e.Text, isMixed || isAlternative, "text/plain", subWriter); err != nil { - return nil, err - } - } - if len(e.HTML) > 0 { - // Write the HTML - if err := writeMessage(buff, e.HTML, isMixed || isAlternative, "text/html", subWriter); err != nil { - return nil, err - } - } - if isMixed && isAlternative { - if err := subWriter.Close(); err != nil { - return nil, err - } - } - } - // Create attachment part, if necessary - for _, a := range e.Attachments { - ap, err := w.CreatePart(a.Header) + if strings.EqualFold(encoding, "base64") { + dr := base64.NewDecoder(base64.StdEncoding, part) + dd, err := ioutil.ReadAll(dr) if err != nil { return nil, err } - // Write the base64Wrapped content to the part - base64Wrap(ap, a.Content) + + return bytes.NewReader(dd), nil } - if isMixed || isAlternative { - if err := w.Close(); err != nil { - return nil, err - } - } - return buff.Bytes(), nil + + return nil, fmt.Errorf("Unknown encoding: %s", encoding) +} + +func isEmbeddedFile(part *multipart.Part) bool { + return part.Header.Get("Content-Transfer-Encoding") != "" } -// Send an email using the given host and SMTP auth (optional), returns any error thrown by smtp.SendMail -// This function merges the To, Cc, and Bcc fields and calls the smtp.SendMail function using the Email.Bytes() output as the message -func (e *Email) Send(addr string, a smtp.Auth) error { - // Check to make sure there is at least one recipient and one "From" address - sender, err := e.parseSender() +func decodeEmbeddedFile(part *multipart.Part) (ef EmbeddedFile, err error) { + cid := decodeMimeSentence(part.Header.Get("Content-Id")) + decoded, err := decodePartData(part) if err != nil { - return err + return } - raw, err := e.Bytes() + + ef.CID = strings.Trim(cid, "<>") + ef.Data = decoded + ef.ContentType = part.Header.Get("Content-Type") + + return +} + +func isAttachment(part *multipart.Part) bool { + return part.FileName() != "" +} + +func decodeAttachment(part *multipart.Part) (at Attachment, err error) { + filename := decodeMimeSentence(part.FileName()) + decoded, err := decodePartData(part) if err != nil { - return err + return } - return smtp.SendMail(addr, a, sender, e.Recipients, raw) + + at.Filename = filename + at.Data = decoded + at.ContentType = strings.Split(part.Header.Get("Content-Type"), ";")[0] + + return } -// Select and parse an SMTP envelope sender address. Choose Email.Sender if set, or fallback to Email.From. -func (e *Email) parseSender() (string, error) { - if e.Sender != "" { - sender, err := mail.ParseAddress(e.Sender) - if err != nil { - return "", err - } - return sender.Address, nil - } else { - from, err := mail.ParseAddress(e.From) - if err != nil { - return "", err - } - return from.Address, nil - } +type headerParser struct { + header *mail.Header + err error } -// SendWithTLS sends an email with an optional TLS config. -// This is helpful if you need to connect to a host that is used an untrusted -// certificate. -func (e *Email) SendWithTLS(addr string, a smtp.Auth, t *tls.Config) error { - // Merge the To, Cc, and Bcc fields - to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc)) - to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) - for i := 0; i < len(to); i++ { - addr, err := mail.ParseAddress(to[i]) - if err != nil { - return err - } - to[i] = addr.Address +func (hp headerParser) parseAddress(s string) (ma *mail.Address) { + if hp.err != nil { + return nil } - // Check to make sure there is at least one recipient and one "From" address - if e.From == "" || len(to) == 0 { - return errors.New("Must specify at least one From address and one To address") + + if strings.Trim(s, " \n") != "" { + ma, hp.err = mail.ParseAddress(s) + + return ma } - sender, err := e.parseSender() - if err != nil { - return err + + return nil +} + +func (hp headerParser) parseAddressList(s string) (ma []*mail.Address) { + if hp.err != nil { + return } - raw, err := e.Bytes() - if err != nil { - return err + + if strings.Trim(s, " \n") != "" { + ma, hp.err = mail.ParseAddressList(s) + return } - conn, err := tls.Dial("tcp", addr, t) - if err != nil { - return err + return +} + +func (hp headerParser) parseTime(s string) (t time.Time) { + if hp.err != nil || s == "" { + return } - c, err := smtp.NewClient(conn, t.ServerName) - if err != nil { - return err - } - defer c.Close() - if err = c.Hello("localhost"); err != nil { - return err + t, hp.err = time.Parse(time.RFC1123Z, s) + if hp.err == nil { + return t } - // Use TLS if available - if ok, _ := c.Extension("STARTTLS"); ok { - if err = c.StartTLS(t); err != nil { - return err - } - } + + t, hp.err = time.Parse("Mon, 2 Jan 2006 15:04:05 -0700", s) + + return +} - if a != nil { - if ok, _ := c.Extension("AUTH"); ok { - if err = c.Auth(a); err != nil { - return err - } - } +func (hp headerParser) parseMessageId(s string) string { + if hp.err != nil { + return "" } - if err = c.Mail(sender); err != nil { - return err + + return strings.Trim(s, "<> ") +} + +func (hp headerParser) parseMessageIdList(s string) (result []string) { + if hp.err != nil { + return } - for _, addr := range to { - if err = c.Rcpt(addr); err != nil { - return err + + for _, p := range strings.Split(s, " ") { + if strings.Trim(p, " \n") != "" { + result = append(result, hp.parseMessageId(p)) } } - w, err := c.Data() - if err != nil { - return err - } - _, err = w.Write(raw) - if err != nil { - return err - } - err = w.Close() - if err != nil { - return err - } - return c.Quit() + + return } -// Attachment is a struct representing an email attachment. -// Based on the mime/multipart.FileHeader struct, Attachment contains the name, MIMEHeader, and content of the attachment in question +// Attachment with filename, content type and data (as a io.Reader) type Attachment struct { - Filename string - Header textproto.MIMEHeader - Content []byte + Filename string + ContentType string + Data io.Reader } -// base64Wrap encodes the attachment content, and wraps it according to RFC 2045 standards (every 76 chars) -// The output is then written to the specified io.Writer -func base64Wrap(w io.Writer, b []byte) { - // 57 raw bytes per 76-byte base64 line. - const maxRaw = 57 - // Buffer for each line, including trailing CRLF. - buffer := make([]byte, MaxLineLength+len("\r\n")) - copy(buffer[MaxLineLength:], "\r\n") - // Process raw chunks until there's no longer enough to fill a line. - for len(b) >= maxRaw { - base64.StdEncoding.Encode(buffer, b[:maxRaw]) - w.Write(buffer) - b = b[maxRaw:] - } - // Handle the last chunk of bytes. - if len(b) > 0 { - out := buffer[:base64.StdEncoding.EncodedLen(len(b))] - base64.StdEncoding.Encode(out, b) - out = append(out, "\r\n"...) - w.Write(out) - } +// EmbeddedFile with content id, content type and data (as a io.Reader) +type EmbeddedFile struct { + CID string + ContentType string + Data io.Reader } -// headerToBytes renders "header" to "buff". If there are multiple values for a -// field, multiple "Field: value\r\n" lines will be emitted. -func headerToBytes(buff io.Writer, header textproto.MIMEHeader) { - for field, vals := range header { - for _, subval := range vals { - // bytes.Buffer.Write() never returns an error. - io.WriteString(buff, field) - io.WriteString(buff, ": ") - // Write the encoded header if needed - switch { - case field == "Content-Type" || field == "Content-Disposition": - buff.Write([]byte(subval)) - default: - buff.Write([]byte(mime.QEncoding.Encode("UTF-8", subval))) - } - io.WriteString(buff, "\r\n") - } - } -} +// Email with fields for all the headers defined in RFC5322 with it's attachments and +type Email struct { + Header mail.Header + + Subject string + Sender *mail.Address + From []*mail.Address + ReplyTo []*mail.Address + To []*mail.Address + Cc []*mail.Address + Bcc []*mail.Address + Date time.Time + MessageID string + InReplyTo []string + References []string + + ResentFrom []*mail.Address + ResentSender *mail.Address + ResentTo []*mail.Address + ResentDate time.Time + ResentCc []*mail.Address + ResentBcc []*mail.Address + ResentMessageID string -var maxBigInt = big.NewInt(math.MaxInt64) + HTMLBody string + TextBody string -// generateMessageID generates and returns a string suitable for an RFC 2822 -// compliant Message-ID, e.g.: -// <1444789264909237300.3464.1819418242800517193@DESKTOP01> -// -// The following parameters are used to generate a Message-ID: -// - The nanoseconds since Epoch -// - The calling PID -// - A cryptographically random int64 -// - The sending hostname -func generateMessageID() (string, error) { - t := time.Now().UnixNano() - pid := os.Getpid() - rint, err := rand.Int(rand.Reader, maxBigInt) - if err != nil { - return "", err - } - h, err := os.Hostname() - // If we can't get the hostname, we'll use localhost - if err != nil { - h = "localhost.localdomain" - } - msgid := fmt.Sprintf("<%d.%d.%d@%s>", t, pid, rint, h) - return msgid, nil + Attachments []Attachment + EmbeddedFiles []EmbeddedFile }
@@ -64,27 +64,22 @@
requireLog() if flag.Arg(0) == "message" { - msg := email.NewEmail() - msg, err := email.NewEmailFromReader(bufio.NewReader(os.Stdin)) + msg, err := parsemail.Parse(bufio.NewReader(os.Stdin)) if err != nil { log.Printf("ERROR_PARSING_MESSAGE Error=%q\n", err.Error()) os.Exit(0) } log.Printf("MESSAGE_RECEIVED From=%q To=%q Cc=%q Bcc=%q Subject=%q\n", msg.From, msg.To, msg.Cc, msg.Bcc, msg.Subject) - handleMessage(msg) + handleMessage(&msg) } else { fmt.Printf("Unknown command %s\n", flag.Arg(0)) } } -func checkAddress(addrs []string, checkAddr string) bool { - for _, to := range addrs { - t, err := mail.ParseAddress(to) - if err != nil { - log.Printf("checkAddress: failed to parse address") - } - if t.Address == checkAddr { +func checkAddress(addrs []*mail.Address, checkAddr string) bool { + for _, a := range addrs { + if a.Address == checkAddr { return true } }@@ -92,7 +87,7 @@ return false
} // Figure out if this is a command, or a mailing list post -func handleMessage(msg *email.Email) { +func handleMessage(msg *parsemail.Email) { if checkAddress(msg.To, gConfig.CommandAddress) { handleCommand(msg) } else {@@ -107,7 +102,7 @@
log.Printf("matchedLists: %q", matchedLists) if len(matchedLists) == 1 { list := matchedLists[0] - if list.CanPost(msg.From) { + if list.CanPost(msg.Sender) { msg := buildListEmail(msg, list) send(msg) log.Printf("MESSAGE_SENT ListId=%q",@@ -149,7 +144,7 @@ return subject
} // Handle the command given by the user -func handleCommand(msg *email.Email) { +func handleCommand(msg *parsemail.Email) { switch subjectParser(msg.Subject) { case "lists": handleShowLists(msg)@@ -165,7 +160,7 @@ }
} // Reply to a message that has nowhere to go -func handleNoDestination(msg *email.Email) { +func handleNoDestination(msg *parsemail.Email) { var body bytes.Buffer fmt.Fprintf(&body, "No mailing lists addressed. Your message has not been delivered.\r\n") reply := buildCommandEmail(msg, body)@@ -174,7 +169,7 @@ log.Printf("UNKNOWN_DESTINATION From=%q To=%q Cc=%q Bcc=%q", msg.From, msg.To, msg.Cc, msg.Bcc)
} // Reply that the user isn't authorised to post to the list -func handleNotAuthorisedToPost(msg *email.Email) { +func handleNotAuthorisedToPost(msg *parsemail.Email) { var body bytes.Buffer fmt.Fprintf(&body, "You are not an approved poster for this mailing list. Your message has not been delivered.\r\n") reply := buildCommandEmail(msg, body)@@ -183,7 +178,7 @@ log.Printf("UNAUTHORISED_POST From=%q To=%q Cc=%q Bcc=%q", msg.From, msg.To, msg.Cc, msg.Bcc)
} // Reply to an unknown command, giving some help -func handleUnknownCommand(msg *email.Email) { +func handleUnknownCommand(msg *parsemail.Email) { var body bytes.Buffer fmt.Fprintf(&body, "%s is not a valid command.\r\n\r\n"+@@ -196,7 +191,7 @@ log.Printf("UNKNOWN_COMMAND From=%q", msg.From)
} // Reply to a help command with help information -func handleHelp(msg *email.Email) { +func handleHelp(msg *parsemail.Email) { var body bytes.Buffer fmt.Fprintf(&body, commandInfo()) reply := buildCommandEmail(msg, body)@@ -205,7 +200,7 @@ log.Printf("HELP_SENT To=%q", reply.To)
} // Reply to a show mailing lists command with a list of mailing lists -func handleShowLists(msg *email.Email) { +func handleShowLists(msg *parsemail.Email) { var body bytes.Buffer fmt.Fprintf(&body, "Available mailing lists\r\n") fmt.Fprintf(&body, "-----------------------\r\n\r\n")@@ -230,7 +225,7 @@ log.Printf("LIST_SENT To=%q", msg.From)
} // Handle a subscribe command -func handleSubscribe(msg *email.Email) { +func handleSubscribe(msg *parsemail.Email) { listId := strings.TrimPrefix(msg.Subject, "Subscribe ") listId = strings.TrimPrefix(listId, "subscribe ") list := lookupList(listId)@@ -241,11 +236,11 @@ os.Exit(0)
} var body bytes.Buffer - if isSubscribed(msg.From, listId) { + if isSubscribed(msg.Sender, listId) { fmt.Fprintf(&body, "You are already subscribed to %s\r\n", listId) log.Printf("DUPLICATE_SUBSCRIPTION_REQUEST User=%q List=%q\n", msg.From, listId) } else { - addSubscription(msg.From, listId) + addSubscription(msg.Sender, listId) fmt.Fprintf(&body, "You are now subscribed to %s\r\n", listId) fmt.Fprintf(&body, "To send a message to this list, send an email to %s\r\n", list.Address) }@@ -254,7 +249,7 @@ send(reply)
} // Handle an unsubscribe command -func handleUnsubscribe(msg *email.Email) { +func handleUnsubscribe(msg *parsemail.Email) { listId := strings.TrimPrefix(msg.Subject, "Unsubscribe ") listId = strings.TrimPrefix(listId, "unsubscribe ") list := lookupList(listId)@@ -265,18 +260,18 @@ os.Exit(0)
} var body bytes.Buffer - if !isSubscribed(msg.From, listId) { + if !isSubscribed(msg.Sender, listId) { fmt.Fprintf(&body, "You aren't subscribed to %s\r\n", listId) log.Printf("DUPLICATE_UNSUBSCRIPTION_REQUEST User=%q List=%q\n", msg.From, listId) } else { - removeSubscription(msg.From, listId) + removeSubscription(msg.Sender, listId) fmt.Fprintf(&body, "You are now unsubscribed from %s\r\n", listId) } reply := buildCommandEmail(msg, body) send(reply) } -func handleInvalidRequest(msg *email.Email, listId string) { +func handleInvalidRequest(msg *parsemail.Email, listId string) { var body bytes.Buffer fmt.Fprintf(&body, "Unable to operate against %s, Invalid mailing list ID.\r\n", listId) reply := buildCommandEmail(msg, body)@@ -284,52 +279,36 @@ send(reply)
log.Printf("INVALID_MAILING_LIST From=%q To=%q Cc=%q Bcc=%q", msg.From, msg.To, msg.Cc, msg.Bcc) } -func badAddress(recipient string, e *email.Email) bool { - // From + all lists should never be recipients (loop prevention) - badAddresses := []string{} - +func badAddress(recipient string, e *parsemail.Email) bool { for _, list := range gConfig.Lists { - badAddresses = append(badAddresses, list.Address) - } - - // We are a bad address if we are part of the list - for _, ba := range badAddresses { - if recipient == ba { + // We are a bad address if we are part of the list + if recipient == list.Address { return true } } // We are a bad address if we are already in to/cc - for _, tocc := range append(e.To, e.Cc...) { - addr, err := mail.ParseAddress(tocc) - if err != nil { - log.Println("badAddress: Error parsing address") - log.Println(tocc) - } - if recipient == addr.Address { + for _, a := range append(append(e.To, e.Cc...), e.Bcc...) { + if recipient == a.Address { return true } } return false } -func buildCommandEmail(e *email.Email, t bytes.Buffer) *email.Email { - from, err := mail.ParseAddress(e.From) - if err != nil { - log.Printf("WARN: CommandEmail: couldn't parse from address") - } - - email := email.NewEmail() - email.Sender = gConfig.CommandAddress - email.From = "<" + gConfig.CommandAddress + ">" - email.To = []string{from.Name + "<" + from.Address + ">"} - email.Recipients = []string{from.Address} - email.Subject = "Re: " + e.Subject - email.Text = []byte(t.String()) - email.Headers["Date"] = []string{time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700")} - email.Headers["Precedence"] = []string{"list"} - email.Headers["List-Help"] = []string{"<mailto:" + gConfig.CommandAddress + "?subject=help>"} - return email +func buildCommandEmail(e *parsemail.Email, t bytes.Buffer) *parsemail.Email { + response := parsemail.Email{} + response.Sender = &mail.Address{"", gConfig.CommandAddress} + var from []*mail.Address + response.From = append(from, &mail.Address{"", gConfig.CommandAddress}) + response.To = e.From + response.Bcc = e.From + response.Subject = "Re: " + e.Subject + response.TextBody = t.String() + response.Header["Date"] = []string{time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700")} + response.Header["Precedence"] = []string{"list"} + response.Header["List-Help"] = []string{"<mailto:" + gConfig.CommandAddress + "?subject=help>"} + return &response } func lookupList(l string) *List {@@ -341,47 +320,47 @@ }
return nil } -func buildListEmail(e *email.Email, l *List) *email.Email { +func buildListEmail(e *parsemail.Email, l *List) *parsemail.Email { // Build recipient list, stripping garbage - recipients := []string{} + var recipients []*mail.Address for _, subscriber := range fetchSubscribers(l.Id) { if !badAddress(subscriber, e) { - recipients = append(recipients, subscriber) + recipients = append(recipients, &mail.Address{"", subscriber}) } } - newEmail := email.NewEmail() - newEmail.Sender = l.Address - newEmail.From = e.From - newEmail.To = e.To - newEmail.Cc = e.Cc - newEmail.Recipients = recipients - newEmail.Subject = e.Subject - newEmail.Text = e.Text - newEmail.Headers["Return-Path"] = []string{"bounce-" + l.Address} - newEmail.Headers["Date"] = e.Headers["Date"] - newEmail.Headers["Reply-To"] = []string{e.From} - newEmail.Headers["Precedence"] = []string{"list"} - newEmail.Headers["List-Id"] = []string{"<" + strings.Replace(l.Address, "@", ".", -1) + ">"} - newEmail.Headers["List-Post"] = []string{"<mailto:" + l.Address + ">"} - newEmail.Headers["List-Help"] = []string{"<mailto:" + l.Address + "?subject=help>"} - newEmail.Headers["List-Subscribe"] = []string{"<mailto:" + gConfig.CommandAddress + "?subject=subscribe%20" + l.Id + ">"} - newEmail.Headers["List-Unsubscribe"] = []string{"<mailto:" + gConfig.CommandAddress + "?subject=unsubscribe%20" + l.Id + ">"} - newEmail.Headers["List-Archive"] = []string{"<" + l.Archive + ">"} - newEmail.Headers["List-Owner"] = []string{"<" + l.Owner + ">"} - return newEmail + post := e + post.Sender = &mail.Address{"", l.Address} + post.Bcc = recipients + post.Header["Return-Path"] = []string{"bounce-" + l.Address} + post.Header["Date"] = e.Header["Date"] // RFC 1123 + post.Header["Reply-To"] = []string{e.Sender.Address} + post.Header["Precedence"] = []string{"list"} + post.Header["List-Id"] = []string{"<" + strings.Replace(l.Address, "@", ".", -1) + ">"} + post.Header["List-Post"] = []string{"<mailto:" + l.Address + ">"} + post.Header["List-Help"] = []string{"<mailto:" + l.Address + "?subject=help>"} + post.Header["List-Subscribe"] = []string{"<mailto:" + gConfig.CommandAddress + "?subject=subscribe%20" + l.Id + ">"} + post.Header["List-Unsubscribe"] = []string{"<mailto:" + gConfig.CommandAddress + "?subject=unsubscribe%20" + l.Id + ">"} + post.Header["List-Archive"] = []string{"<" + l.Archive + ">"} + post.Header["List-Owner"] = []string{"<" + l.Owner + ">"} + return post } -func send(e *email.Email) { - log.Printf("MESSAGE:\n") - log.Printf("%q\n", e) - e.Send("mail.c3f.net:587", smtp.PlainAuth("", gConfig.SMTPUsername, gConfig.SMTPPassword, "mail.c3f.net")) +func send(e *parsemail.Email) { + // Bcc = recipients + var recipients []string + for _, a := range e.Bcc { + recipients = append(recipients, a.Address) + } + + auth := smtp.PlainAuth("", gConfig.SMTPUsername, gConfig.SMTPPassword, "mail.c3f.net") + smtp.SendMail("mail.c3f.net:587", auth, e.Sender.Address, recipients, []byte(e.TextBody)) } // MAILING LIST LOGIC ///////////////////////////////////////////////////////// // Check if the user is authorised to post to this mailing list -func (list *List) CanPost(from string) bool { +func (list *List) CanPost(from *mail.Address) bool { // Is this list restricted to subscribers only? if list.SubscribersOnly && !isSubscribed(from, list.Id) {@@ -391,7 +370,7 @@
// Is there a whitelist of approved posters? if len(list.Posters) > 0 { for _, poster := range list.Posters { - if from == poster { + if from.Address == poster { return true } }@@ -453,16 +432,11 @@ return listIds
} // Check if a user is subscribed to a mailing list -func isSubscribed(user string, list string) bool { - addressObj, err := mail.ParseAddress(user) - if err != nil { - log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) - os.Exit(0) - } +func isSubscribed(sender *mail.Address, list string) bool { db := requireDB() exists := false - err = db.QueryRow("SELECT 1 FROM subscriptions WHERE user=? AND list=?", addressObj.Address, list).Scan(&exists) + err := db.QueryRow("SELECT 1 FROM subscriptions WHERE user=? AND list=?", sender.Address, list).Scan(&exists) if err == sql.ErrNoRows { return false@@ -475,37 +449,25 @@ return true
} // Add a subscription to the subscription database -func addSubscription(user string, list string) { - addressObj, err := mail.ParseAddress(user) - if err != nil { - log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) - os.Exit(0) - } - +func addSubscription(sender *mail.Address, list string) { db := requireDB() - _, err = db.Exec("INSERT INTO subscriptions (user,list) VALUES(?,?)", addressObj.Address, list) + _, err := db.Exec("INSERT INTO subscriptions (user,list) VALUES(?,?)", sender.Address, list) if err != nil { log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) os.Exit(0) } - log.Printf("SUBSCRIPTION_ADDED User=%q List=%q\n", user, list) + log.Printf("SUBSCRIPTION_ADDED Sender=%q List=%q\n", sender, list) } // Remove a subscription from the subscription database -func removeSubscription(user string, list string) { - addressObj, err := mail.ParseAddress(user) - if err != nil { - log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) - os.Exit(0) - } - +func removeSubscription(sender *mail.Address, list string) { db := requireDB() - _, err = db.Exec("DELETE FROM subscriptions WHERE user=? AND list=?", addressObj.Address, list) + _, err := db.Exec("DELETE FROM subscriptions WHERE user=? AND list=?", sender.Address, list) if err != nil { log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) os.Exit(0) } - log.Printf("SUBSCRIPTION_REMOVED User=%q List=%q\n", user, list) + log.Printf("SUBSCRIPTION_REMOVED Sender=%q List=%q\n", sender, list) } // HELPER FUNCTIONS ///////////////////////////////////////////////////////////