diff --git a/.github/workflows/ci_production.yml b/.github/workflows/ci_production.yml index 85a65c2a..120e89de 100644 --- a/.github/workflows/ci_production.yml +++ b/.github/workflows/ci_production.yml @@ -11,6 +11,7 @@ permissions: env: API_IMAGE: email/api APP_IMAGE: email/app + DAEMON_IMAGE: mailserver/daemon TAG: latest REGISTRY: ${{ secrets.REGISTRY_NAME }} @@ -40,6 +41,9 @@ jobs: - name: Build app image run: docker build -t $REGISTRY/$APP_IMAGE:$TAG app/. + + - name: Build daemon image + run: docker build -t $REGISTRY/$DAEMON_IMAGE:$TAG mailserver/daemon/. - name: Log in to registry run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_NAME }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin @@ -49,3 +53,6 @@ jobs: - name: Push app:${{ env.TAG }} run: docker push $REGISTRY/$APP_IMAGE:$TAG + + - name: Push daemon:${{ env.TAG }} + run: docker push $REGISTRY/$DAEMON_IMAGE:$TAG \ No newline at end of file diff --git a/.github/workflows/ci_staging.yml b/.github/workflows/ci_staging.yml index 6e526582..8a9afe55 100644 --- a/.github/workflows/ci_staging.yml +++ b/.github/workflows/ci_staging.yml @@ -11,6 +11,7 @@ permissions: env: API_IMAGE: email/api APP_IMAGE: email/app + DAEMON_IMAGE: mailserver/daemon TAG: staging REGISTRY: ${{ secrets.REGISTRY_NAME }} @@ -38,6 +39,9 @@ jobs: - name: Build app image run: docker build -t $REGISTRY/$APP_IMAGE:$TAG app/. + + - name: Build daemon image + run: docker build -t $REGISTRY/$DAEMON_IMAGE:$TAG mailserver/daemon/. - name: Log in to registry run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_NAME }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin @@ -47,3 +51,6 @@ jobs: - name: Push app:${{ env.TAG }} run: docker push $REGISTRY/$APP_IMAGE:$TAG + + - name: Push daemon:${{ env.TAG }} + run: docker push $REGISTRY/$DAEMON_IMAGE:$TAG \ No newline at end of file diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go index 99f66593..01b2602d 100644 --- a/api/internal/repository/domain.go +++ b/api/internal/repository/domain.go @@ -30,6 +30,12 @@ func (d *Database) GetVerifiedDomain(ctx context.Context, domainID string, userI return domain, err } +func (d *Database) GetVerifiedDomainByName(ctx context.Context, domainName string) (model.Domain, error) { + var domain model.Domain + err := d.Client.Where("name = ? AND owner_verified_at IS NOT NULL AND mx_verified_at IS NOT NULL AND send_verified_at IS NOT NULL", domainName).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 2241d6c7..f8da9be9 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -34,6 +34,7 @@ type DomainStore interface { GetVerifiedDomains(context.Context, string) ([]model.Domain, error) GetDomain(context.Context, string, string) (model.Domain, error) GetVerifiedDomain(context.Context, string, string) (model.Domain, error) + GetVerifiedDomainByName(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 @@ -81,6 +82,15 @@ func (s *Service) GetVerifiedDomain(ctx context.Context, domainID string, userID return domain, nil } +func (s *Service) GetVerifiedDomainByName(ctx context.Context, domainName string) (model.Domain, error) { + domain, err := s.Store.GetVerifiedDomainByName(ctx, domainName) + if err != nil { + 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 { diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index 9cd61736..27387d05 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -26,6 +26,7 @@ type DomainService interface { GetVerifiedDomains(context.Context, string) ([]model.Domain, error) GetDomain(context.Context, string, string) (model.Domain, error) GetVerifiedDomain(context.Context, string, string) (model.Domain, error) + GetVerifiedDomainByName(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 @@ -231,3 +232,43 @@ func (h *Handler) VerifyDomainDNSRecords(c *fiber.Ctx) error { "message": DNSRecordVerificationSuccess, }) } + +// @Summary Check custom domain +// @Description Check if a custom domain exists for the authenticated user +// @Tags domain +// @Accept json +// @Produce json +// @Param domain body DomainReq true "Custom Domain Request" +// @Success 200 {string} string "OK" +// @Failure 400 {object} ErrorRes +// @Router /email/domain/check [post] +func (h *Handler) CheckDomain(c *fiber.Ctx) error { + // Parse the request + 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, + }) + } + + // Check if domain exists + domain, err := h.Service.GetVerifiedDomainByName(c.Context(), req.Name) + if err != nil { + return c.Status(400).SendString("Not Found") + } + + if domain.Name != req.Name { + return c.Status(400).SendString("Not Found") + } + + return c.SendString("OK") +} diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index f2392611..66051e70 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -17,6 +17,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { email := h.Server.Group("/v1/email") email.Use(auth.NewPSK(cfg)) email.Post("", h.HandleEmail) + email.Post("/domain/check", h.CheckDomain) h.Server.Use(auth.NewAPICORS(cfg)) h.Server.Use(helmet.New()) diff --git a/mailserver/.env.sample b/mailserver/.env.sample index 7381f378..3868a50e 100644 --- a/mailserver/.env.sample +++ b/mailserver/.env.sample @@ -1,8 +1,14 @@ +IMAGE_REGISTRY= +IMAGE_TAG=latest +DAEMON_IMAGE=/mailserver/daemon + DMS_HOSTNAME=mail.example.com DMS_IP= +DAEMON_IP= NET_SUBNET= NET_GATEWAY= DOMAIN=example.com +DOMAINS=example.com,example.net SMTP_USER= SMTP_PASS= SSL_TYPE= @@ -10,4 +16,5 @@ ENABLE_RSPAMD=1 ENABLE_FAIL2BAN=1 ENABLE_CLAMAV=0 LOG_LEVEL=info +API_URL=http://email-api:3000 PSK="" diff --git a/mailserver/.gitignore b/mailserver/.gitignore index 79a4dc3c..9975cb1f 100644 --- a/mailserver/.gitignore +++ b/mailserver/.gitignore @@ -3,6 +3,7 @@ ################################################# .env +config.json compose.override.yaml docs/site/ docker-data/ diff --git a/mailserver/compose.deploy.yml b/mailserver/compose.deploy.yml index 96c2c6c3..7072e89c 100644 --- a/mailserver/compose.deploy.yml +++ b/mailserver/compose.deploy.yml @@ -54,6 +54,20 @@ services: net: ipv4_address: ${DMS_IP} + daemon: + image: ${IMAGE_REGISTRY}${DAEMON_IMAGE}:${IMAGE_TAG} + container_name: daemon + environment: + - API_URL=${API_URL} + - PSK=${PSK} + - LOCAL_DOMAINS=${DOMAINS} + - CACHE_POSITIVE_TTL=10m + - CACHE_NEGATIVE_TTL=2m + restart: always + networks: + net: + ipv4_address: ${DAEMON_IP} + networks: net: driver: bridge diff --git a/mailserver/compose.yml b/mailserver/compose.yml index 0f1dc785..1d9a68a6 100644 --- a/mailserver/compose.yml +++ b/mailserver/compose.yml @@ -52,6 +52,19 @@ services: retries: 0 networks: email_net: + + daemon: + build: ./daemon + container_name: daemon + environment: + - API_URL=${API_URL} + - PSK=${PSK} + - LOCAL_DOMAINS=${DOMAINS} + - CACHE_POSITIVE_TTL=10m + - CACHE_NEGATIVE_TTL=2m + restart: always + networks: + email_net: networks: email_net: diff --git a/mailserver/config/postfix-main.cf.sample b/mailserver/config/postfix-main.cf.sample index ea0ec7cc..ea65ad89 100644 --- a/mailserver/config/postfix-main.cf.sample +++ b/mailserver/config/postfix-main.cf.sample @@ -10,6 +10,23 @@ milter_protocol = 6 smtpd_milters = $rspamd_milter non_smtpd_milters = $rspamd_milter +mydestination = $myhostname, localhost +local_recipient_maps = +smtpd_policy_service_timeout = 10s +relay_domains = regexp:/tmp/docker-mailserver/postfix-relay-domains.cf + +smtpd_relay_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + permit_auth_destination, + reject_unauth_destination + +smtpd_recipient_restrictions = + permit_mynetworks, + permit_sasl_authenticated, + check_policy_service inet:daemon:10025, + reject_unauth_destination + smtpd_sender_restrictions = permit_mynetworks, permit_sasl_authenticated, @@ -21,14 +38,9 @@ smtpd_sender_restrictions = reject_unknown_sender_domain, reject_unauth_pipelining -smtpd_relay_restrictions = - permit_mynetworks, - permit_sasl_authenticated, - reject_unauth_destination - smtpd_helo_restrictions = - permit_mynetworks - permit_sasl_authenticated - reject_invalid_helo_hostname - reject_non_fqdn_helo_hostname + permit_mynetworks, + permit_sasl_authenticated, + reject_invalid_helo_hostname, + reject_non_fqdn_helo_hostname, reject_unknown_helo_hostname diff --git a/mailserver/config/postfix-relay-domains.cf.sample b/mailserver/config/postfix-relay-domains.cf.sample new file mode 100644 index 00000000..d7a6d9f2 --- /dev/null +++ b/mailserver/config/postfix-relay-domains.cf.sample @@ -0,0 +1 @@ +/.+/ OK \ No newline at end of file diff --git a/mailserver/config/postfix-virtual.cf.sample b/mailserver/config/postfix-virtual.cf.sample deleted file mode 100644 index 65a3687b..00000000 --- a/mailserver/config/postfix-virtual.cf.sample +++ /dev/null @@ -1 +0,0 @@ -@example.net curl_email diff --git a/mailserver/config/user-patches.sh.sample b/mailserver/config/user-patches.sh.sample index fe99b60b..9ad79c32 100644 --- a/mailserver/config/user-patches.sh.sample +++ b/mailserver/config/user-patches.sh.sample @@ -25,5 +25,8 @@ exit 0" >> /usr/local/bin/curl-email.sh chmod +x /usr/local/bin/curl-email.sh +# Add curl_email service to master.cf +postconf -M "curl_email/unix=curl_email unix - n n - - pipe flags=RqX user=nobody argv=/usr/local/bin/curl-email.sh \${sender} \${recipient}" + # reload Postfix to apply changes postfix reload diff --git a/mailserver/daemon/Dockerfile b/mailserver/daemon/Dockerfile new file mode 100644 index 00000000..e41d3481 --- /dev/null +++ b/mailserver/daemon/Dockerfile @@ -0,0 +1,21 @@ +# stage 1: building application binary file +FROM golang:1.24 AS builder + +RUN mkdir /daemon +ADD . /daemon +WORKDIR /daemon + +ENV CGO_ENABLED=0 +ENV GOOS=linux +ENV GOARCH=amd64 + +RUN go build -o daemon main.go + +# stage 2: copy only the application binary file and necessary files to the alpine container +FROM alpine:latest AS production + +COPY --from=builder /daemon . + +# run the service on container startup +EXPOSE 10025 +CMD ["./daemon"] diff --git a/mailserver/daemon/go.mod b/mailserver/daemon/go.mod new file mode 100644 index 00000000..292fc254 --- /dev/null +++ b/mailserver/daemon/go.mod @@ -0,0 +1,3 @@ +module daemon + +go 1.24 \ No newline at end of file diff --git a/mailserver/daemon/main.go b/mailserver/daemon/main.go new file mode 100644 index 00000000..451db122 --- /dev/null +++ b/mailserver/daemon/main.go @@ -0,0 +1,223 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strings" + "sync" + "time" +) + +type cacheEntry struct { + valid bool + exp time.Time +} + +var cache = map[string]cacheEntry{} +var mu sync.RWMutex + +var positiveTTL = 10 * time.Minute +var negativeTTL = 2 * time.Minute + +var localDomains = map[string]bool{} + +var apiURL string +var psk string + +type domainCheckRequest struct { + Name string `json:"name"` +} + +func main() { + loadConfig() + loadLocalDomains() + + ln, err := net.Listen("tcp", ":10025") + + if err != nil { + panic(err) + } + + fmt.Println("Domain Policy Service (daemon) listening on 10025") + + for { + conn, err := ln.Accept() + if err != nil { + continue + } + + go handle(conn) + } +} + +func loadConfig() { + apiURL = os.Getenv("API_URL") + psk = os.Getenv("PSK") + + if ttl := os.Getenv("CACHE_POSITIVE_TTL"); ttl != "" { + if d, err := time.ParseDuration(ttl); err == nil { + positiveTTL = d + } + } + + if ttl := os.Getenv("CACHE_NEGATIVE_TTL"); ttl != "" { + if d, err := time.ParseDuration(ttl); err == nil { + negativeTTL = d + } + } +} + +func loadLocalDomains() { + domains := os.Getenv("LOCAL_DOMAINS") + + if domains == "" { + return + } + + for _, d := range strings.Split(domains, ",") { + domain := strings.TrimSpace(d) + if domain != "" { + localDomains[domain] = true + } + } +} + +func handle(conn net.Conn) { + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + scanner := bufio.NewScanner(conn) + var recipient string + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + break + } + + if strings.HasPrefix(line, "recipient=") { + recipient = strings.TrimPrefix(line, "recipient=") + } + } + + if recipient == "" { + fmt.Fprintf(conn, "action=DUNNO\n\n") + return + } + + parts := strings.Split(recipient, "@") + + if len(parts) != 2 { + fmt.Fprintf(conn, "action=REJECT invalid recipient\n\n") + return + } + + domain := strings.ToLower(parts[1]) + + // Skip check for local domains + if localDomains[domain] { + fmt.Fprintf(conn, "action=FILTER curl_email:\n\n") + return + } + + // Check cache + if valid, found := checkCache(domain); found { + if valid { + fmt.Fprintf(conn, "action=FILTER curl_email:\n\n") + } else { + fmt.Fprintf(conn, "action=REJECT domain not configured\n\n") + } + return + } + + // API check + valid := checkDomain(domain) + storeCache(domain, valid) + + if valid { + fmt.Fprintf(conn, "action=FILTER curl_email:\n\n") + } else { + fmt.Fprintf(conn, "action=REJECT domain not configured\n\n") + } +} + +func checkCache(domain string) (bool, bool) { + mu.RLock() + entry, ok := cache[domain] + mu.RUnlock() + + if !ok { + return false, false + } + + if time.Now().After(entry.exp) { + mu.Lock() + delete(cache, domain) + mu.Unlock() + return false, false + } + + return entry.valid, true +} + +func storeCache(domain string, valid bool) { + var ttl time.Duration + + if valid { + ttl = positiveTTL + } else { + ttl = negativeTTL + } + + mu.Lock() + + cache[domain] = cacheEntry{ + valid: valid, + exp: time.Now().Add(ttl), + } + + mu.Unlock() +} + +func checkDomain(domain string) bool { + client := http.Client{ + Timeout: 5 * time.Second, + } + + payload := domainCheckRequest{ + Name: domain, + } + + jsonData, err := json.Marshal(payload) + + if err != nil { + return false + } + + req, err := http.NewRequest( + "POST", + apiURL+"/v1/email/domain/check", + bytes.NewBuffer(jsonData), + ) + + if err != nil { + return false + } + + req.Header.Set("Authorization", "Bearer "+psk) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + + if err != nil { + return false + } + + defer resp.Body.Close() + + return resp.StatusCode == 200 +}