diff options
| -rw-r--r-- | config/config.go | 4 | ||||
| -rw-r--r-- | config/types.go | 8 | ||||
| -rw-r--r-- | config/validation.go | 87 | ||||
| -rw-r--r-- | database/database.go | 2 | ||||
| -rw-r--r-- | database/migration.go | 22 | ||||
| -rw-r--r-- | dove/main.go | 5 | ||||
| -rw-r--r-- | example.config.toml | 101 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | messages/config.go | 2 | ||||
| -rw-r--r-- | messages/database.go | 1 | ||||
| -rw-r--r-- | messages/smtp.go | 15 | ||||
| -rw-r--r-- | middleware/constants.go | 5 | ||||
| -rw-r--r-- | middleware/logging.go | 69 | ||||
| -rw-r--r-- | middleware/middleware.go | 1 | ||||
| -rw-r--r-- | models/alias.go | 10 | ||||
| -rw-r--r-- | models/attachment.go | 14 | ||||
| -rw-r--r-- | models/email.go | 25 | ||||
| -rw-r--r-- | models/mailbox.go | 12 | ||||
| -rw-r--r-- | models/tag.go | 9 | ||||
| -rw-r--r-- | models/user.go | 10 | ||||
| -rw-r--r-- | utils/meta/request.go | 40 | ||||
| -rw-r--r-- | utils/meta/types.go | 3 | ||||
| -rw-r--r-- | utils/meta/value.go | 23 | ||||
| -rw-r--r-- | utils/smtp/constants.go | 5 | ||||
| -rw-r--r-- | utils/smtp/server.go | 56 | ||||
| -rw-r--r-- | utils/smtp/session.go | 61 | ||||
| -rw-r--r-- | utils/smtp/storage.go | 22 | ||||
| -rw-r--r-- | utils/smtp/types.go | 13 |
29 files changed, 534 insertions, 97 deletions
diff --git a/config/config.go b/config/config.go index 8a481c7..7465da7 100644 --- a/config/config.go +++ b/config/config.go @@ -42,5 +42,9 @@ func init() { AuthEnabled = isAuthEnabled() + if portError := ValidatePorts(); portError != nil { + logger.Fatalf(LOG_PREFIX, messages.ConfigPortValidFailed, portError) + } + logger.Successf(LOG_PREFIX, messages.ConfigLoaded) } diff --git a/config/types.go b/config/types.go index ab5a569..33d37f7 100644 --- a/config/types.go +++ b/config/types.go @@ -15,7 +15,10 @@ type smtp struct { Port int `toml:"port" default:"5025"` SMTPSPort int `toml:"smtps_port" default:"5465"` StartTLSPort int `toml:"starttls_port" default:"5587"` + Domain string `toml:"domain" default:"localhost"` MaxMessageSize int `toml:"max_message_size" default:"26214400"` + ReadTimeout int `toml:"read_timeout" default:"30"` + WriteTimeout int `toml:"write_timeout" default:"30"` AuthRequired bool `toml:"auth_required" default:"false"` Username string `toml:"username"` Password string `toml:"password"` @@ -58,3 +61,8 @@ type mailbox struct { Mode enums.MailboxMode `toml:"mode" default:"registered"` } +type portAssignment struct { + service string + host string + port int +} diff --git a/config/validation.go b/config/validation.go new file mode 100644 index 0000000..46b85ac --- /dev/null +++ b/config/validation.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + + "dove/messages" + "dove/utils/errors" +) + +func ValidatePorts() error { + portAssignments := collectPortAssignments() + occupiedPorts := make(map[string]string) + + for _, assignment := range portAssignments { + portKey := fmt.Sprintf("%s:%d", assignment.host, assignment.port) + + if existingService, occupied := occupiedPorts[portKey]; occupied { + return errors.Error(messages.ConfigPortCollision, assignment.service, existingService, portKey) + } + + occupiedPorts[portKey] = assignment.service + } + + return nil +} + +func collectPortAssignments() []portAssignment { + assignments := []portAssignment{ + { + service: "HTTP", + host: Server.Host, + port: Server.Port, + }, + { + service: "SMTP", + host: SMTP.Host, + port: SMTP.Port, + }, + { + service: "IMAP", + host: IMAP.Host, + port: IMAP.Port, + }, + { + service: "POP3", + host: POP3.Host, + port: POP3.Port, + }, + } + + if SMTP.TLSEnabled { + assignments = append(assignments, + portAssignment{ + service: "SMTPS", + host: SMTP.Host, + port: SMTP.SMTPSPort, + }, + portAssignment{ + service: "SMTP STARTTLS", + host: SMTP.Host, + port: SMTP.StartTLSPort, + }, + ) + } + + if IMAP.TLSEnabled { + assignments = append(assignments, + portAssignment{ + service: "IMAPS", + host: IMAP.Host, + port: IMAP.IMAPSPort, + }, + ) + } + + if POP3.TLSEnabled { + assignments = append(assignments, + portAssignment{ + service: "POP3S", + host: POP3.Host, + port: POP3.POP3SPort, + }, + ) + } + + return assignments +} diff --git a/database/database.go b/database/database.go index 27b99eb..ac06c98 100644 --- a/database/database.go +++ b/database/database.go @@ -33,4 +33,6 @@ func init() { sqlDB.SetConnMaxLifetime(MAX_CONNECTION_LIFETIME) logger.Successf(LOG_PREFIX, messages.DatabaseConnected, databaseDSN) + + migrate() } diff --git a/database/migration.go b/database/migration.go new file mode 100644 index 0000000..4d8363c --- /dev/null +++ b/database/migration.go @@ -0,0 +1,22 @@ +package database + +import ( + "dove/messages" + "dove/models" + "dove/utils/logger" +) + +func migrate() { + migrationError := DB.AutoMigrate( + &models.User{}, + &models.Mailbox{}, + &models.Alias{}, + &models.Email{}, + &models.Tag{}, + &models.Attachment{}, + ) + + if migrationError != nil { + logger.Fatalf(LOG_PREFIX, messages.DatabaseMigrationFailed, migrationError) + } +} diff --git a/dove/main.go b/dove/main.go index e3da2ce..4de7ef8 100644 --- a/dove/main.go +++ b/dove/main.go @@ -12,6 +12,7 @@ import ( "dove/router" "dove/tags" "dove/utils/logger" + "dove/utils/smtp" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/recover" @@ -50,6 +51,8 @@ func serve(command *cobra.Command, arguments []string) error { middleware.Initialize(application) router.Initialize(application) + smtp.Start() + shutdownSignal := make(chan os.Signal, 1) signal.Notify(shutdownSignal, syscall.SIGINT, syscall.SIGTERM) @@ -65,6 +68,8 @@ func serve(command *cobra.Command, arguments []string) error { <-shutdownSignal logger.Infof(LOG_PREFIX, messages.ServerShuttingDown) + smtp.Shutdown() + if shutdownError := application.Shutdown(); shutdownError != nil { logger.Errorf(LOG_PREFIX, messages.ServerShutdownFailed, shutdownError) } diff --git a/example.config.toml b/example.config.toml index 8748a3b..be3cdaf 100644 --- a/example.config.toml +++ b/example.config.toml @@ -1,22 +1,77 @@ +# ============================================================================= +# Dove - Local SMTP Email Testing Tool +# ============================================================================= +# Copy this file to config.toml and adjust values as needed. +# All values shown below are defaults and can be omitted if unchanged. + +# ----------------------------------------------------------------------------- +# HTTP Server +# ----------------------------------------------------------------------------- +# The web dashboard for viewing and managing captured emails. + [server] +# Network interface to bind the HTTP server to. +# Use "0.0.0.0" to listen on all interfaces, or "127.0.0.1" for local only. host = "0.0.0.0" + +# Port for the web dashboard. port = 8080 + +# Enable verbose debug logging for HTTP requests. debug = false + +# Optional credentials to protect the web dashboard. +# Leave commented out to disable dashboard authentication. # username = "" # password = "" +# ----------------------------------------------------------------------------- +# SMTP Server +# ----------------------------------------------------------------------------- +# Receives incoming emails from mail clients and applications. + [smtp] +# Network interface to bind the SMTP server to. host = "0.0.0.0" + +# Port for plain SMTP connections. port = 5025 + +# Port for implicit TLS (SMTPS) connections. Requires tls_enabled = true. smtps_port = 5465 + +# Port for STARTTLS connections. Requires tls_enabled = true. starttls_port = 5587 + +# The domain name announced in SMTP EHLO/HELO greetings. +# This identifies the mail server to connecting clients. +domain = "localhost" + +# Maximum allowed email size in bytes (default: 25 MB). max_message_size = 26214400 + +# Timeout in seconds for reading data from SMTP clients. +read_timeout = 30 + +# Timeout in seconds for writing data to SMTP clients. +write_timeout = 30 + +# Require SMTP authentication before accepting messages. auth_required = false + +# Credentials for SMTP authentication. Only used when auth_required = true. # username = "" # password = "" + +# Enable TLS support for SMTPS and STARTTLS listeners. tls_enabled = false + +# Paths to TLS certificate and private key files. Required when tls_enabled = true. # tls_cert = "" # tls_key = "" + +# Relay captured emails to an upstream SMTP server. +# Useful for forwarding test emails to a real mail server. relay_enabled = false # relay_host = "" relay_port = 587 @@ -24,27 +79,71 @@ relay_port = 587 # relay_password = "" relay_starttls = true +# ----------------------------------------------------------------------------- +# IMAP Server +# ----------------------------------------------------------------------------- +# Allows mail clients to retrieve captured emails via the IMAP protocol. + [imap] +# Network interface to bind the IMAP server to. host = "0.0.0.0" + +# Port for plain IMAP connections. port = 5143 + +# Port for implicit TLS (IMAPS) connections. Requires tls_enabled = true. imaps_port = 5993 + +# Require IMAP authentication before granting access. auth_required = false + +# Credentials for IMAP authentication. Only used when auth_required = true. # username = "" # password = "" + +# Enable TLS support for the IMAPS listener. tls_enabled = false + +# Paths to TLS certificate and private key files. Required when tls_enabled = true. # tls_cert = "" # tls_key = "" +# ----------------------------------------------------------------------------- +# POP3 Server +# ----------------------------------------------------------------------------- +# Allows mail clients to retrieve captured emails via the POP3 protocol. + [pop3] +# Network interface to bind the POP3 server to. host = "0.0.0.0" + +# Port for plain POP3 connections. port = 5110 + +# Port for implicit TLS (POP3S) connections. Requires tls_enabled = true. pop3s_port = 5995 + +# Require POP3 authentication before granting access. auth_required = false + +# Credentials for POP3 authentication. Only used when auth_required = true. # username = "" # password = "" + +# Enable TLS support for the POP3S listener. tls_enabled = false + +# Paths to TLS certificate and private key files. Required when tls_enabled = true. # tls_cert = "" # tls_key = "" +# ----------------------------------------------------------------------------- +# Mailbox +# ----------------------------------------------------------------------------- +# Controls how incoming emails are routed to mailboxes. + [mailbox] -mode = "registered"
\ No newline at end of file +# Mailbox routing mode: +# "registered" - Only accept emails for registered mailbox addresses. +# "open" - Automatically create mailboxes for any recipient address. +mode = "registered" @@ -14,6 +14,8 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emersion/go-smtp v0.24.0 // indirect github.com/flosch/pongo2/v6 v6.0.0 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect @@ -3,6 +3,10 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= diff --git a/messages/config.go b/messages/config.go index 10c85db..43b40a6 100644 --- a/messages/config.go +++ b/messages/config.go @@ -6,6 +6,8 @@ const ( ConfigFileLoadFailed = "Failed to load config: %v" ConfigFileReadFailed = "Failed to read config file %s: %s." ConfigLoaded = "Configuration loaded successfully." + ConfigPortCollision = "Port collision: %s and %s both configured on %s." + ConfigPortValidFailed = "Port validation failed: %v" ConfigSectionInvalid = "Config section '%s' has invalid data." ConfigSectionParseFailed = "Failed to parse config section: %v" ) diff --git a/messages/database.go b/messages/database.go index 57bc27f..36ba275 100644 --- a/messages/database.go +++ b/messages/database.go @@ -3,5 +3,6 @@ package messages const ( DatabaseConnected = "Connected to %s." DatabaseConnectionFailed = "Failed to connect to database: %v" + DatabaseMigrationFailed = "Failed to run database migrations: %v" DatabasePoolConfigFailed = "Failed to configure connection pool: %v" ) diff --git a/messages/smtp.go b/messages/smtp.go new file mode 100644 index 0000000..eef071c --- /dev/null +++ b/messages/smtp.go @@ -0,0 +1,15 @@ +package messages + +const ( + SMTPListenFailed = "Failed to start %s listener: %v" + SMTPSessionStarted = "New session from %s." + SMTPMailFrom = "Mail from: %s" + SMTPRecipient = "Recipient: %s" + SMTPMessageReceived = "Message received (%d bytes)." + SMTPMessageStoreFailed = "Failed to store message: %v" + SMTPServerStarting = "%s listener started on %s." + SMTPShutdownFailed = "Failed to shutdown %s listener: %v" + SMTPShutdownComplete = "All listeners stopped." + SMTPAuthFailed = "Authentication failed for user: %s" + SMTPInvalidCredentials = "Invalid credentials." +) diff --git a/middleware/constants.go b/middleware/constants.go deleted file mode 100644 index dc6505b..0000000 --- a/middleware/constants.go +++ /dev/null @@ -1,5 +0,0 @@ -package middleware - -const ( - LOG_PREFIX = "HTTP" -) diff --git a/middleware/logging.go b/middleware/logging.go deleted file mode 100644 index a8c3bd6..0000000 --- a/middleware/logging.go +++ /dev/null @@ -1,69 +0,0 @@ -package middleware - -import ( - "fmt" - "strconv" - "strings" - "time" - - "dove/utils/logger" - - "github.com/gofiber/fiber/v2" -) - -func httpLogger(context *fiber.Ctx) error { - startTime := time.Now() - - responseError := context.Next() - - duration := time.Since(startTime) - statusCode := context.Response().StatusCode() - method := context.Method() - path := context.Path() - ipAddress := context.IP() - - paddedMethod := method - if len(method) < 7 { - paddedMethod = method + strings.Repeat(" ", 7-len(method)) - } - - message := fmt.Sprintf( - "%s %-3d %-15s %-10s %s", - paddedMethod, statusCode, "IP: "+ipAddress, "TTR: "+formatDuration(duration), "Path: "+path, - ) - - logByStatus(statusCode, LOG_PREFIX, message) - - return responseError -} - -func logByStatus(statusCode int, prefix string, message string) { - switch { - case statusCode >= fiber.StatusInternalServerError: - logger.Errorf(prefix, "%s", message) - case statusCode >= fiber.StatusBadRequest: - logger.Warnf(prefix, "%s", message) - case statusCode >= fiber.StatusMultipleChoices: - logger.Infof(prefix, "%s", message) - case statusCode >= fiber.StatusOK: - logger.Successf(prefix, "%s", message) - default: - logger.Infof(prefix, "%s", message) - } -} - -func formatDuration(duration time.Duration) string { - if duration < time.Microsecond { - return strconv.FormatInt(duration.Nanoseconds(), 10) + "ns" - } - - if duration < time.Millisecond { - return strconv.FormatInt(duration.Nanoseconds()/1_000, 10) + "µs" - } - - if duration < time.Second { - return strconv.FormatFloat(float64(duration.Nanoseconds())/float64(time.Millisecond), 'f', 3, 64) + "ms" - } - - return strconv.FormatFloat(float64(duration.Nanoseconds())/float64(time.Second), 'f', 3, 64) + "s" -} diff --git a/middleware/middleware.go b/middleware/middleware.go index 1a4f1ed..1e73145 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -3,7 +3,6 @@ package middleware import "github.com/gofiber/fiber/v2" func Initialize(application *fiber.App) { - application.Use(httpLogger) application.Use(requestBuilder) application.Use(globals) } diff --git a/models/alias.go b/models/alias.go new file mode 100644 index 0000000..5b0dcb3 --- /dev/null +++ b/models/alias.go @@ -0,0 +1,10 @@ +package models + +import "gorm.io/gorm" + +type Alias struct { + gorm.Model + SourceAddress string `gorm:"uniqueIndex;not null" json:"source_address"` + MailboxID uint `gorm:"not null" json:"mailbox_id"` + Mailbox Mailbox `gorm:"foreignKey:MailboxID" json:"mailbox"` +} diff --git a/models/attachment.go b/models/attachment.go new file mode 100644 index 0000000..cd1a741 --- /dev/null +++ b/models/attachment.go @@ -0,0 +1,14 @@ +package models + +import "gorm.io/gorm" + +type Attachment struct { + gorm.Model + EmailID uint `gorm:"not null;index" json:"email_id"` + Email Email `gorm:"foreignKey:EmailID" json:"email"` + Filename string `gorm:"not null" json:"filename"` + ContentType string `gorm:"not null" json:"content_type"` + ContentID string `json:"content_id"` + Size int64 `json:"size"` + IsInline bool `gorm:"default:false" json:"is_inline"` +} diff --git a/models/email.go b/models/email.go new file mode 100644 index 0000000..12b3ad1 --- /dev/null +++ b/models/email.go @@ -0,0 +1,25 @@ +package models + +import "gorm.io/gorm" + +type Email struct { + gorm.Model + MailboxID uint `gorm:"not null;index" json:"mailbox_id"` + Mailbox Mailbox `gorm:"foreignKey:MailboxID" json:"mailbox"` + MessageID string `gorm:"index" json:"message_id"` + Filename string `gorm:"uniqueIndex;not null" json:"filename"` + FromAddress string `gorm:"not null" json:"from_address"` + FromName string `json:"from_name"` + ToAddresses string `gorm:"not null" json:"to_addresses"` + CcAddresses string `json:"cc_addresses"` + BccAddresses string `json:"bcc_addresses"` + ReplyToAddress string `json:"reply_to_address"` + ReturnPath string `json:"return_path"` + Subject string `json:"subject"` + Snippet string `json:"snippet"` + Size int64 `json:"size"` + IsRead bool `gorm:"default:false;index" json:"is_read"` + AttachmentCount int `gorm:"default:0" json:"attachment_count"` + InlineCount int `gorm:"default:0" json:"inline_count"` + Tags []Tag `gorm:"many2many:email_tags" json:"tags"` +} diff --git a/models/mailbox.go b/models/mailbox.go new file mode 100644 index 0000000..da4114f --- /dev/null +++ b/models/mailbox.go @@ -0,0 +1,12 @@ +package models + +import "gorm.io/gorm" + +type Mailbox struct { + gorm.Model + Address string `gorm:"uniqueIndex;not null" json:"address"` + UserID uint `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + Aliases []Alias `gorm:"foreignKey:MailboxID" json:"aliases"` + Emails []Email `gorm:"foreignKey:MailboxID" json:"emails"` +} diff --git a/models/tag.go b/models/tag.go new file mode 100644 index 0000000..79fa65f --- /dev/null +++ b/models/tag.go @@ -0,0 +1,9 @@ +package models + +import "gorm.io/gorm" + +type Tag struct { + gorm.Model + Name string `gorm:"uniqueIndex;not null" json:"name"` + Emails []Email `gorm:"many2many:email_tags" json:"emails"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..6bb6edf --- /dev/null +++ b/models/user.go @@ -0,0 +1,10 @@ +package models + +import "gorm.io/gorm" + +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;not null" json:"username"` + DisplayName string `json:"display_name"` + Mailboxes []Mailbox `gorm:"foreignKey:UserID" json:"mailboxes"` +} diff --git a/utils/meta/request.go b/utils/meta/request.go index 3098970..34a659a 100644 --- a/utils/meta/request.go +++ b/utils/meta/request.go @@ -8,36 +8,56 @@ import ( "github.com/gofiber/fiber/v2" ) -func Request(context *fiber.Ctx) request { +func Request(context *fiber.Ctx) *request { data, ok := context.Locals(REQUEST_KEY).(types.Request) if !ok { logger.Errorf(LOG_PREFIX, messages.MetaRequestContextMissing) - return request{} + return nil } - return request{ + return &request{ Request: data, context: context, } } -func (self request) Param(key string) value { +func (self *request) Param(key string) *value { + if self == nil { + return nil + } + if self.context != nil { result := self.context.Params(key) if result != "" { - return value{data: result, found: true} + return &value{data: result} } } - return value{} + return nil } -func (self request) Query(key string) value { +func (self *request) Query(key string) *value { + if self == nil { + return nil + } + result, found := findParam(self.Request.Query, key) - return value{data: result, found: found} + if found { + return &value{data: result} + } + + return nil } -func (self request) Header(key string) value { +func (self *request) Header(key string) *value { + if self == nil { + return nil + } + result, found := findParam(self.Request.Headers, key) - return value{data: result, found: found} + if found { + return &value{data: result} + } + + return nil } diff --git a/utils/meta/types.go b/utils/meta/types.go index 8aa710f..3bc8a0e 100644 --- a/utils/meta/types.go +++ b/utils/meta/types.go @@ -12,6 +12,5 @@ type request struct { } type value struct { - data string - found bool + data string } diff --git a/utils/meta/value.go b/utils/meta/value.go index fb90500..889ba0e 100644 --- a/utils/meta/value.go +++ b/utils/meta/value.go @@ -5,25 +5,30 @@ import ( "dove/utils/logger" ) -func (self value) String() string { +func (self *value) String() string { + if self == nil { + return "" + } + return self.data } -func (self value) Exists() bool { - return self.found +func (self *value) Exists() bool { + return self != nil } -func (self value) Or(fallback string) string { - if self.found { - return self.data +func (self *value) Or(fallback string) string { + if self == nil { + return fallback } - return fallback + return self.data } -func (self value) Required() string { - if !self.found { +func (self *value) Required() string { + if self == nil { logger.Errorf(LOG_PREFIX, messages.MetaRequiredValueMissing) + return "" } return self.data diff --git a/utils/smtp/constants.go b/utils/smtp/constants.go new file mode 100644 index 0000000..cc6d173 --- /dev/null +++ b/utils/smtp/constants.go @@ -0,0 +1,5 @@ +package smtp + +const ( + LOG_PREFIX = "SMTP" +) diff --git a/utils/smtp/server.go b/utils/smtp/server.go new file mode 100644 index 0000000..0092c16 --- /dev/null +++ b/utils/smtp/server.go @@ -0,0 +1,56 @@ +package smtp + +import ( + "dove/config" + "dove/messages" + "dove/utils/logger" + "fmt" + "time" + + gosmtp "github.com/emersion/go-smtp" +) + +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: LOG_PREFIX}) + + go startListener(plainServer, LOG_PREFIX, plainAddress) +} + +func Shutdown() { + for _, instance := range activeServers { + if shutdownError := instance.server.Close(); shutdownError != nil { + logger.Errorf(LOG_PREFIX, messages.SMTPShutdownFailed, instance.label, shutdownError) + } + } + + logger.Infof(LOG_PREFIX, messages.SMTPShutdownComplete) +} + +func createServer(address string) *gosmtp.Server { + smtpServer := gosmtp.NewServer(gosmtp.BackendFunc(func(connection *gosmtp.Conn) (gosmtp.Session, error) { + logger.Debugf(LOG_PREFIX, messages.SMTPSessionStarted, connection.Hostname()) + return &session{}, nil + })) + + smtpServer.Addr = address + smtpServer.Domain = config.SMTP.Domain + smtpServer.ReadTimeout = time.Duration(config.SMTP.ReadTimeout) * time.Second + smtpServer.WriteTimeout = time.Duration(config.SMTP.WriteTimeout) * time.Second + smtpServer.MaxMessageBytes = int64(config.SMTP.MaxMessageSize) + smtpServer.AllowInsecureAuth = true + + return smtpServer +} + +func startListener(smtpServer *gosmtp.Server, label string, address string) { + logger.Successf(LOG_PREFIX, messages.SMTPServerStarting, label, address) + + if listenError := smtpServer.ListenAndServe(); listenError != nil { + logger.Fatalf(LOG_PREFIX, messages.SMTPListenFailed, label, listenError) + } +} diff --git a/utils/smtp/session.go b/utils/smtp/session.go new file mode 100644 index 0000000..029c257 --- /dev/null +++ b/utils/smtp/session.go @@ -0,0 +1,61 @@ +package smtp + +import ( + "dove/config" + "dove/messages" + "dove/utils/logger" + "dove/utils/errors" + "io" + + gosmtp "github.com/emersion/go-smtp" +) + +func (self *session) AuthPlain(username string, password string) error { + if !config.SMTP.AuthRequired { + return nil + } + + if username != config.SMTP.Username || password != config.SMTP.Password { + logger.Warnf(LOG_PREFIX, messages.SMTPAuthFailed, username) + return errors.Error(messages.SMTPInvalidCredentials) + } + + return nil +} + +func (self *session) Mail(senderAddress string, mailOptions *gosmtp.MailOptions) error { + logger.Debugf(LOG_PREFIX, messages.SMTPMailFrom, senderAddress) + self.fromAddress = senderAddress + return nil +} + +func (self *session) Rcpt(recipientAddress string, recipientOptions *gosmtp.RcptOptions) error { + logger.Debugf(LOG_PREFIX, messages.SMTPRecipient, recipientAddress) + self.toAddresses = append(self.toAddresses, recipientAddress) + return nil +} + +func (self *session) Data(messageReader io.Reader) error { + rawMessage, readError := io.ReadAll(messageReader) + if readError != nil { + return readError + } + + logger.Infof(LOG_PREFIX, messages.SMTPMessageReceived, len(rawMessage)) + + if storeError := storeMessage(self.fromAddress, self.toAddresses, rawMessage); storeError != nil { + logger.Errorf(LOG_PREFIX, messages.SMTPMessageStoreFailed, storeError) + return storeError + } + + return nil +} + +func (self *session) Reset() { + self.fromAddress = "" + self.toAddresses = nil +} + +func (self *session) Logout() error { + return nil +} diff --git a/utils/smtp/storage.go b/utils/smtp/storage.go new file mode 100644 index 0000000..b580d67 --- /dev/null +++ b/utils/smtp/storage.go @@ -0,0 +1,22 @@ +package smtp + +import ( + "dove/config" + "fmt" + "os" + "path/filepath" + "time" +) + +func storeMessage(fromAddress string, toAddresses []string, rawMessage []byte) error { + emailDirectory := filepath.Join(config.DataDir, "emails") + + if directoryError := os.MkdirAll(emailDirectory, 0750); directoryError != nil { + return directoryError + } + + filename := fmt.Sprintf("%d.eml", time.Now().UnixNano()) + filePath := filepath.Join(emailDirectory, filename) + + return os.WriteFile(filePath, rawMessage, 0640) +}
\ No newline at end of file diff --git a/utils/smtp/types.go b/utils/smtp/types.go new file mode 100644 index 0000000..38c6504 --- /dev/null +++ b/utils/smtp/types.go @@ -0,0 +1,13 @@ +package smtp + +import gosmtp "github.com/emersion/go-smtp" + +type session struct { + fromAddress string + toAddresses []string +} + +type serverInstance struct { + server *gosmtp.Server + label string +} |
