From 0bd38908bec5333b089cb73d573290db271808ad Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Feb 2026 13:36:15 +0100 Subject: [PATCH 01/51] feat(model): create domain.go --- api/internal/model/domain.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 api/internal/model/domain.go diff --git a/api/internal/model/domain.go b/api/internal/model/domain.go new file mode 100644 index 0000000..c4d1a85 --- /dev/null +++ b/api/internal/model/domain.go @@ -0,0 +1,16 @@ +package model + +import "time" + +type Domain struct { + BaseModel + UserID string `json:"-"` + Name string `gorm:"unique" json:"name"` + Description string `gorm:"default:''" json:"description"` + Recipient string `gorm:"default:''" json:"recipient"` + FromName string `gorm:"default:''" json:"from_name"` + Enabled bool `json:"enabled"` + OwnerVerifiedAt *time.Time `json:"owner_verified_at"` // nullable + InboundVerifiedAt *time.Time `json:"inbound_verified_at"` // nullable + OutboundVerifiedAt *time.Time `json:"outbound_verified_at"` // nullable +} From 107ce0408bc279b713efc7c468a3fcd560e7a773 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Feb 2026 14:02:16 +0100 Subject: [PATCH 02/51] feat(repository): create domain.go --- api/internal/repository/domain.go | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 api/internal/repository/domain.go diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go new file mode 100644 index 0000000..b1fbc59 --- /dev/null +++ b/api/internal/repository/domain.go @@ -0,0 +1,39 @@ +package repository + +import ( + "context" + + "ivpn.net/email/api/internal/model" +) + +func (d *Database) GetDomains(ctx context.Context, userID string) ([]model.Domain, error) { + var domains []model.Domain + err := d.Client.Where("user_id = ?", userID).Order("created_at desc").Find(&domains).Error + return domains, err +} + +func (d *Database) PostDomain(ctx context.Context, domain model.Domain) (model.Domain, error) { + err := d.Client.Create(&domain).Error + return domain, err +} + +func (d *Database) UpdateDomain(ctx context.Context, domain model.Domain) error { + return d.Client.Model(&domain).Where("user_id = ?", domain.UserID).Updates(map[string]any{ + "name": domain.Name, + "description": domain.Description, + "recipient": domain.Recipient, + "from_name": domain.FromName, + "enabled": domain.Enabled, + "owner_verified_at": domain.OwnerVerifiedAt, + "inbound_verified_at": domain.InboundVerifiedAt, + "outbound_verified_at": domain.OutboundVerifiedAt, + }).Error +} + +func (d *Database) DeleteDomain(ctx context.Context, domainID string, userID string) error { + return d.Client.Where("id = ? AND user_id = ?", domainID, userID).Delete(&model.Domain{}).Error +} + +func (d *Database) DeleteDomainsByUserID(ctx context.Context, userID string) error { + return d.Client.Where("user_id = ?", userID).Delete(&model.Domain{}).Error +} From 7a6df1d31def6eb6ac34d8a509086e69742b1b88 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Feb 2026 16:20:01 +0100 Subject: [PATCH 03/51] feat(service): create domain.go --- api/internal/cron/jobs/user.go | 7 ++++ api/internal/service/domain.go | 74 +++++++++++++++++++++++++++++++++ api/internal/service/service.go | 1 + api/internal/service/user.go | 6 +++ 4 files changed, 88 insertions(+) create mode 100644 api/internal/service/domain.go diff --git a/api/internal/cron/jobs/user.go b/api/internal/cron/jobs/user.go index de54ee8..66f5b64 100644 --- a/api/internal/cron/jobs/user.go +++ b/api/internal/cron/jobs/user.go @@ -100,6 +100,13 @@ func deleteUsers(db *gorm.DB, users []model.User) { return } + // Delete domains of the user + err = db.Where("user_id = ?", ID).Delete(&model.Domain{}).Error + if err != nil { + log.Println("Error deleting domains of user:", err) + return + } + // Delete the user err = db.Where("id = ?", ID).Delete(&model.User{}).Error if err != nil { diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go new file mode 100644 index 0000000..b658c17 --- /dev/null +++ b/api/internal/service/domain.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "errors" + "log" + + "ivpn.net/email/api/internal/model" +) + +var ( + ErrGetDomains = errors.New("Unable to retrieve domains.") + ErrPostDomain = errors.New("Unable to create domain. Please try again.") + ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") + ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") +) + +type DomainStore interface { + GetDomains(context.Context, string) ([]model.Domain, error) + PostDomain(context.Context, model.Domain) (model.Domain, error) + UpdateDomain(context.Context, model.Domain) error + DeleteDomain(context.Context, string, string) error + DeleteDomainsByUserID(context.Context, string) error +} + +func (s *Service) GetDomains(ctx context.Context, userId string) ([]model.Domain, error) { + domains, err := s.Store.GetDomains(ctx, userId) + if err != nil { + log.Printf("error getting domains: %s", err.Error()) + return nil, ErrGetDomains + } + + return domains, nil +} + +func (s *Service) PostDomain(ctx context.Context, domain model.Domain) (model.Domain, error) { + createdDomain, err := s.Store.PostDomain(ctx, domain) + if err != nil { + log.Printf("error posting domain: %s", err.Error()) + return model.Domain{}, ErrPostDomain + } + + return createdDomain, nil +} + +func (s *Service) DeleteDomain(ctx context.Context, domainID string, userID string) error { + err := s.Store.DeleteDomain(ctx, domainID, userID) + if err != nil { + log.Printf("error deleting domain: %s", err.Error()) + return ErrDeleteDomain + } + + return nil +} + +func (s *Service) UpdateDomain(ctx context.Context, domain model.Domain) error { + err := s.Store.UpdateDomain(ctx, domain) + if err != nil { + log.Printf("error updating domain: %s", err.Error()) + return ErrUpdateDomain + } + + return nil +} + +func (s *Service) DeleteDomainsByUserID(ctx context.Context, userID string) error { + err := s.Store.DeleteDomainsByUserID(ctx, userID) + if err != nil { + log.Printf("error deleting domains by user ID: %s", err.Error()) + return ErrDeleteDomain + } + + return nil +} diff --git a/api/internal/service/service.go b/api/internal/service/service.go index a7a273d..a09ec90 100644 --- a/api/internal/service/service.go +++ b/api/internal/service/service.go @@ -19,6 +19,7 @@ type Store interface { CredentialStore LogStore AccessKeyStore + DomainStore } type Cache interface { diff --git a/api/internal/service/user.go b/api/internal/service/user.go index 1ffea82..0f313ea 100644 --- a/api/internal/service/user.go +++ b/api/internal/service/user.go @@ -351,6 +351,12 @@ func (s *Service) DeleteUser(ctx context.Context, userID string, OTP string) err return ErrDeleteUser } + err = s.Store.DeleteDomainsByUserID(ctx, userID) + if err != nil { + log.Printf("error deleting user: %s", err.Error()) + return ErrDeleteUser + } + err = s.Store.DeleteSessionByUserID(ctx, userID) if err != nil { log.Printf("error deleting user: %s", err.Error()) From a8f864066aee70eb5918cbeb89fff782032b09ac Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Feb 2026 16:25:04 +0100 Subject: [PATCH 04/51] feat(service): update domain.go --- api/internal/repository/domain.go | 6 ++++++ api/internal/service/domain.go | 20 ++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go index b1fbc59..d054a50 100644 --- a/api/internal/repository/domain.go +++ b/api/internal/repository/domain.go @@ -12,6 +12,12 @@ func (d *Database) GetDomains(ctx context.Context, userID string) ([]model.Domai return domains, err } +func (d *Database) GetDomainsCount(ctx context.Context, userID string) (int64, error) { + var count int64 + err := d.Client.Model(&model.Domain{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} + func (d *Database) PostDomain(ctx context.Context, domain model.Domain) (model.Domain, error) { err := d.Client.Create(&domain).Error return domain, err diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index b658c17..4490ad5 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -9,14 +9,16 @@ import ( ) var ( - ErrGetDomains = errors.New("Unable to retrieve domains.") - ErrPostDomain = errors.New("Unable to create domain. Please try again.") - ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") - ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") + ErrGetDomains = errors.New("Unable to retrieve domains.") + ErrGetDomainsCount = errors.New("Unable to retrieve domains count.") + ErrPostDomain = errors.New("Unable to create domain. Please try again.") + ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") + ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") ) type DomainStore interface { GetDomains(context.Context, string) ([]model.Domain, error) + GetDomainsCount(context.Context, string) (int64, error) PostDomain(context.Context, model.Domain) (model.Domain, error) UpdateDomain(context.Context, model.Domain) error DeleteDomain(context.Context, string, string) error @@ -33,6 +35,16 @@ func (s *Service) GetDomains(ctx context.Context, userId string) ([]model.Domain return domains, nil } +func (s *Service) GetDomainsCount(ctx context.Context, userId string) (int64, error) { + count, err := s.Store.GetDomainsCount(ctx, userId) + if err != nil { + log.Printf("error getting domains count: %s", err.Error()) + return 0, ErrGetDomainsCount + } + + return count, nil +} + func (s *Service) PostDomain(ctx context.Context, domain model.Domain) (model.Domain, error) { createdDomain, err := s.Store.PostDomain(ctx, domain) if err != nil { From dd805b732bf43691c725e5f371ce822171661053 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Feb 2026 16:35:27 +0100 Subject: [PATCH 05/51] feat(api): update config.go --- api/.env.sample | 1 + api/config/config.go | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/api/.env.sample b/api/.env.sample index 10a8fef..0654f9a 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -45,6 +45,7 @@ SMTP_CLIENT_USER="" SMTP_CLIENT_PASSWORD="" SMTP_CLIENT_SENDER="from@example.net" SMTP_CLIENT_SENDER_NAME="From Name" +SMTP_CLIENT_DKIM_SELECTOR="" OTP_EXPIRATION=15m SUBSCRIPTION_TYPE="" diff --git a/api/config/config.go b/api/config/config.go index 9a255c3..9629dca 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -49,12 +49,13 @@ type RedisConfig struct { } type SMTPClientConfig struct { - Host string - Port string - User string - Password string - Sender string - SenderName string + Host string + Port string + User string + Password string + Sender string + SenderName string + DkimSelector string } type ServiceConfig struct { @@ -186,12 +187,13 @@ func New() (Config, error) { TLSInsecureSkipVerify: os.Getenv("REDIS_TLS_INSECURE_SKIP_VERIFY") == "true", }, SMTPClient: SMTPClientConfig{ - Host: os.Getenv("SMTP_CLIENT_HOST"), - Port: os.Getenv("SMTP_CLIENT_PORT"), - User: os.Getenv("SMTP_CLIENT_USER"), - Password: os.Getenv("SMTP_CLIENT_PASSWORD"), - Sender: os.Getenv("SMTP_CLIENT_SENDER"), - SenderName: os.Getenv("SMTP_CLIENT_SENDER_NAME"), + Host: os.Getenv("SMTP_CLIENT_HOST"), + Port: os.Getenv("SMTP_CLIENT_PORT"), + User: os.Getenv("SMTP_CLIENT_USER"), + Password: os.Getenv("SMTP_CLIENT_PASSWORD"), + Sender: os.Getenv("SMTP_CLIENT_SENDER"), + SenderName: os.Getenv("SMTP_CLIENT_SENDER_NAME"), + DkimSelector: os.Getenv("SMTP_CLIENT_DKIM_SELECTOR"), }, Service: ServiceConfig{ From e84c9c651506629e614eebbaece078ae1581bdf6 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Feb 2026 16:52:41 +0100 Subject: [PATCH 06/51] feat(service): update domain.go --- api/internal/model/domain.go | 7 +++++++ api/internal/service/domain.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/api/internal/model/domain.go b/api/internal/model/domain.go index c4d1a85..ad67a92 100644 --- a/api/internal/model/domain.go +++ b/api/internal/model/domain.go @@ -14,3 +14,10 @@ type Domain struct { InboundVerifiedAt *time.Time `json:"inbound_verified_at"` // nullable OutboundVerifiedAt *time.Time `json:"outbound_verified_at"` // nullable } + +type DNSConfig struct { + Verify string `json:"verify"` + Domain string `json:"domain"` + DKIM []string `json:"dkim_selectors"` + Hosts []string `json:"mx_hosts"` +} diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 4490ad5..5a50427 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -2,8 +2,11 @@ package service import ( "context" + "crypto/sha256" "errors" + "fmt" "log" + "strings" "ivpn.net/email/api/internal/model" ) @@ -11,6 +14,7 @@ import ( var ( ErrGetDomains = errors.New("Unable to retrieve domains.") ErrGetDomainsCount = errors.New("Unable to retrieve domains count.") + ErrGetDNSConfig = errors.New("Unable to retrieve DNS config.") ErrPostDomain = errors.New("Unable to create domain. Please try again.") ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") @@ -45,6 +49,34 @@ func (s *Service) GetDomainsCount(ctx context.Context, userId string) (int64, er return count, nil } +func (s *Service) GetDNSConfig(ctx context.Context, userId string) (model.DNSConfig, error) { + count, err := s.GetDomainsCount(ctx, userId) + if err != nil { + log.Printf("error getting domains count for DNS config: %s", err.Error()) + return model.DNSConfig{}, ErrGetDNSConfig + } + + domains := strings.Split(s.Cfg.API.Domains, ",") + if len(domains) == 0 { + log.Printf("no domains configured for DNS config") + return model.DNSConfig{}, ErrGetDNSConfig + } + + verify := sha256.Sum256([]byte(s.Cfg.API.TokenSecret + userId + fmt.Sprint(count))) + domain := domains[0] + dkim := strings.Split(s.Cfg.SMTPClient.DkimSelector, ",") + hosts := strings.Split(s.Cfg.SMTPClient.Host, ",") + + dnsConfig := model.DNSConfig{ + Verify: fmt.Sprintf("%x", verify), + Domain: domain, + DKIM: dkim, + Hosts: hosts, + } + + return dnsConfig, nil +} + func (s *Service) PostDomain(ctx context.Context, domain model.Domain) (model.Domain, error) { createdDomain, err := s.Store.PostDomain(ctx, domain) if err != nil { From 5755022c735c6a056ed3d9436ef2a55a9e06bcf5 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Feb 2026 17:12:11 +0100 Subject: [PATCH 07/51] feat(api): create domain.go --- api/internal/transport/api/domain.go | 67 ++++++++++++++++++++++++++++ api/internal/transport/api/server.go | 1 + 2 files changed, 68 insertions(+) create mode 100644 api/internal/transport/api/domain.go diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go new file mode 100644 index 0000000..cafe1e7 --- /dev/null +++ b/api/internal/transport/api/domain.go @@ -0,0 +1,67 @@ +package api + +import ( + "context" + + "github.com/gofiber/fiber/v2" + "ivpn.net/email/api/internal/middleware/auth" + "ivpn.net/email/api/internal/model" +) + +var ( + ErrGetDomains = "Unable to retrieve custom domains for this user." + ErrGetDNSConfig = "Unable to retrieve custom domains DNS config for this user." + ErrPostDomain = "Unable to create custom domain. Please try again." + ErrUpdateDomain = "Unable to update custom domain. Please try again." + ErrDeleteDomain = "Unable to delete custom domain. Please try again." +) + +type DomainService interface { + GetDomains(context.Context, string) ([]model.Domain, error) + GetDNSConfig(context.Context, string) (model.DNSConfig, error) + PostDomain(context.Context, model.Domain) (model.Domain, error) + UpdateDomain(context.Context, model.Domain) error + DeleteDomain(context.Context, string, string) error +} + +// @Summary Get custom domains +// @Description Get all custom domains for the authenticated user +// @Tags domain +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {array} model.Domain +// @Failure 400 {object} ErrorRes +// @Router /domains [get] +func (h *Handler) GetDomains(c *fiber.Ctx) error { + userID := auth.GetUserID(c) + domains, err := h.Service.GetDomains(c.Context(), userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrGetDomains, + }) + } + + return c.JSON(domains) +} + +// @Summary Get custom domains DNS config +// @Description Get the DNS configuration for all custom domains of the authenticated user +// @Tags domain +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} model.DNSConfig +// @Failure 400 {object} ErrorRes +// @Router /domains/dns-config [get] +func (h *Handler) GetDNSConfig(c *fiber.Ctx) error { + userID := auth.GetUserID(c) + dnsConfig, err := h.Service.GetDNSConfig(c.Context(), userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrGetDNSConfig, + }) + } + + return c.JSON(dnsConfig) +} diff --git a/api/internal/transport/api/server.go b/api/internal/transport/api/server.go index 9c959c7..0ae8fc5 100644 --- a/api/internal/transport/api/server.go +++ b/api/internal/transport/api/server.go @@ -22,6 +22,7 @@ type Service interface { CredentialService LogService AccessKeyService + DomainService } type Handler struct { From 2e71da97b672764da33e702b991ae480a1d8a9f0 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 26 Feb 2026 11:25:54 +0100 Subject: [PATCH 08/51] feat(repository): update domain.go --- api/internal/repository/domain.go | 6 ++++++ api/internal/service/domain.go | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go index d054a50..4495a76 100644 --- a/api/internal/repository/domain.go +++ b/api/internal/repository/domain.go @@ -12,6 +12,12 @@ func (d *Database) GetDomains(ctx context.Context, userID string) ([]model.Domai return domains, err } +func (d *Database) GetDomain(ctx context.Context, domainID string, userID string) (model.Domain, error) { + var domain model.Domain + err := d.Client.Where("id = ? AND user_id = ?", domainID, userID).First(&domain).Error + return domain, err +} + func (d *Database) GetDomainsCount(ctx context.Context, userID string) (int64, error) { var count int64 err := d.Client.Model(&model.Domain{}).Where("user_id = ?", userID).Count(&count).Error diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 5a50427..607c2b4 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -13,6 +13,7 @@ import ( var ( ErrGetDomains = errors.New("Unable to retrieve domains.") + ErrGetDomain = errors.New("Unable to retrieve domain.") ErrGetDomainsCount = errors.New("Unable to retrieve domains count.") ErrGetDNSConfig = errors.New("Unable to retrieve DNS config.") ErrPostDomain = errors.New("Unable to create domain. Please try again.") @@ -22,6 +23,7 @@ var ( type DomainStore interface { GetDomains(context.Context, string) ([]model.Domain, error) + GetDomain(context.Context, string, string) (model.Domain, error) GetDomainsCount(context.Context, string) (int64, error) PostDomain(context.Context, model.Domain) (model.Domain, error) UpdateDomain(context.Context, model.Domain) error @@ -39,6 +41,16 @@ func (s *Service) GetDomains(ctx context.Context, userId string) ([]model.Domain return domains, nil } +func (s *Service) GetDomain(ctx context.Context, domainID string, userID string) (model.Domain, error) { + domain, err := s.Store.GetDomain(ctx, domainID, userID) + if err != nil { + log.Printf("error getting domain: %s", err.Error()) + return model.Domain{}, ErrGetDomain + } + + return domain, nil +} + func (s *Service) GetDomainsCount(ctx context.Context, userId string) (int64, error) { count, err := s.Store.GetDomainsCount(ctx, userId) if err != nil { From 5d716c2f4fdac81d6aee33f043ad9034c2d1926d Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 26 Feb 2026 12:12:45 +0100 Subject: [PATCH 09/51] feat(api): update domain.go --- api/internal/transport/api/domain.go | 60 +++++++++++++++++++++++++--- api/internal/transport/api/req.go | 12 ++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index cafe1e7..8e84ff4 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -9,11 +9,12 @@ import ( ) var ( - ErrGetDomains = "Unable to retrieve custom domains for this user." - ErrGetDNSConfig = "Unable to retrieve custom domains DNS config for this user." - ErrPostDomain = "Unable to create custom domain. Please try again." - ErrUpdateDomain = "Unable to update custom domain. Please try again." - ErrDeleteDomain = "Unable to delete custom domain. Please try again." + ErrGetDomains = "Unable to retrieve custom domains for this user." + ErrGetDNSConfig = "Unable to retrieve custom domains DNS config for this user." + ErrPostDomain = "Unable to create custom domain. Please try again." + ErrUpdateDomain = "Unable to update custom domain. Please try again." + ErrDeleteDomain = "Unable to delete custom domain. Please try again." + PostDomainSuccess = "Custom domain added successfully." ) type DomainService interface { @@ -65,3 +66,52 @@ func (h *Handler) GetDNSConfig(c *fiber.Ctx) error { return c.JSON(dnsConfig) } + +// @Summary Create custom domain +// @Description Create a new custom domain for the authenticated user +// @Tags domain +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param domain body DomainReq true "Custom Domain Request" +// @Success 201 {object} map[string]string "message" +// @Failure 400 {object} ErrorRes +// @Router /domains [post] +func (h *Handler) PostDomain(c *fiber.Ctx) error { + // Parse the request + userID := auth.GetUserID(c) + req := DomainReq{} + err := c.BodyParser(&req) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrInvalidRequest, + }) + } + + // Validate the request + err = h.Validator.Struct(req) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrInvalidRequest, + }) + } + + // Create domain + domain := model.Domain{ + UserID: userID, + Name: req.Name, + Enabled: true, + } + + // Post domain + _, err = h.Service.PostDomain(c.Context(), domain) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrPostDomain, + }) + } + + return c.Status(201).JSON(fiber.Map{ + "message": PostDomainSuccess, + }) +} diff --git a/api/internal/transport/api/req.go b/api/internal/transport/api/req.go index 108fe35..f9ea938 100644 --- a/api/internal/transport/api/req.go +++ b/api/internal/transport/api/req.go @@ -86,3 +86,15 @@ type AccessKeyReq struct { Name string `json:"name" validate:"required"` ExpiresAt string `json:"expires_at"` } + +type DomainReq struct { + Name string `json:"name" validate:"required,hostname"` +} + +type UpdateDomainReq struct { + ID string `json:"id" validate:"required,uuid"` + Description string `json:"description"` + Recipient string `json:"recipient"` + FromName string `json:"from_name"` + Enabled bool `json:"enabled"` +} From 816dcafc1ba2519ed2764fa1da32c3a02dd07bc0 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 26 Feb 2026 12:30:45 +0100 Subject: [PATCH 10/51] feat(api): update domain.go --- api/internal/transport/api/domain.go | 71 +++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index 8e84ff4..ab74bd0 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -9,16 +9,19 @@ import ( ) var ( - ErrGetDomains = "Unable to retrieve custom domains for this user." - ErrGetDNSConfig = "Unable to retrieve custom domains DNS config for this user." - ErrPostDomain = "Unable to create custom domain. Please try again." - ErrUpdateDomain = "Unable to update custom domain. Please try again." - ErrDeleteDomain = "Unable to delete custom domain. Please try again." - PostDomainSuccess = "Custom domain added successfully." + ErrGetDomains = "Unable to retrieve custom domains for this user." + ErrGetDomain = "Unable to retrieve custom domain for this user." + ErrGetDNSConfig = "Unable to retrieve custom domains DNS config for this user." + ErrPostDomain = "Unable to create custom domain. Please try again." + ErrUpdateDomain = "Unable to update custom domain. Please try again." + ErrDeleteDomain = "Unable to delete custom domain. Please try again." + PostDomainSuccess = "Custom domain added successfully." + UpdateDomainSuccess = "Custom domain updated successfully." ) type DomainService interface { GetDomains(context.Context, string) ([]model.Domain, error) + GetDomain(context.Context, string, string) (model.Domain, error) GetDNSConfig(context.Context, string) (model.DNSConfig, error) PostDomain(context.Context, model.Domain) (model.Domain, error) UpdateDomain(context.Context, model.Domain) error @@ -115,3 +118,59 @@ func (h *Handler) PostDomain(c *fiber.Ctx) error { "message": PostDomainSuccess, }) } + +// @Summary Update custom domain +// @Description Update an existing custom domain for the authenticated user +// @Tags domain +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param domain body UpdateDomainReq true "Update Custom Domain Request" +// @Success 200 {object} map[string]string "message" +// @Failure 400 {object} ErrorRes +// @Router /domains [put] +func (h *Handler) UpdateDomain(c *fiber.Ctx) error { + // Parse the request + userID := auth.GetUserID(c) + req := UpdateDomainReq{} + err := c.BodyParser(&req) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrInvalidRequest, + }) + } + + // Validate the request + err = h.Validator.Struct(req) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrInvalidRequest, + }) + } + + // Get existing domain + domain, err := h.Service.GetDomain(c.Context(), req.ID, userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrGetDomain, + }) + } + + // Update domain fields + domain.Description = req.Description + domain.Recipient = req.Recipient + domain.FromName = req.FromName + domain.Enabled = req.Enabled + + // Update domain + err = h.Service.UpdateDomain(c.Context(), domain) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrUpdateDomain, + }) + } + + return c.JSON(fiber.Map{ + "message": UpdateDomainSuccess, + }) +} From bd1cac746ce0339a62b0c1d4a37ceb61a233ee77 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 26 Feb 2026 14:48:52 +0100 Subject: [PATCH 11/51] feat(utils): create dns.go --- api/internal/utils/dns.go | 114 ++++++++++++++++++++++++ api/internal/utils/dns_test.go | 153 +++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 api/internal/utils/dns.go create mode 100644 api/internal/utils/dns_test.go diff --git a/api/internal/utils/dns.go b/api/internal/utils/dns.go new file mode 100644 index 0000000..6468d99 --- /dev/null +++ b/api/internal/utils/dns.go @@ -0,0 +1,114 @@ +package utils + +import ( + "errors" + "net" + "strings" +) + +var ( + ErrLookupTXT = errors.New("failed to lookup TXT records") + ErrLookupMX = errors.New("failed to lookup MX records") + ErrLookupCNAME = errors.New("failed to lookup CNAME record") +) + +// stripDot removes a trailing dot from a DNS hostname. +func stripDot(s string) string { + return strings.TrimSuffix(s, ".") +} + +// LookupTXTExact looks up TXT records for host and returns true if any record +// is an exact match to value (trailing dots stripped before comparison). +// +// Example use: verify ownership TXT record +// +// LookupTXTExact("example.com", "service-verify=9487e243822f333d782eabe1115302643b222ef55072c8e77abf75335950a61a") +func LookupTXTExact(host, value string) (bool, error) { + records, err := net.LookupTXT(host) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) { + return false, nil + } + return false, ErrLookupTXT + } + + want := stripDot(value) + for _, r := range records { + if stripDot(r) == want { + return true, nil + } + } + return false, nil +} + +// LookupTXTContains looks up TXT records for host and returns true if any record +// contains value as a substring (trailing dots stripped before comparison). +// +// Example uses: +// +// LookupTXTContains("example.com", "v=spf1 include:spf.example.net -all") +// LookupTXTContains("_dmarc.example.com", "v=DMARC1; p=quarantine; adkim=s") +func LookupTXTContains(host, value string) (bool, error) { + records, err := net.LookupTXT(host) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) { + return false, nil + } + return false, ErrLookupTXT + } + + want := stripDot(value) + for _, r := range records { + if strings.Contains(stripDot(r), want) { + return true, nil + } + } + return false, nil +} + +// LookupMX looks up MX records for host and returns true if any MX entry's +// hostname matches target (trailing dots stripped, case-insensitive). +// The MX priority/preference value is not checked. +// +// Example use: +// +// LookupMX("example.com", "mail1.example.net.") +func LookupMX(host, target string) (bool, error) { + records, err := net.LookupMX(host) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) { + return false, nil + } + return false, ErrLookupMX + } + + want := strings.ToLower(stripDot(target)) + for _, r := range records { + if strings.ToLower(stripDot(r.Host)) == want { + return true, nil + } + } + return false, nil +} + +// LookupCNAME looks up the canonical name for host and returns true if it matches +// target (trailing dots stripped, case-insensitive). +// +// Example use: +// +// LookupCNAME("mail._domainkey.example.com", "mail._domainkey.example.net.") +func LookupCNAME(host, target string) (bool, error) { + cname, err := net.LookupCNAME(host) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) { + return false, nil + } + return false, ErrLookupCNAME + } + + return strings.EqualFold(stripDot(cname), stripDot(target)), nil +} diff --git a/api/internal/utils/dns_test.go b/api/internal/utils/dns_test.go new file mode 100644 index 0000000..e4eeafe --- /dev/null +++ b/api/internal/utils/dns_test.go @@ -0,0 +1,153 @@ +package utils + +import ( + "testing" +) + +// stripDot tests + +func TestStripDot(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"mail1.example.net.", "mail1.example.net"}, + {"mail1.example.net", "mail1.example.net"}, + {".", ""}, + {"", ""}, + } + for _, tc := range tests { + got := stripDot(tc.input) + if got != tc.want { + t.Errorf("stripDot(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +// LookupTXTExact tests + +func TestLookupTXTExact_NotFound(t *testing.T) { + // .invalid TLD is reserved (RFC 2606) and never resolves. + found, err := LookupTXTExact("nonexistent.invalid", "some-value") + if err != nil { + t.Fatalf("expected nil error for non-existent domain, got: %v", err) + } + if found { + t.Fatal("expected false for non-existent domain") + } +} + +func TestLookupTXTExact_Mismatch(t *testing.T) { + // gmail.com has TXT records but not this value. + found, err := LookupTXTExact("gmail.com", "service-verify=this-will-never-exist") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Fatal("expected false for mismatched TXT value") + } +} + +// LookupTXTContains tests + +func TestLookupTXTContains_NotFound(t *testing.T) { + found, err := LookupTXTContains("nonexistent.invalid", "v=spf1") + if err != nil { + t.Fatalf("expected nil error for non-existent domain, got: %v", err) + } + if found { + t.Fatal("expected false for non-existent domain") + } +} + +func TestLookupTXTContains_SPF(t *testing.T) { + // gmail.com is known to publish an SPF record. + found, err := LookupTXTContains("gmail.com", "v=spf1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !found { + t.Skip("gmail.com SPF TXT record not found; skipping (may be a network issue)") + } +} + +func TestLookupTXTContains_DMARC(t *testing.T) { + // _dmarc.gmail.com is known to publish a DMARC record. + found, err := LookupTXTContains("_dmarc.gmail.com", "v=DMARC1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !found { + t.Skip("_dmarc.gmail.com TXT record not found; skipping (may be a network issue)") + } +} + +func TestLookupTXTContains_Mismatch(t *testing.T) { + found, err := LookupTXTContains("gmail.com", "this-value-will-never-exist-xyz123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Fatal("expected false for mismatched TXT value") + } +} + +// LookupMX tests + +func TestLookupMX_NotFound(t *testing.T) { + found, err := LookupMX("nonexistent.invalid", "mail.example.com") + if err != nil { + t.Fatalf("expected nil error for non-existent domain, got: %v", err) + } + if found { + t.Fatal("expected false for non-existent domain") + } +} + +func TestLookupMX_Mismatch(t *testing.T) { + // gmail.com has MX records but not this host. + found, err := LookupMX("gmail.com", "mail.this-does-not-exist.example") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Fatal("expected false for mismatched MX host") + } +} + +func TestLookupMX_TrailingDot(t *testing.T) { + // Verify that trailing dots on the target are normalised before comparison. + // Both "gmail-smtp-in.l.google.com." and "gmail-smtp-in.l.google.com" must behave + // identically. We check that not-found returns false without an error in both cases. + found1, err1 := LookupMX("gmail.com", "mail.this-does-not-exist.example.") + found2, err2 := LookupMX("gmail.com", "mail.this-does-not-exist.example") + if err1 != nil || err2 != nil { + t.Fatalf("unexpected errors: %v / %v", err1, err2) + } + if found1 != found2 { + t.Fatal("trailing dot normalisation inconsistency") + } +} + +// LookupCNAME tests + +func TestLookupCNAME_NotFound(t *testing.T) { + found, err := LookupCNAME("nonexistent-cname.invalid", "target.example.com") + if err != nil { + t.Fatalf("expected nil error for non-existent host, got: %v", err) + } + if found { + t.Fatal("expected false for non-existent host") + } +} + +func TestLookupCNAME_Mismatch(t *testing.T) { + // www.github.com resolves to a CNAME but not to this target. + found, err := LookupCNAME("www.github.com", "wrong.target.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Fatal("expected false for mismatched CNAME target") + } +} From a5525c370b9d27ca171756b6dc1066894ec77df8 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 27 Feb 2026 11:16:02 +0100 Subject: [PATCH 12/51] feat(service): update domain.go --- api/internal/service/domain.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 607c2b4..5c391bd 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -9,6 +9,7 @@ import ( "strings" "ivpn.net/email/api/internal/model" + "ivpn.net/email/api/internal/utils" ) var ( @@ -19,6 +20,7 @@ var ( ErrPostDomain = errors.New("Unable to create domain. Please try again.") ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") + ErrDNSLookupOwner = errors.New("Unable to verify domain ownership. Please ensure the correct TXT record is set.") ) type DomainStore interface { @@ -128,3 +130,24 @@ func (s *Service) DeleteDomainsByUserID(ctx context.Context, userID string) erro return nil } + +func (s *Service) VerifyDomainOwner(ctx context.Context, domain string, userID string) error { + dnsConfig, err := s.GetDNSConfig(ctx, userID) + if err != nil { + log.Printf("error getting DNS config for domain ownership verification: %s", err.Error()) + return ErrGetDNSConfig + } + + ok, err := utils.LookupTXTExact(domain, "mailx-verify="+dnsConfig.Verify) + if err != nil { + log.Printf("error looking up TXT record for domain ownership verification: %s", err.Error()) + return ErrDNSLookupOwner + } + + if !ok { + log.Printf("TXT record not found for domain ownership verification") + return ErrDNSLookupOwner + } + + return nil +} From d5ed99534c50f5b3c86e088ddea83d296152c6a6 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 27 Feb 2026 11:38:50 +0100 Subject: [PATCH 13/51] feat(service): update domain.go --- api/internal/model/domain.go | 18 +++---- api/internal/repository/domain.go | 16 +++--- api/internal/service/domain.go | 89 +++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 17 deletions(-) diff --git a/api/internal/model/domain.go b/api/internal/model/domain.go index ad67a92..5f78238 100644 --- a/api/internal/model/domain.go +++ b/api/internal/model/domain.go @@ -4,15 +4,15 @@ import "time" type Domain struct { BaseModel - UserID string `json:"-"` - Name string `gorm:"unique" json:"name"` - Description string `gorm:"default:''" json:"description"` - Recipient string `gorm:"default:''" json:"recipient"` - FromName string `gorm:"default:''" json:"from_name"` - Enabled bool `json:"enabled"` - OwnerVerifiedAt *time.Time `json:"owner_verified_at"` // nullable - InboundVerifiedAt *time.Time `json:"inbound_verified_at"` // nullable - OutboundVerifiedAt *time.Time `json:"outbound_verified_at"` // nullable + UserID string `json:"-"` + Name string `gorm:"unique" json:"name"` + Description string `gorm:"default:''" json:"description"` + Recipient string `gorm:"default:''" json:"recipient"` + FromName string `gorm:"default:''" json:"from_name"` + Enabled bool `json:"enabled"` + OwnerVerifiedAt *time.Time `json:"owner_verified_at"` // nullable + MXVerifiedAt *time.Time `json:"mx_verified_at"` // nullable + SendVerifiedAt *time.Time `json:"send_verified_at"` // nullable } type DNSConfig struct { diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go index 4495a76..2f45715 100644 --- a/api/internal/repository/domain.go +++ b/api/internal/repository/domain.go @@ -31,14 +31,14 @@ func (d *Database) PostDomain(ctx context.Context, domain model.Domain) (model.D func (d *Database) UpdateDomain(ctx context.Context, domain model.Domain) error { return d.Client.Model(&domain).Where("user_id = ?", domain.UserID).Updates(map[string]any{ - "name": domain.Name, - "description": domain.Description, - "recipient": domain.Recipient, - "from_name": domain.FromName, - "enabled": domain.Enabled, - "owner_verified_at": domain.OwnerVerifiedAt, - "inbound_verified_at": domain.InboundVerifiedAt, - "outbound_verified_at": domain.OutboundVerifiedAt, + "name": domain.Name, + "description": domain.Description, + "recipient": domain.Recipient, + "from_name": domain.FromName, + "enabled": domain.Enabled, + "owner_verified_at": domain.OwnerVerifiedAt, + "mx_verified_at": domain.MXVerifiedAt, + "send_verified_at": domain.SendVerifiedAt, }).Error } diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 5c391bd..2d70824 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -21,6 +21,10 @@ var ( ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") ErrDNSLookupOwner = errors.New("Unable to verify domain ownership. Please ensure the correct TXT record is set.") + ErrDNSLookupSPF = errors.New("Unable to verify domain DNS records. Please ensure the correct SPF record is set.") + ErrDNSLookupDKIM = errors.New("Unable to verify domain DNS records. Please ensure the correct DKIM records are set.") + ErrDNSLookupDMARC = errors.New("Unable to verify domain DNS records. Please ensure the correct DMARC record is set.") + ErrDNSLookupMX = errors.New("Unable to verify domain DNS records. Please ensure the correct MX records are set.") ) type DomainStore interface { @@ -138,6 +142,7 @@ func (s *Service) VerifyDomainOwner(ctx context.Context, domain string, userID s return ErrGetDNSConfig } + // TXT record for ownership verification ok, err := utils.LookupTXTExact(domain, "mailx-verify="+dnsConfig.Verify) if err != nil { log.Printf("error looking up TXT record for domain ownership verification: %s", err.Error()) @@ -151,3 +156,87 @@ func (s *Service) VerifyDomainOwner(ctx context.Context, domain string, userID s return nil } + +func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domain string, userID string) error { + if err := s.VerifyDomainMX(ctx, domain, userID); err != nil { + return err + } + + if err := s.VerifyDomainSend(ctx, domain, userID); err != nil { + return err + } + + return nil +} + +func (s *Service) VerifyDomainMX(ctx context.Context, domain string, userID string) error { + dnsConfig, err := s.GetDNSConfig(ctx, userID) + if err != nil { + log.Printf("error getting DNS config for domain MX verification: %s", err.Error()) + return ErrGetDNSConfig + } + + // MX records + for _, host := range dnsConfig.Hosts { + ok, err := utils.LookupMX(domain, host) + if err != nil { + log.Printf("error looking up MX record for domain MX verification: %s", err.Error()) + return ErrDNSLookupMX + } + + if !ok { + log.Printf("MX record not found for host %s in domain MX verification", host) + return ErrDNSLookupMX + } + } + + return nil +} + +func (s *Service) VerifyDomainSend(ctx context.Context, domain string, userID string) error { + dnsConfig, err := s.GetDNSConfig(ctx, userID) + if err != nil { + log.Printf("error getting DNS config for domain MX verification: %s", err.Error()) + return ErrGetDNSConfig + } + + // SPF record + ok, err := utils.LookupTXTContains(domain, "v=spf1 include:"+dnsConfig.Domain+" ~all") + if err != nil { + log.Printf("error looking up TXT record for domain SPF verification: %s", err.Error()) + return ErrDNSLookupSPF + } + + if !ok { + log.Printf("SPF record not found for domain SPF verification") + return ErrDNSLookupSPF + } + + // DKIM records + for _, selector := range dnsConfig.DKIM { + ok, err := utils.LookupCNAME(selector+"._domainkey."+domain, selector+"._domainkey."+dnsConfig.Domain) + if err != nil { + log.Printf("error looking up CNAME record for selector %s in domain DKIM verification: %s", selector, err.Error()) + return ErrDNSLookupDKIM + } + + if !ok { + log.Printf("DKIM record not found for selector %s in domain DKIM verification", selector) + return ErrDNSLookupDKIM + } + } + + // DMARC record + ok, err = utils.LookupTXTContains("_dmarc."+domain, "v=DMARC1; p=quarantine; adkim=s") + if err != nil { + log.Printf("error looking up TXT record for domain DMARC verification: %s", err.Error()) + return ErrDNSLookupDMARC + } + + if !ok { + log.Printf("DMARC record not found for domain DMARC verification") + return ErrDNSLookupDMARC + } + + return nil +} From 8681cc4ef6bce3c4ba4ab8df518d28f6bf88bf1a Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 27 Feb 2026 11:59:05 +0100 Subject: [PATCH 14/51] feat(api): update domain.go --- api/internal/service/domain.go | 25 ++++++++++++++-- api/internal/transport/api/domain.go | 44 +++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 2d70824..605b79a 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "strings" + "time" "ivpn.net/email/api/internal/model" "ivpn.net/email/api/internal/utils" @@ -157,15 +158,33 @@ func (s *Service) VerifyDomainOwner(ctx context.Context, domain string, userID s return nil } -func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domain string, userID string) error { - if err := s.VerifyDomainMX(ctx, domain, userID); err != nil { +func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, userID string) error { + domain, err := s.GetDomain(ctx, domainId, userID) + if err != nil { + log.Printf("error getting domain for DNS record verification: %s", err.Error()) + return ErrGetDomain + } + + err = s.VerifyDomainMX(ctx, domain.Name, userID) + if err != nil { return err } - if err := s.VerifyDomainSend(ctx, domain, userID); err != nil { + err = s.VerifyDomainSend(ctx, domain.Name, userID) + if err != nil { return err } + now := time.Now() + domain.MXVerifiedAt = &now + domain.SendVerifiedAt = &now + + err = s.UpdateDomain(ctx, domain) + if err != nil { + log.Printf("error updating domain verification timestamps: %s", err.Error()) + return ErrUpdateDomain + } + return nil } diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index ab74bd0..215f4c2 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -9,14 +9,15 @@ import ( ) var ( - ErrGetDomains = "Unable to retrieve custom domains for this user." - ErrGetDomain = "Unable to retrieve custom domain for this user." - ErrGetDNSConfig = "Unable to retrieve custom domains DNS config for this user." - ErrPostDomain = "Unable to create custom domain. Please try again." - ErrUpdateDomain = "Unable to update custom domain. Please try again." - ErrDeleteDomain = "Unable to delete custom domain. Please try again." - PostDomainSuccess = "Custom domain added successfully." - UpdateDomainSuccess = "Custom domain updated successfully." + ErrGetDomains = "Unable to retrieve custom domains for this user." + ErrGetDomain = "Unable to retrieve custom domain for this user." + ErrGetDNSConfig = "Unable to retrieve custom domains DNS config for this user." + ErrPostDomain = "Unable to create custom domain. Please try again." + ErrUpdateDomain = "Unable to update custom domain. Please try again." + ErrDeleteDomain = "Unable to delete custom domain. Please try again." + PostDomainSuccess = "Custom domain added successfully." + UpdateDomainSuccess = "Custom domain updated successfully." + DNSRecordVerificationSuccess = "Custom domain DNS records verified successfully." ) type DomainService interface { @@ -26,6 +27,7 @@ type DomainService interface { PostDomain(context.Context, model.Domain) (model.Domain, error) UpdateDomain(context.Context, model.Domain) error DeleteDomain(context.Context, string, string) error + VerifyDomainDNSRecords(context.Context, string, string) error } // @Summary Get custom domains @@ -174,3 +176,29 @@ func (h *Handler) UpdateDomain(c *fiber.Ctx) error { "message": UpdateDomainSuccess, }) } + +// @Summary Verify custom domain DNS records +// @Description Verify the DNS records for a custom domain of the authenticated user +// @Tags domain +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path string true "Domain ID" +// @Success 200 {object} map[string]string "message" +// @Failure 400 {object} ErrorRes +// @Router /domains/{id}/verify-dns [post] +func (h *Handler) VerifyDomainDNSRecords(c *fiber.Ctx) error { + userID := auth.GetUserID(c) + domainID := c.Params("id") + + err := h.Service.VerifyDomainDNSRecords(c.Context(), domainID, userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "message": DNSRecordVerificationSuccess, + }) +} From 4723dbc5e32ba8d698aada9b4b8166bbb1ff0533 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 27 Feb 2026 12:38:02 +0100 Subject: [PATCH 15/51] feat(service): update domain.go --- api/internal/service/domain.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 605b79a..5c80308 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -97,6 +97,15 @@ func (s *Service) GetDNSConfig(ctx context.Context, userId string) (model.DNSCon } func (s *Service) PostDomain(ctx context.Context, domain model.Domain) (model.Domain, error) { + err := s.VerifyDomainOwner(ctx, domain.Name, domain.UserID) + if err != nil { + log.Printf("error verifying domain ownership: %s", err.Error()) + return model.Domain{}, ErrDNSLookupOwner + } + + now := time.Now() + domain.OwnerVerifiedAt = &now + createdDomain, err := s.Store.PostDomain(ctx, domain) if err != nil { log.Printf("error posting domain: %s", err.Error()) From 88713da8e72385d7f7dc3f8cca70b6afcca3aaa3 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 27 Feb 2026 12:50:16 +0100 Subject: [PATCH 16/51] feat(api): update routes.go --- api/internal/transport/api/domain.go | 27 +++++++++++++++++++++++++++ api/internal/transport/api/routes.go | 9 ++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index 215f4c2..0bcc9da 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -17,6 +17,7 @@ var ( ErrDeleteDomain = "Unable to delete custom domain. Please try again." PostDomainSuccess = "Custom domain added successfully." UpdateDomainSuccess = "Custom domain updated successfully." + DeleteDomainSuccess = "Custom domain deleted successfully." DNSRecordVerificationSuccess = "Custom domain DNS records verified successfully." ) @@ -177,6 +178,32 @@ func (h *Handler) UpdateDomain(c *fiber.Ctx) error { }) } +// @Summary Delete custom domain +// @Description Delete an existing custom domain for the authenticated user +// @Tags domain +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path string true "Domain ID" +// @Success 200 {object} map[string]string "message" +// @Failure 400 {object} ErrorRes +// @Router /domains/{id} [delete] +func (h *Handler) DeleteDomain(c *fiber.Ctx) error { + userID := auth.GetUserID(c) + domainID := c.Params("id") + + err := h.Service.DeleteDomain(c.Context(), domainID, userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrDeleteDomain, + }) + } + + return c.JSON(fiber.Map{ + "message": DeleteDomainSuccess, + }) +} + // @Summary Verify custom domain DNS records // @Description Verify the DNS records for a custom domain of the authenticated user // @Tags domain diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index 5156d50..1b2b545 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -91,9 +91,16 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { v1.Get("/log/file/:id", h.GetLogFile) v1.Get("/accesskeys", h.GetAccessKeys) - v1.Post("/accesskeys", h.PostAccessKey) + v1.Post("/accesskeys", limiter.New(), h.PostAccessKey) v1.Delete("/accesskeys/:id", h.DeleteAccessKey) + v1.Get("/domains", h.GetDomains) + v1.Get("/domains/dns-config", h.GetDNSConfig) + v1.Post("/domain", limiter.New(), h.PostDomain) + v1.Put("/domain/:id", h.UpdateDomain) + v1.Delete("/domain/:id", h.DeleteDomain) + v1.Post("/domain/:id/verify-dns", h.VerifyDomainDNSRecords) + docs := h.Server.Group("/docs") docs.Use(auth.NewBasicAuth(cfg)) docs.Get("/*", swagger.HandlerDefault) From 4d8b8f38d0d9afe027fd1a0ce2410e4fa80b8f64 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 27 Feb 2026 13:18:34 +0100 Subject: [PATCH 17/51] feat(repository): update alias.go --- api/internal/repository/alias.go | 10 ++++++++++ api/internal/service/alias.go | 23 +++++++++++++++++++++++ api/internal/service/domain.go | 16 +++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/api/internal/repository/alias.go b/api/internal/repository/alias.go index 640d261..bb92a10 100644 --- a/api/internal/repository/alias.go +++ b/api/internal/repository/alias.go @@ -94,6 +94,12 @@ func (d *Database) GetAliases(ctx context.Context, userID string, limit int, off return aliases, nil } +func (d *Database) GetAliasesByDomain(ctx context.Context, domain string, userId string) ([]model.Alias, error) { + aliases := []model.Alias{} + err := d.Client.Where("name LIKE ? AND user_id = ?", "%@"+domain, userId).Find(&aliases).Error + return aliases, err +} + func (d *Database) GetAllAliases(ctx context.Context, userID string) ([]model.Alias, error) { aliases := []model.Alias{} err := d.Client.Where("user_id = ?", userID).Order("created_at desc").Find(&aliases).Error @@ -150,3 +156,7 @@ func (d *Database) DeleteAlias(ctx context.Context, ID string, userID string) er func (d *Database) DeleteAliasByUserID(ctx context.Context, userID string) error { return d.Client.Where("user_id = ?", userID).Delete(&model.Alias{}).Error } + +func (d *Database) DeleteAliasByDomain(ctx context.Context, domain string, userID string) error { + return d.Client.Where("name LIKE ? AND user_id = ?", "%@"+domain, userID).Delete(&model.Alias{}).Error +} diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index 5fe5a53..09d7f84 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -20,11 +20,13 @@ var ( ErrUpdateAlias = errors.New("Unable to update alias. Please try again.") ErrDeleteAlias = errors.New("Unable to delete alias. Please try again.") ErrDeleteAliasByUserID = errors.New("Unable to delete aliases for this user.") + ErrDeleteAliasByDomain = errors.New("Unable to delete aliases for this domain.") ) type AliasStore interface { GetAlias(context.Context, string, string) (model.Alias, error) GetAliases(context.Context, string, int, int, string, string, string, string) ([]model.Alias, error) + GetAliasesByDomain(context.Context, string, string) ([]model.Alias, error) GetAllAliases(context.Context, string) ([]model.Alias, error) GetAliasCount(context.Context, string, string, string) (int, error) GetAliasDailyCount(context.Context, string) (int, error) @@ -33,6 +35,7 @@ type AliasStore interface { UpdateAlias(context.Context, model.Alias) error DeleteAlias(context.Context, string, string) error DeleteAliasByUserID(context.Context, string) error + DeleteAliasByDomain(context.Context, string, string) error } func (s *Service) GetAlias(ctx context.Context, ID string, userID string) (model.Alias, error) { @@ -69,6 +72,16 @@ func (s *Service) GetAliases(ctx context.Context, userID string, limit int, page }, nil } +func (s *Service) GetAliasesByDomain(ctx context.Context, domain string, userID string) ([]model.Alias, error) { + aliases, err := s.Store.GetAliasesByDomain(ctx, domain, userID) + if err != nil { + log.Printf("error fetching aliases by domain: %s", err.Error()) + return nil, ErrGetAliases + } + + return aliases, nil +} + func (s *Service) GetAllAliases(ctx context.Context, userID string) ([]model.Alias, error) { aliases, err := s.Store.GetAllAliases(ctx, userID) if err != nil { @@ -189,6 +202,16 @@ func (s *Service) DeleteAliasByUserID(ctx context.Context, userID string) error return nil } +func (s *Service) DeleteAliasByDomain(ctx context.Context, domain string, userID string) error { + err := s.Store.DeleteAliasByDomain(ctx, domain, userID) + if err != nil { + log.Printf("error deleting aliases by domain: %s", err.Error()) + return ErrDeleteAliasByDomain + } + + return nil +} + func (s *Service) FindAlias(email string) (model.Alias, error) { name, _ := model.ParseReplyTo(email) alias, err := s.GetAliasByName(name) diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 5c80308..987162a 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -116,7 +116,21 @@ func (s *Service) PostDomain(ctx context.Context, domain model.Domain) (model.Do } func (s *Service) DeleteDomain(ctx context.Context, domainID string, userID string) error { - err := s.Store.DeleteDomain(ctx, domainID, userID) + // Delete aliases associated with the domain + domain, err := s.GetDomain(ctx, domainID, userID) + if err != nil { + log.Printf("error getting domain for alias deletion: %s", err.Error()) + return ErrGetDomain + } + + err = s.DeleteAliasByDomain(ctx, domain.Name, userID) + if err != nil { + log.Printf("error deleting aliases by domain: %s", err.Error()) + return ErrDeleteAliasByDomain + } + + // Delete the domain + err = s.Store.DeleteDomain(ctx, domainID, userID) if err != nil { log.Printf("error deleting domain: %s", err.Error()) return ErrDeleteDomain From 626eacfc734a4707febe179ffefb8f345bf11431 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 27 Feb 2026 13:18:57 +0100 Subject: [PATCH 18/51] docs: update docs.go --- api/docs/docs.go | 350 ++++++++++++++++++++++++++++++++++++++++++ api/docs/swagger.json | 350 ++++++++++++++++++++++++++++++++++++++++++ api/docs/swagger.yaml | 225 +++++++++++++++++++++++++++ 3 files changed, 925 insertions(+) diff --git a/api/docs/docs.go b/api/docs/docs.go index 59472a9..5cc54b4 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -672,6 +672,261 @@ const docTemplate = `{ } } }, + "/domains": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all custom domains for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Get custom domains", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Domain" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update an existing custom domain for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Update custom domain", + "parameters": [ + { + "description": "Update Custom Domain Request", + "name": "domain", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UpdateDomainReq" + } + } + ], + "responses": { + "200": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new custom domain for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Create custom domain", + "parameters": [ + { + "description": "Custom Domain Request", + "name": "domain", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.DomainReq" + } + } + ], + "responses": { + "201": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, + "/domains/dns-config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the DNS configuration for all custom domains of the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Get custom domains DNS config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.DNSConfig" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, + "/domains/{id}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete an existing custom domain for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Delete custom domain", + "parameters": [ + { + "type": "string", + "description": "Domain ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, + "/domains/{id}/verify-dns": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Verify the DNS records for a custom domain of the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Verify custom domain DNS records", + "parameters": [ + { + "type": "string", + "description": "Domain ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, "/email": { "post": { "description": "Handle incoming email", @@ -2341,6 +2596,17 @@ const docTemplate = `{ } } }, + "api.DomainReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "api.EmailReq": { "type": "object", "required": [ @@ -2492,6 +2758,29 @@ const docTemplate = `{ } } }, + "api.UpdateDomainReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "recipient": { + "type": "string" + } + } + }, "api.UserReq": { "type": "object", "required": [ @@ -2609,6 +2898,67 @@ const docTemplate = `{ } } }, + "model.DNSConfig": { + "type": "object", + "properties": { + "dkim_selectors": { + "type": "array", + "items": { + "type": "string" + } + }, + "domain": { + "type": "string" + }, + "mx_hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "verify": { + "type": "string" + } + } + }, + "model.Domain": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mx_verified_at": { + "description": "nullable", + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_verified_at": { + "description": "nullable", + "type": "string" + }, + "recipient": { + "type": "string" + }, + "send_verified_at": { + "description": "nullable", + "type": "string" + } + } + }, "model.Log": { "type": "object", "properties": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 0d3688a..8af6895 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -661,6 +661,261 @@ } } }, + "/domains": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all custom domains for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Get custom domains", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Domain" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update an existing custom domain for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Update custom domain", + "parameters": [ + { + "description": "Update Custom Domain Request", + "name": "domain", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UpdateDomainReq" + } + } + ], + "responses": { + "200": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new custom domain for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Create custom domain", + "parameters": [ + { + "description": "Custom Domain Request", + "name": "domain", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.DomainReq" + } + } + ], + "responses": { + "201": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, + "/domains/dns-config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the DNS configuration for all custom domains of the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Get custom domains DNS config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.DNSConfig" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, + "/domains/{id}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete an existing custom domain for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Delete custom domain", + "parameters": [ + { + "type": "string", + "description": "Domain ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, + "/domains/{id}/verify-dns": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Verify the DNS records for a custom domain of the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "domain" + ], + "summary": "Verify custom domain DNS records", + "parameters": [ + { + "type": "string", + "description": "Domain ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "message", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, "/email": { "post": { "description": "Handle incoming email", @@ -2330,6 +2585,17 @@ } } }, + "api.DomainReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "api.EmailReq": { "type": "object", "required": [ @@ -2481,6 +2747,29 @@ } } }, + "api.UpdateDomainReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "recipient": { + "type": "string" + } + } + }, "api.UserReq": { "type": "object", "required": [ @@ -2598,6 +2887,67 @@ } } }, + "model.DNSConfig": { + "type": "object", + "properties": { + "dkim_selectors": { + "type": "array", + "items": { + "type": "string" + } + }, + "domain": { + "type": "string" + }, + "mx_hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "verify": { + "type": "string" + } + } + }, + "model.Domain": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mx_verified_at": { + "description": "nullable", + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_verified_at": { + "description": "nullable", + "type": "string" + }, + "recipient": { + "type": "string" + }, + "send_verified_at": { + "description": "nullable", + "type": "string" + } + } + }, "model.Log": { "type": "object", "properties": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index b48fbd4..3fe909c 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -63,6 +63,13 @@ definitions: required: - otp type: object + api.DomainReq: + properties: + name: + type: string + required: + - name + type: object api.EmailReq: properties: email: @@ -162,6 +169,21 @@ definitions: required: - otp type: object + api.UpdateDomainReq: + properties: + description: + type: string + enabled: + type: boolean + from_name: + type: string + id: + type: string + recipient: + type: string + required: + - id + type: object api.UserReq: properties: email: @@ -239,6 +261,47 @@ definitions: user_id: type: string type: object + model.DNSConfig: + properties: + dkim_selectors: + items: + type: string + type: array + domain: + type: string + mx_hosts: + items: + type: string + type: array + verify: + type: string + type: object + model.Domain: + properties: + created_at: + type: string + description: + type: string + enabled: + type: boolean + from_name: + type: string + id: + type: string + mx_verified_at: + description: nullable + type: string + name: + type: string + owner_verified_at: + description: nullable + type: string + recipient: + type: string + send_verified_at: + description: nullable + type: string + type: object model.Log: properties: attempted_at: @@ -785,6 +848,168 @@ paths: summary: Logout API user tags: - access_key + /domains: + get: + consumes: + - application/json + description: Get all custom domains for the authenticated user + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.Domain' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorRes' + security: + - ApiKeyAuth: [] + summary: Get custom domains + tags: + - domain + post: + consumes: + - application/json + description: Create a new custom domain for the authenticated user + parameters: + - description: Custom Domain Request + in: body + name: domain + required: true + schema: + $ref: '#/definitions/api.DomainReq' + produces: + - application/json + responses: + "201": + description: message + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorRes' + security: + - ApiKeyAuth: [] + summary: Create custom domain + tags: + - domain + put: + consumes: + - application/json + description: Update an existing custom domain for the authenticated user + parameters: + - description: Update Custom Domain Request + in: body + name: domain + required: true + schema: + $ref: '#/definitions/api.UpdateDomainReq' + produces: + - application/json + responses: + "200": + description: message + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorRes' + security: + - ApiKeyAuth: [] + summary: Update custom domain + tags: + - domain + /domains/{id}: + delete: + consumes: + - application/json + description: Delete an existing custom domain for the authenticated user + parameters: + - description: Domain ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: message + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorRes' + security: + - ApiKeyAuth: [] + summary: Delete custom domain + tags: + - domain + /domains/{id}/verify-dns: + post: + consumes: + - application/json + description: Verify the DNS records for a custom domain of the authenticated + user + parameters: + - description: Domain ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: message + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorRes' + security: + - ApiKeyAuth: [] + summary: Verify custom domain DNS records + tags: + - domain + /domains/dns-config: + get: + consumes: + - application/json + description: Get the DNS configuration for all custom domains of the authenticated + user + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.DNSConfig' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorRes' + security: + - ApiKeyAuth: [] + summary: Get custom domains DNS config + tags: + - domain /email: post: consumes: From 381aabef3b8c3be5180869416fa9102da4f5bf7a Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Sun, 8 Mar 2026 19:25:52 +0100 Subject: [PATCH 19/51] feat(service): update domain.go --- api/internal/service/domain.go | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 987162a..e3a5451 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -14,18 +14,19 @@ import ( ) var ( - ErrGetDomains = errors.New("Unable to retrieve domains.") - ErrGetDomain = errors.New("Unable to retrieve domain.") - ErrGetDomainsCount = errors.New("Unable to retrieve domains count.") - ErrGetDNSConfig = errors.New("Unable to retrieve DNS config.") - ErrPostDomain = errors.New("Unable to create domain. Please try again.") - ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") - ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") - ErrDNSLookupOwner = errors.New("Unable to verify domain ownership. Please ensure the correct TXT record is set.") - ErrDNSLookupSPF = errors.New("Unable to verify domain DNS records. Please ensure the correct SPF record is set.") - ErrDNSLookupDKIM = errors.New("Unable to verify domain DNS records. Please ensure the correct DKIM records are set.") - ErrDNSLookupDMARC = errors.New("Unable to verify domain DNS records. Please ensure the correct DMARC record is set.") - ErrDNSLookupMX = errors.New("Unable to verify domain DNS records. Please ensure the correct MX records are set.") + ErrGetDomains = errors.New("Unable to retrieve domains.") + ErrGetDomain = errors.New("Unable to retrieve domain.") + ErrGetDomainsCount = errors.New("Unable to retrieve domains count.") + ErrGetDNSConfig = errors.New("Unable to retrieve DNS config.") + ErrPostDomain = errors.New("Unable to create domain. Please try again.") + ErrPostDomainPredefined = errors.New("Please enter a different domain.") + ErrUpdateDomain = errors.New("Unable to update domain. Please try again.") + ErrDeleteDomain = errors.New("Unable to delete domain. Please try again.") + ErrDNSLookupOwner = errors.New("Unable to verify domain ownership. Please ensure the correct TXT record is set.") + ErrDNSLookupSPF = errors.New("Unable to verify domain DNS records. Please ensure the correct SPF record is set.") + ErrDNSLookupDKIM = errors.New("Unable to verify domain DNS records. Please ensure the correct DKIM records are set.") + ErrDNSLookupDMARC = errors.New("Unable to verify domain DNS records. Please ensure the correct DMARC record is set.") + ErrDNSLookupMX = errors.New("Unable to verify domain DNS records. Please ensure the correct MX records are set.") ) type DomainStore interface { @@ -97,6 +98,11 @@ func (s *Service) GetDNSConfig(ctx context.Context, userId string) (model.DNSCon } func (s *Service) PostDomain(ctx context.Context, domain model.Domain) (model.Domain, error) { + if strings.Contains(s.Cfg.API.Domains, domain.Name) { + log.Printf("domain %s is in predefined list of domains", domain.Name) + return model.Domain{}, ErrPostDomainPredefined + } + err := s.VerifyDomainOwner(ctx, domain.Name, domain.UserID) if err != nil { log.Printf("error verifying domain ownership: %s", err.Error()) From a9aaaabc3873c69d241884b2a9b2df63524ca7dd Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 9 Mar 2026 13:24:03 +0100 Subject: [PATCH 20/51] feat(app): create Domains.vue --- app/src/assets/icons/icon-global.svg | 10 ++++++ app/src/components/Domains.vue | 52 ++++++++++++++++++++++++++++ app/src/components/Sidebar.vue | 4 +++ app/src/components/Tabbar.vue | 3 ++ app/src/router.ts | 8 ++++- app/src/style/components/icon.css | 4 +++ 6 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 app/src/assets/icons/icon-global.svg create mode 100644 app/src/components/Domains.vue diff --git a/app/src/assets/icons/icon-global.svg b/app/src/assets/icons/icon-global.svg new file mode 100644 index 0000000..ae9ed9d --- /dev/null +++ b/app/src/assets/icons/icon-global.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue new file mode 100644 index 0000000..b1fc141 --- /dev/null +++ b/app/src/components/Domains.vue @@ -0,0 +1,52 @@ + + + \ No newline at end of file diff --git a/app/src/components/Sidebar.vue b/app/src/components/Sidebar.vue index de43f1b..d55a846 100644 --- a/app/src/components/Sidebar.vue +++ b/app/src/components/Sidebar.vue @@ -19,6 +19,10 @@ Recipients + + + Domains + Stats diff --git a/app/src/components/Tabbar.vue b/app/src/components/Tabbar.vue index e654421..6f9c06d 100644 --- a/app/src/components/Tabbar.vue +++ b/app/src/components/Tabbar.vue @@ -11,6 +11,9 @@ + + + diff --git a/app/src/router.ts b/app/src/router.ts index 2c59d86..c4c30be 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' import Dashboard from './components/Dashboard.vue' import QuickActions from './components/QuickActions.vue' import Recipients from './components/Recipients.vue' +import Domains from './components/Domains.vue' import Wildcards from './components/Wildcards.vue' import Stats from './components/Stats.vue' import Diagnostics from './components/Diagnostics.vue' @@ -26,7 +27,7 @@ declare global { const AppName = import.meta.env.VITE_APP_NAME // Protected routes that require authentication -const PROTECTED_ROUTES = ['/', '/recipients', '/stats', '/settings', '/account'] +const PROTECTED_ROUTES = ['/', '/recipients', '/domains', '/stats', '/settings', '/account'] // Dashboard child routes const dashboardChildren: RouteRecordRaw[] = [ @@ -45,6 +46,11 @@ const dashboardChildren: RouteRecordRaw[] = [ name: `${AppName} - Recipients`, component: Recipients, }, + { + path: 'domains', + name: `${AppName} - Domains`, + component: Domains, + }, { path: 'stats', name: `${AppName} - Stats`, diff --git a/app/src/style/components/icon.css b/app/src/style/components/icon.css index 4b53e7b..7c8e6ea 100644 --- a/app/src/style/components/icon.css +++ b/app/src/style/components/icon.css @@ -92,6 +92,10 @@ &.alert { mask-image: url("../../assets/icons/icon-alert.svg"); } + + &.global { + mask-image: url("../../assets/icons/icon-global.svg"); + } } .logo { From 007b82798516db44ecc1aede564a0de332b7c716 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 9 Mar 2026 13:41:05 +0100 Subject: [PATCH 21/51] feat(app): create api/domain.ts --- api/internal/repository/db.go | 1 + app/src/api/domain.ts | 9 +++++++++ app/src/components/Domains.vue | 29 +++++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 app/src/api/domain.ts diff --git a/api/internal/repository/db.go b/api/internal/repository/db.go index 2d94c6f..b95e37f 100644 --- a/api/internal/repository/db.go +++ b/api/internal/repository/db.go @@ -108,6 +108,7 @@ func migrate(db *gorm.DB) error { &model.Credential{}, &model.Log{}, &model.AccessKey{}, + &model.Domain{}, ) if err != nil { return err diff --git a/app/src/api/domain.ts b/app/src/api/domain.ts new file mode 100644 index 0000000..00f47be --- /dev/null +++ b/app/src/api/domain.ts @@ -0,0 +1,9 @@ +import { api } from './api' + +export const domainApi = { + getList: () => api.get('/domains'), + getConfig: () => api.get('/domains/dns-config'), + create: (data: any) => api.post('/domain', data), + update: (id: string, data: any) => api.put('/domain/' + id, data), + delete: (id: string) => api.put('/domain/' + id), +} \ No newline at end of file diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue index b1fc141..fd6ac40 100644 --- a/app/src/components/Domains.vue +++ b/app/src/components/Domains.vue @@ -37,7 +37,9 @@ \ No newline at end of file From b867ac1b55070dae5c90680e3db02fcad99ee168 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 9 Mar 2026 14:20:32 +0100 Subject: [PATCH 22/51] feat(app): create DomainCreate.vue --- app/src/components/DomainCreate.vue | 131 ++++++++++++++++++++++++++++ app/src/components/Domains.vue | 5 +- app/src/events.ts | 1 + 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 app/src/components/DomainCreate.vue diff --git a/app/src/components/DomainCreate.vue b/app/src/components/DomainCreate.vue new file mode 100644 index 0000000..109e5a0 --- /dev/null +++ b/app/src/components/DomainCreate.vue @@ -0,0 +1,131 @@ + + + \ No newline at end of file diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue index fd6ac40..09bb79d 100644 --- a/app/src/components/Domains.vue +++ b/app/src/components/Domains.vue @@ -3,7 +3,7 @@

Domains

- +
@@ -11,7 +11,7 @@

You have no domains yet

- +
@@ -40,6 +40,7 @@ import { onMounted, ref } from 'vue' import axios from 'axios' import { domainApi } from '../api/domain.ts' +import DomainCreate from './DomainCreate.vue' const domain = { id: '', diff --git a/app/src/events.ts b/app/src/events.ts index 4af0156..659a284 100644 --- a/app/src/events.ts +++ b/app/src/events.ts @@ -15,6 +15,7 @@ type Events = { 'recipient.delete.error': { error: string } 'recipient.reload': {} 'accesskey.create': {} + 'domain.create': {} } export default mitt() \ No newline at end of file From bc4d2e9a66088a50173fbb2c4ddd4b687f200aac Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 9 Mar 2026 14:50:08 +0100 Subject: [PATCH 23/51] feat(app): update DomainCreate.vue --- app/src/components/DomainCreate.vue | 22 +++++++++++++++++++++- app/src/components/Domains.vue | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/components/DomainCreate.vue b/app/src/components/DomainCreate.vue index 109e5a0..6763974 100644 --- a/app/src/components/DomainCreate.vue +++ b/app/src/components/DomainCreate.vue @@ -15,7 +15,12 @@

- Add a new domain to receive forwarded emails. + To confirm that you own the domain, add the TXT record shown below and then click Add Domain. After the domain has been successfully added, you may remove the TXT record if you wish. +

+

+ Type: TXT
+ Host: @
+ Value: mailx-verify={{ config.verify }}

@@ -56,6 +61,9 @@ import axios from 'axios' import { domainApi } from '../api/domain.ts' import events from '../events.ts' +const config = ref({ + verify: '', +}) const domain = ref({ name: '', }) @@ -67,6 +75,17 @@ const validateName = () => { return !nameError.value } +const getConfig = async () => { + try { + const res = await domainApi.getConfig() + config.value = res.data + } catch (err) { + if (axios.isAxiosError(err)) { + error.value = err.response?.data.error || err.message + } + } +} + const postDomain = async () => { if (!validateName()) { return @@ -127,5 +146,6 @@ const handleKeydown = (event: KeyboardEvent) => { onMounted(() => { overlay.autoInit() addEvents() + getConfig() }) \ No newline at end of file diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue index 09bb79d..93b5daf 100644 --- a/app/src/components/Domains.vue +++ b/app/src/components/Domains.vue @@ -8,7 +8,7 @@
- +

You have no domains yet

From 67a2f92f5406789afa7d627ea165bd41c18ee95f Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 9 Mar 2026 15:16:03 +0100 Subject: [PATCH 24/51] feat(app): update DomainCreate.vue --- app/src/components/DomainCreate.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/components/DomainCreate.vue b/app/src/components/DomainCreate.vue index 6763974..73c6d13 100644 --- a/app/src/components/DomainCreate.vue +++ b/app/src/components/DomainCreate.vue @@ -18,9 +18,8 @@ To confirm that you own the domain, add the TXT record shown below and then click Add Domain. After the domain has been successfully added, you may remove the TXT record if you wish.

- Type: TXT
- Host: @
- Value: mailx-verify={{ config.verify }} + DNS Record:
+ TXT @ mailx-verify={{ config.verify }}

From e7c116e8ad5610a7a455df06d6da1db000b94a35 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 10 Mar 2026 09:28:39 +0100 Subject: [PATCH 25/51] feat(app): update Domains.vue --- app/src/components/Domains.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue index 93b5daf..09821e1 100644 --- a/app/src/components/Domains.vue +++ b/app/src/components/Domains.vue @@ -41,6 +41,7 @@ import { onMounted, ref } from 'vue' import axios from 'axios' import { domainApi } from '../api/domain.ts' import DomainCreate from './DomainCreate.vue' +import events from '../events.ts' const domain = { id: '', @@ -74,5 +75,6 @@ const renderRow = () => { onMounted(() => { getList() + events.on('domain.create', getList) }) \ No newline at end of file From 659e8a68f44b3e4e88d819ad92097637e219f251 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 10 Mar 2026 10:10:00 +0100 Subject: [PATCH 26/51] feat(app): create DomainRow.vue --- app/src/components/DomainRow.vue | 66 ++++++++++++++++++++++++++++++++ app/src/components/Domains.vue | 3 +- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 app/src/components/DomainRow.vue diff --git a/app/src/components/DomainRow.vue b/app/src/components/DomainRow.vue new file mode 100644 index 0000000..eacc448 --- /dev/null +++ b/app/src/components/DomainRow.vue @@ -0,0 +1,66 @@ + + + \ No newline at end of file diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue index 09821e1..9205266 100644 --- a/app/src/components/Domains.vue +++ b/app/src/components/Domains.vue @@ -27,7 +27,7 @@ - +

Error: {{ error }}

@@ -41,6 +41,7 @@ import { onMounted, ref } from 'vue' import axios from 'axios' import { domainApi } from '../api/domain.ts' import DomainCreate from './DomainCreate.vue' +import DomainRow from './DomainRow.vue' import events from '../events.ts' const domain = { From ebb31120fca1285696b8c3dd765cf26eacbc23c2 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 10 Mar 2026 10:30:29 +0100 Subject: [PATCH 27/51] feat(app): update DomainRow.vue --- app/src/components/DomainRow.vue | 11 +++++++++-- app/src/components/Domains.vue | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/components/DomainRow.vue b/app/src/components/DomainRow.vue index eacc448..71aed7b 100644 --- a/app/src/components/DomainRow.vue +++ b/app/src/components/DomainRow.vue @@ -16,11 +16,14 @@
+ +

{{ dnsRecordsVerified() ? 'Verified' : 'Not Verified' }}

+
+ + \ No newline at end of file diff --git a/app/src/events.ts b/app/src/events.ts index 659a284..fe7377f 100644 --- a/app/src/events.ts +++ b/app/src/events.ts @@ -16,6 +16,7 @@ type Events = { 'recipient.reload': {} 'accesskey.create': {} 'domain.create': {} + 'domain.reload': {} } export default mitt() \ No newline at end of file From 742e6d9a5ee33fafe27b43a047beeabde9532159 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 10 Mar 2026 14:22:40 +0100 Subject: [PATCH 32/51] feat(app): create DomainEdit.vue --- app/src/components/Aliases.vue | 2 +- app/src/components/DomainEdit.vue | 102 ++++++++++++++++++++++++++++++ app/src/components/DomainRow.vue | 11 ++-- app/src/components/Domains.vue | 20 +++++- 4 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 app/src/components/DomainEdit.vue diff --git a/app/src/components/Aliases.vue b/app/src/components/Aliases.vue index 0f71d5f..63b182a 100644 --- a/app/src/components/Aliases.vue +++ b/app/src/components/Aliases.vue @@ -46,7 +46,7 @@ - + + - + @@ -52,9 +53,11 @@ import { ref, onMounted } from 'vue' import dropdown from '@preline/dropdown' import { domainApi } from '../api/domain.ts' import DomainDelete from './DomainDelete.vue' +import DomainEdit from './DomainEdit.vue' -const props = defineProps(['domain']) +const props = defineProps(['domain', 'recipients']) const domain = ref(props.domain) +const recipients = ref(props.recipients) const updateDomain = async () => { domain.value.enabled = !domain.value.enabled diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue index 968330e..8f4f405 100644 --- a/app/src/components/Domains.vue +++ b/app/src/components/Domains.vue @@ -21,13 +21,13 @@ + - - +
StatusActive +

EDIT DOMAIN

+ +
+
+

Default Recipient

+

+ Set the default recipient for this domain. +

+
+ + +
+
+
+
+ +

Error: {{ error }}

+
+ + + + + + + \ No newline at end of file diff --git a/app/src/components/DomainRow.vue b/app/src/components/DomainRow.vue index 1792835..8ca7c68 100644 --- a/app/src/components/DomainRow.vue +++ b/app/src/components/DomainRow.vue @@ -9,6 +9,9 @@

{{ domain.default_recipient }}

+

{{ dnsRecordsVerified() ? 'Verified' : 'Not Verified' }}

+
-

{{ dnsRecordsVerified() ? 'Verified' : 'Not Verified' }}

-
Created Domain Default RecipientVerified ActiveDNS Records Actions

Error: {{ error }}

@@ -40,6 +40,7 @@ import { onMounted, ref } from 'vue' import axios from 'axios' import { domainApi } from '../api/domain.ts' +import { recipientApi } from '../api/recipient.ts' import DomainCreate from './DomainCreate.vue' import DomainRow from './DomainRow.vue' import events from '../events.ts' @@ -57,6 +58,7 @@ const domain = { } const list = ref([] as typeof domain[]) +const recipients = ref([]) const error = ref('') const loaded = ref(false) const rowKey = ref(0) @@ -76,12 +78,26 @@ const getList = async () => { } } +const getRecipients = async () => { + try { + const res = await recipientApi.getList() + const list = res.data.filter((item: { is_active: boolean }) => item.is_active) + recipients.value = list.map((recipient: { email: string }) => recipient.email) + error.value = '' + } catch (err) { + if (axios.isAxiosError(err)) { + error.value = err.message + } + } +} + const renderRow = () => { rowKey.value++ } onMounted(() => { getList() + getRecipients() events.on('domain.create', getList) events.on('domain.reload', getList) }) From 14e949b02d9b5d7ae407d6e711216e1da887d43b Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 10 Mar 2026 14:30:13 +0100 Subject: [PATCH 33/51] feat(app): update DomainEdit.vue --- app/src/components/DomainEdit.vue | 3 +++ app/src/components/Domains.vue | 1 + app/src/events.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/app/src/components/DomainEdit.vue b/app/src/components/DomainEdit.vue index 2fbe49a..49b0a1d 100644 --- a/app/src/components/DomainEdit.vue +++ b/app/src/components/DomainEdit.vue @@ -60,6 +60,7 @@ const props = defineProps(['domain', 'recipients']) const domain = ref(props.domain) const recipients = ref(props.recipients) const error = ref('') +import events from '../events.ts' const updateDomain = async () => { const payload = { @@ -70,6 +71,8 @@ const updateDomain = async () => { try { await domainApi.update(domain.value.id, payload) error.value = '' + events.emit('domain.update', {}) + close() } catch (err) { if (axios.isAxiosError(err)) { const errorMsg = err.response?.data.error || err.message diff --git a/app/src/components/Domains.vue b/app/src/components/Domains.vue index 8f4f405..8b9f287 100644 --- a/app/src/components/Domains.vue +++ b/app/src/components/Domains.vue @@ -100,5 +100,6 @@ onMounted(() => { getRecipients() events.on('domain.create', getList) events.on('domain.reload', getList) + events.on('domain.update', getList) }) \ No newline at end of file diff --git a/app/src/events.ts b/app/src/events.ts index fe7377f..80dfb1d 100644 --- a/app/src/events.ts +++ b/app/src/events.ts @@ -17,6 +17,7 @@ type Events = { 'accesskey.create': {} 'domain.create': {} 'domain.reload': {} + 'domain.update': {} } export default mitt() \ No newline at end of file From fed564b1d02eb2f5eca3fc02460d8c0778a1a4b2 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 10 Mar 2026 15:19:59 +0100 Subject: [PATCH 34/51] feat(app): update DomainRow.vue --- app/src/components/DomainRow.vue | 12 +----------- app/src/components/Domains.vue | 21 +-------------------- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/app/src/components/DomainRow.vue b/app/src/components/DomainRow.vue index 8ca7c68..1d0dd8d 100644 --- a/app/src/components/DomainRow.vue +++ b/app/src/components/DomainRow.vue @@ -6,9 +6,6 @@

{{ domain.name }}

- -

{{ domain.default_recipient }}

-

{{ dnsRecordsVerified() ? 'Verified' : 'Not Verified' }}

@@ -30,10 +27,6 @@ class="hs-dropdown-menu hs-dropdown-open:opacity-100 hidden" v-bind:aria-labelledby="'hs-dropdown-domain-edit-' + domain.id" > - + +

@@ -37,6 +40,7 @@ + @@ -45,6 +49,7 @@ import { ref, onMounted } from 'vue' import dropdown from '@preline/dropdown' import { domainApi } from '../api/domain.ts' import DomainDelete from './DomainDelete.vue' +import DomainVerify from './DomainVerify.vue' const props = defineProps(['domain']) const domain = ref(props.domain) diff --git a/app/src/components/DomainVerify.vue b/app/src/components/DomainVerify.vue new file mode 100644 index 0000000..222f5d7 --- /dev/null +++ b/app/src/components/DomainVerify.vue @@ -0,0 +1,135 @@ + + + \ No newline at end of file diff --git a/app/src/style/components/button.css b/app/src/style/components/button.css index 00178d4..5edb239 100644 --- a/app/src/style/components/button.css +++ b/app/src/style/components/button.css @@ -17,6 +17,10 @@ @apply bg-tertiary hover:bg-secondary text-secondary hover:text-secondary; } + &.success { + @apply bg-success text-white; + } + &.sm { @apply font-medium text-sm py-1.5 px-2; } diff --git a/app/src/style/components/table.css b/app/src/style/components/table.css index f805e4c..97c2435 100644 --- a/app/src/style/components/table.css +++ b/app/src/style/components/table.css @@ -54,6 +54,16 @@ } } } + + &.sm { + th { + @apply px-1 py-3 text-[13px]; + } + + td { + @apply px-1 py-3 text-[13px]; + } + } } .table-container { From ce1458fd0a35d8a0de9bf5ebf410f1e57afd97ee Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 11 Mar 2026 10:38:44 +0100 Subject: [PATCH 36/51] feat(app): update DomainVerify.vue --- app/src/api/domain.ts | 1 + app/src/components/DomainVerify.vue | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/api/domain.ts b/app/src/api/domain.ts index 00f47be..e232bde 100644 --- a/app/src/api/domain.ts +++ b/app/src/api/domain.ts @@ -3,6 +3,7 @@ import { api } from './api' export const domainApi = { getList: () => api.get('/domains'), getConfig: () => api.get('/domains/dns-config'), + verifyDns: (id: string) => api.post('/domain/' + id + '/verify-dns'), create: (data: any) => api.post('/domain', data), update: (id: string, data: any) => api.put('/domain/' + id, data), delete: (id: string) => api.put('/domain/' + id), diff --git a/app/src/components/DomainVerify.vue b/app/src/components/DomainVerify.vue index 222f5d7..73af654 100644 --- a/app/src/components/DomainVerify.vue +++ b/app/src/components/DomainVerify.vue @@ -89,6 +89,7 @@ import { ref, onMounted } from 'vue' import overlay from '@preline/overlay' import { domainApi } from '../api/domain.ts' import axios from 'axios' +import events from '../events.ts' const props = defineProps(['domain']) const domain = ref(props.domain) @@ -112,7 +113,18 @@ const getConfig = async () => { } } -const verifyDomain = async () => {} +const verifyDomain = async () => { + try { + await domainApi.verifyDns(domain.value.id) + error.value = '' + events.emit('domain.reload', {}) + close() + } catch (err) { + if (axios.isAxiosError(err)) { + error.value = err.response?.data.error || err.message + } + } +} const close = () => { error.value = '' From 8120651275cdef3c3490b6ca3fc73a937bc88b4e Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 11 Mar 2026 12:21:37 +0100 Subject: [PATCH 37/51] feat(app): update DomainRow.vue --- app/src/components/DomainRow.vue | 6 +++++- app/src/components/Domains.vue | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/components/DomainRow.vue b/app/src/components/DomainRow.vue index e1a5edc..885df67 100644 --- a/app/src/components/DomainRow.vue +++ b/app/src/components/DomainRow.vue @@ -8,7 +8,7 @@

- +

@@ -30,6 +30,10 @@ class="hs-dropdown-menu hs-dropdown-open:opacity-100 hidden" v-bind:aria-labelledby="'hs-dropdown-domain-edit-' + domain.id" > +
@@ -171,6 +175,7 @@ const recipients = ref(props.recipients) const settings = ref(props.settings) const selectRecipients = ref([settings.value.recipient ? settings.value.recipient : props.recipients[0]]) const domains = ref(envDomains) +const customDomains = ref(props.settings.custom_domains || []) const formats = ref([{ name: 'Words', value: 'words' diff --git a/app/src/components/Aliases.vue b/app/src/components/Aliases.vue index 3c64180..355541e 100644 --- a/app/src/components/Aliases.vue +++ b/app/src/components/Aliases.vue @@ -136,6 +136,7 @@ const recipients = ref([]) const settings = ref({ id: '', domain: '', + domains: [], custom_domains: [], recipient: '', from_name: '' diff --git a/app/src/components/Wildcards.vue b/app/src/components/Wildcards.vue index 961343e..06fde0c 100644 --- a/app/src/components/Wildcards.vue +++ b/app/src/components/Wildcards.vue @@ -115,6 +115,7 @@ const recipients = ref([]) const settings = ref({ id: '', domain: '', + domains: [], custom_domains: [], recipient: '', from_name: '' From 7d0e96c830afff147a882d0ed53b9ba095ebd84e Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 11 Mar 2026 13:42:50 +0100 Subject: [PATCH 44/51] feat(api): update alias.go --- api/internal/model/alias_name.go | 1 + api/internal/repository/domain.go | 6 ++++++ api/internal/service/domain.go | 1 + api/internal/transport/api/alias.go | 28 ++++++++++++++++++++++++++-- api/internal/transport/api/domain.go | 1 + 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/api/internal/model/alias_name.go b/api/internal/model/alias_name.go index f4c043f..5662078 100644 --- a/api/internal/model/alias_name.go +++ b/api/internal/model/alias_name.go @@ -13,6 +13,7 @@ const ( AliasFormatRandomChars = "random" AliasFormatUUID = "uuid" AliasFormatCatchAll = "catch_all" + AliasFormatCustom = "custom" ) var ( diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go index eb56a3b..99f6659 100644 --- a/api/internal/repository/domain.go +++ b/api/internal/repository/domain.go @@ -24,6 +24,12 @@ func (d *Database) GetDomain(ctx context.Context, domainID string, userID string return domain, err } +func (d *Database) GetVerifiedDomain(ctx context.Context, domainID string, userID string) (model.Domain, error) { + var domain model.Domain + err := d.Client.Where("id = ? AND user_id = ? AND owner_verified_at IS NOT NULL AND mx_verified_at IS NOT NULL AND send_verified_at IS NOT NULL", domainID, userID).First(&domain).Error + return domain, err +} + func (d *Database) GetDomainsCount(ctx context.Context, userID string) (int64, error) { var count int64 err := d.Client.Model(&model.Domain{}).Where("user_id = ?", userID).Count(&count).Error diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 0c1745e..1a6e4e8 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -33,6 +33,7 @@ type DomainStore interface { GetDomains(context.Context, string) ([]model.Domain, error) GetVerifiedDomains(context.Context, string) ([]model.Domain, error) GetDomain(context.Context, string, string) (model.Domain, error) + GetVerifiedDomain(context.Context, string, string) (model.Domain, error) GetDomainsCount(context.Context, string) (int64, error) PostDomain(context.Context, model.Domain) (model.Domain, error) UpdateDomain(context.Context, model.Domain) error diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index 15a1d9d..bc1c087 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gofiber/fiber/v2" + "github.com/google/uuid" "ivpn.net/email/api/internal/middleware/auth" "ivpn.net/email/api/internal/model" ) @@ -175,6 +176,7 @@ func (h *Handler) PostAlias(c *fiber.Ctx) error { }) } + // Validate request err = h.Validator.Struct(req) if err != nil { return c.Status(400).JSON(fiber.Map{ @@ -182,12 +184,26 @@ func (h *Handler) PostAlias(c *fiber.Ctx) error { }) } - if !strings.Contains(h.Cfg.Domains, req.Domain) { + // Validate domain + domain := req.Domain + isCustomDomain := false + _, err = uuid.Parse(domain) + if err == nil { + isCustomDomain = true + fqdn, err := h.Service.GetVerifiedDomain(c.Context(), domain, userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrInvalidDomain, + }) + } + domain = fqdn.Name + } else if !strings.Contains(h.Cfg.Domains, domain) { return c.Status(400).JSON(fiber.Map{ "error": ErrInvalidDomain, }) } + // Validate recipients rcps, err := h.Service.GetVerifiedRecipients(c.Context(), req.Recipients, userID) if err != nil || len(rcps) == 0 { return c.Status(400).JSON(fiber.Map{ @@ -195,12 +211,20 @@ func (h *Handler) PostAlias(c *fiber.Ctx) error { }) } + // Validate catch-all suffix if req.Format == model.AliasFormatCatchAll && req.CatchAllSuffix == "" { return c.Status(400).JSON(fiber.Map{ "error": ErrInvalidRequest, }) } + // Validate custom alias format + if req.Format == model.AliasFormatCustom && isCustomDomain { + return c.Status(400).JSON(fiber.Map{ + "error": ErrInvalidRequest, + }) + } + alias := model.Alias{ UserID: userID, Description: req.Description, @@ -209,7 +233,7 @@ func (h *Handler) PostAlias(c *fiber.Ctx) error { FromName: req.FromName, } - alias, err = h.Service.PostAlias(c.Context(), alias, req.Format, req.Domain, req.CatchAllSuffix) + alias, err = h.Service.PostAlias(c.Context(), alias, req.Format, domain, req.CatchAllSuffix) if err != nil { return c.Status(400).JSON(fiber.Map{ "error": err.Error(), diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index 7ba639c..9cd6173 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -25,6 +25,7 @@ type DomainService interface { GetDomains(context.Context, string) ([]model.Domain, error) GetVerifiedDomains(context.Context, string) ([]model.Domain, error) GetDomain(context.Context, string, string) (model.Domain, error) + GetVerifiedDomain(context.Context, string, string) (model.Domain, error) GetDNSConfig(context.Context, string) (model.DNSConfig, error) PostDomain(context.Context, model.Domain) (model.Domain, error) UpdateDomain(context.Context, model.Domain) error From 111c682dbd4466fb94b0483dc360a84d801104bf Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 11 Mar 2026 13:44:41 +0100 Subject: [PATCH 45/51] feat(service): update domain.go --- api/internal/service/domain.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 1a6e4e8..2241d6c 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -71,6 +71,16 @@ func (s *Service) GetDomain(ctx context.Context, domainID string, userID string) return domain, nil } +func (s *Service) GetVerifiedDomain(ctx context.Context, domainID string, userID string) (model.Domain, error) { + domain, err := s.Store.GetVerifiedDomain(ctx, domainID, userID) + if err != nil { + log.Printf("error getting verified domain: %s", err.Error()) + return model.Domain{}, ErrGetDomain + } + + return domain, nil +} + func (s *Service) GetDomainsCount(ctx context.Context, userId string) (int64, error) { count, err := s.Store.GetDomainsCount(ctx, userId) if err != nil { From 5bcad8de0d80077d57bab501551ad1132a79bb58 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 11 Mar 2026 14:03:19 +0100 Subject: [PATCH 46/51] feat(app): update AliasCreate.vue --- app/src/components/AliasCreate.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/components/AliasCreate.vue b/app/src/components/AliasCreate.vue index 5400cdd..9de4e0a 100644 --- a/app/src/components/AliasCreate.vue +++ b/app/src/components/AliasCreate.vue @@ -104,7 +104,7 @@ :selected="domain == alias.domain || index === 0" :key="domain"> {{ domain }} - @@ -194,7 +194,10 @@ const loading = ref(false) const postAlias = async () => { if (loading.value) return - alias.value.domain = (document.getElementById('alias_domain') as HTMLInputElement).value + const select = document.getElementById('alias_domain') as HTMLSelectElement + const selectedOption = select.options[select.selectedIndex] + const domain = selectedOption.getAttribute('domain') + alias.value.domain = domain alias.value.recipients = selectRecipients.value.join(',') alias.value.enabled = true From ba55199b0d24e4a502204963f7ba6926eba3eb6b Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 11 Mar 2026 14:19:20 +0100 Subject: [PATCH 47/51] feat(app): update api/domain.ts --- app/src/api/domain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/api/domain.ts b/app/src/api/domain.ts index e232bde..64b7f2e 100644 --- a/app/src/api/domain.ts +++ b/app/src/api/domain.ts @@ -6,5 +6,5 @@ export const domainApi = { verifyDns: (id: string) => api.post('/domain/' + id + '/verify-dns'), create: (data: any) => api.post('/domain', data), update: (id: string, data: any) => api.put('/domain/' + id, data), - delete: (id: string) => api.put('/domain/' + id), + delete: (id: string) => api.delete('/domain/' + id), } \ No newline at end of file From 97aab4ed098b77cdb8e2bda819867faab6a3e88b Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 23 Mar 2026 22:33:31 +0100 Subject: [PATCH 48/51] feat(api): update alias.go --- api/internal/transport/api/alias.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index bc1c087..69f9823 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -219,7 +219,7 @@ func (h *Handler) PostAlias(c *fiber.Ctx) error { } // Validate custom alias format - if req.Format == model.AliasFormatCustom && isCustomDomain { + if req.Format == model.AliasFormatCustom && !isCustomDomain { return c.Status(400).JSON(fiber.Map{ "error": ErrInvalidRequest, }) From c6e6a4a2d7edebc8b71753059a389da99a894aee Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 24 Mar 2026 19:51:30 +0100 Subject: [PATCH 49/51] feat(api): update config.go --- api/.env.sample | 99 ++++++++++++++++++++++---------------------- api/config/config.go | 8 +++- 2 files changed, 56 insertions(+), 51 deletions(-) diff --git a/api/.env.sample b/api/.env.sample index e8b4ca9..6780872 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -1,54 +1,55 @@ -FQDN="localhost" -API_NAME="Service Name" -API_PORT="3000" -API_ALLOW_ORIGIN="http://localhost:3001" -TOKEN_SECRET="secret" +FQDN=localhost +API_NAME=Service Name +API_PORT=3000 +API_ALLOW_ORIGIN=http://localhost:3001 +API_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12 +API_ALLOW_IPS=127.0.0.1 +TOKEN_SECRET=secret TOKEN_EXPIRATION=168h API_TOKEN_EXPIRATION=8760h -PSK="" -PSK_ALLOW_ORIGIN="http://localhost:3001" -DOMAINS="example1.net,example2.com" -LOG_FILE="/var/log/api.log" -BASIC_AUTH_USER="" -BASIC_AUTH_PASSWORD="" -NET_SUBNET="" -NET_GATEWAY="" -SIGNUP_WEBHOOK_URL="" -SIGNUP_WEBHOOK_PSK="" +PSK= +DOMAINS=example1.net,example2.com +LOG_FILE=/var/log/api.log +BASIC_AUTH_USER= +BASIC_AUTH_PASSWORD= +NET_SUBNET= +NET_GATEWAY= +SIGNUP_WEBHOOK_URL= +SIGNUP_WEBHOOK_PSK= -APP_PORT="3001" +APP_PORT=3001 -DB_HOSTS="db" -DB_PORT="3306" -DB_NAME="email" -DB_USER="email" -DB_PASSWORD="email" -DB_ROOT_USER="root" -DB_ROOT_PASSWORD="root" +DB_HOSTS=db +DB_PORT=3306 +DB_NAME=email +DB_USER=email +DB_PASSWORD=email +DB_ROOT_USER=root +DB_ROOT_PASSWORD=root -REDIS_ADDR="redis:6379" -REDIS_ADDRS="" -REDIS_MASTER_NAME="" -REDIS_USERNAME="" -REDIS_PASSWORD="" -REDIS_FAILOVER_USERNAME="" -REDIS_FAILOVER_PASSWORD="" -REDIS_TLS_ENABLED="false" -REDIS_CERT_FILE="" -REDIS_KEY_FILE="" -REDIS_CA_CERT_FILE="" -REDIS_TLS_INSECURE_SKIP_VERIFY="false" +REDIS_ADDR=redis:6379 +REDIS_ADDRS= +REDIS_MASTER_NAME= +REDIS_USERNAME= +REDIS_PASSWORD= +REDIS_FAILOVER_USERNAME= +REDIS_FAILOVER_PASSWORD= +REDIS_TLS_ENABLED=false +REDIS_CERT_FILE= +REDIS_KEY_FILE= +REDIS_CA_CERT_FILE= +REDIS_TLS_INSECURE_SKIP_VERIFY=false -SMTP_CLIENT_HOST="smtp.example.net" -SMTP_CLIENT_PORT="2525" -SMTP_CLIENT_USER="" -SMTP_CLIENT_PASSWORD="" -SMTP_CLIENT_SENDER="from@example.net" -SMTP_CLIENT_SENDER_NAME="From Name" -SMTP_CLIENT_REPORT="" +SMTP_CLIENT_HOST=smtp.example.net +SMTP_CLIENT_PORT=2525 +SMTP_CLIENT_USER= +SMTP_CLIENT_PASSWORD= +SMTP_CLIENT_SENDER=from@example.net +SMTP_CLIENT_SENDER_NAME=From Name +SMTP_CLIENT_REPORT= OTP_EXPIRATION=15m -SUBSCRIPTION_TYPE="" +SUBSCRIPTION_TYPE= MAX_CREDENTIALS=10 MAX_RECIPIENTS=10 MAX_DAILY_ALIASES=100 @@ -59,10 +60,10 @@ ACCOUNT_GRACE_PERIOD_DAYS=194 ID_LIMITER_MAX=5 ID_LIMITER_EXPIRATION=60m -BACKUP_FILENAME="backup" -BACKUP_CRON_EXPRESSION="0 0 29 2 1" +BACKUP_FILENAME=backup +BACKUP_CRON_EXPRESSION=0 0 29 2 1 BACKUP_RETENTION_DAYS=7 -GPG_PASSPHRASE="" -AWS_S3_BUCKET_NAME="" -AWS_ACCESS_KEY_ID="" -AWS_SECRET_ACCESS_KEY="" +GPG_PASSPHRASE= +AWS_S3_BUCKET_NAME= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= diff --git a/api/config/config.go b/api/config/config.go index 5f672ee..0f2261d 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -12,11 +12,12 @@ type APIConfig struct { Name string Port string ApiAllowOrigin string + ApiTrustedProxies []string + ApiAllowIPs []string TokenSecret string TokenExpiration time.Duration ApiTokenExpiration time.Duration PSK string - PSKAllowOrigin string Domains string LogFile string BasicAuthUser string @@ -147,6 +148,8 @@ func New() (Config, error) { dbHosts := strings.Split(os.Getenv("DB_HOSTS"), ",") redisAddrs := strings.Split(os.Getenv("REDIS_ADDRESSES"), ",") + apiTrustedProxies := strings.Split(os.Getenv("API_TRUSTED_PROXIES"), ",") + apiAllowIPs := strings.Split(os.Getenv("API_ALLOW_IPS"), ",") return Config{ API: APIConfig{ @@ -154,11 +157,12 @@ func New() (Config, error) { Name: os.Getenv("API_NAME"), Port: os.Getenv("API_PORT"), ApiAllowOrigin: os.Getenv("API_ALLOW_ORIGIN"), + ApiTrustedProxies: apiTrustedProxies, + ApiAllowIPs: apiAllowIPs, TokenSecret: os.Getenv("TOKEN_SECRET"), TokenExpiration: tokenExp, ApiTokenExpiration: apiTokenExp, PSK: os.Getenv("PSK"), - PSKAllowOrigin: os.Getenv("PSK_ALLOW_ORIGIN"), Domains: os.Getenv("DOMAINS"), LogFile: os.Getenv("LOG_FILE"), BasicAuthUser: os.Getenv("BASIC_AUTH_USER"), From 1a58489967ee34e2f200a07059fa98f250064ad6 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 24 Mar 2026 20:02:48 +0100 Subject: [PATCH 50/51] feat(middleware): update auth.go --- api/internal/middleware/auth/auth.go | 17 +++++++++++++++-- api/internal/transport/api/routes.go | 6 ++++-- api/internal/transport/api/server.go | 6 +++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/api/internal/middleware/auth/auth.go b/api/internal/middleware/auth/auth.go index 8a6b2c8..e1881ba 100644 --- a/api/internal/middleware/auth/auth.go +++ b/api/internal/middleware/auth/auth.go @@ -54,10 +54,23 @@ func New(cfg config.APIConfig, cache Cache, service Service) fiber.Handler { } } -func NewPSK(cfg config.APIConfig) fiber.Handler { +func NewIPFilter(allowedIPs []string) fiber.Handler { return func(c *fiber.Ctx) error { - if GetAuthToken(c) != cfg.PSK { + clientIP := c.IP() + for _, allowedIP := range allowedIPs { + if clientIP == allowedIP { + return c.Next() + } + } + return c.SendStatus(fiber.StatusForbidden) + } +} + +func NewPSK(psk string) fiber.Handler { + + return func(c *fiber.Ctx) error { + if GetAuthToken(c) != psk { return c.SendStatus(fiber.StatusUnauthorized) } diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index 5156d50..759d828 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -15,7 +15,8 @@ import ( func (h *Handler) SetupRoutes(cfg config.APIConfig) { email := h.Server.Group("/v1/email") - email.Use(auth.NewPSK(cfg)) + email.Use(auth.NewIPFilter(cfg.ApiAllowIPs)) + email.Use(auth.NewPSK(cfg.PSK)) email.Post("", h.HandleEmail) h.Server.Use(auth.NewAPICORS(cfg)) @@ -34,7 +35,8 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { h.Server.Post("/v1/login/finish", limiter.New(), h.FinishLogin) sub := h.Server.Group("/v1/subscription") - sub.Use(auth.NewPSK(cfg)) + sub.Use(auth.NewIPFilter(cfg.ApiAllowIPs)) + sub.Use(auth.NewPSK(cfg.PSK)) sub.Post("/add", h.AddSubscription) api := h.Server.Group("/v1/api") diff --git a/api/internal/transport/api/server.go b/api/internal/transport/api/server.go index 9c959c7..143bba3 100644 --- a/api/internal/transport/api/server.go +++ b/api/internal/transport/api/server.go @@ -40,7 +40,11 @@ type Cache interface { func Start(cfg config.APIConfig, service Service, cache Cache) error { log.Printf("API server starting on :%s", cfg.Port) - app := fiber.New() + app := fiber.New(fiber.Config{ + EnableTrustedProxyCheck: true, + TrustedProxies: cfg.ApiTrustedProxies, + ProxyHeader: fiber.HeaderXForwardedFor, + }) h := &Handler{ Cfg: cfg, From a2a97027c53f36da11323ecbea6cb9484740b82f Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 25 Mar 2026 09:44:27 +0100 Subject: [PATCH 51/51] refactor(middleware): update auth.go --- api/internal/middleware/auth/auth.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/internal/middleware/auth/auth.go b/api/internal/middleware/auth/auth.go index e1881ba..1bebb60 100644 --- a/api/internal/middleware/auth/auth.go +++ b/api/internal/middleware/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "slices" "strings" "time" @@ -58,11 +59,10 @@ func NewIPFilter(allowedIPs []string) fiber.Handler { return func(c *fiber.Ctx) error { clientIP := c.IP() - for _, allowedIP := range allowedIPs { - if clientIP == allowedIP { - return c.Next() - } + if slices.Contains(allowedIPs, clientIP) { + return c.Next() } + return c.SendStatus(fiber.StatusForbidden) } } @@ -70,11 +70,11 @@ func NewIPFilter(allowedIPs []string) fiber.Handler { func NewPSK(psk string) fiber.Handler { return func(c *fiber.Ctx) error { - if GetAuthToken(c) != psk { - return c.SendStatus(fiber.StatusUnauthorized) + if GetAuthToken(c) == psk { + return c.Next() } - return c.Next() + return c.SendStatus(fiber.StatusUnauthorized) } }