From 5b6d17c9b9cc1b9993cf52f12c046e01a75d2ffe Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 24 Oct 2024 15:52:14 -0400 Subject: [PATCH 1/9] Add SMTP configuration values Ref T905 --- config/config.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/config.go b/config/config.go index 1afd5f3..17d34c8 100644 --- a/config/config.go +++ b/config/config.go @@ -171,6 +171,14 @@ type ( } EmailCfg struct { + // SMTP configuration values + Host string `ini:"smtp_host"` + Port int `ini:"smtp_port"` + Username string `ini:"smtp_username"` + Password string `ini:"smtp_password"` + EnableStartTLS bool `ini:"smtp_enable_start_tls"` + + // Mailgun configuration values Domain string `ini:"domain"` MailgunPrivate string `ini:"mailgun_private"` } From d06077c43240daf479f3824f0608f7c8b60d7875 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 24 Oct 2024 15:53:11 -0400 Subject: [PATCH 2/9] Add basic email abstraction layer (Untested.) This will allow us to send via any supported provider within WriteFreely. Ref T731 T905 --- go.mod | 3 ++ go.sum | 4 ++ mailer/mailer.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 mailer/mailer.go diff --git a/go.mod b/go.mod index 47d5f5a..c51873b 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,8 @@ require ( golang.org/x/net v0.30.0 ) +require github.com/xhit/go-simple-mail/v2 v2.16.0 + require ( code.as/core/socks v1.0.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect @@ -82,6 +84,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect github.com/writeas/go-writeas/v2 v2.0.2 // indirect github.com/writeas/openssl-go v1.0.0 // indirect github.com/writeas/slug v1.2.0 // indirect diff --git a/go.sum b/go.sum index 084e2c1..e9e3a6f 100644 --- a/go.sum +++ b/go.sum @@ -177,6 +177,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY= @@ -212,6 +214,8 @@ github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ5 github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M= github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= +github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= +github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/mailer/mailer.go b/mailer/mailer.go new file mode 100644 index 0000000..fbc4dd6 --- /dev/null +++ b/mailer/mailer.go @@ -0,0 +1,98 @@ +/* + * Copyright © 2024 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package mailer + +import ( + "fmt" + "github.com/mailgun/mailgun-go" + "github.com/writefreely/writefreely/config" + mail "github.com/xhit/go-simple-mail/v2" +) + +type ( + // Mailer holds configurations for the preferred mailing provider. + Mailer struct { + smtp *mail.SMTPServer + mailGun *mailgun.MailgunImpl + } + + // Message holds the email contents and metadata for the preferred mailing provider. + Message struct { + mgMsg *mailgun.Message + smtpMsg *mail.Email + } +) + +// New creates a new Mailer from the instance's config.EmailCfg, returning an error if not properly configured. +func New(eCfg *config.EmailCfg) (*Mailer, error) { + m := &Mailer{} + if eCfg.Domain != "" && eCfg.MailgunPrivate != "" { + m.mailGun = mailgun.NewMailgun(eCfg.Domain, eCfg.MailgunPrivate) + } else if eCfg.Username != "" && eCfg.Password != "" && eCfg.Host != "" && eCfg.Port > 0 { + m.smtp = mail.NewSMTPClient() + m.smtp.Host = eCfg.Host + m.smtp.Port = eCfg.Port + m.smtp.Username = eCfg.Username + m.smtp.Password = eCfg.Password + if eCfg.EnableStartTLS { + m.smtp.Encryption = mail.EncryptionSTARTTLS + } + } else { + return nil, fmt.Errorf("no email provider is configured") + } + + return m, nil +} + +// NewMessage creates a new Message from the given parameters. +func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, error) { + msg := &Message{} + if m.mailGun != nil { + msg.mgMsg = m.mailGun.NewMessage(from, subject, text, to...) + } else if m.smtp != nil { + msg.smtpMsg = mail.NewMSG() + msg.smtpMsg.SetFrom(from) + msg.smtpMsg.AddTo(to...) + msg.smtpMsg.SetSubject(subject) + msg.smtpMsg.AddAlternative(mail.TextPlain, text) + + if msg.smtpMsg.Error != nil { + return nil, msg.smtpMsg.Error + } + } + return msg, nil +} + +// SetHTML sets the body of the message. +func (m *Message) SetHTML(html string) { + if m.smtpMsg != nil { + m.smtpMsg.SetBody(mail.TextHTML, html) + } else if m.mgMsg != nil { + m.mgMsg.SetHtml(html) + } +} + +// Send sends the given message via the preferred provider. +func (m *Mailer) Send(msg *Message) error { + if m.smtp != nil { + client, err := m.smtp.Connect() + if err != nil { + return err + } + return msg.smtpMsg.Send(client) + } else if m.mailGun != nil { + _, _, err := m.mailGun.Send(msg.mgMsg) + if err != nil { + return err + } + } + return nil +} From 2fcd45819ff5f21aeb29b7b6db6b6b8a2835efdb Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 24 Oct 2024 16:08:43 -0400 Subject: [PATCH 3/9] Send subscription confirmation email through new email layer (Untested) Ref T905 T731 --- email.go | 18 ++++++++++++++---- mailer/mailer.go | 9 ++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/email.go b/email.go index da4590e..a44b253 100644 --- a/email.go +++ b/email.go @@ -14,6 +14,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/writefreely/writefreely/mailer" "html/template" "net/http" "strings" @@ -437,17 +438,23 @@ func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) er } // Send email - gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + mlr, err := mailer.New(app.cfg.Email) + if err != nil { + return err + } plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser): ` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + ` If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.` - m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) + m, err := mlr.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) + if err != nil { + return err + } m.AddTag("Email Verification") - m.SetHtml(` + m.SetHTML(`

Confirm your subscription to ` + c.DisplayTitle() + ` to start receiving future posts:

@@ -456,7 +463,10 @@ If you didn't subscribe to this site or you're not sure why you're getting this
`) - gun.Send(m) + err = mlr.Send(m) + if err != nil { + return err + } return nil } diff --git a/mailer/mailer.go b/mailer/mailer.go index fbc4dd6..1f9f86e 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -32,7 +32,7 @@ type ( ) // New creates a new Mailer from the instance's config.EmailCfg, returning an error if not properly configured. -func New(eCfg *config.EmailCfg) (*Mailer, error) { +func New(eCfg config.EmailCfg) (*Mailer, error) { m := &Mailer{} if eCfg.Domain != "" && eCfg.MailgunPrivate != "" { m.mailGun = mailgun.NewMailgun(eCfg.Domain, eCfg.MailgunPrivate) @@ -80,6 +80,13 @@ func (m *Message) SetHTML(html string) { } } +// AddTag attaches a tag to the Message for providers that support it. +func (m *Message) AddTag(tag string) { + if m.mgMsg != nil { + m.mgMsg.AddTag(tag) + } +} + // Send sends the given message via the preferred provider. func (m *Mailer) Send(msg *Message) error { if m.smtp != nil { From f49c0b1c4c60f6157bc144a2e96a29089e119ee1 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 24 Oct 2024 16:13:36 -0400 Subject: [PATCH 4/9] Send password resets and login emails with new email layer (Untested) Ref T905 T731 --- account.go | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/account.go b/account.go index 423dee2..363af62 100644 --- a/account.go +++ b/account.go @@ -13,7 +13,7 @@ package writefreely import ( "encoding/json" "fmt" - "github.com/mailgun/mailgun-go" + "github.com/writefreely/writefreely/mailer" "github.com/writefreely/writefreely/spam" "html/template" "net/http" @@ -1378,13 +1378,19 @@ func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) e func emailPasswordReset(app *App, toEmail, token string) error { // Send email - gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + mlr, err := mailer.New(app.cfg.Email) + if err != nil { + return err + } footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email." plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara) - m := mailgun.NewMessage(app.cfg.App.SiteName+" ", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail)) + m, err := mlr.NewMessage(app.cfg.App.SiteName+" ", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail)) + if err != nil { + return err + } m.AddTag("Password Reset") - m.SetHtml(fmt.Sprintf(` + m.SetHTML(fmt.Sprintf(`

%s

@@ -1394,8 +1400,7 @@ func emailPasswordReset(app *App, toEmail, token string) error {
`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)) - _, _, err := gun.Send(m) - return err + return mlr.Send(m) } func loginViaEmail(app *App, alias, redirectTo string) error { @@ -1424,15 +1429,21 @@ func loginViaEmail(app *App, alias, redirectTo string) error { } // Send email - gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + mlr, err := mailer.New(app.cfg.Email) + if err != nil { + return err + } toEmail := u.EmailClear(app.keys) footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email." plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara) - m := mailgun.NewMessage(app.cfg.App.SiteName+" ", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail)) + m, err := mlr.NewMessage(app.cfg.App.SiteName+" ", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail)) + if err != nil { + return err + } m.AddTag("Email Login") - m.SetHtml(fmt.Sprintf(` + m.SetHTML(fmt.Sprintf(`

%s

@@ -1441,9 +1452,7 @@ func loginViaEmail(app *App, alias, redirectTo string) error {
`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara)) - _, _, err = gun.Send(m) - - return err + return mlr.Send(m) } func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error { From 83e0a57338d173785e0aaa4bc62f227220496872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Maniaci?= Date: Mon, 9 Dec 2024 14:56:24 +0100 Subject: [PATCH 5/9] email: allow sending emails from EU Mailgun domains The Mailgun API requires being told when a EU email domain is used[1] so introduce a new `mailgun_europe` config parameter to indicate that. [1]: https://github.com/mailgun/mailgun-go?tab=readme-ov-file#usage --- config/config.go | 1 + email.go | 6 ++++++ mailer/mailer.go | 3 +++ 3 files changed, 10 insertions(+) diff --git a/config/config.go b/config/config.go index 17d34c8..8a79224 100644 --- a/config/config.go +++ b/config/config.go @@ -181,6 +181,7 @@ type ( // Mailgun configuration values Domain string `ini:"domain"` MailgunPrivate string `ini:"mailgun_private"` + MailgunEurope bool `ini:"mailgun_europe"` } // Config holds the complete configuration for running a writefreely instance diff --git a/email.go b/email.go index a44b253..7da60e4 100644 --- a/email.go +++ b/email.go @@ -309,6 +309,12 @@ Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.Ca Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + + if app.cfg.Email.MailgunEurope { + gun.SetAPIBase("https://api.eu.mailgun.net/v3") + } + + m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) if replyTo != "" { diff --git a/mailer/mailer.go b/mailer/mailer.go index 1f9f86e..a75b585 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -36,6 +36,9 @@ func New(eCfg config.EmailCfg) (*Mailer, error) { m := &Mailer{} if eCfg.Domain != "" && eCfg.MailgunPrivate != "" { m.mailGun = mailgun.NewMailgun(eCfg.Domain, eCfg.MailgunPrivate) + if eCfg.MailgunEurope { + m.mailGun.SetAPIBase("https://api.eu.mailgun.net/v3") + } } else if eCfg.Username != "" && eCfg.Password != "" && eCfg.Host != "" && eCfg.Port > 0 { m.smtp = mail.NewSMTPClient() m.smtp.Host = eCfg.Host From e65b73dc732f433c6c30ae7b81c5f675c6fbce67 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Giraudeau Date: Wed, 4 Dec 2024 11:13:04 +0100 Subject: [PATCH 6/9] app.go: Use cfg.Email.Enabled() to check for email config before starting publishJobsQueue. Also fix Email.Enabled to handle smtp config. --- app.go | 14 +++++--------- config/config.go | 3 ++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app.go b/app.go index 7630254..932fdca 100644 --- a/app.go +++ b/app.go @@ -428,15 +428,11 @@ func Initialize(apper Apper, debug bool) (*App, error) { initActivityPub(apper.App()) - if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" { - if apper.App().cfg.Email.Domain == "" { - log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.") - } else if apper.App().cfg.Email.MailgunPrivate == "" { - log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.") - } else { - log.Info("Starting publish jobs queue...") - go startPublishJobsQueue(apper.App()) - } + if apper.App().cfg.Email.Enabled() { + log.Info("Starting publish jobs queue...") + go startPublishJobsQueue(apper.App()) + } else { + log.Error("[FAILED] Starting publish jobs queue: no email provider is configured.") } // Handle local timeline, if enabled diff --git a/config/config.go b/config/config.go index 8a79224..5f084a1 100644 --- a/config/config.go +++ b/config/config.go @@ -251,7 +251,8 @@ func (ac *AppCfg) LandingPath() string { } func (lc EmailCfg) Enabled() bool { - return lc.Domain != "" && lc.MailgunPrivate != "" + return (lc.Domain != "" && lc.MailgunPrivate != "") || + lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0 } func (ac AppCfg) SignupPath() string { From d9deb29730b6d42d6ad10293384b1082315efcfb Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Giraudeau Date: Wed, 4 Dec 2024 15:11:01 +0100 Subject: [PATCH 7/9] Implement remaining plumbing for sending stmp emails to subscribers --- email.go | 21 ++++++++++----------- mailer/mailer.go | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/email.go b/email.go index 7da60e4..561af91 100644 --- a/email.go +++ b/email.go @@ -22,7 +22,6 @@ import ( "github.com/aymerick/douceur/inliner" "github.com/gorilla/mux" - "github.com/mailgun/mailgun-go" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/data" @@ -308,14 +307,14 @@ Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.Ca Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` - gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) - - if app.cfg.Email.MailgunEurope { - gun.SetAPIBase("https://api.eu.mailgun.net/v3") + mlr, err := mailer.New(app.cfg.Email) + if err != nil { + return err + } + m, err := mlr.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) + if err != nil { + return err } - - - m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) if replyTo != "" { m.SetReplyTo(replyTo) @@ -412,7 +411,7 @@ Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/un return err } - m.SetHtml(html) + m.SetHTML(html) log.Info("[email] Adding %d recipient(s)", len(subs)) for _, s := range subs { @@ -428,8 +427,8 @@ Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/un } } - res, _, err := gun.Send(m) - log.Info("[email] Send result: %s", res) + err = mlr.Send(m) + log.Info("[email] Email sent") if err != nil { log.Error("Unable to send post email: %v", err) return err diff --git a/mailer/mailer.go b/mailer/mailer.go index a75b585..974796a 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -83,6 +83,14 @@ func (m *Message) SetHTML(html string) { } } +func (m *Message) SetReplyTo(replyTo string) { + if (m.smtpMsg != nil) { + m.smtpMsg.SetReplyTo(replyTo) + } else { + m.mgMsg.SetReplyTo(replyTo) + } +} + // AddTag attaches a tag to the Message for providers that support it. func (m *Message) AddTag(tag string) { if m.mgMsg != nil { @@ -90,6 +98,16 @@ func (m *Message) AddTag(tag string) { } } +// Variable only used by mailgun +func (m *Message) AddRecipientAndVariables(r string, vars map[string]interface{}) error { + if (m.smtpMsg != nil) { + m.smtpMsg.AddBcc(r) + return nil + } else { + return m.mgMsg.AddRecipientAndVariables(r, vars) + } +} + // Send sends the given message via the preferred provider. func (m *Mailer) Send(msg *Message) error { if m.smtp != nil { @@ -97,6 +115,7 @@ func (m *Mailer) Send(msg *Message) error { if err != nil { return err } + // TODO: handle possible limits (new config?) on max recipients (multiple batches, with delay?) return msg.smtpMsg.Send(client) } else if m.mailGun != nil { _, _, err := m.mailGun.Send(msg.mgMsg) From 63963b6b19b0ed53036852319be627e6ca50b1c5 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Giraudeau Date: Wed, 4 Dec 2024 17:42:07 +0100 Subject: [PATCH 8/9] Send smtp msg to recipients individualy instead of using BCC --- email.go | 2 +- mailer/mailer.go | 90 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/email.go b/email.go index 561af91..eed3985 100644 --- a/email.go +++ b/email.go @@ -417,7 +417,7 @@ Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/un for _, s := range subs { e := s.FinalEmail(app.keys) log.Info("[email] Adding %s", e) - err = m.AddRecipientAndVariables(e, map[string]interface{}{ + err = m.AddRecipientAndVariables(e, map[string]string{ "id": s.ID, "to": e, "token": s.Token, diff --git a/mailer/mailer.go b/mailer/mailer.go index 974796a..ea0c7f9 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -12,8 +12,10 @@ package mailer import ( "fmt" + "strings" "github.com/mailgun/mailgun-go" "github.com/writefreely/writefreely/config" + "github.com/writeas/web-core/log" mail "github.com/xhit/go-simple-mail/v2" ) @@ -27,7 +29,21 @@ type ( // Message holds the email contents and metadata for the preferred mailing provider. Message struct { mgMsg *mailgun.Message - smtpMsg *mail.Email + smtpMsg *SmtpMessage + } + + SmtpMessage struct { + from string + replyTo string + subject string + recipients []Recipient + html string + text string + } + + Recipient struct { + email string + vars map[string]string } ) @@ -48,6 +64,8 @@ func New(eCfg config.EmailCfg) (*Mailer, error) { if eCfg.EnableStartTLS { m.smtp.Encryption = mail.EncryptionSTARTTLS } + // To allow sending multiple email + m.smtp.KeepAlive = true } else { return nil, fmt.Errorf("no email provider is configured") } @@ -61,14 +79,16 @@ func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, if m.mailGun != nil { msg.mgMsg = m.mailGun.NewMessage(from, subject, text, to...) } else if m.smtp != nil { - msg.smtpMsg = mail.NewMSG() - msg.smtpMsg.SetFrom(from) - msg.smtpMsg.AddTo(to...) - msg.smtpMsg.SetSubject(subject) - msg.smtpMsg.AddAlternative(mail.TextPlain, text) - - if msg.smtpMsg.Error != nil { - return nil, msg.smtpMsg.Error + msg.smtpMsg = &SmtpMessage { + from, + "", + subject, + make([]Recipient, len(to)), + "", + text, + } + for _, r := range to { + msg.smtpMsg.recipients = append(msg.smtpMsg.recipients, Recipient{r, make(map[string]string)}) } } return msg, nil @@ -77,7 +97,7 @@ func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, // SetHTML sets the body of the message. func (m *Message) SetHTML(html string) { if m.smtpMsg != nil { - m.smtpMsg.SetBody(mail.TextHTML, html) + m.smtpMsg.html = html } else if m.mgMsg != nil { m.mgMsg.SetHtml(html) } @@ -85,7 +105,7 @@ func (m *Message) SetHTML(html string) { func (m *Message) SetReplyTo(replyTo string) { if (m.smtpMsg != nil) { - m.smtpMsg.SetReplyTo(replyTo) + m.smtpMsg.replyTo = replyTo } else { m.mgMsg.SetReplyTo(replyTo) } @@ -98,13 +118,16 @@ func (m *Message) AddTag(tag string) { } } -// Variable only used by mailgun -func (m *Message) AddRecipientAndVariables(r string, vars map[string]interface{}) error { - if (m.smtpMsg != nil) { - m.smtpMsg.AddBcc(r) +func (m *Message) AddRecipientAndVariables(r string, vars map[string]string) error { + if m.smtpMsg != nil { + m.smtpMsg.recipients = append(m.smtpMsg.recipients, Recipient{r, vars}) return nil } else { - return m.mgMsg.AddRecipientAndVariables(r, vars) + varsInterfaces := make(map[string]interface{}, len(vars)) + for k, v := range vars { + varsInterfaces[k] = v + } + return m.mgMsg.AddRecipientAndVariables(r, varsInterfaces) } } @@ -115,8 +138,39 @@ func (m *Mailer) Send(msg *Message) error { if err != nil { return err } - // TODO: handle possible limits (new config?) on max recipients (multiple batches, with delay?) - return msg.smtpMsg.Send(client) + emailSent := false + for _, r := range msg.smtpMsg.recipients { + customMsg := mail.NewMSG() + customMsg.SetFrom(msg.smtpMsg.from) + if (msg.smtpMsg.replyTo != "") { + customMsg.SetReplyTo(msg.smtpMsg.replyTo) + } + customMsg.SetSubject(msg.smtpMsg.subject) + customMsg.AddTo(r.email) + cText := msg.smtpMsg.text + cHtml := msg.smtpMsg.html + for v, value := range r.vars { + placeHolder := fmt.Sprintf("%%recipient.%s%%", v) + cText = strings.ReplaceAll(cText, placeHolder, value) + cHtml = strings.ReplaceAll(cHtml, placeHolder, value) + } + customMsg.SetBody(mail.TextHTML, cHtml) + customMsg.AddAlternative(mail.TextPlain, cText) + e := customMsg.Error + if e == nil { + e = customMsg.Send(client) + } + if e == nil { + emailSent = true + } else { + log.Error("Unable to send email to %s: %v", r.email, e) + err = e + } + } + if !emailSent { + // only send an error if no email could be sent (to avoid retry of successfully sent emails) + return err + } } else if m.mailGun != nil { _, _, err := m.mailGun.Send(msg.mgMsg) if err != nil { From 0f9c32161c4127e7af181975472fde43f2548b54 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Giraudeau Date: Sat, 25 Jan 2025 12:57:53 +0100 Subject: [PATCH 9/9] Add fieldName when constructing SmtpMessage + go fmt --- config/config.go | 2 +- mailer/mailer.go | 36 ++++++++++++++++++------------------ templates.go | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/config/config.go b/config/config.go index 5f084a1..7bee863 100644 --- a/config/config.go +++ b/config/config.go @@ -252,7 +252,7 @@ func (ac *AppCfg) LandingPath() string { func (lc EmailCfg) Enabled() bool { return (lc.Domain != "" && lc.MailgunPrivate != "") || - lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0 + lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0 } func (ac AppCfg) SignupPath() string { diff --git a/mailer/mailer.go b/mailer/mailer.go index ea0c7f9..30892e6 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -12,11 +12,11 @@ package mailer import ( "fmt" - "strings" "github.com/mailgun/mailgun-go" - "github.com/writefreely/writefreely/config" "github.com/writeas/web-core/log" + "github.com/writefreely/writefreely/config" mail "github.com/xhit/go-simple-mail/v2" + "strings" ) type ( @@ -33,17 +33,17 @@ type ( } SmtpMessage struct { - from string - replyTo string - subject string + from string + replyTo string + subject string recipients []Recipient - html string - text string + html string + text string } Recipient struct { email string - vars map[string]string + vars map[string]string } ) @@ -79,13 +79,13 @@ func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, if m.mailGun != nil { msg.mgMsg = m.mailGun.NewMessage(from, subject, text, to...) } else if m.smtp != nil { - msg.smtpMsg = &SmtpMessage { - from, - "", - subject, - make([]Recipient, len(to)), - "", - text, + msg.smtpMsg = &SmtpMessage{ + from: from, + replyTo: "", + subject: subject, + recipients: make([]Recipient, len(to)), + html: "", + text: text, } for _, r := range to { msg.smtpMsg.recipients = append(msg.smtpMsg.recipients, Recipient{r, make(map[string]string)}) @@ -104,7 +104,7 @@ func (m *Message) SetHTML(html string) { } func (m *Message) SetReplyTo(replyTo string) { - if (m.smtpMsg != nil) { + if m.smtpMsg != nil { m.smtpMsg.replyTo = replyTo } else { m.mgMsg.SetReplyTo(replyTo) @@ -142,7 +142,7 @@ func (m *Mailer) Send(msg *Message) error { for _, r := range msg.smtpMsg.recipients { customMsg := mail.NewMSG() customMsg.SetFrom(msg.smtpMsg.from) - if (msg.smtpMsg.replyTo != "") { + if msg.smtpMsg.replyTo != "" { customMsg.SetReplyTo(msg.smtpMsg.replyTo) } customMsg.SetSubject(msg.smtpMsg.subject) @@ -163,7 +163,7 @@ func (m *Mailer) Send(msg *Message) error { if e == nil { emailSent = true } else { - log.Error("Unable to send email to %s: %v", r.email, e) + log.Error("Unable to send email to %s: %v", r.email, e) err = e } } diff --git a/templates.go b/templates.go index 3bb7d13..030467c 100644 --- a/templates.go +++ b/templates.go @@ -14,8 +14,8 @@ import ( "errors" "html/template" "io" - "os" "net/http" + "os" "path/filepath" "strings"