aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--database/migration.go7
-rw-r--r--dove/main.go3
-rw-r--r--go.mod10
-rw-r--r--go.sum14
-rw-r--r--models/dns/a.go14
-rw-r--r--models/dns/aaaa.go13
-rw-r--r--models/dns/cname.go13
-rw-r--r--models/dns/mx.go14
-rw-r--r--models/dns/srv.go17
-rw-r--r--models/dns/txt.go13
-rw-r--r--repositories/dns/a.go30
-rw-r--r--repositories/dns/aaaa.go30
-rw-r--r--repositories/dns/cname.go30
-rw-r--r--repositories/dns/lookup.go26
-rw-r--r--repositories/dns/mx.go39
-rw-r--r--repositories/dns/resolve.go124
-rw-r--r--repositories/dns/srv.go30
-rw-r--r--repositories/dns/txt.go30
-rw-r--r--repositories/mail/mailbox.go10
-rw-r--r--services/domain/defaults.go6
-rw-r--r--services/domain/domain.go23
-rw-r--r--services/mail/mailboxes.go21
-rw-r--r--templates/domains/htmx/index.htmx.django102
-rw-r--r--templates/mail/htmx/index.htmx.django150
-rw-r--r--utils/dns/defaults.go7
-rw-r--r--utils/dns/messages.go11
-rw-r--r--utils/dns/server.go153
-rw-r--r--utils/dns/upstream.go153
-rw-r--r--utils/smtp/messages.go32
-rw-r--r--utils/smtp/server.go41
-rw-r--r--utils/smtp/session.go108
31 files changed, 1190 insertions, 84 deletions
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 @@
<div class="slide-up space-y-6">
<div class="glass rounded-xl glow-border">
- <div class="px-6 py-5 border-b border-white/[0.04]">
- <div class="flex items-center gap-3 mb-4">
- <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-accent-500/10">
- <svg class="w-5 h-5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]">
+ <h2 class="text-sm font-medium text-zinc-200">Domain Manager</h2>
+ </div>
+ <div class="px-5 py-4">
+ <p class="text-xs text-zinc-500 leading-relaxed">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 <span class="text-zinc-400">myapp.dove</span> to <span class="text-zinc-400">127.0.0.1:3000</span>. 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.</p>
+ </div>
+ </div>
+
+ <div class="grid grid-cols-2 gap-4">
+ {% url "domains.tlds" as tlds_path %}
+ <a href="{{ tlds_path }}" hx-get="{{ tlds_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="glass rounded-xl p-5 glow-border hover:bg-white/[0.02] transition-colors duration-150 group">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10">
+ <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
</div>
- <h2 class="text-base font-medium text-zinc-100">Domain Manager</h2>
+ <h3 class="text-sm font-medium text-zinc-200 group-hover:text-zinc-100">Top-Level Domains</h3>
</div>
- <p class="text-sm text-zinc-400 leading-relaxed">Dove includes a built-in DNS server that provides local name resolution for your development environment. The Domain Manager lets you create custom top-level domains and register domain names that resolve entirely on your machine, without touching your system hosts file or relying on external DNS providers.</p>
- </div>
- <div class="px-6 py-5 space-y-6">
- <div>
- <h3 class="text-sm font-medium text-zinc-200 mb-2">Top-Level Domains (TLDs)</h3>
- <p class="text-xs text-zinc-500 leading-relaxed mb-3">TLDs sit at the root of the domain hierarchy. Dove ships with built-in TLDs like <span class="text-zinc-400 font-medium">.local</span> and <span class="text-zinc-400 font-medium">.dev</span> that are ready to use immediately. You can also create custom TLDs to organise domains by team, project, or environment.</p>
- <div class="rounded-lg bg-surface-900/50 border border-white/[0.04] px-4 py-3 space-y-2">
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Built-in TLDs</span> are created during initial setup and cannot be deleted. They provide a stable foundation for common development patterns.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Custom TLDs</span> can be created and deleted freely. A TLD can only be removed after all domains registered under it have been deleted first.</p>
- </div>
- <p class="text-xs text-zinc-500 leading-relaxed mt-3">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.</p>
+ <p class="text-xs text-zinc-500 leading-relaxed">Dove ships with .dove, .local, .nest, and .test. Create additional TLDs to organise domains by team, project, or environment.</p>
+ </a>
+
+ {% url "domains.manage" as manage_path %}
+ <a href="{{ manage_path }}" hx-get="{{ manage_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="glass rounded-xl p-5 glow-border hover:bg-white/[0.02] transition-colors duration-150 group">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10">
+ <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" />
+ </svg>
+ </div>
+ <h3 class="text-sm font-medium text-zinc-200 group-hover:text-zinc-100">Domains</h3>
</div>
- <div class="border-t border-white/[0.04] pt-5">
- <h3 class="text-sm font-medium text-zinc-200 mb-2">Domains</h3>
- <p class="text-xs text-zinc-500 leading-relaxed mb-3">Domains are registered under a TLD and provide the address for your local services. Registering <span class="text-zinc-400 font-medium">myapp</span> under <span class="text-zinc-400 font-medium">.local</span> creates <span class="text-zinc-400 font-medium">myapp.local</span>, which immediately resolves on your machine.</p>
- <div class="rounded-lg bg-surface-900/50 border border-white/[0.04] px-4 py-3 space-y-2">
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Service routing</span> &mdash; 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.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Mail integration</span> &mdash; Registered domains are automatically available for creating mailboxes. A domain like <span class="text-zinc-400">myapp.local</span> lets you create addresses such as <span class="text-zinc-400">[email protected]</span>.</p>
- </div>
- <p class="text-xs text-zinc-500 leading-relaxed mt-3">Domain names follow the same validation rules as TLDs. Each domain must be unique within its TLD &mdash; you cannot register the same name twice under the same TLD, but <span class="text-zinc-400">myapp.local</span> and <span class="text-zinc-400">myapp.dev</span> can coexist.</p>
+ <p class="text-xs text-zinc-500 leading-relaxed">Register domains under your TLDs. Each domain resolves locally and can be used for mail, service routing, and DNS records.</p>
+ </a>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
+ </svg>
+ </div>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">DNS Records</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
+ </div>
</div>
- <div class="border-t border-white/[0.04] pt-5">
- <h3 class="text-sm font-medium text-zinc-200 mb-2">How It Works</h3>
- <p class="text-xs text-zinc-500 leading-relaxed mb-3">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.</p>
- <div class="rounded-lg bg-surface-900/50 border border-white/[0.04] px-4 py-3 space-y-2">
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Configuration</span> &mdash; The DNS server listens on the host and port defined in your <span class="text-zinc-400">config.toml</span> under the <span class="text-zinc-400">[dns]</span> section. Point your machine's DNS resolver at this address to start resolving local domains.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">TTL</span> &mdash; All DNS responses use the <span class="text-zinc-400">default_ttl</span> value from your configuration. Since everything is local, a low TTL ensures changes propagate immediately.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">No system changes</span> &mdash; Dove does not modify <span class="text-zinc-400">/etc/hosts</span> or any system files. It operates entirely as a standalone DNS server that you opt into by configuring your resolver.</p>
+ <p class="text-xs text-zinc-600 leading-relaxed">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.</p>
+ </div>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Zm3.75 11.625a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
+ </svg>
+ </div>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">Query Log</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
+ </div>
+ </div>
+ <p class="text-xs text-zinc-600 leading-relaxed">Log every DNS query with timestamp, source, record type, and answer. Debug service discovery issues and see which domains your applications are resolving.</p>
+ </div>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
+ </svg>
+ </div>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">Local CA &amp; TLS</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
</div>
</div>
+ <p class="text-xs text-zinc-600 leading-relaxed">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.</p>
</div>
+
</div>
</div>
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 @@
<div class="slide-up space-y-6">
<div class="glass rounded-xl glow-border">
- <div class="px-6 py-5 border-b border-white/[0.04]">
- <div class="flex items-center gap-3 mb-4">
- <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-accent-500/10">
- <svg class="w-5 h-5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
- <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
+ <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]">
+ <h2 class="text-sm font-medium text-zinc-200">Mail</h2>
+ </div>
+ <div class="px-5 py-4">
+ <p class="text-xs text-zinc-500 leading-relaxed">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.</p>
+ </div>
+ </div>
+
+ <div class="grid grid-cols-2 gap-4">
+ {% url "mail.users" as users_path %}
+ <a href="{{ users_path }}" hx-get="{{ users_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="glass rounded-xl p-5 glow-border hover:bg-white/[0.02] transition-colors duration-150 group">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10">
+ <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
+ </svg>
+ </div>
+ <h3 class="text-sm font-medium text-zinc-200 group-hover:text-zinc-100">Users</h3>
+ </div>
+ <p class="text-xs text-zinc-500 leading-relaxed">Create and manage mail users. Each user can own multiple mailboxes across different domains and authenticate against the mail server.</p>
+ </a>
+
+ {% url "mail.mailboxes" as mailboxes_path %}
+ <a href="{{ mailboxes_path }}" hx-get="{{ mailboxes_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="glass rounded-xl p-5 glow-border hover:bg-white/[0.02] transition-colors duration-150 group">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10">
+ <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" />
+ </svg>
+ </div>
+ <h3 class="text-sm font-medium text-zinc-200 group-hover:text-zinc-100">Mailboxes</h3>
+ </div>
+ <p class="text-xs text-zinc-500 leading-relaxed">Create email addresses tied to your registered domains. Each mailbox gets its own folders, storage, and supports aliases for receiving mail at multiple addresses.</p>
+ </a>
+
+ {% url "mail.webmail" as webmail_path %}
+ <a href="{{ webmail_path }}" hx-get="{{ webmail_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="glass rounded-xl p-5 glow-border hover:bg-white/[0.02] transition-colors duration-150 group">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10">
+ <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 3.75H6.912a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859M12 3v8.25m0 0-3-3m3 3 3-3" />
+ </svg>
+ </div>
+ <h3 class="text-sm font-medium text-zinc-200 group-hover:text-zinc-100">WebMail</h3>
+ </div>
+ <p class="text-xs text-zinc-500 leading-relaxed">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.</p>
+ </a>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
+ </svg>
+ </div>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">No-Reply Addresses</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
+ </div>
+ </div>
+ <p class="text-xs text-zinc-600 leading-relaxed">Create send-only addresses with no inbox. Useful for transactional emails, notifications, and automated messages that should not receive replies.</p>
+ </div>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
+ </svg>
+ </div>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">Bounce Capture</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
+ </div>
+ </div>
+ <p class="text-xs text-zinc-600 leading-relaxed">Track and inspect bounced emails. Messages sent to non-existent mailboxes on registered domains are captured with sender, recipient, and reason for analysis.</p>
+ </div>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
</div>
- <h2 class="text-base font-medium text-zinc-100">Mail</h2>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">Spam Scoring</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
+ </div>
</div>
- <p class="text-sm text-zinc-400 leading-relaxed">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.</p>
+ <p class="text-xs text-zinc-600 leading-relaxed">Analyse incoming emails for spam indicators. Check SPF, DKIM, and DMARC alignment, score deliverability, and flag suspicious messages automatically.</p>
</div>
- <div class="px-6 py-5 space-y-6">
- <div>
- <h3 class="text-sm font-medium text-zinc-200 mb-2">Users</h3>
- <p class="text-xs text-zinc-500 leading-relaxed mb-3">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.</p>
- <div class="rounded-lg bg-surface-900/50 border border-white/[0.04] px-4 py-3 space-y-2">
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Authentication</span> &mdash; Users authenticate via SMTP to send emails and via IMAP or POP3 to retrieve them. Credentials are managed through the Dove dashboard.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Multiple mailboxes</span> &mdash; A single user can own mailboxes across different domains. For example, one user might manage both <span class="text-zinc-400">[email protected]</span> and <span class="text-zinc-400">[email protected]</span>.</p>
- </div>
- <p class="text-xs text-zinc-500 leading-relaxed mt-3">Usernames must be unique across the system. The display name is used as the sender name in email headers and can contain any characters.</p>
- </div>
- <div class="border-t border-white/[0.04] pt-5">
- <h3 class="text-sm font-medium text-zinc-200 mb-2">Mailboxes</h3>
- <p class="text-xs text-zinc-500 leading-relaxed mb-3">A mailbox is an email address tied to a user and a domain. The address is composed of a local part and a domain: <span class="text-zinc-400 font-medium">local-part</span><span class="text-zinc-500">@</span><span class="text-zinc-400 font-medium">domain.tld</span>.</p>
- <div class="rounded-lg bg-surface-900/50 border border-white/[0.04] px-4 py-3 space-y-2">
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Domain requirement</span> &mdash; Mailboxes are linked to domains managed through the Domain Manager. You must register at least one domain before creating a mailbox.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Unique addresses</span> &mdash; Each email address must be unique across the system. The same local part can exist on different domains (<span class="text-zinc-400">[email protected]</span> and <span class="text-zinc-400">[email protected]</span> are distinct mailboxes).</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Storage</span> &mdash; Each mailbox stores incoming and outgoing emails independently in Dove's local database.</p>
- </div>
- <p class="text-xs text-zinc-500 leading-relaxed mt-3">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.</p>
- </div>
- <div class="border-t border-white/[0.04] pt-5">
- <h3 class="text-sm font-medium text-zinc-200 mb-2">How It Works</h3>
- <p class="text-xs text-zinc-500 leading-relaxed mb-3">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.</p>
- <div class="rounded-lg bg-surface-900/50 border border-white/[0.04] px-4 py-3 space-y-2">
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">SMTP</span> &mdash; Handles both sending and receiving. Configure your application or mail client to use <span class="text-zinc-400">localhost</span> on the port defined in <span class="text-zinc-400">[smtp]</span> of your <span class="text-zinc-400">config.toml</span>.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">IMAP / POP3</span> &mdash; Retrieval protocols for reading mail. Connect any standard mail client (Thunderbird, Apple Mail, etc.) using the ports defined in <span class="text-zinc-400">[imap]</span> and <span class="text-zinc-400">[pop3]</span>.</p>
- <p class="text-xs text-zinc-400"><span class="text-zinc-300 font-medium">Local only</span> &mdash; 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.</p>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
+ </svg>
+ </div>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">Mail Rules</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
+ </div>
+ </div>
+ <p class="text-xs text-zinc-600 leading-relaxed">Server-side mail filters for automatic organisation. Define conditions and actions to move, tag, forward, or auto-respond to incoming messages.</p>
+ </div>
+
+ <div class="glass rounded-xl p-5 glow-border opacity-60">
+ <div class="flex items-center gap-3 mb-3">
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10">
+ <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
+ </svg>
+ </div>
+ <div class="flex items-center gap-2">
+ <h3 class="text-sm font-medium text-zinc-400">IMAP &amp; POP3</h3>
+ <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span>
</div>
</div>
+ <p class="text-xs text-zinc-600 leading-relaxed">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.</p>
</div>
+
</div>
</div>
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
+}