diff --git a/api/.env.sample b/api/.env.sample index 1ca2d8fb..bc3db03c 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -59,6 +59,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 22f09c22..f59cc982 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -67,14 +67,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 { @@ -139,6 +140,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"), ",") @@ -216,14 +222,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/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/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..69a14ac0 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 || alias.ID != "" { + 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..d51f81ab 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 && alias.ID == "" { + 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..037bac55 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) { @@ -303,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 { @@ -390,7 +403,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 +426,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/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) + } +} 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/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 '' 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. +

+
+ +
+