Skip to content

Commit 6343616

Browse files
committed
normalize all emails
1 parent 921f1e4 commit 6343616

File tree

7 files changed

+142
-7
lines changed

7 files changed

+142
-7
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [26.15] - 2026-01-31
6+
7+
- **Contacts**: Emails are now normalized to lowercase on import to prevent case-sensitivity issues (#231)
8+
- **Security**: Upgraded fast-xml-parser to 5.3.4
9+
510
## [26.14] - 2026-01-30
611

712
- **Email Builder**: Fixed buttons with HTML content like `<strong>` rendering as default "Button" text instead of custom content (#242, [gomjml PR#33](https://github.com/preslavrachev/gomjml/pull/33))

config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
"github.com/spf13/viper"
1515
)
1616

17-
const VERSION = "26.14"
17+
const VERSION = "26.15"
1818

1919
type Config struct {
2020
Server ServerConfig

console/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

console/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@
106106
},
107107
"overrides": {
108108
"seroval": "^1.4.1",
109-
"seroval-plugins": "^1.4.1"
109+
"seroval-plugins": "^1.4.1",
110+
"fast-xml-parser": "5.3.4"
110111
},
111112
"devDependencies": {
112113
"@eslint/js": "^9.19.0",

internal/domain/contact.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ func (c *Contact) Validate() error {
9191
if c.Email == "" {
9292
return fmt.Errorf("email is required")
9393
}
94+
// Normalize email to lowercase
95+
c.Email = NormalizeEmail(c.Email)
9496
// Email must be valid
9597
if !govalidator.IsEmail(c.Email) {
9698
return fmt.Errorf("invalid email format")
@@ -656,8 +658,8 @@ func FromJSON(data interface{}) (*Contact, error) {
656658
return nil, fmt.Errorf("unsupported data type: %T", data)
657659
}
658660

659-
// Extract required fields and trim all Unicode whitespace (including NBSP)
660-
email := trimUnicodeSpace(jsonResult.Get("email").String())
661+
// Extract required fields and normalize email for consistent storage and lookups
662+
email := NormalizeEmail(jsonResult.Get("email").String())
661663
if email == "" {
662664
return nil, fmt.Errorf("email is required")
663665
}
@@ -817,6 +819,12 @@ func trimUnicodeSpace(s string) string {
817819
return strings.TrimFunc(s, unicode.IsSpace)
818820
}
819821

822+
// NormalizeEmail normalizes an email address by trimming whitespace and converting to lowercase.
823+
// This ensures consistent storage and lookups regardless of input case.
824+
func NormalizeEmail(email string) string {
825+
return strings.ToLower(trimUnicodeSpace(email))
826+
}
827+
820828
// Helper functions for parsing nullable fields from JSON
821829
func parseNullableString(result gjson.Result, field string, target **NullableString) error {
822830
if value := result.Get(field); value.Exists() {

internal/domain/contact_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,105 @@ func TestContact_Validate(t *testing.T) {
143143
}
144144
}
145145

146+
func TestContact_Validate_NormalizesEmail(t *testing.T) {
147+
tests := []struct {
148+
name string
149+
input string
150+
expected string
151+
}{
152+
{
153+
name: "lowercase email unchanged",
154+
155+
expected: "[email protected]",
156+
},
157+
{
158+
name: "uppercase email normalized",
159+
160+
expected: "[email protected]",
161+
},
162+
{
163+
name: "mixed case email normalized",
164+
165+
expected: "[email protected]",
166+
},
167+
{
168+
name: "email with leading/trailing spaces normalized",
169+
input: " [email protected] ",
170+
expected: "[email protected]",
171+
},
172+
{
173+
name: "mixed case with spaces normalized",
174+
input: " [email protected] ",
175+
expected: "[email protected]",
176+
},
177+
}
178+
179+
for _, tt := range tests {
180+
t.Run(tt.name, func(t *testing.T) {
181+
c := &Contact{Email: tt.input}
182+
err := c.Validate()
183+
assert.NoError(t, err)
184+
assert.Equal(t, tt.expected, c.Email)
185+
})
186+
}
187+
}
188+
189+
func TestNormalizeEmail(t *testing.T) {
190+
tests := []struct {
191+
name string
192+
input string
193+
expected string
194+
}{
195+
{
196+
name: "lowercase unchanged",
197+
198+
expected: "[email protected]",
199+
},
200+
{
201+
name: "uppercase to lowercase",
202+
203+
expected: "[email protected]",
204+
},
205+
{
206+
name: "mixed case to lowercase",
207+
208+
expected: "[email protected]",
209+
},
210+
{
211+
name: "trim leading spaces",
212+
input: " [email protected]",
213+
expected: "[email protected]",
214+
},
215+
{
216+
name: "trim trailing spaces",
217+
input: "[email protected] ",
218+
expected: "[email protected]",
219+
},
220+
{
221+
name: "trim both and lowercase",
222+
input: " [email protected] ",
223+
expected: "[email protected]",
224+
},
225+
{
226+
name: "empty string",
227+
input: "",
228+
expected: "",
229+
},
230+
{
231+
name: "only spaces",
232+
input: " ",
233+
expected: "",
234+
},
235+
}
236+
237+
for _, tt := range tests {
238+
t.Run(tt.name, func(t *testing.T) {
239+
result := NormalizeEmail(tt.input)
240+
assert.Equal(t, tt.expected, result)
241+
})
242+
}
243+
}
244+
146245
func TestScanContact(t *testing.T) {
147246
now := time.Now()
148247

@@ -998,6 +1097,22 @@ func TestFromJSON(t *testing.T) {
9981097
},
9991098
wantErr: false,
10001099
},
1100+
{
1101+
name: "email is normalized to lowercase",
1102+
input: `{"email": "[email protected]"}`,
1103+
want: &Contact{
1104+
1105+
},
1106+
wantErr: false,
1107+
},
1108+
{
1109+
name: "email with mixed case and spaces is normalized",
1110+
input: `{"email": " [email protected] "}`,
1111+
want: &Contact{
1112+
1113+
},
1114+
wantErr: false,
1115+
},
10011116
}
10021117

10031118
for _, tt := range tests {

internal/service/contact_service.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ func NewContactService(
4444
}
4545

4646
func (s *ContactService) GetContactByEmail(ctx context.Context, workspaceID string, email string) (*domain.Contact, error) {
47+
// Normalize email for consistent lookups
48+
email = domain.NormalizeEmail(email)
49+
4750
// Check if this is a system call (e.g., from Supabase webhook)
4851
isSystemCall := ctx.Value(domain.SystemCallKey) != nil
4952

@@ -132,6 +135,9 @@ func (s *ContactService) GetContacts(ctx context.Context, req *domain.GetContact
132135
}
133136

134137
func (s *ContactService) DeleteContact(ctx context.Context, workspaceID string, email string) error {
138+
// Normalize email for consistent lookups
139+
email = domain.NormalizeEmail(email)
140+
135141
var err error
136142
log.Println("DeleteContact", email, workspaceID)
137143
ctx, _, userWorkspace, err := s.authService.AuthenticateUserForWorkspace(ctx, workspaceID)

0 commit comments

Comments
 (0)