jfa-go/email.go

397 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"html/template"
"net/smtp"
"strings"
"time"
jEmail "github.com/jordan-wright/email"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
)
// implements email sending, right now via smtp or mailgun.
type emailClient interface {
send(address, fromName, fromAddr string, email *Email) error
}
// Mailgun client implements emailClient.
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
}
// SMTP supports SSL/TLS and STARTTLS; implements emailClient.
type SMTP struct {
sslTLS bool
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)
server := fmt.Sprintf("%s:%d", sm.server, sm.port)
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: sm.server,
}
var err error
// err = e.Send(server, sm.auth)
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
lang emailLang
sender emailClient
}
// Email stores content.
type Email struct {
subject string
html, text string
}
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
d, _ = strtime.Strftime(expiry, datePattern)
t, _ = strtime.Strftime(expiry, timePattern)
currentTime := time.Now()
if tzaware {
currentTime = currentTime.UTC()
}
_, _, days, hours, minutes, _ := timeDiff(expiry, currentTime)
if days != 0 {
expiresIn += fmt.Sprintf("%dd ", days)
}
if hours != 0 {
expiresIn += fmt.Sprintf("%dh ", hours)
}
if minutes != 0 {
expiresIn += fmt.Sprintf("%dm ", minutes)
}
expiresIn = strings.TrimSuffix(expiresIn, " ")
return
}
// NewEmailer configures and returns a new emailer.
func NewEmailer(app *appContext) *Emailer {
emailer := &Emailer{
fromAddr: app.config.Section("email").Key("address").String(),
fromName: app.config.Section("email").Key("from").String(),
lang: app.storage.lang.Email[app.storage.lang.chosenEmailLang],
}
method := app.config.Section("email").Key("method").String()
if method == "smtp" {
sslTls := false
if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
sslTls = true
}
username := ""
if u := app.config.Section("smtp").Key("username").MustString(""); u != "" {
username = u
} else {
username = emailer.fromAddr
}
emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, app.config.Section("smtp").Key("password").String(), sslTls)
} else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
}
return emailer
}
// NewMailgun returns a Mailgun emailClient.
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
}
// NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool) {
emailer.sender = &SMTP{
auth: smtp.PlainAuth("", username, password, server),
server: server,
port: port,
sslTLS: sslTLS,
}
}
func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
}
message := app.config.Section("email").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key)
for _, key := range []string{"html", "text"} {
filesystem, fpath := app.GetPath("email_confirmation", "email_"+key)
tpl, err := template.ParseFS(filesystem, fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"helloUser": emailer.lang.Strings.format("helloUser", username),
"clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"urlVal": inviteLink,
"confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"),
"message": message,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
}
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
inviteLink = fmt.Sprintf("%s/%s", inviteLink, code)
for _, key := range []string{"html", "text"} {
filesystem, fpath := app.GetPath("invite_emails", "email_"+key)
tpl, err := template.ParseFS(filesystem, fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
"toJoin": emailer.lang.InviteEmail.get("toJoin"),
"inviteExpiry": emailer.lang.InviteEmail.format("inviteExpiry", d, t, expiresIn),
"linkButton": emailer.lang.InviteEmail.get("linkButton"),
"invite_link": inviteLink,
"message": message,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext) (*Email, error) {
email := &Email{
subject: emailer.lang.InviteExpiry.get("title"),
}
expiry := app.formatDatetime(invite.ValidTill)
for _, key := range []string{"html", "text"} {
filesystem, fpath := app.GetPath("notifications", "expiry_"+key)
tpl, err := template.ParseFS(filesystem, fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
"expiredAt": emailer.lang.InviteExpiry.format("expiredAt", "\""+code+"\"", expiry),
"notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"),
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) (*Email, error) {
email := &Email{
subject: emailer.lang.UserCreated.get("title"),
}
created := app.formatDatetime(invite.Created)
var tplAddress string
if app.config.Section("email").Key("no_username").MustBool(false) {
tplAddress = "n/a"
} else {
tplAddress = address
}
for _, key := range []string{"html", "text"} {
filesystem, fpath := app.GetPath("notifications", "created_"+key)
tpl, err := template.ParseFS(filesystem, fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"aUserWasCreated": emailer.lang.UserCreated.format("aUserWasCreated", "\""+code+"\""),
"name": emailer.lang.Strings.get("name"),
"address": emailer.lang.Strings.get("emailAddress"),
"time": emailer.lang.UserCreated.get("time"),
"nameVal": username,
"addressVal": tplAddress,
"timeVal": created,
"notificationNotice": emailer.lang.UserCreated.get("notificationNotice"),
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
}
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
for _, key := range []string{"html", "text"} {
filesystem, fpath := app.GetPath("password_resets", "email_"+key)
tpl, err := template.ParseFS(filesystem, fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"helloUser": emailer.lang.Strings.format("helloUser", pwr.Username),
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
"codeExpiry": emailer.lang.PasswordReset.format("codeExpiry", d, t, expiresIn),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"pin": emailer.lang.PasswordReset.get("pin"),
"pinVal": pwr.Pin,
"message": message,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
}
for _, key := range []string{"html", "text"} {
filesystem, fpath := app.GetPath("deletion", "email_"+key)
tpl, err := template.ParseFS(filesystem, fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"yourAccountWasDeleted": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
"reason": emailer.lang.UserDeleted.get("reason"),
"reasonVal": reason,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
}
for _, key := range []string{"html", "text"} {
filesystem, fpath := app.GetPath("welcome_email", "email_"+key)
tpl, err := template.ParseFS(filesystem, fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURL": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"jellyfinURLVal": app.config.Section("jellyfin").Key("public_server").String(),
"username": emailer.lang.Strings.get("username"),
"usernameVal": username,
"message": app.config.Section("email").Key("message").String(),
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
// calls the send method in the underlying emailClient.
func (emailer *Emailer) send(address string, email *Email) error {
return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email)
}