This is the main dovel repository, it has the Go code to run dovel SMTP server.
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 --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
+}