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 +}