Skip to content

Commit

Permalink
feat: add basic smtpd
Browse files Browse the repository at this point in the history
Signed-off-by: Barry Simons <linuxuser586@gmail.com>
  • Loading branch information
linuxuser586 committed Jun 9, 2024
1 parent 1c17670 commit 0a4f32b
Show file tree
Hide file tree
Showing 6 changed files with 362 additions and 5 deletions.
89 changes: 89 additions & 0 deletions cmd/smtpd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2024 The OpenMail Authors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"log"
"os"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"

"github.com/linuxuser586/openmail/logger"
"github.com/linuxuser586/openmail/loggerzap"
"github.com/linuxuser586/openmail/smtpd"
)

const (
defaultTimeout = 5 * 60
name = "smtpd"
)

func main() {
s := smtpd.New(conf{}, tel{}, repo{})
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
os.Exit(1)
}
}

// TODO create configuration
type conf struct{}

func (conf) Network() smtpd.Network {
return smtpd.Tcp
}

func (conf) Host() string {
return "localhost"
}

func (conf) Port() int {
return 2525
}

func (conf) InitialTimeout() int {
return defaultTimeout
}

func (conf) MailCmdTimeout() int {
return defaultTimeout
}

func (conf) RecipientCmdTimeout() int {
return defaultTimeout
}

func (conf) DataInitTimeout() int {
return defaultTimeout
}

func (conf) DataBlockTimeout() int {
return defaultTimeout
}

func (conf) DataTerminationTimeout() int {
return defaultTimeout
}

func (conf) HostName() string {
return "smtp.example.com"
}

type tel struct{}

func (tel) Tracer() trace.Tracer {
return otel.Tracer(name)
}

func (tel) Meter() metric.Meter {
return otel.Meter(name)
}

func (tel) Logger() logger.Logger {
return loggerzap.New(loggerzap.NewDevConfig())
}

type repo struct{}
13 changes: 11 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ module github.com/linuxuser586/openmail

go 1.22.2

require go.uber.org/zap v1.27.0
require (
go.opentelemetry.io/otel v1.27.0
go.opentelemetry.io/otel/metric v1.27.0
go.opentelemetry.io/otel/trace v1.27.0
go.uber.org/zap v1.27.0
)

require go.uber.org/multierr v1.10.0 // indirect
require (
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
go.uber.org/multierr v1.10.0 // indirect
)
17 changes: 15 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
Expand Down
2 changes: 1 addition & 1 deletion loggerzap/loggerzap.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func (c Config) GetLevel() logger.Level {
func NewDevConfig() Config {
return Config{
Env: logger.Development,
Level: logger.Info,
Level: logger.Debug,
Encoding: Console,
MessageKey: "msg",
}
Expand Down
228 changes: 228 additions & 0 deletions smtpd/smtpd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Copyright 2024 The OpenMail Authors
// SPDX-License-Identifier: Apache-2.0

package smtpd

import (
"errors"
"fmt"
"net"
"net/textproto"
"strings"
"time"

"github.com/linuxuser586/openmail/telemetry"
)

var ErrUnknownCommand = errors.New("unknown command")

// Network listener enum
type Network string

const (
Tcp Network = "tcp"
Tcp4 Network = "tcp4"
Tcp6 Network = "tcp6"
Unix Network = "unix"
Unixpacket Network = "unixpacket"
)

// Command enum
type Command string

const (
Unknown Command = ""
ExtendedHello Command = "EHLO"
Hello Command = "HELO"
Mail Command = "MAIL"
Recipient Command = "RCPT"
Data Command = "DATA"
Reset Command = "RSET"
NoOperation Command = "NOOP"
Quit Command = "QUIT"
Verify Command = "VRFY"
)

func (c Command) String() string {
return string(c)
}

// ToCommand converts a string to a command enum
func ToCommand(s string) (Command, error) {
// TODO finish implementation
switch strings.ToUpper(s) {
case ExtendedHello.String():
return ExtendedHello, nil
case Quit.String():
return Quit, nil
default:
return Unknown, ErrUnknownCommand
}
}

// Config contains read only configurations for smtpd
type Config interface {
// Network type
Network() Network
// Host to listen on
Host() string
// Port to listen on
Port() int
// InitialTimeout in seconds
InitialTimeout() int
// MailCmdTimeout in seconds
MailCmdTimeout() int
// RecipientCmdTimeout in seconds
RecipientCmdTimeout() int
// DataInitTimeout in seconds
DataInitTimeout() int
// DataBlockTimeout in seconds
DataBlockTimeout() int
// DataTerminationTimeout in seconds
DataTerminationTimeout() int
// HostName for this instance
HostName() string
}

// Repository manages persistence
type Repository interface {
}

// TextConnection works with [net/textproto]
type TextConnection struct {
conn net.Conn
}

func (t *TextConnection) Read(p []byte) (n int, err error) {
return t.conn.Read(p)
}

func (t *TextConnection) Write(p []byte) (n int, err error) {
return t.conn.Write(p)
}

func (t *TextConnection) Close() error {
return t.conn.Close()
}

type Smtpd struct {
config Config
telemetry telemetry.Telemetry
repository Repository
}

func New(config Config, telemetry telemetry.Telemetry, repository Repository) Smtpd {
return Smtpd{config: config, telemetry: telemetry, repository: repository}
}

func (s *Smtpd) ListenAndServe() error {
log := s.telemetry.Logger()
listen, err := net.Listen(string(s.config.Network()), fmt.Sprintf("%s:%d", s.config.Host(), s.config.Port()))
if err != nil {
return err
}
defer listen.Close()
log.Infof("smtpd started on %s", listen.Addr().String())

return s.serve(listen)
}

func (s *Smtpd) serve(listen net.Listener) error {
for {
conn, err := listen.Accept()
if err != nil {
return err
}
config := s.config
telemetry := s.telemetry
repository := s.repository
go handleConn(config, telemetry, repository, conn)
}
}

func handleConn(
config Config,
telemetry telemetry.Telemetry,
repository Repository,
conn net.Conn) {
textConn := TextConnection{conn: conn}
h := handler{
config: config,
telemetry: telemetry,
repository: repository,
textConn: textConn,
protoConn: textproto.NewConn(&textConn),
}
h.handleConn()
}

type handler struct {
config Config
telemetry telemetry.Telemetry
repository Repository
textConn TextConnection
protoConn *textproto.Conn
}

func (h *handler) handleConn() {
tp := textproto.NewConn(&h.textConn)
conn := h.textConn.conn
log := h.telemetry.Logger()
log.Infof("request client address %s", conn.RemoteAddr().String())
tp.PrintfLine(fmt.Sprintf("220 %s ESMTP OpenMail", h.config.HostName()))
conn.SetDeadline(time.Now().Add(time.Duration(h.config.InitialTimeout()) * time.Second))
for {
line, err := tp.ReadLine()
if err != nil {
if h.handleReadTimeout(err, tp) {
return
}
log.Error(err.Error())
// TODO respond to client with appropriate error message
continue
}

log.Debug(line)

cmd, err := ToCommand(line)
if err != nil {
tp.PrintfLine("500 5.5.2 Error: command not recognized")
continue
}
if err = h.handleCommand(cmd); err != nil {
// TODO use custom error
tp.PrintfLine(err.Error())
}
}
}

func (h handler) handleReadTimeout(err error, tp *textproto.Conn) bool {
if netErr := err.(*net.OpError); netErr != nil {
if netErr.Timeout() {
conn := h.textConn.conn
// reset the deadline so that the message can be sent to the client
conn.SetDeadline(time.Now().Add(5 * time.Second))
tp.PrintfLine("421 connection timeout")
if err := h.textConn.Close(); err != nil {
h.telemetry.Logger().Error(err.Error())
}
return true
}
}
return false
}

func (h handler) handleCommand(cmd Command) error {
log := h.telemetry.Logger()
switch cmd {
case Quit:
if err := h.textConn.Close(); err != nil {
log.Error(err.Error())
}
return nil
default:
log.Infof("TODO: %s not implemented", cmd.String())
}

return nil
}
18 changes: 18 additions & 0 deletions telemetry/telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright 2024 The OpenMail Authors
// SPDX-License-Identifier: Apache-2.0

// package telemetry provides traces, metrics, and logs
package telemetry

import (
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"

"github.com/linuxuser586/openmail/logger"
)

type Telemetry interface {
Tracer() trace.Tracer
Meter() metric.Meter
Logger() logger.Logger
}

0 comments on commit 0a4f32b

Please sign in to comment.