Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions api/internal/model/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions api/internal/model/msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import (
"mime"
"mime/multipart"
"net/mail"
"regexp"
"strings"

"ivpn.net/email/api/internal/utils"
)

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 {
Expand Down Expand Up @@ -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
}

Expand Down
56 changes: 56 additions & 0 deletions api/internal/model/msg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions api/internal/repository/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
151 changes: 112 additions & 39 deletions api/internal/service/recipient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -361,5 +434,5 @@ func (s *Service) FindRecipients(from string, to string, msgType model.MessageTy
}
}

return recipients, alias, model.Forward, nil
return recipients, nil
}
1 change: 1 addition & 0 deletions api/internal/transport/api/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions api/internal/transport/api/req.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading
Loading