From c337c5af21e95ba78882c6e89d1999a13ffc0fd8 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 26 Feb 2017 16:52:57 -0500 Subject: [PATCH] Initial commit --- .gitignore | 2 + .travis.yml | 70 +++++ DCO | 36 +++ LICENSE | 201 +++++++++++++ MAINTAINERS | 1 + NOTICE | 5 + README.md | 97 +++++++ address.go | 140 +++++++++ buffer.go | 27 ++ docs.go | 69 +++++ encoding.go | 222 ++++++++++++++ encoding_test.go | 163 +++++++++++ helpers.go | 53 ++++ message.go | 739 +++++++++++++++++++++++++++++++++++++++++++++++ message_test.go | 299 +++++++++++++++++++ sender.go | 110 +++++++ test-file.txt | 1 + 17 files changed, 2235 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 DCO create mode 100644 LICENSE create mode 100644 MAINTAINERS create mode 100644 NOTICE create mode 100644 README.md create mode 100644 address.go create mode 100644 buffer.go create mode 100644 docs.go create mode 100644 encoding.go create mode 100644 encoding_test.go create mode 100644 helpers.go create mode 100644 message.go create mode 100644 message_test.go create mode 100644 sender.go create mode 100644 test-file.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..404365f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +README.html +coverage.out diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a608a93 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,70 @@ +language: go +sudo: false +go: + - 1.8 + - 1.7.5 + - 1.7.4 + - 1.7.3 + - 1.7.2 + - 1.7.1 + - 1.7 + - tip + - 1.6.4 + - 1.6.3 + - 1.6.2 + - 1.6.1 + - 1.6 + - 1.5.4 + - 1.5.3 + - 1.5.2 + - 1.5.1 + - 1.5 + - 1.4.3 + - 1.4.2 + - 1.4.1 + - 1.4 + - 1.3.3 + - 1.3.2 + - 1.3.1 + - 1.3 + - 1.2.2 + - 1.2.1 + - 1.2 + - 1.1.2 + - 1.1.1 + - 1.1 +# before_install: +# - go get github.com/mattn/goveralls +# script: +# - $HOME/gopath/bin/goveralls -service=travis-ci +notifications: + email: + on_success: never +matrix: + fast_finish: true + allow_failures: + - go: tip + - go: 1.6.4 + - go: 1.6.3 + - go: 1.6.2 + - go: 1.6.1 + - go: 1.6 + - go: 1.5.4 + - go: 1.5.3 + - go: 1.5.2 + - go: 1.5.1 + - go: 1.5 + - go: 1.4.3 + - go: 1.4.2 + - go: 1.4.1 + - go: 1.4 + - go: 1.3.3 + - go: 1.3.2 + - go: 1.3.1 + - go: 1.3 + - go: 1.2.2 + - go: 1.2.1 + - go: 1.2 + - go: 1.1.2 + - go: 1.1.1 + - go: 1.1 diff --git a/DCO b/DCO new file mode 100644 index 0000000..716561d --- /dev/null +++ b/DCO @@ -0,0 +1,36 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +660 York Street, Suite 102, +San Francisco, CA 94110 USA + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..726c2af --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1 @@ +Alex Bucataru (@AlexBucataru) diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2241ea8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +Alrux Go EXTensions (AGExt) - package email +Copyright 2016 ALRUX Inc. + +This product includes software developed at ALRUX Inc. +(http://www.alrux.com/). diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff1de39 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# A Go package for composing and sending email messages + +[![Release](https://img.shields.io/github/release/agext/email.svg?style=flat&colorB=eebb00)](https://github.com/agext/email/releases/latest) +[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/agext/email)  +[![Build Status](https://travis-ci.org/agext/email.svg?branch=master&style=flat)](https://travis-ci.org/agext/email) +[![Coverage Status](https://coveralls.io/repos/github/agext/email/badge.svg?style=flat)](https://coveralls.io/github/agext/email) +[![Go Report Card](https://goreportcard.com/badge/github.com/agext/email?style=flat)](https://goreportcard.com/report/github.com/agext/email) + + +This package implements a simple yet powerful API for email message composition and sending via an authenticating SMTP server, in [Go](http://golang.org). + +## Project Status + +v0.2 Edge: Breaking changes to the API unlikely but possible until the v1.0 release. May be robust enough to use in production, though provided on "AS IS" basis. Vendoring recommended. + +This package is under active development. If you encounter any problems or have any suggestions for improvement, please [open an issue](https://github.com/agext/email/issues). Pull requests are welcome. + +## Overview + +This package provides a fluid API for composing and sending email messages. + +Any application needs one (and usually only one) `Sender`, representing an SMTP account connection information plus a sender `Address`. + +A `Message` contains all the information required to create an email message. Usually, at least some parts of each message are templates to be filled with data from the rest of the application. Also, it is often convenient to define base messages on program initialization, and clone them for fine-tuning and send-out when needed. + +Below is an overly-simplified example, to showcase the basic functionality. Note that by not defining From: and To: addresses for a message, they default to the sender address, which is convenient for system messaging. + +```go +package main + +import ( + "log" + + "github.com/agext/email" +) + +var ( + host = "smtp.example.com" + user = "username" + pass = "password" + name = "Application mail" + addr = "app@example.com" + sender *email.Sender + contactFormMessage *email.Message +) + +func main() { + var err error + // create a sender with a given configuration + sender, err = email.NewSender(host, user, pass, name, addr) + if err != nil { + log.Fatalln("invalid sender configuration: " + err.Error()) + } + + // create a message from scratch, to be used as a base for the actual messages + contactFormMessage = email.NewMessage(nil). + SubjectTemplate("Contact form message from {{.first}} {{.last}}"). + TextTemplate(` +First Name: {{.first}} +Last Name: {{.last}} +Phone: {{.phone}} +Email: {{.email}} +`) + + // ... +} + +func sendContact(data map[string]interface{}) error { + // create a message from the base we created in main() - basically, clone all its data + msg := email.NewMessage(contactFormMessage) + + // customize / adapt the message as needed... + // if the base messages are well thought out, the need should be minimal, if at all + // as an example, we could set the To: address based on the form data, to dispatch the messages + // to the person in the company who is most capable to handle it. + + // send the message after composing it with the provided data + err := sender.Send(msg, data) + + if err != nil { + log.Println(err, msg.Errors()) + } + + return err +} + +``` + +## Installation + +``` +go get github.com/agext/email +``` + +## License + +Package email is released under the Apache 2.0 license. See the [LICENSE](LICENSE) file for details. diff --git a/address.go b/address.go new file mode 100644 index 0000000..58a7604 --- /dev/null +++ b/address.go @@ -0,0 +1,140 @@ +package email + +import ( + "errors" +) + +// Address represents a human-friendly email address: a name plus the actual address. +type Address struct { + Name string + Addr string +} + +// NewAddress creates a new Address enforcing a very basic validity check - see `SeemsValidAddr`. +func NewAddress(name, addr string) (*Address, error) { + if !SeemsValidAddr(addr) { + return nil, errors.New("invalid address: " + addr) + } + return &Address{name, addr}, nil +} + +// SeemsValidAddr does a very loose check on addr, to weed out obviously invalid addresses. +// This function only checks that addr contains one and only one '@', followed by a domain name +// that has a TLD part. +func SeemsValidAddr(addr string) bool { + var seenAt, seenDom, seenDot, seenTld bool + + for _, char := range addr { + switch char { + case '@': + if seenAt { // more than one '@' + return false + } + seenAt = true + case '.': + seenDot = seenAt && seenDom // only care about '.' after '@' and domain name + default: + if '!' > char || char > '~' { + return false + } + if seenAt { + // https://tools.ietf.org/html/rfc5322#section-3.4.1 + if char == '[' || char == ']' || char == '\\' { + return false + } + seenDom = !seenDot + seenTld = seenDot + } + } + } + return seenTld +} + +// Clone creates a new Address with the same contents as the receiver. +func (a *Address) Clone() *Address { + if a == nil { + return nil + } + return &Address{a.Name, a.Addr} +} + +// Domain extracts the domain portion of the email address in the receiver. +func (a *Address) Domain() string { + for i := len(a.Addr) - 1; i > -1; i-- { + if a.Addr[i] == '@' { + return a.Addr[i+1:] + } + } + return "" +} + +func (a *Address) encode(offset int) (dst []byte, pos int) { + la := len(a.Addr) + if ln := len(a.Name); ln > 0 { + nq, safe := 0, true + for i := 0; i < ln && safe; i++ { + c := a.Name[i] + safe = ' ' <= c && c <= '~' + if c == '\\' || c == '"' { + nq++ + } + } + if safe { + dst = make([]byte, 0, ln+nq+la+7) // 2*'"'+(' ' or "\r\n ")+'<'+'>' + dst = append(dst, '"') + for i := 0; i < ln; i++ { + c := a.Name[i] + if c == '\\' || c == '"' { + dst = append(dst, '\\') + } + dst = append(dst, c) + } + + offset += ln + nq + 3 // 2*'"'+' ' + if offset+la <= 74 { // max 76; need room for '<' and '>' + dst = append(dst, '"', ' ') + } else { + dst = append(dst, '"', '\r', '\n', ' ') + offset = 1 + } + } else { + var buf []byte + buf, offset = QEncode([]byte(a.Name), offset) + dst = make([]byte, len(buf), len(buf)+la+5) // (' ' or "\r\n ")+'<'+'>' + copy(dst, buf) + offset++ + if offset+la <= 74 { // max 76; need room for '<' and '>' + dst = append(dst, ' ') + } else { + dst = append(dst, '\r', '\n', ' ') + offset = 1 + } + } + } else { + if offset+la <= 74 { // max 76; need room for '<' and '>' + dst = make([]byte, 0, la+2) + } else { + dst = make([]byte, 0, la+5) + dst = append(dst, '\r', '\n', ' ') + offset = 1 + } + } + dst = append(dst, '<') + dst = append(dst, []byte(a.Addr)...) + dst = append(dst, '>') + offset += la + 2 + return dst, offset +} + +type addrList []*Address + +func (al addrList) Clone() addrList { + if len(al) == 0 { + return nil + } + cl := make(addrList, len(al)) + for i, a := range al { + cl[i] = a.Clone() + } + return cl +} diff --git a/buffer.go b/buffer.go new file mode 100644 index 0000000..05b1610 --- /dev/null +++ b/buffer.go @@ -0,0 +1,27 @@ +package email + +type buffer []byte + +func newBuffer(size int) *buffer { + b := buffer(make([]byte, 0, size)) + return &b +} + +func (b *buffer) Write(data ...interface{}) { + for _, value := range data { + switch v := value.(type) { + case string: + *b = append(*b, v...) + case []byte: + *b = append(*b, v...) + case byte: + *b = append(*b, v) + case rune: + *b = append(*b, string(v)...) + } + } +} + +func (b *buffer) Bytes() []byte { + return *b +} diff --git a/docs.go b/docs.go new file mode 100644 index 0000000..a095474 --- /dev/null +++ b/docs.go @@ -0,0 +1,69 @@ +/* +Package email provides a fluid API for composing and sending email messages. + +Any application needs one (and usually only one) Sender, representing an SMTP account connection information plus a sender Address. + +A Message contains all the information required to create an email message. Usually, at least some parts of each message are templates to be filled with data from the rest of the application. Also, it is often convenient to define base messages on program initialization, and clone them for fine-tuning and send-out when needed. + +Below is an overly-simplified example, to showcase the basic functionality. Note that by not defining From: and To: addresses for a message, they default to the sender address, which is convenient for system messaging. + + package main + + import ( + "log" + + "github.com/agext/email" + ) + + var ( + host = "smtp.example.com" + user = "username" + pass = "password" + name = "Application mail" + addr = "app@example.com" + sender *email.Sender + contactFormMessage *email.Message + ) + + func main() { + var err error + // create a sender with a given configuration + sender, err = email.NewSender(host, user, pass, name, addr) + if err != nil { + log.Fatalln("invalid sender configuration: " + err.Error()) + } + + // create a message from scratch, to be used as a base for the actual messages + contactFormMessage = email.NewMessage(nil). + SubjectTemplate("Contact form message from {{.first}} {{.last}}"). + TextTemplate(` + First Name: {{.first}} + Last Name: {{.last}} + Phone: {{.phone}} + Email: {{.email}} + `) + + // ... + } + + func sendContact(data map[string]interface{}) error { + // create a message from the base we created in main() - basically, clone all its data + msg := email.NewMessage(contactFormMessage) + + // customize / adapt the message as needed... + // if the base messages are well thought out, the need should be minimal, if at all + // as an example, we could set the To: address based on the form data, to dispatch the messages + // to the person in the company who is most capable to handle it. + + // send the message after composing it with the provided date + err := sender.Send(msg, data) + + if err != nil { + log.Println(err, msg.Errors()) + } + + return err + } + +*/ +package email diff --git a/encoding.go b/encoding.go new file mode 100644 index 0000000..517fe9f --- /dev/null +++ b/encoding.go @@ -0,0 +1,222 @@ +package email + +const ( + hextable = "0123456789ABCDEF" + base64table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +) + +// QuotedPrintableEncode encodes the src data using the quoted-printable content +// transfer encoding specified by RFC 2045. Although RFC 2045 does not require that +// UTF multi-byte characters be kept on the same line of encoded text, this function +// does so. +func QuotedPrintableEncode(src []byte) []byte { + srcLen := len(src) + if srcLen == 0 { + return []byte{} + } + // guestimate max size of dst, trying to avoid reallocation on append + dst := make([]byte, 0, 2*srcLen) + pos := 0 + + var ( + c byte + le int + // 'ending in space'; does the encoded text end in a whitespace ? + eis bool + ) + enc := make([]byte, 0, 12) // enough for encoding a 4-byte utf symbol + for i := 0; i < srcLen; i++ { + enc, eis = enc[:0], false + switch c = src[i]; { + case c == '\t', c == ' ': + enc = append(enc, c) + eis = true + case '!' <= c && c <= '~' && c != '=': + enc = append(enc, c) + case c&0xC0 == 0xC0: + // start of utf-8 rune; subsequent bytes always have the top two bits set to 10. + enc = append(make([]byte, 0, 12), '=', hextable[c>>4], hextable[c&0x0f]) + for i++; i < srcLen; i++ { + c = src[i] + if c&0xC0 != 0x80 { + // stepped past the end of the rune; step back and break out + i-- + break + } + enc = append(enc, '=', hextable[c>>4], hextable[c&0x0f]) + } + default: + enc = append(enc, '=', hextable[c>>4], hextable[c&0x0f]) + } + le = len(enc) + if pos += le; pos > 75 { // max 76; need room for '=' + dst = append(dst, []byte("=\r\n")...) + pos = le + } + dst = append(dst, enc...) + } + if eis { + dst = append(dst, '=') + } + return dst +} + +// QEncode encodes the src data using the q-encoding encoded-word syntax specified +// by RFC 2047. Since RFC 2047 requires that each line of a header that includes +// encoded-word text be no longer than 76, this function takes an offset argument +// for the length of the current header line already used up, e.g. by the header +// name, colon and space. +func QEncode(src []byte, offset int) (dst []byte, pos int) { + srcLen := len(src) + if srcLen == 0 { + return []byte{}, offset + } + + // guestimate max size of dst, trying to avoid reallocation on append + dst = make([]byte, 0, 12+2*srcLen) + + if offset < 1 { + // header line can be max 76, but encoded-words can only be max 75; + // on subsequent lines, if any, the leading space evens things out, + // but if the first line is empty, we need to pretend it has one char. + offset = 1 + } + // count in the 10 chars of "=?utf-8?q?", but do not add them yet! There is + // a chance that we cannot fit even one encoded character on the first line, + // but we won't know its length until we encoded it. + pos = 10 + offset + + var ( + c byte + le int + ) + enc := make([]byte, 0, 12) // enough for encoding a 4-byte utf symbol + for i := 0; i < srcLen; i++ { + enc = enc[:0] + switch c = src[i]; { + case c == ' ': + enc = append(enc, '_') + case '!' <= c && c <= '~' && c != '=' && c != '?' && c != '_': + enc = append(enc, c) + case c&0xC0 == 0xC0: + // start of utf-8 rune; subsequent bytes always have the top two bits set to 10. + enc = append(make([]byte, 0, 12), '=', hextable[c>>4], hextable[c&0x0f]) + for i++; i < srcLen; i++ { + c = src[i] + if c&0xC0 != 0x80 { + // stepped past the end of the rune; step back and break out + i-- + break + } + enc = append(enc, '=', hextable[c>>4], hextable[c&0x0f]) + } + default: + enc = append(enc, '=', hextable[c>>4], hextable[c&0x0f]) + } + le = len(enc) + if pos += le; pos > 74 { // max 76; need room for '?=' + if len(dst) > 0 { + dst = append(dst, []byte("?=\r\n =?utf-8?q?")...) + } else { + // the first encoded char doesn't fit on the first line, so + // start a new line and the encoded-word + dst = append(dst, []byte("\r\n =?utf-8?q?")...) + } + pos = le + 11 + } else { + if len(dst) == 0 { + // the first encoded char fits on the first line, so start the encoded-word + dst = append(dst, []byte("=?utf-8?q?")...) + } + } + dst = append(dst, enc...) + } + dst = append(dst, '?', '=') + pos += 2 + return +} + +// QEncodeIfNeeded q-encodes the src data only if it contains 'unsafe' characters. +func QEncodeIfNeeded(src []byte, offset int) (dst []byte) { + safe := true + for i, sl := 0, len(src); i < sl && safe; i++ { + safe = ' ' <= src[i] && src[i] <= '~' + } + if safe { + return src + } + dst, _ = QEncode(src, offset) + return dst +} + +// Base64Encode encodes the src data using the base64 content transfer encoding +// specified by RFC 2045. The result is the equivalent of base64-encoding src using +// StdEncoding from the standard package encoding/base64, then breaking it into +// lines of maximum 76 characters, separated by CRLF. Besides convenience, this +// function also has the advantage of combining the encoding and line-breaking +// steps into a single pass, with a single buffer allocation. +func Base64Encode(src []byte) []byte { + if len(src) == 0 { + return []byte{} + } + dstLen := ((len(src) + 2) / 3 * 4) // base64 encoded length + dstLen += (dstLen - 1) / 76 * 2 // add 2 bytes for each full 76-char line + dst := make([]byte, dstLen) + // fmt.Println(len(src), dstLen) + + var ( + p [4]int + ) +Loop: + for pos, line := 0, 0; len(src) > 0; { + // fmt.Println("step", pos, len(src), len(dst)) + switch 76 - line { + case 0: + dst[pos], dst[pos+1] = '\r', '\n' + p[0], p[1], p[2], p[3] = pos+2, pos+3, pos+4, pos+5 + pos += 6 + line = 4 + case 1: + dst[pos+1], dst[pos+2] = '\r', '\n' + p[0], p[1], p[2], p[3] = pos, pos+3, pos+4, pos+5 + pos += 6 + line = 3 + case 2: + dst[pos+2], dst[pos+3] = '\r', '\n' + p[0], p[1], p[2], p[3] = pos, pos+1, pos+4, pos+5 + pos += 6 + line = 2 + case 3: + dst[pos+3], dst[pos+4] = '\r', '\n' + p[0], p[1], p[2], p[3] = pos, pos+1, pos+2, pos+5 + pos += 6 + line = 1 + default: + p[0], p[1], p[2], p[3] = pos, pos+1, pos+2, pos+3 + pos += 4 + line += 4 + } + + switch len(src) { + case 1: + dst[p[3]], dst[p[2]] = '=', '=' + dst[p[1]] = base64table[(src[0]<<4)&0x3F] + dst[p[0]] = base64table[src[0]>>2] + break Loop + case 2: + dst[p[3]] = '=' + dst[p[2]] = base64table[(src[1]<<2)&0x3F] + dst[p[1]] = base64table[(src[1]>>4)|(src[0]<<4)&0x3F] + dst[p[0]] = base64table[src[0]>>2] + break Loop + default: + dst[p[3]] = base64table[src[2]&0x3F] + dst[p[2]] = base64table[(src[2]>>6)|(src[1]<<2)&0x3F] + dst[p[1]] = base64table[(src[1]>>4)|(src[0]<<4)&0x3F] + dst[p[0]] = base64table[src[0]>>2] + src = src[3:] + } + } + + return dst +} diff --git a/encoding_test.go b/encoding_test.go new file mode 100644 index 0000000..bb5f1f3 --- /dev/null +++ b/encoding_test.go @@ -0,0 +1,163 @@ +package email + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "testing" +) + +type encodingTestCase struct { + src, exp []byte +} + +func Test_Base64Encode(t *testing.T) { + for i := 1; i < 1024; i++ { + src := make([]byte, i) + rand.Read(src) + el := base64.StdEncoding.EncodedLen(i) + b64 := make([]byte, el) + base64.StdEncoding.Encode(b64, src) + exp := make([]byte, 0, el+el/76*2) + for len(b64) > 76 { + exp = append(exp, b64[:76]...) + exp = append(exp, '\r', '\n') + b64 = b64[76:] + } + exp = append(exp, b64...) + if act := Base64Encode(src); !bytes.Equal(act, exp) { + t.Errorf("Base64Encode(%d): got (len=%d)\n%s\nwant (len=%d)\n%s", i, len(act), act, len(exp), exp) + } + } +} + +func Test_QuotedPrintableEncode(t *testing.T) { + cases := []encodingTestCase{ + {[]byte("test "), []byte("test =")}, + {[]byte("test\n me"), []byte("test=0A me")}, + {[]byte("test\\/me=again…"), []byte("test\\/me=3Dagain=E2=80=A6")}, + {[]byte("Lorem ipsum dolor sit amet, no sit enim fugit, solum omittam evertitur qui cu. Usu ad sonet facilisis, cu partem platonem conceptam has. Tincidunt scribentur nec ex, eu hinc quodsi consequat quo, ex est labore fuisset. Vel semper salutatus ne."), + []byte("Lorem ipsum dolor sit amet, no sit enim fugit, solum omittam evertitur qui =\r\n" + + "cu. Usu ad sonet facilisis, cu partem platonem conceptam has. Tincidunt scr=\r\n" + + "ibentur nec ex, eu hinc quodsi consequat quo, ex est labore fuisset. Vel se=\r\n" + + "mper salutatus ne.")}, + {[]byte("Δεσωρε αππελλανθυρ υθ μει, αν ηαβεο ομνες νυμκυαμ μεα. Αδ φιξ αλικυιπ ινφιδυντ, ηις εξ σαπερεθ δετρασθο σαεφολα, αδ δολορ αλικυανδο ηας. Ευ πυρθο ιυδισο εως, φισι σωνσεκυαθ πρι ευ. Ασυμ σοντεντιωνες ιυς ει, ει κυαεκυε ινσωλενς σενσιβυς κυο. Εξ κυωτ αλιενυμ ηις, συ πρω σονσυλατυ μεδιοσριθαθεμ. Τιβικυε ινστρυσθιορ κυι νο, ευμ ιδ κυοδσι τασιμαθες αδωλεσενς."), + []byte("=CE=94=CE=B5=CF=83=CF=89=CF=81=CE=B5 =CE=B1=CF=80=CF=80=CE=B5=CE=BB=CE=BB=\r\n" + + "=CE=B1=CE=BD=CE=B8=CF=85=CF=81 =CF=85=CE=B8 =CE=BC=CE=B5=CE=B9, =CE=B1=\r\n" + + "=CE=BD =CE=B7=CE=B1=CE=B2=CE=B5=CE=BF =CE=BF=CE=BC=CE=BD=CE=B5=CF=82 =CE=BD=\r\n" + + "=CF=85=CE=BC=CE=BA=CF=85=CE=B1=CE=BC =CE=BC=CE=B5=CE=B1. =CE=91=CE=B4 =\r\n" + + "=CF=86=CE=B9=CE=BE =CE=B1=CE=BB=CE=B9=CE=BA=CF=85=CE=B9=CF=80 =CE=B9=CE=BD=\r\n" + + "=CF=86=CE=B9=CE=B4=CF=85=CE=BD=CF=84, =CE=B7=CE=B9=CF=82 =CE=B5=CE=BE =\r\n" + + "=CF=83=CE=B1=CF=80=CE=B5=CF=81=CE=B5=CE=B8 =CE=B4=CE=B5=CF=84=CF=81=CE=B1=\r\n" + + "=CF=83=CE=B8=CE=BF =CF=83=CE=B1=CE=B5=CF=86=CE=BF=CE=BB=CE=B1, =CE=B1=CE=B4=\r\n" + + " =CE=B4=CE=BF=CE=BB=CE=BF=CF=81 =CE=B1=CE=BB=CE=B9=CE=BA=CF=85=CE=B1=CE=BD=\r\n" + + "=CE=B4=CE=BF =CE=B7=CE=B1=CF=82. =CE=95=CF=85 =CF=80=CF=85=CF=81=CE=B8=\r\n" + + "=CE=BF =CE=B9=CF=85=CE=B4=CE=B9=CF=83=CE=BF =CE=B5=CF=89=CF=82, =CF=86=\r\n" + + "=CE=B9=CF=83=CE=B9 =CF=83=CF=89=CE=BD=CF=83=CE=B5=CE=BA=CF=85=CE=B1=CE=B8 =\r\n" + + "=CF=80=CF=81=CE=B9 =CE=B5=CF=85. =CE=91=CF=83=CF=85=CE=BC =CF=83=CE=BF=\r\n" + + "=CE=BD=CF=84=CE=B5=CE=BD=CF=84=CE=B9=CF=89=CE=BD=CE=B5=CF=82 =CE=B9=CF=85=\r\n" + + "=CF=82 =CE=B5=CE=B9, =CE=B5=CE=B9 =CE=BA=CF=85=CE=B1=CE=B5=CE=BA=CF=85=\r\n" + + "=CE=B5 =CE=B9=CE=BD=CF=83=CF=89=CE=BB=CE=B5=CE=BD=CF=82 =CF=83=CE=B5=CE=BD=\r\n" + + "=CF=83=CE=B9=CE=B2=CF=85=CF=82 =CE=BA=CF=85=CE=BF. =CE=95=CE=BE =CE=BA=\r\n" + + "=CF=85=CF=89=CF=84 =CE=B1=CE=BB=CE=B9=CE=B5=CE=BD=CF=85=CE=BC =CE=B7=CE=B9=\r\n" + + "=CF=82, =CF=83=CF=85 =CF=80=CF=81=CF=89 =CF=83=CE=BF=CE=BD=CF=83=CF=85=\r\n" + + "=CE=BB=CE=B1=CF=84=CF=85 =CE=BC=CE=B5=CE=B4=CE=B9=CE=BF=CF=83=CF=81=CE=B9=\r\n" + + "=CE=B8=CE=B1=CE=B8=CE=B5=CE=BC. =CE=A4=CE=B9=CE=B2=CE=B9=CE=BA=CF=85=CE=B5 =\r\n" + + "=CE=B9=CE=BD=CF=83=CF=84=CF=81=CF=85=CF=83=CE=B8=CE=B9=CE=BF=CF=81 =CE=BA=\r\n" + + "=CF=85=CE=B9 =CE=BD=CE=BF, =CE=B5=CF=85=CE=BC =CE=B9=CE=B4 =CE=BA=CF=85=\r\n" + + "=CE=BF=CE=B4=CF=83=CE=B9 =CF=84=CE=B1=CF=83=CE=B9=CE=BC=CE=B1=CE=B8=CE=B5=\r\n" + + "=CF=82 =CE=B1=CE=B4=CF=89=CE=BB=CE=B5=CF=83=CE=B5=CE=BD=CF=82.")}, + } + for _, c := range cases { + if act := QuotedPrintableEncode(c.src); !bytes.Equal(act, c.exp) { + t.Errorf("QuotedPrintableEncode: got (len=%d)\n%s\nwant (len=%d)\n%s", len(act), act, len(c.exp), c.exp) + } + } +} + +func Test_QEncode(t *testing.T) { + cases := []encodingTestCase{ + {[]byte("test "), []byte("=?utf-8?q?test_?=")}, + {[]byte("test\n me"), []byte("=?utf-8?q?test=0A_me?=")}, + {[]byte("test\\/me=again…"), []byte("=?utf-8?q?test\\/me=3Dagain=E2=80=A6?=")}, + {[]byte("Lorem ipsum dolor sit amet, no sit enim fugit, solum omittam evertitur qui cu. Usu ad sonet facilisis, cu partem platonem conceptam has. Tincidunt scribentur nec ex, eu hinc quodsi consequat quo, ex est labore fuisset. Vel semper salutatus ne."), + []byte("=?utf-8?q?Lorem_ipsum_dolor_sit_amet,_no_s?=\r\n" + + " =?utf-8?q?it_enim_fugit,_solum_omittam_evertitur_qui_cu._Usu_ad_sonet_fac?=\r\n" + + " =?utf-8?q?ilisis,_cu_partem_platonem_conceptam_has._Tincidunt_scribentur_?=\r\n" + + " =?utf-8?q?nec_ex,_eu_hinc_quodsi_consequat_quo,_ex_est_labore_fuisset._Ve?=\r\n" + + " =?utf-8?q?l_semper_salutatus_ne.?=")}, + {[]byte("Δεσωρε αππελλανθυρ υθ μει, αν ηαβεο ομνες νυμκυαμ μεα. Αδ φιξ αλικυιπ ινφιδυντ, ηις εξ σαπερεθ δετρασθο σαεφολα, αδ δολορ αλικυανδο ηας. Ευ πυρθο ιυδισο εως, φισι σωνσεκυαθ πρι ευ. Ασυμ σοντεντιωνες ιυς ει, ει κυαεκυε ινσωλενς σενσιβυς κυο. Εξ κυωτ αλιενυμ ηις, συ πρω σονσυλατυ μεδιοσριθαθεμ. Τιβικυε ινστρυσθιορ κυι νο, ευμ ιδ κυοδσι τασιμαθες αδωλεσενς."), + []byte("=?utf-8?q?=CE=94=CE=B5=CF=83=CF=89=CF=81?=\r\n" + + " =?utf-8?q?=CE=B5_=CE=B1=CF=80=CF=80=CE=B5=CE=BB=CE=BB=CE=B1=CE=BD=CE=B8?=\r\n" + + " =?utf-8?q?=CF=85=CF=81_=CF=85=CE=B8_=CE=BC=CE=B5=CE=B9,_=CE=B1=CE=BD_?=\r\n" + + " =?utf-8?q?=CE=B7=CE=B1=CE=B2=CE=B5=CE=BF_=CE=BF=CE=BC=CE=BD=CE=B5=CF=82_?=\r\n" + + " =?utf-8?q?=CE=BD=CF=85=CE=BC=CE=BA=CF=85=CE=B1=CE=BC_=CE=BC=CE=B5=CE=B1._?=\r\n" + + " =?utf-8?q?=CE=91=CE=B4_=CF=86=CE=B9=CE=BE_=CE=B1=CE=BB=CE=B9=CE=BA=CF=85?=\r\n" + + " =?utf-8?q?=CE=B9=CF=80_=CE=B9=CE=BD=CF=86=CE=B9=CE=B4=CF=85=CE=BD=CF=84,_?=\r\n" + + " =?utf-8?q?=CE=B7=CE=B9=CF=82_=CE=B5=CE=BE_=CF=83=CE=B1=CF=80=CE=B5=CF=81?=\r\n" + + " =?utf-8?q?=CE=B5=CE=B8_=CE=B4=CE=B5=CF=84=CF=81=CE=B1=CF=83=CE=B8=CE=BF_?=\r\n" + + " =?utf-8?q?=CF=83=CE=B1=CE=B5=CF=86=CE=BF=CE=BB=CE=B1,_=CE=B1=CE=B4_=CE=B4?=\r\n" + + " =?utf-8?q?=CE=BF=CE=BB=CE=BF=CF=81_=CE=B1=CE=BB=CE=B9=CE=BA=CF=85=CE=B1?=\r\n" + + " =?utf-8?q?=CE=BD=CE=B4=CE=BF_=CE=B7=CE=B1=CF=82._=CE=95=CF=85_=CF=80?=\r\n" + + " =?utf-8?q?=CF=85=CF=81=CE=B8=CE=BF_=CE=B9=CF=85=CE=B4=CE=B9=CF=83=CE=BF_?=\r\n" + + " =?utf-8?q?=CE=B5=CF=89=CF=82,_=CF=86=CE=B9=CF=83=CE=B9_=CF=83=CF=89=CE=BD?=\r\n" + + " =?utf-8?q?=CF=83=CE=B5=CE=BA=CF=85=CE=B1=CE=B8_=CF=80=CF=81=CE=B9_=CE=B5?=\r\n" + + " =?utf-8?q?=CF=85._=CE=91=CF=83=CF=85=CE=BC_=CF=83=CE=BF=CE=BD=CF=84=CE=B5?=\r\n" + + " =?utf-8?q?=CE=BD=CF=84=CE=B9=CF=89=CE=BD=CE=B5=CF=82_=CE=B9=CF=85=CF=82_?=\r\n" + + " =?utf-8?q?=CE=B5=CE=B9,_=CE=B5=CE=B9_=CE=BA=CF=85=CE=B1=CE=B5=CE=BA=CF=85?=\r\n" + + " =?utf-8?q?=CE=B5_=CE=B9=CE=BD=CF=83=CF=89=CE=BB=CE=B5=CE=BD=CF=82_=CF=83?=\r\n" + + " =?utf-8?q?=CE=B5=CE=BD=CF=83=CE=B9=CE=B2=CF=85=CF=82_=CE=BA=CF=85=CE=BF._?=\r\n" + + " =?utf-8?q?=CE=95=CE=BE_=CE=BA=CF=85=CF=89=CF=84_=CE=B1=CE=BB=CE=B9=CE=B5?=\r\n" + + " =?utf-8?q?=CE=BD=CF=85=CE=BC_=CE=B7=CE=B9=CF=82,_=CF=83=CF=85_=CF=80?=\r\n" + + " =?utf-8?q?=CF=81=CF=89_=CF=83=CE=BF=CE=BD=CF=83=CF=85=CE=BB=CE=B1=CF=84?=\r\n" + + " =?utf-8?q?=CF=85_=CE=BC=CE=B5=CE=B4=CE=B9=CE=BF=CF=83=CF=81=CE=B9=CE=B8?=\r\n" + + " =?utf-8?q?=CE=B1=CE=B8=CE=B5=CE=BC._=CE=A4=CE=B9=CE=B2=CE=B9=CE=BA=CF=85?=\r\n" + + " =?utf-8?q?=CE=B5_=CE=B9=CE=BD=CF=83=CF=84=CF=81=CF=85=CF=83=CE=B8=CE=B9?=\r\n" + + " =?utf-8?q?=CE=BF=CF=81_=CE=BA=CF=85=CE=B9_=CE=BD=CE=BF,_=CE=B5=CF=85?=\r\n" + + " =?utf-8?q?=CE=BC_=CE=B9=CE=B4_=CE=BA=CF=85=CE=BF=CE=B4=CF=83=CE=B9_=CF=84?=\r\n" + + " =?utf-8?q?=CE=B1=CF=83=CE=B9=CE=BC=CE=B1=CE=B8=CE=B5=CF=82_=CE=B1=CE=B4?=\r\n" + + " =?utf-8?q?=CF=89=CE=BB=CE=B5=CF=83=CE=B5=CE=BD=CF=82.?=")}, + } + for _, c := range cases { + expOffset := 32 + len(c.exp) + if pos := bytes.LastIndex(c.exp, []byte("\r\n")); pos > -1 { + expOffset = len(c.exp) - pos - 2 + } + act, pos := QEncode(c.src, 32) + if !bytes.Equal(act, c.exp) { + t.Errorf("QEncode: got (len=%d)\n%s\nwant (len=%d)\n%s", len(act), act, len(c.exp), c.exp) + } + if pos != expOffset { + t.Errorf("QEncode: got offset = %d, want %d", pos, expOffset) + + } + } +} + +func Benchmark_Base64Encode(b *testing.B) { + for i := 0; i < b.N; i++ { + srcLen := i/1024 + 100 + src := make([]byte, srcLen) + rand.Read(src) + act := Base64Encode(src) + _ = act + } +} +func Benchmark_Base64Encode_builtin(b *testing.B) { + for i := 0; i < b.N; i++ { + srcLen := i/1024 + 100 + src := make([]byte, srcLen) + rand.Read(src) + el := base64.StdEncoding.EncodedLen(srcLen) + b64 := make([]byte, el) + base64.StdEncoding.Encode(b64, src) + exp := make([]byte, 0, el+el/76*2) + for len(b64) > 76 { + exp = append(exp, b64[:76]...) + exp = append(exp, '\r', '\n') + b64 = b64[76:] + } + exp = append(exp, b64...) + _ = exp + } +} diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..7f5091e --- /dev/null +++ b/helpers.go @@ -0,0 +1,53 @@ +package email + +import ( + "html" + "regexp" + "strings" +) + +var ( + // HTML tags; assumes correctly formed HTML tags, with properly escaped attribute values + reHtmlTags = regexp.MustCompile(`<[^>]+>`) + // whitespace, including \xa0 (ASCII 160; non-breaking space) + reWhitespace = regexp.MustCompile(`[\s\xa0]+`) + // \xa0 (ASCII 160) is non-breaking space + htmlToTextREWhitespace = regexp.MustCompile(`(\s|\xa0| )+`) + // tags that we want removed completely, including contents + htmlToTextRETagsRm = regexp.MustCompile(`(?i)||`) + // tags that we want convert to line breaks + htmlToTextRETagsLn = regexp.MustCompile(`(?i)<(/h\d|/p|p|br|/ul|/ol|/li|/div|/table|/td)[^a-z]`) + // tags that we want convert to space + htmlToTextRETagsSp = regexp.MustCompile(`(?i)<(/?p|br|/?ul|/?ol|/?li|/?div|/?table|/?td|hr|img)`) + // the alt text from img tags + htmlToTextREImgAlt = regexp.MustCompile(`(?is)]*alt\s*=\s*"([^"]+)"`) + // the "href" url from links + htmlToTextREAHref = regexp.MustCompile(`(?is)]*href\s*=\s*"([^"]+)".*`) +) + +func htmlToText(src string) string { + // reduce multiple whitespace chars to single space + src = htmlToTextREWhitespace.ReplaceAllLiteralString(src, " ") + // remove these tags completely, including contents + src = htmlToTextRETagsRm.ReplaceAllString(src, "") + // make sure we have line breaks before these tags + src = htmlToTextRETagsLn.ReplaceAllString(src, "\n$0") + // make sure we have white space before these tags + src = htmlToTextRETagsSp.ReplaceAllString(src, " $0") + // extract the alt text from images + src = htmlToTextREImgAlt.ReplaceAllString(src, "$1$0") + // extract the "href" url from links + src = htmlToTextREAHref.ReplaceAllString(src, "$0 [ $1 ] ") + // strip tags + src = reHtmlTags.ReplaceAllLiteralString(src, "") + // convert html entities to UTF-8 characters + src = html.UnescapeString(src) + // reduce whitespace again; preserve the number of newline chars, or at least a space + src = reWhitespace.ReplaceAllStringFunc(src, func(m string) string { + if n := strings.Count(m, "\n"); n > 0 { + return strings.Repeat("\n", n) + } + return " " + }) + return strings.TrimSpace(src) +} diff --git a/message.go b/message.go new file mode 100644 index 0000000..c1228ff --- /dev/null +++ b/message.go @@ -0,0 +1,739 @@ +package email + +import ( + "bytes" + "crypto/sha256" + "errors" + htpl "html/template" + "io/ioutil" + "mime" + "path/filepath" + "strconv" + "sync" + ttpl "text/template" + "time" +) + +// CTE represents a "Content-Transfer-Encoding" method identifier. +type CTE byte + +const ( + // AutoCTE leaves it up to the package to determine CTE + AutoCTE CTE = iota + // QuotedPrintable indicates "quoted-printable" CTE + QuotedPrintable + // Base64 indicates "base64" CTE + Base64 +) + +var ( + now = time.Now +) + +// Message represents all the information necessary for composing an email message with optional +// external data, and sending it via a Sender. +type Message struct { + sync.RWMutex + domain []byte + subject []byte + subjectTplSrc string + subjectTpl *ttpl.Template + sender *Sender + from, replyTo *Address + to, cc, bcc addrList + parts []*part + text, html *part + attachments []*attachment + errors []error + prepared bool +} + +// Domain sets the domain portion of the generated message Id. +// +// If not specified, the domain is extracted from the sender email address - which is +// the right choice for most applications. +func (m *Message) Domain(domain string) *Message { + m.Lock() + defer m.Unlock() + m.domain = []byte(domain) + return m +} + +func (m *Message) setSender(s *Sender) *Message { + m.Lock() + defer m.Unlock() + m.sender = s + return m +} + +// Subject sets the text for the subject of the message. +func (m *Message) Subject(subject string) *Message { + m.Lock() + defer m.Unlock() + m.subject = []byte(subject) + return m +} + +// SubjectTemplate sets a template for the subject of the message. +func (m *Message) SubjectTemplate(tpl string) *Message { + var ( + t *ttpl.Template + err error + ) + if tpl != "" { + t, err = ttpl.New("").Parse(tpl) + if err != nil { + m.errors = append(m.errors, errors.New("invalid subject template:\n"+tpl+"\nerror: "+err.Error())) + return m + } + } + m.Lock() + defer m.Unlock() + m.subjectTplSrc = tpl + m.subjectTpl = t + return m +} + +// From sets the From: email address. +func (m *Message) From(addr *Address) *Message { + if addr != nil && !SeemsValidAddr(addr.Addr) { + addr = nil + } + m.Lock() + defer m.Unlock() + m.from = addr + return m +} + +// To sets the To: email address(es). Last call overrides any previous calls, replacing rather than +// adding to the list. +func (m *Message) To(addr ...*Address) *Message { + lst := make(addrList, 0, len(addr)) + for _, a := range addr { + if a != nil && SeemsValidAddr(a.Addr) { + lst = append(lst, a) + } + } + m.Lock() + defer m.Unlock() + m.to = lst + return m +} + +// Cc sets the (optional) Cc: email addresses. Last call overrides any previous calls, replacing rather than +// adding to the list. +func (m *Message) Cc(addr ...*Address) *Message { + lst := make(addrList, 0, len(addr)) + for _, a := range addr { + if a != nil && SeemsValidAddr(a.Addr) { + lst = append(lst, a) + } + } + m.Lock() + defer m.Unlock() + m.cc = lst + return m +} + +// Bcc sets the (optional) Bcc: email addresses. Last call overrides any previous calls, replacing rather than +// adding to the list. +func (m *Message) Bcc(addr ...*Address) *Message { + lst := make(addrList, 0, len(addr)) + for _, a := range addr { + if a != nil && SeemsValidAddr(a.Addr) { + lst = append(lst, a) + } + } + m.Lock() + defer m.Unlock() + m.bcc = lst + return m +} + +// ReplyTo sets the (optional) Reply-To: email address. A `*Address` argument is expected for +// consistency, although only the email address part is used. +func (m *Message) ReplyTo(addr *Address) *Message { + if addr != nil && !SeemsValidAddr(addr.Addr) { + addr = nil + } + m.Lock() + defer m.Unlock() + m.replyTo = addr + return m +} + +// Part adds an alternative part to the message. For a plain-text and/or an HTML body use the +// convenience methods: Text, TextTemplate, Html or HtmlTemplate. +func (m *Message) Part(ctype string, cte CTE, bytes []byte, related ...Related) *Message { + m.Lock() + defer m.Unlock() + m.parts = append(m.parts, &part{ + ctype: ctype, + cte: cte, + bytes: bytes, + related: related, + }) + m.prepared = false // related may include files + return m +} + +// Text sets the plain-text version of the message body to the provided content. +func (m *Message) Text(text string) *Message { + m.Lock() + defer m.Unlock() + if m.text == nil { + m.text = &part{} + m.parts = append(m.parts, m.text) + } + *(m.text) = part{ + ctype: "text/plain; charset=utf-8", + cte: QuotedPrintable, + bytes: []byte(text), + } + return m +} + +// TextTemplate sets the plain-text version of the message body to the provided template. +func (m *Message) TextTemplate(tpl string) *Message { + var ( + t *ttpl.Template + err error + ) + if tpl != "" { + t, err = ttpl.New("").Parse(tpl) + if err != nil { + m.errors = append(m.errors, errors.New("invalid text template:\n"+tpl+"\nerror: "+err.Error())) + return m + } + } + m.Lock() + defer m.Unlock() + if m.text == nil { + m.text = &part{} + m.parts = append(m.parts, m.text) + } + *(m.text) = part{ + ctype: "text/plain; charset=utf-8", + cte: QuotedPrintable, + tplSrc: tpl, + tpl: t, + } + return m +} + +// Html sets the HTML version of the message body to the provided content. +// Optionally, related objects can be specified for inclusion. +func (m *Message) Html(html string, related ...Related) *Message { + m.Lock() + defer m.Unlock() + if m.html == nil { + m.html = &part{} + m.parts = append(m.parts, m.html) + } + *(m.html) = part{ + ctype: "text/html; charset=utf-8", + cte: QuotedPrintable, + bytes: []byte(html), + related: related, + } + m.prepared = false // related may include files + return m +} + +// HtmlTemplate sets the HTML version of the message body to the provided template. +// Optionally, related objects can be specified for inclusion. +func (m *Message) HtmlTemplate(tpl string, related ...Related) *Message { + var ( + t *htpl.Template + err error + ) + if tpl != "" { + t, err = htpl.New("").Parse(tpl) + if err != nil { + m.errors = append(m.errors, errors.New("invalid html template:\n"+tpl+"\nerror: "+err.Error())) + return m + } + } + m.Lock() + defer m.Unlock() + if m.html == nil { + m.html = &part{} + m.parts = append(m.parts, m.html) + } + *(m.html) = part{ + ctype: "text/html; charset=utf-8", + cte: QuotedPrintable, + htmlTplSrc: tpl, + htmlTpl: t, + related: related, + } + m.prepared = false // related may include files + return m +} + +// Attach attaches the files provided as filesystem paths. +func (m *Message) Attach(file ...string) *Message { + m.Lock() + defer m.Unlock() + for _, fileName := range file { + m.attachments = append(m.attachments, &attachment{fileName: fileName}) + } + m.prepared = false + return m +} + +// AttachFile attaches a file specified by its filesystem path, setting its name and type +// to the provided values. +func (m *Message) AttachFile(name, ctype, file string) *Message { + m.Lock() + defer m.Unlock() + m.attachments = append(m.attachments, &attachment{ + name: name, + ctype: ctype, + fileName: file, + }) + m.prepared = false + return m +} + +// AttachObject creates an attachment with the name, type and data provided. +func (m *Message) AttachObject(name, ctype string, data []byte) *Message { + m.Lock() + defer m.Unlock() + m.attachments = append(m.attachments, &attachment{ + name: name, + ctype: ctype, + data: data, + }) + return m +} + +func (m *Message) prepare(force bool) { + if m.prepared && !force { + return + } + allOk := true + for _, p := range m.parts { + for _, r := range p.related { + if r.fileName != "" && (force || len(r.data) == 0) { + if file, err := ioutil.ReadFile(r.fileName); err == nil { + r.data = file + } else { + m.errors = append(m.errors, errors.New("cannot read file: "+r.fileName+": "+err.Error())) + allOk = false + } + } + } + } + for _, a := range m.attachments { + if a.fileName != "" && (force || len(a.data) == 0) { + if file, err := ioutil.ReadFile(a.fileName); err == nil { + a.data = file + if a.name == "" { + a.name = filepath.Base(a.fileName) + } + if a.ctype == "" { + a.ctype = mime.TypeByExtension(filepath.Ext(a.fileName)) + } + } else { + m.errors = append(m.errors, errors.New("cannot read file: "+a.fileName+": "+err.Error())) + allOk = false + } + } + } + m.prepared = allOk +} + +// Prepare reads all the files referenced by the message at attachments or related items. +// +// If the message was already prepared and no new files have been added, it is no-op. +func (m *Message) Prepare() *Message { + m.Lock() + defer m.Unlock() + m.prepare(false) + return m +} + +// PrepareFresh forces a new preparation of the message, even if there were no changes to the referred +// files since the previous one. +func (m *Message) PrepareFresh() *Message { + m.Lock() + defer m.Unlock() + m.prepare(true) + return m +} + +// Compose merges the `data` into the receiver's templates and creates the body of the SMTP message +// to be sent. +func (m *Message) Compose(data interface{}) []byte { + m.Lock() + defer m.Unlock() + var ( + from *Address + recpts []*Address + buf bytes.Buffer + ) + switch { + case m.from != nil: + from = m.from + case m.sender != nil && m.sender.address != nil: + from = m.sender.address + case defaultSender != nil && defaultSender.address != nil: + from = defaultSender.address + } + if from == nil { + m.errors = append(m.errors, errors.New("no From address")) + return []byte{} + } + if m.subjectTpl != nil { + buf.Reset() + if err := m.subjectTpl.Execute(&buf, data); err != nil { + m.errors = append(m.errors, errors.New("failed Execute on subject template: "+err.Error())) + } + m.subject = make([]byte, buf.Len()) + copy(m.subject, buf.Bytes()) + } + for partNo, partData := range m.parts { + switch { + case partData.tpl != nil: + buf.Reset() + if err := partData.tpl.Execute(&buf, data); err != nil { + m.errors = append(m.errors, errors.New("failed Execute on part["+strconv.Itoa(partNo)+"] template: "+err.Error())) + } + partData.bytes = make([]byte, buf.Len()) + copy(partData.bytes, buf.Bytes()) + case partData.htmlTpl != nil: + buf.Reset() + if err := partData.htmlTpl.Execute(&buf, data); err != nil { + m.errors = append(m.errors, errors.New("failed Execute on part["+strconv.Itoa(partNo)+"] html template: "+err.Error())) + } + partData.bytes = make([]byte, buf.Len()) + copy(partData.bytes, buf.Bytes()) + } + } + if len(m.parts) == 0 { + m.errors = append(m.errors, errors.New("message has no parts")) + } + m.prepare(false) + if len(m.errors) != 0 { + return []byte{} + } + + domain := m.domain + if len(domain) == 0 { + domain = []byte(from.Domain()) + } + + ts := []byte(now().In(time.UTC).Format(time.RFC1123Z)) + // hash := sha256.New() + // hash.Write(ts) + // hash.Write(m.subject) + hash := sha256.Sum256(append(ts, m.subject...)) + // uid := Base64Encode(hash.Sum(nil))[:43] // discard padding '=' + uid := Base64Encode(hash[:])[:43] // discard padding '=' + // fmt.Println(string(ts), string(m.subject), ":", string(uid), "--", base64.RawStdEncoding.EncodeToString(hash[:])) + + msg := newBuffer(4096) + msg.Write("Message-ID: <", uid, '@', domain, ">\r\n") + msg.Write("Date: ", ts, "\r\n") + msg.Write("Subject: ", QEncodeIfNeeded(m.subject, 9), "\r\n") + addr, _ := from.encode(6) + msg.Write("From: ", addr, "\r\n") + if m.replyTo != nil && m.replyTo.Addr != "" && m.replyTo.Addr != from.Addr { + addr, _ = m.replyTo.encode(10) + msg.Write("Reply-To: ", addr, "\r\n") + } + + listAddrs := func(list []*Address, offset int) []byte { + addrs := newBuffer(1024) + for i, item := range list { + if i > 0 { + switch { + case offset < 75: + addrs.Write(", ") + offset += 2 + case offset < 76: + addrs.Write(",\r\n ") + offset = 1 + default: + addrs.Write("\r\n , ") + offset = 3 + } + } + addr, offset = item.encode(offset) + addrs.Write(addr) + } + return addrs.Bytes() + } + + recpts = m.to + if len(recpts) == 0 { + recpts = []*Address{from} + } + msg.Write("To: ", listAddrs(recpts, 4), "\r\n") + if len(m.cc) > 0 { + msg.Write("Cc: ", listAddrs(m.cc, 4), "\r\n") + } + + // Do not add BCC addresses into the message - they will show up at all recipients! + + msg.Write("MIME-Version: 1.0\r\n") + + if len(m.attachments) > 0 { + msg.Write("Content-Type: multipart/mixed;\r\n\tboundary==_m", uid, + "\r\n\r\n--=_m", uid, "\r\n") + } + + alt := m.html != nil || len(m.parts) > 1 + + if alt { + msg.Write("Content-Type: multipart/alternative;\r\n\tboundary==_a", uid, "\r\n") + } + + if m.html != nil && m.text == nil { + if alt { + msg.Write("\r\n--=_a", uid, "\r\n") + } + msg.Write("Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", + QuotedPrintableEncode([]byte(htmlToText(string(m.html.bytes)))), "\r\n") + } + for partNo, partData := range m.parts { + if alt { + msg.Write("\r\n--=_a", uid, "\r\n") + } + pn := strconv.Itoa(partNo) + if len(partData.related) > 0 { + msg.Write("Content-Type: multipart/related;\r\n\tboundary==_r", pn, uid, + "\r\n\r\n--=_r", pn, uid, "\r\n") + // ToDo: substitute the related Ids in content + } + switch partData.cte { + case Base64: + msg.Write("Content-Type: ", partData.ctype, "\r\nContent-Transfer-Encoding: base64\r\n\r\n", + Base64Encode(partData.bytes), "\r\n") + default: + fallthrough + case QuotedPrintable: + msg.Write("Content-Type: ", partData.ctype, "\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", + QuotedPrintableEncode(partData.bytes), "\r\n") + } + for _, relData := range partData.related { + msg.Write("\r\n--=_r", pn, uid, "\r\n") + msg.Write("Content-Type: ", relData.ctype, "\r\nContent-Transfer-Encoding: base64\r\n\r\n", + Base64Encode(relData.data), "\r\n") + } + if len(partData.related) > 0 { + msg.Write("\r\n--=_r", pn, uid, "--\r\n") + } + } + if alt { + msg.Write("\r\n--=_a", uid, "--\r\n") + } + + for _, attData := range m.attachments { + msg.Write("\r\n--=_m", uid, "\r\n") + msg.Write("Content-Type: ", attData.ctype, + "\r\nContent-Disposition: attachment;\r\n\tfilename=", attData.name, + "\r\nContent-Transfer-Encoding: base64\r\n\r\n", + Base64Encode(attData.data), "\r\n") + } + + if len(m.attachments) > 0 { + msg.Write("\r\n--=_m", uid, "--\r\n") + } + + return msg.Bytes() +} + +// FromAddr returns the email address that the message would be sent from. +func (m *Message) FromAddr() string { + m.RLock() + defer m.RUnlock() + var from *Address + switch { + case m.from != nil: + from = m.from + case m.sender != nil && m.sender.address != nil: + from = m.sender.address + case defaultSender != nil && defaultSender.address != nil: + from = defaultSender.address + } + if from != nil { + return from.Addr + } + return "" +} + +// RecipientAddrs returns a list of email addresses with all the recipients for the message. +// +// It includes addresses from the To, CC and BCC fields. +func (m *Message) RecipientAddrs() []string { + m.RLock() + defer m.RUnlock() + to := make([]string, 0, len(m.to)+len(m.cc)+len(m.bcc)+1) + seen := map[string]struct{}{} + if len(m.to) == 0 { + addr := m.FromAddr() + to = append(to, addr) + seen[addr] = struct{}{} + } + for _, val := range m.to { + addr := val.Addr + if _, s := seen[addr]; !s { + to = append(to, addr) + seen[addr] = struct{}{} + } + } + for _, val := range m.cc { + addr := val.Addr + if _, s := seen[addr]; !s { + to = append(to, addr) + seen[addr] = struct{}{} + } + } + for _, val := range m.bcc { + addr := val.Addr + if _, s := seen[addr]; !s { + to = append(to, addr) + seen[addr] = struct{}{} + } + } + return to +} + +// HasErrors checks if there are any errors associated with the receiver +func (m *Message) HasErrors() bool { + m.RLock() + defer m.RUnlock() + return len(m.errors) > 0 +} + +// Errors returns the list of errors associated with the receiver, then resets the internal list. +func (m *Message) Errors() (errs []error) { + m.Lock() + defer m.Unlock() + errs, m.errors = m.errors, nil + return +} + +// NewMessage creates a new Message, deep-copying from `msg`, if provided. +func NewMessage(msg *Message) *Message { + if msg == nil { + return &Message{prepared: true} + } + msg.RLock() + defer msg.RUnlock() + m := &Message{ + domain: msg.domain, + sender: msg.sender, + subject: msg.subject, + subjectTplSrc: msg.subjectTplSrc, + from: msg.from.Clone(), + replyTo: msg.replyTo.Clone(), + to: msg.to.Clone(), + cc: msg.cc.Clone(), + bcc: msg.bcc.Clone(), + prepared: msg.prepared, + } + if msg.subjectTplSrc != "" { + // the template source was already parsed successfully once, so it is guaranteed to be valid + m.subjectTpl, _ = ttpl.New("").Parse(msg.subjectTplSrc) + } + m.parts = make([]*part, len(msg.parts)) + for i, partData := range msg.parts { + p := &part{ + ctype: partData.ctype, + cte: partData.cte, + tplSrc: partData.tplSrc, + htmlTplSrc: partData.htmlTplSrc, + // related []Related + } + if len(partData.bytes) > 0 { + p.bytes = make([]byte, len(partData.bytes)) + copy(p.bytes, partData.bytes) + } + if partData.tplSrc != "" { + // the template source was already parsed successfully once, so it is guaranteed to be valid + p.tpl, _ = ttpl.New("").Parse(partData.tplSrc) + } + if partData.htmlTplSrc != "" { + // the template source was already parsed successfully once, so it is guaranteed to be valid + p.htmlTpl, _ = htpl.New("").Parse(partData.htmlTplSrc) + } + if len(partData.related) > 0 { + p.related = make([]Related, len(partData.related)) + copy(p.related, partData.related) + // do not copy partData.related.data, to save memory; it is never updated in place + } + if msg.text == partData { + m.text = p + } + if msg.html == partData { + m.html = p + } + m.parts[i] = p + } + m.attachments = make([]*attachment, len(msg.attachments)) + for i, attData := range msg.attachments { + m.attachments[i] = attData + // do not copy attData.data, to save memory; it is never updated in place + } + return m +} + +// QuickMessage creates a Message with the subject and the body provided. Alternative text and HTML +// body versions can be provided, in this order. +func QuickMessage(subject string, body ...string) *Message { + msg := &Message{subject: []byte(subject), prepared: true} + if len(body) > 0 { + msg.Text(body[0]) + } + if len(body) > 1 { + msg.Html(body[1]) + } + return msg +} + +type part struct { + ctype string + cte CTE + bytes []byte + tplSrc string + tpl *ttpl.Template + htmlTplSrc string + htmlTpl *htpl.Template + related []Related +} + +// Related represents a multipart/related item. +type Related struct { + id string + ctype string + fileName string + data []byte +} + +// RelatedFile creates a Related structure from the provided file information. +func RelatedFile(id, ctype, file string) Related { + return Related{ + id: id, + ctype: ctype, + fileName: file, + } +} + +// RelatedObject creates a Related structure from the provided data. +func RelatedObject(id, ctype string, data []byte) Related { + return Related{ + id: id, + ctype: ctype, + data: data, + } +} + +type attachment struct { + name string + ctype string + fileName string + data []byte +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..5cbf3c4 --- /dev/null +++ b/message_test.go @@ -0,0 +1,299 @@ +package email + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +type messageObj struct { + name, ctype string + cte CTE + bytes []byte + related []Related +} +type messageIn struct { + domain string + subject string + subjectTpl string + sender *Sender + from, replyTo *Address + to, cc, bcc []*Address + parts []messageObj + text, textTpl string + html, htmlTpl string + rel []Related + attachments []messageObj +} + +type messageTestCase struct { + src messageIn + data interface{} + date time.Time + expOut []byte + expErr []string +} + +func forceNow(unix int64) { + now = func() time.Time { return time.Unix(unix, 0) } +} + +func Test_Compose(t *testing.T) { + date := time.Date(2013, 8, 30, 9, 10, 11, 0, time.UTC) + workDir, _ := os.Getwd() + cases := []messageTestCase{ + { + src: messageIn{ + subject: "Test #1", + from: &Address{"test name", "test@example.com"}, + text: "Short test message", + }, + expOut: []byte("Message-ID: <9srTfUIxPZpi3yFLMGGIRUDRiRmfXdU6R044aSQgZPc@example.com>\r\n" + + "Date: Fri, 30 Aug 2013 09:10:11 +0000\r\n" + + "Subject: Test #1\r\n" + + "From: \"test name\" \r\n" + + "To: \"test name\" \r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "Short test message\r\n"), + }, + { + src: messageIn{ + subject: "Test #2", + from: &Address{"accented nåmé", "test@example.com"}, + html: "Html test message", + }, + expOut: []byte("Message-ID: \r\n" + + "Date: Fri, 30 Aug 2013 09:10:11 +0000\r\n" + + "Subject: Test #2\r\n" + + "From: =?utf-8?q?accented_n=C3=A5m=C3=A9?= \r\n" + + "To: =?utf-8?q?accented_n=C3=A5m=C3=A9?= \r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/alternative;\r\n" + + "\tboundary==_au2lehLlrgGh7f9uCkAS4pw+z2Pp7ohm8ZguLmSnaQUU\r\n\r\n" + + "--=_au2lehLlrgGh7f9uCkAS4pw+z2Pp7ohm8ZguLmSnaQUU\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "Html test message\r\n\r\n" + + "--=_au2lehLlrgGh7f9uCkAS4pw+z2Pp7ohm8ZguLmSnaQUU\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "Html test message=\r\n" + + "\r\n\r\n" + + "--=_au2lehLlrgGh7f9uCkAS4pw+z2Pp7ohm8ZguLmSnaQUU--\r\n"), + }, + { + src: messageIn{ + subject: "Test… #3", + from: &Address{"accented nåmé", "test@example.com"}, + to: []*Address{{"Δεσωρε αππελλανθυρ υθ μει, ", "test1@example.com"}, + {"αν ηαβεο ομνες νυμκυαμ μεα.", "test2@example.com"}}, + html: "\r\nHtml test message = Αδ φιξ αλικυιπ ινφιδυντ, ηις εξ σαπερεθ δετρασθο σαεφολα, αδ δολορ αλικυανδο ηας.", + attachments: []messageObj{ + {name: "test-file.txt", ctype: "text/plain", bytes: []byte("Δεσωρε αππελλανθυρ υθ μει, αν ηαβεο ομνες νυμκυαμ μεα. Αδ φιξ αλικυιπ ινφιδυντ, ηις εξ σαπερεθ δετρασθο σαεφολα, αδ δολορ αλικυανδο ηας. Ευ πυρθο ιυδισο εως, φισι σωνσεκυαθ πρι ευ. Ασυμ σοντεντιωνες ιυς ει, ει κυαεκυε ινσωλενς σενσιβυς κυο. Εξ κυωτ αλιενυμ ηις, συ πρω σονσυλατυ μεδιοσριθαθεμ. Τιβικυε ινστρυσθιορ κυι νο, ευμ ιδ κυοδσι τασιμαθες αδωλεσενς.")}, + }, + }, + expOut: []byte("Message-ID: \r\n" + + "Date: Fri, 30 Aug 2013 09:10:11 +0000\r\n" + + "Subject: =?utf-8?q?Test=E2=80=A6_#3?=\r\n" + + "From: =?utf-8?q?accented_n=C3=A5m=C3=A9?= \r\n" + + "To: =?utf-8?q?=CE=94=CE=B5=CF=83=CF=89=CF=81=CE=B5_=CE=B1=CF=80=CF=80?=\r\n" + + " =?utf-8?q?=CE=B5=CE=BB=CE=BB=CE=B1=CE=BD=CE=B8=CF=85=CF=81_=CF=85=CE=B8_?=\r\n" + + " =?utf-8?q?=CE=BC=CE=B5=CE=B9,_?= , =?utf-8?q?=CE=B1?=\r\n" + + " =?utf-8?q?=CE=BD_=CE=B7=CE=B1=CE=B2=CE=B5=CE=BF_=CE=BF=CE=BC=CE=BD=CE=B5?=\r\n" + + " =?utf-8?q?=CF=82_=CE=BD=CF=85=CE=BC=CE=BA=CF=85=CE=B1=CE=BC_=CE=BC=CE=B5?=\r\n" + + " =?utf-8?q?=CE=B1.?= \r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed;\r\n" + + "\tboundary==_moO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts\r\n\r\n" + + "--=_moO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts\r\n" + + "Content-Type: multipart/alternative;\r\n" + + "\tboundary==_aoO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts\r\n\r\n" + + "--=_aoO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "Html test message =3D =CE=91=CE=B4 =CF=86=CE=B9=CE=BE =CE=B1=CE=BB=CE=B9=\r\n" + + "=CE=BA=CF=85=CE=B9=CF=80 =CE=B9=CE=BD=CF=86=CE=B9=CE=B4=CF=85=CE=BD=CF=84, =\r\n" + + "=CE=B7=CE=B9=CF=82 =CE=B5=CE=BE =CF=83=CE=B1=CF=80=CE=B5=CF=81=CE=B5=CE=B8 =\r\n" + + "=CE=B4=CE=B5=CF=84=CF=81=CE=B1=CF=83=CE=B8=CE=BF =CF=83=CE=B1=CE=B5=CF=86=\r\n" + + "=CE=BF=CE=BB=CE=B1, =CE=B1=CE=B4 =CE=B4=CE=BF=CE=BB=CE=BF=CF=81 =CE=B1=\r\n" + + "=CE=BB=CE=B9=CE=BA=CF=85=CE=B1=CE=BD=CE=B4=CE=BF =CE=B7=CE=B1=CF=82.\r\n\r\n" + + "--=_aoO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "=0D=0AHtml test m=\r\n" + + "essage =3D =CE=91=CE=B4 =CF=86=CE=B9=CE=BE =CE=B1=CE=BB=CE=B9=CE=BA=CF=85=\r\n" + + "=CE=B9=CF=80 =CE=B9=CE=BD=CF=86=CE=B9=CE=B4=CF=85=CE=BD=CF=84, =CE=B7=CE=B9=\r\n" + + "=CF=82 =CE=B5=CE=BE =CF=83=CE=B1=CF=80=CE=B5=CF=81=CE=B5=CE=B8 =CE=B4=CE=B5=\r\n" + + "=CF=84=CF=81=CE=B1=CF=83=CE=B8=CE=BF =CF=83=CE=B1=CE=B5=CF=86=CE=BF=CE=BB=\r\n" + + "=CE=B1, =CE=B1=CE=B4 =CE=B4=CE=BF=CE=BB=CE=BF=CF=81 =CE=B1=CE=BB=CE=B9=\r\n" + + "=CE=BA=CF=85=CE=B1=CE=BD=CE=B4=CE=BF =CE=B7=CE=B1=CF=82.\r\n\r\n" + + "--=_aoO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts--\r\n\r\n" + + "--=_moO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Disposition: attachment;\r\n" + + "\tfilename=test-file.txt\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "zpTOtc+Dz4nPgc61IM6xz4DPgM61zrvOu86xzr3OuM+Fz4Egz4XOuCDOvM61zrksIM6xzr0gzrfO\r\n" + + "sc6yzrXOvyDOv868zr3Otc+CIM69z4XOvM66z4XOsc68IM68zrXOsS4gzpHOtCDPhs65zr4gzrHO\r\n" + + "u865zrrPhc65z4AgzrnOvc+GzrnOtM+Fzr3PhCwgzrfOuc+CIM61zr4gz4POsc+AzrXPgc61zrgg\r\n" + + "zrTOtc+Ez4HOsc+DzrjOvyDPg86xzrXPhs6/zrvOsSwgzrHOtCDOtM6/zrvOv8+BIM6xzrvOuc66\r\n" + + "z4XOsc69zrTOvyDOt86xz4IuIM6Vz4Ugz4DPhc+BzrjOvyDOuc+FzrTOuc+Dzr8gzrXPic+CLCDP\r\n" + + "hs65z4POuSDPg8+Jzr3Pg861zrrPhc6xzrggz4DPgc65IM61z4UuIM6Rz4PPhc68IM+Dzr/Ovc+E\r\n" + + "zrXOvc+EzrnPic69zrXPgiDOuc+Fz4IgzrXOuSwgzrXOuSDOus+FzrHOtc66z4XOtSDOuc69z4PP\r\n" + + "ic67zrXOvc+CIM+DzrXOvc+DzrnOss+Fz4IgzrrPhc6/LiDOlc6+IM66z4XPic+EIM6xzrvOuc61\r\n" + + "zr3Phc68IM63zrnPgiwgz4PPhSDPgM+Bz4kgz4POv869z4PPhc67zrHPhM+FIM68zrXOtM65zr/P\r\n" + + "g8+BzrnOuM6xzrjOtc68LiDOpM65zrLOuc66z4XOtSDOuc69z4PPhM+Bz4XPg864zrnOv8+BIM66\r\n" + + "z4XOuSDOvc6/LCDOtc+FzrwgzrnOtCDOus+Fzr/OtM+Dzrkgz4TOsc+DzrnOvM6xzrjOtc+CIM6x\r\n" + + "zrTPic67zrXPg861zr3Pgi4=\r\n\r\n" + + "--=_moO+P6lWT3aDnzhsVt6NCD9+MWry18Hzbzt0hGcsiAts--\r\n"), + }, + { + src: messageIn{ + subject: "Test… #4", + from: &Address{"accented nåmé", "test@example.com"}, + to: []*Address{{"Δεσωρε αππελλανθυρ υθ μει, ", "test1@example.com"}, + {"αν ηαβεο ομνες νυμκυαμ μεα.", "test2@example.com"}}, + replyTo: &Address{"different name", "test-reply@example.com"}, + html: "\r\nHtml test message = Αδ φιξ αλικυιπ ινφιδυντ, ηις εξ σαπερεθ δετρασθο σαεφολα, αδ δολορ αλικυανδο ηας.", + attachments: []messageObj{ + {name: filepath.Join(workDir, "test-file.txt")}, + }, + }, + expOut: []byte("Message-ID: \r\n" + + "Date: Fri, 30 Aug 2013 09:10:11 +0000\r\n" + + "Subject: =?utf-8?q?Test=E2=80=A6_#4?=\r\n" + + "From: =?utf-8?q?accented_n=C3=A5m=C3=A9?= \r\n" + + "Reply-To: \"different name\" \r\n" + + "To: =?utf-8?q?=CE=94=CE=B5=CF=83=CF=89=CF=81=CE=B5_=CE=B1=CF=80=CF=80?=\r\n" + + " =?utf-8?q?=CE=B5=CE=BB=CE=BB=CE=B1=CE=BD=CE=B8=CF=85=CF=81_=CF=85=CE=B8_?=\r\n" + + " =?utf-8?q?=CE=BC=CE=B5=CE=B9,_?= , =?utf-8?q?=CE=B1?=\r\n" + + " =?utf-8?q?=CE=BD_=CE=B7=CE=B1=CE=B2=CE=B5=CE=BF_=CE=BF=CE=BC=CE=BD=CE=B5?=\r\n" + + " =?utf-8?q?=CF=82_=CE=BD=CF=85=CE=BC=CE=BA=CF=85=CE=B1=CE=BC_=CE=BC=CE=B5?=\r\n" + + " =?utf-8?q?=CE=B1.?= \r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed;\r\n" + + "\tboundary==_mBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc\r\n\r\n" + + "--=_mBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc\r\n" + + "Content-Type: multipart/alternative;\r\n" + + "\tboundary==_aBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc\r\n\r\n" + + "--=_aBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "Html test message =3D =CE=91=CE=B4 =CF=86=CE=B9=CE=BE =CE=B1=CE=BB=CE=B9=\r\n" + + "=CE=BA=CF=85=CE=B9=CF=80 =CE=B9=CE=BD=CF=86=CE=B9=CE=B4=CF=85=CE=BD=CF=84, =\r\n" + + "=CE=B7=CE=B9=CF=82 =CE=B5=CE=BE =CF=83=CE=B1=CF=80=CE=B5=CF=81=CE=B5=CE=B8 =\r\n" + + "=CE=B4=CE=B5=CF=84=CF=81=CE=B1=CF=83=CE=B8=CE=BF =CF=83=CE=B1=CE=B5=CF=86=\r\n" + + "=CE=BF=CE=BB=CE=B1, =CE=B1=CE=B4 =CE=B4=CE=BF=CE=BB=CE=BF=CF=81 =CE=B1=\r\n" + + "=CE=BB=CE=B9=CE=BA=CF=85=CE=B1=CE=BD=CE=B4=CE=BF =CE=B7=CE=B1=CF=82.\r\n\r\n" + + "--=_aBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "=0D=0AHtml test m=\r\n" + + "essage =3D =CE=91=CE=B4 =CF=86=CE=B9=CE=BE =CE=B1=CE=BB=CE=B9=CE=BA=CF=85=\r\n" + + "=CE=B9=CF=80 =CE=B9=CE=BD=CF=86=CE=B9=CE=B4=CF=85=CE=BD=CF=84, =CE=B7=CE=B9=\r\n" + + "=CF=82 =CE=B5=CE=BE =CF=83=CE=B1=CF=80=CE=B5=CF=81=CE=B5=CE=B8 =CE=B4=CE=B5=\r\n" + + "=CF=84=CF=81=CE=B1=CF=83=CE=B8=CE=BF =CF=83=CE=B1=CE=B5=CF=86=CE=BF=CE=BB=\r\n" + + "=CE=B1, =CE=B1=CE=B4 =CE=B4=CE=BF=CE=BB=CE=BF=CF=81 =CE=B1=CE=BB=CE=B9=\r\n" + + "=CE=BA=CF=85=CE=B1=CE=BD=CE=B4=CE=BF =CE=B7=CE=B1=CF=82.\r\n\r\n" + + "--=_aBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc--\r\n\r\n" + + "--=_mBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Disposition: attachment;\r\n" + + "\tfilename=test-file.txt\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "zpTOtc+Dz4nPgc61IM6xz4DPgM61zrvOu86xzr3OuM+Fz4Egz4XOuCDOvM61zrksIM6xzr0gzrfO\r\n" + + "sc6yzrXOvyDOv868zr3Otc+CIM69z4XOvM66z4XOsc68IM68zrXOsS4gzpHOtCDPhs65zr4gzrHO\r\n" + + "u865zrrPhc65z4AgzrnOvc+GzrnOtM+Fzr3PhCwgzrfOuc+CIM61zr4gz4POsc+AzrXPgc61zrgg\r\n" + + "zrTOtc+Ez4HOsc+DzrjOvyDPg86xzrXPhs6/zrvOsSwgzrHOtCDOtM6/zrvOv8+BIM6xzrvOuc66\r\n" + + "z4XOsc69zrTOvyDOt86xz4IuIM6Vz4Ugz4DPhc+BzrjOvyDOuc+FzrTOuc+Dzr8gzrXPic+CLCDP\r\n" + + "hs65z4POuSDPg8+Jzr3Pg861zrrPhc6xzrggz4DPgc65IM61z4UuIM6Rz4PPhc68IM+Dzr/Ovc+E\r\n" + + "zrXOvc+EzrnPic69zrXPgiDOuc+Fz4IgzrXOuSwgzrXOuSDOus+FzrHOtc66z4XOtSDOuc69z4PP\r\n" + + "ic67zrXOvc+CIM+DzrXOvc+DzrnOss+Fz4IgzrrPhc6/LiDOlc6+IM66z4XPic+EIM6xzrvOuc61\r\n" + + "zr3Phc68IM63zrnPgiwgz4PPhSDPgM+Bz4kgz4POv869z4PPhc67zrHPhM+FIM68zrXOtM65zr/P\r\n" + + "g8+BzrnOuM6xzrjOtc68LiDOpM65zrLOuc66z4XOtSDOuc69z4PPhM+Bz4XPg864zrnOv8+BIM66\r\n" + + "z4XOuSDOvc6/LCDOtc+FzrwgzrnOtCDOus+Fzr/OtM+Dzrkgz4TOsc+DzrnOvM6xzrjOtc+CIM6x\r\n" + + "zrTPic67zrXPg861zr3Pgi4K\r\n\r\n" + + "--=_mBmwOIPdD22kjLpeb2oVNP1eFKgm3ilySX9MIo9lozPc--\r\n"), + }, + { + src: messageIn{ + subjectTpl: "Test {{.name}}", + from: &Address{"test name", "test@example.com"}, + textTpl: "Hi {{.name}}!", + htmlTpl: "Hi {{.name}}!", + }, + data: map[string]string{"name": "John & Jill"}, + expOut: []byte("Message-ID: \r\n" + + "Date: Fri, 30 Aug 2013 09:10:11 +0000\r\n" + + "Subject: Test John & Jill\r\n" + + "From: \"test name\" \r\n" + + "To: \"test name\" \r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/alternative;\r\n" + + "\tboundary==_aM39MI6ET2vppJiewP9y3Uy0DUP+wE4yy8lze78aobrA\r\n\r\n" + + "--=_aM39MI6ET2vppJiewP9y3Uy0DUP+wE4yy8lze78aobrA\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "Hi John & Jill!\r\n\r\n" + + "--=_aM39MI6ET2vppJiewP9y3Uy0DUP+wE4yy8lze78aobrA\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "Hi John & Jill!\r\n\r\n" + + "--=_aM39MI6ET2vppJiewP9y3Uy0DUP+wE4yy8lze78aobrA--\r\n"), + }, + } + + for i, c := range cases { + msg := NewMessage(nil).Domain(c.src.domain).Subject(c.src.subject). + setSender(c.src.sender).From(c.src.from).ReplyTo(c.src.replyTo). + To(c.src.to...).Cc(c.src.cc...).Bcc(c.src.bcc...) + if c.src.subjectTpl != "" { + msg.SubjectTemplate(c.src.subjectTpl) + } + if c.src.text != "" { + msg.Text(c.src.text) + } + if c.src.textTpl != "" { + msg.TextTemplate(c.src.textTpl) + } + if c.src.html != "" { + msg.Html(c.src.html, c.src.rel...) + } + if c.src.htmlTpl != "" { + msg.HtmlTemplate(c.src.htmlTpl, c.src.rel...) + } + for _, partData := range c.src.parts { + msg.Part(partData.ctype, partData.cte, partData.bytes, partData.related...) + } + for _, attData := range c.src.attachments { + if len(attData.bytes) > 0 { + msg.AttachObject(attData.name, attData.ctype, attData.bytes) + } else { + msg.Attach(attData.name) + } + } + if !c.date.IsZero() { + forceNow(c.date.Unix()) + } else { + forceNow(date.Unix()) + } + act := msg.Compose(c.data) + if !bytes.Equal(act, c.expOut) { + t.Errorf("(*Message).Compose [%d]: got (len=%d)\n%s\nwant (len=%d)\n%s", i, len(act), act, len(c.expOut), c.expOut) + } + if len(msg.errors) != len(c.expErr) { + t.Errorf("(*Message).Compose [%d]: got %d errors, want %d:\n", i, len(msg.errors), len(c.expErr)) + for _, err := range msg.errors { + t.Errorf("%s\n", err.Error()) + } + t.Error("was expecting:\n") + for _, err := range c.expErr { + t.Errorf("%s\n", err) + } + } + } +} diff --git a/sender.go b/sender.go new file mode 100644 index 0000000..8727395 --- /dev/null +++ b/sender.go @@ -0,0 +1,110 @@ +package email + +import ( + "errors" + "net/smtp" + "strconv" + "sync" +) + +// Sender represents the SMTP credentials along with the (optional) Address of a sender. +type Sender struct { + host string + port int + username string + password string + address *Address +} + +var ( + defaultSender *Sender + defaultSenderMutex sync.RWMutex +) + +// NewSender creates a new Sender from the provided information. +// +// The `host` may include a port number, which defaults to 25. That is, "example.com" +// and "example.com:25" are equivalent. +// The `addr` parameters are optional and may be either an email address or a name followed by an +// email address. +func NewSender(host, user, pass string, addr ...string) (*Sender, error) { + port := 0 + for i, l := 0, len(host); i < l; i++ { + if host[i] == ':' { + for _, digit := range host[i+1:] { + if digit < '0' || digit > '9' { + return nil, errors.New("NewSender: invalid port number: " + host) + } + port = port*10 + int(digit-'0') + } + host = host[:i] + break + } + } + if port == 0 { + port = 25 + } + if user == "" { + return nil, errors.New("NewSender: empty username: " + user) + } + if pass == "" { + return nil, errors.New("NewSender: empty password: " + pass) + } + var ( + address *Address + err error + ) + switch len(addr) { + case 2: + address, err = NewAddress(addr[0], addr[1]) + case 1: + address, err = NewAddress("", addr[0]) + } + if err != nil { + return nil, errors.New("NewSender: " + err.Error()) + } + return &Sender{host, port, user, pass, address}, nil +} + +// SetDefault sets the receiver as the default sender. +func (s *Sender) SetDefault() *Sender { + defaultSenderMutex.Lock() + defaultSender = s + defaultSenderMutex.Unlock() + return s +} + +// Send composes the provided message using the `data`, and sends it. +func (s *Sender) Send(msg *Message, data interface{}) error { + if msg == nil { + return errors.New("Sender.Send: no message to send") + } + body := msg.setSender(s).Compose(data) + if msg.HasErrors() { + return errors.New("Sender.Send: failed to compose message") + } + go smtp.SendMail( + s.host+":"+strconv.Itoa(s.port), + smtp.PlainAuth( + "", + s.username, + s.password, + s.host, + ), + msg.FromAddr(), + msg.RecipientAddrs(), + body, + ) + return nil +} + +// Send composes the provided message using the `data`, and sends it using the default Sender. +func Send(msg *Message, data interface{}) error { + defaultSenderMutex.RLock() + defer defaultSenderMutex.RUnlock() + sender := defaultSender + if sender == nil { + return errors.New("Send: no default sender") + } + return sender.Send(msg, data) +} diff --git a/test-file.txt b/test-file.txt new file mode 100644 index 0000000..19d3112 --- /dev/null +++ b/test-file.txt @@ -0,0 +1 @@ +Δεσωρε αππελλανθυρ υθ μει, αν ηαβεο ομνες νυμκυαμ μεα. Αδ φιξ αλικυιπ ινφιδυντ, ηις εξ σαπερεθ δετρασθο σαεφολα, αδ δολορ αλικυανδο ηας. Ευ πυρθο ιυδισο εως, φισι σωνσεκυαθ πρι ευ. Ασυμ σοντεντιωνες ιυς ει, ει κυαεκυε ινσωλενς σενσιβυς κυο. Εξ κυωτ αλιενυμ ηις, συ πρω σονσυλατυ μεδιοσριθαθεμ. Τιβικυε ινστρυσθιορ κυι νο, ευμ ιδ κυοδσι τασιμαθες αδωλεσενς.