From 13ea1af20e7e828797d734d483a219ef8aeb02cb Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 4 Aug 2026 10:45:34 +0200
Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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.
+
+
+
+
+
+
+
@@ -88,6 +103,7 @@ const updateDomain = async () => {
from_name: fromName.value,
enabled: domain.value.enabled,
catch_all: domain.value.catch_all,
+ create_alias: domain.value.create_alias,
}
try {
From b509546350cd56693b74667b55b73a9c9cd089a3 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 4 Aug 2026 13:43:52 +0200
Subject: [PATCH 08/16] feat(service): update alias.go
---
api/.env.sample | 1 +
api/config/config.go | 39 +++++++++++++++++++-------------
api/internal/repository/alias.go | 6 +++++
api/internal/service/alias.go | 11 +++++++++
4 files changed, 41 insertions(+), 16 deletions(-)
diff --git a/api/.env.sample b/api/.env.sample
index 37bb0631..119604b8 100644
--- a/api/.env.sample
+++ b/api/.env.sample
@@ -58,6 +58,7 @@ OTP_EXPIRATION=15m
MAX_CREDENTIALS=10
MAX_RECIPIENTS=10
MAX_DAILY_ALIASES=100
+MAX_INBOUND_ALIASES_PER_HOUR=10
MAX_DAILY_SEND_REPLY=100
MAX_SESSIONS=10
ID_LIMITER_MAX=5
diff --git a/api/config/config.go b/api/config/config.go
index 7cc2fdb3..1dec1c58 100644
--- a/api/config/config.go
+++ b/api/config/config.go
@@ -66,14 +66,15 @@ type SMTPClientConfig struct {
}
type ServiceConfig struct {
- OTPExpiration time.Duration
- MaxCredentials int
- MaxRecipients int
- MaxDailyAliases int
- MaxDailySendReply int
- MaxSessions int
- IdLimiterMax int
- IdLimiterExpiration time.Duration
+ OTPExpiration time.Duration
+ MaxCredentials int
+ MaxRecipients int
+ MaxDailyAliases int
+ MaxDailySendReply int
+ MaxSessions int
+ IdLimiterMax int
+ IdLimiterExpiration time.Duration
+ MaxInboundAliasesPerHour int
}
type Config struct {
@@ -138,6 +139,11 @@ func New() (Config, error) {
return Config{}, err
}
+ maxInboundAliasesPerHour, err := strconv.Atoi(os.Getenv("MAX_INBOUND_ALIASES_PER_HOUR"))
+ if err != nil {
+ return Config{}, err
+ }
+
dbHosts := strings.Split(os.Getenv("DB_HOSTS"), ",")
redisAddrs := strings.Split(os.Getenv("REDIS_ADDRESSES"), ",")
apiTrustedProxies := strings.Split(os.Getenv("API_TRUSTED_PROXIES"), ",")
@@ -206,14 +212,15 @@ func New() (Config, error) {
},
Service: ServiceConfig{
- OTPExpiration: otpExp,
- MaxCredentials: maxCredentials,
- MaxRecipients: maxRecipients,
- MaxDailyAliases: maxDailyAliases,
- MaxDailySendReply: maxDailySendReply,
- MaxSessions: maxSessions,
- IdLimiterMax: idLimiterMax,
- IdLimiterExpiration: idLimiterExpiration,
+ OTPExpiration: otpExp,
+ MaxCredentials: maxCredentials,
+ MaxRecipients: maxRecipients,
+ MaxDailyAliases: maxDailyAliases,
+ MaxDailySendReply: maxDailySendReply,
+ MaxSessions: maxSessions,
+ MaxInboundAliasesPerHour: maxInboundAliasesPerHour,
+ IdLimiterMax: idLimiterMax,
+ IdLimiterExpiration: idLimiterExpiration,
},
}, nil
}
diff --git a/api/internal/repository/alias.go b/api/internal/repository/alias.go
index 460a00e1..8b95b129 100644
--- a/api/internal/repository/alias.go
+++ b/api/internal/repository/alias.go
@@ -141,6 +141,12 @@ func (d *Database) GetAliasCount(ctx context.Context, userID string, catchAll st
return int(count), err
}
+func (d *Database) GetCreatedAliasesCount(ctx context.Context, userID string) (int, error) {
+ var count int64
+ err := d.Client.Model(&model.Alias{}).Where("user_id = ? AND origin = ? AND created_at > NOW() - INTERVAL 1 HOUR", userID, model.Inbound).Count(&count).Error
+ return int(count), err
+}
+
func (d *Database) GetAliasDailyCount(ctx context.Context, userID string) (int, error) {
var count int64
err := d.Client.Model(&model.Alias{}).Where("user_id = ? AND created_at > NOW() - INTERVAL 1 DAY", userID).Count(&count).Error
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index fecc6f03..712a8387 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -31,6 +31,7 @@ type AliasStore interface {
GetAliasesByDomain(context.Context, string, string) ([]model.Alias, error)
GetAllAliases(context.Context, string) ([]model.Alias, error)
GetAliasCount(context.Context, string, string, string, string) (int, error)
+ GetCreatedAliasesCount(context.Context, string) (int, error)
GetAliasDailyCount(context.Context, string) (int, error)
GetAliasByName(string) (model.Alias, error)
PostAlias(context.Context, model.Alias) (model.Alias, error)
@@ -272,6 +273,16 @@ func (s *Service) PostInboundAlias(ctx context.Context, alias model.Alias) error
return nil
}
+ count, err := s.Store.GetCreatedAliasesCount(ctx, alias.UserID)
+ if err != nil {
+ return nil
+ }
+
+ if count >= s.Cfg.Service.MaxInboundAliasesPerHour {
+ log.Printf("user reached maximum number of inbound aliases per hour for domain: %s", domain)
+ return nil
+ }
+
localPart := aliasLocalPart(alias.Name)
alias, err = s.PostAlias(ctx, alias, model.AliasFormatCustom, domain, localPart)
if err != nil {
From f01f75da104298f897ea86db5903ce2b44ed169b Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Thu, 6 Aug 2026 14:46:16 +0200
Subject: [PATCH 09/16] feat(service): update alias.go
---
api/internal/service/alias.go | 21 +++++++++++----------
api/internal/service/processor.go | 10 +++++-----
api/internal/service/recipient.go | 2 +-
3 files changed, 17 insertions(+), 16 deletions(-)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 712a8387..6202a698 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -17,6 +17,7 @@ var (
ErrDisabledAlias = errors.New("alias disabled:")
ErrDisabledDomain = errors.New("domain disabled:")
ErrPostAlias = errors.New("Unable to create alias. Please try again.")
+ ErrPostInboundAlias = errors.New("Unable to create inbound alias. Please try again.")
ErrPostAliasLimit = errors.New("You’ve reached the maximum number of allowed aliases.")
ErrPostAliasInactiveSub = errors.New("Your subscription is not active. Please renew to create new aliases.")
ErrUpdateAlias = errors.New("Unable to update alias. Please try again.")
@@ -248,49 +249,49 @@ 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 {
+func (s *Service) PostInboundAlias(ctx context.Context, alias model.Alias) (model.Alias, error) {
if alias.Origin != model.Inbound {
- return nil
+ return model.Alias{}, ErrPostInboundAlias
}
domain := aliasDomainPart(alias.Name)
if !isCustomAliasDomain(domain, s.Cfg.API.Domains) {
- return nil
+ return model.Alias{}, ErrPostInboundAlias
}
domains, err := s.Store.GetVerifiedDomains(ctx, alias.UserID)
if err != nil {
log.Printf("error fetching verified domains: %s", err.Error())
- return nil
+ return model.Alias{}, ErrPostInboundAlias
}
if !isCustomDomainEnabled(domain, domains) {
- return nil
+ return model.Alias{}, ErrPostInboundAlias
}
if !isCreateAliasEnabled(domain, domains) {
- return nil
+ return model.Alias{}, ErrPostInboundAlias
}
count, err := s.Store.GetCreatedAliasesCount(ctx, alias.UserID)
if err != nil {
- return nil
+ return model.Alias{}, ErrPostInboundAlias
}
if count >= s.Cfg.Service.MaxInboundAliasesPerHour {
log.Printf("user reached maximum number of inbound aliases per hour for domain: %s", domain)
- return nil
+ return model.Alias{}, ErrPostInboundAlias
}
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 model.Alias{}, ErrPostInboundAlias
}
- return nil
+ return alias, nil
}
func (s *Service) UpdateAlias(ctx context.Context, alias model.Alias) error {
diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go
index 7f811fa7..de3dba31 100644
--- a/api/internal/service/processor.go
+++ b/api/internal/service/processor.go
@@ -177,14 +177,14 @@ func (s *Service) ProcessMessage(data []byte) error {
}
go func() {
- err := s.SaveMessage(context.Background(), alias, relayType)
- if err != nil {
- log.Println("error saving message", err)
+ inboundAlias, err := s.PostInboundAlias(context.Background(), alias)
+ if err == nil {
+ alias.BaseModel = inboundAlias.BaseModel
}
- err = s.PostInboundAlias(context.Background(), alias)
+ err = s.SaveMessage(context.Background(), alias, relayType)
if err != nil {
- log.Println("error posting inbound alias", err)
+ log.Println("error saving message", err)
}
}()
diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go
index 08877155..4f3d412f 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, Origin: model.Inbound}
+ catchAllAlias := model.Alias{Name: aliasName, UserID: domain.UserID, FromName: domain.FromName, Origin: model.Inbound, Enabled: true}
if !domain.Enabled {
if err = s.SaveMessage(context.Background(), catchAllAlias, model.Block); err != nil {
From 2cf87f169e6f92808996af5de2c94d84e9fa9252 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Thu, 6 Aug 2026 15:58:22 +0200
Subject: [PATCH 10/16] feat(service): update recipient.go
---
api/internal/service/recipient.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go
index 4f3d412f..23384490 100644
--- a/api/internal/service/recipient.go
+++ b/api/internal/service/recipient.go
@@ -407,6 +407,7 @@ func (s *Service) resolveCatchAll(domainPart string, aliasName string) (bool, []
return true, nil, catchAllAlias, ErrNoRecipients
}
recipientEmail = settings.Recipient
+ catchAllAlias.Recipients = settings.Recipient
}
if recipientEmail == "" {
From 9f36059bf9b3dd06b726ae376c506f1bf2e9ed36 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Thu, 6 Aug 2026 17:17:28 +0200
Subject: [PATCH 11/16] feat(service): update recipient.go
---
api/internal/service/processor.go | 15 ++++++++++-----
api/internal/service/recipient.go | 3 ++-
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go
index de3dba31..fec0b041 100644
--- a/api/internal/service/processor.go
+++ b/api/internal/service/processor.go
@@ -169,19 +169,24 @@ func (s *Service) ProcessMessage(data []byte) error {
continue
}
+ // Handle Inbound Alias
+ if alias.Origin == model.Inbound {
+ inboundAlias, err := s.PostInboundAlias(context.Background(), alias)
+ if err == nil {
+ alias.BaseModel = inboundAlias.BaseModel
+ }
+ }
+
for _, recipient := range recipients {
g.Go(func() error {
+ // Queue Message
err = s.QueueMessage(msg.From, msg.FromName, recipient, data, alias, relayType, settings)
if err != nil {
return err
}
+ // Save Message for stats
go func() {
- inboundAlias, err := s.PostInboundAlias(context.Background(), alias)
- if err == nil {
- alias.BaseModel = inboundAlias.BaseModel
- }
-
err = s.SaveMessage(context.Background(), alias, relayType)
if err != nil {
log.Println("error saving message", err)
diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go
index 23384490..1f1cf441 100644
--- a/api/internal/service/recipient.go
+++ b/api/internal/service/recipient.go
@@ -407,13 +407,14 @@ func (s *Service) resolveCatchAll(domainPart string, aliasName string) (bool, []
return true, nil, catchAllAlias, ErrNoRecipients
}
recipientEmail = settings.Recipient
- catchAllAlias.Recipients = settings.Recipient
}
if recipientEmail == "" {
return true, nil, catchAllAlias, ErrNoRecipients
}
+ catchAllAlias.Recipients = recipientEmail
+
rcps, err := s.GetVerifiedRecipients(context.Background(), recipientEmail, domain.UserID)
if err != nil || len(rcps) == 0 {
return true, nil, catchAllAlias, ErrNoRecipients
From 1f3ccce2fb69ae73fcbb1ef6a77cd2809e0670ae Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 18 Aug 2026 09:57:23 +0200
Subject: [PATCH 12/16] feat(service): update alias.go
---
api/internal/service/alias.go | 2 +-
api/internal/service/processor.go | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 6202a698..69a14ac0 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -250,7 +250,7 @@ func (s *Service) PostAlias(ctx context.Context, alias model.Alias, format strin
}
func (s *Service) PostInboundAlias(ctx context.Context, alias model.Alias) (model.Alias, error) {
- if alias.Origin != model.Inbound {
+ if alias.Origin != model.Inbound || alias.ID != "" {
return model.Alias{}, ErrPostInboundAlias
}
diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go
index fec0b041..d51f81ab 100644
--- a/api/internal/service/processor.go
+++ b/api/internal/service/processor.go
@@ -170,7 +170,7 @@ func (s *Service) ProcessMessage(data []byte) error {
}
// Handle Inbound Alias
- if alias.Origin == model.Inbound {
+ if alias.Origin == model.Inbound && alias.ID == "" {
inboundAlias, err := s.PostInboundAlias(context.Background(), alias)
if err == nil {
alias.BaseModel = inboundAlias.BaseModel
From 4c1db88014eb09417509e645145636a8e8e1428d Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 18 Aug 2026 13:46:54 +0200
Subject: [PATCH 13/16] feat(service): update recipient.go
---
api/internal/model/message.go | 25 ++++++++++++++++---
api/internal/model/message_test.go | 40 ++++++++++++++++++++++++++++--
api/internal/service/recipient.go | 9 +++++++
3 files changed, 69 insertions(+), 5 deletions(-)
diff --git a/api/internal/model/message.go b/api/internal/model/message.go
index 252d4cf4..c3e46bea 100644
--- a/api/internal/model/message.go
+++ b/api/internal/model/message.go
@@ -62,14 +62,33 @@ func ParseReplyTo(email string) (string, string) {
return alias, rcp
}
- // If there is "+" in the email, convert alias to catch-all format
- if rcp != "" && strings.Contains(email, "+") {
- alias = "*" + email[strings.Index(email, "+"):]
+ // Plain sub-address tag (e.g. "alias+tag@domain.com"): resolve against the base alias.
+ if rcp != "" {
+ alias = email[:plusIndex] + email[strings.Index(email, "@"):]
}
return alias, ""
}
+// WildcardAlias returns the wildcard-suffix form of a plus-tagged address
+// (e.g. "anything+suffix@domain.com" -> "*+suffix@domain.com"), used to
+// match Wildcard Aliases when no exact alias exists for the tagged address.
+// ok is false when email has no plain "+" tag (none, or a reply-encoded one).
+func WildcardAlias(email string) (string, bool) {
+ atIndex := strings.Index(email, "@")
+ plusIndex := strings.Index(email, "+")
+ if plusIndex == -1 || atIndex == -1 || plusIndex > atIndex {
+ return "", false
+ }
+
+ rcp := email[plusIndex+1 : atIndex]
+ if rcp == "" || strings.Contains(rcp, "=") {
+ return "", false
+ }
+
+ return "*" + email[plusIndex:], true
+}
+
func GenerateReplyTo(alias string, to string) string {
replaced := strings.Replace(to, "@", "=", 1)
email := strings.Replace(alias, "@", "+"+replaced+"@", 1)
diff --git a/api/internal/model/message_test.go b/api/internal/model/message_test.go
index b23bc341..37520528 100644
--- a/api/internal/model/message_test.go
+++ b/api/internal/model/message_test.go
@@ -87,7 +87,7 @@ func TestParseReplyTo(t *testing.T) {
},
{
email: "user+reply@domain.com",
- expectedAlias: "*+reply@domain.com",
+ expectedAlias: "user@domain.com",
expectedRcp: "",
},
{
@@ -97,7 +97,7 @@ func TestParseReplyTo(t *testing.T) {
},
{
email: "user+catchall@domain.com",
- expectedAlias: "*+catchall@domain.com",
+ expectedAlias: "user@domain.com",
expectedRcp: "",
},
}
@@ -114,3 +114,39 @@ func TestParseReplyTo(t *testing.T) {
})
}
}
+
+func TestWildcardAlias(t *testing.T) {
+ tests := []struct {
+ email string
+ expectedAlias string
+ expectedOk bool
+ }{
+ {
+ email: "anything+shop@domain.com",
+ expectedAlias: "*+shop@domain.com",
+ expectedOk: true,
+ },
+ {
+ email: "user@domain.com",
+ expectedAlias: "",
+ expectedOk: false,
+ },
+ {
+ email: "user+reply=example.com@domain.com",
+ expectedAlias: "",
+ expectedOk: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.email, func(t *testing.T) {
+ alias, ok := WildcardAlias(tt.email)
+ if alias != tt.expectedAlias {
+ t.Errorf("expected alias %s, got %s", tt.expectedAlias, alias)
+ }
+ if ok != tt.expectedOk {
+ t.Errorf("expected ok %v, got %v", tt.expectedOk, ok)
+ }
+ })
+ }
+}
diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go
index 1f1cf441..f7838704 100644
--- a/api/internal/service/recipient.go
+++ b/api/internal/service/recipient.go
@@ -295,6 +295,15 @@ func (s *Service) FindRecipients(from string, to string, msgType model.MessageTy
aliasName, replyTo := model.ParseReplyTo(to)
alias, err := s.GetAliasByName(aliasName)
+ // Fall back to a Wildcard Alias match (e.g. "*+suffix@domain.com") before giving up.
+ if err != nil {
+ if wildcardName, ok := model.WildcardAlias(to); ok {
+ if wcAlias, wcErr := s.GetAliasByName(wildcardName); wcErr == nil {
+ alias, err = wcAlias, nil
+ }
+ }
+ }
+ // If we still don't have an alias, check for a catch-all domain.
if err != nil {
domainPart := aliasDomainPart(aliasName)
if isCustomAliasDomain(domainPart, s.Cfg.API.Domains) {
From 7d0c9de6b596e3a1240f7d15cc1b62bbd71cab1e Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Wed, 19 Aug 2026 10:24:00 +0200
Subject: [PATCH 14/16] tests: create recipient_test.go
---
api/internal/service/recipient_test.go | 263 +++++++++++++++++++++++++
1 file changed, 263 insertions(+)
create mode 100644 api/internal/service/recipient_test.go
diff --git a/api/internal/service/recipient_test.go b/api/internal/service/recipient_test.go
new file mode 100644
index 00000000..c594fd38
--- /dev/null
+++ b/api/internal/service/recipient_test.go
@@ -0,0 +1,263 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "ivpn.net/email/api/config"
+ "ivpn.net/email/api/internal/model"
+)
+
+var errNotFound = errors.New("not found")
+
+// fakeStore implements the Store interface. It embeds Store (as a nil
+// interface) so tests only need to override the methods FindRecipients and
+// its helpers actually call; anything else panics if invoked unexpectedly.
+type fakeStore struct {
+ Store
+
+ aliases map[string]model.Alias
+ domains map[string]model.Domain
+ settingsByUser map[string]model.Settings
+ verifiedRecipients map[string][]model.Recipient
+ recipients map[string][]model.Recipient
+ postedMessages []model.Message
+}
+
+func newFakeStore() *fakeStore {
+ return &fakeStore{
+ aliases: map[string]model.Alias{},
+ domains: map[string]model.Domain{},
+ settingsByUser: map[string]model.Settings{},
+ verifiedRecipients: map[string][]model.Recipient{},
+ recipients: map[string][]model.Recipient{},
+ }
+}
+
+func (f *fakeStore) GetAliasByName(name string) (model.Alias, error) {
+ alias, ok := f.aliases[name]
+ if !ok {
+ return model.Alias{}, errNotFound
+ }
+ return alias, nil
+}
+
+func (f *fakeStore) GetVerifiedDomainByName(ctx context.Context, name string) (model.Domain, error) {
+ domain, ok := f.domains[name]
+ if !ok {
+ return model.Domain{}, errNotFound
+ }
+ return domain, nil
+}
+
+func (f *fakeStore) GetSettings(ctx context.Context, userID string) (model.Settings, error) {
+ return f.settingsByUser[userID], nil
+}
+
+func (f *fakeStore) GetVerifiedRecipients(ctx context.Context, emails string, userID string) ([]model.Recipient, error) {
+ wanted := strings.Split(emails, ",")
+ var matches []model.Recipient
+ for _, rcp := range f.verifiedRecipients[userID] {
+ for _, email := range wanted {
+ if rcp.Email == email {
+ matches = append(matches, rcp)
+ }
+ }
+ }
+ return matches, nil
+}
+
+func (f *fakeStore) GetRecipients(ctx context.Context, userID string) ([]model.Recipient, error) {
+ return f.recipients[userID], nil
+}
+
+func (f *fakeStore) PostMessage(ctx context.Context, message model.Message) error {
+ f.postedMessages = append(f.postedMessages, message)
+ return nil
+}
+
+func newTestService(store *fakeStore) *Service {
+ return &Service{
+ Cfg: config.Config{
+ API: config.APIConfig{
+ Domains: "mailx.net",
+ },
+ },
+ Store: store,
+ }
+}
+
+func TestFindRecipients_PlusTagResolvesExistingAlias(t *testing.T) {
+ store := newFakeStore()
+ store.aliases["myalias@mailx.net"] = model.Alias{
+ BaseModel: model.BaseModel{ID: "alias-1"},
+ Name: "myalias@mailx.net",
+ UserID: "user-1",
+ Enabled: true,
+ Recipients: "rcpt@example.com",
+ }
+ store.recipients["user-1"] = []model.Recipient{{Email: "rcpt@example.com"}}
+ s := newTestService(store)
+
+ rcps, alias, msgType, err := s.FindRecipients("sender@somewhere.com", "myalias+shop@mailx.net", model.Send)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if alias.Name != "myalias@mailx.net" {
+ t.Errorf("expected alias name myalias@mailx.net, got %s", alias.Name)
+ }
+ if msgType != model.Forward {
+ t.Errorf("expected msgType Forward, got %v", msgType)
+ }
+ if len(rcps) != 1 || rcps[0].Email != "rcpt@example.com" {
+ t.Errorf("expected recipient rcpt@example.com, got %+v", rcps)
+ }
+}
+
+func TestFindRecipients_WildcardAliasFallbackWhenBaseAliasMissing(t *testing.T) {
+ store := newFakeStore()
+ store.aliases["*+news@customdomain.com"] = model.Alias{
+ BaseModel: model.BaseModel{ID: "alias-2"},
+ Name: "*+news@customdomain.com",
+ UserID: "user-2",
+ Enabled: true,
+ CatchAll: true,
+ Recipients: "rcpt@example.com",
+ }
+ store.domains["customdomain.com"] = model.Domain{Name: "customdomain.com", UserID: "user-2", Enabled: true}
+ store.recipients["user-2"] = []model.Recipient{{Email: "rcpt@example.com"}}
+ s := newTestService(store)
+
+ rcps, alias, msgType, err := s.FindRecipients("sender@somewhere.com", "anything+news@customdomain.com", model.Send)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if alias.Name != "*+news@customdomain.com" {
+ t.Errorf("expected wildcard alias match, got %s", alias.Name)
+ }
+ if msgType != model.Forward {
+ t.Errorf("expected msgType Forward, got %v", msgType)
+ }
+ if len(rcps) != 1 || rcps[0].Email != "rcpt@example.com" {
+ t.Errorf("expected recipient rcpt@example.com, got %+v", rcps)
+ }
+}
+
+func TestFindRecipients_ReplyToRoundTripUnaffectedByFix(t *testing.T) {
+ store := newFakeStore()
+ store.aliases["myalias@mailx.net"] = model.Alias{
+ BaseModel: model.BaseModel{ID: "alias-3"},
+ Name: "myalias@mailx.net",
+ UserID: "user-3",
+ Enabled: true,
+ }
+ store.verifiedRecipients["user-3"] = []model.Recipient{{Email: "sender@somewhere.com", IsActive: true}}
+ s := newTestService(store)
+
+ rcps, alias, msgType, err := s.FindRecipients("sender@somewhere.com", "myalias+contact=external.com@mailx.net", model.Reply)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if alias.Name != "myalias@mailx.net" {
+ t.Errorf("expected alias myalias@mailx.net, got %s", alias.Name)
+ }
+ if msgType != model.Reply {
+ t.Errorf("expected msgType Reply (passthrough), got %v", msgType)
+ }
+ if len(rcps) != 1 || rcps[0].Email != "contact@external.com" {
+ t.Errorf("expected reply target contact@external.com, got %+v", rcps)
+ }
+}
+
+func TestFindRecipients_DisabledAliasStillBlockedAfterPlusTagStripped(t *testing.T) {
+ store := newFakeStore()
+ store.aliases["disabled@mailx.net"] = model.Alias{
+ BaseModel: model.BaseModel{ID: "alias-4"},
+ Name: "disabled@mailx.net",
+ UserID: "user-4",
+ Enabled: false,
+ }
+ s := newTestService(store)
+
+ _, alias, _, err := s.FindRecipients("sender@somewhere.com", "disabled+tag@mailx.net", model.Send)
+ if err != ErrDisabledAlias {
+ t.Fatalf("expected ErrDisabledAlias, got %v", err)
+ }
+ if alias.Name != "disabled@mailx.net" {
+ t.Errorf("expected resolved alias name disabled@mailx.net, got %s", alias.Name)
+ }
+ if len(store.postedMessages) != 1 || store.postedMessages[0].Type != model.Block {
+ t.Errorf("expected a Block message to be recorded, got %+v", store.postedMessages)
+ }
+}
+
+func TestFindRecipients_UnmatchedPlusTagFallsThroughToDomainCatchAll(t *testing.T) {
+ store := newFakeStore()
+ store.domains["customdomain.com"] = model.Domain{
+ Name: "customdomain.com",
+ UserID: "user-5",
+ Enabled: true,
+ CatchAll: true,
+ Recipient: "catchall@example.com",
+ }
+ store.verifiedRecipients["user-5"] = []model.Recipient{{Email: "catchall@example.com", IsActive: true}}
+ s := newTestService(store)
+
+ rcps, alias, msgType, err := s.FindRecipients("sender@somewhere.com", "random+tag@customdomain.com", model.Send)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ // No real alias or Wildcard Alias matches, so the base (tag-stripped) name is
+ // used purely as a label on the domain-wide catch-all result.
+ if alias.Name != "random@customdomain.com" {
+ t.Errorf("expected catch-all alias label random@customdomain.com, got %s", alias.Name)
+ }
+ if msgType != model.Forward {
+ t.Errorf("expected msgType Forward, got %v", msgType)
+ }
+ if len(rcps) != 1 || rcps[0].Email != "catchall@example.com" {
+ t.Errorf("expected recipient catchall@example.com, got %+v", rcps)
+ }
+}
+
+func TestFindRecipients_NoAliasNoCatchAllReturnsError(t *testing.T) {
+ store := newFakeStore()
+ s := newTestService(store)
+
+ _, alias, _, err := s.FindRecipients("sender@somewhere.com", "randomjunk+tag@mailx.net", model.Send)
+ if err != ErrGetAliasByName {
+ t.Fatalf("expected ErrGetAliasByName, got %v", err)
+ }
+ if alias.Name != "randomjunk@mailx.net" {
+ t.Errorf("expected resolved alias name randomjunk@mailx.net, got %s", alias.Name)
+ }
+}
+
+func TestResolveForward(t *testing.T) {
+ store := newFakeStore()
+ store.recipients["user-1"] = []model.Recipient{
+ {Email: "a@example.com"},
+ {Email: "b@example.com"},
+ }
+ s := newTestService(store)
+
+ rcps, err := s.resolveForward(model.Alias{UserID: "user-1", Recipients: "a@example.com"})
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if len(rcps) != 1 || rcps[0].Email != "a@example.com" {
+ t.Errorf("expected only a@example.com, got %+v", rcps)
+ }
+}
+
+func TestResolveForward_NoRecipientsConfigured(t *testing.T) {
+ store := newFakeStore()
+ s := newTestService(store)
+
+ _, err := s.resolveForward(model.Alias{UserID: "user-1"})
+ if err != ErrNoRecipients {
+ t.Fatalf("expected ErrNoRecipients, got %v", err)
+ }
+}
From be1ed11b89d90b6e50f73c34fa041484f7fd3abf Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Wed, 19 Aug 2026 12:21:06 +0200
Subject: [PATCH 15/16] feat(service): update recipient.go
---
api/internal/service/recipient.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go
index f7838704..037bac55 100644
--- a/api/internal/service/recipient.go
+++ b/api/internal/service/recipient.go
@@ -312,6 +312,10 @@ func (s *Service) FindRecipients(from string, to string, msgType model.MessageTy
return []model.Recipient{}, catchAllAlias, msgType, catchAllErr
}
+ if err = s.checkCustomDomain(catchAllAlias); err != nil {
+ return []model.Recipient{}, alias, 0, err
+ }
+
if utils.ValidateEmail(replyTo) == nil {
rcps, err := s.resolveReply(from, catchAllAlias, replyTo)
if err != nil {
From 91ead82e49b4ddcb458dd3883f884783156faa61 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Wed, 19 Aug 2026 13:20:33 +0200
Subject: [PATCH 16/16] feat(app): update AliasRow.vue
---
app/src/components/AliasRow.vue | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/src/components/AliasRow.vue b/app/src/components/AliasRow.vue
index 9cb3291a..c3c15edb 100644
--- a/app/src/components/AliasRow.vue
+++ b/app/src/components/AliasRow.vue
@@ -35,6 +35,7 @@
{{ copyText }}: {{ alias.name }}
+ Created by Catch-All
@@ -126,6 +127,7 @@
{{ copyText }}: {{ alias.name }}
+ Created by Catch-All
@@ -234,6 +236,7 @@ const alias = ref(props.alias)
const recipients = ref(props.recipients)
const isDomainUnverified = computed(() => alias.value.is_custom_domain === true && (alias.value.is_domain_verified === false || alias.value.is_domain_enabled === false))
const isAliasDeleted = computed(() => alias.value.deleted_at !== null)
+const isCreatedByCatchAll = computed(() => alias.value.origin === 1)
const truncatedDescription = computed(() => {
const desc = alias.value.description
if (!desc) return ''