diff --git a/cmd/server/assets/email/default_email_verify.html b/cmd/server/assets/email/default_email_verify.html new file mode 100644 index 000000000..acd5410c5 --- /dev/null +++ b/cmd/server/assets/email/default_email_verify.html @@ -0,0 +1,11 @@ +{{- define "email/verifyemail" -}} +{{template "email/plainheader" .}} + +Hello, + +Click the link below to verify your email address for {{.RealmName}} on the COVID-19 exposure notifications verification server: + +{{.VerifyLink}} + +If you did not request verification of this email, please disregard this message. +{{end}} diff --git a/cmd/server/assets/email/default_reset_password.html b/cmd/server/assets/email/default_reset_password.html new file mode 100644 index 000000000..a51bea90e --- /dev/null +++ b/cmd/server/assets/email/default_reset_password.html @@ -0,0 +1,11 @@ +{{- define "email/passwordresetemail" -}} +{{template "email/plainheader" .}} + +Hello, + +Click the link below to reset your password for the COVID-19 exposure notifications verification server. + +{{.ResetLink}} + +If you did not request a password reset, please disregard this message. +{{end}} diff --git a/cmd/server/assets/login/verify-email.html b/cmd/server/assets/login/verify-email.html index 81f6a2a94..84e165943 100644 --- a/cmd/server/assets/login/verify-email.html +++ b/cmd/server/assets/login/verify-email.html @@ -30,10 +30,13 @@

Email address ownership for {{.currentUser.Email}} is not confirmed.

- +
+ - Click to send an email containing a verification - link. + + Click to send an email containing a verification link. + +
{{if ne .currentRealm.EmailVerifiedMode.String "required"}} @@ -52,18 +55,18 @@ let $skip = $('#skip'); let $not = $('#not'); + {{if eq .emailVerify "send"}} $(function() { - $verify.on('click', function(event) { - let user = firebase.auth().currentUser - if (!user.emailVerified) { - user.sendEmailVerification().then(function() { - flash.clear(); - flash.alert('Verification email sent.'); - $verify.prop('disabled', true); - }); - } - }); + let user = firebase.auth().currentUser + if (!user.emailVerified) { + user.sendEmailVerification().then(function() { + flash.clear(); + flash.alert('Verification email sent.'); + $verify.prop('disabled', true); + }); + } }); + {{end}} firebase.auth().onAuthStateChanged(function(user) { if (!user) { @@ -71,7 +74,7 @@ return; } - if (user.emailVerified) { + if ({{if eq .emailVerify "sent"}}true{{else}}user.emailVerified{{end}}) { $not.hide(); $skip.text("Go home"); } else { diff --git a/cmd/server/assets/realmadmin/_form_email.html b/cmd/server/assets/realmadmin/_form_email.html new file mode 100644 index 000000000..9c1fcab88 --- /dev/null +++ b/cmd/server/assets/realmadmin/_form_email.html @@ -0,0 +1,89 @@ +{{define "realmadmin/_form_email"}} + +{{$realm := .realm}} +{{$emailConfig := .emailConfig}} + +

+ These are the settings for configuring an SMTP email provider. The verification server + will use this email account to send invitations, password resets, and account-verifications + for the realm. +

+ +
+ {{ .csrfField }} + + + {{if $realm.CanUseSystemEmailConfig}} + {{if $realm.UseSystemEmailConfig}} +
+ Warning! You are currently using the system-provided + SMTP email configuration. Specifying values below will override the system + configuration to use your supplied credentials. +
+ {{end}} + +
+ + +
+ {{end}} + +
+
+ + + {{template "errorable" $emailConfig.ErrorsFor "smtpAccount"}} + + This is the SMTP email account eg. noreply@example.com + +
+ +
+
+ + +
+ + + +
+
+ {{template "errorable" $emailConfig.ErrorsFor "smtpPassword"}} + + This is the password for your SMTP email. + +
+ +
+ + + {{template "errorable" $emailConfig.ErrorsFor "smtpHost"}} + + SMTP host is the hostname for the SMTP server. + +
+ +
+ + + {{template "errorable" $emailConfig.ErrorsFor "smtpPort"}} + + SMTP port is the port number to connect to. + 587 is the default port for SMTP, and legacy port 25 is blocked. + +
+
+ +
+ +
+
+ +{{end}} diff --git a/cmd/server/assets/realmadmin/edit.html b/cmd/server/assets/realmadmin/edit.html index 0f7980b1c..4e1ad8034 100644 --- a/cmd/server/assets/realmadmin/edit.html +++ b/cmd/server/assets/realmadmin/edit.html @@ -39,6 +39,9 @@

Realm settings

+ @@ -64,6 +67,9 @@

Realm settings

{{template "realmadmin/_form_sms" .}}
+
+ {{template "realmadmin/_form_email" .}} +
{{template "realmadmin/_form_security" .}}
diff --git a/cmd/server/main.go b/cmd/server/main.go index 06eb26240..edc66c038 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -40,7 +40,6 @@ import ( "github.com/google/exposure-notifications-verification-server/pkg/controller/realmadmin" "github.com/google/exposure-notifications-verification-server/pkg/controller/realmkeys" "github.com/google/exposure-notifications-verification-server/pkg/controller/user" - "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/ratelimit" "github.com/google/exposure-notifications-verification-server/pkg/ratelimit/limitware" "github.com/google/exposure-notifications-verification-server/pkg/render" @@ -156,16 +155,6 @@ func realMain(ctx context.Context) error { return fmt.Errorf("failed to create renderer: %w", err) } - // Setup server emailer - var emailer email.Provider - if cfg.Email.HasSMTPCreds() { - cfg.Email.ProviderType = email.ProviderTypeSMTP - emailer, err = email.ProviderFor(ctx, &cfg.Email) - if err != nil { - return fmt.Errorf("failed to configure internal firebase client: %w", err) - } - } - // Rate limiting limiterStore, err := ratelimit.RateLimiterFor(ctx, &cfg.RateLimit) if err != nil { @@ -240,7 +229,7 @@ func realMain(ctx context.Context) error { Queries("oobCode", "", "mode", "resetPassword").Methods("GET") sub.Handle("/login/manage-account", loginController.HandleSubmitNewPassword()). Queries("oobCode", "", "mode", "resetPassword").Methods("POST") - sub.Handle("/login/manage-account", loginController.HandleSubmitVerifyEmail()). + sub.Handle("/login/manage-account", loginController.HandleReceiveVerifyEmail()). Queries("oobCode", "{oobCode:.+}", "mode", "{mode:(?:verifyEmail|recoverEmail)}").Methods("GET") sub.Handle("/session", loginController.HandleCreateSession()).Methods("POST") sub.Handle("/signout", loginController.HandleSignOut()).Methods("GET") @@ -266,6 +255,8 @@ func realMain(ctx context.Context) error { sub.Use(processFirewall) sub.Handle("/login/manage-account", loginController.HandleShowVerifyEmail()). Queries("mode", "verifyEmail").Methods("GET") + sub.Handle("/login/manage-account", loginController.HandleSubmitVerifyEmail()). + Queries("mode", "verifyEmail").Methods("POST") // SMS auth registration is realm-specific, so it needs to load the current realm. sub = r.PathPrefix("").Subrouter() @@ -374,7 +365,7 @@ func realMain(ctx context.Context) error { userSub.Use(requireMFA) userSub.Use(rateLimit) - userController := user.New(ctx, firebaseInternal, auth, emailer, cacher, cfg, db, h) + userController := user.New(ctx, firebaseInternal, auth, cacher, cfg, db, h) userSub.Handle("", userController.HandleIndex()).Methods("GET") userSub.Handle("", userController.HandleIndex()). Queries("offset", "{[0-9]*}", "email", "").Methods("GET") diff --git a/pkg/config/server_config.go b/pkg/config/server_config.go index 5a003fa4e..ef51c1f37 100644 --- a/pkg/config/server_config.go +++ b/pkg/config/server_config.go @@ -21,7 +21,6 @@ import ( "github.com/google/exposure-notifications-verification-server/pkg/cache" "github.com/google/exposure-notifications-verification-server/pkg/database" - "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/ratelimit" "github.com/google/exposure-notifications-server/pkg/observability" @@ -52,7 +51,6 @@ type ServerConfig struct { Database database.Config Observability observability.Config Cache cache.Config - Email email.Config Port string `env:"PORT,default=8080"` diff --git a/pkg/controller/email_compose.go b/pkg/controller/email_compose.go index 0fcfef35c..29e4c7445 100644 --- a/pkg/controller/email_compose.go +++ b/pkg/controller/email_compose.go @@ -19,9 +19,13 @@ import ( "fmt" "firebase.google.com/go/auth" + "github.com/google/exposure-notifications-verification-server/pkg/database" + "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/render" ) +type ComposeFn func(provider email.Provider, realm *database.Realm, toEmail string) ([]byte, error) + // ComposeInviteEmail uses the renderer and auth client to generate a password reset link // and emit an invite email func ComposeInviteEmail( @@ -51,3 +55,88 @@ func ComposeInviteEmail( } return message, nil } + +// ComposeEmailVerifyEmail uses the renderer and auth client to generate an email to verify +// the user's email address. +func ComposeEmailVerifyEmail( + ctx context.Context, + h *render.Renderer, + auth *auth.Client, toEmail, fromEmail, realmName string) ([]byte, error) { + verifyLink, err := auth.EmailVerificationLink(ctx, toEmail) + if err != nil { + return nil, fmt.Errorf("failed generating verification link: %w", err) + } + + // Compose message + message, err := h.RenderEmail("email/verifyemail", + struct { + ToEmail string + FromEmail string + VerifyLink string + RealmName string + }{ + ToEmail: toEmail, + FromEmail: fromEmail, + VerifyLink: verifyLink, + RealmName: realmName, + }) + if err != nil { + return nil, fmt.Errorf("failed rendering email verification template: %w", err) + } + return message, nil +} + +// ComposePasswordResetEmail uses the renderer and auth client to generate an email for resetting +// a user password +func ComposePasswordResetEmail( + ctx context.Context, + h *render.Renderer, + auth *auth.Client, toEmail, fromEmail string) ([]byte, error) { + resetLink, err := auth.PasswordResetLink(ctx, toEmail) + if err != nil { + return nil, fmt.Errorf("failed generating password reset link: %w", err) + } + + // Compose message + message, err := h.RenderEmail("email/passwordresetemail", + struct { + ToEmail string + FromEmail string + ResetLink string + RealmName string + }{ + ToEmail: toEmail, + FromEmail: fromEmail, + ResetLink: resetLink, + }) + if err != nil { + return nil, fmt.Errorf("failed rendering password reset template: %w", err) + } + return message, nil +} + +func SendRealmEmail(ctx context.Context, db *database.Database, compose ComposeFn, toEmail string) (bool, error) { + realm := RealmFromContext(ctx) + if realm == nil { + return false, nil + } + + emailer, err := realm.EmailProvider(db) + if err != nil { + if database.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed creating email provider: %w", err) + } + + message, err := compose(emailer, realm, toEmail) + if err != nil { + return false, fmt.Errorf("failed composing email verification: %w", err) + } + + if err := emailer.SendEmail(ctx, toEmail, message); err != nil { + return false, fmt.Errorf("failed sending email verification: %w", err) + } + + return true, nil +} diff --git a/pkg/controller/login/reset_password.go b/pkg/controller/login/reset_password.go index 83ebe1af2..f5071f388 100644 --- a/pkg/controller/login/reset_password.go +++ b/pkg/controller/login/reset_password.go @@ -17,6 +17,7 @@ package login import ( "context" + "fmt" "net/http" "strings" @@ -77,6 +78,45 @@ func (c *Controller) HandleSubmitResetPassword() http.Handler { stats.Record(ctx, controller.MFirebaseRecreates.M(1)) } - c.renderResetPassword(ctx, w, flash, email, true) + sent, err := c.sendResetFromSystemEmailer(ctx, email) + if err != nil { + c.logger.Warnw("failed sending password reset", "error", err) + } + if !sent { + // fallback to firebase + c.renderResetPassword(ctx, w, flash, email, true) + return + } + + flash.Alert("Password reset email sent.") + c.renderResetPassword(ctx, w, flash, email, false) }) } + +func (c *Controller) sendResetFromSystemEmailer(ctx context.Context, toEmail string) (bool, error) { + // Send email with system email config + + emailConfig, err := c.db.SystemEmailConfig() + if err != nil { + if database.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to get email config for system: %w", err) + } + + emailer, err := emailConfig.Provider() + if err != nil { + return false, fmt.Errorf("failed to get emailer for realm: %w", err) + } + + message, err := controller.ComposePasswordResetEmail(ctx, c.h, c.client, toEmail, emailer.From()) + if err != nil { + return false, fmt.Errorf("failed composing password reset email: %w", err) + } + + if err := emailer.SendEmail(ctx, toEmail, message); err != nil { + return false, fmt.Errorf("failed sending password reset email: %w", err) + } + + return true, nil +} diff --git a/pkg/controller/login/verify_email.go b/pkg/controller/login/verify_email_receive.go similarity index 63% rename from pkg/controller/login/verify_email.go rename to pkg/controller/login/verify_email_receive.go index 3b0ae2c52..bc4a9413f 100644 --- a/pkg/controller/login/verify_email.go +++ b/pkg/controller/login/verify_email_receive.go @@ -21,26 +21,7 @@ import ( "github.com/google/exposure-notifications-verification-server/pkg/controller" ) -func (c *Controller) HandleShowVerifyEmail() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - session := controller.SessionFromContext(ctx) - if session == nil { - controller.MissingSession(w, r, c.h) - return - } - - // Mark prompted so we only prompt once. - controller.StoreSessionEmailVerificationPrompted(session, true) - - m := controller.TemplateMapFromContext(ctx) - m["firebase"] = c.config.Firebase - c.h.RenderHTML(w, "login/verify-email", m) - }) -} - -func (c *Controller) HandleSubmitVerifyEmail() http.Handler { +func (c *Controller) HandleReceiveVerifyEmail() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/pkg/controller/login/verify_email_send.go b/pkg/controller/login/verify_email_send.go new file mode 100644 index 000000000..4d6cc1790 --- /dev/null +++ b/pkg/controller/login/verify_email_send.go @@ -0,0 +1,87 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package login defines the controller for the login page. +package login + +import ( + "context" + "net/http" + + "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/database" + "github.com/google/exposure-notifications-verification-server/pkg/email" +) + +func (c *Controller) HandleShowVerifyEmail() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + session := controller.SessionFromContext(ctx) + if session == nil { + controller.MissingSession(w, r, c.h) + return + } + + // Mark prompted so we only prompt once. + controller.StoreSessionEmailVerificationPrompted(session, true) + + c.renderEmailVerify(ctx, w, "") + }) +} + +func (c *Controller) HandleSubmitVerifyEmail() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + session := controller.SessionFromContext(ctx) + if session == nil { + controller.MissingSession(w, r, c.h) + return + } + flash := controller.Flash(session) + + currentUser := controller.UserFromContext(ctx) + if currentUser == nil { + controller.MissingUser(w, r, c.h) + return + } + + compose := func(emailer email.Provider, realm *database.Realm, toEmail string) ([]byte, error) { + return controller.ComposeEmailVerifyEmail(ctx, c.h, c.client, toEmail, emailer.From(), realm.Name) + } + sent, err := controller.SendRealmEmail(ctx, c.db, compose, currentUser.Email) + if err != nil { + c.logger.Warnw("failed sending verification", "error", err) + flash.Error("Failed to send verification email.") + c.renderEmailVerify(ctx, w, "") + return + } + if !sent { + // fallback to Firebase if not SMTP found + c.renderEmailVerify(ctx, w, "send") + return + } + + flash.Alert("Verification email sent.") + c.renderEmailVerify(ctx, w, "sent") + }) +} + +func (c *Controller) renderEmailVerify(ctx context.Context, w http.ResponseWriter, sendInvite string) { + m := controller.TemplateMapFromContext(ctx) + m["sendInvite"] = sendInvite + m["firebase"] = c.config.Firebase + c.h.RenderHTML(w, "login/verify-email", m) +} diff --git a/pkg/controller/realmadmin/express.go b/pkg/controller/realmadmin/express.go index 1c214a3a6..63ff0f166 100644 --- a/pkg/controller/realmadmin/express.go +++ b/pkg/controller/realmadmin/express.go @@ -46,7 +46,7 @@ func (c *Controller) HandleDisableExpress() http.Handler { if !realm.EnableENExpress { flash.Error("Realm is not currently enrolled in EN Express.") - c.renderSettings(ctx, w, r, realm, nil, 0, 0) + c.renderSettings(ctx, w, r, realm, nil, nil, 0, 0) return } @@ -56,7 +56,7 @@ func (c *Controller) HandleDisableExpress() http.Handler { if err := c.db.SaveRealm(realm, currentUser); err != nil { flash.Error("Failed to disable EN Express: %v", err) - c.renderSettings(ctx, w, r, realm, nil, 0, 0) + c.renderSettings(ctx, w, r, realm, nil, nil, 0, 0) return } @@ -90,7 +90,7 @@ func (c *Controller) HandleEnableExpress() http.Handler { if realm.EnableENExpress { flash.Error("Realm already has EN Express Enabled.") - c.renderSettings(ctx, w, r, realm, nil, 0, 0) + c.renderSettings(ctx, w, r, realm, nil, nil, 0, 0) return } @@ -110,7 +110,7 @@ func (c *Controller) HandleEnableExpress() http.Handler { // This will allow the user to correct other validation errors and then click "uprade" again. realm.EnableENExpress = false realm.SMSTextTemplate = enxSettings.SMSTextTemplate - c.renderSettings(ctx, w, r, realm, nil, 0, 0) + c.renderSettings(ctx, w, r, realm, nil, nil, 0, 0) return } diff --git a/pkg/controller/realmadmin/settings.go b/pkg/controller/realmadmin/settings.go index abfcc37fd..de4f2d74b 100644 --- a/pkg/controller/realmadmin/settings.go +++ b/pkg/controller/realmadmin/settings.go @@ -21,6 +21,7 @@ import ( "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" + "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/sms" ) @@ -66,6 +67,13 @@ func (c *Controller) HandleSettings() http.Handler { TwilioAuthToken string `form:"twilio_auth_token"` TwilioFromNumber string `form:"twilio_from_number"` + Email bool `form:"email"` + UseSystemEmailConfig bool `form:"use_system_email_config"` + SMTPAccount string `form:"smtp_account"` + SMTPPassword string `form:"smtp_password"` + SMTPHost string `form:"smtp_host"` + SMTPPort string `form:"smtp_port"` + Security bool `form:"security"` MFAMode int16 `form:"mfa_mode"` MFARequiredGracePeriod int64 `form:"mfa_grace_period"` @@ -120,14 +128,14 @@ func (c *Controller) HandleSettings() http.Handler { } if r.Method == http.MethodGet { - c.renderSettings(ctx, w, r, realm, nil, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, nil, nil, quotaLimit, quotaRemaining) return } var form FormData if err := controller.BindForm(w, r, &form); err != nil { flash.Error("Failed to process form: %v", err) - c.renderSettings(ctx, w, r, realm, nil, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, nil, nil, quotaLimit, quotaRemaining) return } @@ -159,6 +167,11 @@ func (c *Controller) HandleSettings() http.Handler { realm.SMSCountry = form.SMSCountry } + // Email + if form.Email { + realm.UseSystemEmailConfig = form.UseSystemEmailConfig + } + // Security if form.Security { realm.EmailVerifiedMode = database.AuthRequirement(form.EmailVerifiedMode) @@ -171,7 +184,7 @@ func (c *Controller) HandleSettings() http.Handler { if err != nil { realm.AddError("allowedCIDRsAdminAPI", err.Error()) flash.Error("Failed to update realm") - c.renderSettings(ctx, w, r, realm, nil, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, nil, nil, quotaLimit, quotaRemaining) return } realm.AllowedCIDRsAdminAPI = allowedCIDRsAdminADPI @@ -180,7 +193,7 @@ func (c *Controller) HandleSettings() http.Handler { if err != nil { realm.AddError("allowedCIDRsAPIServer", err.Error()) flash.Error("Failed to update realm") - c.renderSettings(ctx, w, r, realm, nil, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, nil, nil, quotaLimit, quotaRemaining) return } realm.AllowedCIDRsAPIServer = allowedCIDRsAPIServer @@ -189,7 +202,7 @@ func (c *Controller) HandleSettings() http.Handler { if err != nil { realm.AddError("allowedCIDRsServer", err.Error()) flash.Error("Failed to update realm") - c.renderSettings(ctx, w, r, realm, nil, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, nil, nil, quotaLimit, quotaRemaining) return } realm.AllowedCIDRsServer = allowedCIDRsServer @@ -230,7 +243,7 @@ func (c *Controller) HandleSettings() http.Handler { // Save realm if err := c.db.SaveRealm(realm, currentUser); err != nil { flash.Error("Failed to update realm: %v", err) - c.renderSettings(ctx, w, r, realm, nil, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, nil, nil, quotaLimit, quotaRemaining) return } @@ -252,7 +265,7 @@ func (c *Controller) HandleSettings() http.Handler { if err := c.db.SaveSMSConfig(smsConfig); err != nil { flash.Error("Failed to update realm: %v", err) - c.renderSettings(ctx, w, r, realm, smsConfig, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, smsConfig, nil, quotaLimit, quotaRemaining) return } } else { @@ -268,7 +281,49 @@ func (c *Controller) HandleSettings() http.Handler { if err := c.db.SaveSMSConfig(smsConfig); err != nil { flash.Error("Failed to update realm: %v", err) - c.renderSettings(ctx, w, r, realm, smsConfig, quotaLimit, quotaRemaining) + c.renderSettings(ctx, w, r, realm, smsConfig, nil, quotaLimit, quotaRemaining) + return + } + } + } + + // Email + if form.Email && !form.UseSystemEmailConfig { + // Fetch the existing Email config record, if one exists + emailConfig, err := realm.EmailConfig(c.db) + if err != nil && !database.IsNotFound(err) { + controller.InternalError(w, r, c.h, err) + return + } + if emailConfig != nil && !emailConfig.IsSystem { + // We have an existing record and the existing record is NOT the system + // record. + emailConfig.ProviderType = email.ProviderTypeSMTP + emailConfig.SMTPAccount = form.SMTPAccount + emailConfig.SMTPPassword = form.SMTPPassword + emailConfig.SMTPHost = form.SMTPHost + emailConfig.SMTPPort = form.SMTPPort + + if err := c.db.SaveEmailConfig(emailConfig); err != nil { + flash.Error("Failed to update realm: %v", err) + c.renderSettings(ctx, w, r, realm, nil, emailConfig, quotaLimit, quotaRemaining) + return + } + } else { + // There's no record or the existing record was the system config so we + // want to create our own. + emailConfig := &database.EmailConfig{ + RealmID: realm.ID, + ProviderType: email.ProviderTypeSMTP, + SMTPAccount: form.SMTPAccount, + SMTPPassword: form.SMTPPassword, + SMTPHost: form.SMTPHost, + SMTPPort: form.SMTPPort, + } + + if err := c.db.SaveEmailConfig(emailConfig); err != nil { + flash.Error("Failed to update realm: %v", err) + c.renderSettings(ctx, w, r, realm, nil, emailConfig, quotaLimit, quotaRemaining) return } } @@ -294,7 +349,9 @@ func (c *Controller) HandleSettings() http.Handler { }) } -func (c *Controller) renderSettings(ctx context.Context, w http.ResponseWriter, r *http.Request, realm *database.Realm, smsConfig *database.SMSConfig, quotaLimit, quotaRemaining uint64) { +func (c *Controller) renderSettings( + ctx context.Context, w http.ResponseWriter, r *http.Request, realm *database.Realm, + smsConfig *database.SMSConfig, emailConfig *database.EmailConfig, quotaLimit, quotaRemaining uint64) { if smsConfig == nil { var err error smsConfig, err = realm.SMSConfig(c.db) @@ -307,6 +364,18 @@ func (c *Controller) renderSettings(ctx context.Context, w http.ResponseWriter, } } + if emailConfig == nil { + var err error + emailConfig, err = realm.EmailConfig(c.db) + if err != nil { + if !database.IsNotFound(err) { + controller.InternalError(w, r, c.h, err) + return + } + emailConfig = &database.EmailConfig{SMTPPort: "587"} + } + } + // Don't pass through the system config to the template - we don't want to // risk accidentally rendering its ID or values since the realm should never // see these values. However, we have to go lookup the actual SMS config @@ -328,9 +397,26 @@ func (c *Controller) renderSettings(ctx context.Context, w http.ResponseWriter, } } + if emailConfig.IsSystem { + var tmpRealm database.Realm + tmpRealm.ID = realm.ID + tmpRealm.UseSystemEmailConfig = false + + var err error + emailConfig, err = tmpRealm.EmailConfig(c.db) + if err != nil { + if !database.IsNotFound(err) { + controller.InternalError(w, r, c.h, err) + return + } + emailConfig = new(database.EmailConfig) + } + } + m := controller.TemplateMapFromContext(ctx) m["realm"] = realm m["smsConfig"] = smsConfig + m["emailConfig"] = emailConfig m["countries"] = database.Countries m["testTypes"] = map[string]database.TestType{ "confirmed": database.TestTypeConfirmed, diff --git a/pkg/controller/user/controller.go b/pkg/controller/user/controller.go index 0974dc99c..9e345d230 100644 --- a/pkg/controller/user/controller.go +++ b/pkg/controller/user/controller.go @@ -23,7 +23,6 @@ import ( "github.com/google/exposure-notifications-verification-server/pkg/cache" "github.com/google/exposure-notifications-verification-server/pkg/config" "github.com/google/exposure-notifications-verification-server/pkg/database" - "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/render" "github.com/google/exposure-notifications-server/pkg/logging" @@ -36,7 +35,6 @@ type Controller struct { cacher cache.Cacher firebaseInternal *firebase.Client client *auth.Client - emailer email.Provider config *config.ServerConfig db *database.Database h *render.Renderer @@ -48,7 +46,6 @@ func New( ctx context.Context, firebaseInternal *firebase.Client, client *auth.Client, - emailer email.Provider, cacher cache.Cacher, config *config.ServerConfig, db *database.Database, @@ -59,7 +56,6 @@ func New( cacher: cacher, firebaseInternal: firebaseInternal, client: client, - emailer: emailer, config: config, db: db, h: h, diff --git a/pkg/controller/user/importbatch.go b/pkg/controller/user/importbatch.go index a6c169686..edcbe7388 100644 --- a/pkg/controller/user/importbatch.go +++ b/pkg/controller/user/importbatch.go @@ -15,7 +15,6 @@ package user import ( - "context" "errors" "fmt" "net/http" @@ -107,34 +106,3 @@ func (c *Controller) HandleImportBatch() http.Handler { c.h.RenderJSON(w, http.StatusOK, response) }) } - -func (c *Controller) sendInvitation(ctx context.Context, toEmail string) error { - // Send email with emailer - if c.emailer != nil { - realmName := "" - if realm := controller.RealmFromContext(ctx); realm != nil { - realmName = realm.Name - } - - from := c.emailer.From() - message, err := controller.ComposeInviteEmail(ctx, c.h, c.client, toEmail, from, realmName) - if err != nil { - c.logger.Warnw("failed composing invitation", "error", err) - return fmt.Errorf("failed composing invitation: %w", err) - } - if err := c.emailer.SendEmail(ctx, toEmail, message); err != nil { - c.logger.Warnw("failed sending invitation", "error", err) - return fmt.Errorf("failed sending invitation: %w", err) - } - - return nil - } - - // Fallback to Firebase - - if err := c.firebaseInternal.SendNewUserInvitation(ctx, toEmail); err != nil { - c.logger.Warnw("failed sending invitation", "error", err) - return fmt.Errorf("failed sending invitation: %w", err) - } - return nil -} diff --git a/pkg/controller/user/send_invite.go b/pkg/controller/user/send_invite.go new file mode 100644 index 000000000..a3773abd2 --- /dev/null +++ b/pkg/controller/user/send_invite.go @@ -0,0 +1,44 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package user + +import ( + "context" + "fmt" + + "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/database" + "github.com/google/exposure-notifications-verification-server/pkg/email" +) + +func (c *Controller) sendInvitation(ctx context.Context, toEmail string) error { + compose := func(emailer email.Provider, realm *database.Realm, toEmail string) ([]byte, error) { + return controller.ComposeInviteEmail(ctx, c.h, c.client, toEmail, emailer.From(), realm.Name) + } + sent, err := controller.SendRealmEmail(ctx, c.db, compose, toEmail) + if err != nil { + c.logger.Warnw("failed sending invitation", "error", err) + return fmt.Errorf("failed sending invitation: %w", err) + } + if !sent { + // fallback to Firebase if not SMTP found + if err := c.firebaseInternal.SendNewUserInvitation(ctx, toEmail); err != nil { + c.logger.Warnw("failed sending invitation", "error", err) + return fmt.Errorf("failed sending invitation: %w", err) + } + } + + return nil +} diff --git a/pkg/database/database.go b/pkg/database/database.go index 0a457d578..136b0adeb 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -196,6 +196,15 @@ func (db *Database) OpenWithCacher(ctx context.Context, cacher cache.Cacher) err rawDB.Callback().Query().After("gorm:after_query").Register("sms_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "sms_configs", "TwilioAuthToken")) + // Email configs + rawDB.Callback().Create().Before("gorm:create").Register("email_configs:encrypt", callbackKMSEncrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + rawDB.Callback().Create().After("gorm:create").Register("email_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + + rawDB.Callback().Update().Before("gorm:update").Register("email_configs:encrypt", callbackKMSEncrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + rawDB.Callback().Update().After("gorm:update").Register("email_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + + rawDB.Callback().Query().After("gorm:after_query").Register("email_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + // Verification codes rawDB.Callback().Create().Before("gorm:create").Register("verification_codes:hmac_code", callbackHMAC(ctx, db.GenerateVerificationCodeHMAC, "verification_codes", "code")) rawDB.Callback().Create().Before("gorm:create").Register("verification_codes:hmac_long_code", callbackHMAC(ctx, db.GenerateVerificationCodeHMAC, "verification_codes", "long_code")) @@ -247,7 +256,7 @@ func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) || gorm.IsRecordNotFoundError(err) } -// callbackIncremementMetric incremements the provided metric +// callbackIncrementMetric increments the provided metric func callbackIncrementMetric(ctx context.Context, m *stats.Int64Measure, table string) func(scope *gorm.Scope) { return func(scope *gorm.Scope) { if scope.TableName() != table { diff --git a/pkg/database/email_config.go b/pkg/database/email_config.go new file mode 100644 index 000000000..8f1b99491 --- /dev/null +++ b/pkg/database/email_config.go @@ -0,0 +1,123 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "context" + "fmt" + "strings" + + "github.com/google/exposure-notifications-verification-server/pkg/email" + "github.com/jinzhu/gorm" +) + +// EmailConfig represents and email configuration. +type EmailConfig struct { + gorm.Model + Errorable + + // email Config belongs to exactly one realm. + RealmID uint `gorm:"type:integer"` + + // ProviderType is the email provider type - it's used to determine the + // underlying configuration. + ProviderType email.ProviderType `gorm:"type:varchar(100)"` + + SMTPAccount string `gorm:"type:varchar(250)"` + SMTPHost string `gorm:"type:varchar(250)"` + SMTPPort string `gorm:"type:varchar(250)"` + + // SMTPPassword is encrypted/decrypted automatically by callbacks. The + // cache fields exist as optimizations. + SMTPPassword string `gorm:"type:varchar(250)" json:"-"` // ignored by zap's JSON formatter + SMTPPasswordPlaintextCache string `gorm:"-"` + SMTPPasswordCiphertextCache string `gorm:"-"` + + // IsSystem determines if this is a system-level email configuration. There can + // only be one system-level email configuration. + IsSystem bool `gorm:"type:bool; not null; default:false;"` +} + +func (e *EmailConfig) BeforeSave(tx *gorm.DB) error { + // Email config is all or nothing + if (e.SMTPAccount != "" || e.SMTPPassword != "" || e.SMTPHost != "") && + (e.SMTPAccount == "" || e.SMTPPassword == "" || e.SMTPHost == "") { + e.AddError("SMTPAccount", "all must be specified or all must be blank") + e.AddError("SMTPPassword", "all must be specified or all must be blank") + e.AddError("SMTPHost", "all must be specified or all must be blank") + } + + if len(e.Errors()) > 0 { + return fmt.Errorf("email config validation failed: %s", strings.Join(e.ErrorMessages(), ", ")) + } + return nil +} + +func (e *EmailConfig) Provider() (email.Provider, error) { + ctx := context.Background() + provider, err := email.ProviderFor(ctx, &email.Config{ + ProviderType: email.ProviderType(e.ProviderType), + User: e.SMTPAccount, + Password: e.SMTPPassword, + SMTPHost: e.SMTPHost, + SMTPPort: e.SMTPPort, + }) + if err != nil { + return nil, err + } + return provider, nil +} + +// SystemEmailConfig returns the system email config, if one exists +func (db *Database) SystemEmailConfig() (*EmailConfig, error) { + var emailConfig EmailConfig + if err := db.db. + Model(&EmailConfig{}). + Where("is_system IS TRUE"). + First(&emailConfig). + Error; err != nil { + return nil, err + } + return &emailConfig, nil +} + +// SaveEmailConfig creates or updates an email configuration record. +func (db *Database) SaveEmailConfig(s *EmailConfig) error { + if s.SMTPAccount == "" && s.SMTPPassword == "" && s.SMTPHost == "" { + if db.db.NewRecord(s) { + // The fields are all blank, do not create the record. + return nil + } + + if s.IsSystem { + // We're about to delete the system email config, revoke everyone's + // permissions to use it. You would think there'd be a way to do this with + // gorm natively. You'd even find an example in the documentation that led + // you to believe that gorm does this. + // + // Narrator: gorm does not do this. + if err := db.db. + Exec(`UPDATE realms SET can_use_system_email_config = FALSE, use_system_email_config = FALSE`). + Error; err != nil { + return err + } + } + + // The fields were blank, delete the email config. + return db.db.Unscoped().Delete(s).Error + } + + return db.db.Save(s).Error +} diff --git a/pkg/database/email_config_test.go b/pkg/database/email_config_test.go new file mode 100644 index 000000000..63b0f755f --- /dev/null +++ b/pkg/database/email_config_test.go @@ -0,0 +1,152 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "testing" + + "github.com/google/exposure-notifications-verification-server/pkg/email" +) + +func TestEmailConfig_Lifecycle(t *testing.T) { + t.Parallel() + + db := NewTestDatabase(t) + + // Create realm + realmName := t.Name() + realm, err := db.CreateRealm(realmName) + if err != nil { + t.Fatalf("unable to cerate test realm: %v", err) + } + + // Initial config should be nil + { + got, err := realm.EmailConfig(db) + if !IsNotFound(err) { + t.Errorf("expected %#v to be %#v", err, "not found") + } + if got != nil { + t.Errorf("expected %#v to be %#v", got, nil) + } + } + + // Create email config on the realm + emailConfig := &EmailConfig{ + RealmID: realm.ID, + ProviderType: email.ProviderType(email.ProviderTypeSMTP), + SMTPAccount: "noreply@sendemails.meh", + SMTPPassword: "my-secret-ref", + SMTPHost: "smtp.sendemails.meh", + } + if err := db.SaveEmailConfig(emailConfig); err != nil { + t.Fatal(err) + } + + // Get the realm to verify email configs are NOT preloaded + realm, err = db.FindRealm(realm.ID) + if err != nil { + t.Fatal(err) + } + + // Load the email config + { + got, err := realm.EmailConfig(db) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatalf("expected emailConfig, got %#v", got) + } + + if got, want := got.ID, emailConfig.ID; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.RealmID, emailConfig.RealmID; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.ProviderType, emailConfig.ProviderType; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.SMTPAccount, emailConfig.SMTPAccount; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.SMTPPassword, emailConfig.SMTPPassword; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.SMTPHost, emailConfig.SMTPHost; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + } + + // Update value + emailConfig.SMTPPassword = "banana123" + if err := db.SaveEmailConfig(emailConfig); err != nil { + t.Fatal(err) + } + + // Read back updated value + { + got, err := realm.EmailConfig(db) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatalf("expected EmailConfig, got %#v", got) + } + + if got, want := got.SMTPPassword, "banana123"; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + } +} + +func TestEmailProvider(t *testing.T) { + t.Parallel() + + db := NewTestDatabase(t) + + realm, err := db.CreateRealm("test-email-realm-1") + if err != nil { + t.Fatalf("realm create failed: %v", err) + } + + provider, err := realm.EmailProvider(db) + if !IsNotFound(err) { + t.Fatal(err) + } + if provider != nil { + t.Errorf("expected %v to be %v", provider, nil) + } + + emailConfig := &EmailConfig{ + RealmID: realm.ID, + ProviderType: email.ProviderType(email.ProviderTypeSMTP), + SMTPAccount: "noreply@sendemails.meh", + SMTPPassword: "my-secret-ref", + SMTPHost: "smtp.sendemails.meh", + } + if err := db.SaveEmailConfig(emailConfig); err != nil { + t.Fatal(err) + } + + provider, err = realm.EmailProvider(db) + if err != nil { + t.Fatal(err) + } + if provider == nil { + t.Errorf("expected %v to be not nil", provider) + } +} diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index 705ff5498..31e633e9b 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -1465,6 +1465,85 @@ func (db *Database) getMigrations(ctx context.Context) *gormigrate.Gormigrate { return nil }, }, + { + ID: "00060-AddEmailConfig", + Migrate: func(tx *gorm.DB) error { + logger.Debugw("adding email_configs table") + return tx.AutoMigrate(&EmailConfig{}).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.DropTable("email_configs").Error + }, + }, + { + ID: "00061-CreateSystemEmailConfig", + Migrate: func(tx *gorm.DB) error { + sqls := []string{ + // Add a new is_system boolean column and a constraint to ensure that + // only one row can have a value of true. + `CREATE UNIQUE INDEX IF NOT EXISTS uix_email_configs_is_system_true ON email_configs (is_system) WHERE (is_system IS TRUE)`, + + // Require realm_id be set on all rows except system configs, and + // ensure that realm_id is unique. + `ALTER TABLE email_configs DROP CONSTRAINT IF EXISTS nn_email_configs_realm_id`, + `DROP INDEX IF EXISTS nn_email_configs_realm_id`, + `ALTER TABLE email_configs ADD CONSTRAINT nn_email_configs_realm_id CHECK (is_system IS TRUE OR realm_id IS NOT NULL)`, + + `ALTER TABLE email_configs DROP CONSTRAINT IF EXISTS uix_email_configs_realm_id`, + `DROP INDEX IF EXISTS uix_email_configs_realm_id`, + `ALTER TABLE email_configs ADD CONSTRAINT uix_email_configs_realm_id UNIQUE (realm_id)`, + + // Realm option set by system admins to share the system Email config + // with the realm. + `ALTER TABLE realms ADD COLUMN IF NOT EXISTS can_use_system_email_config BOOL`, + `UPDATE realms SET can_use_system_email_config = FALSE WHERE can_use_system_email_config IS NULL`, + `ALTER TABLE realms ALTER COLUMN can_use_system_email_config SET DEFAULT FALSE`, + `ALTER TABLE realms ALTER COLUMN can_use_system_email_config SET NOT NULL`, + + // If true, the realm is set to use the system Email config. + `ALTER TABLE realms ADD COLUMN IF NOT EXISTS use_system_email_config BOOL`, + `UPDATE realms SET use_system_email_config = FALSE WHERE use_system_email_config IS NULL`, + `ALTER TABLE realms ALTER COLUMN use_system_email_config SET DEFAULT FALSE`, + `ALTER TABLE realms ALTER COLUMN use_system_email_config SET NOT NULL`, + + // Add templates + `ALTER TABLE realms ADD COLUMN IF NOT EXISTS email_invite_template text`, + `ALTER TABLE realms ADD COLUMN IF NOT EXISTS email_password_reset_template text`, + `ALTER TABLE realms ADD COLUMN IF NOT EXISTS email_verify_template text`, + } + + for _, sql := range sqls { + if err := tx.Exec(sql).Error; err != nil { + return err + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + sqls := []string{ + `ALTER TABLE email_configs DROP COLUMN IF EXISTS is_system`, + `DROP INDEX IF EXISTS uix_email_configs_is_system_true`, + `ALTER TABLE email_configs DROP CONSTRAINT IF EXISTS nn_email_configs_realm_id`, + `ALTER TABLE email_configs DROP CONSTRAINT IF EXISTS uix_email_configs_realm_id`, + + `ALTER TABLE realms DROP COLUMN IF EXISTS can_use_system_email_config`, + `ALTER TABLE realms DROP COLUMN IF EXISTS use_system_email_config`, + + `ALTER TABLE realms DROP COLUMN IF EXISTS email_invite_template`, + `ALTER TABLE realms DROP COLUMN IF EXISTS email_password_reset_template`, + `ALTER TABLE realms DROP COLUMN IF EXISTS email_verify_template`, + } + + for _, sql := range sqls { + if err := tx.Exec(sql).Error; err != nil { + return err + } + } + + return nil + }, + }, }) } diff --git a/pkg/database/realm.go b/pkg/database/realm.go index ad4317b86..d3aa59341 100644 --- a/pkg/database/realm.go +++ b/pkg/database/realm.go @@ -26,6 +26,7 @@ import ( "github.com/google/exposure-notifications-server/pkg/timeutils" "github.com/google/exposure-notifications-verification-server/pkg/digest" + "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/sms" "github.com/microcosm-cc/bluemonday" @@ -143,6 +144,26 @@ type Realm struct { // configuration, making it impossible to opt out of text message sending. UseSystemSMSConfig bool `gorm:"column:use_system_sms_config; type:bool; not null; default:false;"` + // EmailInviteTemplate is the template for inviting new users. + EmailInviteTemplate string `gorm:"type:text;"` + + // EmailPasswordResetTemplate is the template for resetting password. + EmailPasswordResetTemplate string `gorm:"type:text;"` + + // EmailVerifyTemplate is the template used for email verification. + EmailVerifyTemplate string `gorm:"type:text;"` + + // CanUseSystemEmailConfig is configured by system administrators to share the + // system email config with this realm. Note that the system email config could be + // empty and a local email config is preferred over the system value. + CanUseSystemEmailConfig bool `gorm:"column:can_use_system_email_config; type:bool; not null; default:false;"` + + // UseSystemEmailConfig is a realm-level configuration that lets a realm opt-out + // of sending email messages using the system-provided email configuration. + // Without this, a realm would always fallback to the system-level email + // configuration, making it impossible to opt out of text message sending. + UseSystemEmailConfig bool `gorm:"column:use_system_email_config; type:bool; not null; default:false;"` + // MFAMode represents the mode for Multi-Factor-Authorization requirements for the realm. MFAMode AuthRequirement `gorm:"type:smallint; not null; default: 0"` @@ -462,6 +483,38 @@ func (r *Realm) SMSProvider(db *Database) (sms.Provider, error) { return provider, nil } +// EmailConfig returns the email configuration for this realm, if one exists. If the +// realm is configured to use the system email configuration, that configuration +// is preferred. +func (r *Realm) EmailConfig(db *Database) (*EmailConfig, error) { + q := db.db. + Model(&EmailConfig{}). + Order("is_system DESC"). + Where("realm_id = ?", r.ID) + + if r.UseSystemEmailConfig { + q = q.Or("is_system IS TRUE") + } + + var emailConfig EmailConfig + if err := q.First(&emailConfig).Error; err != nil { + return nil, err + } + return &emailConfig, nil +} + +// EmailProvider returns the email provider for the realm. If no email configuration +// exists, it returns nil. If any errors occur creating the provider, they are +// returned. +func (r *Realm) EmailProvider(db *Database) (email.Provider, error) { + emailConfig, err := r.EmailConfig(db) + if err != nil { + return nil, err + } + + return emailConfig.Provider() +} + func (r *Realm) Audits(db *Database) ([]*AuditEntry, error) { var entries []*AuditEntry if err := db.db. diff --git a/pkg/database/sms_config.go b/pkg/database/sms_config.go index 5a73554e1..2c78b8e10 100644 --- a/pkg/database/sms_config.go +++ b/pkg/database/sms_config.go @@ -40,7 +40,7 @@ type SMSConfig struct { // TwilioAuthToken is encrypted/decrypted automatically by callbacks. The // cache fields exist as optimizations. - TwilioAuthToken string `gorm:"type:varchar(250)"` + TwilioAuthToken string `gorm:"type:varchar(250)" json:"-"` // ignored by zap's JSON formatter TwilioAuthTokenPlaintextCache string `gorm:"-"` TwilioAuthTokenCiphertextCache string `gorm:"-"` @@ -108,8 +108,3 @@ func (db *Database) SaveSMSConfig(s *SMSConfig) error { } return db.db.Save(s).Error } - -// DeleteSMSConfig removes an SMS configuration record. -func (db *Database) DeleteSMSConfig(s *SMSConfig) error { - return db.db.Unscoped().Delete(s).Error -} diff --git a/pkg/email/config.go b/pkg/email/config.go index 29d1c0613..ea76092c2 100644 --- a/pkg/email/config.go +++ b/pkg/email/config.go @@ -43,7 +43,7 @@ type Config struct { ProviderType ProviderType User string `env:"EMAIL_USER"` - Password string `env:"EMAIL_PASSWORD"` + Password string `env:"EMAIL_PASSWORD" json:"-"` // ignored by zap's JSON formatter SMTPHost string `env:"EMAIL_SMTP_HOST"` // SMTPPort defines the email port to connect to.