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.
pull/61/head
Harvey Tindall 4 years ago
parent adbb5b9d38
commit fa433c88a8
No known key found for this signature in database
GPG Key ID: BBC65952848FB1A2

@ -130,7 +130,7 @@ func (app *appContext) checkInvites() {
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification", code)
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.debug.Printf("Error: %s", err)
} else {
@ -169,7 +169,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification", code)
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.debug.Printf("Error: %s", err)
} 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)
respondUser(500, true, false, err.Error(), gc)
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)
respondUser(500, true, false, err.Error(), gc)
return
@ -363,7 +363,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
if err != nil {
app.err.Printf("%s: Failed to construct confirmation email", req.Code)
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)
} else {
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 {
app.err.Printf("%s: Failed to construct user creation notification", req.Code)
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.debug.Printf("%s: Error: %s", req.Code, err)
} else {
@ -455,7 +455,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
msg, err := app.email.constructWelcome(req.Username, app)
if err != nil {
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)
} else {
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)
}
// @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.
// @Produce json
// @Param deleteUserDTO body deleteUserDTO true "User deletion request object"
@ -552,7 +592,7 @@ func (app *appContext) DeleteUser(gc *gin.Context) {
if err != nil {
app.err.Printf("%s: Failed to construct account deletion email", userID)
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.debug.Printf("%s: Error: %s", userID, err)
} else {
@ -619,7 +659,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
app.err.Printf("%s: Failed to construct invite email", inviteCode)
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)
app.err.Printf("%s: %s", inviteCode, invite.Email)
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_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("device").SetValue("jfa-go")
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 {
background-color: inherit; /* so we can use a17t code blocks */
font-family: Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;
}
sup.\~critical, .text-critical {

@ -8,8 +8,11 @@ import (
"html/template"
"net/smtp"
"strings"
"sync"
"time"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
jEmail "github.com/jordan-wright/email"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
@ -17,7 +20,7 @@ import (
// implements email sending, right now via smtp or mailgun.
type emailClient interface {
send(address, fromName, fromAddr string, email *Email) error
send(fromName, fromAddr string, email *Email, address ...string) error
}
// Mailgun client implements emailClient.
@ -25,13 +28,16 @@ type Mailgun struct {
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(
fmt.Sprintf("%s <%s>", fromName, fromAddr),
email.subject,
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)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
@ -47,25 +53,33 @@ type SMTP struct {
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)
func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string) error {
server := fmt.Sprintf("%s:%d", sm.server, sm.port)
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: sm.server,
}
from := fmt.Sprintf("%s <%s>", fromName, fromAddr)
var wg sync.WaitGroup
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)
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 {
err = e.SendWithTLS(server, sm.auth, tlsConfig)
} else {
err = e.SendWithStartTLS(server, sm.auth, tlsConfig)
}
}(addr)
}
wg.Wait()
return err
}
@ -197,6 +211,22 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
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) {
email := &Email{
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.
func (emailer *Emailer) send(address string, email *Email) error {
return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email)
func (emailer *Emailer) send(email *Email, address ...string) error {
return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...)
}

@ -20,6 +20,7 @@ require (
github.com/go-openapi/spec v0.20.3 // indirect
github.com/go-playground/validator/v10 v10.4.1 // 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/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71

@ -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.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
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.3.0/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/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
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-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

@ -93,6 +93,22 @@
</div>
</form>
</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">&times;</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 class="modal-content card ~critical !low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
@ -256,6 +272,7 @@
<span class="heading">{{ .strings.accounts }}</span>
<div class="fr">
<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 ~critical !normal" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>

@ -30,6 +30,10 @@
"profile": "Profile",
"unknown": "Unknown",
"label": "Label",
"announce": "Announce",
"subject": "Email Subject",
"message": "Message",
"markdownSupported": "Markdown is supported.",
"modifySettings": "Modify Settings",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
"applyHomescreenLayout": "Apply homescreen layout",
@ -72,6 +76,7 @@
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
"saveSettings": "Settings were saved",
"sentAnnouncement": "Announcement sent.",
"setOmbiDefaults": "Stored ombi defaults.",
"errorConnection": "Couldn't connect to jfa-go.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
@ -117,6 +122,10 @@
"singular": "Deleted {n} user.",
"plural": "Deleted {n} users."
},
"announceTo": {
"singular": "Announce to {n} user",
"plural": "Announce to {n} users"
},
"appliedSettings": {
"singular": "Applied settings to {n} user.",
"plural": "Applied settings to {n} users."

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

@ -0,0 +1,3 @@
{{ .text }}
{{ .message }}

@ -131,6 +131,12 @@ type userSettingsDTO struct {
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 configDTO map[string]interface{}

6
package-lock.json generated

@ -228,9 +228,9 @@
"integrity": "sha1-vfpzUplmTfr9NFKe1PhSKidf6lY="
},
"esbuild": {
"version": "0.8.47",
"resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.47.tgz",
"integrity": "sha1-XVxZt9y4og3632WpheXlynsk7/I="
"version": "0.8.48",
"resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.48.tgz",
"integrity": "sha1-pX5N3oTsVtocbsrv7pfp2mxbALU="
},
"escalade": {
"version": "3.1.1",

@ -18,7 +18,7 @@
"homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": {
"a17t": "^0.4.0",
"esbuild": "^0.8.47",
"esbuild": "^0.8.48",
"lodash": "^4.17.19",
"mjml": "^4.8.0",
"remixicon": "^2.5.0",

@ -86,7 +86,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
if err != nil {
app.err.Printf("Failed to construct password reset email for %s", pwr.Username)
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.debug.Printf("%s: Error: %s", pwr.Username, err)
} else {

@ -138,6 +138,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/users/emails", app.ModifyEmails)
// api.POST(p + "/setDefaults", app.SetDefaults)
api.POST(p+"/users/settings", app.ApplySettings)
api.POST(p+"/users/announce", app.Announce)
api.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig)
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.addProfile = new Modal(document.getElementById("modal-add-profile"));
window.modals.announce = new Modal(document.getElementById("modal-announce"));
})();
var inviteCreator = new createInvite();

@ -148,6 +148,7 @@ export class accountsList {
private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
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 _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
@ -189,6 +190,9 @@ export class accountsList {
this._selectAll.checked = false;
this._modifySettings.classList.add("unfocused");
this._deleteUser.classList.add("unfocused");
if (window.emailEnabled) {
this._announceButton.classList.add("unfocused");
}
} else {
if (this._checkCount == Object.keys(this._users).length) {
this._selectAll.checked = true;
@ -200,6 +204,9 @@ export class accountsList {
this._modifySettings.classList.remove("unfocused");
this._deleteUser.classList.remove("unfocused");
this._deleteUser.textContent = window.lang.quantity("deleteUser", this._checkCount);
if (window.emailEnabled) {
this._announceButton.classList.remove("unfocused");
}
}
}
@ -247,6 +254,39 @@ export class accountsList {
}
}, 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 = () => {
const modalHeader = document.getElementById("header-delete-user");
@ -397,6 +437,9 @@ export class accountsList {
this._deleteUser.onclick = this.deleteUsers;
this._deleteUser.classList.add("unfocused");
this._announceButton.onclick = this.announce;
this._announceButton.classList.add("unfocused");
if (!window.usernameEnabled) {
this._addUserName.classList.add("unfocused");
this._addUserName = this._addUserEmail;

@ -74,6 +74,7 @@ declare interface Modals {
ombiDefaults?: Modal;
profiles: Modal;
addProfile: Modal;
announce: Modal;
}
interface Invite {

Loading…
Cancel
Save