diff --git a/README.md b/README.md index 0ca983c1..dde7295f 100644 --- a/README.md +++ b/README.md @@ -237,22 +237,42 @@ docker compose -f compose.deploy.yml up -d domain.com. . 14400 IN MX 10 mail.domain.com. mail.domain.com. . 14400 IN MX 10 MAIL_SERVER_IPV4 ``` +Check: +```bash +dig MX domain.com +short +dig MX mail.domain.com +short +``` #### SPF Records: ``` domain.com. 3600 IN TXT "v=spf1 ip4:MAIL_SERVER_IPV4 -all" mail.domain.com. 3600 IN TXT "v=spf1 ip4:MAIL_SERVER_IPV4 -all" ``` +Check: +```bash +dig TXT domain.com +short +dig TXT mail.domain.com +short +``` #### DMARC (TXT record): ``` -_dmarc.mail.domain.com. 3600 IN TXT v=DMARC1; p=quarantine +_dmarc.domain.com. 3600 IN TXT "v=DMARC1; p=quarantine" +_dmarc.mail.domain.com. 3600 IN TXT "v=DMARC1; p=quarantine" +``` +Check: +```bash +dig TXT _dmarc.domain.com +short +dig TXT _dmarc.mail.domain.com +short ``` #### DKIM (TXT record): ``` mail._domainkey.domain.com. 3600 IN TXT v=DKIM1;k=rsa;p=DKIM_PUBLIC_KEY ``` +Check: +```bash +dig TXT mail._domainkey.domain.com +short +``` ### API diff --git a/api/internal/model/domain.go b/api/internal/model/domain.go index 841252a6..1c467661 100644 --- a/api/internal/model/domain.go +++ b/api/internal/model/domain.go @@ -20,6 +20,7 @@ type Domain struct { OwnerVerifiedAt *time.Time `json:"owner_verified_at"` // nullable MXVerifiedAt *time.Time `json:"mx_verified_at"` // nullable SendVerifiedAt *time.Time `json:"send_verified_at"` // nullable + CatchAll bool `gorm:"default:false" json:"catch_all"` } type DNSConfig struct { diff --git a/api/internal/model/msg.go b/api/internal/model/msg.go index 877cf6df..2ec03d12 100644 --- a/api/internal/model/msg.go +++ b/api/internal/model/msg.go @@ -8,6 +8,7 @@ import ( "mime" "mime/multipart" "net/mail" + "regexp" "strings" "ivpn.net/email/api/internal/utils" @@ -15,6 +16,7 @@ import ( var ( ErrExtractOriginalFrom = fmt.Errorf("error extracting original From from bounce") + replySubjectRE = regexp.MustCompile(`(?i)^\s*(re|aw|antw|sv|rif|回复|回覆)\s*:\s*`) ) type Msg struct { @@ -93,6 +95,11 @@ func isReply(m *mail.Message) bool { return true } + subject := m.Header.Get("Subject") + if replySubjectRE.MatchString(subject) { + return true + } + return false } diff --git a/api/internal/model/msg_test.go b/api/internal/model/msg_test.go index e4c95736..38cc4982 100644 --- a/api/internal/model/msg_test.go +++ b/api/internal/model/msg_test.go @@ -27,6 +27,62 @@ func TestIsReply(t *testing.T) { data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test Subject\r\n\r\nThis is the body of the email.", want: false, }, + // Subject-prefix based detection (no In-Reply-To / References headers) + { + name: "reply subject prefix re:", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Re: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix RE: uppercase", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: RE: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix re: with leading whitespace", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: re: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix aw: (German)", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Aw: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix antw: (German)", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Antw: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix sv: (Scandinavian)", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Sv: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix rif: (Italian)", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Rif: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix 回复: (Chinese simplified)", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: 回复: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "reply subject prefix 回覆: (Chinese traditional)", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: 回覆: Test Subject\r\n\r\nBody.", + want: true, + }, + { + name: "non-reply subject that starts with a similar word", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Rethink the approach\r\n\r\nBody.", + want: false, + }, + { + name: "empty subject", + data: "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: \r\n\r\nBody.", + want: false, + }, } for _, tt := range tests { diff --git a/api/internal/repository/domain.go b/api/internal/repository/domain.go index 416ab946..5a59e1e0 100644 --- a/api/internal/repository/domain.go +++ b/api/internal/repository/domain.go @@ -63,6 +63,7 @@ func (d *Database) UpdateDomain(ctx context.Context, domain model.Domain) error "owner_verified_at": domain.OwnerVerifiedAt, "mx_verified_at": domain.MXVerifiedAt, "send_verified_at": domain.SendVerifiedAt, + "catch_all": domain.CatchAll, }).Error } diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go index 61559410..9654d3e1 100644 --- a/api/internal/service/recipient.go +++ b/api/internal/service/recipient.go @@ -292,66 +292,139 @@ func (s *Service) DeleteRecipientByUserID(ctx context.Context, userID string) er } func (s *Service) FindRecipients(from string, to string, msgType model.MessageType) ([]model.Recipient, model.Alias, model.MessageType, error) { - // Extract alias name from the "to" email - name, replyTo := model.ParseReplyTo(to) - alias, err := s.GetAliasByName(name) + aliasName, replyTo := model.ParseReplyTo(to) + + alias, err := s.GetAliasByName(aliasName) if err != nil { - return []model.Recipient{}, model.Alias{Name: name}, 0, err + domainPart := aliasDomainPart(aliasName) + if isCustomAliasDomain(domainPart, s.Cfg.API.Domains) { + if ok, rcps, catchAllAlias, catchAllErr := s.resolveCatchAll(domainPart, aliasName); ok { + if catchAllErr != nil { + return []model.Recipient{}, catchAllAlias, msgType, catchAllErr + } + + if utils.ValidateEmail(replyTo) == nil { + rcps, err := s.resolveReply(from, catchAllAlias, replyTo) + if err != nil { + return []model.Recipient{}, catchAllAlias, msgType, err + } + return rcps, catchAllAlias, msgType, nil + } + + return rcps, catchAllAlias, model.Forward, nil + } + } + return []model.Recipient{}, model.Alias{Name: aliasName}, 0, err + } + + if err = s.checkAliasEnabled(alias); err != nil { + return []model.Recipient{}, alias, 0, err + } + + if err = s.checkCustomDomain(alias); err != nil { + return []model.Recipient{}, alias, 0, err } - // Handle disabled alias - if !alias.Enabled { - err = s.SaveMessage(context.Background(), alias, model.Block) + if utils.ValidateEmail(replyTo) == nil { + rcps, err := s.resolveReply(from, alias, replyTo) if err != nil { - log.Println("error saving message", err) + return []model.Recipient{}, alias, 0, err } + return rcps, alias, msgType, nil + } - return []model.Recipient{}, alias, 0, ErrDisabledAlias + rcps, err := s.resolveForward(alias) + if err != nil { + return []model.Recipient{}, alias, 0, err } - // Handle disabled domain - domains := s.Cfg.API.Domains - isCustomDomain := true - for domain := range strings.SplitSeq(domains, ",") { - if strings.HasSuffix(alias.Name, "@"+domain) { - isCustomDomain = false - break - } + return rcps, alias, model.Forward, nil +} + +func (s *Service) checkAliasEnabled(alias model.Alias) error { + if alias.Enabled { + return nil } - if isCustomDomain { - domainName := strings.SplitN(alias.Name, "@", 2)[1] - domain, err := s.GetVerifiedDomainByName(context.Background(), domainName) - if err != nil { - log.Printf("error fetching domain: %s", err.Error()) - return []model.Recipient{}, alias, 0, ErrDisabledDomain + if err := s.SaveMessage(context.Background(), alias, model.Block); err != nil { + log.Println("error saving message", err) + } + + return ErrDisabledAlias +} + +func (s *Service) checkCustomDomain(alias model.Alias) error { + domainPart := aliasDomainPart(alias.Name) + if !isCustomAliasDomain(domainPart, s.Cfg.API.Domains) { + return nil + } + + domain, err := s.GetVerifiedDomainByName(context.Background(), domainPart) + if err != nil { + log.Printf("error fetching domain: %s", err.Error()) + return ErrDisabledDomain + } + + if !domain.Enabled { + if err = s.SaveMessage(context.Background(), alias, model.Block); err != nil { + log.Println("error saving message", err) } + return ErrDisabledDomain + } - if !domain.Enabled { - err = s.SaveMessage(context.Background(), alias, model.Block) - if err != nil { - log.Println("error saving message", err) - } + return nil +} + +func (s *Service) resolveReply(from string, alias model.Alias, replyTo string) ([]model.Recipient, error) { + rcps, err := s.GetVerifiedRecipients(context.Background(), from, alias.UserID) + if err != nil || len(rcps) == 0 { + return []model.Recipient{}, ErrNoVerifiedRecipients + } + + return []model.Recipient{{Email: replyTo}}, nil +} - return []model.Recipient{}, alias, 0, ErrDisabledDomain +func (s *Service) resolveCatchAll(domainPart string, aliasName string) (bool, []model.Recipient, model.Alias, error) { + domain, err := s.GetVerifiedDomainByName(context.Background(), domainPart) + if err != nil || !domain.CatchAll { + return false, nil, model.Alias{}, nil + } + + catchAllAlias := model.Alias{Name: aliasName, UserID: domain.UserID, FromName: domain.FromName} + + if !domain.Enabled { + if err = s.SaveMessage(context.Background(), catchAllAlias, model.Block); err != nil { + log.Println("error saving message", err) } + return true, nil, catchAllAlias, ErrDisabledDomain } - // Handle Reply | Send - err = utils.ValidateEmail(replyTo) - if err == nil { - rcps, err := s.GetVerifiedRecipients(context.Background(), from, alias.UserID) - if err != nil || len(rcps) == 0 { - return []model.Recipient{}, alias, 0, ErrNoVerifiedRecipients + recipientEmail := domain.Recipient + if recipientEmail == "" { + settings, err := s.GetSettings(context.Background(), domain.UserID) + if err != nil { + log.Printf("error fetching settings for catch-all: %s", err.Error()) + return true, nil, catchAllAlias, ErrNoRecipients } + recipientEmail = settings.Recipient + } - return []model.Recipient{{Email: replyTo}}, alias, model.MessageType(msgType), nil + if recipientEmail == "" { + return true, nil, catchAllAlias, ErrNoRecipients } - // Handle Forward + rcps, err := s.GetVerifiedRecipients(context.Background(), recipientEmail, domain.UserID) + if err != nil || len(rcps) == 0 { + return true, nil, catchAllAlias, ErrNoRecipients + } + + return true, rcps, catchAllAlias, nil +} + +func (s *Service) resolveForward(alias model.Alias) ([]model.Recipient, error) { rcps, err := s.GetRecipients(context.Background(), alias.UserID) if err != nil || len(rcps) == 0 { - return []model.Recipient{}, alias, 0, ErrNoRecipients + return []model.Recipient{}, ErrNoRecipients } var recipients []model.Recipient @@ -361,5 +434,5 @@ func (s *Service) FindRecipients(from string, to string, msgType model.MessageTy } } - return recipients, alias, model.Forward, nil + return recipients, nil } diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index 06cb6c02..ca9e4935 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -168,6 +168,7 @@ func (h *Handler) UpdateDomain(c *fiber.Ctx) error { domain.Recipient = req.Recipient domain.FromName = req.FromName domain.Enabled = req.Enabled + domain.CatchAll = req.CatchAll // Update domain err = h.Service.UpdateDomain(c.Context(), domain) diff --git a/api/internal/transport/api/req.go b/api/internal/transport/api/req.go index a3f4602b..b41c254a 100644 --- a/api/internal/transport/api/req.go +++ b/api/internal/transport/api/req.go @@ -108,4 +108,5 @@ type UpdateDomainReq struct { Recipient string `json:"recipient"` FromName string `json:"from_name"` Enabled bool `json:"enabled"` + CatchAll bool `json:"catch_all"` } diff --git a/app/src/components/DomainEdit.vue b/app/src/components/DomainEdit.vue index 49b0a1d8..f7d8469d 100644 --- a/app/src/components/DomainEdit.vue +++ b/app/src/components/DomainEdit.vue @@ -11,25 +11,42 @@
-

Default Recipient

+

Catch-All Recipient

- Set the default recipient for this domain. + Set the default recipient for this domain. This overrides the default recipient selected in the Settings.

-
+
+

Catch-All From Name

+

+ Set the "From" name used in replies and emails sent from this domain. This overrides the default set in Settings. Replies from specific aliases use "From" name set for the alias. +

+
+ + +
+