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:28:50 2023 -0300
Parent: edad56d
Moved files up
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 +}
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
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) + } +}
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