From 13ea1af20e7e828797d734d483a219ef8aeb02cb Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 4 Aug 2026 10:45:34 +0200 Subject: [PATCH 01/11] feat(model): update alias.go --- api/internal/model/alias.go | 9 +++++++++ api/internal/transport/api/alias.go | 1 + 2 files changed, 10 insertions(+) diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go index 7636d81d..4715ea1c 100644 --- a/api/internal/model/alias.go +++ b/api/internal/model/alias.go @@ -11,6 +11,14 @@ var ( ErrDuplicateAliasDomain = errors.New("wildcard aliases limit reached for this domain") ) +type AliasOrigin int + +const ( + Manual AliasOrigin = 0 + Inbound AliasOrigin = 1 + Import AliasOrigin = 2 +) + type Alias struct { BaseModel DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` @@ -21,6 +29,7 @@ type Alias struct { Recipients string `gorm:"default:''" json:"recipients"` FromName string `gorm:"default:''" json:"from_name"` CatchAll bool `json:"catch_all"` + Origin AliasOrigin `json:"origin"` Stats AliasStats `gorm:"-" json:"stats"` IsCustomDomain bool `gorm:"-" json:"is_custom_domain"` IsDomainVerified *bool `gorm:"-" json:"is_domain_verified"` diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index c7080232..16f38ee4 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -253,6 +253,7 @@ func (h *Handler) PostAlias(c *fiber.Ctx) error { Enabled: req.Enabled, Recipients: model.GetEmails(rcps), FromName: req.FromName, + Origin: model.Manual, } localPart := req.LocalPart From 66c2c9b19dde58176de0b8f1f3138bbeb23dc040 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 4 Aug 2026 10:57:10 +0200 Subject: [PATCH 02/11] feat(model): update domain.go --- api/internal/model/domain.go | 1 + api/internal/repository/domain.go | 1 + api/internal/transport/api/domain.go | 1 + api/internal/transport/api/req.go | 1 + 4 files changed, 4 insertions(+) diff --git a/api/internal/model/domain.go b/api/internal/model/domain.go index 1c467661..3990434c 100644 --- a/api/internal/model/domain.go +++ b/api/internal/model/domain.go @@ -21,6 +21,7 @@ type Domain struct { MXVerifiedAt *time.Time `json:"mx_verified_at"` // nullable SendVerifiedAt *time.Time `json:"send_verified_at"` // nullable CatchAll bool `gorm:"default:false" json:"catch_all"` + CreateAlias bool `gorm:"default:false" json:"create_alias"` } type DNSConfig struct { diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go index 5a59e1e0..f35edcae 100644 --- a/api/internal/repository/domain.go +++ b/api/internal/repository/domain.go @@ -64,6 +64,7 @@ func (d *Database) UpdateDomain(ctx context.Context, domain model.Domain) error "mx_verified_at": domain.MXVerifiedAt, "send_verified_at": domain.SendVerifiedAt, "catch_all": domain.CatchAll, + "create_alias": domain.CreateAlias, }).Error } diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index ca9e4935..9754c261 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -169,6 +169,7 @@ func (h *Handler) UpdateDomain(c *fiber.Ctx) error { domain.FromName = req.FromName domain.Enabled = req.Enabled domain.CatchAll = req.CatchAll + domain.CreateAlias = req.CreateAlias // Update domain err = h.Service.UpdateDomain(c.Context(), domain) diff --git a/api/internal/transport/api/req.go b/api/internal/transport/api/req.go index b06a8a78..47744efb 100644 --- a/api/internal/transport/api/req.go +++ b/api/internal/transport/api/req.go @@ -109,4 +109,5 @@ type UpdateDomainReq struct { FromName string `json:"from_name"` Enabled bool `json:"enabled"` CatchAll bool `json:"catch_all"` + CreateAlias bool `json:"create_alias"` } From 8d99c4f15514185c0fcd90d485dcf3fd1f89e4fe Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 4 Aug 2026 11:54:29 +0200 Subject: [PATCH 03/11] feat(service): update processor.go --- api/internal/service/alias.go | 9 +++++++++ api/internal/service/processor.go | 9 +++++++++ api/internal/service/recipient.go | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index ef6d575f..c5702d83 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -50,6 +50,15 @@ func aliasDomainPart(name string) string { return "" } +// aliasLocalPart returns the local portion of an alias name (e.g. "user@example.com" → "user"). +func aliasLocalPart(name string) string { + parts := strings.SplitN(name, "@", 2) + if len(parts) == 2 { + return parts[0] + } + return "" +} + // isCustomAliasDomain reports whether domainPart is not one of the predefined built-in domains. func isCustomAliasDomain(domainPart, predefinedDomains string) bool { return !strings.Contains(predefinedDomains, domainPart) diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index b4bec40d..86feb217 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -180,6 +180,15 @@ func (s *Service) ProcessMessage(data []byte) error { log.Println("error saving message", err) } + if alias.Origin == model.Inbound { + domain := aliasDomainPart(alias.Name) + localPart := aliasLocalPart(alias.Name) + alias, err = s.PostAlias(context.Background(), alias, model.AliasFormatCustom, domain, localPart) + if err != nil { + log.Println("error creating catch-all alias", err) + } + } + return nil }) } diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go index ca2660e3..08877155 100644 --- a/api/internal/service/recipient.go +++ b/api/internal/service/recipient.go @@ -390,7 +390,7 @@ func (s *Service) resolveCatchAll(domainPart string, aliasName string) (bool, [] return false, nil, model.Alias{}, nil } - catchAllAlias := model.Alias{Name: aliasName, UserID: domain.UserID, FromName: domain.FromName} + catchAllAlias := model.Alias{Name: aliasName, UserID: domain.UserID, FromName: domain.FromName, Origin: model.Inbound} if !domain.Enabled { if err = s.SaveMessage(context.Background(), catchAllAlias, model.Block); err != nil { From 12f0b158bb295693b0e232bd305a0dc83e6869e0 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 4 Aug 2026 12:18:28 +0200 Subject: [PATCH 04/11] tests: create alias_test.go --- api/internal/service/alias_test.go | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 api/internal/service/alias_test.go diff --git a/api/internal/service/alias_test.go b/api/internal/service/alias_test.go new file mode 100644 index 00000000..991a3680 --- /dev/null +++ b/api/internal/service/alias_test.go @@ -0,0 +1,123 @@ +package service + +import ( + "testing" + + "ivpn.net/email/api/internal/model" +) + +func TestAliasDomainPart(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "standard email", input: "user@example.com", expected: "example.com"}, + {name: "multiple at signs", input: "user@foo@example.com", expected: "foo@example.com"}, + {name: "no at sign", input: "userexample.com", expected: ""}, + {name: "empty string", input: "", expected: ""}, + {name: "only at sign", input: "@", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := aliasDomainPart(tt.input) + if got != tt.expected { + t.Errorf("aliasDomainPart(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestAliasLocalPart(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "standard email", input: "user@example.com", expected: "user"}, + {name: "multiple at signs", input: "user@foo@example.com", expected: "user"}, + {name: "no at sign", input: "userexample.com", expected: ""}, + {name: "empty string", input: "", expected: ""}, + {name: "only at sign", input: "@", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := aliasLocalPart(tt.input) + if got != tt.expected { + t.Errorf("aliasLocalPart(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestIsCustomAliasDomain(t *testing.T) { + tests := []struct { + name string + domainPart string + predefinedDomains string + expected bool + }{ + {name: "domain in predefined list", domainPart: "example.com", predefinedDomains: "example.com,other.com", expected: false}, + {name: "domain not in predefined list", domainPart: "custom.com", predefinedDomains: "example.com,other.com", expected: true}, + {name: "empty predefined domains", domainPart: "example.com", predefinedDomains: "", expected: true}, + {name: "empty domain part", domainPart: "", predefinedDomains: "example.com", expected: false}, + {name: "single match", domainPart: "other.com", predefinedDomains: "other.com", expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCustomAliasDomain(tt.domainPart, tt.predefinedDomains) + if got != tt.expected { + t.Errorf("isCustomAliasDomain(%q, %q) = %v, want %v", tt.domainPart, tt.predefinedDomains, got, tt.expected) + } + }) + } +} + +func TestIsCustomDomainEnabled(t *testing.T) { + tests := []struct { + name string + domainPart string + verifiedDomains []model.Domain + expected bool + }{ + { + name: "domain found and enabled", + domainPart: "example.com", + verifiedDomains: []model.Domain{ + {Name: "example.com", Enabled: true}, + {Name: "other.com", Enabled: false}, + }, + expected: true, + }, + { + name: "domain found and disabled", + domainPart: "example.com", + verifiedDomains: []model.Domain{ + {Name: "example.com", Enabled: false}, + }, + expected: false, + }, + { + name: "domain not in list", + domainPart: "missing.com", + verifiedDomains: []model.Domain{ + {Name: "example.com", Enabled: true}, + }, + expected: false, + }, + {name: "empty domain list", domainPart: "example.com", verifiedDomains: []model.Domain{}, expected: false}, + {name: "nil domain list", domainPart: "example.com", verifiedDomains: nil, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCustomDomainEnabled(tt.domainPart, tt.verifiedDomains) + if got != tt.expected { + t.Errorf("isCustomDomainEnabled(%q, ...) = %v, want %v", tt.domainPart, got, tt.expected) + } + }) + } +} From 58719eff84f4f5cce264efd93fbfe6bae8d24cb6 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 4 Aug 2026 12:38:34 +0200 Subject: [PATCH 05/11] feat(service): update alias.go --- api/internal/service/alias.go | 45 +++++++++++++++++++++++++++++ api/internal/service/alias_test.go | 46 ++++++++++++++++++++++++++++++ api/internal/service/processor.go | 9 ++---- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index c5702d83..fecc6f03 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -74,6 +74,16 @@ func isCustomDomainEnabled(domainPart string, verifiedDomains []model.Domain) bo return false } +// isCreateAliasEnabled checks if the given domainPart is in the list of verified domains and has CreateAlias enabled. +func isCreateAliasEnabled(domainPart string, verifiedDomains []model.Domain) bool { + for _, d := range verifiedDomains { + if d.Name == domainPart { + return d.CreateAlias + } + } + return false +} + func (s *Service) GetAlias(ctx context.Context, ID string, userID string) (model.Alias, error) { alias, err := s.Store.GetAlias(ctx, ID, userID) if err != nil { @@ -237,6 +247,41 @@ func (s *Service) PostAlias(ctx context.Context, alias model.Alias, format strin return alias, nil } +func (s *Service) PostInboundAlias(ctx context.Context, alias model.Alias) error { + if alias.Origin != model.Inbound { + return nil + } + + domain := aliasDomainPart(alias.Name) + + if !isCustomAliasDomain(domain, s.Cfg.API.Domains) { + return nil + } + + domains, err := s.Store.GetVerifiedDomains(ctx, alias.UserID) + if err != nil { + log.Printf("error fetching verified domains: %s", err.Error()) + return nil + } + + if !isCustomDomainEnabled(domain, domains) { + return nil + } + + if !isCreateAliasEnabled(domain, domains) { + return nil + } + + localPart := aliasLocalPart(alias.Name) + alias, err = s.PostAlias(ctx, alias, model.AliasFormatCustom, domain, localPart) + if err != nil { + log.Printf("error creating inbound alias: %s", err.Error()) + return ErrPostAlias + } + + return nil +} + func (s *Service) UpdateAlias(ctx context.Context, alias model.Alias) error { err := s.Store.UpdateAlias(ctx, alias) if err != nil { diff --git a/api/internal/service/alias_test.go b/api/internal/service/alias_test.go index 991a3680..7901bc87 100644 --- a/api/internal/service/alias_test.go +++ b/api/internal/service/alias_test.go @@ -121,3 +121,49 @@ func TestIsCustomDomainEnabled(t *testing.T) { }) } } + +func TestIsCreateAliasEnabled(t *testing.T) { + tests := []struct { + name string + domainPart string + verifiedDomains []model.Domain + expected bool + }{ + { + name: "domain found and create alias enabled", + domainPart: "example.com", + verifiedDomains: []model.Domain{ + {Name: "example.com", CreateAlias: true}, + {Name: "other.com", CreateAlias: false}, + }, + expected: true, + }, + { + name: "domain found and create alias disabled", + domainPart: "example.com", + verifiedDomains: []model.Domain{ + {Name: "example.com", CreateAlias: false}, + }, + expected: false, + }, + { + name: "domain not in list", + domainPart: "missing.com", + verifiedDomains: []model.Domain{ + {Name: "example.com", CreateAlias: true}, + }, + expected: false, + }, + {name: "empty domain list", domainPart: "example.com", verifiedDomains: []model.Domain{}, expected: false}, + {name: "nil domain list", domainPart: "example.com", verifiedDomains: nil, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCreateAliasEnabled(tt.domainPart, tt.verifiedDomains) + if got != tt.expected { + t.Errorf("isCreateAliasEnabled(%q, ...) = %v, want %v", tt.domainPart, got, tt.expected) + } + }) + } +} diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index 86feb217..c5b3e9be 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -180,13 +180,8 @@ func (s *Service) ProcessMessage(data []byte) error { log.Println("error saving message", err) } - if alias.Origin == model.Inbound { - domain := aliasDomainPart(alias.Name) - localPart := aliasLocalPart(alias.Name) - alias, err = s.PostAlias(context.Background(), alias, model.AliasFormatCustom, domain, localPart) - if err != nil { - log.Println("error creating catch-all alias", err) - } + if err := s.PostInboundAlias(context.Background(), alias); err != nil { + log.Println("error posting inbound alias", err) } return nil From 3526458bd1b9ce49c238ee007a6cd3cc2b7aea37 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 4 Aug 2026 12:46:32 +0200 Subject: [PATCH 06/11] feat(service): update processor.go --- api/internal/service/processor.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index c5b3e9be..7f811fa7 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -176,13 +176,17 @@ func (s *Service) ProcessMessage(data []byte) error { return err } - if err := s.SaveMessage(context.Background(), alias, relayType); err != nil { - log.Println("error saving message", err) - } + go func() { + err := s.SaveMessage(context.Background(), alias, relayType) + if err != nil { + log.Println("error saving message", err) + } - if err := s.PostInboundAlias(context.Background(), alias); err != nil { - log.Println("error posting inbound alias", err) - } + err = s.PostInboundAlias(context.Background(), alias) + if err != nil { + log.Println("error posting inbound alias", err) + } + }() return nil }) From 5548150371195bc803fb745bc8ee598a85d2d221 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 4 Aug 2026 13:04:23 +0200 Subject: [PATCH 07/11] feat(app): update DomainEdit.vue --- api/internal/model/alias.go | 15 +++++++++++++++ api/internal/repository/alias.go | 2 +- app/src/components/DomainEdit.vue | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go index 4715ea1c..e1f0281d 100644 --- a/api/internal/model/alias.go +++ b/api/internal/model/alias.go @@ -2,6 +2,7 @@ package model import ( "errors" + "fmt" "gorm.io/gorm" ) @@ -19,6 +20,20 @@ const ( Import AliasOrigin = 2 ) +// Scan handles NULL origin values from rows predating the column addition. +func (a *AliasOrigin) Scan(src any) error { + if src == nil { + *a = Manual + return nil + } + v, ok := src.(int64) + if !ok { + return fmt.Errorf("AliasOrigin: unsupported scan type %T", src) + } + *a = AliasOrigin(v) + return nil +} + type Alias struct { BaseModel DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` diff --git a/api/internal/repository/alias.go b/api/internal/repository/alias.go index 05fc15d6..460a00e1 100644 --- a/api/internal/repository/alias.go +++ b/api/internal/repository/alias.go @@ -88,7 +88,7 @@ func (d *Database) GetAliases(ctx context.Context, userID string, limit int, off for rows.Next() { var alias model.Alias var forwards, blocks, replies, sends int - if err := rows.Scan(&alias.ID, &alias.CreatedAt, &alias.UpdatedAt, &alias.DeletedAt, &alias.Name, &alias.UserID, &alias.Enabled, &alias.Description, &alias.Recipients, &alias.FromName, &alias.CatchAll, &forwards, &blocks, &replies, &sends); err != nil { + if err := rows.Scan(&alias.ID, &alias.CreatedAt, &alias.UpdatedAt, &alias.DeletedAt, &alias.Name, &alias.UserID, &alias.Enabled, &alias.Description, &alias.Recipients, &alias.FromName, &alias.CatchAll, &alias.Origin, &forwards, &blocks, &replies, &sends); err != nil { return nil, err } alias.Stats = model.AliasStats{ diff --git a/app/src/components/DomainEdit.vue b/app/src/components/DomainEdit.vue index f7d8469d..3e9b6ad9 100644 --- a/app/src/components/DomainEdit.vue +++ b/app/src/components/DomainEdit.vue @@ -47,6 +47,21 @@ /> +
+

Create alias when receiving Catch-All emails

+

+ When enabled, a new alias will be created for every email received by the catch-all recipient. This allows you to track which emails are sent to your domain and manage them individually. +

+
+ +
+