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/model/alias.go b/api/internal/model/alias.go index 7636d81d..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" ) @@ -11,6 +12,28 @@ var ( ErrDuplicateAliasDomain = errors.New("wildcard aliases limit reached for this domain") ) +type AliasOrigin int + +const ( + Manual AliasOrigin = 0 + Inbound AliasOrigin = 1 + 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"` @@ -21,6 +44,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/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/alias.go b/api/internal/repository/alias.go index 05fc15d6..8b95b129 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{ @@ -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/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/service/alias.go b/api/internal/service/alias.go index ef6d575f..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.") @@ -31,6 +32,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) @@ -50,6 +52,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) @@ -65,6 +76,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 { @@ -228,6 +249,51 @@ 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) (model.Alias, error) { + if alias.Origin != model.Inbound { + return model.Alias{}, ErrPostInboundAlias + } + + domain := aliasDomainPart(alias.Name) + + if !isCustomAliasDomain(domain, s.Cfg.API.Domains) { + 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 model.Alias{}, ErrPostInboundAlias + } + + if !isCustomDomainEnabled(domain, domains) { + return model.Alias{}, ErrPostInboundAlias + } + + if !isCreateAliasEnabled(domain, domains) { + return model.Alias{}, ErrPostInboundAlias + } + + count, err := s.Store.GetCreatedAliasesCount(ctx, alias.UserID) + if err != 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 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 model.Alias{}, ErrPostInboundAlias + } + + return alias, 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 new file mode 100644 index 00000000..7901bc87 --- /dev/null +++ b/api/internal/service/alias_test.go @@ -0,0 +1,169 @@ +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) + } + }) + } +} + +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 b4bec40d..fec0b041 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -169,16 +169,29 @@ 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 } - if err := s.SaveMessage(context.Background(), alias, relayType); err != nil { - log.Println("error saving message", err) - } + // Save Message for stats + go func() { + err = s.SaveMessage(context.Background(), alias, relayType) + if err != nil { + log.Println("error saving message", err) + } + }() return nil }) diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go index ca2660e3..1f1cf441 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, Enabled: true} if !domain.Enabled { if err = s.SaveMessage(context.Background(), catchAllAlias, model.Block); err != nil { @@ -413,6 +413,8 @@ func (s *Service) resolveCatchAll(domainPart string, aliasName string) (bool, [] 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 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 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"` } 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. +

+
+ +
+