summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabe Kangas <gabek@real-ity.com>2022-01-20 14:00:00 -0800
committerGabe Kangas <gabek@real-ity.com>2022-03-07 16:50:44 -0800
commit1b9d8f7d0ca9f3384622c0c58510e6afcc5f4e8d (patch)
tree0066c822b220345b2d73f60d22f38631d09b83cb
parenta967f5e0aaf0e618f6d7897f9698ff549fe05f69 (diff)
Email notifications/smtp support
-rw-r--r--controllers/admin/notifications.go22
-rw-r--r--controllers/admin/serverConfig.go25
-rw-r--r--controllers/config.go9
-rw-r--r--controllers/notifications.go54
-rw-r--r--core/data/config.go25
-rw-r--r--core/streamState.go41
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--models/notification.go12
-rw-r--r--notifications/email/email.go83
-rw-r--r--notifications/email/golive.tmpl.html487
-rw-r--r--notifications/mailjet/mailjet.go87
-rw-r--r--notifications/notifications.go42
-rw-r--r--router/router.go4
-rw-r--r--webroot/js/components/notification.js128
-rw-r--r--webroot/js/utils/constants.js1
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
}
diff --git a/go.mod b/go.mod
index 586adcf9a..06ac2940f 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index 9a62e430f..e15e642e8 100644
--- a/go.sum
+++ b/go.sum
@@ -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;
+ "
+ >
+ &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;
+ </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"
+ />&nbsp;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 &notifier, 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