From 2d5fb5e2078e92e7ec19582c3954409dd93f89fd Mon Sep 17 00:00:00 2001 From: Bobby Date: Sun, 8 Mar 2026 17:00:49 +0530 Subject: feat(dns): Implement DNS record management and query handling - Added models for various DNS record types: A, AAAA, CNAME, MX, SRV, and TXT. - Created repository functions for CRUD operations on DNS records. - Developed DNS server functionality to handle incoming queries and forward them to upstream servers. - Implemented local resolution for DNS queries, including support for A, AAAA, CNAME, MX, TXT, and SRV records. - Enhanced SMTP server to support TLS and STARTTLS configurations. - Improved email session handling with local delivery and error logging. - Added new log messages for better traceability of DNS operations and SMTP actions. --- database/migration.go | 7 ++ dove/main.go | 3 + go.mod | 10 +- go.sum | 14 +++ models/dns/a.go | 14 +++ models/dns/aaaa.go | 13 +++ models/dns/cname.go | 13 +++ models/dns/mx.go | 14 +++ models/dns/srv.go | 17 ++++ models/dns/txt.go | 13 +++ repositories/dns/a.go | 30 ++++++ repositories/dns/aaaa.go | 30 ++++++ repositories/dns/cname.go | 30 ++++++ repositories/dns/lookup.go | 26 ++++++ repositories/dns/mx.go | 39 ++++++++ repositories/dns/resolve.go | 124 +++++++++++++++++++++++++ repositories/dns/srv.go | 30 ++++++ repositories/dns/txt.go | 30 ++++++ repositories/mail/mailbox.go | 10 ++ services/domain/defaults.go | 6 ++ services/domain/domain.go | 23 +++++ services/mail/mailboxes.go | 21 ++++- templates/domains/htmx/index.htmx.django | 102 ++++++++++++++------- templates/mail/htmx/index.htmx.django | 150 +++++++++++++++++++++++------- utils/dns/defaults.go | 7 ++ utils/dns/messages.go | 11 +++ utils/dns/server.go | 153 +++++++++++++++++++++++++++++++ utils/dns/upstream.go | 153 +++++++++++++++++++++++++++++++ utils/smtp/messages.go | 32 ++++--- utils/smtp/server.go | 41 ++++++++- utils/smtp/session.go | 108 +++++++++++++++++++++- 31 files changed, 1190 insertions(+), 84 deletions(-) create mode 100644 models/dns/a.go create mode 100644 models/dns/aaaa.go create mode 100644 models/dns/cname.go create mode 100644 models/dns/mx.go create mode 100644 models/dns/srv.go create mode 100644 models/dns/txt.go create mode 100644 repositories/dns/a.go create mode 100644 repositories/dns/aaaa.go create mode 100644 repositories/dns/cname.go create mode 100644 repositories/dns/lookup.go create mode 100644 repositories/dns/mx.go create mode 100644 repositories/dns/resolve.go create mode 100644 repositories/dns/srv.go create mode 100644 repositories/dns/txt.go create mode 100644 services/domain/defaults.go create mode 100644 utils/dns/defaults.go create mode 100644 utils/dns/messages.go create mode 100644 utils/dns/server.go create mode 100644 utils/dns/upstream.go diff --git a/database/migration.go b/database/migration.go index a99b57c..9df1bfc 100644 --- a/database/migration.go +++ b/database/migration.go @@ -1,6 +1,7 @@ package database import ( + "dove/models/dns" "dove/models/domain" "dove/models/mail" "dove/utils/logger" @@ -10,6 +11,12 @@ func migrate() { migrationError := DB.AutoMigrate( &domain.TLD{}, &domain.Domain{}, + &dns.ARecord{}, + &dns.AAAARecord{}, + &dns.CNAMERecord{}, + &dns.MXRecord{}, + &dns.TXTRecord{}, + &dns.SRVRecord{}, &mail.User{}, &mail.Mailbox{}, &mail.Alias{}, diff --git a/dove/main.go b/dove/main.go index d03fd0a..28c759a 100644 --- a/dove/main.go +++ b/dove/main.go @@ -10,6 +10,7 @@ import ( "dove/middleware" "dove/router" "dove/tags" + "dove/utils/dns" "dove/utils/logger" "dove/utils/smtp" @@ -47,6 +48,7 @@ func main() { middleware.Initialize(application) router.Initialize(application) + dns.Start() smtp.Start() shutdownSignal := make(chan os.Signal, 1) @@ -64,6 +66,7 @@ func main() { <-shutdownSignal logger.Infof(LogPrefix, ServerShuttingDown) + dns.Shutdown() smtp.Shutdown() if shutdownError := application.Shutdown(); shutdownError != nil { diff --git a/go.mod b/go.mod index 7edcbae..296e5a1 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/miekg/dns v1.1.72 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect @@ -41,7 +42,10 @@ require ( github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/net v0.23.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 09318cd..c64bd0a 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -83,14 +85,26 @@ go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/models/dns/a.go b/models/dns/a.go new file mode 100644 index 0000000..b3c7f38 --- /dev/null +++ b/models/dns/a.go @@ -0,0 +1,14 @@ +package dns + +import ( + "gorm.io/gorm" +) + +type ARecord struct { + gorm.Model + DomainID uint `gorm:"not null;index" json:"domain_id"` + Name string `gorm:"not null;default:@" json:"name"` + Address string `gorm:"not null" json:"address"` + Port int `gorm:"default:0" json:"port"` + TTL uint32 `gorm:"not null;default:300" json:"ttl"` +} diff --git a/models/dns/aaaa.go b/models/dns/aaaa.go new file mode 100644 index 0000000..0053fe0 --- /dev/null +++ b/models/dns/aaaa.go @@ -0,0 +1,13 @@ +package dns + +import ( + "gorm.io/gorm" +) + +type AAAARecord struct { + gorm.Model + DomainID uint `gorm:"not null;index" json:"domain_id"` + Name string `gorm:"not null;default:@" json:"name"` + Address string `gorm:"not null" json:"address"` + TTL uint32 `gorm:"not null;default:300" json:"ttl"` +} diff --git a/models/dns/cname.go b/models/dns/cname.go new file mode 100644 index 0000000..5460f82 --- /dev/null +++ b/models/dns/cname.go @@ -0,0 +1,13 @@ +package dns + +import ( + "gorm.io/gorm" +) + +type CNAMERecord struct { + gorm.Model + DomainID uint `gorm:"not null;index" json:"domain_id"` + Name string `gorm:"not null" json:"name"` + Target string `gorm:"not null" json:"target"` + TTL uint32 `gorm:"not null;default:300" json:"ttl"` +} diff --git a/models/dns/mx.go b/models/dns/mx.go new file mode 100644 index 0000000..1e05489 --- /dev/null +++ b/models/dns/mx.go @@ -0,0 +1,14 @@ +package dns + +import ( + "gorm.io/gorm" +) + +type MXRecord struct { + gorm.Model + DomainID uint `gorm:"not null;index" json:"domain_id"` + Name string `gorm:"not null;default:@" json:"name"` + Target string `gorm:"not null" json:"target"` + Priority uint16 `gorm:"not null;default:10" json:"priority"` + TTL uint32 `gorm:"not null;default:300" json:"ttl"` +} diff --git a/models/dns/srv.go b/models/dns/srv.go new file mode 100644 index 0000000..481ee7e --- /dev/null +++ b/models/dns/srv.go @@ -0,0 +1,17 @@ +package dns + +import ( + "gorm.io/gorm" +) + +type SRVRecord struct { + gorm.Model + DomainID uint `gorm:"not null;index" json:"domain_id"` + Name string `gorm:"not null" json:"name"` + Target string `gorm:"not null" json:"target"` + Priority uint16 `gorm:"not null;default:0" json:"priority"` + Weight uint16 `gorm:"not null;default:0" json:"weight"` + Port uint16 `gorm:"not null" json:"port"` + Protocol string `gorm:"not null;default:tcp" json:"protocol"` + TTL uint32 `gorm:"not null;default:300" json:"ttl"` +} diff --git a/models/dns/txt.go b/models/dns/txt.go new file mode 100644 index 0000000..98342b3 --- /dev/null +++ b/models/dns/txt.go @@ -0,0 +1,13 @@ +package dns + +import ( + "gorm.io/gorm" +) + +type TXTRecord struct { + gorm.Model + DomainID uint `gorm:"not null;index" json:"domain_id"` + Name string `gorm:"not null;default:@" json:"name"` + Content string `gorm:"not null" json:"content"` + TTL uint32 `gorm:"not null;default:300" json:"ttl"` +} diff --git a/repositories/dns/a.go b/repositories/dns/a.go new file mode 100644 index 0000000..8643d9f --- /dev/null +++ b/repositories/dns/a.go @@ -0,0 +1,30 @@ +package dns + +import ( + "dove/database" + "dove/models/dns" +) + +func FindARecords(domainID uint, name string) []dns.ARecord { + var records []dns.ARecord + database.DB.Where("domain_id = ? AND name = ?", domainID, name).Find(&records) + return records +} + +func FindARecordsByDomainID(domainID uint) []dns.ARecord { + var records []dns.ARecord + database.DB.Where("domain_id = ?", domainID).Find(&records) + return records +} + +func CreateARecord(record *dns.ARecord) error { + return database.DB.Create(record).Error +} + +func DeleteARecord(record *dns.ARecord) error { + return database.DB.Delete(record).Error +} + +func DeleteARecordsByDomainID(domainID uint) error { + return database.DB.Where("domain_id = ?", domainID).Delete(&dns.ARecord{}).Error +} diff --git a/repositories/dns/aaaa.go b/repositories/dns/aaaa.go new file mode 100644 index 0000000..3539acd --- /dev/null +++ b/repositories/dns/aaaa.go @@ -0,0 +1,30 @@ +package dns + +import ( + "dove/database" + "dove/models/dns" +) + +func FindAAAARecords(domainID uint, name string) []dns.AAAARecord { + var records []dns.AAAARecord + database.DB.Where("domain_id = ? AND name = ?", domainID, name).Find(&records) + return records +} + +func FindAAAARecordsByDomainID(domainID uint) []dns.AAAARecord { + var records []dns.AAAARecord + database.DB.Where("domain_id = ?", domainID).Find(&records) + return records +} + +func CreateAAAARecord(record *dns.AAAARecord) error { + return database.DB.Create(record).Error +} + +func DeleteAAAARecord(record *dns.AAAARecord) error { + return database.DB.Delete(record).Error +} + +func DeleteAAAARecordsByDomainID(domainID uint) error { + return database.DB.Where("domain_id = ?", domainID).Delete(&dns.AAAARecord{}).Error +} diff --git a/repositories/dns/cname.go b/repositories/dns/cname.go new file mode 100644 index 0000000..540d0c0 --- /dev/null +++ b/repositories/dns/cname.go @@ -0,0 +1,30 @@ +package dns + +import ( + "dove/database" + "dove/models/dns" +) + +func FindCNAMERecords(domainID uint, name string) []dns.CNAMERecord { + var records []dns.CNAMERecord + database.DB.Where("domain_id = ? AND name = ?", domainID, name).Find(&records) + return records +} + +func FindCNAMERecordsByDomainID(domainID uint) []dns.CNAMERecord { + var records []dns.CNAMERecord + database.DB.Where("domain_id = ?", domainID).Find(&records) + return records +} + +func CreateCNAMERecord(record *dns.CNAMERecord) error { + return database.DB.Create(record).Error +} + +func DeleteCNAMERecord(record *dns.CNAMERecord) error { + return database.DB.Delete(record).Error +} + +func DeleteCNAMERecordsByDomainID(domainID uint) error { + return database.DB.Where("domain_id = ?", domainID).Delete(&dns.CNAMERecord{}).Error +} diff --git a/repositories/dns/lookup.go b/repositories/dns/lookup.go new file mode 100644 index 0000000..437f48b --- /dev/null +++ b/repositories/dns/lookup.go @@ -0,0 +1,26 @@ +package dns + +import ( + "dove/database" + domainModel "dove/models/domain" + + "gorm.io/gorm" +) + +func findDomainByFQDN(domainName string, tldName string) *domainModel.Domain { + var foundDomain domainModel.Domain + result := database.DB. + Joins("TLD"). + Where("domains.name = ? AND TLD.name = ?", domainName, tldName). + First(&foundDomain) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + return &foundDomain +} + +func tldExists(tldName string) bool { + var count int64 + database.DB.Model(&domainModel.TLD{}).Where("name = ?", tldName).Count(&count) + return count > 0 +} diff --git a/repositories/dns/mx.go b/repositories/dns/mx.go new file mode 100644 index 0000000..f0c9497 --- /dev/null +++ b/repositories/dns/mx.go @@ -0,0 +1,39 @@ +package dns + +import ( + "dove/database" + "dove/models/dns" +) + +func FindMXRecords(domainID uint, name string) []dns.MXRecord { + var records []dns.MXRecord + database.DB.Where("domain_id = ? AND name = ?", domainID, name).Order("priority ASC").Find(&records) + return records +} + +func FindMXRecordsByDomainID(domainID uint) []dns.MXRecord { + var records []dns.MXRecord + database.DB.Where("domain_id = ?", domainID).Order("priority ASC").Find(&records) + return records +} + +func FindMXRecordByTarget(domainID uint, target string) *dns.MXRecord { + var record dns.MXRecord + result := database.DB.Where("domain_id = ? AND target = ?", domainID, target).First(&record) + if result.Error != nil { + return nil + } + return &record +} + +func CreateMXRecord(record *dns.MXRecord) error { + return database.DB.Create(record).Error +} + +func DeleteMXRecord(record *dns.MXRecord) error { + return database.DB.Delete(record).Error +} + +func DeleteMXRecordsByDomainID(domainID uint) error { + return database.DB.Where("domain_id = ?", domainID).Delete(&dns.MXRecord{}).Error +} diff --git a/repositories/dns/resolve.go b/repositories/dns/resolve.go new file mode 100644 index 0000000..21dc0df --- /dev/null +++ b/repositories/dns/resolve.go @@ -0,0 +1,124 @@ +package dns + +import ( + "dove/models/dns" + "strings" +) + +type ResolvedRecords struct { + ARecords []dns.ARecord + AAAARecords []dns.AAAARecord + CNAMERecords []dns.CNAMERecord + MXRecords []dns.MXRecord + TXTRecords []dns.TXTRecord + SRVRecords []dns.SRVRecord +} + +func IsLocalDomain(queryName string) bool { + _, tldName := splitQueryName(queryName) + return tldExists(tldName) +} + +func ResolveA(queryName string) []dns.ARecord { + domainName, tldName, subdomain := splitQueryParts(queryName) + foundDomain := findDomainByFQDN(domainName, tldName) + if foundDomain == nil { + return nil + } + + records := FindARecords(foundDomain.ID, subdomain) + if len(records) == 0 && subdomain != "@" { + records = FindARecords(foundDomain.ID, "*") + } + return records +} + +func ResolveAAAA(queryName string) []dns.AAAARecord { + domainName, tldName, subdomain := splitQueryParts(queryName) + foundDomain := findDomainByFQDN(domainName, tldName) + if foundDomain == nil { + return nil + } + + records := FindAAAARecords(foundDomain.ID, subdomain) + if len(records) == 0 && subdomain != "@" { + records = FindAAAARecords(foundDomain.ID, "*") + } + return records +} + +func ResolveCNAME(queryName string) []dns.CNAMERecord { + domainName, tldName, subdomain := splitQueryParts(queryName) + foundDomain := findDomainByFQDN(domainName, tldName) + if foundDomain == nil { + return nil + } + + records := FindCNAMERecords(foundDomain.ID, subdomain) + if len(records) == 0 && subdomain != "@" { + records = FindCNAMERecords(foundDomain.ID, "*") + } + return records +} + +func ResolveMX(queryName string) []dns.MXRecord { + domainName, tldName, subdomain := splitQueryParts(queryName) + foundDomain := findDomainByFQDN(domainName, tldName) + if foundDomain == nil { + return nil + } + + return FindMXRecords(foundDomain.ID, subdomain) +} + +func ResolveTXT(queryName string) []dns.TXTRecord { + domainName, tldName, subdomain := splitQueryParts(queryName) + foundDomain := findDomainByFQDN(domainName, tldName) + if foundDomain == nil { + return nil + } + + return FindTXTRecords(foundDomain.ID, subdomain) +} + +func ResolveSRV(queryName string) []dns.SRVRecord { + domainName, tldName, subdomain := splitQueryParts(queryName) + foundDomain := findDomainByFQDN(domainName, tldName) + if foundDomain == nil { + return nil + } + + return FindSRVRecords(foundDomain.ID, subdomain) +} + +func splitQueryName(queryName string) (string, string) { + name := strings.TrimSuffix(queryName, ".") + parts := strings.Split(name, ".") + + if len(parts) < 2 { + return name, "" + } + + tldName := parts[len(parts)-1] + domainName := parts[len(parts)-2] + return domainName, tldName +} + +func splitQueryParts(queryName string) (string, string, string) { + name := strings.TrimSuffix(queryName, ".") + parts := strings.Split(name, ".") + + if len(parts) < 2 { + return name, "", "@" + } + + tldName := parts[len(parts)-1] + domainName := parts[len(parts)-2] + + if len(parts) == 2 { + return domainName, tldName, "@" + } + + subdomain := strings.Join(parts[:len(parts)-2], ".") + return domainName, tldName, subdomain +} diff --git a/repositories/dns/srv.go b/repositories/dns/srv.go new file mode 100644 index 0000000..998bea3 --- /dev/null +++ b/repositories/dns/srv.go @@ -0,0 +1,30 @@ +package dns + +import ( + "dove/database" + "dove/models/dns" +) + +func FindSRVRecords(domainID uint, name string) []dns.SRVRecord { + var records []dns.SRVRecord + database.DB.Where("domain_id = ? AND name = ?", domainID, name).Order("priority ASC").Find(&records) + return records +} + +func FindSRVRecordsByDomainID(domainID uint) []dns.SRVRecord { + var records []dns.SRVRecord + database.DB.Where("domain_id = ?", domainID).Order("priority ASC").Find(&records) + return records +} + +func CreateSRVRecord(record *dns.SRVRecord) error { + return database.DB.Create(record).Error +} + +func DeleteSRVRecord(record *dns.SRVRecord) error { + return database.DB.Delete(record).Error +} + +func DeleteSRVRecordsByDomainID(domainID uint) error { + return database.DB.Where("domain_id = ?", domainID).Delete(&dns.SRVRecord{}).Error +} diff --git a/repositories/dns/txt.go b/repositories/dns/txt.go new file mode 100644 index 0000000..9b8f711 --- /dev/null +++ b/repositories/dns/txt.go @@ -0,0 +1,30 @@ +package dns + +import ( + "dove/database" + "dove/models/dns" +) + +func FindTXTRecords(domainID uint, name string) []dns.TXTRecord { + var records []dns.TXTRecord + database.DB.Where("domain_id = ? AND name = ?", domainID, name).Find(&records) + return records +} + +func FindTXTRecordsByDomainID(domainID uint) []dns.TXTRecord { + var records []dns.TXTRecord + database.DB.Where("domain_id = ?", domainID).Find(&records) + return records +} + +func CreateTXTRecord(record *dns.TXTRecord) error { + return database.DB.Create(record).Error +} + +func DeleteTXTRecord(record *dns.TXTRecord) error { + return database.DB.Delete(record).Error +} + +func DeleteTXTRecordsByDomainID(domainID uint) error { + return database.DB.Where("domain_id = ?", domainID).Delete(&dns.TXTRecord{}).Error +} diff --git a/repositories/mail/mailbox.go b/repositories/mail/mailbox.go index dfd1fed..181654b 100644 --- a/repositories/mail/mailbox.go +++ b/repositories/mail/mailbox.go @@ -20,6 +20,16 @@ func FindMailboxByAddress(address string) *mail.Mailbox { return &mailbox } +func FindMailboxByAlias(aliasAddress string) *mail.Mailbox { + var alias mail.Alias + result := database.DB.Where("source_address = ?", aliasAddress).Preload("Mailbox").First(&alias) + if result.Error != nil { + return nil + } + + return &alias.Mailbox +} + func CreateMailbox(mailbox *mail.Mailbox) error { return database.DB.Create(mailbox).Error } diff --git a/services/domain/defaults.go b/services/domain/defaults.go new file mode 100644 index 0000000..aee8c14 --- /dev/null +++ b/services/domain/defaults.go @@ -0,0 +1,6 @@ +package domain + +const ( + DefaultAddress = "127.0.0.1" + DefaultMXTarget = "mail" +) diff --git a/services/domain/domain.go b/services/domain/domain.go index f320189..ff3efa5 100644 --- a/services/domain/domain.go +++ b/services/domain/domain.go @@ -3,7 +3,9 @@ package domain import ( "strings" + dnsModel "dove/models/dns" domainModel "dove/models/domain" + dnsRepo "dove/repositories/dns" domainRepo "dove/repositories/domain" mailRepo "dove/repositories/mail" "dove/utils/shortcuts" @@ -86,9 +88,19 @@ func CreateDomain(request CreateDomainRequest) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.Internal, DomainCreationFailed) } + seedDefaultARecord(newDomain.ID) + return nil } +func seedDefaultARecord(domainID uint) { + dnsRepo.CreateARecord(&dnsModel.ARecord{ + DomainID: domainID, + Name: "@", + Address: DefaultAddress, + }) +} + func EditDomainFormData(domainID uint) (*EditDomainFormResponse, *shortcuts.Error) { foundDomain := domainRepo.FindDomainByID(domainID) if foundDomain == nil { @@ -156,9 +168,20 @@ func DeleteDomain(domainID uint) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.Unprocessable, DomainHasMailboxes) } + deleteAllDNSRecords(foundDomain.ID) + if deleteError := domainRepo.DeleteDomain(foundDomain); deleteError != nil { return shortcuts.ServiceError(shortcuts.Internal, DomainDeletionFailed) } return nil } + +func deleteAllDNSRecords(domainID uint) { + dnsRepo.DeleteARecordsByDomainID(domainID) + dnsRepo.DeleteAAAARecordsByDomainID(domainID) + dnsRepo.DeleteCNAMERecordsByDomainID(domainID) + dnsRepo.DeleteMXRecordsByDomainID(domainID) + dnsRepo.DeleteTXTRecordsByDomainID(domainID) + dnsRepo.DeleteSRVRecordsByDomainID(domainID) +} diff --git a/services/mail/mailboxes.go b/services/mail/mailboxes.go index d940907..3a4ee90 100644 --- a/services/mail/mailboxes.go +++ b/services/mail/mailboxes.go @@ -3,9 +3,11 @@ package mail import ( "strings" + dnsModel "dove/models/dns" domainModel "dove/models/domain" - domainRepo "dove/repositories/domain" mailModel "dove/models/mail" + dnsRepo "dove/repositories/dns" + domainRepo "dove/repositories/domain" mailRepo "dove/repositories/mail" "dove/utils/meta" "dove/utils/shortcuts" @@ -89,9 +91,26 @@ func CreateMailbox(request CreateMailboxRequest) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.Internal, FolderSeedFailed) } + seedMXRecordForDomain(foundDomain) + return nil } +func seedMXRecordForDomain(targetDomain *domainModel.Domain) { + fullDomainName := targetDomain.Name + "." + targetDomain.TLD.Name + existingMX := dnsRepo.FindMXRecordByTarget(targetDomain.ID, fullDomainName) + if existingMX != nil { + return + } + + dnsRepo.CreateMXRecord(&dnsModel.MXRecord{ + DomainID: targetDomain.ID, + Name: "@", + Target: fullDomainName, + Priority: 10, + }) +} + func EditMailboxFormData(mailboxID uint) (*EditMailboxFormResponse, *shortcuts.Error) { mailbox := mailRepo.FindMailboxByIDWithRelations(mailboxID) if mailbox == nil { diff --git a/templates/domains/htmx/index.htmx.django b/templates/domains/htmx/index.htmx.django index 0efaf45..3b2ef96 100644 --- a/templates/domains/htmx/index.htmx.django +++ b/templates/domains/htmx/index.htmx.django @@ -1,44 +1,84 @@
-
-
-
- +
+

Domain Manager

+
+
+

Dove runs a local DNS server that resolves your registered TLDs and domains. Each domain gets DNS records (A, AAAA, CNAME, MX, TXT, SRV) with port mapping support, so you can route myapp.dove to 127.0.0.1:3000. Queries for unregistered domains are forwarded to your system's upstream DNS, so your regular internet browsing is not disrupted. Point your system's DNS resolver at Dove's DNS address to start resolving local domains.

+
+
+ + -
-
-

Top-Level Domains (TLDs)

-

TLDs sit at the root of the domain hierarchy. Dove ships with built-in TLDs like .local and .dev that are ready to use immediately. You can also create custom TLDs to organise domains by team, project, or environment.

-
-

Built-in TLDs are created during initial setup and cannot be deleted. They provide a stable foundation for common development patterns.

-

Custom TLDs can be created and deleted freely. A TLD can only be removed after all domains registered under it have been deleted first.

-
-

TLD names must be valid DNS labels: lowercase letters, numbers, and hyphens only, between 1 and 63 characters, and cannot start or end with a hyphen.

+

Dove ships with .dove, .local, .nest, and .test. Create additional TLDs to organise domains by team, project, or environment.

+
+ + {% url "domains.manage" as manage_path %} + +
+
+ + + +
+

Domains

-
-

Domains

-

Domains are registered under a TLD and provide the address for your local services. Registering myapp under .local creates myapp.local, which immediately resolves on your machine.

-
-

Service routing — Each domain can be mapped to local services through DNS records with port support, similar to how Cloudflare handles traffic routing. Your services get proper domain names that resolve to the correct local ports.

-

Mail integration — Registered domains are automatically available for creating mailboxes. A domain like myapp.local lets you create addresses such as admin@myapp.local.

-
-

Domain names follow the same validation rules as TLDs. Each domain must be unique within its TLD — you cannot register the same name twice under the same TLD, but myapp.local and myapp.dev can coexist.

+

Register domains under your TLDs. Each domain resolves locally and can be used for mail, service routing, and DNS records.

+
+ +
+
+
+ + + +
+
+

DNS Records

+ Coming soon +
-
-

How It Works

-

Dove runs a DNS server that intercepts queries for your registered TLDs and returns the appropriate local addresses. Queries for unregistered domains are forwarded to your system's upstream DNS resolver, so internet connectivity is unaffected.

-
-

Configuration — The DNS server listens on the host and port defined in your config.toml under the [dns] section. Point your machine's DNS resolver at this address to start resolving local domains.

-

TTL — All DNS responses use the default_ttl value from your configuration. Since everything is local, a low TTL ensures changes propagate immediately.

-

No system changes — Dove does not modify /etc/hosts or any system files. It operates entirely as a standalone DNS server that you opt into by configuring your resolver.

+

Manage A, AAAA, CNAME, MX, TXT, and SRV records per domain. A records support port mapping for routing domains to local services. Wildcard subdomain records for multi-tenant apps.

+
+ +
+
+
+ + + +
+
+

Query Log

+ Coming soon +
+
+

Log every DNS query with timestamp, source, record type, and answer. Debug service discovery issues and see which domains your applications are resolving.

+
+ +
+
+
+ + + +
+
+

Local CA & TLS

+ Coming soon
+

Auto-generated root CA with per-domain TLS certificates. Install the CA once into your system or browser, and every registered domain gets automatic HTTPS with subdomain SSL support.

+
diff --git a/templates/mail/htmx/index.htmx.django b/templates/mail/htmx/index.htmx.django index 802d047..0125c5e 100644 --- a/templates/mail/htmx/index.htmx.django +++ b/templates/mail/htmx/index.htmx.django @@ -1,45 +1,127 @@
-
-
-
- - +
+

Mail

+
+
+

Dove runs a full local mail server stack with SMTP, IMAP, and POP3. Applications connect to the SMTP server to send emails, and mail clients like Thunderbird or Apple Mail connect via IMAP or POP3 to retrieve them. MX records are automatically created when mailboxes are added to a domain. Incoming emails are parsed for headers, content, attachments, and spam scoring. All mail stays on your machine.

+
+
+ +
+ {% url "mail.users" as users_path %} + +
+
+ + + +
+

Users

+
+

Create and manage mail users. Each user can own multiple mailboxes across different domains and authenticate against the mail server.

+
+ + {% url "mail.mailboxes" as mailboxes_path %} + +
+
+ + + +
+

Mailboxes

+
+

Create email addresses tied to your registered domains. Each mailbox gets its own folders, storage, and supports aliases for receiving mail at multiple addresses.

+
+ + {% url "mail.webmail" as webmail_path %} + +
+
+ + + +
+

WebMail

+
+

Full browser-based mail client. Compose with rich text editor, reply, reply all, forward, and attach files with drag-and-drop. Manage folders, search and filter messages, tag and star emails. Inspect complete headers, raw MIME source, and HTML rendering per message.

+
+ +
+
+
+ + + +
+
+

No-Reply Addresses

+ Coming soon +
+
+

Create send-only addresses with no inbox. Useful for transactional emails, notifications, and automated messages that should not receive replies.

+
+ +
+
+
+ + + +
+
+

Bounce Capture

+ Coming soon +
+
+

Track and inspect bounced emails. Messages sent to non-existent mailboxes on registered domains are captured with sender, recipient, and reason for analysis.

+
+ +
+
+
+ +
-

Mail

+
+

Spam Scoring

+ Coming soon +
-

Dove provides a complete local mail infrastructure with SMTP, IMAP, and POP3 support. Create users, assign mailboxes across your registered domains, and send and receive emails entirely on your machine with zero external dependencies.

+

Analyse incoming emails for spam indicators. Check SPF, DKIM, and DMARC alignment, score deliverability, and flag suspicious messages automatically.

-
-
-

Users

-

Users are the identities that own mailboxes and authenticate against the mail server. Each user has a unique username and a display name that appears on outgoing messages.

-
-

Authentication — Users authenticate via SMTP to send emails and via IMAP or POP3 to retrieve them. Credentials are managed through the Dove dashboard.

-

Multiple mailboxes — A single user can own mailboxes across different domains. For example, one user might manage both admin@myapp.local and support@api.dev.

-
-

Usernames must be unique across the system. The display name is used as the sender name in email headers and can contain any characters.

-
-
-

Mailboxes

-

A mailbox is an email address tied to a user and a domain. The address is composed of a local part and a domain: local-part@domain.tld.

-
-

Domain requirement — Mailboxes are linked to domains managed through the Domain Manager. You must register at least one domain before creating a mailbox.

-

Unique addresses — Each email address must be unique across the system. The same local part can exist on different domains (info@app.local and info@api.dev are distinct mailboxes).

-

Storage — Each mailbox stores incoming and outgoing emails independently in Dove's local database.

-
-

The local part of the address must contain only lowercase letters, numbers, dots, hyphens, and underscores. It cannot start or end with a special character.

-
-
-

How It Works

-

Dove runs a full mail server stack locally. Any application on your machine can connect to the SMTP server to send emails, and mail clients can connect via IMAP or POP3 to read them.

-
-

SMTP — Handles both sending and receiving. Configure your application or mail client to use localhost on the port defined in [smtp] of your config.toml.

-

IMAP / POP3 — Retrieval protocols for reading mail. Connect any standard mail client (Thunderbird, Apple Mail, etc.) using the ports defined in [imap] and [pop3].

-

Local only — All mail stays on your machine. This gives you a complete email workflow for development and testing without relying on external mail services, sandbox APIs, or third-party tools.

+ +
+
+
+ + + +
+
+

Mail Rules

+ Coming soon +
+
+

Server-side mail filters for automatic organisation. Define conditions and actions to move, tag, forward, or auto-respond to incoming messages.

+
+ +
+
+
+ + + +
+
+

IMAP & POP3

+ Coming soon
+

Connect desktop and mobile mail clients like Thunderbird, Apple Mail, or Outlook directly to Dove. Retrieve emails over IMAP for synced access or POP3 for download.

+
diff --git a/utils/dns/defaults.go b/utils/dns/defaults.go new file mode 100644 index 0000000..dc9824a --- /dev/null +++ b/utils/dns/defaults.go @@ -0,0 +1,7 @@ +package dns + +const ( + LogPrefix = "DNS" + UpstreamTimeoutSeconds = 5 + UpstreamResponseTTL = 60 +) diff --git a/utils/dns/messages.go b/utils/dns/messages.go new file mode 100644 index 0000000..a73c8de --- /dev/null +++ b/utils/dns/messages.go @@ -0,0 +1,11 @@ +package dns + +const ( + ForwardFailed = "Failed to forward query for %s to upstream: %v" + ForwardSuccess = "Forwarded query for %s to upstream." + ListenFailed = "Failed to start DNS listener: %v" + QueryReceived = "Query: %s %s" + ServerStarting = "DNS server started on %s (UDP)." + ShutdownComplete = "DNS server stopped." + ShutdownFailed = "Failed to shutdown DNS server: %v" +) diff --git a/utils/dns/server.go b/utils/dns/server.go new file mode 100644 index 0000000..7ae8770 --- /dev/null +++ b/utils/dns/server.go @@ -0,0 +1,153 @@ +package dns + +import ( + "fmt" + "net" + + "dove/config" + dnsRepo "dove/repositories/dns" + "dove/utils/logger" + + mdns "github.com/miekg/dns" +) + +var activeServer *mdns.Server + +func Start() { + address := fmt.Sprintf("%s:%d", config.DNS.Host, config.DNS.Port) + + mdns.HandleFunc(".", handleQuery) + + activeServer = &mdns.Server{ + Addr: address, + Net: "udp", + } + + go func() { + logger.Successf(LogPrefix, ServerStarting, address) + + if listenError := activeServer.ListenAndServe(); listenError != nil { + logger.Fatalf(LogPrefix, ListenFailed, listenError) + } + }() +} + +func Shutdown() { + if activeServer == nil { + return + } + + if shutdownError := activeServer.Shutdown(); shutdownError != nil { + logger.Errorf(LogPrefix, ShutdownFailed, shutdownError) + } + + logger.Infof(LogPrefix, ShutdownComplete) +} + +func handleQuery(writer mdns.ResponseWriter, request *mdns.Msg) { + if len(request.Question) == 0 { + return + } + + question := request.Question[0] + queryName := question.Name + queryType := mdns.TypeToString[question.Qtype] + + logger.Debugf(LogPrefix, QueryReceived, queryType, queryName) + + if dnsRepo.IsLocalDomain(queryName) { + response := resolveLocal(request) + writer.WriteMsg(response) + return + } + + upstreamResponse := forwardToUpstream(request) + if upstreamResponse != nil { + writer.WriteMsg(upstreamResponse) + return + } + + refusedResponse := &mdns.Msg{} + refusedResponse.SetRcode(request, mdns.RcodeServerFailure) + writer.WriteMsg(refusedResponse) +} + +func resolveLocal(request *mdns.Msg) *mdns.Msg { + question := request.Question[0] + queryName := question.Name + + response := &mdns.Msg{} + response.SetReply(request) + response.Authoritative = true + response.RecursionAvailable = true + + switch question.Qtype { + case mdns.TypeA: + for _, record := range dnsRepo.ResolveA(queryName) { + parsedIP := net.ParseIP(record.Address) + if parsedIP == nil || parsedIP.To4() == nil { + continue + } + + response.Answer = append(response.Answer, &mdns.A{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeA, Class: mdns.ClassINET, Ttl: record.TTL}, + A: parsedIP.To4(), + }) + } + + case mdns.TypeAAAA: + for _, record := range dnsRepo.ResolveAAAA(queryName) { + parsedIP := net.ParseIP(record.Address) + if parsedIP == nil || parsedIP.To16() == nil { + continue + } + + response.Answer = append(response.Answer, &mdns.AAAA{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeAAAA, Class: mdns.ClassINET, Ttl: record.TTL}, + AAAA: parsedIP.To16(), + }) + } + + case mdns.TypeCNAME: + for _, record := range dnsRepo.ResolveCNAME(queryName) { + response.Answer = append(response.Answer, &mdns.CNAME{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeCNAME, Class: mdns.ClassINET, Ttl: record.TTL}, + Target: mdns.Fqdn(record.Target), + }) + } + + case mdns.TypeMX: + for _, record := range dnsRepo.ResolveMX(queryName) { + response.Answer = append(response.Answer, &mdns.MX{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeMX, Class: mdns.ClassINET, Ttl: record.TTL}, + Preference: record.Priority, + Mx: mdns.Fqdn(record.Target), + }) + } + + case mdns.TypeTXT: + for _, record := range dnsRepo.ResolveTXT(queryName) { + response.Answer = append(response.Answer, &mdns.TXT{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeTXT, Class: mdns.ClassINET, Ttl: record.TTL}, + Txt: []string{record.Content}, + }) + } + + case mdns.TypeSRV: + for _, record := range dnsRepo.ResolveSRV(queryName) { + response.Answer = append(response.Answer, &mdns.SRV{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeSRV, Class: mdns.ClassINET, Ttl: record.TTL}, + Priority: record.Priority, + Weight: record.Weight, + Port: record.Port, + Target: mdns.Fqdn(record.Target), + }) + } + } + + if len(response.Answer) == 0 { + response.Rcode = mdns.RcodeNameError + } + + return response +} diff --git a/utils/dns/upstream.go b/utils/dns/upstream.go new file mode 100644 index 0000000..e2af583 --- /dev/null +++ b/utils/dns/upstream.go @@ -0,0 +1,153 @@ +package dns + +import ( + "context" + "net" + "time" + + "dove/utils/logger" + + mdns "github.com/miekg/dns" +) + +func forwardToUpstream(request *mdns.Msg) *mdns.Msg { + if len(request.Question) == 0 { + return nil + } + + question := request.Question[0] + queryName := question.Name + + resolver := &net.Resolver{PreferGo: false} + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(UpstreamTimeoutSeconds)*time.Second) + defer cancel() + + response := &mdns.Msg{} + response.SetReply(request) + response.Authoritative = false + response.RecursionAvailable = true + + switch question.Qtype { + case mdns.TypeA: + addresses, lookupError := resolver.LookupIPAddr(ctx, mdns.Fqdn(queryName)) + if lookupError != nil { + logger.Debugf(LogPrefix, ForwardFailed, queryName, lookupError) + response.Rcode = mdns.RcodeNameError + return response + } + + for _, address := range addresses { + if ipv4 := address.IP.To4(); ipv4 != nil { + response.Answer = append(response.Answer, &mdns.A{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeA, Class: mdns.ClassINET, Ttl: UpstreamResponseTTL}, + A: ipv4, + }) + } + } + + case mdns.TypeAAAA: + addresses, lookupError := resolver.LookupIPAddr(ctx, mdns.Fqdn(queryName)) + if lookupError != nil { + logger.Debugf(LogPrefix, ForwardFailed, queryName, lookupError) + response.Rcode = mdns.RcodeNameError + return response + } + + for _, address := range addresses { + if address.IP.To4() == nil && address.IP.To16() != nil { + response.Answer = append(response.Answer, &mdns.AAAA{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeAAAA, Class: mdns.ClassINET, Ttl: UpstreamResponseTTL}, + AAAA: address.IP.To16(), + }) + } + } + + case mdns.TypeCNAME: + canonicalName, lookupError := resolver.LookupCNAME(ctx, mdns.Fqdn(queryName)) + if lookupError != nil { + logger.Debugf(LogPrefix, ForwardFailed, queryName, lookupError) + response.Rcode = mdns.RcodeNameError + return response + } + + response.Answer = append(response.Answer, &mdns.CNAME{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeCNAME, Class: mdns.ClassINET, Ttl: UpstreamResponseTTL}, + Target: mdns.Fqdn(canonicalName), + }) + + case mdns.TypeMX: + mxRecords, lookupError := resolver.LookupMX(ctx, mdns.Fqdn(queryName)) + if lookupError != nil { + logger.Debugf(LogPrefix, ForwardFailed, queryName, lookupError) + response.Rcode = mdns.RcodeNameError + return response + } + + for _, mxRecord := range mxRecords { + response.Answer = append(response.Answer, &mdns.MX{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeMX, Class: mdns.ClassINET, Ttl: UpstreamResponseTTL}, + Preference: mxRecord.Pref, + Mx: mdns.Fqdn(mxRecord.Host), + }) + } + + case mdns.TypeTXT: + txtRecords, lookupError := resolver.LookupTXT(ctx, mdns.Fqdn(queryName)) + if lookupError != nil { + logger.Debugf(LogPrefix, ForwardFailed, queryName, lookupError) + response.Rcode = mdns.RcodeNameError + return response + } + + if len(txtRecords) > 0 { + response.Answer = append(response.Answer, &mdns.TXT{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeTXT, Class: mdns.ClassINET, Ttl: UpstreamResponseTTL}, + Txt: txtRecords, + }) + } + + case mdns.TypeSRV: + _, srvRecords, lookupError := resolver.LookupSRV(ctx, "", "", mdns.Fqdn(queryName)) + if lookupError != nil { + logger.Debugf(LogPrefix, ForwardFailed, queryName, lookupError) + response.Rcode = mdns.RcodeNameError + return response + } + + for _, srvRecord := range srvRecords { + response.Answer = append(response.Answer, &mdns.SRV{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeSRV, Class: mdns.ClassINET, Ttl: UpstreamResponseTTL}, + Priority: srvRecord.Priority, + Weight: srvRecord.Weight, + Port: srvRecord.Port, + Target: mdns.Fqdn(srvRecord.Target), + }) + } + + case mdns.TypeNS: + nsRecords, lookupError := resolver.LookupNS(ctx, mdns.Fqdn(queryName)) + if lookupError != nil { + logger.Debugf(LogPrefix, ForwardFailed, queryName, lookupError) + response.Rcode = mdns.RcodeNameError + return response + } + + for _, nsRecord := range nsRecords { + response.Answer = append(response.Answer, &mdns.NS{ + Hdr: mdns.RR_Header{Name: queryName, Rrtype: mdns.TypeNS, Class: mdns.ClassINET, Ttl: UpstreamResponseTTL}, + Ns: mdns.Fqdn(nsRecord.Host), + }) + } + + default: + response.Rcode = mdns.RcodeNotImplemented + return response + } + + if len(response.Answer) == 0 { + response.Rcode = mdns.RcodeNameError + } + + logger.Debugf(LogPrefix, ForwardSuccess, queryName) + return response +} diff --git a/utils/smtp/messages.go b/utils/smtp/messages.go index 90568e0..6441fde 100644 --- a/utils/smtp/messages.go +++ b/utils/smtp/messages.go @@ -1,15 +1,25 @@ package smtp const ( - AuthFailed = "Authentication failed for user: %s" - InvalidCredentials = "Invalid credentials." - ListenFailed = "Failed to start %s listener: %v" - MailFrom = "Mail from: %s" - MessageReceived = "Message received (%d bytes)." - MessageStoreFailed = "Failed to store message: %v" - Recipient = "Recipient: %s" - ServerStarting = "%s listener started on %s." - SessionStarted = "New session from %s." - ShutdownComplete = "All listeners stopped." - ShutdownFailed = "Failed to shutdown %s listener: %v" + AuthFailed = "Authentication failed for user: %s" + DeliveryFailed = "Failed to deliver to %s: %v" + DeliverySuccess = "Delivered to local mailbox: %s" + InvalidCredentials = "Invalid credentials." + ListenFailed = "Failed to start %s listener: %v" + MailFrom = "Mail from: %s" + MessageParseFailed = "Failed to parse incoming message: %v" + MessageReceived = "Message received (%d bytes) from %s to %v." + MessageStoreFailed = "Failed to store message: %v" + NoLocalRecipients = "No local recipients found for message from %s." + Recipient = "Recipient: %s" + RecipientNotLocal = "Recipient %s is not a local mailbox." + RelayDeliveryFailed = "Failed to relay message to %s: %v" + RelayDeliverySuccess = "Relayed message to %s via %s:%d." + RelayDisabled = "Relay disabled. External recipient %s will not receive the message." + ServerStarting = "%s listener started on %s." + SessionStarted = "New session from %s." + ShutdownComplete = "All listeners stopped." + ShutdownFailed = "Failed to shutdown %s listener: %v" + TLSCertLoadFailed = "Failed to load TLS certificate: %v" + UnknownRecipientDomain = "Recipient domain %s is not managed by this server." ) diff --git a/utils/smtp/server.go b/utils/smtp/server.go index 2773647..5260d20 100644 --- a/utils/smtp/server.go +++ b/utils/smtp/server.go @@ -1,6 +1,7 @@ package smtp import ( + "crypto/tls" "dove/config" "dove/utils/logger" "fmt" @@ -19,10 +20,26 @@ var activeServers []ServerInstance func Start() { plainAddress := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.Port) plainServer := createServer(plainAddress) + activeServers = append(activeServers, ServerInstance{Server: plainServer, Label: "SMTP"}) + go startListener(plainServer, "SMTP", plainAddress) - activeServers = append(activeServers, ServerInstance{Server: plainServer, Label: LogPrefix}) + if config.SMTP.TLSEnabled { + tlsConfig := loadTLSConfig() + if tlsConfig != nil { + smtpsAddress := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.SMTPSPort) + smtpsServer := createServer(smtpsAddress) + smtpsServer.TLSConfig = tlsConfig + activeServers = append(activeServers, ServerInstance{Server: smtpsServer, Label: "SMTPS"}) + go startTLSListener(smtpsServer, "SMTPS", smtpsAddress) - go startListener(plainServer, LogPrefix, plainAddress) + starttlsAddress := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.StartTLSPort) + starttlsServer := createServer(starttlsAddress) + starttlsServer.TLSConfig = tlsConfig + starttlsServer.EnableSMTPUTF8 = true + activeServers = append(activeServers, ServerInstance{Server: starttlsServer, Label: "STARTTLS"}) + go startListener(starttlsServer, "STARTTLS", starttlsAddress) + } + } } func Shutdown() { @@ -51,6 +68,18 @@ func createServer(address string) *gosmtp.Server { return smtpServer } +func loadTLSConfig() *tls.Config { + certificate, loadError := tls.LoadX509KeyPair(config.SMTP.TLSCertPath, config.SMTP.TLSKeyPath) + if loadError != nil { + logger.Errorf(LogPrefix, TLSCertLoadFailed, loadError) + return nil + } + + return &tls.Config{ + Certificates: []tls.Certificate{certificate}, + } +} + func startListener(smtpServer *gosmtp.Server, label string, address string) { logger.Successf(LogPrefix, ServerStarting, label, address) @@ -58,3 +87,11 @@ func startListener(smtpServer *gosmtp.Server, label string, address string) { logger.Fatalf(LogPrefix, ListenFailed, label, listenError) } } + +func startTLSListener(smtpServer *gosmtp.Server, label string, address string) { + logger.Successf(LogPrefix, ServerStarting, label, address) + + if listenError := smtpServer.ListenAndServeTLS(); listenError != nil { + logger.Fatalf(LogPrefix, ListenFailed, label, listenError) + } +} diff --git a/utils/smtp/session.go b/utils/smtp/session.go index 73fc4e1..ef5ddc7 100644 --- a/utils/smtp/session.go +++ b/utils/smtp/session.go @@ -2,9 +2,16 @@ package smtp import ( "dove/config" + mailModel "dove/models/mail" + mailRepo "dove/repositories/mail" + "dove/utils/email" "dove/utils/errors" "dove/utils/logger" + "dove/utils/storage" + "fmt" "io" + "strings" + "time" gosmtp "github.com/emersion/go-smtp" ) @@ -45,9 +52,41 @@ func (self *Session) Data(messageReader io.Reader) error { return readError } - logger.Infof(LogPrefix, MessageReceived, len(rawMessage)) + logger.Infof(LogPrefix, MessageReceived, len(rawMessage), self.fromAddress, self.toAddresses) - _ = rawMessage + parsedEmail, parseError := email.Parse(rawMessage) + if parseError != nil { + logger.Errorf(LogPrefix, MessageParseFailed, parseError) + return parseError + } + + deliveredToLocal := false + + for _, recipientAddress := range self.toAddresses { + recipientMailbox := mailRepo.FindMailboxByAddress(recipientAddress) + if recipientMailbox == nil { + aliasMailbox := mailRepo.FindMailboxByAlias(recipientAddress) + if aliasMailbox != nil { + recipientMailbox = aliasMailbox + } + } + + if recipientMailbox != nil { + deliverError := deliverToLocalMailbox(recipientMailbox, parsedEmail, rawMessage) + if deliverError != nil { + logger.Errorf(LogPrefix, DeliveryFailed, recipientAddress, deliverError) + continue + } + logger.Infof(LogPrefix, DeliverySuccess, recipientAddress) + deliveredToLocal = true + } else { + logger.Debugf(LogPrefix, RecipientNotLocal, recipientAddress) + } + } + + if !deliveredToLocal { + logger.Warnf(LogPrefix, NoLocalRecipients, self.fromAddress) + } return nil } @@ -60,3 +99,68 @@ func (self *Session) Reset() { func (self *Session) Logout() error { return nil } + +func deliverToLocalMailbox(mailbox *mailModel.Mailbox, parsedEmail *email.ParsedEmail, rawMessage []byte) error { + inboxFolder := mailRepo.FindFolderBySlug(mailbox.ID, "inbox") + if inboxFolder == nil { + return errors.Error("Inbox folder not found for mailbox %s.", mailbox.Address) + } + + filename := fmt.Sprintf("%d", time.Now().UnixNano()) + + toAddressesJoined := strings.Join(parsedEmail.ToAddresses, ", ") + ccAddressesJoined := strings.Join(parsedEmail.CcAddresses, ", ") + bccAddressesJoined := strings.Join(parsedEmail.BccAddresses, ", ") + + attachmentCount := 0 + inlineCount := 0 + for _, attachment := range parsedEmail.Attachments { + if attachment.IsInline { + inlineCount++ + } else { + attachmentCount++ + } + } + + emailRecord := &mailModel.Email{ + MailboxID: mailbox.ID, + FolderID: inboxFolder.ID, + MessageID: parsedEmail.MessageID, + Filename: filename, + FromAddress: parsedEmail.FromAddress, + FromName: parsedEmail.FromName, + ToAddresses: toAddressesJoined, + CcAddresses: ccAddressesJoined, + BccAddresses: bccAddressesJoined, + ReplyToAddress: parsedEmail.ReplyToAddress, + ReturnPath: parsedEmail.ReturnPath, + Subject: parsedEmail.Subject, + Snippet: parsedEmail.Snippet, + Size: parsedEmail.Size, + IsRead: false, + AttachmentCount: attachmentCount, + InlineCount: inlineCount, + } + + if createError := mailRepo.CreateEmail(emailRecord); createError != nil { + return createError + } + + for _, parsedAttachment := range parsedEmail.Attachments { + attachmentRecord := &mailModel.Attachment{ + EmailID: emailRecord.ID, + Filename: parsedAttachment.Filename, + ContentType: parsedAttachment.ContentType, + ContentID: parsedAttachment.ContentID, + Size: parsedAttachment.Size, + IsInline: parsedAttachment.IsInline, + } + mailRepo.CreateAttachment(attachmentRecord) + } + + if writeError := storage.WriteMailFile(mailbox.Address, "inbox", filename, rawMessage); writeError != nil { + logger.Warnf(LogPrefix, MessageStoreFailed, writeError) + } + + return nil +} -- cgit v1.2.3