list

server

This is the main dovel repository, it has the Go code to run dovel SMTP server.

curl -O https://dovel.email/server.tar tar

4ff3b75

Author: brian (git@myr.sh)

Date: Sun Oct 8 18:28:50 2023 -0300

Parent: edad56d

Moved files up

Diff

backend.go

diff --git a/backend.go b/backend.go
new file mode 100644
index 0000000..0cb61d7
--- /dev/null
+++ b/backend.go
@@ -0,0 +1,263 @@
+package main
+
+import (
+	"bytes"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"net/mail"
+	"os"
+	"os/exec"
+	"path"
+	"strings"
+	"time"
+
+	"github.com/emersion/go-mbox"
+	"github.com/emersion/go-msgauth/dkim"
+	"github.com/emersion/go-openpgp-wkd"
+	"github.com/emersion/go-smtp"
+	"golang.org/x/crypto/openpgp"
+)
+
+// A Session is returned after EHLO.
+type Session struct {
+	user string
+	from string
+	tos  []string
+}
+
+func (s *Session) AuthPlain(username, password string) error {
+	slog.Debug("authenticating", "user", username, "pass", password)
+	if !v.Validate(username, password) {
+		slog.Warn("unauthorized", "user", username)
+		return fmt.Errorf("user not authorized")
+	}
+	slog.Debug("authorized", "user", username)
+	s.user = username
+	return nil
+}
+
+func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
+	slog.Debug("received mail", "from", from)
+	s.from = from
+	return nil
+}
+
+func (s *Session) Rcpt(to string) error {
+	slog.Debug("received rcpt to", "to", to)
+	s.tos = append(s.tos, to)
+	return nil
+}
+
+func (s *Session) Data(raw io.Reader) error {
+	cont, err := io.ReadAll(raw)
+	if err != nil {
+		return err
+	}
+	slog.Debug("received data", "data", cont)
+
+	w := bytes.Buffer{}
+	box := mbox.NewWriter(&w)
+	boxW, err := box.CreateMessage(s.from, time.Now())
+	if err != nil {
+		return err
+	}
+	boxW.Write(cont)
+	box.Close()
+
+	mess, err := io.ReadAll(&w)
+	if err != nil {
+		return err
+	}
+
+	tos, err := mail.ParseAddressList(strings.Join(s.tos, ","))
+	if err != nil {
+		return err
+	}
+
+	// sending email
+	dom := strings.Split(s.from, "@")[1]
+	if dom == cfg.Domain {
+		// check auth
+		if s.user == "" {
+			return smtp.ErrAuthRequired
+		}
+		err = s.Send(s.from, tos, strings.NewReader(string(cont)))
+		if err != nil {
+			slog.Error("send error", "msg", err.Error())
+			return err
+		}
+
+		// running handlers is optional
+		h := path.Join(configPath, "hooks", "send-"+cfg.Domain)
+		if f, err := os.Lstat(h); err == nil {
+			if !f.Mode().IsRegular() {
+				h, err = os.Readlink(h)
+				if err != nil {
+					slog.Error(cfg.Domain, "read link", err.Error())
+				}
+			}
+
+			c := exec.Command(h)
+			c.Stdin = strings.NewReader(string(mess))
+			c.Stdout = os.Stdout
+			if err := c.Run(); err != nil {
+				slog.Error("run script", err.Error())
+				return err
+			}
+		}
+	}
+
+	// receiving email
+	for _, to := range tos {
+		domain := strings.Split(to.Address, "@")[1]
+
+		h := path.Join(configPath, "hooks", "receive-"+domain)
+		if f, err := os.Lstat(h); err != nil {
+			slog.Error(domain, "receive error:", err.Error())
+			continue
+		} else if !f.Mode().IsRegular() {
+			h, err = os.Readlink(h)
+			if err != nil {
+				slog.Error(domain, "read link", err.Error())
+			}
+		}
+
+		c := exec.Command(h)
+		c.Stdin = strings.NewReader(string(mess))
+		c.Stdout = os.Stdout
+		if err := c.Run(); err != nil {
+			slog.Error("run script", err.Error())
+			continue
+		}
+	}
+
+	return nil
+}
+
+func (s *Session) Reset() {}
+
+func (s *Session) Logout() error {
+	slog.Debug("logged out", "user", s.user)
+	return nil
+}
+
+func (s *Session) Send(from string, tos []*mail.Address, raw io.Reader) error {
+	email, err := mail.ReadMessage(raw)
+	if err != nil {
+		return err
+	}
+	if email.Header.Get("message-id") == "" {
+		email.Header["Message-ID"] = []string{
+			fmt.Sprintf("%s%d", from, time.Now().Unix()),
+		}
+	}
+
+	fromdom := strings.Split(from, "@")
+	content, err := io.ReadAll(email.Body)
+	if err != nil {
+		return err
+	}
+	for _, to := range tos {
+		slog.Debug("sending email", "to", to)
+		body := content
+
+		// dns mx for email
+		addr := strings.Split(to.Address, "@")
+		mxs, err := net.LookupMX(addr[1])
+		if err != nil {
+			return err
+		}
+		if len(mxs) == 0 {
+			return err
+		}
+
+		// TODO: use external package
+		slog.Debug("checking wkd key")
+		key, _ := wkd.Discover(to.Address)
+		if key != nil {
+			slog.Debug("found wkd key")
+			enc := bytes.Buffer{}
+			c, err := openpgp.Encrypt(&enc, key, key[0], nil, nil)
+			if err != nil {
+				return err
+			}
+			c.Write(content)
+			c.Close()
+			email.Header["Content-Type"] = []string{"application/pgp-encrypted"}
+			body = enc.Bytes()
+		}
+
+		// write email headers into payload
+		var headers string
+		for k, v := range email.Header {
+			headers += fmt.Sprintf(
+				"%s: %s\r\n",
+				k, strings.Join(v, ", "),
+			)
+		}
+		headers += "\r\n"
+		body = append([]byte(headers), body...)
+
+		// dkim
+		slog.Debug("dkim check")
+		res := bytes.Buffer{}
+		if keyPath := v.GetUser(s.user).PrivateKey; keyPath != "" {
+			slog.Debug("user has dkim key")
+
+			keyData, err := os.ReadFile(keyPath)
+			if err != nil {
+				return err
+			}
+			block, _ := pem.Decode(keyData)
+			privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+			if err != nil {
+				return err
+			}
+			options := &dkim.SignOptions{
+				Domain:   fromdom[1],
+				Selector: "dkim",
+				Signer:   privateKey.(*rsa.PrivateKey),
+			}
+
+			err = dkim.Sign(&res, bytes.NewReader(body), options)
+			if err != nil {
+				slog.Error("failed to sign body", "err", err)
+			}
+		} else {
+			slog.Debug("no dkim key")
+			io.Copy(&res, bytes.NewReader(body))
+		}
+
+		addrs := make([]string, len(tos))
+		for i, to := range tos {
+			addrs[i] = to.Address
+		}
+
+		server := mxs[0].Host + ":smtp"
+		slog.Debug("sending", "host", server, "from", from, "to", tos)
+		err = smtp.SendMail(
+			server,
+			nil,
+			from,
+			addrs,
+			&res,
+		)
+		if err != nil {
+			return err
+		}
+	}
+
+	slog.Debug("email sent")
+	return nil
+}
+
+type backend struct{}
+
+func (b backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
+	return &Session{tos: []string{}}, nil
+}

doc.go

diff --git a/doc.go b/doc.go
index 0495676..57cd3d8 100644
--- a/doc.go
+++ b/doc.go
@@ -65,4 +65,4 @@
 // use any email client to communicate with dovel. When an email comes from
 // the domain configured Dovel will check if the sending user is valid using
 // the Vault interface, in positive case the email is sent.
-package server
+package main

main.go

diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d8da75b
--- /dev/null
+++ b/main.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+	"crypto/tls"
+	"encoding/json"
+	"log/slog"
+	"os"
+	"path"
+	"time"
+
+	"git.derelict.garden/bryon/vault"
+	"dovel.email/server"
+	"github.com/emersion/go-smtp"
+)
+
+var (
+	cfg        = server.Config{}
+	configPath string
+	v          vault.Vault[server.User]
+)
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		hand := slog.NewTextHandler(
+			os.Stdout,
+			&slog.HandlerOptions{Level: slog.LevelDebug},
+		)
+		slog.SetDefault(slog.New(hand))
+	}
+
+	var err error
+	configPath, err = os.UserConfigDir()
+	if err != nil {
+		slog.Error(err.Error(), "using ~/.config/dovel/config.json")
+		configPath = "~/.config"
+	}
+	configPath = path.Join(configPath, "dovel")
+	configFile, err := os.Open(path.Join(configPath, "config.json"))
+	if err != nil {
+		panic(err)
+	}
+
+	json.NewDecoder(configFile).Decode(&cfg)
+	slog.Debug("config loaded", "config", cfg)
+
+	if cfg.VaultFile != "" {
+		slog.Debug("creating vault", "path", cfg.VaultFile)
+		v, err = vault.NewJSONPlainTextVault[server.User](cfg.VaultFile)
+		if err != nil {
+			panic(err)
+		}
+		slog.Debug("vault created", "vault", v)
+	}
+
+	tlsCfg := &tls.Config{}
+	if cfg.Certificate != "" {
+		slog.Debug("loading certs", "cert", cfg.Certificate, "key", cfg.PrivateKey)
+		c, err := tls.LoadX509KeyPair(cfg.Certificate, cfg.PrivateKey)
+		if err != nil {
+			panic(err)
+		}
+		tlsCfg.Certificates = []tls.Certificate{c}
+	}
+
+	s := smtp.NewServer(backend{})
+	s.Addr = ":" + cfg.Port
+	s.Domain = cfg.Domain
+	s.ReadTimeout = 10 * time.Second
+	s.WriteTimeout = 10 * time.Second
+	s.MaxMessageBytes = 1024 * 1024
+	s.MaxRecipients = 5
+	s.AllowInsecureAuth = true
+
+	if len(tlsCfg.Certificates) > 0 {
+		s.TLSConfig = tlsCfg
+		slog.Debug("starting server", "tls", tlsCfg)
+		err = s.ListenAndServeTLS()
+	} else {
+		slog.Debug("starting server")
+		err = s.ListenAndServe()
+	}
+	if err != nil {
+		panic(err)
+	}
+}

model.go

diff --git a/model.go b/model.go
index c8c16bf..56c314b 100644
--- a/model.go
+++ b/model.go
@@ -1,4 +1,4 @@
-package server
+package main
 
 // Config is used to configure your email server.
 // Port is used to specify which port the server should listen