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.
- Send verification email
+
{{if ne .currentRealm.EmailVerifiedMode.String "required"}}
Skip for now
@@ -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.
+
+
+
+
+{{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
SMS
+
+ Email
+
Security
@@ -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.