From a220e90daf0d73322183e600d6814465a4c46529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Fri, 9 Apr 2021 12:17:17 +0200 Subject: [PATCH] feat: sender address verification --- README.md | 12 +- config.default.yml | 8 +- config/config.go | 14 +- main.go | 26 ---- service/mail.go | 83 +++++++++++ service/user.go | 83 +++++++++-- service/verification.go | 36 +++++ templates/sender_verification.tpl.html | 156 +++++++++++++++++++++ types/client.go | 2 +- types/dto/user_update.go | 19 +++ types/mail.go | 2 - types/mail_address.go | 5 + types/user.go | 30 +++- types/verification.go | 24 ++++ version.txt | 2 +- web/handlers/auth.go | 3 +- web/routes/api/client.go | 16 ++- web/routes/api/user.go | 95 ++++++++++--- webui/src/App.svelte | 4 +- webui/src/api/users.js | 10 +- webui/src/components/Navigation.svelte | 6 +- webui/src/views/Addresses.svelte | 187 +++++++++++++++++++++++++ webui/src/views/Clients.svelte | 40 ++++-- webui/src/views/Spf.svelte | 27 ---- 24 files changed, 760 insertions(+), 130 deletions(-) create mode 100644 service/mail.go create mode 100644 service/verification.go create mode 100644 templates/sender_verification.tpl.html create mode 100644 types/dto/user_update.go create mode 100644 types/verification.go create mode 100644 webui/src/views/Addresses.svelte delete mode 100644 webui/src/views/Spf.svelte diff --git a/README.md b/README.md index c02b19d..4df1dc4 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,7 @@ $ docker run -d \ First of all, you can get most tasks done through the web UI, available at http://localhost:3000. ### 1. Define a user -To get started with MailWhale, you need to create a **user** first. -To do so, you can either let the application initialize a default user by supplying `security.seed_users` in [config.default.yml](config.default.yml)). -Alternatively, you can also register new users at runtime via API or web UI. `security.allow_signup` needs to be set to `true`. +To get started with MailWhale, you need to create a **user** first. To do so, register a new user API or web UI. `security.allow_signup` needs to be set to `true`. ### 2. Create an API client It is good practice to not authenticate against the API as a user directly. Instead, create an **API client** with limited privileges, that could easily be revoked in the future. A client is identified by a **client ID** and a **client secret** (or token), very similar to what you might already be familiar with from AWS APIs. Usually, such a client corresponds to an individual client application of yours, which wants to access MailWhale's API. @@ -163,9 +161,10 @@ You can specify configuration options either via a config file (`config.yml`) or |---------------------------|---------------------------|--------------|---------------------------------------------------------------------| | `env` | `MW_ENV` | `dev` | Whether to use development- or production settings | | `mail.domain` | `MW_MAIL_DOMAIN` | - | Default domain for sending mails | -| `mail.spf_check` | `MW_MAIL_SPF_CHECK` | `false` | Whether to validate sender address domains' SPF records | +| `mail.verify_senders` | `MW_VERIFY_SENDERS` | `true` | Whether to validate sender addresses and their domains' SPF records | | `web.listen_v4` | `MW_WEB_LISTEN_V4` | `127.0.0.1:3000` | IP and port for the web server to listen on | | `web.cors_origin` | - | [`http://localhost:5000`] | List of URLs which to accept CORS requests for | +| `web.public_url` | `MW_PUBLIC_URL` | `http://localhost:3000` | The URL under which your MailWhale server is available from the public internet | | `smtp.host` | `MW_SMTP_HOST` | - | SMTP relay host name or IP | | `smtp.port` | `MW_SMTP_PORT` | - | SMTP relay port | | `smtp.username` | `MW_SMTP_USER` | - | SMTP relay authentication user name | @@ -174,9 +173,8 @@ You can specify configuration options either via a config file (`config.yml`) or | `store.path` | `MW_STORE_PATH` | `./data.gob.db` | Target location of the database file | | `security.pepper` | `MW_SECURITY_PEPPER`| - | Pepper to use for hashing user passwords | | `security.allow_signup` | `MW_SECURITY_ALLOW_SIGNUP` | `false` | Whether to allow the registration of new users | -| `security.seed_users` | - | - | List of users to initially populate the database with (see above) | -### SPF Check +### Sender verification & SPF Check By default, mails are sent using a randomly generated address in the `From` header, which belongs to the domain configured via `mail.domain` (i.e. `user+abcdefgh@wakapi.dev`). Optionally, custom sender addresses can be configured on a per-API-client basis. However, it is recommended to properly configure [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) on that custom domain and instruct MailWhale to verify that configuration. **As a user**, you need to configure your domain, which you want to use as part of your senders address (e.g. `example.org` for sending mails from `User Server `), to publish an SPF record that delegates to the domain under which MailWhale is running (e.g. mailwhale.dev). @@ -184,7 +182,7 @@ By default, mails are sent using a randomly generated address in the `From` head example.org. IN TXT v=spf1 include:mailwhale.dev ``` -**As a server operator** of a MailWhale instance, you need to enable `mail.spf_check` and set your `mail.domain`. For that domain, you need to configure an SPF record that allows your SMTP relay provider's (e.g. Mailbox.org, GMail, SendGrid, etc.) mail servers to be senders. Refer to your provider's documentation, e.g. [this](https://kb.mailbox.org/display/MBOKBEN/How+to+integrate+external+e-mail+accounts). +**As a server operator** of a MailWhale instance, you need to enable `mail.verify_senders` and set your `mail.domain` and `web.public_url`. For that domain, you need to configure an SPF record that allows your SMTP relay provider's (e.g. Mailbox.org, GMail, SendGrid, etc.) mail servers to be senders. Refer to your provider's documentation, e.g. [this](https://kb.mailbox.org/display/MBOKBEN/How+to+integrate+external+e-mail+accounts). ## 🚀 Features (planned) diff --git a/config.default.yml b/config.default.yml index b5488f2..feb91e0 100644 --- a/config.default.yml +++ b/config.default.yml @@ -2,9 +2,10 @@ env: dev # Affects log level and a few other things mail: domain: mailwhale.dev # Your server's domain name - spf_check: true + verify_senders: true # Whether to send verification mail when adding new sender addresses web: + public_url: 'http://localhost:3000' # Publicly available URL of your instance, required for callback links via e-mail listen_v4: '127.0.0.1:3000' # Where to make the http server listen cors_origins: - 'http://localhost:5000' @@ -21,7 +22,4 @@ store: security: pepper: 'sshhh' # Change this! - allow_signup: false - seed_users: - - email: 'admin@local.host' - password: 'admin' # Change this! \ No newline at end of file + allow_signup: true \ No newline at end of file diff --git a/config/config.go b/config/config.go index 493ed07..784a2cf 100644 --- a/config/config.go +++ b/config/config.go @@ -25,8 +25,8 @@ type EmailPasswordTuple struct { } type mailConfig struct { - Domain string `yaml:"domain" env:"MW_MAIL_DOMAIN"` - SpfCheck bool `yaml:"spf_check" env:"MW_MAIL_SPF_CHECK"` + Domain string `yaml:"domain" env:"MW_MAIL_DOMAIN"` + VerifySenders bool `yaml:"verify_senders" env:"MW_MAIL_VERIFY_SENDERS"` } type smtpConfig struct { @@ -40,6 +40,7 @@ type smtpConfig struct { type webConfig struct { ListenV4 string `yaml:"listen_v4" default:"127.0.0.1:3000" env:"MW_WEB_LISTEN_V4"` CorsOrigins []string `yaml:"cors_origins" env:"MW_WEB_CORS_ORIGINS"` + PublicUrl string `yaml:"public_url" default:"https://mailwhale.dev/" env:"MW_WEB_PUBLIC_URL"` } type storeConfig struct { @@ -47,9 +48,8 @@ type storeConfig struct { } type securityConfig struct { - Pepper string `env:"MW_SECURITY_PEPPER"` - AllowSignup bool `env:"MW_SECURITY_ALLOW_SIGNUP" yaml:"allow_signup"` - SeedUsers []EmailPasswordTuple `yaml:"seed_users"` + Pepper string `env:"MW_SECURITY_PEPPER"` + AllowSignup bool `env:"MW_SECURITY_ALLOW_SIGNUP" yaml:"allow_signup"` } type Config struct { @@ -91,6 +91,10 @@ func Load() *Config { return Get() } +func (c *webConfig) GetPublicUrl() string { + return strings.TrimSuffix(c.PublicUrl, "/") +} + func (c *smtpConfig) ConnStr() string { return fmt.Sprintf("%s:%d", c.Host, c.Port) } diff --git a/main.go b/main.go index 3f8f4b7..55a705c 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ import ( "github.com/gorilla/mux" conf "github.com/muety/mailwhale/config" "github.com/muety/mailwhale/service" - "github.com/muety/mailwhale/types" "github.com/muety/mailwhale/web/handlers" "github.com/muety/mailwhale/web/routes/api" "github.com/rs/cors" @@ -36,8 +35,6 @@ func main() { // Services userService = service.NewUserService() - initDefaults() - // Global middlewares recoverMiddleware := ghandlers.RecoveryHandler() loggingMiddleware := handlers.NewLoggingMiddleware(logbuch.Info, []string{}) @@ -98,26 +95,3 @@ func listen(handler http.Handler, config *conf.Config) { <-make(chan interface{}, 1) } - -func initDefaults() { - for _, u := range config.Security.SeedUsers { - if user, err := userService.GetById(u.Email); err == nil { - user.Password = u.Password - user, err = userService.Update(user) - if err != nil { - logbuch.Fatal("failed to update user '%s': %v", u.Email, err) - } - logbuch.Info("updated user '%s'", user.ID) - continue - } - - user, err := userService.Create(&types.Signup{ - Email: u.Email, - Password: u.Password, - }) - if err != nil { - logbuch.Fatal("failed to create seed user '%s': %v", u.Email, err) - } - logbuch.Info("created seed user '%s'", user.ID) - } -} diff --git a/service/mail.go b/service/mail.go new file mode 100644 index 0000000..4cf8a89 --- /dev/null +++ b/service/mail.go @@ -0,0 +1,83 @@ +package service + +import ( + "bytes" + "fmt" + "github.com/muety/mailwhale/config" + "github.com/muety/mailwhale/types" + "io/ioutil" + "os" + "text/template" +) + +// Service with methods for sending system mails, not to be confused with SendService + +const ( + tplPath = "templates" + tplNameVerifySender = "sender_verification" +) + +type MailService struct { + config *config.Config + sendService *SendService +} + +func NewMailService() *MailService { + return &MailService{ + config: config.Get(), + sendService: NewSendService(), + } +} + +func (s *MailService) SendSenderVerification(user *types.User, sender types.SenderAddress, token string) error { + tpl, err := s.loadTemplate(tplNameVerifySender) + if err != nil { + return err + } + + type data struct { + UserId string + SenderAddress string + VerifyLink string + } + + verifyLink := fmt.Sprintf( + "%s/api/user/verify?token=%s", + s.config.Web.GetPublicUrl(), + token, + ) + payload := &data{ + UserId: user.ID, + SenderAddress: sender.Raw(), + VerifyLink: verifyLink, + } + + var rendered bytes.Buffer + if err := tpl.Execute(&rendered, payload); err != nil { + return err + } + + mail := &types.Mail{ + From: types.MailAddress(fmt.Sprintf("MailWhale System ", s.config.Mail.Domain)), + To: []types.MailAddress{sender.MailAddress}, + Subject: "Verify your e-mail address for MailWhale", + } + mail.WithHTML(rendered.String()) + + return s.sendService.Send(mail) +} + +func (s *MailService) loadTemplate(tplName string) (*template.Template, error) { + tplFile, err := os.Open(fmt.Sprintf("%s/%s.tpl.html", tplPath, tplName)) + if err != nil { + return nil, err + } + defer tplFile.Close() + + tplData, err := ioutil.ReadAll(tplFile) + if err != nil { + return nil, err + } + + return template.New(tplName).Parse(string(tplData)) +} diff --git a/service/user.go b/service/user.go index 80316ba..1cbafff 100644 --- a/service/user.go +++ b/service/user.go @@ -2,21 +2,30 @@ package service import ( "errors" + "fmt" + "github.com/emvi/logbuch" conf "github.com/muety/mailwhale/config" "github.com/muety/mailwhale/types" "github.com/muety/mailwhale/util" "github.com/timshannon/bolthold" + "strings" ) type UserService struct { - config *conf.Config - store *bolthold.Store + config *conf.Config + store *bolthold.Store + spfService *SpfService + mailService *MailService + verificationService *VerificationService } func NewUserService() *UserService { return &UserService{ - config: conf.Get(), - store: conf.GetStore(), + config: conf.Get(), + store: conf.GetStore(), + spfService: NewSpfService(), + mailService: NewMailService(), + verificationService: NewVerificationService(), } } @@ -31,13 +40,14 @@ func (s *UserService) GetAll() (users []*types.User, err error) { func (s *UserService) GetById(id string) (*types.User, error) { var user types.User err := s.store.Get(id, &user) - return &user, err + return user.Sanitize(), err } func (s *UserService) Create(signup *types.Signup) (*types.User, error) { user := &types.User{ ID: signup.Email, Password: util.HashBcrypt(signup.Password, s.config.Security.Pepper), + Senders: []types.SenderAddress{}, } if !user.IsValid() { return nil, errors.New("can't create user (empty password or invalid e-mail address)") @@ -45,20 +55,75 @@ func (s *UserService) Create(signup *types.Signup) (*types.User, error) { if err := s.store.Insert(user.ID, user); err != nil { return nil, err } - return user, nil + return user.Sanitize(), nil } -func (s *UserService) Update(user *types.User) (*types.User, error) { - user.Password = util.HashBcrypt(user.Password, s.config.Security.Pepper) +func (s *UserService) Update(user *types.User, update *types.User) (*types.User, error) { + if update.Password != "" { + user.Password = util.HashBcrypt(update.Password, s.config.Security.Pepper) + } + + newSenders := s.extractNewSenders(user, update) + if s.config.Mail.VerifySenders { + if err := s.spfCheckSenders(newSenders); err != nil { + return nil, err + } + go s.verifySenders(user, newSenders) + } + user.Senders = update.Senders + if !user.IsValid() { return nil, errors.New("user data invalid") } if err := s.store.Update(user.ID, user); err != nil { return nil, err } - return user, nil + return user.Sanitize(), nil } func (s *UserService) Delete(id string) error { return s.store.Delete(id, &types.User{}) } + +func (s *UserService) extractNewSenders(user *types.User, update *types.User) []types.SenderAddress { + newSenders := make([]types.SenderAddress, 0) + for _, sender := range update.Senders { + if user.HasSender(sender.MailAddress) { + continue + } + newSenders = append(newSenders, sender) + } + return newSenders +} + +func (s *UserService) spfCheckSenders(senders []types.SenderAddress) error { + // TODO: parallelize + for _, sender := range senders { + senderDomain := strings.Split(sender.MailAddress.Raw(), "@")[1] + if err := s.spfService.Validate(senderDomain); err != nil { + return errors.New(fmt.Sprintf("failed to verify spf entry for domain '%s'", senderDomain)) + } + } + return nil +} + +// generates verification tokens for senders addresses and sends them via mail +func (s *UserService) verifySenders(user *types.User, senders []types.SenderAddress) error { + for _, sender := range senders { + verification, err := s.verificationService.Create(types.NewVerification( + user, + types.VerificationScopeSender, + sender.String(), + )) + if err != nil { + return err + } + if err := s.mailService.SendSenderVerification(user, sender, verification.Token); err != nil { + logbuch.Error("failed to send sender verification to '%s'", sender.MailAddress.String()) + return err + } else { + logbuch.Info("sent sender verification mail for user '%s' to '%s'", user.ID, sender.String()) + } + } + return nil +} diff --git a/service/verification.go b/service/verification.go new file mode 100644 index 0000000..a3f8360 --- /dev/null +++ b/service/verification.go @@ -0,0 +1,36 @@ +package service + +import ( + conf "github.com/muety/mailwhale/config" + "github.com/muety/mailwhale/types" + "github.com/timshannon/bolthold" +) + +type VerificationService struct { + config *conf.Config + store *bolthold.Store +} + +func NewVerificationService() *VerificationService { + return &VerificationService{ + config: conf.Get(), + store: conf.GetStore(), + } +} + +func (s *VerificationService) GetByToken(token string) (*types.Verification, error) { + var verification types.Verification + err := s.store.Get(token, &verification) + return &verification, err +} + +func (s *VerificationService) Create(verification *types.Verification) (*types.Verification, error) { + if err := s.store.Insert(verification.Token, verification); err != nil { + return nil, err + } + return verification, nil +} + +func (s *VerificationService) Delete(token string) error { + return s.store.Delete(token, &types.Verification{}) +} diff --git a/templates/sender_verification.tpl.html b/templates/sender_verification.tpl.html new file mode 100644 index 0000000..d3efe5e --- /dev/null +++ b/templates/sender_verification.tpl.html @@ -0,0 +1,156 @@ + + + + + + MailWhale :: Verify E-Mail Address + + + + + + + + + +
  +
+ + + + + + + + + + +
+ + + + +
+

Verify E-Mail Address

+

+ You have linked a new sender e-mail address to your MailWhale account ({{ .UserId }}):
+

{{ .SenderAddress }}

+ Please click the following link to verify it. If you did not request this change, please just ignore this mail. +

+ + + + + + +
+ + + + + + +
Verify
+
+
+
+ + + + + + +
+
 
+ + diff --git a/types/client.go b/types/client.go index f29cf3c..61a040d 100644 --- a/types/client.go +++ b/types/client.go @@ -27,7 +27,7 @@ func AllPermissions() []string { } type Client struct { - ID string `json:"id"` + ID string `json:"id" boltholdKey:"ID"` Description string `json:"description"` UserId string `json:"-" boltholdIndex:"UserId"` Permissions []string `json:"permissions"` diff --git a/types/dto/user_update.go b/types/dto/user_update.go new file mode 100644 index 0000000..7373610 --- /dev/null +++ b/types/dto/user_update.go @@ -0,0 +1,19 @@ +package dto + +import "github.com/muety/mailwhale/types" + +type UserUpdate struct { + Password string `json:"password"` + Senders types.MailAddresses `json:"senders"` +} + +func (u *UserUpdate) GetSenders(user *types.User) []types.SenderAddress { + senders := make([]types.SenderAddress, len(u.Senders)) + for i := range u.Senders { + senders[i] = types.SenderAddress{ + MailAddress: u.Senders[i], + Verified: user.HasVerifiedSender(u.Senders[i]), + } + } + return senders +} diff --git a/types/mail.go b/types/mail.go index 8a03020..14af8dc 100644 --- a/types/mail.go +++ b/types/mail.go @@ -5,8 +5,6 @@ import ( "strings" ) -// TODO: support multipart - type Mail struct { From MailAddress `json:"from"` To MailAddresses `json:"to"` diff --git a/types/mail_address.go b/types/mail_address.go index 68a7bd1..a1f0ed6 100644 --- a/types/mail_address.go +++ b/types/mail_address.go @@ -21,6 +21,11 @@ type MailAddress string type MailAddresses []MailAddress +type SenderAddress struct { + MailAddress `json:"mail"` + Verified bool `json:"verified"` +} + func (m MailAddress) String() string { return string(m) } diff --git a/types/user.go b/types/user.go index f4337ed..04da33a 100644 --- a/types/user.go +++ b/types/user.go @@ -3,8 +3,9 @@ package types import "github.com/muety/mailwhale/util" type User struct { - ID string `json:"id"` - Password string `json:"-"` + ID string `json:"id" boltholdKey:"ID"` + Password string `json:"-"` + Senders []SenderAddress `json:"senders"` } type Signup struct { @@ -15,3 +16,28 @@ type Signup struct { func (u *User) IsValid() bool { return util.IsEmail(u.ID) && len(u.Password) > 0 } + +func (u *User) HasSender(sender MailAddress) bool { + return u.findSender(sender) != nil +} + +func (u *User) HasVerifiedSender(sender MailAddress) bool { + s := u.findSender(sender) + return s != nil && s.Verified +} + +func (u *User) Sanitize() *User { + if u.Senders == nil { + u.Senders = []SenderAddress{} + } + return u +} + +func (u *User) findSender(sender MailAddress) *SenderAddress { + for _, v := range u.Senders { + if v.MailAddress == sender { + return &v + } + } + return nil +} diff --git a/types/verification.go b/types/verification.go new file mode 100644 index 0000000..9fc6a79 --- /dev/null +++ b/types/verification.go @@ -0,0 +1,24 @@ +package types + +import "github.com/google/uuid" + +const ( + VerificationScopeUser = "scope_user" + VerificationScopeSender = "scope_sender_address" +) + +type Verification struct { + Token string `boltholdKey:"Token"` + UserId string + Scope string + Subject string +} + +func NewVerification(user *User, scope, subject string) *Verification { + return &Verification{ + Token: uuid.New().String(), + UserId: user.ID, + Scope: scope, + Subject: subject, + } +} diff --git a/version.txt b/version.txt index f514a2f..2774f85 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.9.1 \ No newline at end of file +0.10.0 \ No newline at end of file diff --git a/web/handlers/auth.go b/web/handlers/auth.go index fd0a96d..02bfaf9 100644 --- a/web/handlers/auth.go +++ b/web/handlers/auth.go @@ -30,8 +30,6 @@ func NewAuthMiddleware(clientService *service.ClientService, userService *servic } func (m *AuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // TODO: Securecookie auth for web ui - clientOrUser, credentials, ok := r.BasicAuth() if !ok { util.RespondEmpty(w, r, http.StatusUnauthorized) @@ -85,6 +83,7 @@ func (m *AuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { } client = c + user, _ = m.userService.GetById(client.UserId) } if m.permissions == nil || len(m.permissions) == 0 || client.HasPermissionAnyOf(m.permissions) { diff --git a/web/routes/api/client.go b/web/routes/api/client.go index b21fac8..d1b450e 100644 --- a/web/routes/api/client.go +++ b/web/routes/api/client.go @@ -2,6 +2,8 @@ package api import ( "encoding/json" + "errors" + "fmt" "github.com/emvi/logbuch" "github.com/gorilla/mux" conf "github.com/muety/mailwhale/config" @@ -10,7 +12,6 @@ import ( "github.com/muety/mailwhale/util" "github.com/muety/mailwhale/web/handlers" "net/http" - "strings" ) const routeClient = "/api/client" @@ -19,7 +20,6 @@ type ClientHandler struct { config *conf.Config clientService *service.ClientService userService *service.UserService - spfService *service.SpfService } func NewClientHandler() *ClientHandler { @@ -27,7 +27,6 @@ func NewClientHandler() *ClientHandler { config: conf.Get(), clientService: service.NewClientService(), userService: service.NewUserService(), - spfService: service.NewSpfService(), } } @@ -87,10 +86,13 @@ func (h *ClientHandler) post(w http.ResponseWriter, r *http.Request) { return } - if payload.Sender != "" { - senderDomain := strings.Split(payload.Sender.Raw(), "@")[1] - if err := h.spfService.Validate(senderDomain); err != nil { - util.RespondErrorMessage(w, r, http.StatusBadRequest, err) + if payload.Sender != "" && h.config.Mail.VerifySenders { + var user *types.User + if u := r.Context().Value(conf.KeyUser); u != nil { + user = u.(*types.User) + } + if user == nil || !user.HasVerifiedSender(payload.Sender) { + util.RespondErrorMessage(w, r, http.StatusForbidden, errors.New(fmt.Sprintf("'%s' is not a verified sender address", payload.Sender))) return } } diff --git a/web/routes/api/user.go b/web/routes/api/user.go index db25122..47c5a71 100644 --- a/web/routes/api/user.go +++ b/web/routes/api/user.go @@ -8,6 +8,7 @@ import ( conf "github.com/muety/mailwhale/config" "github.com/muety/mailwhale/service" "github.com/muety/mailwhale/types" + "github.com/muety/mailwhale/types/dto" "github.com/muety/mailwhale/util" "github.com/muety/mailwhale/web/handlers" "net/http" @@ -16,28 +17,44 @@ import ( const routeUser = "/api/user" type UserHandler struct { - config *conf.Config - clientService *service.ClientService - userService *service.UserService + config *conf.Config + clientService *service.ClientService + userService *service.UserService + verificationService *service.VerificationService } func NewUserHandler() *UserHandler { return &UserHandler{ - config: conf.Get(), - clientService: service.NewClientService(), - userService: service.NewUserService(), + config: conf.Get(), + clientService: service.NewClientService(), + userService: service.NewUserService(), + verificationService: service.NewVerificationService(), } } func (h *UserHandler) Register(router *mux.Router) { r := router.PathPrefix(routeUser).Subrouter() r.Path("").Methods(http.MethodPost).HandlerFunc(h.post) + r.Path("/verify").Methods(http.MethodGet).HandlerFunc(h.verify) auth := handlers.NewAuthMiddleware(h.clientService, h.userService, []string{types.PermissionManageUser}) r2 := r.PathPrefix("").Subrouter() r2.Use(auth) - r2.Path("/{id}").Methods(http.MethodPut).HandlerFunc(h.update) + r2.Path("/me").Methods(http.MethodGet).HandlerFunc(h.getMe) + r2.Path("/me").Methods(http.MethodPut).HandlerFunc(h.updateMe) +} + +func (h *UserHandler) getMe(w http.ResponseWriter, r *http.Request) { + var user *types.User + if u := r.Context().Value(conf.KeyUser); u != nil { + user = u.(*types.User) + } + if user == nil { + util.RespondError(w, r, http.StatusNotFound, errors.New("user not found")) + return + } + util.RespondJson(w, http.StatusOK, user) } func (h *UserHandler) post(w http.ResponseWriter, r *http.Request) { @@ -62,34 +79,72 @@ func (h *UserHandler) post(w http.ResponseWriter, r *http.Request) { util.RespondJson(w, http.StatusCreated, user) } -func (h *UserHandler) update(w http.ResponseWriter, r *http.Request) { - reqClient := r.Context().Value(conf.KeyClient).(*types.Client) - - var payload types.Signup +func (h *UserHandler) updateMe(w http.ResponseWriter, r *http.Request) { + var payload dto.UserUpdate if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { util.RespondError(w, r, http.StatusBadRequest, err) return } - if payload.Email != reqClient.UserId { - util.RespondEmpty(w, r, http.StatusForbidden) + var user *types.User + if u := r.Context().Value(conf.KeyUser); u != nil { + user = u.(*types.User) + } + if user == nil { + util.RespondError(w, r, http.StatusNotFound, errors.New("user not found")) return } - user, err := h.userService.GetById(reqClient.UserId) + update := *user + update.Password = payload.Password + update.Senders = payload.GetSenders(user) + + user, err := h.userService.Update(user, &update) if err != nil { - util.RespondError(w, r, http.StatusNotFound, err) + util.RespondErrorMessage(w, r, http.StatusBadRequest, err) + return + } + + logbuch.Info("updated user '%s'", user.ID) + util.RespondJson(w, http.StatusOK, user) +} + +func (h *UserHandler) verify(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + util.RespondErrorMessage(w, r, http.StatusUnauthorized, errors.New("invalid verification token")) return } - user.Password = payload.Password + verification, err := h.verificationService.GetByToken(token) + if err != nil { + util.RespondErrorMessage(w, r, http.StatusUnauthorized, errors.New("invalid verification token")) + return + } - user, err = h.userService.Update(user) + user, err := h.userService.GetById(verification.UserId) if err != nil { - util.RespondError(w, r, http.StatusInternalServerError, err) + util.RespondErrorMessage(w, r, http.StatusNotFound, errors.New("user not found")) return } - logbuch.Info("updated user '%s'", user.ID) - util.RespondJson(w, http.StatusOK, user) + if verification.Scope == types.VerificationScopeSender { + update := *user // copy + update.Password = "" + + for i, s := range update.Senders { + if s.MailAddress.String() == verification.Subject { + update.Senders[i].Verified = true + break + } + } + if _, err := h.userService.Update(user, &update); err != nil { + util.RespondErrorMessage(w, r, http.StatusNotFound, err) + return + } + go h.verificationService.Delete(verification.Token) + logbuch.Info("verified sender address '%s' for user '%s'", verification.Subject, user.ID) + } + + http.Redirect(w, r, "/", http.StatusFound) } diff --git a/webui/src/App.svelte b/webui/src/App.svelte index 3689677..c3fb62e 100644 --- a/webui/src/App.svelte +++ b/webui/src/App.svelte @@ -6,7 +6,7 @@ import Clients from './views/Clients.svelte' import Mails from './views/Mails.svelte'; import Imprint from './views/Imprint.svelte'; - import Spf from './views/Spf.svelte'; + import Addresses from './views/Addresses.svelte'; import Templates from './views/Templates.svelte'; import { user } from './stores/auth' @@ -31,7 +31,7 @@ router('/clients', () => (page = Clients)) router('/mails', () => (page = Mails)) router('/imprint', () => (page = Imprint)) - router('/spf', () => (page = Spf)) + router('/addresses', () => (page = Addresses)) router('/templates', () => (page = Templates)) router.start() diff --git a/webui/src/api/users.js b/webui/src/api/users.js index 157f981..a4f9867 100644 --- a/webui/src/api/users.js +++ b/webui/src/api/users.js @@ -4,8 +4,12 @@ async function createUser(signup) { return (await request('/user', signup, { method: 'POST' })).data } -async function updateUser(id, signup) { - return (await request(`/user/${id}`, signup, { method: 'PUT' })).data +async function getMe() { + return (await request('/user/me', null, {})).data } -export { createUser, updateUser } \ No newline at end of file +async function updateMe(data) { + return (await request(`/user/me`, data, { method: 'PUT' })).data +} + +export { createUser, updateMe, getMe } \ No newline at end of file diff --git a/webui/src/components/Navigation.svelte b/webui/src/components/Navigation.svelte index 48386c1..08c3b7c 100644 --- a/webui/src/components/Navigation.svelte +++ b/webui/src/components/Navigation.svelte @@ -15,10 +15,10 @@ class="hover:text-gray-800">Templates
  • - lock + alternate_email SPF & DKIM + href="addresses" + class="hover:text-gray-800">E-Mail Addresses
  • email diff --git a/webui/src/views/Addresses.svelte b/webui/src/views/Addresses.svelte new file mode 100644 index 0000000..b962893 --- /dev/null +++ b/webui/src/views/Addresses.svelte @@ -0,0 +1,187 @@ + + + + + +
    +
    + +
    +
    +
    +

    E-Mail Addresses

    + +
    +

    + By default, mails are sent from a pseudo-randomly generated default + addresses, like + vldsbgfr+user@mailwhale.dev + or so. Alternatively, you can specify custom sender addresses from a + domain that you own. However, there are a few conditions to be met to do + so. +

    +
    +

    + First, you have to + verify + the respective e-mail address. That is, once you specify it below, a + confirmation mail is sent to that address. Only after successful + verification it can be used to send mail. +

    +
    +

    + Second, you have to set proper + SPF + and + DKIM + records for your domain, which permit MailWhale to send mail on your + behalf. SPF and DKIM are security measures in the context of e-mail that + aim at verifying the real sender of a message and prevent spam. In order + to send mail with a custom domain as part of the sender address, you + need to provide certain SPF- and DKIM DNS records for that domain, which + will subsequently be verified by the recipient mail server. +

    +
    +

    + For + SPF + please refer to + this README section + on GitHub. + DKIM + is not implemented, yet. +

    + +

    Your Addresses

    + {#if me && me.senders && me.senders.length} +
    + {#each me.senders as sender, i} +
    +
    + #{i + 1} + {sender.mail} +
    + {#if sender.verified} + verified + {:else}not verified{/if} +
    +
    +
    + Remove +
    +
    + {/each} +
    + {:else} +
    + No addresses added, yet. +
    + {/if} +
    + + {#if newAddressModal} + (newAddressModal = false) || reset()}> +

    + Add new sender address +

    +
    +
    +
    + + +
    + +
    +
    + +
    + +
    + + {/if} +
    + diff --git a/webui/src/views/Clients.svelte b/webui/src/views/Clients.svelte index 7a87218..34e30e0 100644 --- a/webui/src/views/Clients.svelte +++ b/webui/src/views/Clients.svelte @@ -5,9 +5,17 @@ import Navigation from '../components/Navigation.svelte' import Modal from '../components/Modal.svelte' import { getClients, createClient, deleteClient } from '../api/clients' + import { getMe } from '../api/users' + import { user } from '../stores/auth' - const availablePermissions = ['send_mail', 'manage_client', 'manage_user', 'manage_template'] + const availablePermissions = [ + 'send_mail', + 'manage_client', + 'manage_user', + 'manage_template', + ] + let me let clients = [] const emptyClient = { @@ -54,6 +62,7 @@ } onMount(async () => { + me = await getMe() clients = await getClients() }) @@ -195,19 +204,34 @@
    Please Note: - You can set an optional sender address for this client (e.g. My App <noreply@example.org>), that will be used in the mail's "From" header. However, you need to make sure that SPF and DMARC records are properly set for your domain. You need to authorize MailWhale's servers to send mail on your behalf. If left blank, a default sender address like user+vldsbgfr@mailwhale.dev will be used. + You can set an optional sender address for this client + (e.g. + My App <noreply@example.org>), + that will be used in the mail's + "From" + header. However, you need to make sure that SPF and DMARC + records are properly set for your domain. You need to + authorize MailWhale's servers to send mail on your behalf. + If left blank, a default sender address like + vldsbgfr+user@mailwhale.dev + will be used.
    - +
    diff --git a/webui/src/views/Spf.svelte b/webui/src/views/Spf.svelte deleted file mode 100644 index dc775af..0000000 --- a/webui/src/views/Spf.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - -
    -
    - -
    -
    -

    SPF & DKIM Configuration

    - SPF and DKIM are security measures in the context of e-mail that aim at verifying the real sender of a message and prevent spam. In order to send mail with a custom domain as part of the sender address, you need to provide certain SPF- and DKIM DNS records for that domain, which will subsequently be verified by the recipient mail server. - -

    SPF

    -

    - Please refer to this README section on GitHub. -

    - -

    DKIM

    -

    - DKIM verification is not yet implemented. -

    -
    -
    - -