From bd00db2a6e45cb6ecb4bf857841ac82c8c673052 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Mon, 22 Jun 2026 22:50:06 +0200 Subject: [PATCH] fix guard rails for lowercasing emails Signed-off-by: Ronni Skansing --- backend/repository/recipient.go | 32 +++++++++++++++++++++++++++ backend/service/recipient.go | 38 +++++++-------------------------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go index fecffc7..58a754a 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" "strconv" + "strings" "time" "github.com/google/uuid" @@ -754,6 +755,31 @@ func (r *Recipient) GetByEmail( return ToRecipient(&dbRecipient) } +// GetByEmailLower looks up a recipient by a case insensitive email match. +// the supplied email is compared with LOWER() on both sides so that the same +// address in a different casing (e.g. Foo@bar.tld vs foo@bar.tld) is treated as +// the same recipient. used to reject a new recipient that duplicates an +// existing one apart from casing. for resolving a specific recipient use the +// exact match GetByEmail instead. +func (r *Recipient) GetByEmailLower( + ctx context.Context, + email *vo.Email, + fields ...string, +) (*model.Recipient, error) { + var dbRecipient database.Recipient + fields = assignTableToColumns(database.RECIPIENT_TABLE, fields) + res := useSelect(r.DB, fields). + Where( + fmt.Sprintf("LOWER(%s) = LOWER(?)", TableColumn(database.RECIPIENT_TABLE, "email")), + email.String(), + ). + First(&dbRecipient) + if res.Error != nil { + return nil, res.Error + } + return ToRecipient(&dbRecipient) +} + // GetByEmailLowerAndCompanyID looks up a recipient by a case-insensitive email // match within a company. the supplied email is compared with LOWER() on both // sides so that differently cased addresses (e.g. John@X.com vs john@x.com) are @@ -832,6 +858,12 @@ func (r *Recipient) Insert( ) (*uuid.UUID, error) { id := uuid.New() row := recp.ToDBMap() + // the email is the recipient identity and is stored lowercased so the same + // address in a different casing is one recipient. only applied on create so + // an existing stored casing is never rewritten on update + if email, ok := row["email"].(string); ok { + row["email"] = strings.ToLower(email) + } row["id"] = id AddTimestamps(row) res := r.DB.Model(&database.Recipient{}).Create(row) diff --git a/backend/service/recipient.go b/backend/service/recipient.go index dbfc4a3..88c09c5 100644 --- a/backend/service/recipient.go +++ b/backend/service/recipient.go @@ -2,7 +2,6 @@ package service import ( "context" - "fmt" "github.com/go-errors/errors" "github.com/oapi-codegen/nullable" @@ -51,7 +50,8 @@ func (r *Recipient) Create( email := recipient.Email.MustGet() // check if recipient already exists to avoid a unique constraint error // and gorm does not return a unique constraint error but a string error depending on DB - _, err = r.RecipientRepository.GetByEmail( + // the match is case insensitive so the same address in a different casing is rejected + _, err = r.RecipientRepository.GetByEmailLower( ctx, &email, ) @@ -112,32 +112,8 @@ func (r *Recipient) UpdateByID( } // update config - if a field is present and not null, update it - // if the email is changed, check that another recipient is not using this email already - if v, err := incoming.Email.Get(); err != nil { - if v.String() != current.Email.MustGet().String() { - var companyID *uuid.UUID - if current.CompanyID != nil { - if cid, err := current.CompanyID.Get(); err != nil { - companyID = &cid - } - } - _, err := r.RecipientRepository.GetByEmailAndCompanyID( - ctx, - &v, - companyID, - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - r.Logger.Errorw("failed check existing recipient email", "error", err) - return err - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - r.Logger.Debugw("email is already taken", "email", v.String()) - s := fmt.Sprintf("email '%s' is already used by another recipient", v.String()) - return validate.WrapErrorWithField(errors.New("not unique"), s) - } - } - current.Email.Set(v) - } + // the email is the recipient identity and is immutable, so any incoming + // email is ignored and the stored one is preserved if v, err := incoming.Phone.Get(); err == nil { current.Phone.Set(v) } @@ -589,9 +565,11 @@ func (r *Recipient) Import( continue } - // check if the recipient exists + // check if the recipient exists, case insensitively so the same address + // in a different casing updates the existing recipient instead of creating + // a duplicate email := incoming.Email.MustGet() - current, err := r.RecipientRepository.GetByEmail( + current, err := r.RecipientRepository.GetByEmailLower( ctx, &email, "id", "email", "company_id",