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

54cd2b2

Author: brian (git@myr.sh)

Date: Sun Oct 8 18:29:13 2023 -0300

Parent: 4ff3b75 aee0979

Merge branch 'main' of zero:git/dovel.email/server

Diff

backend.go

diff --cc backend.go
index 0cb61d7,0000000..73741d2
mode 100644,000000..100644
--- a/backend.go
+++ b/backend.go
@@@ -1,263 -1,0 +1,267 @@@
 +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())
++					slog.Error("read link", "domain", cfg.Domain, "error", err.Error())
 +				}
 +			}
 +
 +			c := exec.Command(h)
++			c.Dir = path.Join(configPath, "hooks")
 +			c.Stdin = strings.NewReader(string(mess))
- 			c.Stdout = os.Stdout
++			out := bytes.Buffer{}
++			c.Stdout = &out
 +			if err := c.Run(); err != nil {
- 				slog.Error("run script", err.Error())
++				slog.Error("run script", "error", err.Error(), "output", out.String())
 +				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
++			slog.Error("lstat error", "domain", domain, "error", err.Error())
++			break
 +		} else if !f.Mode().IsRegular() {
 +			h, err = os.Readlink(h)
 +			if err != nil {
- 				slog.Error(domain, "read link", err.Error())
++				slog.Error("read link", "domain", domain, "error", err.Error())
 +			}
 +		}
 +
 +		c := exec.Command(h)
++		c.Dir = path.Join(configPath, "hooks")
 +		c.Stdin = strings.NewReader(string(mess))
- 		c.Stdout = os.Stdout
- 		if err := c.Run(); err != nil {
- 			slog.Error("run script", err.Error())
- 			continue
++		out := bytes.Buffer{}
++		c.Stdout = &out
++		if err = c.Run(); err != nil {
++			slog.Error("run script", "error", err.Error(), "output", out.String())
++			break
 +		}
 +	}
 +
- 	return nil
++	return err
 +}
 +
 +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
 +}