Merge pull request #1131 from writefreely/smtp

Support SMTP email configuration
This commit is contained in:
Matt Baer 2025-06-03 16:02:22 -04:00 committed by GitHub
commit 79ab0a3786
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 251 additions and 33 deletions

View file

@ -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+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "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(`<html>
m.SetHTML(fmt.Sprintf(`<html>
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
@ -1394,8 +1400,7 @@ func emailPasswordReset(app *App, toEmail, token string) error {
</div>
</body>
</html>`, 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+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "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(`<html>
m.SetHTML(fmt.Sprintf(`<html>
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
@ -1441,9 +1452,7 @@ func loginViaEmail(app *App, alias, redirectTo string) error {
</div>
</body>
</html>`, 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 {

14
app.go
View file

@ -429,15 +429,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

View file

@ -171,8 +171,17 @@ 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"`
MailgunEurope bool `ini:"mailgun_europe"`
}
// Config holds the complete configuration for running a writefreely instance
@ -242,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 {

View file

@ -14,6 +14,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"github.com/writefreely/writefreely/mailer"
"html/template"
"net/http"
"strings"
@ -21,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"
@ -307,8 +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)
m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
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
}
replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo)
if replyTo != "" {
m.SetReplyTo(replyTo)
@ -405,13 +411,13 @@ 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 {
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,
@ -421,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
@ -437,17 +443,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(`<html>
m.SetHTML(`<html>
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
<div style="font-size: 1.2em;">
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
@ -456,7 +468,10 @@ If you didn't subscribe to this site or you're not sure why you're getting this
</div>
</body>
</html>`)
gun.Send(m)
err = mlr.Send(m)
if err != nil {
return err
}
return nil
}

3
go.mod
View file

@ -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

4
go.sum
View file

@ -178,6 +178,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=
@ -213,6 +215,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=

181
mailer/mailer.go Normal file
View file

@ -0,0 +1,181 @@
/*
* 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/writeas/web-core/log"
"github.com/writefreely/writefreely/config"
mail "github.com/xhit/go-simple-mail/v2"
"strings"
)
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 *SmtpMessage
}
SmtpMessage struct {
from string
replyTo string
subject string
recipients []Recipient
html string
text string
}
Recipient struct {
email string
vars map[string]string
}
)
// 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)
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
m.smtp.Port = eCfg.Port
m.smtp.Username = eCfg.Username
m.smtp.Password = eCfg.Password
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")
}
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 = &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)})
}
}
return msg, nil
}
// SetHTML sets the body of the message.
func (m *Message) SetHTML(html string) {
if m.smtpMsg != nil {
m.smtpMsg.html = html
} else if m.mgMsg != nil {
m.mgMsg.SetHtml(html)
}
}
func (m *Message) SetReplyTo(replyTo string) {
if m.smtpMsg != nil {
m.smtpMsg.replyTo = 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 {
m.mgMsg.AddTag(tag)
}
}
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 {
varsInterfaces := make(map[string]interface{}, len(vars))
for k, v := range vars {
varsInterfaces[k] = v
}
return m.mgMsg.AddRecipientAndVariables(r, varsInterfaces)
}
}
// 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
}
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 {
return err
}
}
return nil
}