Skip to content
1 change: 1 addition & 0 deletions api/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 23 additions & 16 deletions api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"), ",")
Expand Down Expand Up @@ -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
}
24 changes: 24 additions & 0 deletions api/internal/model/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package model

import (
"errors"
"fmt"

"gorm.io/gorm"
)
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions api/internal/model/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion api/internal/repository/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions api/internal/repository/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
66 changes: 66 additions & 0 deletions api/internal/service/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading