From e8d73421aa5f080225463cf6d6bc2e68445aeac0 Mon Sep 17 00:00:00 2001 From: mikestefanello Date: Fri, 14 Jan 2022 15:42:32 -0500 Subject: [PATCH] Expanded mail client for easier email operations. --- README.md | 37 ++++++++-- routes/contact.go | 11 ++- routes/forgot_password.go | 12 ++-- routes/register.go | 14 ++-- services/mail.go | 145 ++++++++++++++++++++++++++------------ 5 files changed, 160 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 1fcbf03..a5dc6e5 100644 --- a/README.md +++ b/README.md @@ -662,7 +662,7 @@ if form := ctx.Get(context.FormKey); form != nil { ``` And finally, your template: -``` +```html ``` @@ -675,7 +675,7 @@ While [validator](https://github.com/go-playground/validator) is a great package To provide the inline validation in your template, there are two things that need to be done. First, include a status class on the element so it will highlight green or red based on the validation: -``` +```html ``` @@ -964,7 +964,7 @@ The cache max-life is controlled by the configuration at `Config.Cache.Expiratio While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function is provided in the [funcmap](#funcmap) to generate a static file URL for a given file that appends a cache-buster query. This query string is randomly generated and persisted until the application restarts. For example, to render a file located in `static/picture.png`, you would use: -```go +```html ``` @@ -979,7 +979,36 @@ Where `9fhe73kaf3` is the randomly-generated cache-buster. An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is because there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications. -Two starter methods were added to the `MailClient`, one to send an email via plain-text and one to send via a template by leveraging the [template renderer](#template-renderer). The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. +The structure in the client (`MailClient`) makes composing emails very easy and you have the option to construct the body using either a simple string or with a template by leveraging the [template renderer](#template-renderer). The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `mail.Send`. + +The _from_ address will default to the configuration value at `Config.Mail.FromAddress`. This can be overridden per-email by calling `From()` on the email and passing in the desired address. + +See below for examples on how to use the client to compose emails. + +**Sending with a string body**: + +```go +err = c.Mail. + Compose(). + To("hello@example.com"). + Subject("Welcome!"). + Body("Thank you for registering."). + Send(ctx) +``` + +**Sending with a template body**: + +```go +err = c.Mail. + Compose(). + To("hello@example.com"). + Subject("Welcome!"). + Template("welcome"). + TemplateData(templateData). + Send(ctx) +``` + +This will use the template located at `templates/emails/welcome.gohtml` and pass `templateData` to it. ## HTTPS diff --git a/routes/contact.go b/routes/contact.go index 4a08e65..0ae07d9 100644 --- a/routes/contact.go +++ b/routes/contact.go @@ -1,6 +1,8 @@ package routes import ( + "fmt" + "github.com/mikestefanello/pagoda/context" "github.com/mikestefanello/pagoda/controller" @@ -47,7 +49,14 @@ func (c *Contact) Post(ctx echo.Context) error { } if !form.Submission.HasErrors() { - if err := c.Container.Mail.Send(ctx, form.Email, "Hello!"); err != nil { + err := c.Container.Mail. + Compose(). + To(form.Email). + Subject("Contact form submitted"). + Body(fmt.Sprintf("The message is: %s", form.Message)). + Send(ctx) + + if err != nil { return c.Fail(ctx, err, "unable to send email") } } diff --git a/routes/forgot_password.go b/routes/forgot_password.go index d1f860d..604c99b 100644 --- a/routes/forgot_password.go +++ b/routes/forgot_password.go @@ -84,10 +84,14 @@ func (c *ForgotPassword) Post(ctx echo.Context) error { ctx.Logger().Infof("generated password reset token for user %d", u.ID) // Email the user - err = c.Container.Mail.Send(ctx, u.Email, fmt.Sprintf( - "Go here to reset your password: %s", - ctx.Echo().Reverse("reset_password", u.ID, token), - )) + url := ctx.Echo().Reverse("reset_password", u.ID, token) + err = c.Container.Mail. + Compose(). + To(u.Email). + Subject("Reset your password"). + Body(fmt.Sprintf("Go here to reset your password: %s", url)). + Send(ctx) + if err != nil { return c.Fail(ctx, err, "error sending password reset email") } diff --git a/routes/register.go b/routes/register.go index 619c866..dde596d 100644 --- a/routes/register.go +++ b/routes/register.go @@ -105,12 +105,16 @@ func (c *Register) sendVerificationEmail(ctx echo.Context, usr *ent.User) { } // Send the email - err = c.Container.Mail.Send(ctx, usr.Email, fmt.Sprintf( - "Confirm your email address: %s", - ctx.Echo().Reverse("verify_email", token), - )) + url := ctx.Echo().Reverse("verify_email", token) + err = c.Container.Mail. + Compose(). + To(usr.Email). + Subject("Confirm your email address"). + Body(fmt.Sprintf("Click here to confirm your email address: %s", url)). + Send(ctx) + if err != nil { - ctx.Logger().Errorf("unable to send email verification token: %v", err) + ctx.Logger().Errorf("unable to send email verification link: %v", err) return } diff --git a/services/mail.go b/services/mail.go index 81659a9..7f59b78 100644 --- a/services/mail.go +++ b/services/mail.go @@ -8,17 +8,29 @@ import ( "github.com/labstack/echo/v4" ) -// MailClient provides a client for sending email -// This is purposely not completed because there are many different methods and services -// for sending email, many of which are very different. Choose what works best for you -// and populate the methods below -type MailClient struct { - // config stores application configuration - config *config.Config +type ( + // MailClient provides a client for sending email + // This is purposely not completed because there are many different methods and services + // for sending email, many of which are very different. Choose what works best for you + // and populate the methods below + MailClient struct { + // config stores application configuration + config *config.Config - // templates stores the template renderer - templates *TemplateRenderer -} + // templates stores the template renderer + templates *TemplateRenderer + } + + mail struct { + client *MailClient + from string + to string + subject string + body string + template string + templateData interface{} + } +) // NewMailClient creates a new MailClient func NewMailClient(cfg *config.Config, templates *TemplateRenderer) (*MailClient, error) { @@ -28,43 +40,86 @@ func NewMailClient(cfg *config.Config, templates *TemplateRenderer) (*MailClient }, nil } -// Send sends an email to a given email address with a given body -func (c *MailClient) Send(ctx echo.Context, to, body string) error { - if c.skipSend() { - ctx.Logger().Debugf("skipping email sent to: %s", to) +// Compose creates a new email +func (m *MailClient) Compose() *mail { + return &mail{ + client: m, + from: m.config.Mail.FromAddress, } - - // TODO: Finish based on your mail sender of choice - return nil -} - -// SendTemplate sends an email to a given email address using a template and data which is passed to the template -// The template name should only include the filename without the extension or directory. -// The funcmap will be automatically added to the template and the data will be passed in. -func (c *MailClient) SendTemplate(ctx echo.Context, to, template string, data interface{}) error { - if c.skipSend() { - ctx.Logger().Debugf("skipping template email sent to: %s", to) - } - - // Parse and execute template - // Uncomment the first variable when ready to use - _, err := c.templates.ParseAndExecute( - "mail", - template, - template, - []string{fmt.Sprintf("emails/%s", template)}, - []string{}, - data, - ) - if err != nil { - return err - } - - // TODO: Finish based on your mail sender of choice - return nil } // skipSend determines if mail sending should be skipped -func (c *MailClient) skipSend() bool { - return c.config.App.Environment != config.EnvProduction +func (m *MailClient) skipSend() bool { + return m.config.App.Environment != config.EnvProduction +} + +// From sets the email from address +func (m *mail) From(from string) *mail { + m.from = from + return m +} + +// To sets the email address this email will be sent to +func (m *mail) To(to string) *mail { + m.to = to + return m +} + +// Subject sets the subject line of the email +func (m *mail) Subject(subject string) *mail { + m.subject = subject + return m +} + +// Body sets the body of the email +// This is not required and will be ignored if a template via Template() +func (m *mail) Body(body string) *mail { + m.body = body + return m +} + +// Template sets the template to be used to produce the body of the email +// The template name should only include the filename without the extension or directory. +// The template must reside within the emails sub-directory. +// The funcmap will be automatically added to the template. +// Use TemplateData() to supply the data that will be passed in to the template. +func (m *mail) Template(template string) *mail { + m.template = template + return m +} + +// TemplateData sets the data that will be passed to the template specified when calling Template() +func (m *mail) TemplateData(data interface{}) *mail { + m.templateData = data + return m +} + +// Send attempts to send the email +func (m *mail) Send(ctx echo.Context) error { + // Check if a template was supplied + if m.template != "" { + // Parse and execute template + buf, err := m.client.templates.ParseAndExecute( + "mail", + m.template, + m.template, + []string{fmt.Sprintf("emails/%s", m.template)}, + []string{}, + m.templateData, + ) + if err != nil { + return err + } + + m.body = buf.String() + } + + // Check if mail sending should be skipped + if m.client.skipSend() { + ctx.Logger().Debugf("skipping email sent to: %s", m.to) + return nil + } + + // TODO: Finish based on your mail sender of choice! + return nil }