diff --git a/Dockerfile b/Dockerfile index 6eefa81b8..87a38cae4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -124,6 +124,9 @@ RUN filetest -c $GOPATH/src/github.com/gobuffalo/buffalo/buffalo/cmd/filetests/g RUN buffalo g resource person_event RUN filetest -c $GOPATH/src/github.com/gobuffalo/buffalo/buffalo/cmd/filetests/generate_underscore.json +RUN buffalo g mailer welcome_email +RUN filetest -c $GOPATH/src/github.com/gobuffalo/buffalo/buffalo/cmd/filetests/generate_mailer.json + RUN rm -rf bin RUN buffalo build -k -e RUN filetest -c $GOPATH/src/github.com/gobuffalo/buffalo/buffalo/cmd/filetests/no_assets_build.json diff --git a/buffalo/cmd/filetests/generate_mailer.json b/buffalo/cmd/filetests/generate_mailer.json new file mode 100644 index 000000000..01acbd54c --- /dev/null +++ b/buffalo/cmd/filetests/generate_mailer.json @@ -0,0 +1,30 @@ +[{ + "path": "mailers/mailers.go", + "contains": [ + "github.com/gobuffalo/buffalo/mail", + "smtp, err = mail.NewSMTPSender(host, port, user, password)" + ], + "!contains": [ + "github.com/gobuffalo/x/mail" + ] + }, + { + "path": "templates/mail/layout.html", + "contains": [ + "

templates/mailers/layout.html

" + ] + }, + { + "path": "mailers/welcome_email.go", + "contains": [ + "err := m.AddBody(r.HTML(\"welcome_email.html\"), render.Data{})" + ] + }, + { + "path": "templates/mail/welcome_email.html", + "contains": [ + "

Welcome Email

", + "

../templates/mail/welcome_email.html

" + ] + } +] diff --git a/buffalo/cmd/generate.go b/buffalo/cmd/generate.go index 0fea27a8c..b676aa572 100644 --- a/buffalo/cmd/generate.go +++ b/buffalo/cmd/generate.go @@ -17,6 +17,7 @@ func init() { generateCmd.AddCommand(generate.ActionCmd) generateCmd.AddCommand(generate.DockerCmd) generateCmd.AddCommand(generate.TaskCmd) + generateCmd.AddCommand(generate.MailCmd) decorate("generate", generateCmd) RootCmd.AddCommand(generateCmd) diff --git a/buffalo/cmd/generate/mailer.go b/buffalo/cmd/generate/mailer.go new file mode 100644 index 000000000..1cc975d72 --- /dev/null +++ b/buffalo/cmd/generate/mailer.go @@ -0,0 +1,31 @@ +package generate + +import ( + "github.com/gobuffalo/buffalo/generators/mail" + "github.com/gobuffalo/buffalo/meta" + "github.com/gobuffalo/makr" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var mailer = mail.Generator{} + +// MailCmd for generating mailers +var MailCmd = &cobra.Command{ + Use: "mailer", + Short: "Generates a new mailer for Buffalo", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New("you must supply a name for your mailer") + } + mailer.App = meta.New(".") + mailer.Name = meta.Name(args[0]) + data := makr.Data{} + return mailer.Run(".", data) + + }, +} + +func init() { + MailCmd.Flags().BoolVar(&mailer.SkipInit, "skip-init", false, "skip initializing mailers/") +} diff --git a/generators/mail/init/templates/mailers/mailers.go.tmpl b/generators/mail/init/templates/mailers/mailers.go.tmpl new file mode 100644 index 000000000..4cc20fcb2 --- /dev/null +++ b/generators/mail/init/templates/mailers/mailers.go.tmpl @@ -0,0 +1,36 @@ +package mailers + +import ( + "log" + + "github.com/gobuffalo/buffalo/render" + "github.com/gobuffalo/envy" + "github.com/gobuffalo/packr" + "github.com/gobuffalo/buffalo/mail" + "github.com/pkg/errors" +) + +var smtp mail.Sender +var r *render.Engine + +func init() { + + // Pulling config from the env. + port := envy.Get("SMTP_PORT", "1025") + host := envy.Get("SMTP_HOST", "localhost") + user := envy.Get("SMTP_USER", "") + password := envy.Get("SMTP_PASSWORD", "") + + var err error + smtp, err = mail.NewSMTPSender(host, port, user, password) + + if err != nil { + log.Fatal(err) + } + + r = render.New(render.Options{ + HTMLLayout: "layout.html", + TemplatesBox: packr.NewBox("../templates/mail"), + Helpers: render.Helpers{}, + }) +} diff --git a/generators/mail/init/templates/templates/mail/layout.html.tmpl b/generators/mail/init/templates/templates/mail/layout.html.tmpl new file mode 100644 index 000000000..7d88c8fe6 --- /dev/null +++ b/generators/mail/init/templates/templates/mail/layout.html.tmpl @@ -0,0 +1,3 @@ +

templates/mailers/layout.html

+ +<%= yield %> diff --git a/generators/mail/mail.go b/generators/mail/mail.go new file mode 100644 index 000000000..78899bbd1 --- /dev/null +++ b/generators/mail/mail.go @@ -0,0 +1,84 @@ +package mail + +import ( + "os" + "path/filepath" + + "github.com/gobuffalo/buffalo/generators" + "github.com/gobuffalo/buffalo/meta" + "github.com/gobuffalo/makr" + "github.com/pkg/errors" +) + +// Generator for creating new mailers +type Generator struct { + App meta.App `json:"app"` + Name meta.Name `json:"name"` + SkipInit bool `json:"skip_init"` +} + +// Run the new mailer generator. It will init the mailers directory +// if it doesn't already exist +func (d Generator) Run(root string, data makr.Data) error { + g := makr.New() + defer g.Fmt(root) + data["opts"] = d + + if err := d.initGenerator(data); err != nil { + return errors.WithStack(err) + } + + fn := d.Name.File() + g.Add(makr.NewFile(filepath.Join("mailers", fn+".go"), mailerTmpl)) + g.Add(makr.NewFile(filepath.Join("templates", "mail", fn+".html"), mailTmpl)) + return g.Run(root, data) +} + +func (d Generator) initGenerator(data makr.Data) error { + files, err := generators.Find(filepath.Join(generators.TemplatesPath, "mail", "init")) + if err != nil { + return errors.WithStack(err) + } + g := makr.New() + for _, f := range files { + g.Add(makr.NewFile(f.WritePath, f.Body)) + } + + g.Should = func(data makr.Data) bool { + if d.SkipInit { + return false + } + if _, err := os.Stat(filepath.Join("mailers", "mailers.go")); err == nil { + return false + } + return true + } + return g.Run(".", data) +} + +const mailerTmpl = `package mailers + +import ( + "github.com/gobuffalo/buffalo/render" + "github.com/gobuffalo/buffalo/mail" + "github.com/pkg/errors" +) + +func Send{{.opts.Name.Model}}() error { + m := mail.NewMessage() + + // fill in with your stuff: + m.Subject = "{{.opts.Name.Title}}" + m.From = "" + m.To = []string{} + err := m.AddBody(r.HTML("{{.opts.Name.File}}.html"), render.Data{}) + if err != nil { + return errors.WithStack(err) + } + return smtp.Send(m) +} +` + +const mailTmpl = `

{{.opts.Name.Title}}

+ +

../templates/mail/{{.opts.Name.File}}.html

` diff --git a/mail/README.md b/mail/README.md new file mode 100644 index 000000000..e86b0c562 --- /dev/null +++ b/mail/README.md @@ -0,0 +1,125 @@ +# github.com/gobuffalo/buffalo/mail + +This package is intended to allow easy Email sending with Buffalo, it allows you to define your custom `mail.Sender` for the provider you would like to use. + +## Generator + +```bash +$ buffalo generate mailer welcome_email +``` + +## Example Usage + +```go +//actions/mail.go +package x + +import ( + "log" + + "github.com/gobuffalo/buffalo/render" + "github.com/gobuffalo/envy" + "github.com/gobuffalo/packr" + "github.com/gobuffalo/plush" + "github.com/gobuffalo/buffalo/mail" + "github.com/pkg/errors" + "gitlab.com/wawandco/app/models" +) + +var smtp mail.Sender +var r *render.Engine + +func init() { + + //Pulling config from the env. + port := envy.Get("SMTP_PORT", "1025") + host := envy.Get("SMTP_HOST", "localhost") + user := envy.Get("SMTP_USER", "") + password := envy.Get("SMTP_PASSWORD", "") + + var err error + smtp, err = mail.NewSMTPSender(host, port, user, password) + + if err != nil { + log.Fatal(err) + } + + //The rendering engine, this is usually generated inside actions/render.go in your buffalo app. + r = render.New(render.Options{ + TemplatesBox: packr.NewBox("../templates"), + }) +} + +//SendContactMessage Sends contact message to contact@myapp.com +func SendContactMessage(c *models.Contact) error { + + //Creates a new message + m := mail.NewMessage() + m.From = "sender@myapp.com" + m.Subject = "New Contact" + m.To = []string{"contact@myapp.com"} + + // Data that will be used inside the templates when rendering. + data := map[string]interface{}{ + "contact": c, + } + + // You can add multiple bodies to the message you're creating to have content-types alternatives. + err := m.AddBodies(data, r.HTML("mail/contact.html"), r.Plain("mail/contact.txt")) + + if err != nil { + return errors.WithStack(err) + } + + err = smtp.Send(m) + if err != nil { + return errors.WithStack(err) + } + + return nil +} + +``` + +This `SendContactMessage` could be called by one of your actions, p.e. the action that handles your contact form submission. + +```go +//actions/contact.go +... + +func ContactFormHandler(c buffalo.Context) error { + contact := &models.Contact{} + c.Bind(contact) + + //Calling to send the message + SendContactMessage(contact) + return c.Redirect(302, "contact/thanks") +} +... +``` + +If you're using Gmail or need to configure your SMTP connection you can use the Dialer property on the SMTPSender, p.e: (for Gmail) + +```go +... +var smtp mail.Sender + +func init() { + port := envy.Get("SMTP_PORT", "465") + // or 587 with TLS + + host := envy.Get("SMTP_HOST", "smtp.gmail.com") + user := envy.Get("SMTP_USER", "your@email.com") + password := envy.Get("SMTP_PASSWORD", "yourp4ssw0rd") + + var err error + sender, err := mail.NewSMTPSender(host, port, user, password) + sender.Dialer.SSL = true + + //or if TLS + sender.Dialer.TLSConfig = &tls.Config{...} + + smtp = sender +} +... +``` diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 000000000..755ce3d16 --- /dev/null +++ b/mail/mail.go @@ -0,0 +1,6 @@ +package mail + +// Sender interface for any upcomming mailers. +type Sender interface { + Send(Message) error +} diff --git a/mail/message.go b/mail/message.go new file mode 100644 index 000000000..7c9295ea4 --- /dev/null +++ b/mail/message.go @@ -0,0 +1,82 @@ +package mail + +import ( + "io" + + "bytes" + + "github.com/gobuffalo/buffalo/render" +) + +//Message represents an Email message +type Message struct { + From string + To []string + CC []string + Bcc []string + Subject string + + Bodies []Body + Attachments []Attachment +} + +// Body represents one of the bodies in the Message could be main or alternative +type Body struct { + Content string + ContentType string +} + +// Attachment are files added into a email message +type Attachment struct { + Name string + Reader io.Reader + ContentType string +} + +// AddBody the message by receiving a renderer and rendering data, first message will be +// used as the main message Body rest of them will be passed as alternative bodies on the +// email message +func (m *Message) AddBody(r render.Renderer, data render.Data) error { + buf := bytes.NewBuffer([]byte{}) + err := r.Render(buf, data) + + if err != nil { + return err + } + + m.Bodies = append(m.Bodies, Body{ + Content: string(buf.Bytes()), + ContentType: r.ContentType(), + }) + + return nil +} + +// AddBodies Allows to add multiple bodies to the message, it returns errors that +// could happen in the rendering. +func (m *Message) AddBodies(data render.Data, renderers ...render.Renderer) error { + for _, r := range renderers { + err := m.AddBody(r, data) + if err != nil { + return err + } + } + + return nil +} + +//AddAttachment adds the attachment to the list of attachments the Message has. +func (m *Message) AddAttachment(name, contentType string, r io.Reader) error { + m.Attachments = append(m.Attachments, Attachment{ + Name: name, + ContentType: contentType, + Reader: r, + }) + + return nil +} + +//NewMessage Builds a new message. +func NewMessage() Message { + return Message{} +} diff --git a/mail/smtp_sender.go b/mail/smtp_sender.go new file mode 100644 index 000000000..987a411a6 --- /dev/null +++ b/mail/smtp_sender.go @@ -0,0 +1,80 @@ +package mail + +import ( + "io" + "strconv" + + "github.com/pkg/errors" + gomail "gopkg.in/gomail.v2" +) + +//SMTPSender allows to send Emails by connecting to a SMTP server. +type SMTPSender struct { + Dialer *gomail.Dialer +} + +//Send a message using SMTP configuration or returns an error if something goes wrong. +func (sm SMTPSender) Send(message Message) error { + m := gomail.NewMessage() + + m.SetHeader("From", message.From) + m.SetHeader("To", message.To...) + m.SetHeader("Subject", message.Subject) + m.SetHeader("Cc", message.CC...) + m.SetHeader("Bcc", message.Bcc...) + + if len(message.Bodies) > 0 { + mainBody := message.Bodies[0] + m.SetBody(mainBody.ContentType, mainBody.Content, gomail.SetPartEncoding(gomail.Unencoded)) + } + + if len(message.Bodies) > 1 { + for i := 1; i < len(message.Bodies); i++ { + alt := message.Bodies[i] + m.AddAlternative(alt.ContentType, alt.Content, gomail.SetPartEncoding(gomail.Unencoded)) + } + } + + for _, at := range message.Attachments { + settings := gomail.SetCopyFunc(func(w io.Writer) error { + if _, err := io.Copy(w, at.Reader); err != nil { + return err + } + + return nil + }) + + m.Attach(at.Name, settings) + } + + err := sm.Dialer.DialAndSend(m) + + if err != nil { + return errors.WithStack(err) + } + + return nil +} + +//NewSMTPSender builds a SMTP mail based in passed config. +func NewSMTPSender(host string, port string, user string, password string) (SMTPSender, error) { + iport, err := strconv.Atoi(port) + + if err != nil { + return SMTPSender{}, errors.New("invalid port for the SMTP mail") + } + + dialer := &gomail.Dialer{ + Host: host, + Port: iport, + } + + if user != "" { + dialer.Username = user + dialer.Password = password + } + + return SMTPSender{ + Dialer: dialer, + }, nil +} diff --git a/mail/smtp_sender_test.go b/mail/smtp_sender_test.go new file mode 100644 index 000000000..9521db906 --- /dev/null +++ b/mail/smtp_sender_test.go @@ -0,0 +1,58 @@ +package mail_test + +import ( + "bytes" + "testing" + + "github.com/gobuffalo/buffalo/render" + "github.com/gobuffalo/x/fakesmtp" + "github.com/gobuffalo/x/mail" + "github.com/stretchr/testify/require" +) + +var sender mail.Sender +var rend *render.Engine +var smtpServer *fakesmtp.Server + +const smtpPort = "2002" + +func init() { + rend = render.New(render.Options{}) + smtpServer, _ = fakesmtp.New(smtpPort) + sender, _ = mail.NewSMTPSender("127.0.0.1", smtpPort, "username", "password") + + go smtpServer.Start(smtpPort) +} + +func TestSendPlain(t *testing.T) { + smtpServer.Clear() + r := require.New(t) + + m := mail.Message{ + From: "mark@example.com", + To: []string{"something@something.com"}, + Subject: "Cool Message", + CC: []string{"other@other.com", "my@other.com"}, + Bcc: []string{"secret@other.com"}, + } + + m.AddAttachment("someFile.txt", "text/plain", bytes.NewBuffer([]byte("hello"))) + m.AddBody(rend.String("Hello <%= Name %>"), render.Data{"Name": "Antonio"}) + r.Equal(m.Bodies[0].Content, "Hello Antonio") + + err := sender.Send(m) + r.Nil(err) + + lastMessage := smtpServer.LastMessage() + + r.Contains(lastMessage, "FROM:") + r.Contains(lastMessage, "RCPT TO:") + r.Contains(lastMessage, "RCPT TO:") + r.Contains(lastMessage, "RCPT TO:") + r.Contains(lastMessage, "Subject: Cool Message") + r.Contains(lastMessage, "Cc: other@other.com, my@other.com") + r.Contains(lastMessage, "Content-Type: text/plain") + r.Contains(lastMessage, "Hello Antonio") + r.Contains(lastMessage, "Content-Disposition: attachment; filename=\"someFile.txt\"") + r.Contains(lastMessage, "aGVsbG8=") //base64 of the file content +}