diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e4e719e5862d8fde98b08f69f2c6513b138a754c..c3cc4996c31030c390bc57b25a7fff8ff6f7dc70 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,6 +9,7 @@ Unit tests: - strong # Disable this build on small runners services: - "lesterpig/mongo:latest" # Use this light version of mongo + - "lesterpig/postfix:latest" #artifacts: # Waiting GitLab 8.2.1... # paths: # - "coverage.html" @@ -16,6 +17,7 @@ Unit tests: - "go get gopkg.in/mgo.v2" - "go test -coverprofile auth.part -v ./auth" - "go test -coverprofile mgdb.part -v ./mgdb" + - "go test -coverprofile mails.part -v ./mails" - "echo 'mode: set' *part > c.out" - "grep -h -v 'mode: set' *part >> c.out" - "go tool cover -html=c.out -o coverage.html" @@ -26,7 +28,8 @@ ARM tests: - arm script: - "go get gopkg.in/mgo.v2" - - "go test -cover -short -run 'Test[^M][^o][^n][^g][^o]' -v ./..." + - "go test -cover -short -v ./auth" + - "go test -cover -short -v ./mgdb" Code lint: stage: test @@ -38,4 +41,4 @@ Code lint: - "go get github.com/alecthomas/gometalinter" - "go get gopkg.in/mgo.v2" - "gometalinter --install" - - "gometalinter -t --deadline=60s ./..." + - "gometalinter -t --deadline=100s -j1 ./..." diff --git a/mails/README.md b/mails/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4226c1df5a4153cfef18b4c296a909d6170cd93e --- /dev/null +++ b/mails/README.md @@ -0,0 +1,45 @@ +### DFSS - Mails lib ### + +This library is designed to wrap the smtp library of go. + +## Initiating a connection to a server ## + +To start a connection to a server, create a CustomClient via NewCustomClient +This takes : +- A sender (ex : qdauchy@insa-rennes.fr) +- A host (ex : mailhost.insa-rennes.fr) +- A port (ex : 587) +- A user (ex : qdauchy) +- A password + +This requires the server to have TLS + +## Using the connection ## + +The connection that has been created can then be used to send one or several mails + +Using Send requires : +- A slice of receivers +- A subject +- A message +- A (possibly empty) slice of extensions +- A (possibly empty) slice of filenames. This slice must be of the same length as the extensions one. + +## Closing the connection ## + +Finally, close the connection using Close. + +## Example ### + +Refer to the doc's to see the library in practice + +## Testing the library ## + +The testing file uses the following variables to set up the tests : +DFSS_TEST_MAIL_SENDER +DFSS_TEST_MAIL_HOST +DFSS_TEST_MAIL_PORT +DFSS_TEST_MAIL_USER +DFSS_TEST_MAIL_PASSWORD +DFSS_TEST_MAIL_RCPT1 +DFSS_TEST_MAIL_RCPT2 diff --git a/mails/TODO.md b/mails/TODO.md new file mode 100644 index 0000000000000000000000000000000000000000..d0077834eca47622d3c24a64852fa2d6ae643bdc --- /dev/null +++ b/mails/TODO.md @@ -0,0 +1,2 @@ +Modify the header so that the spam score is lower (9.986 is cutting it close) +Document diff --git a/mails/email.go b/mails/email.go new file mode 100644 index 0000000000000000000000000000000000000000..6dc595e22e9551bc00df2c835a064b3e6592f74f --- /dev/null +++ b/mails/email.go @@ -0,0 +1,206 @@ +// Package mails provides a simple interface with the smtp library +package mails + +import ( + "bytes" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/smtp" + "net/textproto" + "strings" + "time" +) + +// CustomClient : +// Modelizes the constants of a connection : an actual client, and a sender +type CustomClient struct { + sender string + client *smtp.Client +} + +const rfc2822 = "Fri 18 Dec 2015 10:01:17 -0606" // used to format time to rfc2822. Not accurate but fmt can't see a , + +// NewCustomClient starts up a custom client. +func NewCustomClient(sender, host, port, username, password string) (*CustomClient, error) { + + // Connect to the server. Type of connection is smtp.Client + connection, err := smtp.Dial(host + ":" + port) + if err != nil { + return nil, err + } + + // Check that the server does implement TLS + if ok, _ := connection.Extension("StartTLS"); !ok { + return nil, errors.New("Connection failed : mail server doesn't support TLS") + } + + // Start tls + if err := connection.StartTLS(&tls.Config{InsecureSkipVerify: true, ServerName: host}); err != nil { + return nil, err + } + + // Authenticate to the server if it is supported + if ok, _ := connection.Extension("AUTH"); ok { + auth := smtp.PlainAuth(username, username, password, host) + if err := connection.Auth(auth); err != nil { + return nil, err + } + } + + return &CustomClient{sender, connection}, nil +} + +// Send a mail with the custom client. Returns nil on success. +func (c *CustomClient) Send(receivers []string, subject, message string, extensions, filenames []string) error { + // Keep the connection in a local variable for ease of access + connection := c.client + + boundary := randomBoundary() + header := createHeader(c.sender, subject, boundary) + + // Encode the message in base64 ONCE + base64Message := base64.StdEncoding.EncodeToString([]byte(message)) + + for _, receiver := range receivers { + + // Set the sender + if err := connection.Mail(c.sender); err != nil { + return err + } + + // Set the receiver. This modifies the header in the current instance + if err := connection.Rcpt(receiver); err != nil { + return err + } + + // Set the message : header, then message encoded in base64, then attachments + var localBuffer bytes.Buffer + if err := createFullMessage(&localBuffer, receiver, c.sender, header, base64Message, extensions, filenames, boundary); err != nil { + return err + } + + // Send it. Data returns a writer to which one can write to write the message itself + emailWriter, err := connection.Data() + if err != nil { + return err + } + + _, err = fmt.Fprintf(emailWriter, localBuffer.String()) + if err != nil { + return err + } + err = emailWriter.Close() + if err != nil { + return err + } + + // Reset the envellope + err = connection.Reset() + if err != nil { + return err + } + } + + return nil +} + +// Close the connection of CustomClient +func (c *CustomClient) Close() error { + return c.client.Close() +} + +// Creates the header for all messages +func createHeader(sender, subject, boundary string) string { + var buffer bytes.Buffer + fmt.Fprintf(&buffer, "From: %s\r\n", sender) + fmt.Fprintf(&buffer, "MIME-Version: 1.0\r\n") + fmt.Fprintf(&buffer, "Subject: %s\r\n", subject) + // Replace the first space with a comma and a space to conform to rfc2822 + fmt.Fprintf(&buffer, "Date: %s%s", strings.Replace(time.Now().UTC().Format(rfc2822), " ", ", ", 1), "\r\n") + fmt.Fprintf(&buffer, "Content-Type: multipart/mixed; boundary=\"%s\"; charset=\"UTF-8\"\r\n", boundary) + fmt.Fprintf(&buffer, "To: ") + return buffer.String() +} + +// Create the full message for a single receiver +func createFullMessage(b *bytes.Buffer, receiver, sender, globalHeader, base64Message string, extensions, filenames []string, boundary string) error { + fmt.Fprintf(b, "%s%s\r\n", globalHeader, receiver) + + writer := multipart.NewWriter(b) + if err := writer.SetBoundary(boundary); err != nil { + return err + } + // Set the message + if err := createText(writer, base64Message); err != nil { + return err + } + + // Set attachments. Here for now because the boundaries are wanted unique + for index, value := range filenames { + if err := createAttachment(writer, extensions[index], value); err != nil { + return err + } + } + if err := writer.Close(); err != nil { + return err + } + + return nil +} + +// Create an attachment with a certain extension +func createAttachment(writer *multipart.Writer, extension, path string) error { + // Create a header + newHeader := make(textproto.MIMEHeader) + newHeader.Add("Content-Type", extension) + newHeader.Add("Content-Transfer-Encoding", "base64") + newHeader.Add("Content-Disposition", "attachment; filename="+path+";") + + // Create a writer for the file + output, err := writer.CreatePart(newHeader) + if err != nil { + return err + } + + // Write the file to the message + data, err := ioutil.ReadFile(path) + if err != nil { + return err + } + fmt.Fprintf(output, base64.StdEncoding.EncodeToString([]byte(data))) + + return nil +} + +// Creates the equivalent of the message wrapped in a boundary. The message is expected to have been encoded via base64 +func createText(writer *multipart.Writer, message string) error { + // Create the mime header for the message + mimeHeaderMessage := make(textproto.MIMEHeader) + mimeHeaderMessage.Add("Content-Transfer-Encoding", "base64") + mimeHeaderMessage.Add("Content-Type", "text/plain; charset=\"UTF-8\"") + + // Set the message + output, err := writer.CreatePart(mimeHeaderMessage) + if err != nil { + return err + } + + fmt.Fprintf(output, "%s", message) + return nil +} + +// Totally copied from go stl +func randomBoundary() string { + var buf [30]byte + _, err := io.ReadFull(rand.Reader, buf[:]) + if err != nil { + panic(err) + } + return fmt.Sprintf("%x", buf[:]) +} diff --git a/mails/mail_test.go b/mails/mail_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b04f1066e0afc62f8b83dda831a77e1ec0edaccf --- /dev/null +++ b/mails/mail_test.go @@ -0,0 +1,106 @@ +package mails + +import ( + "fmt" + "os" + "testing" +) + +var client *CustomClient +var err error +var rcpt1 string +var rcpt2 string + +func TestMain(m *testing.M) { + // Setup is based on environment variables + sender := os.Getenv("DFSS_TEST_MAIL_SENDER") + host := os.Getenv("DFSS_TEST_MAIL_HOST") + port := os.Getenv("DFSS_TEST_MAIL_PORT") + username := os.Getenv("DFSS_TEST_MAIL_USER") + password := os.Getenv("DFSS_TEST_MAIL_PASSWORD") + rcpt1 = os.Getenv("DFSS_TEST_MAIL_RCPT1") + rcpt2 = os.Getenv("DFSS_TEST_MAIL_RCPT2") + client, err = NewCustomClient(sender, host, port, username, password) + if err != nil { + fmt.Println(err) + } + + code := m.Run() + + err = client.Close() + if err != nil { + fmt.Println(err) + } + os.Exit(code) +} + +func TestSingleMail(t *testing.T) { + err = client.Send([]string{rcpt1}, "TestSingleMail", "Gros espoirs!", []string{}, []string{}) + if err != nil { + t.Fatal(err) + } +} + +func TestDoubleMail(t *testing.T) { + err = client.Send([]string{rcpt1, rcpt2}, "TestDoubleMail", "Gros espoirs!", []string{}, []string{}) + if err != nil { + t.Fatal(err) + } +} + +func TestRuneMail(t *testing.T) { + err = client.Send([]string{rcpt1}, "TestRuneMail", "测试", []string{}, []string{}) + if err != nil { + t.Fatal(err) + } +} + +func TestAttachmentMailText(t *testing.T) { + err = client.Send([]string{rcpt1}, "TestAttachmentMailText", "What would make a good attachment?", []string{"text/plain"}, []string{"mail_test.go"}) + if err != nil { + t.Fatal(err) + } +} + +func TestAttachmentMailImage(t *testing.T) { + err = client.Send([]string{rcpt1}, "TestAttachmentMailImage", "What would make a good attachment?", []string{"image/gif"}, []string{"testImg.gif"}) + if err != nil { + t.Fatal(err) + } +} + +func ExampleCustomClient() { + + // Create a connection + client, err := NewCustomClient("qdauchy@insa-rennes.fr", "mailhost.insa-rennes.fr", "587", "qdauchy", "notreallymypass") + if err != nil { + fmt.Println(err) + } + + // Some reused variables + receiver1 := "lbonniot@insa-rennes.fr" + receiver2 := "qdauchy@insa-rennes.fr" + receiver3 := "tclaverie@insa-rennes.fr" + subject := "Mail example" + message := `Hello, this is a mail example. It's not like the cactus is going + to be jealous or anything...` + + // Send a first mail, without attachments + err = client.Send([]string{receiver1, receiver2}, subject, message, []string{}, []string{}) + if err != nil { + fmt.Println(err) + } + + // Send a second mail, with some attachments + err = client.Send([]string{receiver1, receiver3}, subject, message, []string{"text/plain", "image/gif"}, []string{"email.go", "testImg.gif"}) + if err != nil { + fmt.Println(err) + } + + // Close the connection + err = client.Close() + if err != nil { + fmt.Println(err) + } + +} diff --git a/mails/testImg.gif b/mails/testImg.gif new file mode 100644 index 0000000000000000000000000000000000000000..c161ddeb013501d84d9b7414973869333f373a61 Binary files /dev/null and b/mails/testImg.gif differ