Skip to content

Commit 3b74699

Browse files
Merge pull request #45 from Valentin-Kaiser/email
Improved email security
2 parents 1273519 + 62a1f2e commit 3b74699

26 files changed

+4118
-113
lines changed

apperror/apperror_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ func TestCatchFunction(t *testing.T) {
351351
}, "operation failed")
352352
}
353353

354-
func TestCatchFunctionSuccess(t *testing.T) {
354+
func TestCatchFunctionSuccess(_ *testing.T) {
355355
// Test successful operation (should not panic)
356356
apperror.Catch(func() error {
357357
return nil

cache/memory.go

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ func (mc *MemoryCache) Get(_ context.Context, key string, dest interface{}) (boo
9595

9696
memItem, ok := element.Value.(*memoryItem)
9797
if !ok {
98+
mc.mutex.Unlock()
9899
mc.updateStats(func(s *Stats) { s.Misses++ })
99100
mc.emitEvent(EventGet, key, nil, nil)
100101
return false, NewCacheError("get", key, errors.New("invalid cache item type"))
@@ -162,6 +163,7 @@ func (mc *MemoryCache) Set(_ context.Context, key string, value interface{}, ttl
162163
TTL: effectiveTTL,
163164
Size: dataSize,
164165
Namespace: mc.config.Namespace,
166+
ExpiresAt: time.Time{}, // Default to no expiration
165167
}
166168

167169
if effectiveTTL > 0 {
@@ -177,7 +179,8 @@ func (mc *MemoryCache) Set(_ context.Context, key string, value interface{}, ttl
177179
defer mc.mutex.Unlock()
178180

179181
// Check if key already exists
180-
if element, exists := mc.items[formattedKey]; exists {
182+
element, exists := mc.items[formattedKey]
183+
if exists {
181184
// Update existing item
182185
oldMemItem, ok := element.Value.(*memoryItem)
183186
if !ok {
@@ -192,20 +195,24 @@ func (mc *MemoryCache) Set(_ context.Context, key string, value interface{}, ttl
192195
mc.updateStats(func(s *Stats) {
193196
s.Memory = s.Memory - oldMemItem.dataSize + dataSize
194197
})
195-
} else {
196-
// Add new item
197-
element := mc.lruList.PushFront(memItem)
198-
mc.items[formattedKey] = element
199198

200-
mc.updateStats(func(s *Stats) {
201-
s.Size++
202-
s.Memory += dataSize
203-
})
199+
mc.updateStats(func(s *Stats) { s.Sets++ })
200+
mc.emitEvent(EventSet, key, value, nil)
201+
return nil
202+
}
204203

205-
// Check if we need to evict items
206-
if mc.config.MaxSize > 0 && mc.stats.Size > mc.config.MaxSize {
207-
mc.evictLRU()
208-
}
204+
// Add new item
205+
element = mc.lruList.PushFront(memItem)
206+
mc.items[formattedKey] = element
207+
208+
mc.updateStats(func(s *Stats) {
209+
s.Size++
210+
s.Memory += dataSize
211+
})
212+
213+
// Check if we need to evict items
214+
if mc.config.MaxSize > 0 && mc.stats.Size > mc.config.MaxSize {
215+
mc.evictLRU()
209216
}
210217

211218
mc.updateStats(func(s *Stats) { s.Sets++ })
@@ -250,17 +257,17 @@ func (mc *MemoryCache) Exists(_ context.Context, key string) (bool, error) {
250257
item := memItem.item
251258

252259
// Check if item has expired
253-
if item.IsExpired() {
260+
if !item.IsExpired() {
254261
mc.mutex.RUnlock()
255-
// Remove expired item
256-
mc.mutex.Lock()
257-
mc.removeElement(element, formattedKey)
258-
mc.mutex.Unlock()
259-
return false, nil
262+
return true, nil
260263
}
261264

262265
mc.mutex.RUnlock()
263-
return true, nil
266+
// Remove expired item
267+
mc.mutex.Lock()
268+
mc.removeElement(element, formattedKey)
269+
mc.mutex.Unlock()
270+
return false, nil
264271
}
265272

266273
// Clear removes all entries from the cache
@@ -364,13 +371,11 @@ func (mc *MemoryCache) SetTTL(_ context.Context, key string, ttl time.Duration)
364371
return NewCacheError("setttl", key, errors.New("invalid item type"))
365372
}
366373
item := memItem.item
367-
374+
item.ExpiresAt = time.Time{}
375+
item.TTL = 0
368376
if ttl > 0 {
369377
item.ExpiresAt = time.Now().Add(ttl)
370378
item.TTL = ttl
371-
} else {
372-
item.ExpiresAt = time.Time{}
373-
item.TTL = 0
374379
}
375380

376381
item.UpdatedAt = time.Now()
@@ -430,17 +435,20 @@ func (mc *MemoryCache) evictLRU() {
430435
}
431436

432437
element := mc.lruList.Back()
433-
if element != nil {
434-
memItem, ok := element.Value.(*memoryItem)
435-
if !ok {
436-
return // Skip if invalid type
437-
}
438-
key := memItem.item.Key
439-
mc.removeElement(element, key)
438+
if element == nil {
439+
return
440+
}
440441

441-
mc.updateStats(func(s *Stats) { s.Evictions++ })
442-
mc.emitEvent(EventEvict, key, nil, nil)
442+
memItem, ok := element.Value.(*memoryItem)
443+
if !ok {
444+
return // Skip if invalid type
443445
}
446+
447+
key := memItem.item.Key
448+
mc.removeElement(element, key)
449+
450+
mc.updateStats(func(s *Stats) { s.Evictions++ })
451+
mc.emitEvent(EventEvict, key, nil, nil)
444452
}
445453

446454
// startCleanup starts the background cleanup goroutine
@@ -478,16 +486,21 @@ func (mc *MemoryCache) cleanupExpired() {
478486
}
479487
item := memItem.item
480488

481-
if !item.ExpiresAt.IsZero() && now.After(item.ExpiresAt) {
482-
expiredKeys = append(expiredKeys, key)
489+
if item.ExpiresAt.IsZero() || !now.After(item.ExpiresAt) {
490+
continue
483491
}
492+
493+
expiredKeys = append(expiredKeys, key)
484494
}
485495

486496
// Remove expired items
487497
for _, key := range expiredKeys {
488-
if element, exists := mc.items[key]; exists {
489-
mc.removeElement(element, key)
490-
mc.emitEvent(EventExpire, key, nil, nil)
498+
element, exists := mc.items[key]
499+
if !exists {
500+
continue
491501
}
502+
503+
mc.removeElement(element, key)
504+
mc.emitEvent(EventExpire, key, nil, nil)
492505
}
493506
}

cache/redis.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,9 @@ func (rc *RedisCache) GetMulti(ctx context.Context, keys []string) (map[string]i
249249
}
250250
result[keys[i]] = dest
251251
rc.updateStats(func(s *Stats) { s.Hits++ })
252+
continue
252253
}
253-
} else {
254+
254255
rc.updateStats(func(s *Stats) { s.Misses++ })
255256
}
256257
}
@@ -334,7 +335,8 @@ func (rc *RedisCache) SetTTL(ctx context.Context, key string, ttl time.Duration)
334335
var err error
335336
if ttl > 0 {
336337
err = rc.client.Expire(ctx, formattedKey, ttl).Err()
337-
} else {
338+
}
339+
if ttl <= 0 {
338340
err = rc.client.Persist(ctx, formattedKey).Err()
339341
}
340342

cache/tiered.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,8 @@ func (tc *TieredCache) GetMulti(ctx context.Context, keys []string) (map[string]
184184
if err != nil {
185185
tc.recordError(err)
186186
// Continue to L2 even if L1 fails
187-
} else {
187+
}
188+
if err == nil {
188189
for key, value := range l1Results {
189190
result[key] = value
190191
}

database/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func (c *Config) Validate() error {
4747
return nil
4848
}
4949

50+
// Changed checks if the configuration has changed
5051
func (c *Config) Changed(n *Config) bool {
5152
if c.Driver != n.Driver {
5253
return true

database/database_test.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,10 @@ func TestExecuteWithoutConnection(t *testing.T) {
157157
// Ensure we're in a disconnected state for this test
158158
// Only disconnect if currently connected to avoid hanging
159159
if database.Connected() {
160-
database.Disconnect()
160+
err := database.Disconnect()
161+
if err != nil {
162+
t.Errorf("Disconnect should not return an error: %v", err)
163+
}
161164
time.Sleep(100 * time.Millisecond) // Wait for disconnection
162165
}
163166

@@ -184,7 +187,10 @@ func TestReconnect(t *testing.T) {
184187
func TestAwaitConnectionTimeout(t *testing.T) {
185188
// Ensure we start in a disconnected state for this test
186189
if database.Connected() {
187-
database.Disconnect()
190+
err := database.Disconnect()
191+
if err != nil {
192+
t.Errorf("Disconnect should not return an error: %v", err)
193+
}
188194
time.Sleep(200 * time.Millisecond) // Wait for disconnection
189195
}
190196

@@ -230,7 +236,10 @@ func TestConnectWithInvalidConfig(t *testing.T) {
230236
}
231237

232238
// Clean up
233-
database.Disconnect()
239+
err := database.Disconnect()
240+
if err != nil {
241+
t.Errorf("Disconnect should not return an error: %v", err)
242+
}
234243
}
235244

236245
func TestConnectWithSQLiteConfig(t *testing.T) {

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ require (
1010
github.com/fsnotify/fsnotify v1.9.0
1111
github.com/google/uuid v1.6.0
1212
github.com/gorilla/websocket v1.5.3
13-
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
1413
github.com/rabbitmq/amqp091-go v1.10.0
1514
github.com/redis/go-redis/v9 v9.12.1
1615
github.com/rs/zerolog v1.34.0
1716
github.com/spf13/pflag v1.0.7
1817
github.com/spf13/viper v1.20.1
1918
github.com/stoewer/go-strcase v1.3.1
19+
github.com/stretchr/testify v1.10.0
2020
golang.org/x/sync v0.16.0
2121
golang.org/x/text v0.28.0
2222
golang.org/x/time v0.12.0
@@ -31,6 +31,7 @@ require (
3131
filippo.io/edwards25519 v1.1.0 // indirect
3232
github.com/ProtonMail/go-crypto v1.3.0 // indirect
3333
github.com/cloudflare/circl v1.6.1 // indirect
34+
github.com/davecgh/go-spew v1.1.1 // indirect
3435
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
3536
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
3637
github.com/go-sql-driver/mysql v1.8.1 // indirect
@@ -41,6 +42,7 @@ require (
4142
github.com/mattn/go-isatty v0.0.19 // indirect
4243
github.com/mattn/go-sqlite3 v1.14.22 // indirect
4344
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
45+
github.com/pmezard/go-difflib v1.0.0 // indirect
4446
github.com/sagikazarmark/locafero v0.7.0 // indirect
4547
github.com/sourcegraph/conc v0.3.0 // indirect
4648
github.com/spf13/afero v1.12.0 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
4343
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
4444
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
4545
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
46-
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
47-
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
4846
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
4947
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
5048
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

mail/config.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,34 @@ type ServerConfig struct {
8484
AllowInsecureAuth bool `yaml:"allow_insecure_auth" json:"allow_insecure_auth"`
8585
// MaxConcurrentHandlers limits the number of concurrent notification handlers
8686
MaxConcurrentHandlers int `yaml:"max_concurrent_handlers" json:"max_concurrent_handlers"`
87+
// Security holds the security configuration for the SMTP server
88+
Security SecurityConfig `yaml:"security" json:"security"`
89+
}
90+
91+
// SecurityConfig holds security-related configuration for the smtp server
92+
type SecurityConfig struct {
93+
// HeloValidation enables HELO/EHLO hostname validation
94+
HeloValidation bool `yaml:"helo_validation" json:"helo_validation"`
95+
// HeloRequireFQDN requires HELO hostname to be a fully qualified domain name
96+
HeloRequireFQDN bool `yaml:"helo_require_fqdn" json:"helo_require_fqdn"`
97+
// HeloDNSCheck enables DNS resolution check for HELO hostname
98+
HeloDNSCheck bool `yaml:"helo_dns_check" json:"helo_dns_check"`
99+
// IPAllowlist contains allowed IP addresses/CIDR blocks
100+
IPAllowlist []string `yaml:"ip_allowlist" json:"ip_allowlist"`
101+
// IPBlocklist contains blocked IP addresses/CIDR blocks
102+
IPBlocklist []string `yaml:"ip_blocklist" json:"ip_blocklist"`
103+
// MaxConnectionsPerIP limits connections per IP address
104+
MaxConnectionsPerIP int `yaml:"max_connections_per_ip" json:"max_connections_per_ip"`
105+
// RateLimitPerIP limits commands per IP per minute
106+
RateLimitPerIP int `yaml:"rate_limit_per_ip" json:"rate_limit_per_ip"`
107+
// AuthFailureDelay adds delay after authentication failures
108+
AuthFailureDelay time.Duration `yaml:"auth_failure_delay" json:"auth_failure_delay"`
109+
// MaxAuthFailures limits auth attempts before blocking IP
110+
MaxAuthFailures int `yaml:"max_auth_failures" json:"max_auth_failures"`
111+
// AuthFailureWindow is the time window to track auth failures
112+
AuthFailureWindow time.Duration `yaml:"auth_failure_window" json:"auth_failure_window"`
113+
// LogSecurityEvents enables detailed security logging
114+
LogSecurityEvents bool `yaml:"log_security_events" json:"log_security_events"`
87115
}
88116

89117
// QueueConfig holds the queue configuration for mail processing
@@ -143,6 +171,19 @@ func DefaultConfig() *Config {
143171
MaxRecipients: 100,
144172
AllowInsecureAuth: false,
145173
MaxConcurrentHandlers: 50, // Limit concurrent notification handlers
174+
Security: SecurityConfig{
175+
HeloValidation: false,
176+
HeloRequireFQDN: false,
177+
HeloDNSCheck: false,
178+
IPAllowlist: []string{},
179+
IPBlocklist: []string{},
180+
MaxConnectionsPerIP: 10,
181+
RateLimitPerIP: 60, // 60 commands per minute
182+
AuthFailureDelay: time.Second,
183+
MaxAuthFailures: 5,
184+
AuthFailureWindow: 15 * time.Minute,
185+
LogSecurityEvents: true,
186+
},
146187
},
147188
Queue: QueueConfig{
148189
Enabled: true,
@@ -168,6 +209,7 @@ func (c *ClientConfig) TLSConfig() *tls.Config {
168209
}
169210
}
170211

212+
// Validate checks the client configuration for errors
171213
func (c *ClientConfig) Validate() error {
172214
if c.Host == "" {
173215
return apperror.NewError("SMTP host is required")
@@ -199,6 +241,7 @@ func (c *ServerConfig) TLSConfig() *tls.Config {
199241
}
200242
}
201243

244+
// Validate checks the server configuration for errors
202245
func (c *ServerConfig) Validate() error {
203246
if c.Host == "" {
204247
return apperror.NewError("SMTP server host is required")
@@ -215,6 +258,7 @@ func (c *ServerConfig) Validate() error {
215258
return nil
216259
}
217260

261+
// Validate checks the queue configuration for errors
218262
func (c *QueueConfig) Validate() error {
219263
if c.QueueName == "" {
220264
return apperror.NewError("Queue name is required")
@@ -228,13 +272,15 @@ func (c *QueueConfig) Validate() error {
228272
return nil
229273
}
230274

275+
// Validate checks the template configuration for errors
231276
func (c *TemplateConfig) Validate() error {
232277
if c.DefaultTemplate == "" {
233278
return apperror.NewError("Default template is required")
234279
}
235280
return nil
236281
}
237282

283+
// Validate checks the configuration for errors
238284
func (c *Config) Validate() error {
239285
if err := c.Client.Validate(); err != nil {
240286
return apperror.Wrap(err)

0 commit comments

Comments
 (0)