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