From 3e1a19685ba5c9f5150fd890ce83b97caa0e74e3 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Mon, 21 Nov 2022 14:53:05 +0200 Subject: [PATCH] [#1069] added default Message-ID and more options to customize the mail message --- apis/settings_test.go | 24 ++++---- core/events.go | 2 + forms/test_email_send_test.go | 4 +- mails/admin.go | 35 ++++++----- mails/admin_test.go | 4 +- mails/record.go | 110 ++++++++++++++++++---------------- mails/record_test.go | 12 ++-- tests/mailer.go | 25 +++----- tools/mailer/mailer.go | 23 ++++--- tools/mailer/sendmail.go | 30 ++++------ tools/mailer/smtp.go | 74 +++++++++++++++-------- 11 files changed, 186 insertions(+), 157 deletions(-) diff --git a/apis/settings_test.go b/apis/settings_test.go index 8f8a2181..ad70513e 100644 --- a/apis/settings_test.go +++ b/apis/settings_test.go @@ -303,12 +303,12 @@ func TestSettingsTestEmail(t *testing.T) { t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend) } - if app.TestMailer.LastToAddress.Address != "test@example.com" { - t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address) + if app.TestMailer.LastMessage.To.Address != "test@example.com" { + t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address) } - if !strings.Contains(app.TestMailer.LastHtmlBody, "Verify") { - t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody) + if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") { + t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML) } }, ExpectedStatus: 204, @@ -334,12 +334,12 @@ func TestSettingsTestEmail(t *testing.T) { t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend) } - if app.TestMailer.LastToAddress.Address != "test@example.com" { - t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address) + if app.TestMailer.LastMessage.To.Address != "test@example.com" { + t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address) } - if !strings.Contains(app.TestMailer.LastHtmlBody, "Reset password") { - t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody) + if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") { + t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML) } }, ExpectedStatus: 204, @@ -365,12 +365,12 @@ func TestSettingsTestEmail(t *testing.T) { t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend) } - if app.TestMailer.LastToAddress.Address != "test@example.com" { - t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address) + if app.TestMailer.LastMessage.To.Address != "test@example.com" { + t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address) } - if !strings.Contains(app.TestMailer.LastHtmlBody, "Confirm new email") { - t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody) + if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") { + t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML) } }, ExpectedStatus: 204, diff --git a/core/events.go b/core/events.go index e2c55bec..24eab5cf 100644 --- a/core/events.go +++ b/core/events.go @@ -35,12 +35,14 @@ type ModelEvent struct { type MailerRecordEvent struct { MailClient mailer.Mailer + Message *mailer.Message Record *models.Record Meta map[string]any } type MailerAdminEvent struct { MailClient mailer.Mailer + Message *mailer.Message Admin *models.Admin Meta map[string]any } diff --git a/forms/test_email_send_test.go b/forms/test_email_send_test.go index 551d1760..60406098 100644 --- a/forms/test_email_send_test.go +++ b/forms/test_email_send_test.go @@ -72,8 +72,8 @@ func TestEmailSendValidateAndSubmit(t *testing.T) { expectedContent = "Confirm new email" } - if !strings.Contains(app.TestMailer.LastHtmlBody, expectedContent) { - t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastHtmlBody) + if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) { + t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML) } } } diff --git a/mails/admin.go b/mails/admin.go index a2b7fbb6..142f8c4d 100644 --- a/mails/admin.go +++ b/mails/admin.go @@ -8,6 +8,7 @@ import ( "github.com/pocketbase/pocketbase/mails/templates" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tokens" + "github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/rest" ) @@ -43,29 +44,31 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error { mailClient := app.NewMailClient() + // resolve body template + body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody) + if renderErr != nil { + return renderErr + } + + message := &mailer.Message{ + From: mail.Address{ + Name: app.Settings().Meta.SenderName, + Address: app.Settings().Meta.SenderAddress, + }, + To: mail.Address{Address: admin.Email}, + Subject: "Reset admin password", + HTML: body, + } + event := &core.MailerAdminEvent{ MailClient: mailClient, + Message: message, Admin: admin, Meta: map[string]any{"token": token}, } sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error { - // resolve body template - body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody) - if renderErr != nil { - return renderErr - } - - return e.MailClient.Send( - mail.Address{ - Name: app.Settings().Meta.SenderName, - Address: app.Settings().Meta.SenderAddress, - }, - mail.Address{Address: e.Admin.Email}, - "Reset admin password", - body, - nil, - ) + return e.MailClient.Send(e.Message) }) if sendErr == nil { diff --git a/mails/admin_test.go b/mails/admin_test.go index 3ae320c2..32bb6fe7 100644 --- a/mails/admin_test.go +++ b/mails/admin_test.go @@ -30,8 +30,8 @@ func TestSendAdminPasswordReset(t *testing.T) { "http://localhost:8090/_/#/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", } for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody) + if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { + t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) } } } diff --git a/mails/record.go b/mails/record.go index 6371f6b4..daac3344 100644 --- a/mails/record.go +++ b/mails/record.go @@ -7,8 +7,8 @@ import ( "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/mails/templates" "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/tokens" + "github.com/pocketbase/pocketbase/tools/mailer" ) // SendRecordPasswordReset sends a password reset request email to the specified user. @@ -20,30 +20,32 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error { mailClient := app.NewMailClient() + settings := app.Settings() + + subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ResetPasswordTemplate) + if err != nil { + return err + } + + message := &mailer.Message{ + From: mail.Address{ + Name: settings.Meta.SenderName, + Address: settings.Meta.SenderAddress, + }, + To: mail.Address{Address: authRecord.Email()}, + Subject: subject, + HTML: body, + } + event := &core.MailerRecordEvent{ MailClient: mailClient, + Message: message, Record: authRecord, Meta: map[string]any{"token": token}, } sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error { - settings := app.Settings() - - subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ResetPasswordTemplate) - if err != nil { - return err - } - - return e.MailClient.Send( - mail.Address{ - Name: settings.Meta.SenderName, - Address: settings.Meta.SenderAddress, - }, - mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)}, - subject, - body, - nil, - ) + return e.MailClient.Send(e.Message) }) if sendErr == nil { @@ -62,30 +64,32 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error { mailClient := app.NewMailClient() + settings := app.Settings() + + subject, body, err := resolveEmailTemplate(app, token, settings.Meta.VerificationTemplate) + if err != nil { + return err + } + + message := &mailer.Message{ + From: mail.Address{ + Name: settings.Meta.SenderName, + Address: settings.Meta.SenderAddress, + }, + To: mail.Address{Address: authRecord.Email()}, + Subject: subject, + HTML: body, + } + event := &core.MailerRecordEvent{ MailClient: mailClient, + Message: message, Record: authRecord, Meta: map[string]any{"token": token}, } sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error { - settings := app.Settings() - - subject, body, err := resolveEmailTemplate(app, token, settings.Meta.VerificationTemplate) - if err != nil { - return err - } - - return e.MailClient.Send( - mail.Address{ - Name: settings.Meta.SenderName, - Address: settings.Meta.SenderAddress, - }, - mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)}, - subject, - body, - nil, - ) + return e.MailClient.Send(e.Message) }) if sendErr == nil { @@ -104,8 +108,26 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string) mailClient := app.NewMailClient() + settings := app.Settings() + + subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ConfirmEmailChangeTemplate) + if err != nil { + return err + } + + message := &mailer.Message{ + From: mail.Address{ + Name: settings.Meta.SenderName, + Address: settings.Meta.SenderAddress, + }, + To: mail.Address{Address: newEmail}, + Subject: subject, + HTML: body, + } + event := &core.MailerRecordEvent{ MailClient: mailClient, + Message: message, Record: record, Meta: map[string]any{ "token": token, @@ -114,23 +136,7 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string) } sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error { - settings := app.Settings() - - subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ConfirmEmailChangeTemplate) - if err != nil { - return err - } - - return e.MailClient.Send( - mail.Address{ - Name: settings.Meta.SenderName, - Address: settings.Meta.SenderAddress, - }, - mail.Address{Address: newEmail}, - subject, - body, - nil, - ) + return e.MailClient.Send(e.Message) }) if sendErr == nil { diff --git a/mails/record_test.go b/mails/record_test.go index 2f74c402..f885d1f2 100644 --- a/mails/record_test.go +++ b/mails/record_test.go @@ -30,8 +30,8 @@ func TestSendRecordPasswordReset(t *testing.T) { "http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", } for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody) + if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { + t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) } } } @@ -55,8 +55,8 @@ func TestSendRecordVerification(t *testing.T) { "http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", } for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody) + if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { + t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) } } } @@ -80,8 +80,8 @@ func TestSendRecordChangeEmail(t *testing.T) { "http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", } for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody) + if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { + t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) } } } diff --git a/tests/mailer.go b/tests/mailer.go index ff586f9b..fa6c2116 100644 --- a/tests/mailer.go +++ b/tests/mailer.go @@ -1,9 +1,6 @@ package tests import ( - "io" - "net/mail" - "github.com/pocketbase/pocketbase/tools/mailer" ) @@ -11,28 +8,20 @@ var _ mailer.Mailer = (*TestMailer)(nil) // TestMailer is a mock `mailer.Mailer` implementation. type TestMailer struct { - TotalSend int - LastFromAddress mail.Address - LastToAddress mail.Address - LastHtmlSubject string - LastHtmlBody string + TotalSend int + LastMessage mailer.Message } // Reset clears any previously test collected data. func (m *TestMailer) Reset() { m.TotalSend = 0 - m.LastFromAddress = mail.Address{} - m.LastToAddress = mail.Address{} - m.LastHtmlSubject = "" - m.LastHtmlBody = "" + m.LastMessage = mailer.Message{} } // Send implements `mailer.Mailer` interface. -func (m *TestMailer) Send(fromEmail mail.Address, toEmail mail.Address, subject string, html string, attachments map[string]io.Reader) error { - m.LastFromAddress = fromEmail - m.LastToAddress = toEmail - m.LastHtmlSubject = subject - m.LastHtmlBody = html - m.TotalSend++ +func (c *TestMailer) Send(m *mailer.Message) error { + c.TotalSend++ + c.LastMessage = *m + return nil } diff --git a/tools/mailer/mailer.go b/tools/mailer/mailer.go index 4ebe1baa..d46ac4ea 100644 --- a/tools/mailer/mailer.go +++ b/tools/mailer/mailer.go @@ -5,14 +5,21 @@ import ( "net/mail" ) +// Message defines a generic email message struct. +type Message struct { + From mail.Address + To mail.Address + Bcc []string + Cc []string + Subject string + HTML string + Text string + Headers map[string]string + Attachments map[string]io.Reader +} + // Mailer defines a base mail client interface. type Mailer interface { - // Send sends an email with HTML body to the specified recipient. - Send( - fromEmail mail.Address, - toEmail mail.Address, - subject string, - htmlContent string, - attachments map[string]io.Reader, - ) error + // Send sends an email with the provided Message. + Send(message *Message) error } diff --git a/tools/mailer/sendmail.go b/tools/mailer/sendmail.go index fc69cee3..e56e53f1 100644 --- a/tools/mailer/sendmail.go +++ b/tools/mailer/sendmail.go @@ -3,10 +3,8 @@ package mailer import ( "bytes" "errors" - "io" "mime" "net/http" - "net/mail" "os/exec" ) @@ -20,19 +18,11 @@ type Sendmail struct { } // Send implements `mailer.Mailer` interface. -// -// Attachments are currently not supported. -func (m *Sendmail) Send( - fromEmail mail.Address, - toEmail mail.Address, - subject string, - htmlContent string, - attachments map[string]io.Reader, -) error { +func (c *Sendmail) Send(m *Message) error { headers := make(http.Header) - headers.Set("Subject", mime.QEncoding.Encode("utf-8", subject)) - headers.Set("From", fromEmail.String()) - headers.Set("To", toEmail.String()) + headers.Set("Subject", mime.QEncoding.Encode("utf-8", m.Subject)) + headers.Set("From", m.From.String()) + headers.Set("To", m.To.String()) headers.Set("Content-Type", "text/html; charset=UTF-8") cmdPath, err := findSendmailPath() @@ -50,12 +40,18 @@ func (m *Sendmail) Send( if _, err := buffer.Write([]byte("\r\n")); err != nil { return err } - if _, err := buffer.Write([]byte(htmlContent)); err != nil { - return err + if m.HTML != "" { + if _, err := buffer.Write([]byte(m.HTML)); err != nil { + return err + } + } else { + if _, err := buffer.Write([]byte(m.Text)); err != nil { + return err + } } // --- - sendmail := exec.Command(cmdPath, toEmail.Address) + sendmail := exec.Command(cmdPath, m.To.Address) sendmail.Stdin = &buffer return sendmail.Run() diff --git a/tools/mailer/smtp.go b/tools/mailer/smtp.go index d9301352..56fc862d 100644 --- a/tools/mailer/smtp.go +++ b/tools/mailer/smtp.go @@ -2,11 +2,11 @@ package mailer import ( "fmt" - "io" - "net/mail" "net/smtp" + "strings" "github.com/domodwyer/mailyak/v3" + "github.com/pocketbase/pocketbase/tools/security" ) var _ Mailer = (*SmtpClient)(nil) @@ -39,46 +39,72 @@ type SmtpClient struct { } // Send implements `mailer.Mailer` interface. -func (m *SmtpClient) Send( - fromEmail mail.Address, - toEmail mail.Address, - subject string, - htmlContent string, - attachments map[string]io.Reader, -) error { +func (c *SmtpClient) Send(m *Message) error { var smtpAuth smtp.Auth - if m.username != "" || m.password != "" { - smtpAuth = smtp.PlainAuth("", m.username, m.password, m.host) + if c.username != "" || c.password != "" { + smtpAuth = smtp.PlainAuth("", c.username, c.password, c.host) } // create mail instance var yak *mailyak.MailYak - if m.tls { + if c.tls { var tlsErr error - yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", m.host, m.port), smtpAuth, nil) + yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.host, c.port), smtpAuth, nil) if tlsErr != nil { return tlsErr } } else { - yak = mailyak.New(fmt.Sprintf("%s:%d", m.host, m.port), smtpAuth) + yak = mailyak.New(fmt.Sprintf("%s:%d", c.host, c.port), smtpAuth) } - if fromEmail.Name != "" { - yak.FromName(fromEmail.Name) + if m.From.Name != "" { + yak.FromName(m.From.Name) } - yak.From(fromEmail.Address) - yak.To(toEmail.Address) - yak.Subject(subject) - yak.HTML().Set(htmlContent) + yak.From(m.From.Address) + yak.To(m.To.Address) + yak.Subject(m.Subject) + yak.HTML().Set(m.HTML) - // try to generate a plain text version of the HTML - if plain, err := html2Text(htmlContent); err == nil { - yak.Plain().Set(plain) + if m.Text == "" { + // try to generate a plain text version of the HTML + if plain, err := html2Text(m.HTML); err == nil { + yak.Plain().Set(plain) + } + } else { + yak.Plain().Set(m.Text) } - for name, data := range attachments { + if len(m.Bcc) > 0 { + yak.Bcc(m.Bcc...) + } + + if len(m.Cc) > 0 { + yak.Cc(m.Cc...) + } + + // add attachements (if any) + for name, data := range m.Attachments { yak.Attach(name, data) } + // add custom headers (if any) + var hasMessageId bool + for k, v := range m.Headers { + if strings.EqualFold(k, "Message-ID") { + hasMessageId = true + } + yak.AddHeader(k, v) + } + if !hasMessageId { + // add a default message id if missing + fromParts := strings.Split(m.From.Address, "@") + if len(fromParts) == 2 { + yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>", + security.PseudorandomString(15), + fromParts[1], + )) + } + } + return yak.Send() }