From 81e540c2ce7e229c1a311da7c179156a48d4bf4f Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Mon, 26 Feb 2024 07:30:53 -0500 Subject: [PATCH] feat: initial mailer module with password reset and email verification templates --- cmd/portal/init.go | 3 + cmd/portal/main.go | 3 + config/core.go | 1 + config/mail.go | 10 +++ go.mod | 1 + go.sum | 2 + mailer/email.go | 69 +++++++++++++++ mailer/mailer.go | 70 +++++++++++++++ mailer/templates.go | 95 +++++++++++++++++++++ mailer/templates/password_reset_body.tpl | 16 ++++ mailer/templates/password_reset_subject.tpl | 1 + mailer/templates/verify_email_body.tpl | 10 +++ mailer/templates/verify_email_subject.tpl | 1 + 13 files changed, 282 insertions(+) create mode 100644 config/mail.go create mode 100644 mailer/email.go create mode 100644 mailer/mailer.go create mode 100644 mailer/templates.go create mode 100644 mailer/templates/password_reset_body.tpl create mode 100644 mailer/templates/password_reset_subject.tpl create mode 100644 mailer/templates/verify_email_body.tpl create mode 100644 mailer/templates/verify_email_subject.tpl diff --git a/cmd/portal/init.go b/cmd/portal/init.go index 16f8152..2c685bb 100644 --- a/cmd/portal/init.go +++ b/cmd/portal/init.go @@ -32,6 +32,9 @@ func initCheckRequiredConfig(logger *zap.Logger, config *config.Manager) error { "core.storage.s3.region", "core.storage.s3.access_key", "core.storage.s3.secret_key", + "core.mail.host", + "core.mail.username", + "core.mail.password", } for _, key := range required { diff --git a/cmd/portal/main.go b/cmd/portal/main.go index 119f5e4..3cd7961 100644 --- a/cmd/portal/main.go +++ b/cmd/portal/main.go @@ -4,6 +4,8 @@ import ( "flag" "net/http" + "git.lumeweb.com/LumeWeb/portal/mailer" + "git.lumeweb.com/LumeWeb/portal/config" "git.lumeweb.com/LumeWeb/portal/account" @@ -60,6 +62,7 @@ func main() { cron.Module, account.Module, metadata.Module, + mailer.Module, protocols.BuildProtocols(cfg), api.BuildApis(cfg), fx.Provide(api.NewCasbin), diff --git a/config/core.go b/config/core.go index 5ada0a3..f2ce91b 100644 --- a/config/core.go +++ b/config/core.go @@ -11,4 +11,5 @@ type CoreConfig struct { Sia SiaConfig `mapstructure:"sia"` Storage StorageConfig `mapstructure:"storage"` Protocols []string `mapstructure:"protocols"` + Mail MailConfig `mapstructure:"mail"` } diff --git a/config/mail.go b/config/mail.go new file mode 100644 index 0000000..6a1ae9e --- /dev/null +++ b/config/mail.go @@ -0,0 +1,10 @@ +package config + +type MailConfig struct { + Host string + Port int + SSL bool + AuthType string + Username string + Password string +} diff --git a/go.mod b/go.mod index 5de6b24..f1fac1f 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/spf13/viper v1.18.2 github.com/tus/tusd/v2 v2.2.3-0.20240125123123-9080d351525d github.com/vmihailenco/msgpack/v5 v5.4.1 + github.com/wneessen/go-mail v0.4.1 go.etcd.io/bbolt v1.3.8 go.sia.tech/core v0.1.12 go.sia.tech/jape v0.11.1 diff --git a/go.sum b/go.sum index 7d123f4..5053af4 100644 --- a/go.sum +++ b/go.sum @@ -370,6 +370,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/wneessen/go-mail v0.4.1 h1:m2rSg/sc8FZQCdtrV5M8ymHYOFrC6KJAQAIcgrXvqoo= +github.com/wneessen/go-mail v0.4.1/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/mailer/email.go b/mailer/email.go new file mode 100644 index 0000000..853bbde --- /dev/null +++ b/mailer/email.go @@ -0,0 +1,69 @@ +package mailer + +import "github.com/wneessen/go-mail" + +type Email struct { + to string + from string + subject string + body string +} + +func (e *Email) To() string { + return e.to +} + +func (e *Email) SetTo(to string) { + e.to = to +} + +func (e *Email) From() string { + return e.from +} + +func (e *Email) SetFrom(from string) { + e.from = from +} + +func (e *Email) Subject() string { + return e.subject +} + +func (e *Email) SetSubject(subject string) { + e.subject = subject +} + +func (e *Email) Body() string { + return e.body +} + +func (e *Email) SetBody(body string) { + e.body = body +} + +func (e *Email) ToMessage() (*mail.Msg, error) { + msg := + mail.NewMsg() + + err := msg.From(e.from) + if err != nil { + return nil, err + } + + err = msg.To(e.to) + + if err != nil { + return nil, err + } + + msg.SetBodyString("text/plain", e.body) + + return msg, nil +} + +func NewEmail(subject, body string) *Email { + return &Email{ + subject: subject, + body: body, + } +} diff --git a/mailer/mailer.go b/mailer/mailer.go new file mode 100644 index 0000000..e215e19 --- /dev/null +++ b/mailer/mailer.go @@ -0,0 +1,70 @@ +package mailer + +import ( + "git.lumeweb.com/LumeWeb/portal/config" + "github.com/wneessen/go-mail" + "go.uber.org/fx" + "go.uber.org/zap" +) + +type TemplateData = map[string]interface{} + +var Module = fx.Module("mailer", + fx.Options( + fx.Provide(NewMailer), + fx.Provide(NewTemplateRegistry), + fx.Invoke(func(registry *TemplateRegistry) error { + return registry.loadTemplates() + }), + ), +) + +type Mailer struct { + config *config.Manager + logger *zap.Logger + client *mail.Client + templateRegistry *TemplateRegistry +} + +func (m *Mailer) TemplateSend(template string, subjectVars TemplateData, bodyVars TemplateData, to string) error { + email, err := m.templateRegistry.RenderTemplate(template, subjectVars, bodyVars) + + if err != nil { + return err + } + + msg, err := email.ToMessage() + if err != nil { + return err + } + + return m.client.DialAndSend(msg) +} + +func NewMailer(config *config.Manager, templateRegistry *TemplateRegistry, logger *zap.Logger) (*Mailer, error) { + var options []mail.Option + + if config.Config().Core.Mail.Port != 0 { + options = append(options, mail.WithPort(config.Config().Core.Mail.Port)) + } + + if config.Config().Core.Mail.AuthType != "" { + options = append(options, mail.WithSMTPAuth(mail.SMTPAuthType(config.Config().Core.Mail.AuthType))) + } + + if config.Config().Core.Mail.SSL { + options = append(options, mail.WithSSLPort(true)) + } + + options = append(options, mail.WithUsername(config.Config().Core.Mail.Username)) + options = append(options, mail.WithPassword(config.Config().Core.Mail.Password)) + + client, err := mail.NewClient(config.Config().Core.Mail.Host, options...) + if err != nil { + return nil, err + } + + m := &Mailer{config: config, logger: logger, client: client, templateRegistry: templateRegistry} + + return m, nil +} diff --git a/mailer/templates.go b/mailer/templates.go new file mode 100644 index 0000000..a32e0e3 --- /dev/null +++ b/mailer/templates.go @@ -0,0 +1,95 @@ +package mailer + +import ( + "embed" + "errors" + "io/fs" + "strings" + "text/template" +) + +const EMAIL_FS_PREFIX = "templates" + +const TEMPLATE_PASSWORD_RESET = "password_reset.tpl" +const VERIFY_EMAIL = "verify_email.tpl" + +type EmailTemplate struct { + Subject *template.Template + Body *template.Template +} + +//go:embed templates/* +var templateFS embed.FS + +var ErrTemplateNotFound = errors.New("template not found") + +type TemplateRegistry struct { + templates map[string]EmailTemplate +} + +func NewTemplateRegistry() *TemplateRegistry { + return &TemplateRegistry{ + templates: make(map[string]EmailTemplate), + } +} + +func (tr *TemplateRegistry) loadTemplates() error { + subjectTemplates, err := fs.Glob(templateFS, EMAIL_FS_PREFIX+"*_subject*") + + if err != nil { + return err + } + + for _, subjectTemplate := range subjectTemplates { + templateName := strings.TrimSuffix(strings.TrimPrefix(subjectTemplate, EMAIL_FS_PREFIX), "_subject.tpl") + bodyTemplate := strings.TrimSuffix(subjectTemplate, "_subject.tpl") + "_body.tpl" + + subjectContent, err := fs.ReadFile(templateFS, templateName+"_subject.tpl") + if err != nil { + return err + } + + subjectTmpl, err := template.New(templateName).Parse(string(subjectContent)) + if err != nil { + return err + } + + bodyContent, err := fs.ReadFile(templateFS, bodyTemplate) + if err != nil { + return err + } + + bodyTmpl, err := template.New(templateName).Parse(string(bodyContent)) + if err != nil { + return err + } + + tr.templates[templateName] = EmailTemplate{ + Subject: subjectTmpl, + Body: bodyTmpl, + } + } + + return nil +} + +func (tr *TemplateRegistry) RenderTemplate(templateName string, subjectVars TemplateData, bodyVars TemplateData) (*Email, error) { + tmpl, ok := tr.templates[templateName] + if !ok { + return nil, ErrTemplateNotFound + } + + var subjectBuilder strings.Builder + err := tmpl.Subject.Execute(&subjectBuilder, subjectVars) + if err != nil { + return nil, err + } + + var bodyBuilder strings.Builder + err = tmpl.Body.Execute(&bodyBuilder, bodyVars) + if err != nil { + return nil, err + } + + return NewEmail(subjectBuilder.String(), bodyBuilder.String()), nil +} diff --git a/mailer/templates/password_reset_body.tpl b/mailer/templates/password_reset_body.tpl new file mode 100644 index 0000000..7d0fcab --- /dev/null +++ b/mailer/templates/password_reset_body.tpl @@ -0,0 +1,16 @@ +Dear {{if .FirstName}}{{.FirstName}}{{else}}{{.Email}}{{end}}, + +You are receiving this email because we received a password reset request for your account. If you did not request a password reset, please ignore this email. + +To reset your password, please click the link below: +{{.ResetURL}} + +This link will expire in {{.ExpireTime}} hours. If you did not request a password reset, no further action is required. + +If you're having trouble clicking the reset link, copy and paste the URL below into your web browser: +{{.ResetURL}} + +Thank you for using {{.PortalName}}. + +Best regards, +The {{.PortalName}} Team diff --git a/mailer/templates/password_reset_subject.tpl b/mailer/templates/password_reset_subject.tpl new file mode 100644 index 0000000..4e82220 --- /dev/null +++ b/mailer/templates/password_reset_subject.tpl @@ -0,0 +1 @@ +Reset Your Password for {{.PortalName}} diff --git a/mailer/templates/verify_email_body.tpl b/mailer/templates/verify_email_body.tpl new file mode 100644 index 0000000..e654dde --- /dev/null +++ b/mailer/templates/verify_email_body.tpl @@ -0,0 +1,10 @@ +Dear {{if .FirstName}}{{.FirstName}}{{else}}{{.Email}}{{end}}, + +Thank you for registering with {{.PortalName}}. To complete your registration and verify your email address, please enter the following verification code in the provided field on our website: + +Verification Code: {{.VerificationCode}} + +Please note, this code will expire in {{.ExpireTime}} minutes. If you did not initiate this request, please ignore this email or contact our support team for assistance. + +Best regards, +The {{.PortalName}} Team diff --git a/mailer/templates/verify_email_subject.tpl b/mailer/templates/verify_email_subject.tpl new file mode 100644 index 0000000..25ca6b1 --- /dev/null +++ b/mailer/templates/verify_email_subject.tpl @@ -0,0 +1 @@ +Verify Your Email for {{.PortalName}}