diff --git a/README.md b/README.md index d9b4e07f..260b8881 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Some of the features of this library: * [X] Debug logging of SMTP traffic * [X] Custom error types for delivery errors * [X] Custom dial-context functions for more control over the connection (proxing, DNS hooking, etc.) +* [X] Output a go-mail message as EML file and parse EML file into a go-mail message go-mail works like a programatic email client and provides lots of methods and functionalities you would consider standard in a MUA. diff --git a/doc.go b/doc.go index f38b857d..5f41bc90 100644 --- a/doc.go +++ b/doc.go @@ -6,4 +6,4 @@ package mail // VERSION is used in the default user agent string -const VERSION = "0.4.1" +const VERSION = "0.4.2" diff --git a/eml.go b/eml.go new file mode 100644 index 00000000..ed20d0dc --- /dev/null +++ b/eml.go @@ -0,0 +1,418 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + netmail "net/mail" + "os" + "strings" +) + +// EMLToMsgFromString will parse a given EML string and returns a pre-filled Msg pointer +func EMLToMsgFromString(emlString string) (*Msg, error) { + eb := bytes.NewBufferString(emlString) + return EMLToMsgFromReader(eb) +} + +// EMLToMsgFromReader will parse a reader that holds EML content and returns a pre-filled +// Msg pointer +func EMLToMsgFromReader(reader io.Reader) (*Msg, error) { + msg := &Msg{ + addrHeader: make(map[AddrHeader][]*netmail.Address), + genHeader: make(map[Header][]string), + preformHeader: make(map[Header]string), + mimever: MIME10, + } + + parsedMsg, bodybuf, err := readEMLFromReader(reader) + if err != nil || parsedMsg == nil { + return msg, fmt.Errorf("failed to parse EML from reader: %w", err) + } + + if err := parseEML(parsedMsg, bodybuf, msg); err != nil { + return msg, fmt.Errorf("failed to parse EML contents: %w", err) + } + + return msg, nil +} + +// EMLToMsgFromFile will open and parse a .eml file at a provided file path and returns a +// pre-filled Msg pointer +func EMLToMsgFromFile(filePath string) (*Msg, error) { + msg := &Msg{ + addrHeader: make(map[AddrHeader][]*netmail.Address), + genHeader: make(map[Header][]string), + preformHeader: make(map[Header]string), + mimever: MIME10, + } + + parsedMsg, bodybuf, err := readEML(filePath) + if err != nil || parsedMsg == nil { + return msg, fmt.Errorf("failed to parse EML file: %w", err) + } + + if err := parseEML(parsedMsg, bodybuf, msg); err != nil { + return msg, fmt.Errorf("failed to parse EML contents: %w", err) + } + + return msg, nil +} + +// parseEML parses the EML's headers and body and inserts the parsed values into the Msg +func parseEML(parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *Msg) error { + if err := parseEMLHeaders(&parsedMsg.Header, msg); err != nil { + return fmt.Errorf("failed to parse EML headers: %w", err) + } + if err := parseEMLBodyParts(parsedMsg, bodybuf, msg); err != nil { + return fmt.Errorf("failed to parse EML body parts: %w", err) + } + return nil +} + +// readEML opens an EML file and uses net/mail to parse the header and body +func readEML(filePath string) (*netmail.Message, *bytes.Buffer, error) { + fileHandle, err := os.Open(filePath) + if err != nil { + return nil, nil, fmt.Errorf("failed to open EML file: %w", err) + } + defer func() { + _ = fileHandle.Close() + }() + return readEMLFromReader(fileHandle) +} + +// readEMLFromReader uses net/mail to parse the header and body from a given io.Reader +func readEMLFromReader(reader io.Reader) (*netmail.Message, *bytes.Buffer, error) { + parsedMsg, err := netmail.ReadMessage(reader) + if err != nil { + return parsedMsg, nil, fmt.Errorf("failed to parse EML: %w", err) + } + + buf := bytes.Buffer{} + if _, err = buf.ReadFrom(parsedMsg.Body); err != nil { + return nil, nil, err + } + + return parsedMsg, &buf, nil +} + +// parseEMLHeaders will check the EML headers for the most common headers and set the +// according settings in the Msg +func parseEMLHeaders(mailHeader *netmail.Header, msg *Msg) error { + commonHeaders := []Header{ + HeaderContentType, HeaderImportance, HeaderInReplyTo, HeaderListUnsubscribe, + HeaderListUnsubscribePost, HeaderMessageID, HeaderMIMEVersion, HeaderOrganization, + HeaderPrecedence, HeaderPriority, HeaderReferences, HeaderSubject, HeaderUserAgent, + HeaderXMailer, HeaderXMSMailPriority, HeaderXPriority, + } + + // Extract content type, charset and encoding first + parseEMLEncoding(mailHeader, msg) + parseEMLContentTypeCharset(mailHeader, msg) + + // Extract address headers + if value := mailHeader.Get(HeaderFrom.String()); value != "" { + if err := msg.From(value); err != nil { + return fmt.Errorf(`failed to parse %q header: %w`, HeaderFrom, err) + } + } + addrHeaders := map[AddrHeader]func(...string) error{ + HeaderTo: msg.To, + HeaderCc: msg.Cc, + HeaderBcc: msg.Bcc, + } + for addrHeader, addrFunc := range addrHeaders { + if v := mailHeader.Get(addrHeader.String()); v != "" { + var addrStrings []string + parsedAddrs, err := netmail.ParseAddressList(v) + if err != nil { + return fmt.Errorf(`failed to parse address list: %w`, err) + } + for _, addr := range parsedAddrs { + addrStrings = append(addrStrings, addr.String()) + } + if err = addrFunc(addrStrings...); err != nil { + return fmt.Errorf(`failed to parse %q header: %w`, addrHeader, err) + } + } + } + + // Extract date from message + date, err := mailHeader.Date() + if err != nil { + switch { + case errors.Is(err, netmail.ErrHeaderNotPresent): + msg.SetDate() + default: + return fmt.Errorf("failed to parse EML date: %w", err) + } + } + if err == nil { + msg.SetDateWithValue(date) + } + + // Extract common headers + for _, header := range commonHeaders { + if value := mailHeader.Get(header.String()); value != "" { + if strings.EqualFold(header.String(), HeaderContentType.String()) && + strings.HasPrefix(value, TypeMultipartMixed.String()) { + continue + } + msg.SetGenHeader(header, value) + } + } + + return nil +} + +// parseEMLBodyParts parses the body of a EML based on the different content types and encodings +func parseEMLBodyParts(parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *Msg) error { + // Extract the transfer encoding of the body + mediatype, params, err := mime.ParseMediaType(parsedMsg.Header.Get(HeaderContentType.String())) + if err != nil { + return fmt.Errorf("failed to extract content type: %w", err) + } + if value, ok := params["charset"]; ok { + msg.SetCharset(Charset(value)) + } + + switch { + case strings.EqualFold(mediatype, TypeTextPlain.String()), + strings.EqualFold(mediatype, TypeTextHTML.String()): + if err = parseEMLBodyPlain(mediatype, parsedMsg, bodybuf, msg); err != nil { + return fmt.Errorf("failed to parse plain body: %w", err) + } + case strings.EqualFold(mediatype, TypeMultipartAlternative.String()), + strings.EqualFold(mediatype, TypeMultipartMixed.String()), + strings.EqualFold(mediatype, TypeMultipartRelated.String()): + if err = parseEMLMultipart(params, bodybuf, msg); err != nil { + return fmt.Errorf("failed to parse multipart body: %w", err) + } + default: + return fmt.Errorf("failed to parse body, unknown content type: %s", mediatype) + } + return nil +} + +// parseEMLBodyPlain parses the mail body of plain type mails +func parseEMLBodyPlain(mediatype string, parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *Msg) error { + contentTransferEnc := parsedMsg.Header.Get(HeaderContentTransferEnc.String()) + if strings.EqualFold(contentTransferEnc, NoEncoding.String()) { + msg.SetEncoding(NoEncoding) + msg.SetBodyString(ContentType(mediatype), bodybuf.String()) + return nil + } + if strings.EqualFold(contentTransferEnc, EncodingQP.String()) { + msg.SetEncoding(EncodingQP) + qpReader := quotedprintable.NewReader(bodybuf) + qpBuffer := bytes.Buffer{} + if _, err := qpBuffer.ReadFrom(qpReader); err != nil { + return fmt.Errorf("failed to read quoted-printable body: %w", err) + } + msg.SetBodyString(ContentType(mediatype), qpBuffer.String()) + return nil + } + if strings.EqualFold(contentTransferEnc, EncodingB64.String()) { + msg.SetEncoding(EncodingB64) + b64Decoder := base64.NewDecoder(base64.StdEncoding, bodybuf) + b64Buffer := bytes.Buffer{} + if _, err := b64Buffer.ReadFrom(b64Decoder); err != nil { + return fmt.Errorf("failed to read base64 body: %w", err) + } + msg.SetBodyString(ContentType(mediatype), b64Buffer.String()) + return nil + } + return fmt.Errorf("unsupported Content-Transfer-Encoding") +} + +// parseEMLMultipart parses a multipart body part of a EML +func parseEMLMultipart(params map[string]string, bodybuf *bytes.Buffer, msg *Msg) error { + boundary, ok := params["boundary"] + if !ok { + return fmt.Errorf("no boundary tag found in multipart body") + } + multipartReader := multipart.NewReader(bodybuf, boundary) +ReadNextPart: + multiPart, err := multipartReader.NextPart() + defer func() { + if multiPart != nil { + _ = multiPart.Close() + } + }() + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("failed to get next part of multipart message: %w", err) + } + for err == nil { + // Multipart/related and Multipart/alternative parts need to be parsed seperately + if contentTypeSlice, ok := multiPart.Header[HeaderContentType.String()]; ok && len(contentTypeSlice) == 1 { + contentType, _ := parseMultiPartHeader(contentTypeSlice[0]) + if strings.EqualFold(contentType, TypeMultipartRelated.String()) || + strings.EqualFold(contentType, TypeMultipartAlternative.String()) { + relatedPart := &netmail.Message{ + Header: netmail.Header(multiPart.Header), + Body: multiPart, + } + relatedBuf := &bytes.Buffer{} + if _, err = relatedBuf.ReadFrom(multiPart); err != nil { + return fmt.Errorf("failed to read related multipart message to buffer: %w", err) + } + if err := parseEMLBodyParts(relatedPart, relatedBuf, msg); err != nil { + return fmt.Errorf("failed to parse related multipart body: %w", err) + } + } + } + + // Content-Disposition header means we have an attachment or embed + if contentDisposition, ok := multiPart.Header[HeaderContentDisposition.String()]; ok { + if err = parseEMLAttachmentEmbed(contentDisposition, multiPart, msg); err != nil { + return fmt.Errorf("failed to parse attachment/embed: %w", err) + } + goto ReadNextPart + } + + multiPartData, mperr := io.ReadAll(multiPart) + if mperr != nil { + _ = multiPart.Close() + return fmt.Errorf("failed to read multipart: %w", err) + } + + multiPartContentType, ok := multiPart.Header[HeaderContentType.String()] + if !ok { + return fmt.Errorf("failed to get content-type from part") + } + contentType, optional := parseMultiPartHeader(multiPartContentType[0]) + if strings.EqualFold(contentType, TypeMultipartRelated.String()) { + goto ReadNextPart + } + part := msg.newPart(ContentType(contentType)) + if charset, ok := optional["charset"]; ok { + part.SetCharset(Charset(charset)) + } + + mutliPartTransferEnc, ok := multiPart.Header[HeaderContentTransferEnc.String()] + if !ok { + // If CTE is empty we can assume that it's a quoted-printable CTE since the + // GO stdlib multipart packages deletes that header + // See: https://cs.opensource.google/go/go/+/refs/tags/go1.22.0:src/mime/multipart/multipart.go;l=161 + mutliPartTransferEnc = []string{EncodingQP.String()} + } + + switch { + case strings.EqualFold(mutliPartTransferEnc[0], EncodingB64.String()): + if err := handleEMLMultiPartBase64Encoding(multiPartData, part); err != nil { + return fmt.Errorf("failed to handle multipart base64 transfer-encoding: %w", err) + } + case strings.EqualFold(mutliPartTransferEnc[0], EncodingQP.String()): + part.SetContent(string(multiPartData)) + default: + return fmt.Errorf("unsupported Content-Transfer-Encoding") + } + + msg.parts = append(msg.parts, part) + multiPart, err = multipartReader.NextPart() + } + if !errors.Is(err, io.EOF) { + return fmt.Errorf("failed to read multipart: %w", err) + } + return nil +} + +// parseEMLEncoding parses and determines the encoding of the message +func parseEMLEncoding(mailHeader *netmail.Header, msg *Msg) { + if value := mailHeader.Get(HeaderContentTransferEnc.String()); value != "" { + switch { + case strings.EqualFold(value, EncodingQP.String()): + msg.SetEncoding(EncodingQP) + case strings.EqualFold(value, EncodingB64.String()): + msg.SetEncoding(EncodingB64) + default: + msg.SetEncoding(NoEncoding) + } + } +} + +// parseEMLContentTypeCharset parses and determines the charset and content type of the message +func parseEMLContentTypeCharset(mailHeader *netmail.Header, msg *Msg) { + if value := mailHeader.Get(HeaderContentType.String()); value != "" { + contentType, optional := parseMultiPartHeader(value) + if charset, ok := optional["charset"]; ok { + msg.SetCharset(Charset(charset)) + } + msg.setEncoder() + if contentType != "" && !strings.EqualFold(contentType, TypeMultipartMixed.String()) { + msg.SetGenHeader(HeaderContentType, contentType) + } + } +} + +// handleEMLMultiPartBase64Encoding sets the content body of a base64 encoded Part +func handleEMLMultiPartBase64Encoding(multiPartData []byte, part *Part) error { + part.SetEncoding(EncodingB64) + content, err := base64.StdEncoding.DecodeString(string(multiPartData)) + if err != nil { + return fmt.Errorf("failed to decode base64 part: %w", err) + } + part.SetContent(string(content)) + return nil +} + +// parseMultiPartHeader parses a multipart header and returns the value and optional parts as +// separate map +func parseMultiPartHeader(multiPartHeader string) (header string, optional map[string]string) { + optional = make(map[string]string) + headerSplit := strings.SplitN(multiPartHeader, ";", 2) + header = headerSplit[0] + if len(headerSplit) == 2 { + optString := strings.TrimLeft(headerSplit[1], " ") + optSplit := strings.SplitN(optString, "=", 2) + if len(optSplit) == 2 { + optional[optSplit[0]] = optSplit[1] + } + } + return +} + +// parseEMLAttachmentEmbed parses a multipart that is an attachment or embed +func parseEMLAttachmentEmbed(contentDisposition []string, multiPart *multipart.Part, msg *Msg) error { + cdType, optional := parseMultiPartHeader(contentDisposition[0]) + filename := "generic.attachment" + if name, ok := optional["filename"]; ok { + filename = name[1 : len(name)-1] + } + + var dataReader io.Reader + dataReader = multiPart + contentTransferEnc, _ := parseMultiPartHeader(multiPart.Header.Get(HeaderContentTransferEnc.String())) + b64Decoder := base64.NewDecoder(base64.StdEncoding, multiPart) + if strings.EqualFold(contentTransferEnc, EncodingB64.String()) { + dataReader = b64Decoder + } + + switch strings.ToLower(cdType) { + case "attachment": + if err := msg.AttachReader(filename, dataReader); err != nil { + return fmt.Errorf("failed to attach multipart body: %w", err) + } + case "inline": + if contentID, _ := parseMultiPartHeader(multiPart.Header.Get(HeaderContentID.String())); contentID != "" { + if err := msg.EmbedReader(filename, dataReader, WithFileContentID(contentID)); err != nil { + return fmt.Errorf("failed to embed multipart body: %w", err) + } + return nil + } + if err := msg.EmbedReader(filename, dataReader); err != nil { + return fmt.Errorf("failed to embed multipart body: %w", err) + } + } + return nil +} diff --git a/eml_test.go b/eml_test.go new file mode 100644 index 00000000..8823eec2 --- /dev/null +++ b/eml_test.go @@ -0,0 +1,881 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "fmt" + "os" + "strings" + "testing" + "time" +) + +const ( + exampleMailPlainNoEnc = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainBrokenBody = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: base64 + +This plain text body should not be parsed as Base64. +` + exampleMailPlainNoContentType = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: + +This plain text body should not be parsed as Base64. +` + exampleMailPlainUnknownContentType = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: application/unknown; charset=UTF-8 +Content-Transfer-Encoding: base64 + +This plain text body should not be parsed as Base64. +` + exampleMailPlainBrokenHeader = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version = 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent = go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From = "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding = 8bit + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainBrokenFrom = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: Toni Tester" go-mail@go-mail.dev> +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainBrokenTo = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: go-mail+test.go-mail.dev> +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainNoEncInvalidDate = `Date: Inv, 99 Nov 9999 99:99:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainNoEncNoDate = `MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainQP = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text quoted-printable +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very lo= +ng so it +should be wrapped. + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainUnsupportedTransferEnc = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text quoted-printable +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=ISO-8859-1 +Content-Transfer-Encoding: unknown + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it should be wrapped. +㋛ +This is not ==D3 + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainB64 = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: base64 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGlzIGxpbmUgaXMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU=` + exampleMailPlainB64WithAttachment = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary=45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Transfer-Encoding: base64 +Content-Type: text/plain; charset=UTF-8 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGlzIGxpbmUgaXMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU= + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Disposition: attachment; filename="test.attachment" +Content-Transfer-Encoding: base64 +Content-Type: application/octet-stream; name="test.attachment" + +VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg +ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh +Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` + exampleMailPlainB64WithAttachmentNoBoundary = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Transfer-Encoding: base64 +Content-Type: text/plain; charset=UTF-8 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGlzIGxpbmUgaXMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU= + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Disposition: attachment; filename="test.attachment" +Content-Transfer-Encoding: base64 +Content-Type: application/octet-stream; name="test.attachment" + +VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg +ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh +Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` + exampleMailPlainB64BrokenBody = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary=45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Transfer-Encoding = base64 +Content-Type = text/plain; charset=UTF-8 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGlzIGxpbmUgaXMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU= + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Disposition: attachment; filename="test.attachment" +Content-Transfer-Encoding: base64 +Content-Type: application/octet-stream; name="test.attachment" + +VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg +ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh +Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` + exampleMailPlainB64WithEmbed = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with embed +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/related; + boundary=ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b + +--ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +This is a test body string +--ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b +Content-Disposition: inline; filename="pixel.png" +Content-Id: +Content-Transfer-Encoding: base64 +Content-Type: image/png; name="pixel.png" + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2O +UAAAAABJRU5ErkJggg== + +--ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b--` + exampleMailPlainB64WithEmbedNoContentID = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with embed +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/related; + boundary=ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b + +--ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +This is a test body string +--ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b +Content-Disposition: inline; filename="pixel.png" +Content-Transfer-Encoding: base64 +Content-Type: image/png; name="pixel.png" + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2O +UAAAAABJRU5ErkJggg== + +--ffbcfb94b44e5297325102f6ced05b3b37f1d70fc38a5e78dc73c1a8434b--` + exampleMailMultipartMixedAlternativeRelated = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment, embed and alternative part +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary=fe785e0384e2607697cc2ecb17cce003003bb7ca9112104f3e8ce727edb5 + +--fe785e0384e2607697cc2ecb17cce003003bb7ca9112104f3e8ce727edb5 +Content-Type: multipart/related; + boundary=5897e40a22c608e252cfab849e966112fcbd5a1c291208026b3ca2bfab8a + + + +--5897e40a22c608e252cfab849e966112fcbd5a1c291208026b3ca2bfab8a +Content-Type: multipart/alternative; + boundary=cbace12de35ef4eae53fd974ccd41cb2aee4f9c9c76057ec8bfdd0c97813 + + + +--cbace12de35ef4eae53fd974ccd41cb2aee4f9c9c76057ec8bfdd0c97813 +Content-Transfer-Encoding: base64 +Content-Type: text/plain; charset=UTF-8 + +RGVhciBDdXN0b21lciwKCkdvb2QgbmV3cyEgWW91ciBvcmRlciBpcyBvbiB0aGUgd2F5IGFuZCBp +bnZvaWNlIGlzIGF0dGFjaGVkIQoKWW91IHdpbGwgZmluZCB5b3VyIHRyYWNraW5nIG51bWJlciBv +biB0aGUgaW52b2ljZS4gVHJhY2tpbmcgZGF0YSBtYXkgdGFrZQp1cCB0byAyNCBob3VycyB0byBi +ZSBhY2Nlc3NpYmxlIG9ubGluZS4KCuKAoiBQbGVhc2UgcmVtaXQgcGF5bWVudCBhdCB5b3VyIGVh +cmxpZXN0IGNvbnZlbmllbmNlIHVubGVzcyBpbnZvaWNlIGlzCm1hcmtlZCDigJxQQUlE4oCdLgri +gKIgU29tZSBpdGVtcyBtYXkgc2hpcCBzZXBhcmF0ZWx5IGZyb20gbXVsdGlwbGUgbG9jYXRpb25z +LiBTZXBhcmF0ZQppbnZvaWNlcyB3aWxsIGJlIGlzc3VlZCB3aGVuIGFwcGxpY2FibGUuCuKAoiBQ +TEVBU0UgSU5TUEVDVCBVUE9OIFJFQ0VJUFQgRk9SIFBBVFRFUk4sIENPTE9SLCBERUZFQ1RTLCBE +QU1BR0UgRlJPTQpTSElQUElORywgQ09SUkVDVCBZQVJEQUdFLCBFVEMhIE9uY2UgYW4gb3JkZXIg +aXMgY3V0IG9yIHNld24sIG5vIHJldHVybnMKd2lsbCBiZSBhY2NlcHRlZCBmb3IgYW55IHJlYXNv +biwgbm8gbWF0dGVyIHRoZSBwYXJ0eSBpbiBlcnJvci4gTm8gcmV0dXJucwp3aWxsIGJlIGF1dGhv +cml6ZWQgYWZ0ZXIgMzAgZGF5cyBvZiBpbnZvaWNlIGRhdGUuIE5vIGV4Y2VwdGlvbnMgd2lsbCBi +ZQptYWRlLgoKVGhhbmsgeW91ciBmb3IgeW91ciBidXNpbmVzcyEKCk5haWxkb2N0b3IgRmFicmlj +cw== + +--cbace12de35ef4eae53fd974ccd41cb2aee4f9c9c76057ec8bfdd0c97813 +Content-Transfer-Encoding: base64 +Content-Type: text/html; charset=UTF-8 + +PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGJvZHk+CjxwPlRoaXMgaXMgSFRNTCA8 +c3Ryb25nPmluIEJPTEQ8L3N0cm9uZz4KPHA+ClRoaXMgaXMgYW4gZW1iZWRkZWQgcGljdHVyZTog +CjxpbWcgYWx0PSJwaXhlbC5wbmciIHNyYz0iY2lkOmltYWdlMS5wbmciPgo8YnI+Rm9vPC9wPg== + +--cbace12de35ef4eae53fd974ccd41cb2aee4f9c9c76057ec8bfdd0c97813-- + +--5897e40a22c608e252cfab849e966112fcbd5a1c291208026b3ca2bfab8a +Content-Disposition: inline; filename="pixel.png" +Content-Id: image1.png +Content-Transfer-Encoding: base64 +Content-Type: image/png; name="pixel.png" + +iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAAAAACoWZBhAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnht +cAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQi +Pz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUg +NS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIy +LXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1s +bnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9w +PSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0 +cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRv +YmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hh +cC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9z +VHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDYtMjhUMTM6MjY6 +MDYrMDIwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDYtMjhUMTM6Mjc6MDgrMDI6MDAiCiAg +IHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDYtMjhUMTM6Mjc6MDgrMDI6MDAiCiAgIHBob3Rvc2hv +cDpEYXRlQ3JlYXRlZD0iMjAyNC0wNi0yOFQxMzoyNjowNiswMjAwIgogICBwaG90b3Nob3A6Q29s +b3JNb2RlPSIxIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0iR3JleXNjYWxlIEQ1MCIKICAgZXhp +ZjpQaXhlbFhEaW1lbnNpb249IjEwIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMTAiCiAgIGV4 +aWY6Q29sb3JTcGFjZT0iNjU1MzUiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iMTAiCiAgIHRpZmY6SW1h +Z2VMZW5ndGg9IjEwIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0 +aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+ +CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQi +CiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjAiCiAgICAg +IHN0RXZ0OndoZW49IjIwMjQtMDYtMjhUMTM6Mjc6MDgrMDI6MDAiLz4KICAgIDwvcmRmOlNlcT4K +ICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6 +eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PpwIGG4AAADdaUNDUEdyZXlzY2FsZSBENTAAABiV +dVC9CsJAGEul6KCDg4vSoQ+gIIjiKoou6qAVrLiUs/5gq8e1In0v30TwGRycnc0VcRD9IJdwfMmR +A4xlIMLIrAPhIVaDSceduws7d0cWFvIow/JEJEfTvoO/87zB0Hyt6az/ez/HXPmRIF+IlpAqJj+I +4TmW1EaburR3Jl3qIXUxDE7i7dWvFvzDbEquEBYGUPCRIIKAh4DaRg9N6H6/ffXUN8aRm4KnpFth +hw22iFHl7YlpOmedZvtMTfQffXeXnvI+rTKNxguyvDKvB7U4qQAAAAlwSFlzAAALEwAACxMBAJqc +GAAAABFJREFUCJljnMoAA0wMNGcCAEQrAKk9oHKhAAAAAElFTkSuQmCC + +--5897e40a22c608e252cfab849e966112fcbd5a1c291208026b3ca2bfab8a-- + +--fe785e0384e2607697cc2ecb17cce003003bb7ca9112104f3e8ce727edb5 +Content-Disposition: attachment; filename="attachment.png" +Content-Transfer-Encoding: base64 +Content-Type: image/png; name="attachment.png" + +iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAAAAACoWZBhAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnht +cAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQi +Pz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUg +NS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIy +LXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1s +bnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9w +PSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0 +cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRv +YmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hh +cC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9z +VHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDYtMjhUMTM6MjY6 +MDYrMDIwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDYtMjhUMTM6Mjc6MDgrMDI6MDAiCiAg +IHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDYtMjhUMTM6Mjc6MDgrMDI6MDAiCiAgIHBob3Rvc2hv +cDpEYXRlQ3JlYXRlZD0iMjAyNC0wNi0yOFQxMzoyNjowNiswMjAwIgogICBwaG90b3Nob3A6Q29s +b3JNb2RlPSIxIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0iR3JleXNjYWxlIEQ1MCIKICAgZXhp +ZjpQaXhlbFhEaW1lbnNpb249IjEwIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMTAiCiAgIGV4 +aWY6Q29sb3JTcGFjZT0iNjU1MzUiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iMTAiCiAgIHRpZmY6SW1h +Z2VMZW5ndGg9IjEwIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0 +aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+ +CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQi +CiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjAiCiAgICAg +IHN0RXZ0OndoZW49IjIwMjQtMDYtMjhUMTM6Mjc6MDgrMDI6MDAiLz4KICAgIDwvcmRmOlNlcT4K +ICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6 +eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PpwIGG4AAADdaUNDUEdyZXlzY2FsZSBENTAAABiV +dVC9CsJAGEul6KCDg4vSoQ+gIIjiKoou6qAVrLiUs/5gq8e1In0v30TwGRycnc0VcRD9IJdwfMmR +A4xlIMLIrAPhIVaDSceduws7d0cWFvIow/JEJEfTvoO/87zB0Hyt6az/ez/HXPmRIF+IlpAqJj+I +4TmW1EaburR3Jl3qIXUxDE7i7dWvFvzDbEquEBYGUPCRIIKAh4DaRg9N6H6/ffXUN8aRm4KnpFth +hw22iFHl7YlpOmedZvtMTfQffXeXnvI+rTKNxguyvDKvB7U4qQAAAAlwSFlzAAALEwAACxMBAJqc +GAAAABFJREFUCJljnMoAA0wMNGcCAEQrAKk9oHKhAAAAAElFTkSuQmCC + +--fe785e0384e2607697cc2ecb17cce003003bb7ca9112104f3e8ce727edb5--` +) + +func TestEMLToMsgFromString(t *testing.T) { + tests := []struct { + name string + eml string + enc string + sub string + }{ + { + "Plain text no encoding", exampleMailPlainNoEnc, "8bit", + "Example mail // plain text without encoding", + }, + { + "Plain text quoted-printable", exampleMailPlainQP, "quoted-printable", + "Example mail // plain text quoted-printable", + }, + { + "Plain text base64", exampleMailPlainB64, "base64", + "Example mail // plain text base64", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := EMLToMsgFromString(tt.eml) + if err != nil { + t.Errorf("failed to parse EML: %s", err) + } + if msg.Encoding() != tt.enc { + t.Errorf("EMLToMsgFromString failed: expected encoding: %s, but got: %s", tt.enc, msg.Encoding()) + } + if subject := msg.GetGenHeader(HeaderSubject); len(subject) > 0 && !strings.EqualFold(subject[0], tt.sub) { + t.Errorf("EMLToMsgFromString failed: expected subject: %s, but got: %s", + tt.sub, subject[0]) + } + }) + } +} + +func TestEMLToMsgFromFile(t *testing.T) { + tests := []struct { + name string + eml string + enc string + sub string + }{ + { + "Plain text no encoding", exampleMailPlainNoEnc, "8bit", + "Example mail // plain text without encoding", + }, + { + "Plain text quoted-printable", exampleMailPlainQP, "quoted-printable", + "Example mail // plain text quoted-printable", + }, + { + "Plain text base64", exampleMailPlainB64, "base64", + "Example mail // plain text base64", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, tempFile, err := stringToTempFile(tt.eml, tt.name) + defer func() { + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } + }() + msg, err := EMLToMsgFromFile(tempFile) + if err != nil { + t.Errorf("failed to parse EML: %s", err) + } + if msg.Encoding() != tt.enc { + t.Errorf("EMLToMsgFromString failed: expected encoding: %s, but got: %s", tt.enc, msg.Encoding()) + } + if subject := msg.GetGenHeader(HeaderSubject); len(subject) > 0 && !strings.EqualFold(subject[0], tt.sub) { + t.Errorf("EMLToMsgFromString failed: expected subject: %s, but got: %s", + tt.sub, subject[0]) + } + }) + } +} + +func TestEMLToMsgFromReaderFailing(t *testing.T) { + mailbuf := bytes.NewBufferString(exampleMailPlainBrokenFrom) + _, err := EMLToMsgFromReader(mailbuf) + if err == nil { + t.Error("EML from Reader with broken FROM was supposed to fail, but didn't") + } + mailbuf.Reset() + mailbuf.WriteString(exampleMailPlainBrokenHeader) + _, err = EMLToMsgFromReader(mailbuf) + if err == nil { + t.Error("EML from Reader with broken header was supposed to fail, but didn't") + } + mailbuf.Reset() + mailbuf.WriteString(exampleMailPlainB64BrokenBody) + _, err = EMLToMsgFromReader(mailbuf) + if err == nil { + t.Error("EML from Reader with broken body was supposed to fail, but didn't") + } + mailbuf.Reset() + mailbuf.WriteString(exampleMailPlainBrokenBody) + _, err = EMLToMsgFromReader(mailbuf) + if err == nil { + t.Error("EML from Reader with broken body was supposed to fail, but didn't") + } + mailbuf.Reset() + mailbuf.WriteString(exampleMailPlainUnknownContentType) + _, err = EMLToMsgFromReader(mailbuf) + if err == nil { + t.Error("EML from Reader with unknown content type was supposed to fail, but didn't") + } + mailbuf.Reset() + mailbuf.WriteString(exampleMailPlainNoContentType) + _, err = EMLToMsgFromReader(mailbuf) + if err == nil { + t.Error("EML from Reader with no content type was supposed to fail, but didn't") + } + mailbuf.Reset() + mailbuf.WriteString(exampleMailPlainUnsupportedTransferEnc) + _, err = EMLToMsgFromReader(mailbuf) + if err == nil { + t.Error("EML from Reader with unsupported Transer-Encoding was supposed to fail, but didn't") + } +} + +func TestEMLToMsgFromFileFailing(t *testing.T) { + tempDir, tempFile, err := stringToTempFile(exampleMailPlainBrokenFrom, "testmail") + if err != nil { + t.Errorf("failed to write EML string to temp file: %s", err) + } + _, err = EMLToMsgFromFile(tempFile) + if err == nil { + t.Error("EML from Reader with broken FROM was supposed to fail, but didn't") + } + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } + tempDir, tempFile, err = stringToTempFile(exampleMailPlainBrokenHeader, "testmail") + if err != nil { + t.Errorf("failed to write EML string to temp file: %s", err) + } + _, err = EMLToMsgFromFile(tempFile) + if err == nil { + t.Error("EML from Reader with broken header was supposed to fail, but didn't") + } + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } + tempDir, tempFile, err = stringToTempFile(exampleMailPlainB64BrokenBody, "testmail") + if err != nil { + t.Errorf("failed to write EML string to temp file: %s", err) + } + _, err = EMLToMsgFromFile(tempFile) + if err == nil { + t.Error("EML from Reader with broken body was supposed to fail, but didn't") + } + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } + tempDir, tempFile, err = stringToTempFile(exampleMailPlainBrokenBody, "testmail") + if err != nil { + t.Errorf("failed to write EML string to temp file: %s", err) + } + _, err = EMLToMsgFromFile(tempFile) + if err == nil { + t.Error("EML from Reader with broken body was supposed to fail, but didn't") + } + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } + tempDir, tempFile, err = stringToTempFile(exampleMailPlainUnknownContentType, "testmail") + if err != nil { + t.Errorf("failed to write EML string to temp file: %s", err) + } + _, err = EMLToMsgFromFile(tempFile) + if err == nil { + t.Error("EML from Reader with unknown content type was supposed to fail, but didn't") + } + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } + tempDir, tempFile, err = stringToTempFile(exampleMailPlainNoContentType, "testmail") + if err != nil { + t.Errorf("failed to write EML string to temp file: %s", err) + } + _, err = EMLToMsgFromFile(tempFile) + if err == nil { + t.Error("EML from Reader with no content type was supposed to fail, but didn't") + } + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } + tempDir, tempFile, err = stringToTempFile(exampleMailPlainUnsupportedTransferEnc, "testmail") + if err != nil { + t.Errorf("failed to write EML string to temp file: %s", err) + } + _, err = EMLToMsgFromFile(tempFile) + if err == nil { + t.Error("EML from Reader with unsupported Transer-Encoding was supposed to fail, but didn't") + } + if err = os.RemoveAll(tempDir); err != nil { + t.Error("failed to remove temp dir:", err) + } +} + +func TestEMLToMsgFromStringBrokenDate(t *testing.T) { + _, err := EMLToMsgFromString(exampleMailPlainNoEncInvalidDate) + if err == nil { + t.Error("EML with invalid date was supposed to fail, but didn't") + } + now := time.Now() + m, err := EMLToMsgFromString(exampleMailPlainNoEncNoDate) + if err != nil { + t.Errorf("EML with no date parsing failed: %s", err) + } + da := m.GetGenHeader(HeaderDate) + if len(da) < 1 { + t.Error("EML with no date expected current date, but got nothing") + return + } + d := da[0] + if d != now.Format(time.RFC1123Z) { + t.Errorf("EML with no date expected: %s, got: %s", now.Format(time.RFC1123Z), d) + } +} + +func TestEMLToMsgFromStringBrokenFrom(t *testing.T) { + _, err := EMLToMsgFromString(exampleMailPlainBrokenFrom) + if err == nil { + t.Error("EML with broken FROM was supposed to fail, but didn't") + } +} + +func TestEMLToMsgFromStringBrokenTo(t *testing.T) { + _, err := EMLToMsgFromString(exampleMailPlainBrokenTo) + if err == nil { + t.Error("EML with broken TO was supposed to fail, but didn't") + } +} + +func TestEMLToMsgFromStringNoBoundary(t *testing.T) { + _, err := EMLToMsgFromString(exampleMailPlainB64WithAttachmentNoBoundary) + if err == nil { + t.Error("EML with no boundary was supposed to fail, but didn't") + } +} + +func TestEMLToMsgFromStringWithAttachment(t *testing.T) { + wantSubject := "Example mail // plain text base64 with attachment" + msg, err := EMLToMsgFromString(exampleMailPlainB64WithAttachment) + if err != nil { + t.Errorf("EML with attachment failed: %s", err) + } + if subject := msg.GetGenHeader(HeaderSubject); len(subject) > 0 && !strings.EqualFold(subject[0], wantSubject) { + t.Errorf("EMLToMsgFromString of EML with attachment failed: expected subject: %s, but got: %s", + wantSubject, subject[0]) + } + if len(msg.attachments) != 1 { + t.Errorf("EMLToMsgFromString of EML with attachment failed: expected no. of attachments: %d, but got: %d", + 1, len(msg.attachments)) + } +} + +func TestEMLToMsgFromStringWithEmbed(t *testing.T) { + wantSubject := "Example mail // plain text base64 with embed" + msg, err := EMLToMsgFromString(exampleMailPlainB64WithEmbed) + if err != nil { + t.Errorf("EML with embed failed: %s", err) + } + if subject := msg.GetGenHeader(HeaderSubject); len(subject) > 0 && !strings.EqualFold(subject[0], wantSubject) { + t.Errorf("EMLToMsgFromString of EML with embed failed: expected subject: %s, but got: %s", + wantSubject, subject[0]) + } + if len(msg.embeds) != 1 { + t.Errorf("EMLToMsgFromString of EML with embed failed: expected no. of embeds: %d, but got: %d", + 1, len(msg.embeds)) + } + msg, err = EMLToMsgFromString(exampleMailPlainB64WithEmbedNoContentID) + if err != nil { + t.Errorf("EML with embed failed: %s", err) + } + if subject := msg.GetGenHeader(HeaderSubject); len(subject) > 0 && !strings.EqualFold(subject[0], wantSubject) { + t.Errorf("EMLToMsgFromString of EML with embed failed: expected subject: %s, but got: %s", + wantSubject, subject[0]) + } + if len(msg.embeds) != 1 { + t.Errorf("EMLToMsgFromString of EML with embed failed: expected no. of embeds: %d, but got: %d", + 1, len(msg.embeds)) + } +} + +func TestEMLToMsgFromStringMultipartMixedAlternativeRelated(t *testing.T) { + wantSubject := "Example mail // plain text base64 with attachment, embed and alternative part" + msg, err := EMLToMsgFromString(exampleMailMultipartMixedAlternativeRelated) + if err != nil { + t.Errorf("EML multipart mixed, related, alternative failed: %s", err) + } + if subject := msg.GetGenHeader(HeaderSubject); len(subject) > 0 && !strings.EqualFold(subject[0], wantSubject) { + t.Errorf("EMLToMsgFromString of EML multipart mixed, related, alternative failed: expected subject: %s,"+ + " but got: %s", wantSubject, subject[0]) + } + if len(msg.embeds) != 1 { + t.Errorf("EMLToMsgFromString of EML multipart mixed, related, alternative failed: expected no. of "+ + "embeds: %d, but got: %d", 1, len(msg.embeds)) + } + if len(msg.attachments) != 1 { + t.Errorf("EMLToMsgFromString of EML multipart mixed, related, alternative failed: expected no. of "+ + "attachments: %d, but got: %d", 1, len(msg.attachments)) + } + if len(msg.parts) != 3 { + t.Errorf("EMLToMsgFromString of EML multipart mixed, related, alternative failed: expected no. of "+ + "parts: %d, but got: %d", 3, len(msg.parts)) + } + + var hasPlain, hasHTML, hasAlternative bool + for _, part := range msg.parts { + if strings.EqualFold(part.contentType.String(), TypeMultipartAlternative.String()) { + hasAlternative = true + } + if strings.EqualFold(part.contentType.String(), TypeTextPlain.String()) { + hasPlain = true + } + if strings.EqualFold(part.contentType.String(), TypeTextHTML.String()) { + hasHTML = true + } + } + if !hasPlain { + t.Error("EMLToMsgFromString of EML multipart mixed, related, alternative failed: expected PLAIN " + + "but got none") + } + if !hasHTML { + t.Error("EMLToMsgFromString of EML multipart mixed, related, alternative failed: expected HTML " + + "but got none") + } + if !hasAlternative { + t.Error("EMLToMsgFromString of EML multipart mixed, related, alternative failed: expected Alternative " + + "but got none") + } +} + +// stringToTempFile is a helper method that will create a temporary file form a give data string +func stringToTempFile(data, name string) (string, string, error) { + tempDir, err := os.MkdirTemp("", fmt.Sprintf("*-%s", name)) + if err != nil { + return tempDir, "", fmt.Errorf("failed to create temp dir: %w", err) + } + filePath := fmt.Sprintf("%s/%s", tempDir, name) + err = os.WriteFile(filePath, []byte(data), 0o666) + if err != nil { + return tempDir, "", fmt.Errorf("failed to write data to temp file: %w", err) + } + return tempDir, filePath, nil +} diff --git a/encoding.go b/encoding.go index dff1e6f5..2187e5f8 100644 --- a/encoding.go +++ b/encoding.go @@ -132,17 +132,20 @@ const ( // List of MIME versions const ( - // Mime10 is the MIME Version 1.0 - Mime10 MIMEVersion = "1.0" + // MIME10 is the MIME Version 1.0 + MIME10 MIMEVersion = "1.0" ) // List of common content types const ( - TypeTextPlain ContentType = "text/plain" - TypeTextHTML ContentType = "text/html" - TypeAppOctetStream ContentType = "application/octet-stream" - TypePGPSignature ContentType = "application/pgp-signature" - TypePGPEncrypted ContentType = "application/pgp-encrypted" + TypeAppOctetStream ContentType = "application/octet-stream" + TypeMultipartAlternative ContentType = "multipart/alternative" + TypeMultipartMixed ContentType = "multipart/mixed" + TypeMultipartRelated ContentType = "multipart/related" + TypePGPSignature ContentType = "application/pgp-signature" + TypePGPEncrypted ContentType = "application/pgp-encrypted" + TypeTextHTML ContentType = "text/html" + TypeTextPlain ContentType = "text/plain" ) // List of MIMETypes @@ -152,12 +155,17 @@ const ( MIMERelated MIMEType = "related" ) -// String is a standard method to convert an Encoding into a printable format -func (e Encoding) String() string { - return string(e) -} - // String is a standard method to convert an Charset into a printable format func (c Charset) String() string { return string(c) } + +// String is a standard method to convert an ContentType into a printable format +func (c ContentType) String() string { + return string(c) +} + +// String is a standard method to convert an Encoding into a printable format +func (e Encoding) String() string { + return string(e) +} diff --git a/encoding_test.go b/encoding_test.go index 71624f31..86f686a3 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -27,6 +27,50 @@ func TestEncoding_String(t *testing.T) { } } +// TestContentType_String tests the string method of the ContentType object +func TestContentType_String(t *testing.T) { + tests := []struct { + name string + ct ContentType + want string + }{ + {"ContentType: text/plain", TypeTextPlain, "text/plain"}, + {"ContentType: text/html", TypeTextHTML, "text/html"}, + { + "ContentType: application/octet-stream", TypeAppOctetStream, + "application/octet-stream", + }, + { + "ContentType: multipart/alternative", TypeMultipartAlternative, + "multipart/alternative", + }, + { + "ContentType: multipart/mixed", TypeMultipartMixed, + "multipart/mixed", + }, + { + "ContentType: multipart/related", TypeMultipartRelated, + "multipart/related", + }, + { + "ContentType: application/pgp-signature", TypePGPSignature, + "application/pgp-signature", + }, + { + "ContentType: application/pgp-encrypted", TypePGPEncrypted, + "application/pgp-encrypted", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.ct.String() != tt.want { + t.Errorf("wrong string for Content-Type returned. Expected: %s, got: %s", + tt.want, tt.ct.String()) + } + }) + } +} + // TestCharset_String tests the string method of the Charset object func TestCharset_String(t *testing.T) { tests := []struct { diff --git a/file.go b/file.go index 1ad2d5ae..45e142a9 100644 --- a/file.go +++ b/file.go @@ -22,6 +22,13 @@ type File struct { Writer func(w io.Writer) (int64, error) } +// WithFileContentID sets the Content-ID header for the File +func WithFileContentID(id string) FileOption { + return func(f *File) { + f.Header.Set(HeaderContentID.String(), id) + } +} + // WithFileName sets the filename of the File func WithFileName(name string) FileOption { return func(f *File) { diff --git a/file_test.go b/file_test.go index 8815d053..43b8cfee 100644 --- a/file_test.go +++ b/file_test.go @@ -56,6 +56,32 @@ func TestFile_WithFileDescription(t *testing.T) { } } +// TestFile_WithContentID tests the WithFileContentID option +func TestFile_WithContentID(t *testing.T) { + tests := []struct { + name string + contentid string + }{ + {"File Content-ID: test", "test"}, + {"File Content-ID: empty", ""}, + } + for _, tt := range tests { + m := NewMsg() + t.Run(tt.name, func(t *testing.T) { + m.AttachFile("file.go", WithFileContentID(tt.contentid)) + al := m.GetAttachments() + if len(al) <= 0 { + t.Errorf("AttachFile() failed. Attachment list is empty") + } + a := al[0] + if a.Header.Get(HeaderContentID.String()) != tt.contentid { + t.Errorf("WithFileContentID() failed. Expected: %s, got: %s", tt.contentid, + a.Header.Get(HeaderContentID.String())) + } + }) + } +} + // TestFile_WithFileEncoding tests the WithFileEncoding option func TestFile_WithFileEncoding(t *testing.T) { tests := []struct { diff --git a/header.go b/header.go index 4e5e3784..9191b7ec 100644 --- a/header.go +++ b/header.go @@ -73,6 +73,9 @@ const ( // HeaderPriority represents the "Priority" field HeaderPriority Header = "Priority" + // HeaderReferences is the "References" header field + HeaderReferences Header = "References" + // HeaderReplyTo is the "Reply-To" header field HeaderReplyTo Header = "Reply-To" diff --git a/header_test.go b/header_test.go index aea2f4c8..a060ae6d 100644 --- a/header_test.go +++ b/header_test.go @@ -87,6 +87,7 @@ func TestHeader_String(t *testing.T) { {"Header: Organization", HeaderOrganization, "Organization"}, {"Header: Precedence", HeaderPrecedence, "Precedence"}, {"Header: Priority", HeaderPriority, "Priority"}, + {"Header: HeaderReferences", HeaderReferences, "References"}, {"Header: Reply-To", HeaderReplyTo, "Reply-To"}, {"Header: Subject", HeaderSubject, "Subject"}, {"Header: User-Agent", HeaderUserAgent, "User-Agent"}, diff --git a/msg.go b/msg.go index ff3f018b..a0343fcb 100644 --- a/msg.go +++ b/msg.go @@ -136,7 +136,7 @@ func NewMsg(opts ...MsgOption) *Msg { encoding: EncodingQP, genHeader: make(map[Header][]string), preformHeader: make(map[Header]string), - mimever: Mime10, + mimever: MIME10, } // Override defaults with optionally provided MsgOption functions diff --git a/msg_test.go b/msg_test.go index e73bf817..b8c4d716 100644 --- a/msg_test.go +++ b/msg_test.go @@ -137,7 +137,7 @@ func TestNewMsgWithMIMEVersion(t *testing.T) { value MIMEVersion want MIMEVersion }{ - {"MIME version is 1.0", Mime10, "1.0"}, + {"MIME version is 1.0", MIME10, "1.0"}, } for _, tt := range tests { diff --git a/msgwriter.go b/msgwriter.go index 08073db8..ff1b47b5 100644 --- a/msgwriter.go +++ b/msgwriter.go @@ -89,11 +89,11 @@ func (mw *msgWriter) writeMsg(msg *Msg) { } if msg.hasMixed() { - mw.startMP("mixed", msg.boundary) + mw.startMP(MIMEMixed, msg.boundary) mw.writeString(DoubleNewLine) } if msg.hasRelated() { - mw.startMP("related", msg.boundary) + mw.startMP(MIMERelated, msg.boundary) mw.writeString(DoubleNewLine) } if msg.hasAlt() {