diff --git a/cmd/server/main.go b/cmd/server/main.go index c618f6766..1d56605bf 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -160,13 +160,13 @@ func realMain(ctx context.Context) error { } // Setup server emailer - cfg.Email.ProviderType = email.ProviderTypeFirebase + var emailer email.Provider if cfg.Email.HasSMTPCreds() { cfg.Email.ProviderType = email.ProviderTypeSMTP - } - emailer, err := email.ProviderFor(ctx, &cfg.Email, h, auth) - if err != nil { - return fmt.Errorf("failed to configure internal firebase client: %w", err) + emailer, err = email.ProviderFor(ctx, &cfg.Email) + if err != nil { + return fmt.Errorf("failed to configure internal firebase client: %w", err) + } } // Rate limiting @@ -377,7 +377,7 @@ func realMain(ctx context.Context) error { userSub.Use(requireMFA) userSub.Use(rateLimit) - userController := user.New(ctx, auth, emailer, cacher, cfg, db, h) + userController := user.New(ctx, firebaseInternal, auth, emailer, 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/controller/email_compose.go b/pkg/controller/email_compose.go new file mode 100644 index 000000000..0fcfef35c --- /dev/null +++ b/pkg/controller/email_compose.go @@ -0,0 +1,53 @@ +// 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 controller + +import ( + "context" + "fmt" + + "firebase.google.com/go/auth" + "github.com/google/exposure-notifications-verification-server/pkg/render" +) + +// ComposeInviteEmail uses the renderer and auth client to generate a password reset link +// and emit an invite email +func ComposeInviteEmail( + ctx context.Context, + h *render.Renderer, + auth *auth.Client, toEmail, fromEmail, realmName string) ([]byte, error) { + inviteLink, err := auth.PasswordResetLink(ctx, toEmail) + if err != nil { + return nil, fmt.Errorf("failed generating reset link: %w", err) + } + + // Compose message + message, err := h.RenderEmail("email/invite", + struct { + ToEmail string + FromEmail string + InviteLink string + RealmName string + }{ + ToEmail: toEmail, + FromEmail: fromEmail, + InviteLink: inviteLink, + RealmName: realmName, + }) + if err != nil { + return nil, fmt.Errorf("failed rendering invite template: %w", err) + } + return message, nil +} diff --git a/pkg/controller/user/user.go b/pkg/controller/user/controller.go similarity index 71% rename from pkg/controller/user/user.go rename to pkg/controller/user/controller.go index afca3f64f..0974dc99c 100644 --- a/pkg/controller/user/user.go +++ b/pkg/controller/user/controller.go @@ -19,6 +19,7 @@ import ( "context" "firebase.google.com/go/auth" + "github.com/google/exposure-notifications-verification-server/internal/firebase" "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" @@ -32,18 +33,20 @@ import ( // Controller manages users type Controller struct { - cacher cache.Cacher - client *auth.Client - emailer email.Provider - config *config.ServerConfig - db *database.Database - h *render.Renderer - logger *zap.SugaredLogger + cacher cache.Cacher + firebaseInternal *firebase.Client + client *auth.Client + emailer email.Provider + config *config.ServerConfig + db *database.Database + h *render.Renderer + logger *zap.SugaredLogger } // New creates a new controller for managing users. func New( ctx context.Context, + firebaseInternal *firebase.Client, client *auth.Client, emailer email.Provider, cacher cache.Cacher, @@ -53,12 +56,13 @@ func New( logger := logging.FromContext(ctx) return &Controller{ - cacher: cacher, - client: client, - emailer: emailer, - config: config, - db: db, - h: h, - logger: logger, + cacher: cacher, + firebaseInternal: firebaseInternal, + client: client, + emailer: emailer, + config: config, + db: db, + h: h, + logger: logger, } } diff --git a/pkg/controller/user/importbatch.go b/pkg/controller/user/importbatch.go index c26d66340..f835afd8b 100644 --- a/pkg/controller/user/importbatch.go +++ b/pkg/controller/user/importbatch.go @@ -15,7 +15,9 @@ package user import ( + "context" "errors" + "fmt" "net/http" "github.com/google/exposure-notifications-verification-server/pkg/api" @@ -74,8 +76,8 @@ func (c *Controller) HandleImportBatch() http.Handler { continue } else if created { newUsers = append(newUsers, &batchUser) - if err := c.emailer.SendNewUserInvitation(ctx, user.Email); err != nil { - c.logger.Warnw("failed sending invitation", "error", err) + + if err := c.sendInvitation(ctx, user.Email); err != nil { batchErr = multierror.Append(batchErr, errors.New("send invitation failed")) continue } @@ -105,3 +107,34 @@ 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/reset_password.go b/pkg/controller/user/reset_password.go index b308249a4..318319efd 100644 --- a/pkg/controller/user/reset_password.go +++ b/pkg/controller/user/reset_password.go @@ -49,9 +49,7 @@ func (c *Controller) ensureFirebaseUserExists(ctx context.Context, user *databas } if created { - err := c.emailer.SendNewUserInvitation(ctx, user.Email) - if err != nil { - c.logger.Warnw("failed sending invitation", "error", err) + if err := c.sendInvitation(ctx, user.Email); err != nil { flash.Error("Could not send new user invitation.") return true, err } diff --git a/pkg/email/config.go b/pkg/email/config.go index 48f99e954..29d1c0613 100644 --- a/pkg/email/config.go +++ b/pkg/email/config.go @@ -18,9 +18,7 @@ import ( "context" "fmt" - "firebase.google.com/go/auth" "github.com/google/exposure-notifications-server/pkg/secrets" - "github.com/google/exposure-notifications-verification-server/pkg/render" ) // ProviderType represents a type of email provider. @@ -30,10 +28,6 @@ const ( // ProviderTypeNoop is a no-op provider ProviderTypeNoop ProviderType = "NOOP" - // ProviderTypeFirebase falls back to firebase's default email template. - // it uses password-reset rather than a true invitation. - ProviderTypeFirebase ProviderType = "FIREBASE" - // ProviderTypeSMTP composes emails and sends them via an external SMTP server. ProviderTypeSMTP ProviderType = "SIMPLE_SMTP" ) @@ -65,8 +59,11 @@ type Config struct { // Provider is an interface for email-sending mechanisms. type Provider interface { - // SendNewUserInvitation sends an invite to join the server. - SendNewUserInvitation(ctx context.Context, email string) error + // SendEmail sends an email with the given message. + SendEmail(ctx context.Context, toEmail string, message []byte) error + + // From returns who shown as the sender of the email. + From() string } // HasSMTPCreds returns true if required fields for connecting to SMTP are set. @@ -75,14 +72,12 @@ func (c *Config) HasSMTPCreds() bool { } // ProviderFor creates an email provider given a Config. -func ProviderFor(ctx context.Context, c *Config, h *render.Renderer, auth *auth.Client) (Provider, error) { +func ProviderFor(ctx context.Context, c *Config) (Provider, error) { switch typ := c.ProviderType; typ { case ProviderTypeNoop: return NewNoop(), nil - case ProviderTypeFirebase: - return NewFirebase(ctx) case ProviderTypeSMTP: - return NewSMTP(ctx, c.User, c.Password, c.SMTPHost, c.SMTPPort, h, auth), nil + return NewSMTP(ctx, c.User, c.Password, c.SMTPHost, c.SMTPPort), nil default: return nil, fmt.Errorf("unknown email provider type: %v", typ) } diff --git a/pkg/email/firebase.go b/pkg/email/firebase.go deleted file mode 100644 index 3ca9d8a4b..000000000 --- a/pkg/email/firebase.go +++ /dev/null @@ -1,34 +0,0 @@ -// 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 email is logic for sending email invitations -package email - -import ( - "context" - "fmt" - - "github.com/google/exposure-notifications-verification-server/internal/firebase" -) - -var _ Provider = (*firebase.Client)(nil) - -// NewFirebase creates a new SMTP email sender with the given auth. -func NewFirebase(ctx context.Context) (Provider, error) { - firebaseInternal, err := firebase.New(ctx) - if err != nil { - return nil, fmt.Errorf("failed to configure internal firebase client: %w", err) - } - return firebaseInternal, nil -} diff --git a/pkg/email/noop.go b/pkg/email/noop.go index d6580edb7..3b24cf386 100644 --- a/pkg/email/noop.go +++ b/pkg/email/noop.go @@ -31,9 +31,14 @@ func NewNoop() Provider { return &NoopProvider{} } -// SendNewUserInvitation sends a password reset email to the user. -func (s *NoopProvider) SendNewUserInvitation(ctx context.Context, toEmail string) error { +// SendEmail sends a password reset email to the user. +func (s *NoopProvider) SendEmail(ctx context.Context, toEmail string, message []byte) error { logger := logging.FromContext(ctx) - logger.Infow("Noop send invitation", "email", toEmail) + logger.Infow("Noop send email", "email", toEmail) return nil } + +// From returns who the invitation should be send from. +func (s *NoopProvider) From() string { + return "" +} diff --git a/pkg/email/smtp.go b/pkg/email/smtp.go index cd48727ee..e1feadcc8 100644 --- a/pkg/email/smtp.go +++ b/pkg/email/smtp.go @@ -19,20 +19,13 @@ import ( "context" "net/smtp" - "firebase.google.com/go/auth" "github.com/google/exposure-notifications-server/pkg/logging" - "github.com/google/exposure-notifications-verification-server/pkg/controller" - "github.com/google/exposure-notifications-verification-server/pkg/render" ) var _ Provider = (*SMTPProvider)(nil) // SMTPProvider sends messages via an external SMTP server. type SMTPProvider struct { - FirebaseAuth *auth.Client - - Renderer *render.Renderer - User string Password string SMTPHost string @@ -40,45 +33,17 @@ type SMTPProvider struct { } // NewSMTP creates a new Smtp email sender with the given auth. -func NewSMTP(ctx context.Context, user, password, host, port string, h *render.Renderer, auth *auth.Client) Provider { +func NewSMTP(ctx context.Context, user, password, host, port string) Provider { return &SMTPProvider{ - FirebaseAuth: auth, - Renderer: h, - User: user, - Password: password, - SMTPHost: host, - SMTPPort: port, + User: user, + Password: password, + SMTPHost: host, + SMTPPort: port, } } -// SendNewUserInvitation sends a password reset email to the user. -func (s *SMTPProvider) SendNewUserInvitation(ctx context.Context, toEmail string) error { - inviteLink, err := s.FirebaseAuth.PasswordResetLink(ctx, toEmail) - if err != nil { - return err - } - - realmName := "" - if realm := controller.RealmFromContext(ctx); realm != nil { - realmName = realm.Name - } - - // Compose message - message, err := s.Renderer.RenderEmail("email/invite", - struct { - ToEmail string - FromEmail string - InviteLink string - RealmName string - }{ - ToEmail: toEmail, - FromEmail: s.User, - InviteLink: inviteLink, - RealmName: realmName, - }) - if err != nil { - return err - } +// SendEmail sends an email to the user. +func (s *SMTPProvider) SendEmail(ctx context.Context, toEmail string, message []byte) error { // Authentication. auth := smtp.PlainAuth("", s.User, s.Password, s.SMTPHost) @@ -96,3 +61,8 @@ func (s *SMTPProvider) sendMail(ctx context.Context, auth smtp.Auth, toEmail str logger.Warnw("failed to send invitation email", "error", err) } } + +// From returns who shown as the sender of the email. +func (s *SMTPProvider) From() string { + return s.User +}