1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-28 03:50:10 +00:00

Compare commits

...

2 Commits

Author SHA1 Message Date
b8dfb5d6a3
decouple email content from sender to ensure thread safety
If two emails fired off at once, they would previously replace each
other's content and possibly send the wrong email to the wrong person.
construct* methods now return the email content, which is sent
separately.
2020-09-13 21:18:47 +01:00
51839b5942
Restructure email sending
smtp and mailgun now implement an emailClient interface, which the
Emailer can use.
2020-09-13 21:07:15 +01:00
5 changed files with 163 additions and 114 deletions

34
api.go
View File

@ -92,10 +92,13 @@ func (app *appContext) checkInvites() {
for address, settings := range notify { for address, settings := range notify {
if settings["notify-expiry"] { if settings["notify-expiry"] {
go func() { go func() {
if app.email.constructExpiry(code, data, app) != nil { msg, err := app.email.constructExpiry(code, data, app)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification", code) app.err.Printf("%s: Failed to construct expiry notification", code)
} else if app.email.send(address, app) != nil { app.debug.Printf("Error: %s", err)
} else if err := app.email.send(address, msg); err != nil {
app.err.Printf("%s: Failed to send expiry notification", code) app.err.Printf("%s: Failed to send expiry notification", code)
app.debug.Printf("Error: %s", err)
} else { } else {
app.info.Printf("Sent expiry notification to %s", address) app.info.Printf("Sent expiry notification to %s", address)
} }
@ -126,10 +129,13 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
for address, settings := range notify { for address, settings := range notify {
if settings["notify-expiry"] { if settings["notify-expiry"] {
go func() { go func() {
if app.email.constructExpiry(code, inv, app) != nil { msg, err := app.email.constructExpiry(code, inv, app)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification", code) app.err.Printf("%s: Failed to construct expiry notification", code)
} else if app.email.send(address, app) != nil { app.debug.Printf("Error: %s", err)
} else if err := app.email.send(address, msg); err != nil {
app.err.Printf("%s: Failed to send expiry notification", code) app.err.Printf("%s: Failed to send expiry notification", code)
app.debug.Printf("Error: %s", err)
} else { } else {
app.info.Printf("Sent expiry notification to %s", address) app.info.Printf("Sent expiry notification to %s", address)
} }
@ -216,10 +222,11 @@ func (app *appContext) NewUser(gc *gin.Context) {
for address, settings := range invite.Notify { for address, settings := range invite.Notify {
if settings["notify-creation"] { if settings["notify-creation"] {
go func() { go func() {
if app.email.constructCreated(req.Code, req.Username, req.Email, invite, app) != nil { msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app)
if err != nil {
app.err.Printf("%s: Failed to construct user creation notification", req.Code) app.err.Printf("%s: Failed to construct user creation notification", req.Code)
app.debug.Printf("%s: Error: %s", req.Code, err) app.debug.Printf("%s: Error: %s", req.Code, err)
} else if app.email.send(address, app) != nil { } else if err := app.email.send(address, msg); err != nil {
app.err.Printf("%s: Failed to send user creation notification", req.Code) app.err.Printf("%s: Failed to send user creation notification", req.Code)
app.debug.Printf("%s: Error: %s", req.Code, err) app.debug.Printf("%s: Error: %s", req.Code, err)
} else { } else {
@ -300,11 +307,12 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
if req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { if req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
app.debug.Printf("%s: Sending invite email", invite_code) app.debug.Printf("%s: Sending invite email", invite_code)
invite.Email = req.Email invite.Email = req.Email
if err := app.email.constructInvite(invite_code, invite, app); err != nil { msg, err := app.email.constructInvite(invite_code, invite, app)
if err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
app.err.Printf("%s: Failed to construct invite email", invite_code) app.err.Printf("%s: Failed to construct invite email", invite_code)
app.debug.Printf("%s: Error: %s", invite_code, err) app.debug.Printf("%s: Error: %s", invite_code, err)
} else if err := app.email.send(req.Email, app); err != nil { } else if err := app.email.send(req.Email, msg); err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
app.err.Printf("%s: %s", invite_code, invite.Email) app.err.Printf("%s: %s", invite_code, invite.Email)
app.debug.Printf("%s: Error: %s", invite_code, err) app.debug.Printf("%s: Error: %s", invite_code, err)
@ -353,9 +361,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
address = app.config.Section("ui").Key("email").String() address = app.config.Section("ui").Key("email").String()
} }
if _, ok := inv.Notify[address]; ok { if _, ok := inv.Notify[address]; ok {
for _, notify_type := range []string{"notify-expiry", "notify-creation"} { for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
if _, ok = inv.Notify[notify_type]; ok { if _, ok = inv.Notify[notifyType]; ok {
invite[notify_type] = inv.Notify[address][notify_type] invite[notifyType] = inv.Notify[address][notifyType]
} }
} }
} }
@ -557,7 +565,7 @@ func (app *appContext) SetDefaults(gc *gin.Context) {
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
userId := user["Id"].(string) userID := user["Id"].(string)
policy := user["Policy"].(map[string]interface{}) policy := user["Policy"].(map[string]interface{})
app.storage.policy = policy app.storage.policy = policy
app.storage.storePolicy() app.storage.storePolicy()
@ -565,7 +573,7 @@ func (app *appContext) SetDefaults(gc *gin.Context) {
if req.Homescreen { if req.Homescreen {
configuration := user["Configuration"].(map[string]interface{}) configuration := user["Configuration"].(map[string]interface{})
var displayprefs map[string]interface{} var displayprefs map[string]interface{}
displayprefs, status, err = app.jf.getDisplayPreferences(userId) displayprefs, status, err = app.jf.getDisplayPreferences(userID)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get DisplayPrefs: Code %d", status) app.err.Printf("Failed to get DisplayPrefs: Code %d", status)
app.debug.Printf("Error: %s", err) app.debug.Printf("Error: %s", err)

View File

@ -69,5 +69,7 @@ func (app *appContext) loadConfig() error {
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html"))) app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html")))
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt"))) app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt")))
app.email = NewEmailer(app)
return nil return nil
} }

228
email.go
View File

@ -15,15 +15,64 @@ import (
"github.com/mailgun/mailgun-go/v4" "github.com/mailgun/mailgun-go/v4"
) )
type Emailer struct { // implements email sending, right now via smtp or mailgun.
smtpAuth smtp.Auth type emailClient interface {
sendType, sendMethod, fromAddr, fromName string send(address, fromName, fromAddr string, email *Email) error
content Email
mg *mailgun.MailgunImpl
mime string
host string
} }
type Mailgun struct {
client *mailgun.MailgunImpl
}
func (mg *Mailgun) send(address, fromName, fromAddr string, email *Email) error {
message := mg.client.NewMessage(
fmt.Sprintf("%s <%s>", fromName, fromAddr),
email.subject,
email.text,
address,
)
message.SetHtml(email.html)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, _, err := mg.client.Send(ctx, message)
return err
}
type Smtp struct {
sslTls bool
host, server string
port int
auth smtp.Auth
}
func (sm *Smtp) send(address, fromName, fromAddr string, email *Email) error {
e := jEmail.NewEmail()
e.Subject = email.subject
e.From = fmt.Sprintf("%s <%s>", fromName, fromAddr)
e.To = []string{address}
e.Text = []byte(email.text)
e.HTML = []byte(email.html)
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: sm.host,
}
server := fmt.Sprintf("%s:%d", sm.server, sm.port)
var err error
if sm.sslTls {
err = e.SendWithTLS(server, sm.auth, tlsConfig)
} else {
err = e.SendWithStartTLS(server, sm.auth, tlsConfig)
}
return err
}
// Emailer contains the email sender, email content, and methods to construct message content.
type Emailer struct {
fromAddr, fromName string
sender emailClient
}
// Email stores content.
type Email struct { type Email struct {
subject string subject string
html, text string html, text string
@ -50,29 +99,53 @@ func (email *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern,
return return
} }
func (email *Emailer) init(app *appContext) { func NewEmailer(app *appContext) *Emailer {
email.fromAddr = app.config.Section("email").Key("address").String() emailer := &Emailer{
email.fromName = app.config.Section("email").Key("from").String() fromAddr: app.config.Section("email").Key("address").String(),
email.sendMethod = app.config.Section("email").Key("method").String() fromName: app.config.Section("email").Key("from").String(),
if email.sendMethod == "mailgun" { }
email.mg = mailgun.NewMailgun(strings.Split(email.fromAddr, "@")[1], app.config.Section("mailgun").Key("api_key").String()) method := app.config.Section("email").Key("method").String()
api_url := app.config.Section("mailgun").Key("api_url").String() if method == "smtp" {
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages' sslTls := false
if strings.Contains(api_url, "messages") { if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
api_url = api_url[0:strings.LastIndex(api_url, "/")] sslTls = true
api_url = api_url[0:strings.LastIndex(api_url, "/")]
} }
email.mg.SetAPIBase(api_url) emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), app.config.Section("smtp").Key("password").String(), app.host, sslTls)
} else if email.sendMethod == "smtp" { } else if method == "mailgun" {
app.host = app.config.Section("smtp").Key("server").String() emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
email.smtpAuth = smtp.PlainAuth("", email.fromAddr, app.config.Section("smtp").Key("password").String(), app.host) }
return emailer
}
func (emailer *Emailer) NewMailgun(url, key string) {
sender := &Mailgun{
client: mailgun.NewMailgun(strings.Split(emailer.fromAddr, "@")[1], key),
}
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages'
if strings.Contains(url, "messages") {
url = url[0:strings.LastIndex(url, "/")]
url = url[0:strings.LastIndex(url, "/")]
}
sender.client.SetAPIBase(url)
emailer.sender = sender
}
func (emailer *Emailer) NewSMTP(server string, port int, password, host string, sslTls bool) {
emailer.sender = &Smtp{
auth: smtp.PlainAuth("", emailer.fromAddr, password, host),
server: server,
host: host,
port: port,
sslTls: sslTls,
} }
} }
func (email *Emailer) constructInvite(code string, invite Invite, app *appContext) error { func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
email.content.subject = app.config.Section("invite_emails").Key("subject").String() email := &Email{
subject: app.config.Section("invite_emails").Key("subject").String(),
}
expiry := invite.ValidTill expiry := invite.ValidTill
d, t, expires_in := email.formatExpiry(expiry, false, app.datePattern, app.timePattern) d, t, expires_in := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String() message := app.config.Section("email").Key("message").String()
invite_link := app.config.Section("invite_emails").Key("url_base").String() invite_link := app.config.Section("invite_emails").Key("url_base").String()
invite_link = fmt.Sprintf("%s/%s", invite_link, code) invite_link = fmt.Sprintf("%s/%s", invite_link, code)
@ -81,7 +154,7 @@ func (email *Emailer) constructInvite(code string, invite Invite, app *appContex
fpath := app.config.Section("invite_emails").Key("email_" + key).String() fpath := app.config.Section("invite_emails").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath) tpl, err := template.ParseFiles(fpath)
if err != nil { if err != nil {
return err return nil, err
} }
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{ err = tpl.Execute(&tplData, map[string]string{
@ -92,26 +165,27 @@ func (email *Emailer) constructInvite(code string, invite Invite, app *appContex
"message": message, "message": message,
}) })
if err != nil { if err != nil {
return err return nil, err
} }
if key == "html" { if key == "html" {
email.content.html = tplData.String() email.html = tplData.String()
} else { } else {
email.content.text = tplData.String() email.text = tplData.String()
} }
} }
email.sendType = "invite" return email, nil
return nil
} }
func (email *Emailer) constructExpiry(code string, invite Invite, app *appContext) error { func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext) (*Email, error) {
email.content.subject = "Notice: Invite expired" email := &Email{
subject: "Notice: Invite expired",
}
expiry := app.formatDatetime(invite.ValidTill) expiry := app.formatDatetime(invite.ValidTill)
for _, key := range []string{"html", "text"} { for _, key := range []string{"html", "text"} {
fpath := app.config.Section("notifications").Key("expiry_" + key).String() fpath := app.config.Section("notifications").Key("expiry_" + key).String()
tpl, err := template.ParseFiles(fpath) tpl, err := template.ParseFiles(fpath)
if err != nil { if err != nil {
return err return nil, err
} }
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{ err = tpl.Execute(&tplData, map[string]string{
@ -119,20 +193,21 @@ func (email *Emailer) constructExpiry(code string, invite Invite, app *appContex
"expiry": expiry, "expiry": expiry,
}) })
if err != nil { if err != nil {
return err return nil, err
} }
if key == "html" { if key == "html" {
email.content.html = tplData.String() email.html = tplData.String()
} else { } else {
email.content.text = tplData.String() email.text = tplData.String()
} }
} }
email.sendType = "expiry" return email, nil
return nil
} }
func (email *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) error { func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) (*Email, error) {
email.content.subject = "Notice: User created" email := &Email{
subject: "Notice: User created",
}
created := app.formatDatetime(invite.Created) created := app.formatDatetime(invite.Created)
var tplAddress string var tplAddress string
if app.config.Section("email").Key("no_username").MustBool(false) { if app.config.Section("email").Key("no_username").MustBool(false) {
@ -144,7 +219,7 @@ func (email *Emailer) constructCreated(code, username, address string, invite In
fpath := app.config.Section("notifications").Key("created_" + key).String() fpath := app.config.Section("notifications").Key("created_" + key).String()
tpl, err := template.ParseFiles(fpath) tpl, err := template.ParseFiles(fpath)
if err != nil { if err != nil {
return err return nil, err
} }
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{ err = tpl.Execute(&tplData, map[string]string{
@ -154,27 +229,28 @@ func (email *Emailer) constructCreated(code, username, address string, invite In
"time": created, "time": created,
}) })
if err != nil { if err != nil {
return err return nil, err
} }
if key == "html" { if key == "html" {
email.content.html = tplData.String() email.html = tplData.String()
} else { } else {
email.content.text = tplData.String() email.text = tplData.String()
} }
} }
email.sendType = "created" return email, nil
return nil
} }
func (email *Emailer) constructReset(pwr Pwr, app *appContext) error { func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error) {
email.content.subject = app.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin") email := &Email{
d, t, expires_in := email.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern) subject: app.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin"),
}
d, t, expires_in := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String() message := app.config.Section("email").Key("message").String()
for _, key := range []string{"html", "text"} { for _, key := range []string{"html", "text"} {
fpath := app.config.Section("password_resets").Key("email_" + key).String() fpath := app.config.Section("password_resets").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath) tpl, err := template.ParseFiles(fpath)
if err != nil { if err != nil {
return err return nil, err
} }
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{ err = tpl.Execute(&tplData, map[string]string{
@ -186,55 +262,17 @@ func (email *Emailer) constructReset(pwr Pwr, app *appContext) error {
"message": message, "message": message,
}) })
if err != nil { if err != nil {
return err return nil, err
} }
if key == "html" { if key == "html" {
email.content.html = tplData.String() email.html = tplData.String()
} else { } else {
email.content.text = tplData.String() email.text = tplData.String()
} }
} }
email.sendType = "reset" return email, nil
return nil
} }
func (email *Emailer) send(address string, app *appContext) error { func (emailer *Emailer) send(address string, email *Email) error {
if email.sendMethod == "mailgun" { return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email)
message := email.mg.NewMessage(
fmt.Sprintf("%s <%s>", email.fromName, email.fromAddr),
email.content.subject,
email.content.text,
address)
message.SetHtml(email.content.html)
mgapp, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
_, _, err := email.mg.Send(mgapp, message)
if err != nil {
return err
}
} else if email.sendMethod == "smtp" {
e := jEmail.NewEmail()
e.Subject = email.content.subject
e.From = fmt.Sprintf("%s <%s>", email.fromName, email.fromAddr)
e.To = []string{address}
e.Text = []byte(email.content.text)
e.HTML = []byte(email.content.html)
smtpType := app.config.Section("smtp").Key("encryption").String()
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: app.host,
}
var err error
if smtpType == "ssl_tls" {
port := app.config.Section("smtp").Key("port").MustInt(465)
server := fmt.Sprintf("%s:%d", app.host, port)
err = e.SendWithTLS(server, email.smtpAuth, tlsConfig)
} else if smtpType == "starttls" {
port := app.config.Section("smtp").Key("port").MustInt(587)
server := fmt.Sprintf("%s:%d", app.host, port)
e.SendWithStartTLS(server, email.smtpAuth, tlsConfig)
}
return err
}
return nil
} }

View File

@ -53,7 +53,7 @@ type appContext struct {
timePattern string timePattern string
storage Storage storage Storage
validator Validator validator Validator
email Emailer email *Emailer
info, debug, err *log.Logger info, debug, err *log.Logger
host string host string
port int port int
@ -361,8 +361,6 @@ func start(asDaemon, firstCall bool) {
} }
app.validator.init(validatorConf) app.validator.init(validatorConf)
app.email.init(app)
inviteDaemon := NewRepeater(time.Duration(60*time.Second), app) inviteDaemon := NewRepeater(time.Duration(60*time.Second), app)
go inviteDaemon.Run() go inviteDaemon.Run()
@ -476,7 +474,7 @@ func main() {
folder = os.Getenv("TEMP") folder = os.Getenv("TEMP")
} }
SOCK = filepath.Join(folder, SOCK) SOCK = filepath.Join(folder, SOCK)
fmt.Println(SOCK) fmt.Println("Socket:", SOCK)
if flagPassed("start") { if flagPassed("start") {
args := []string{} args := []string{}
for i, f := range os.Args { for i, f := range os.Args {

View File

@ -71,10 +71,13 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
app.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username) app.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username)
return return
} }
if app.email.constructReset(pwr, app) != nil { msg, err := app.email.constructReset(pwr, app)
if err != nil {
app.err.Printf("Failed to construct password reset email for %s", pwr.Username) app.err.Printf("Failed to construct password reset email for %s", pwr.Username)
} else if app.email.send(address, app) != nil { app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else if err := app.email.send(address, msg); err != nil {
app.err.Printf("Failed to send password reset email to \"%s\"", address) app.err.Printf("Failed to send password reset email to \"%s\"", address)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else { } else {
app.info.Printf("Sent password reset email to \"%s\"", address) app.info.Printf("Sent password reset email to \"%s\"", address)
} }