diff --git a/api/.env.sample b/api/.env.sample index e8b4ca96..ea77ae84 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="" SMTP_CLIENT_REPORT="" OTP_EXPIRATION=15m diff --git a/api/config/config.go b/api/config/config.go index 5f672eeb..72b2cf59 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -49,14 +49,15 @@ type RedisConfig struct { } type SMTPClientConfig struct { - Host string - Port string - User string - Password string - Sender string - SenderName string - Report string - TokenSecret string + Host string + Port string + User string + Password string + Sender string + SenderName string + DkimSelector string + Report string + TokenSecret string } type ServiceConfig struct { @@ -188,14 +189,15 @@ 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"), - Report: os.Getenv("SMTP_CLIENT_REPORT"), - TokenSecret: os.Getenv("TOKEN_SECRET"), + 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"), + Report: os.Getenv("SMTP_CLIENT_REPORT"), + TokenSecret: os.Getenv("TOKEN_SECRET"), }, Service: ServiceConfig{ diff --git a/api/docs/docs.go b/api/docs/docs.go index 59472a91..5cc54b4c 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 0d3688a5..8af6895c 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 b48fbd4d..3fe909c0 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: diff --git a/api/internal/cron/jobs/user.go b/api/internal/cron/jobs/user.go index de54ee87..66f5b64a 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/model/domain.go b/api/internal/model/domain.go new file mode 100644 index 00000000..5f78238f --- /dev/null +++ b/api/internal/model/domain.go @@ -0,0 +1,23 @@ +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 + MXVerifiedAt *time.Time `json:"mx_verified_at"` // nullable + SendVerifiedAt *time.Time `json:"send_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/repository/alias.go b/api/internal/repository/alias.go index 640d2619..bb92a10e 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/repository/domain.go b/api/internal/repository/domain.go new file mode 100644 index 00000000..2f45715b --- /dev/null +++ b/api/internal/repository/domain.go @@ -0,0 +1,51 @@ +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) 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 + 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 +} + +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, + "mx_verified_at": domain.MXVerifiedAt, + "send_verified_at": domain.SendVerifiedAt, + }).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 +} diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index 5fe5a530..09d7f84d 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 new file mode 100644 index 00000000..987162a5 --- /dev/null +++ b/api/internal/service/domain.go @@ -0,0 +1,284 @@ +package service + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "log" + "strings" + "time" + + "ivpn.net/email/api/internal/model" + "ivpn.net/email/api/internal/utils" +) + +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.") +) + +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 + 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) 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 { + log.Printf("error getting domains count: %s", err.Error()) + return 0, ErrGetDomainsCount + } + + 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) { + 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()) + return model.Domain{}, ErrPostDomain + } + + return createdDomain, nil +} + +func (s *Service) DeleteDomain(ctx context.Context, domainID string, userID string) error { + // 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 + } + + 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 +} + +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 + } + + // 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()) + return ErrDNSLookupOwner + } + + if !ok { + log.Printf("TXT record not found for domain ownership verification") + return ErrDNSLookupOwner + } + + return 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 + } + + 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 +} + +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 +} diff --git a/api/internal/service/service.go b/api/internal/service/service.go index a7a273d0..a09ec904 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 6e818b65..0e1f3c58 100644 --- a/api/internal/service/user.go +++ b/api/internal/service/user.go @@ -349,6 +349,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()) diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go new file mode 100644 index 00000000..0bcc9da3 --- /dev/null +++ b/api/internal/transport/api/domain.go @@ -0,0 +1,231 @@ +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." + 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." + DeleteDomainSuccess = "Custom domain deleted successfully." + DNSRecordVerificationSuccess = "Custom domain DNS records verified 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 + DeleteDomain(context.Context, string, string) error + VerifyDomainDNSRecords(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) +} + +// @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, + }) +} + +// @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, + }) +} + +// @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 +// @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, + }) +} diff --git a/api/internal/transport/api/req.go b/api/internal/transport/api/req.go index 108fe358..f9ea9383 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"` +} diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index 5156d508..1b2b5454 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) diff --git a/api/internal/transport/api/server.go b/api/internal/transport/api/server.go index 9c959c7c..0ae8fc5c 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 { diff --git a/api/internal/utils/dns.go b/api/internal/utils/dns.go new file mode 100644 index 00000000..6468d997 --- /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 00000000..e4eeafe1 --- /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") + } +}