Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Allow custom email server for invitations #796

Merged
merged 8 commits into from
Oct 9, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ 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"
Expand Down Expand Up @@ -144,6 +145,16 @@ func realMain(ctx context.Context) error {
return fmt.Errorf("failed to configure internal firebase client: %w", err)
}

// Setup server emailer
cfg.Email.ProviderType = email.ProviderTypeFirebase
if cfg.Email.SmtpHost != "" {
cfg.Email.ProviderType = email.ProviderTypeSmtp
}
emailer, err := email.ProviderFor(ctx, &cfg.Email, auth)
if err != nil {
return fmt.Errorf("failed to configure internal firebase client: %w", err)
}

// Create the router
r := mux.NewRouter()

Expand Down Expand Up @@ -366,7 +377,7 @@ func realMain(ctx context.Context) error {
userSub.Use(requireMFA)
userSub.Use(rateLimit)

userController := user.New(ctx, firebaseInternal, auth, cacher, cfg, db, h)
userController := user.New(ctx, auth, emailer, cacher, cfg, db, h)
userSub.Handle("", userController.HandleIndex()).Methods("GET")
userSub.Handle("", userController.HandleIndex()).
Queries("offset", "{[0-9]*}", "email", "").Methods("GET")
Expand Down
4 changes: 3 additions & 1 deletion pkg/config/server_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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"
Expand All @@ -40,7 +41,7 @@ type PasswordRequirementsConfig struct {
Special int `env:"MIN_PWD_SPECIAL,default=1"`
}

// HasRequirements is true if any requirments are set.
// HasRequirements is true if any requirements are set.
func (c *PasswordRequirementsConfig) HasRequirements() bool {
return c.Length > 0 || c.Uppercase > 0 || c.Lowercase > 0 || c.Number > 0 || c.Special > 0
}
Expand All @@ -51,6 +52,7 @@ type ServerConfig struct {
Database database.Config
Observability observability.Config
Cache cache.Config
Email email.Config

Port string `env:"PORT,default=8080"`

Expand Down
2 changes: 1 addition & 1 deletion pkg/controller/user/importbatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func (c *Controller) HandleImportBatch() http.Handler {
continue
} else if created {
newUsers = append(newUsers, &batchUser)
if err := c.firebaseInternal.SendNewUserInvitation(ctx, user.Email); err != nil {
if err := c.emailer.SendNewUserInvitation(ctx, user.Email); err != nil {
batchErr = multierror.Append(batchErr, err)
continue
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/controller/user/reset_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func (c *Controller) ensureFirebaseUserExists(ctx context.Context, user *databas
}

if created {
if err := c.firebaseInternal.SendNewUserInvitation(ctx, user.Email); err != nil {
err := c.emailer.SendNewUserInvitation(ctx, user.Email)
if err != nil {
flash.Error("Could not send new user invitation: %v", err)
return true, err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dunno if we want to wrap errors. We're typically trying to.

}
Expand Down
32 changes: 16 additions & 16 deletions pkg/controller/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ 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"
"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"
Expand All @@ -32,33 +32,33 @@ import (

// Controller manages users
type Controller struct {
cacher cache.Cacher
firebaseInternal *firebase.Client
client *auth.Client
config *config.ServerConfig
db *database.Database
h *render.Renderer
logger *zap.SugaredLogger
cacher cache.Cacher
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,
config *config.ServerConfig,
db *database.Database,
h *render.Renderer) *Controller {
logger := logging.FromContext(ctx)

return &Controller{
cacher: cacher,
firebaseInternal: firebaseInternal,
client: client,
config: config,
db: db,
h: h,
logger: logger,
cacher: cacher,
client: client,
emailer: emailer,
config: config,
db: db,
h: h,
logger: logger,
}
}
64 changes: 64 additions & 0 deletions pkg/email/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 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

import (
"context"
"fmt"

"firebase.google.com/go/auth"
"github.com/google/exposure-notifications-server/pkg/secrets"
)

// ProviderType represents a type of email provider.
type ProviderType string

const (
ProviderTypeNoop ProviderType = "NOOP"
ProviderTypeFirebase ProviderType = "FIREBASE"
ProviderTypeSmtp ProviderType = "SIMPLE_SMTP"
)

// Config represents the env var based configuration for email SMTP server connection.
type Config struct {
ProviderType ProviderType

User string `env:"EMAIL_USER" json:",omitempty"`
Password string `env:"EMAIL_PASSWORD" json:",omitempty"`
SmtpHost string `env:"EMAIL_SMTP_HOST" json:",omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dunno why these are separate, especially when you just combine them later....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines like smtp.PlainAuth("", s.User, s.Password, s.SmtpHost) use the host alone, we add on the port only for the connection

SmtpPort string `env:"EMAIL_SMTP_PORT" json:",omitempty"`

// Secrets is the secret configuration. This is used to resolve values that
// are actually pointers to secrets before returning them to the caller. The
// table implementation is the source of truth for which values are secrets
// and which are plaintext.
Secrets secrets.Config
}

type Provider interface {
// SendNewUserInvitation sends an invite to join the server.
SendNewUserInvitation(ctx context.Context, email string) error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be better to abstract the email content from the email sending. I'd expect the email provider to only know how to SendEmail:

type Provider interface {
  SendEmail(ctx context.Context, subject, to, cc, bcc string, body io.Reader)
}

}

func ProviderFor(ctx context.Context, c *Config, auth *auth.Client) (Provider, error) {
switch typ := c.ProviderType; typ {
case ProviderTypeFirebase:
return NewFirebase(ctx)
case ProviderTypeSmtp:
return NewSmtp(ctx, c.User, c.Password, c.SmtpHost, c.SmtpPort, auth)
default:
return nil, fmt.Errorf("unknown email provider type: %v", typ)
}
}
34 changes: 34 additions & 0 deletions pkg/email/firebase.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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
}
94 changes: 94 additions & 0 deletions pkg/email/smtp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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 (
"bytes"
"context"
"fmt"
"mime/quotedprintable"
"net/smtp"

"firebase.google.com/go/auth"
)

var _ Provider = (*SmtpProvider)(nil)

// Smtp sends messages via an external SMTP server.
type SmtpProvider struct {
FirebaseAuth *auth.Client

User string
Password string
SmtpHost string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

SmtpPort string
}

// NewSmtp creates a new Smtp email sender with the given auth.
func NewSmtp(ctx context.Context, user, password, host, port string, auth *auth.Client) (Provider, error) {
return &SmtpProvider{
FirebaseAuth: auth,
User: user,
Password: password,
SmtpHost: host,
SmtpPort: port,
}, nil
}

// SendNewUserInvitation sends a password reset email to the user.
func (s *SmtpProvider) SendNewUserInvitation(ctx context.Context, toEmail string) error {
// Header
header := make(map[string]string)
header["From"] = s.User
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should try to make most of these constants. Both the map keys and many of their values are constant.

header["To"] = toEmail
header["Subject"] = "COVID-19 Verification Server Invitation"

header["MIME-Version"] = "1.0"
header["Content-Type"] = fmt.Sprintf("%s; charset=\"utf-8\"", "text/html")
whaught marked this conversation as resolved.
Show resolved Hide resolved
header["Content-Disposition"] = "inline"
header["Content-Transfer-Encoding"] = "quoted-printable"

headerMessage := ""
for key, value := range header {
headerMessage += fmt.Sprintf("%s: %s\r\n", key, value)
}

inviteLink, err := s.FirebaseAuth.PasswordResetLink(ctx, toEmail)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrap

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump

}

// Message.
body := fmt.Sprintf(
"You've been invited to the COVID-19 Verification Server. Use the link below to set up your account."+
" \n\n %s", inviteLink)
var bodyMessage bytes.Buffer
temp := quotedprintable.NewWriter(&bodyMessage)
temp.Write([]byte(body))
temp.Close()

finalMessage := headerMessage + "\r\n" + bodyMessage.String()

// Authentication.
auth := smtp.PlainAuth("", s.User, s.Password, s.SmtpHost)

// Sending email.
err = smtp.SendMail(s.SmtpHost+":"+s.SmtpPort, auth, s.User, []string{toEmail}, []byte(finalMessage))
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrap, or don't bother setting err.

}
return nil
}