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

Commit

Permalink
Allow custom email server for invitations (#796)
Browse files Browse the repository at this point in the history
* Allow custom email server for invitations

* strings

* fix lint

* lint2

* fix returns

* fix message

* break

* has creds
  • Loading branch information
whaught authored Oct 9, 2020
1 parent d08dcf0 commit 9dc1498
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 20 deletions.
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.HasSMTPCreds() {
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
}
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,
}
}
68 changes: 68 additions & 0 deletions pkg/email/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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"`
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
}

func (c *Config) HasSMTPCreds() bool {
return c.User != "" && c.Password != "" && c.SMTPHost != "" && c.SMTPPort != ""
}

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)

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

User string
Password string
SMTPHost string
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
header["To"] = toEmail
header["Subject"] = "COVID-19 Verification Server Invitation"

header["MIME-Version"] = "1.0"
header["Content-Type"] = `text/html; charset="utf-8"`
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
}

// Message.
body := fmt.Sprintf(
`You've been invited to the COVID-19 Verification Server.
Use the link below to set up your account.<br>%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
}
return nil
}

0 comments on commit 9dc1498

Please sign in to comment.