mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 17:10:10 +00:00
add announcement emails
After selecting users in the accounts tab, you can press 'Announce', then write a subject and message (with markdown), and an email will be sent to each selected user.
This commit is contained in:
parent
adbb5b9d38
commit
fa433c88a8
56
api.go
56
api.go
@ -130,7 +130,7 @@ func (app *appContext) checkInvites() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
app.err.Printf("%s: Failed to construct expiry notification", code)
|
app.err.Printf("%s: Failed to construct expiry notification", code)
|
||||||
app.debug.Printf("Error: %s", err)
|
app.debug.Printf("Error: %s", err)
|
||||||
} else if err := app.email.send(addr, msg); err != nil {
|
} else if err := app.email.send(msg, addr); 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)
|
app.debug.Printf("Error: %s", err)
|
||||||
} else {
|
} else {
|
||||||
@ -169,7 +169,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
app.err.Printf("%s: Failed to construct expiry notification", code)
|
app.err.Printf("%s: Failed to construct expiry notification", code)
|
||||||
app.debug.Printf("Error: %s", err)
|
app.debug.Printf("Error: %s", err)
|
||||||
} else if err := app.email.send(address, msg); err != nil {
|
} else if err := app.email.send(msg, address); 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)
|
app.debug.Printf("Error: %s", err)
|
||||||
} else {
|
} else {
|
||||||
@ -308,7 +308,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
|||||||
app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err)
|
app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err)
|
||||||
respondUser(500, true, false, err.Error(), gc)
|
respondUser(500, true, false, err.Error(), gc)
|
||||||
return
|
return
|
||||||
} else if err := app.email.send(req.Email, msg); err != nil {
|
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||||
app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err)
|
app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err)
|
||||||
respondUser(500, true, false, err.Error(), gc)
|
respondUser(500, true, false, err.Error(), gc)
|
||||||
return
|
return
|
||||||
@ -363,7 +363,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
app.err.Printf("%s: Failed to construct confirmation email", req.Code)
|
app.err.Printf("%s: Failed to construct confirmation email", req.Code)
|
||||||
app.debug.Printf("%s: Error: %s", req.Code, err)
|
app.debug.Printf("%s: Error: %s", req.Code, err)
|
||||||
} else if err := app.email.send(req.Email, msg); err != nil {
|
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||||
app.err.Printf("%s: Failed to send user confirmation email: %s", req.Code, err)
|
app.err.Printf("%s: Failed to send user confirmation email: %s", req.Code, err)
|
||||||
} else {
|
} else {
|
||||||
app.info.Printf("%s: Sent user confirmation email to %s", req.Code, req.Email)
|
app.info.Printf("%s: Sent user confirmation email to %s", req.Code, req.Email)
|
||||||
@ -393,7 +393,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
|||||||
if err != nil {
|
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 err := app.email.send(address, msg); err != nil {
|
} else if err := app.email.send(msg, address); 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 {
|
||||||
@ -455,7 +455,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
|||||||
msg, err := app.email.constructWelcome(req.Username, app)
|
msg, err := app.email.constructWelcome(req.Username, app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err)
|
app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err)
|
||||||
} else if err := app.email.send(req.Email, msg); err != nil {
|
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||||
app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err)
|
app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err)
|
||||||
} else {
|
} else {
|
||||||
app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email)
|
app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email)
|
||||||
@ -508,6 +508,46 @@ func (app *appContext) NewUser(gc *gin.Context) {
|
|||||||
gc.JSON(code, validation)
|
gc.JSON(code, validation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Summary Send an announcement via email to a given list of users.
|
||||||
|
// @Produce json
|
||||||
|
// @Param announcementDTO body announcementDTO true "Announcement request object"
|
||||||
|
// @Success 200 {object} boolResponse
|
||||||
|
// @Failure 400 {object} boolResponse
|
||||||
|
// @Failure 500 {object} boolResponse
|
||||||
|
// @Router /users/announce [post]
|
||||||
|
// @Security Bearer
|
||||||
|
// @tags Users
|
||||||
|
func (app *appContext) Announce(gc *gin.Context) {
|
||||||
|
var req announcementDTO
|
||||||
|
gc.BindJSON(&req)
|
||||||
|
if !emailEnabled {
|
||||||
|
respondBool(400, false, gc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addresses := []string{}
|
||||||
|
for _, userID := range req.Users {
|
||||||
|
addr, ok := app.storage.emails[userID]
|
||||||
|
if !ok || addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addresses = append(addresses, addr.(string))
|
||||||
|
}
|
||||||
|
msg, err := app.email.constructAnnouncement(req.Subject, req.Message, app)
|
||||||
|
if err != nil {
|
||||||
|
app.err.Println("Failed to construct announcement email")
|
||||||
|
app.debug.Printf("Error: %s", err)
|
||||||
|
respondBool(500, false, gc)
|
||||||
|
return
|
||||||
|
} else if err := app.email.send(msg, addresses...); err != nil {
|
||||||
|
app.err.Println("Failed to send announcement email")
|
||||||
|
app.debug.Printf("Error: %s", err)
|
||||||
|
respondBool(500, false, gc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.info.Println("Sent announcement email")
|
||||||
|
respondBool(200, true, gc)
|
||||||
|
}
|
||||||
|
|
||||||
// @Summary Delete a list of users, optionally notifying them why.
|
// @Summary Delete a list of users, optionally notifying them why.
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param deleteUserDTO body deleteUserDTO true "User deletion request object"
|
// @Param deleteUserDTO body deleteUserDTO true "User deletion request object"
|
||||||
@ -552,7 +592,7 @@ func (app *appContext) DeleteUser(gc *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
app.err.Printf("%s: Failed to construct account deletion email", userID)
|
app.err.Printf("%s: Failed to construct account deletion email", userID)
|
||||||
app.debug.Printf("%s: Error: %s", userID, err)
|
app.debug.Printf("%s: Error: %s", userID, err)
|
||||||
} else if err := app.email.send(address, msg); err != nil {
|
} else if err := app.email.send(msg, address); err != nil {
|
||||||
app.err.Printf("%s: Failed to send to %s", userID, address)
|
app.err.Printf("%s: Failed to send to %s", userID, address)
|
||||||
app.debug.Printf("%s: Error: %s", userID, err)
|
app.debug.Printf("%s: Error: %s", userID, err)
|
||||||
} else {
|
} else {
|
||||||
@ -619,7 +659,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
|||||||
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", inviteCode)
|
app.err.Printf("%s: Failed to construct invite email", inviteCode)
|
||||||
app.debug.Printf("%s: Error: %s", inviteCode, err)
|
app.debug.Printf("%s: Error: %s", inviteCode, err)
|
||||||
} else if err := app.email.send(req.Email, msg); err != nil {
|
} else if err := app.email.send(msg, req.Email); 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", inviteCode, invite.Email)
|
app.err.Printf("%s: %s", inviteCode, invite.Email)
|
||||||
app.debug.Printf("%s: Error: %s", inviteCode, err)
|
app.debug.Printf("%s: Error: %s", inviteCode, err)
|
||||||
|
@ -63,6 +63,9 @@ func (app *appContext) loadConfig() error {
|
|||||||
app.config.Section("welcome_email").Key("email_html").SetValue(app.config.Section("welcome_email").Key("email_html").MustString("jfa-go:" + "welcome.html"))
|
app.config.Section("welcome_email").Key("email_html").SetValue(app.config.Section("welcome_email").Key("email_html").MustString("jfa-go:" + "welcome.html"))
|
||||||
app.config.Section("welcome_email").Key("email_text").SetValue(app.config.Section("welcome_email").Key("email_text").MustString("jfa-go:" + "welcome.txt"))
|
app.config.Section("welcome_email").Key("email_text").SetValue(app.config.Section("welcome_email").Key("email_text").MustString("jfa-go:" + "welcome.txt"))
|
||||||
|
|
||||||
|
app.config.Section("announcement_email").Key("email_html").SetValue(app.config.Section("announcement_email").Key("email_html").MustString("jfa-go:" + "announcement.html"))
|
||||||
|
app.config.Section("announcement_email").Key("email_text").SetValue(app.config.Section("announcement_email").Key("email_text").MustString("jfa-go:" + "announcement.txt"))
|
||||||
|
|
||||||
app.config.Section("jellyfin").Key("version").SetValue(VERSION)
|
app.config.Section("jellyfin").Key("version").SetValue(VERSION)
|
||||||
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||||
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", VERSION, COMMIT))
|
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", VERSION, COMMIT))
|
||||||
|
@ -164,6 +164,7 @@ div.card:contains(section.banner.footer) {
|
|||||||
|
|
||||||
.monospace {
|
.monospace {
|
||||||
background-color: inherit; /* so we can use a17t code blocks */
|
background-color: inherit; /* so we can use a17t code blocks */
|
||||||
|
font-family: Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
sup.\~critical, .text-critical {
|
sup.\~critical, .text-critical {
|
||||||
|
56
email.go
56
email.go
@ -8,8 +8,11 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gomarkdown/markdown"
|
||||||
|
"github.com/gomarkdown/markdown/html"
|
||||||
jEmail "github.com/jordan-wright/email"
|
jEmail "github.com/jordan-wright/email"
|
||||||
"github.com/knz/strtime"
|
"github.com/knz/strtime"
|
||||||
"github.com/mailgun/mailgun-go/v4"
|
"github.com/mailgun/mailgun-go/v4"
|
||||||
@ -17,7 +20,7 @@ import (
|
|||||||
|
|
||||||
// implements email sending, right now via smtp or mailgun.
|
// implements email sending, right now via smtp or mailgun.
|
||||||
type emailClient interface {
|
type emailClient interface {
|
||||||
send(address, fromName, fromAddr string, email *Email) error
|
send(fromName, fromAddr string, email *Email, address ...string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mailgun client implements emailClient.
|
// Mailgun client implements emailClient.
|
||||||
@ -25,13 +28,16 @@ type Mailgun struct {
|
|||||||
client *mailgun.MailgunImpl
|
client *mailgun.MailgunImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mg *Mailgun) send(address, fromName, fromAddr string, email *Email) error {
|
func (mg *Mailgun) send(fromName, fromAddr string, email *Email, address ...string) error {
|
||||||
message := mg.client.NewMessage(
|
message := mg.client.NewMessage(
|
||||||
fmt.Sprintf("%s <%s>", fromName, fromAddr),
|
fmt.Sprintf("%s <%s>", fromName, fromAddr),
|
||||||
email.subject,
|
email.subject,
|
||||||
email.text,
|
email.text,
|
||||||
address,
|
|
||||||
)
|
)
|
||||||
|
for _, a := range address {
|
||||||
|
// Adding variable tells mailgun to do a batch send, so users don't see other recipients.
|
||||||
|
message.AddRecipientAndVariables(a, map[string]interface{}{"unique_id": a})
|
||||||
|
}
|
||||||
message.SetHtml(email.html)
|
message.SetHtml(email.html)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@ -47,25 +53,33 @@ type SMTP struct {
|
|||||||
auth smtp.Auth
|
auth smtp.Auth
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SMTP) send(address, fromName, fromAddr string, email *Email) error {
|
func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string) 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)
|
server := fmt.Sprintf("%s:%d", sm.server, sm.port)
|
||||||
tlsConfig := &tls.Config{
|
tlsConfig := &tls.Config{
|
||||||
InsecureSkipVerify: false,
|
InsecureSkipVerify: false,
|
||||||
ServerName: sm.server,
|
ServerName: sm.server,
|
||||||
}
|
}
|
||||||
|
from := fmt.Sprintf("%s <%s>", fromName, fromAddr)
|
||||||
|
var wg sync.WaitGroup
|
||||||
var err error
|
var err error
|
||||||
// err = e.Send(server, sm.auth)
|
for _, addr := range address {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(addr string) {
|
||||||
|
defer wg.Done()
|
||||||
|
e := jEmail.NewEmail()
|
||||||
|
e.Subject = email.subject
|
||||||
|
e.From = from
|
||||||
|
e.Text = []byte(email.text)
|
||||||
|
e.HTML = []byte(email.html)
|
||||||
|
e.To = []string{addr}
|
||||||
if sm.sslTLS {
|
if sm.sslTLS {
|
||||||
err = e.SendWithTLS(server, sm.auth, tlsConfig)
|
err = e.SendWithTLS(server, sm.auth, tlsConfig)
|
||||||
} else {
|
} else {
|
||||||
err = e.SendWithStartTLS(server, sm.auth, tlsConfig)
|
err = e.SendWithStartTLS(server, sm.auth, tlsConfig)
|
||||||
}
|
}
|
||||||
|
}(addr)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,6 +211,22 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
|
|||||||
return email, nil
|
return email, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (emailer *Emailer) constructAnnouncement(subject, md string, app *appContext) (*Email, error) {
|
||||||
|
email := &Email{subject: subject}
|
||||||
|
renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
|
||||||
|
html := markdown.ToHTML([]byte(md), nil, renderer)
|
||||||
|
message := app.config.Section("email").Key("message").String()
|
||||||
|
var err error
|
||||||
|
email.html, email.text, err = emailer.construct(app, "announcement_email", "email_", map[string]interface{}{
|
||||||
|
"text": template.HTML(html),
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
|
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
|
||||||
email := &Email{
|
email := &Email{
|
||||||
subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
|
subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
|
||||||
@ -327,6 +357,6 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Ema
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calls the send method in the underlying emailClient.
|
// calls the send method in the underlying emailClient.
|
||||||
func (emailer *Emailer) send(address string, email *Email) error {
|
func (emailer *Emailer) send(email *Email, address ...string) error {
|
||||||
return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email)
|
return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...)
|
||||||
}
|
}
|
||||||
|
1
go.mod
1
go.mod
@ -20,6 +20,7 @@ require (
|
|||||||
github.com/go-openapi/spec v0.20.3 // indirect
|
github.com/go-openapi/spec v0.20.3 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.4.1 // indirect
|
github.com/go-playground/validator/v10 v10.4.1 // indirect
|
||||||
github.com/golang/protobuf v1.4.3 // indirect
|
github.com/golang/protobuf v1.4.3 // indirect
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 // indirect
|
||||||
github.com/google/uuid v1.1.2 // indirect
|
github.com/google/uuid v1.1.2 // indirect
|
||||||
github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
|
github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
|
||||||
github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71
|
github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71
|
||||||
|
4
go.sum
4
go.sum
@ -109,6 +109,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
|||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
|
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 h1:nWU6p08f1VgIalT6iZyqXi4o5cZsz4X6qa87nusfcsc=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
@ -226,6 +228,8 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
|||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||||
github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
|
github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead h1:jeP6FgaSLNTMP+Yri3qjlACywQLye+huGLmNGhBzm6k=
|
||||||
|
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
@ -93,6 +93,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modal-announce" class="modal">
|
||||||
|
<form class="modal-content card" id="form-announce" href="">
|
||||||
|
<span class="heading"><span id="header-announce"></span> <span class="modal-close">×</span></span>
|
||||||
|
<div class="content mt-half">
|
||||||
|
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
|
||||||
|
<input type="text" id="announce-subject" class="input ~neutral !normal mb-1 mt-half">
|
||||||
|
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
|
||||||
|
<textarea id="textarea-announce" class="textarea full-width ~neutral !normal mt-half monospace"></textarea>
|
||||||
|
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
|
||||||
|
<label>
|
||||||
|
<input type="submit" class="unfocused">
|
||||||
|
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div id="modal-restart" class="modal">
|
<div id="modal-restart" class="modal">
|
||||||
<div class="modal-content card ~critical !low">
|
<div class="modal-content card ~critical !low">
|
||||||
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">×</span></span>
|
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">×</span></span>
|
||||||
@ -256,6 +272,7 @@
|
|||||||
<span class="heading">{{ .strings.accounts }}</span>
|
<span class="heading">{{ .strings.accounts }}</span>
|
||||||
<div class="fr">
|
<div class="fr">
|
||||||
<span class="button ~neutral !normal" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
<span class="button ~neutral !normal" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||||
|
<span class="button ~info !normal" id="accounts-announce">{{ .strings.announce }}</span>
|
||||||
<span class="button ~urge !normal" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
<span class="button ~urge !normal" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
||||||
<span class="button ~critical !normal" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
<span class="button ~critical !normal" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,6 +30,10 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
|
"announce": "Announce",
|
||||||
|
"subject": "Email Subject",
|
||||||
|
"message": "Message",
|
||||||
|
"markdownSupported": "Markdown is supported.",
|
||||||
"modifySettings": "Modify Settings",
|
"modifySettings": "Modify Settings",
|
||||||
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
|
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
|
||||||
"applyHomescreenLayout": "Apply homescreen layout",
|
"applyHomescreenLayout": "Apply homescreen layout",
|
||||||
@ -72,6 +76,7 @@
|
|||||||
"userCreated": "User {n} created.",
|
"userCreated": "User {n} created.",
|
||||||
"createProfile": "Created profile {n}.",
|
"createProfile": "Created profile {n}.",
|
||||||
"saveSettings": "Settings were saved",
|
"saveSettings": "Settings were saved",
|
||||||
|
"sentAnnouncement": "Announcement sent.",
|
||||||
"setOmbiDefaults": "Stored ombi defaults.",
|
"setOmbiDefaults": "Stored ombi defaults.",
|
||||||
"errorConnection": "Couldn't connect to jfa-go.",
|
"errorConnection": "Couldn't connect to jfa-go.",
|
||||||
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
|
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
|
||||||
@ -117,6 +122,10 @@
|
|||||||
"singular": "Deleted {n} user.",
|
"singular": "Deleted {n} user.",
|
||||||
"plural": "Deleted {n} users."
|
"plural": "Deleted {n} users."
|
||||||
},
|
},
|
||||||
|
"announceTo": {
|
||||||
|
"singular": "Announce to {n} user",
|
||||||
|
"plural": "Announce to {n} users"
|
||||||
|
},
|
||||||
"appliedSettings": {
|
"appliedSettings": {
|
||||||
"singular": "Applied settings to {n} user.",
|
"singular": "Applied settings to {n} user.",
|
||||||
"plural": "Applied settings to {n} users."
|
"plural": "Applied settings to {n} users."
|
||||||
|
35
mail/announcement.mjml
Normal file
35
mail/announcement.mjml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<mjml>
|
||||||
|
<mj-head>
|
||||||
|
<mj-attributes>
|
||||||
|
<mj-class name="bg" background-color="#101010" />
|
||||||
|
<mj-class name="bg2" background-color="#242424" />
|
||||||
|
<mj-class name="text" color="rgba(255,255,255,0.8)" />
|
||||||
|
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||||
|
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||||
|
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||||
|
</mj-attributes>
|
||||||
|
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||||
|
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||||
|
</mj-head>
|
||||||
|
<mj-body>
|
||||||
|
<mj-section mj-class="bg2">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-section mj-class="bg">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||||
|
<mj-raw>{{ .text }}</mj-raw>
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-section mj-class="bg2">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||||
|
{{ .message }}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</body>
|
||||||
|
</mjml>
|
3
mail/announcement.txt
Normal file
3
mail/announcement.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ .text }}
|
||||||
|
|
||||||
|
{{ .message }}
|
@ -131,6 +131,12 @@ type userSettingsDTO struct {
|
|||||||
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
|
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type announcementDTO struct {
|
||||||
|
Users []string `json:"users"` // List of User IDs to send announcement to
|
||||||
|
Subject string `json:"subject"` // Email subject
|
||||||
|
Message string `json:"message"` // Email content (markdown supported)
|
||||||
|
}
|
||||||
|
|
||||||
type errorListDTO map[string]map[string]string
|
type errorListDTO map[string]map[string]string
|
||||||
|
|
||||||
type configDTO map[string]interface{}
|
type configDTO map[string]interface{}
|
||||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -228,9 +228,9 @@
|
|||||||
"integrity": "sha1-vfpzUplmTfr9NFKe1PhSKidf6lY="
|
"integrity": "sha1-vfpzUplmTfr9NFKe1PhSKidf6lY="
|
||||||
},
|
},
|
||||||
"esbuild": {
|
"esbuild": {
|
||||||
"version": "0.8.47",
|
"version": "0.8.48",
|
||||||
"resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.47.tgz",
|
"resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.48.tgz",
|
||||||
"integrity": "sha1-XVxZt9y4og3632WpheXlynsk7/I="
|
"integrity": "sha1-pX5N3oTsVtocbsrv7pfp2mxbALU="
|
||||||
},
|
},
|
||||||
"escalade": {
|
"escalade": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
"homepage": "https://github.com/hrfee/jfa-go#readme",
|
"homepage": "https://github.com/hrfee/jfa-go#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"a17t": "^0.4.0",
|
"a17t": "^0.4.0",
|
||||||
"esbuild": "^0.8.47",
|
"esbuild": "^0.8.48",
|
||||||
"lodash": "^4.17.19",
|
"lodash": "^4.17.19",
|
||||||
"mjml": "^4.8.0",
|
"mjml": "^4.8.0",
|
||||||
"remixicon": "^2.5.0",
|
"remixicon": "^2.5.0",
|
||||||
|
@ -86,7 +86,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
|
|||||||
if err != nil {
|
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)
|
||||||
app.debug.Printf("%s: Error: %s", pwr.Username, err)
|
app.debug.Printf("%s: Error: %s", pwr.Username, err)
|
||||||
} else if err := app.email.send(address, msg); err != nil {
|
} else if err := app.email.send(msg, address); 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)
|
app.debug.Printf("%s: Error: %s", pwr.Username, err)
|
||||||
} else {
|
} else {
|
||||||
|
@ -138,6 +138,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
|||||||
api.POST(p+"/users/emails", app.ModifyEmails)
|
api.POST(p+"/users/emails", app.ModifyEmails)
|
||||||
// api.POST(p + "/setDefaults", app.SetDefaults)
|
// api.POST(p + "/setDefaults", app.SetDefaults)
|
||||||
api.POST(p+"/users/settings", app.ApplySettings)
|
api.POST(p+"/users/settings", app.ApplySettings)
|
||||||
|
api.POST(p+"/users/announce", app.Announce)
|
||||||
api.GET(p+"/config", app.GetConfig)
|
api.GET(p+"/config", app.GetConfig)
|
||||||
api.POST(p+"/config", app.ModifyConfig)
|
api.POST(p+"/config", app.ModifyConfig)
|
||||||
api.POST(p+"/restart", app.restart)
|
api.POST(p+"/restart", app.restart)
|
||||||
|
@ -51,6 +51,8 @@ window.availableProfiles = window.availableProfiles || [];
|
|||||||
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
|
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
|
||||||
|
|
||||||
window.modals.addProfile = new Modal(document.getElementById("modal-add-profile"));
|
window.modals.addProfile = new Modal(document.getElementById("modal-add-profile"));
|
||||||
|
|
||||||
|
window.modals.announce = new Modal(document.getElementById("modal-announce"));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
var inviteCreator = new createInvite();
|
var inviteCreator = new createInvite();
|
||||||
|
@ -148,6 +148,7 @@ export class accountsList {
|
|||||||
private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
|
private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
|
||||||
|
|
||||||
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
|
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
|
||||||
|
private _announceButton = document.getElementById("accounts-announce") as HTMLSpanElement;
|
||||||
private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement;
|
private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement;
|
||||||
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
|
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
|
||||||
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
|
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
|
||||||
@ -189,6 +190,9 @@ export class accountsList {
|
|||||||
this._selectAll.checked = false;
|
this._selectAll.checked = false;
|
||||||
this._modifySettings.classList.add("unfocused");
|
this._modifySettings.classList.add("unfocused");
|
||||||
this._deleteUser.classList.add("unfocused");
|
this._deleteUser.classList.add("unfocused");
|
||||||
|
if (window.emailEnabled) {
|
||||||
|
this._announceButton.classList.add("unfocused");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this._checkCount == Object.keys(this._users).length) {
|
if (this._checkCount == Object.keys(this._users).length) {
|
||||||
this._selectAll.checked = true;
|
this._selectAll.checked = true;
|
||||||
@ -200,6 +204,9 @@ export class accountsList {
|
|||||||
this._modifySettings.classList.remove("unfocused");
|
this._modifySettings.classList.remove("unfocused");
|
||||||
this._deleteUser.classList.remove("unfocused");
|
this._deleteUser.classList.remove("unfocused");
|
||||||
this._deleteUser.textContent = window.lang.quantity("deleteUser", this._checkCount);
|
this._deleteUser.textContent = window.lang.quantity("deleteUser", this._checkCount);
|
||||||
|
if (window.emailEnabled) {
|
||||||
|
this._announceButton.classList.remove("unfocused");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,6 +255,39 @@ export class accountsList {
|
|||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
announce = () => {
|
||||||
|
const modalHeader = document.getElementById("header-announce");
|
||||||
|
modalHeader.textContent = window.lang.quantity("announceTo", this._checkCount);
|
||||||
|
const form = document.getElementById("form-announce") as HTMLFormElement;
|
||||||
|
let list = this._collectUsers();
|
||||||
|
const button = form.querySelector("span.submit") as HTMLSpanElement;
|
||||||
|
const subject = document.getElementById("announce-subject") as HTMLInputElement;
|
||||||
|
const message = document.getElementById("textarea-announce") as HTMLTextAreaElement;
|
||||||
|
subject.value = "";
|
||||||
|
message.value = "";
|
||||||
|
form.onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleLoader(button);
|
||||||
|
let send = {
|
||||||
|
"users": list,
|
||||||
|
"subject": subject.value,
|
||||||
|
"message": message.value
|
||||||
|
}
|
||||||
|
_post("/users/announce", send, (req: XMLHttpRequest) => {
|
||||||
|
if (req.readyState == 4) {
|
||||||
|
toggleLoader(button);
|
||||||
|
window.modals.announce.close();
|
||||||
|
if (req.status != 200 && req.status != 204) {
|
||||||
|
window.notifications.customError("announcementError", window.lang.notif("errorFailureCheckLogs"));
|
||||||
|
} else {
|
||||||
|
window.notifications.customSuccess("announcementSuccess", window.lang.notif("sentAnnouncement"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.modals.announce.show();
|
||||||
|
}
|
||||||
|
|
||||||
deleteUsers = () => {
|
deleteUsers = () => {
|
||||||
const modalHeader = document.getElementById("header-delete-user");
|
const modalHeader = document.getElementById("header-delete-user");
|
||||||
modalHeader.textContent = window.lang.quantity("deleteNUsers", this._checkCount);
|
modalHeader.textContent = window.lang.quantity("deleteNUsers", this._checkCount);
|
||||||
@ -397,6 +437,9 @@ export class accountsList {
|
|||||||
this._deleteUser.onclick = this.deleteUsers;
|
this._deleteUser.onclick = this.deleteUsers;
|
||||||
this._deleteUser.classList.add("unfocused");
|
this._deleteUser.classList.add("unfocused");
|
||||||
|
|
||||||
|
this._announceButton.onclick = this.announce;
|
||||||
|
this._announceButton.classList.add("unfocused");
|
||||||
|
|
||||||
if (!window.usernameEnabled) {
|
if (!window.usernameEnabled) {
|
||||||
this._addUserName.classList.add("unfocused");
|
this._addUserName.classList.add("unfocused");
|
||||||
this._addUserName = this._addUserEmail;
|
this._addUserName = this._addUserEmail;
|
||||||
|
@ -74,6 +74,7 @@ declare interface Modals {
|
|||||||
ombiDefaults?: Modal;
|
ombiDefaults?: Modal;
|
||||||
profiles: Modal;
|
profiles: Modal;
|
||||||
addProfile: Modal;
|
addProfile: Modal;
|
||||||
|
announce: Modal;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Invite {
|
interface Invite {
|
||||||
|
Loading…
Reference in New Issue
Block a user