diff options
author | Gabe Kangas <gabek@real-ity.com> | 2022-01-20 14:00:00 -0800 |
---|---|---|
committer | Gabe Kangas <gabek@real-ity.com> | 2022-03-07 16:50:44 -0800 |
commit | 1b9d8f7d0ca9f3384622c0c58510e6afcc5f4e8d (patch) | |
tree | 0066c822b220345b2d73f60d22f38631d09b83cb | |
parent | a967f5e0aaf0e618f6d7897f9698ff549fe05f69 (diff) |
Email notifications/smtp support
-rw-r--r-- | controllers/admin/notifications.go | 22 | ||||
-rw-r--r-- | controllers/admin/serverConfig.go | 25 | ||||
-rw-r--r-- | controllers/config.go | 9 | ||||
-rw-r--r-- | controllers/notifications.go | 54 | ||||
-rw-r--r-- | core/data/config.go | 25 | ||||
-rw-r--r-- | core/streamState.go | 41 | ||||
-rw-r--r-- | go.mod | 1 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | models/notification.go | 12 | ||||
-rw-r--r-- | notifications/email/email.go | 83 | ||||
-rw-r--r-- | notifications/email/golive.tmpl.html | 487 | ||||
-rw-r--r-- | notifications/mailjet/mailjet.go | 87 | ||||
-rw-r--r-- | notifications/notifications.go | 42 | ||||
-rw-r--r-- | router/router.go | 4 | ||||
-rw-r--r-- | webroot/js/components/notification.js | 128 | ||||
-rw-r--r-- | webroot/js/utils/constants.js | 1 |
16 files changed, 966 insertions, 57 deletions
diff --git a/controllers/admin/notifications.go b/controllers/admin/notifications.go index ce7eee102..effc63fd0 100644 --- a/controllers/admin/notifications.go +++ b/controllers/admin/notifications.go @@ -52,3 +52,25 @@ func SetBrowserNotificationConfiguration(w http.ResponseWriter, r *http.Request) controllers.WriteSimpleResponse(w, false, "unable to update browser push config with provided values") } } + +// SetMailjetNotificationConfiguration will set the browser notification configuration. +func SetMailjetNotificationConfiguration(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type request struct { + Value models.MailjetConfiguration `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var config request + if err := decoder.Decode(&config); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update mailjet config with provided values") + return + } + + if err := data.SetMailjetConfiguration(config.Value); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update mailjet config with provided values") + } +} diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index f48a03fcc..b6015137d 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -80,6 +80,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { Notifications: notificationsConfigResponse{ Discord: data.GetDiscordConfig(), Browser: data.GetBrowserPushConfig(), + Email: data.GetMailjetConfiguration(), }, } @@ -120,18 +121,17 @@ type videoSettings struct { } type webConfigResponse struct { - Name string `json:"name"` - Summary string `json:"summary"` - WelcomeMessage string `json:"welcomeMessage"` - Logo string `json:"logo"` - Tags []string `json:"tags"` - Version string `json:"version"` - NSFW bool `json:"nsfw"` - ExtraPageContent string `json:"extraPageContent"` - StreamTitle string `json:"streamTitle"` // What's going on with the current stream - SocialHandles []models.SocialHandle `json:"socialHandles"` - CustomStyles string `json:"customStyles"` - Notifications notificationsConfigResponse `json:"notifications"` + Name string `json:"name"` + Summary string `json:"summary"` + WelcomeMessage string `json:"welcomeMessage"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` + CustomStyles string `json:"customStyles"` } type yp struct { @@ -152,4 +152,5 @@ type federationConfigResponse struct { type notificationsConfigResponse struct { Browser models.BrowserNotificationConfiguration `json:"browser"` Discord models.DiscordConfiguration `json:"discord"` + Email models.MailjetConfiguration `json:"email"` } diff --git a/controllers/config.go b/controllers/config.go index a5b09281b..7bf5bf225 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -45,13 +45,13 @@ type browserNotificationsConfigResponse struct { PublicKey string `json:"publicKey,omitempty"` } -type textMessageNotificatoinsConfigResponse struct { +type EmailNotificationsConfigResponse struct { Enabled bool `json:"enabled"` } type notificationsConfigResponse struct { - Browser browserNotificationsConfigResponse `json:"browser"` - TextMessages textMessageNotificatoinsConfigResponse `json:"textMessages"` + Browser browserNotificationsConfigResponse `json:"browser"` + Email EmailNotificationsConfigResponse `json:"email"` } // GetWebConfig gets the status of the server. @@ -100,6 +100,9 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) { Enabled: browserPushEnabled, PublicKey: browserPushPublicKey, }, + Email: EmailNotificationsConfigResponse{ + Enabled: data.GetMailjetConfiguration().Enabled, + }, } configuration := webConfigResponse{ diff --git a/controllers/notifications.go b/controllers/notifications.go index de060fb85..8f0dd97ea 100644 --- a/controllers/notifications.go +++ b/controllers/notifications.go @@ -4,12 +4,65 @@ import ( "encoding/json" "net/http" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/notifications" + "github.com/owncast/owncast/notifications/mailjet" + "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) +// RegisterForEmailNotifications will register a single email address with +// an email list, creating the list if necessary. +func RegisterForEmailNotifications(w http.ResponseWriter, r *http.Request) { + type request struct { + EmailAddress string `json:"emailAddress"` + } + + emailConfig := data.GetMailjetConfiguration() + if !emailConfig.Enabled { + WriteSimpleResponse(w, false, "email notifications are not enabled") + return + } + + decoder := json.NewDecoder(r.Body) + var req request + if err := decoder.Decode(&req); err != nil { + log.Errorln(err) + WriteSimpleResponse(w, false, "unable to register for email notifications") + return + } + + m := mailjet.New(emailConfig.Username, emailConfig.Password) + + // If we have not previously created an email list for Owncast then create + // a new one now, and add the requested email address to it. + if emailConfig.ListID == 0 { + listAddress, listID, err := m.CreateListAndAddAddress(req.EmailAddress) + if err != nil { + log.Errorln(err) + WriteSimpleResponse(w, false, "unable to register address for notifications") + return + } + emailConfig.ListAddress = listAddress + emailConfig.ListID = listID + if err := data.SetMailjetConfiguration(emailConfig); err != nil { + log.Errorln(err) + WriteSimpleResponse(w, false, "error in saving email configuration") + return + } + } else { + if err := m.AddEmailToList(req.EmailAddress, emailConfig.ListID); err != nil { + log.Errorln(err) + WriteSimpleResponse(w, false, "error in adding email address to list") + return + } + } + + WriteSimpleResponse(w, true, "added") +} + // RegisterForLiveNotifications will register a channel + destination to be // notified when a stream goes live. func RegisterForLiveNotifications(w http.ResponseWriter, r *http.Request) { @@ -30,6 +83,7 @@ func RegisterForLiveNotifications(w http.ResponseWriter, r *http.Request) { if err := decoder.Decode(&req); err != nil { log.Errorln(err) WriteSimpleResponse(w, false, "unable to register for notifications") + return } // Make sure the requested channel is one we want to handle. diff --git a/core/data/config.go b/core/data/config.go index 770627c2f..b9e563de0 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -61,6 +61,8 @@ const ( browserPushConfigurationKey = "browser_push_configuration" browserPushPublicKeyKey = "browser_push_public_key" browserPushPrivateKeyKey = "browser_push_private_key" + mailjetConfigurationKey = "mailjet_configuration" + mailjetEmailListIDKey = "mailjet_email_list_id" hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications" ) @@ -822,7 +824,7 @@ func SetDiscordConfig(config models.DiscordConfiguration) error { return _datastore.Save(configEntry) } -// GetDiscordConfig will return the browser push configuration. +// GetBrowserPushConfig will return the browser push configuration. func GetBrowserPushConfig() models.BrowserNotificationConfiguration { configEntry, err := _datastore.Get(browserPushConfigurationKey) if err != nil { @@ -863,6 +865,27 @@ func GetBrowserPushPrivateKey() (string, error) { return _datastore.GetString(browserPushPrivateKeyKey) } +// GetMailjetConfiguration will return the browser push configuration. +func GetMailjetConfiguration() models.MailjetConfiguration { + configEntry, err := _datastore.Get(mailjetConfigurationKey) + if err != nil { + return models.MailjetConfiguration{Enabled: false} + } + + var config models.MailjetConfiguration + if err := configEntry.getObject(&config); err != nil { + return models.MailjetConfiguration{Enabled: false} + } + + return config +} + +// SetMailjetConfiguration will set the mailjet configuration. +func SetMailjetConfiguration(config models.MailjetConfiguration) error { + configEntry := ConfigEntry{Key: mailjetConfigurationKey, Value: config} + return _datastore.Save(configEntry) +} + // SetHasPerformedInitialNotificationsConfig sets when performed initial setup. func SetHasPerformedInitialNotificationsConfig(hasConfigured bool) error { return _datastore.SetBool(hasConfiguredInitialNotificationsKey, true) diff --git a/core/streamState.go b/core/streamState.go index c65f8c8a7..8013c18da 100644 --- a/core/streamState.go +++ b/core/streamState.go @@ -74,19 +74,8 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) { _ = chat.SendSystemAction("Stay tuned, the stream is **starting**!", true) chat.SendAllWelcomeMessage() - // Send a delayed live Federated message. - if data.GetFederationEnabled() { - _onlineTimerCancelFunc = startFederatedLiveStreamMessageTimer() - } - - // Send alerts to those who have registered for notifications. - if notifier, err := notifications.New(data.GetDatastore()); err != nil { - log.Errorln(err) - } else if _lastNotified == nil || time.Since(*_lastNotified) > 5*time.Minute { - notifier.Notify() - now := time.Now() - _lastNotified = &now - } + // Send delayed notification messages. + _onlineTimerCancelFunc = startLiveStreamNotificationsTimer() } // SetStreamAsDisconnected sets the stream as disconnected. @@ -173,19 +162,37 @@ func stopOnlineCleanupTimer() { } } -func startFederatedLiveStreamMessageTimer() context.CancelFunc { - // Send a delayed live Federated message. +func startLiveStreamNotificationsTimer() context.CancelFunc { + // Send delayed notification messages. c, cancelFunc := context.WithCancel(context.Background()) _onlineTimerCancelFunc = cancelFunc go func(c context.Context) { select { case <-time.After(time.Minute * 2.0): - log.Traceln("Sending Federated Go Live message.") - if err := activitypub.SendLive(); err != nil { + if _lastNotified != nil && time.Since(*_lastNotified) < 10*time.Minute { + return + } + + // Send Fediverse message. + if data.GetFederationEnabled() { + log.Traceln("Sending Federated Go Live message.") + if err := activitypub.SendLive(); err != nil { + log.Errorln(err) + } + } + + // Send notification to those who have registered for them. + if notifier, err := notifications.New(data.GetDatastore()); err != nil { log.Errorln(err) + } else { + notifier.Notify() } + + now := time.Now() + _lastNotified = &now case <-c.Done(): } }(c) + return cancelFunc } @@ -67,6 +67,7 @@ require ( github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/gorilla/css v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394 github.com/oschwald/maxminddb-golang v1.8.0 // indirect ) @@ -182,6 +182,8 @@ github.com/lestrrat-go/strftime v1.0.4 h1:T1Rb9EPkAhgxKqbcMIPguPq8glqXTA1koF8n9B github.com/lestrrat-go/strftime v1.0.4/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394 h1:+6kiV40vfmh17TDlZG15C2uGje1/XBGT32j6xKmUkqM= +github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394/go.mod h1:ogN8Sxy3n5VKLhQxbtSBM3ICG/VgjXS/akQJIoDSrgA= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk= github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= diff --git a/models/notification.go b/models/notification.go index 7528a95e8..3fbf6f2a2 100644 --- a/models/notification.go +++ b/models/notification.go @@ -10,3 +10,15 @@ type BrowserNotificationConfiguration struct { Enabled bool `json:"enabled"` GoLiveMessage string `json:"goLiveMessage,omitempty"` } + +// MailjetConfiguration represents the configuration for using Mailjet. +type MailjetConfiguration struct { + Enabled bool `json:"enabled"` + ListAddress string `json:"listAddress,omitempty"` + ListID int64 `json:"listID,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + SMTPServer string `json:"smtpServer,omitempty"` + FromAddress string `json:"fromAddress,omitempty"` + GoLiveSubject string `json:"goLiveSubject,omitempty"` +} diff --git a/notifications/email/email.go b/notifications/email/email.go new file mode 100644 index 000000000..e69607b1a --- /dev/null +++ b/notifications/email/email.go @@ -0,0 +1,83 @@ +package email + +import ( + "bytes" + _ "embed" + "fmt" + "net/smtp" + "strings" + "text/template" + + "github.com/owncast/owncast/core/data" + "github.com/pkg/errors" +) + +//go:embed "golive.tmpl.html" +var goLiveTemplate string + +// Email represents an instance of the Email notifier. +type Email struct { + From string + SMTPServer string + SMTPPort string + Username string + Password string +} + +// New creates a new instance of the Email notifier. +func New(from, server, port, username, password string) *Email { + return &Email{ + From: from, + SMTPServer: server, + SMTPPort: port, + Username: username, + Password: password, + } +} + +// Send will send an email notification. +func (e *Email) Send(to []string, content, subject string) error { + auth := smtp.PlainAuth("", e.Username, e.Password, e.SMTPServer) + + msg := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\r\n" + msg += fmt.Sprintf("From: %s\r\n", e.From) + msg += fmt.Sprintf("To: %s\r\n", strings.Join(to, ";")) + msg += fmt.Sprintf("Subject: %s\r\n", subject) + msg += fmt.Sprintf("\r\n%s\r\n", content) + + return smtp.SendMail(e.SMTPServer+":"+e.SMTPPort, auth, e.From, to, []byte(msg)) +} + +// GenerateEmailContent will return email content as a string. +func GenerateEmailContent() (string, error) { + type templateData struct { + Logo string + Thumbnail string + ServerURL string + ServerName string + Description string + StreamDescription string + } + + td := templateData{ + Logo: data.GetServerURL() + "/logo", + Thumbnail: data.GetServerURL() + "/thumbnail.jpg", + ServerURL: data.GetServerURL(), + ServerName: data.GetServerName(), + Description: data.GetServerSummary(), + StreamDescription: data.GetStreamTitle(), + } + + t, err := template.New("goLive").Parse(goLiveTemplate) + if err != nil { + return "", errors.Wrap(err, "failed to parse go live email template") + } + var tpl bytes.Buffer + if err := t.Execute(&tpl, td); err != nil { + return "", errors.Wrap(err, "failed to execute go live email template") + } + + content := tpl.String() + + return content, nil +} diff --git a/notifications/email/golive.tmpl.html b/notifications/email/golive.tmpl.html new file mode 100644 index 000000000..c773fe52f --- /dev/null +++ b/notifications/email/golive.tmpl.html @@ -0,0 +1,487 @@ +<!DOCTYPE html> +<html + lang="en" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" +> + <head> + <meta charset="utf-8" /> + <!-- utf-8 works for most cases --> + <meta name="viewport" content="width=device-width" /> + <!-- Forcing initial-scale shouldn't be necessary --> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <!-- Use the latest (edge) version of IE rendering engine --> + <meta name="x-apple-disable-message-reformatting" /> + <!-- Disable auto-scale in iOS 10 Mail entirely --> + <title></title> + <!-- The title tag shows in email notifications, like Android 4.4. --> + + <link + href="https://fonts.googleapis.com/css?family=Lato:300,400,700" + rel="stylesheet" + /> + + <!-- CSS Reset : BEGIN --> + <style> + /* What it does: Remove spaces around the email design added by some email clients. */ + /* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */ + html, + body { + margin: 0 auto !important; + padding: 0 !important; + height: 100% !important; + width: 100% !important; + background: #f1f1f1; + } + + /* What it does: Stops email clients resizing small text. */ + * { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + } + + /* What it does: Centers email on Android 4.4 */ + div[style*='margin: 16px 0'] { + margin: 0 !important; + } + + /* What it does: Stops Outlook from adding extra spacing to tables. */ + table, + td { + mso-table-lspace: 0pt !important; + mso-table-rspace: 0pt !important; + } + + /* What it does: Fixes webkit padding issue. */ + table { + border-spacing: 0 !important; + border-collapse: collapse !important; + table-layout: fixed !important; + margin: 0 auto !important; + } + + /* What it does: Uses a better rendering method when resizing images in IE. */ + img { + -ms-interpolation-mode: bicubic; + } + + /* What it does: Prevents Windows 10 Mail from underlining links despite inline CSS. Styles for underlined links should be inline. */ + a { + text-decoration: none; + } + + /* What it does: A work-around for email clients meddling in triggered links. */ + *[x-apple-data-detectors], /* iOS */ +.unstyle-auto-detected-links *, +.aBn { + border-bottom: 0 !important; + cursor: default !important; + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + } + + /* What it does: Prevents Gmail from displaying a download button on large, non-linked images. */ + .a6S { + display: none !important; + opacity: 0.01 !important; + } + + /* What it does: Prevents Gmail from changing the text color in conversation threads. */ + .im { + color: inherit !important; + } + + /* If the above doesn't work, add a .g-img class to any image in question. */ + img.g-img + div { + display: none !important; + } + + /* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */ + /* Create one of these media queries for each additional viewport size you'd like to fix */ + + /* iPhone 4, 4S, 5, 5S, 5C, and 5SE */ + @media only screen and (min-device-width: 320px) and (max-device-width: 374px) { + u ~ div .email-container { + min-width: 320px !important; + } + } + /* iPhone 6, 6S, 7, 8, and X */ + @media only screen and (min-device-width: 375px) and (max-device-width: 413px) { + u ~ div .email-container { + min-width: 375px !important; + } + } + /* iPhone 6+, 7+, and 8+ */ + @media only screen and (min-device-width: 414px) { + u ~ div .email-container { + min-width: 414px !important; + } + } + </style> + + <!-- CSS Reset : END --> + + <!-- Progressive Enhancements : BEGIN --> + <style> + .primary { + background: #6655b3; + } + .bg_white { + background: #ffffff; + } + .bg_light { + background: #fafafa; + } + .bg_black { + background: #000000; + } + .bg_dark { + background: rgba(0, 0, 0, 0.8); + } + .email-section { + padding: 2.5em; + } + + /*BUTTON*/ + .btn { + padding: 10px 15px; + display: inline-block; + font-size: 1.4em; + } + .btn.btn-primary { + border-radius: 5px; + background: #6655b3; + color: #ffffff; + } + .btn.btn-white { + border-radius: 5px; + background: #ffffff; + color: #000000; + } + .btn.btn-white-outline { + border-radius: 5px; + background: transparent; + border: 1px solid #fff; + color: #fff; + } + .btn.btn-black-outline { + border-radius: 0px; + background: transparent; + border: 2px solid #000; + color: #000; + font-weight: 700; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: 'Lato', sans-serif; + color: #000000; + margin-top: 0; + font-weight: 400; + } + + body { + font-family: 'Lato', sans-serif; + font-weight: 400; + font-size: 15px; + line-height: 1.8; + color: rgba(0, 0, 0, 0.4); + } + + a { + color: #6655b3; + } + + table { + } + /*LOGO*/ + + .logo h1 { + margin: 0; + } + .logo h1 a { + color: #6655b3; + font-size: 24px; + font-weight: 700; + font-family: 'Lato', sans-serif; + } + + /*HERO*/ + .hero { + position: relative; + z-index: 0; + } + + .hero .text { + color: rgba(0, 0, 0, 0.3); + } + .hero .text h2 { + color: #000; + font-size: 40px; + margin-bottom: 0; + font-weight: 400; + line-height: 1.4; + } + .hero .text h3 { + font-size: 24px; + font-weight: 300; + } + .hero .text h2 span { + font-weight: 600; + color: #6655b3; + } + + /*HEADING SECTION*/ + .heading-section { + } + .heading-section h2 { + color: #000000; + font-size: 28px; + margin-top: 0; + line-height: 1.4; + font-weight: 400; + } + .heading-section .subheading { + margin-bottom: 20px !important; + display: inline-block; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 2px; + color: rgba(0, 0, 0, 0.4); + position: relative; + } + .heading-section .subheading::after { + position: absolute; + left: 0; + right: 0; + bottom: -10px; + content: ''; + width: 100%; + height: 2px; + background: #6655b3; + margin: 0 auto; + } + + .heading-section-white { + color: rgba(255, 255, 255, 0.8); + } + .heading-section-white h2 { + line-height: 1; + padding-bottom: 0; + } + .heading-section-white h2 { + color: #ffffff; + } + .heading-section-white .subheading { + margin-bottom: 0; + display: inline-block; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 2px; + color: rgba(255, 255, 255, 0.4); + } + + ul.social { + padding: 0; + } + ul.social li { + display: inline-block; + margin-right: 10px; + } + + /*FOOTER*/ + + .footer { + border-top: 1px solid rgba(0, 0, 0, 0.05); + color: rgba(0, 0, 0, 0.5); + } + .footer .heading { + color: #000; + font-size: 20px; + } + .footer ul { + margin: 0; + padding: 0; + } + .footer ul li { + list-style: none; + margin-bottom: 10px; + } + .footer ul li a { + color: rgba(0, 0, 0, 1); + } + + #owncast-promo { + font-size: 10px; + } + + @media screen and (max-width: 500px) { + } + </style> + </head> + + <body + width="100%" + style=" + margin: 0; + padding: 0 !important; + mso-line-height-rule: exactly; + background-color: #f1f1f1; + " + > + <center style="width: 100%; background-color: #f1f1f1"> + <div + style=" + display: none; + font-size: 1px; + max-height: 0px; + max-width: 0px; + opacity: 0; + overflow: hidden; + mso-hide: all; + font-family: sans-serif; + " + > + ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ + </div> + <div style="max-width: 600px; margin: 0 auto" class="email-container"> + <!-- BEGIN BODY --> + <table + align="center" + role="presentation" + cellspacing="0" + cellpadding="0" + border="0" + width="100%" + style="margin: auto" + > + <tr> + <td + valign="top" + class="bg_white" + style="padding: 1em 2.5em 0 2.5em" + > + <table + role="presentation" + border="0" + cellpadding="0" + cellspacing="0" + width="100%" + > + <tr> + <td class="logo" style="text-align: center"> + <h1><a href="{{.ServerURL}}">{{.ServerName}}</a></h1> + </td> + </tr> + </table> + </td> + </tr> + <!-- end tr --> + <tr> + <td + valign="middle" + class="hero bg_white" + style="padding: 3em 0 2em 0" + > + <a href="{{.ServerURL}}"> + <img + src="{{.Logo}}" + alt="" + style=" + width: 100px; + max-width: 600px; + height: auto; + margin: auto; + display: block; + " + /> + </a> + </td> + </tr> + <!-- end tr --> + <tr> + <td + valign="middle" + class="hero bg_white" + style="padding: 2em 0 4em 0" + > + <table> + <tr> + <td> + <div + class="text" + style="padding: 0 2.5em; text-align: center" + > + <a href="{{.ServerURL}}"> + <img + src="{{.Thumbnail}}" + alt="" + style=" + width: 300px; + max-width: 600px; + height: auto; + margin: auto; + display: block; + " + /> + </a> + <h2><a href="{{.ServerURL}}">{{.ServerName}}</h2></a> + <h3>{{.StreamDescription}}</h3> + <p> + <a href="{{.ServerURL}}" class="btn btn-primary" + >Watch now!</a + > + </p> + </div> + </td> + </tr> + </table> + </td> + </tr> + <!-- end tr --> + <!-- 1 Column Text + Button : END --> + </table> + <table + align="center" + role="presentation" + cellspacing="0" + cellpadding="0" + border="0" + width="100%" + style="margin: auto" + > + <!-- end: tr --> + <tr> + <td class="bg_light" style="text-align: center"> + <p> + No longer want to receive emails from {{.ServerName}}? You should + <a href="[[UNSUB_LINK_EN]]" style="color: rgba(0, 0, 0, 0.8)" + >unsubscribe here</a + >. + </p> + <p id="owncast-promo"> + This stream is powered by + <a href="https://owncast.online" + ><img + src="https://owncast.online/images/logo.svg" + width="10px" + /> Owncast</a + > + and you can run your own, too. + </p> + </td> + </tr> + </table> + </div> + </center> + </body> +</html> diff --git a/notifications/mailjet/mailjet.go b/notifications/mailjet/mailjet.go new file mode 100644 index 000000000..88c5015d2 --- /dev/null +++ b/notifications/mailjet/mailjet.go @@ -0,0 +1,87 @@ +package mailjet + +import ( + mailjet "github.com/mailjet/mailjet-apiv3-go" + "github.com/mailjet/mailjet-apiv3-go/resources" + "github.com/teris-io/shortid" + + "github.com/pkg/errors" +) + +// MailJet represents an instance of the MailJet email service. +type MailJet struct { + APIKey string + APISecret string + Client *mailjet.Client +} + +// New returns a new instance of the MailJet email service. +func New(apiKey, apiSecret string) *MailJet { + return &MailJet{ + APIKey: apiKey, + APISecret: apiSecret, + Client: mailjet.NewMailjetClient(apiKey, apiSecret), + } +} + +// CreateListAndAddAddress will create a new email list and add address to it. +func (m *MailJet) CreateListAndAddAddress(address string) (string, int64, error) { + listAddress, listID, err := m.createList("owncast-" + shortid.MustGenerate()) + if err != nil { + return "", 0, err + } + + if err := m.AddEmailToList(address, listID); err != nil { + return "", 0, err + } + + return listAddress, listID, nil +} + +// AddEmailToList will add an email address to the provided list. +func (m *MailJet) AddEmailToList(address string, listID int64) error { + var data []resources.ContactslistManageContact + request := &mailjet.Request{ + Resource: "contactslist", + ID: listID, + Action: "managecontact", + } + fullRequest := &mailjet.FullRequest{ + Info: request, + Payload: &resources.ContactslistManageContact{ + Properties: "object", + Action: "addnoforce", + Email: address, + }, + } + if err := m.Client.Post(fullRequest, &data); err != nil { + return errors.Wrap(err, "unable to subscribe email to list") + } + + return nil +} + +// createList will create a new email list on Mailjet. +func (m *MailJet) createList(name string) (string, int64, error) { + var data []resources.Contactslist + mr := &mailjet.Request{ + Resource: "contactslist", + } + fmr := &mailjet.FullRequest{ + Info: mr, + Payload: &resources.Contactslist{ + Name: name, + }, + } + err := m.Client.Post(fmr, &data) + if err != nil { + return "", 0, errors.Wrap(err, "unable to create email list from provider") + } + + if len(data) == 0 { + return "", 0, errors.New("provider returned no new email lists") + } + + list := data[0] + return list.Address + "@lists.mailjet.com", list.ID, nil +} diff --git a/notifications/notifications.go b/notifications/notifications.go index 678d5e221..afdb188c0 100644 --- a/notifications/notifications.go +++ b/notifications/notifications.go @@ -6,15 +6,24 @@ import ( "github.com/owncast/owncast/models" "github.com/owncast/owncast/notifications/browser" "github.com/owncast/owncast/notifications/discord" + "github.com/owncast/owncast/notifications/email" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) +const ( + smtpServer = "in-v3.mailjet.com" + smtpPort = "587" + username = "username" + password = "password" +) + // Notifier is an instance of the live stream notifier. type Notifier struct { datastore *data.Datastore browser *browser.Browser discord *discord.Discord + email *email.Email } // Setup will perform any pre-use setup for the notifier. @@ -90,6 +99,12 @@ func New(datastore *data.Datastore) (*Notifier, error) { notifier.discord = discordNotifier } + // Add email notifications + if emailConfig := data.GetMailjetConfiguration(); emailConfig.Enabled && emailConfig.FromAddress != "" && emailConfig.ListAddress != "" { + e := email.New(emailConfig.FromAddress, emailConfig.SMTPServer, "587", emailConfig.Username, emailConfig.Password) + notifier.email = e + } + return ¬ifier, nil } @@ -111,6 +126,29 @@ func (n *Notifier) notifyDiscord() { } } +func (n *Notifier) notifyEmail() { + content, err := email.GenerateEmailContent() + if err != nil { + log.Errorln("unable to generate email notification content: ", err) + return + } + + emailConfig := data.GetMailjetConfiguration() + if !emailConfig.Enabled { + return + } + + subject := emailConfig.GoLiveSubject + if data.GetStreamTitle() != "" { + subject += " - " + data.GetStreamTitle() + } + + if err := n.email.Send([]string{emailConfig.ListAddress}, content, subject); err != nil { + log.Errorln("unable to send email notification: ", err) + return + } +} + // Notify will fire the different notification channels. func (n *Notifier) Notify() { if n.browser != nil { @@ -120,4 +158,8 @@ func (n *Notifier) Notify() { if n.discord != nil { n.notifyDiscord() } + + if n.email != nil { + n.notifyEmail() + } } diff --git a/router/router.go b/router/router.go index f72951b7c..b517eb7c3 100644 --- a/router/router.go +++ b/router/router.go @@ -83,6 +83,9 @@ func Start() error { // Register for notifications http.HandleFunc("/api/notifications/register", middleware.RequireUserAccessToken(controllers.RegisterForLiveNotifications)) + // Email notifications have a special handler due to the 3rd party delivery + http.HandleFunc("/api/notifications/register/email", middleware.RequireUserAccessToken(controllers.RegisterForEmailNotifications)) + // Authenticated admin requests // Current inbound broadcaster @@ -339,6 +342,7 @@ func Start() error { // Configure outbound notification channels. http.HandleFunc("/api/admin/config/notifications/discord", middleware.RequireAdminAuth(admin.SetDiscordNotificationConfiguration)) http.HandleFunc("/api/admin/config/notifications/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration)) + http.HandleFunc("/api/admin/config/notifications/mailjet", middleware.RequireAdminAuth(admin.SetMailjetNotificationConfiguration)) // ActivityPub has its own router activitypub.Start(data.GetDatastore()) diff --git a/webroot/js/components/notification.js b/webroot/js/components/notification.js index 0a0da3937..3a82ccbcd 100644 --- a/webroot/js/components/notification.js +++ b/webroot/js/components/notification.js @@ -7,13 +7,22 @@ import { registerWebPushNotifications, isPushNotificationSupported, } from '../notification/registerWeb.js'; -import { URL_REGISTER_NOTIFICATION } from '../utils/constants.js'; +import { + URL_REGISTER_NOTIFICATION, + URL_REGISTER_EMAIL_NOTIFICATION, +} from '../utils/constants.js'; const html = htm.bind(h); export function NotifyModal({ notifications, streamName, accessToken }) { const [error, setError] = useState(null); const [loaderStyle, setLoaderStyle] = useState('none'); + const [emailNotificationsButtonEnabled, setEmailNotificationsButtonEnabled] = + useState(false); + const [emailAddress, setEmailAddress] = useState(null); + const emailNotificationButtonState = emailNotificationsButtonEnabled + ? '' + : 'cursor-not-allowed opacity-50'; const [browserRegistrationComplete, setBrowserRegistrationComplete] = useState(false); @@ -23,10 +32,11 @@ export function NotifyModal({ notifications, streamName, accessToken }) { ? '' : 'cursor-not-allowed opacity-50'; - const { browser } = notifications; + const { browser, email } = notifications; const { publicKey } = browser; let browserPushEnabled = browser.enabled; + let emailEnabled = email.enabled; // Browser push notifications are only supported on Chrome currently. // Also make sure the browser supports them. @@ -71,6 +81,39 @@ export function NotifyModal({ notifications, streamName, accessToken }) { } } + async function registerForEmailButtonPressed() { + try { + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ emailAddress: emailAddress }), + }; + + try { + await fetch( + URL_REGISTER_EMAIL_NOTIFICATION + `?accessToken=${accessToken}`, + options + ); + } catch (e) { + console.error(e); + } + } catch (e) { + setError(`Error registering for email notifications: ${e.message}.`); + } + } + + function onEmailInput(e) { + const { value } = e.target; + + // TODO: Add validation for email + const valid = true; + + setEmailAddress(value); + setEmailNotificationsButtonEnabled(valid); + } + function getBrowserPushButtonText() { if (browserRegistrationComplete) { return 'Done!'; @@ -85,6 +128,13 @@ export function NotifyModal({ notifications, streamName, accessToken }) { return pushNotificationButtonText; } + var gridColumns = 2; + if (browserPushEnabled && !emailEnabled) { + gridColumns = 1; + } else if (!browserPushEnabled && emailEnabled) { + gridColumns = 1; + } + const pushNotificationButtonText = getBrowserPushButtonText(); return html` @@ -93,28 +143,56 @@ export function NotifyModal({ notifications, streamName, accessToken }) { Never miss a stream! Get notified when ${streamName} goes live. </p> - <div style=${{ display: browserPushEnabled ? 'block' : 'none' }}> - <h2 class="text-indigo-600 text-3xl font-semibold">Browser</h2> - <p>Get notified inside your browser when ${streamName} goes live.</p> - <p class="mt-4"> - Make sure you change the setting from ${' '} - <em>until I close this site</em> or you won't receive future - notifications. - </p> - <img class="mt-4" src="/img/browser-push-notifications-settings.png" /> - <div - style=${error ? 'display: block' : 'display: none'} - class="bg-red-100 border border-red-400 text-red-700 mt-5 px-4 py-3 rounded relative" - role="alert" - > - ${error} + <div class="grid grid-cols-${gridColumns} gap-6"> + <div style=${{ display: browserPushEnabled ? 'block' : 'none' }}> + <h2 class="text-indigo-600 text-3xl font-semibold">Browser</h2> + <p>Get notified inside your browser when ${streamName} goes live.</p> + <p class="mt-4"> + Make sure you change the setting from ${' '} + <em>until I close this site</em> or you won't receive future + notifications. + </p> + <img + class="mt-4" + src="/img/browser-push-notifications-settings.png" + /> + <div + style=${error ? 'display: block' : 'display: none'} + class="bg-red-100 border border-red-400 text-red-700 mt-5 px-4 py-3 rounded relative" + role="alert" + > + ${error} + </div> + <button + class="rounded-sm flex flex-row justify-center items-center overflow-hidden mt-5 px-3 py-1 text-base text-white bg-gray-800 ${browserNotificationsButtonState}" + onClick=${registerForBrowserPushButtonPressed} + > + ${pushNotificationButtonText} + </button> + </div> + <div style=${{ display: emailEnabled ? 'block' : 'none' }}> + <h2 class="text-indigo-600 text-3xl font-semibold">Email</h2> + <p>Get notified directly when ${''} ${streamName} goes live.</p> + <p class="mt-4"> + Easily unsubscribe if you no longer want to receive them. + </p> + <input + class="border bg-white rounded w-full py-2 px-3 mb-2 mt-10 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline" + value=${emailAddress} + onInput=${onEmailInput} + placeholder="streamlover42@gmail.com" + /> + <p class="text-gray-600 text-xs italic"> + Provide your email address. + </p> + + <button + class="rounded-sm flex flex-row justify-center items-center overflow-hidden mt-5 px-3 py-1 text-base text-white bg-gray-800 ${emailNotificationButtonState}" + onClick=${registerForEmailButtonPressed} + > + Notify Me! + </button> </div> - <button - class="rounded-sm flex flex-row justify-center items-center overflow-hidden mt-5 px-3 py-1 text-base text-white bg-gray-800 ${browserNotificationsButtonState}" - onClick=${registerForBrowserPushButtonPressed} - > - ${pushNotificationButtonText} - </button> </div> <div @@ -122,6 +200,8 @@ export function NotifyModal({ notifications, streamName, accessToken }) { style="display: ${loaderStyle}" > <img id="follow-loading-spinner" src="/img/loading.gif" /> + <p class="text-gray-700 text-lg">Contacting your server.</p> + <p class="text-gray-600 text-lg">Please wait...</p> </div> </div> `; @@ -136,7 +216,7 @@ export function NotifyButton({ serverName, federationInfo, onClick }) { }; return html` - <span id="notify-button-container"> + <span id="fediverse-follow-button-container"> <${ExternalActionButton} onClick=${onClick} action=${notifyAction} /> </span> `; diff --git a/webroot/js/utils/constants.js b/webroot/js/utils/constants.js index b9cbc6911..9629d0f9e 100644 --- a/webroot/js/utils/constants.js +++ b/webroot/js/utils/constants.js @@ -19,6 +19,7 @@ export const URL_CHAT_REGISTRATION = `/api/chat/register`; export const URL_FOLLOWERS = `/api/followers`; export const URL_REGISTER_NOTIFICATION = `/api/notifications/register`; +export const URL_REGISTER_EMAIL_NOTIFICATION = `/api/notifications/register/email`; export const TIMER_STATUS_UPDATE = 5000; // ms export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins |