This is the main dovel repository, it has the Go code to run dovel SMTP server.
Author: b (git@mail.blmayer.dev)
Date: Tue Jul 18 20:03:41 2023 -0300
Parent: 66c46aa
Moved web part out - Added raw handler
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/TODO.txt b/TODO.txt index eb715a1..0c35df6 100644 --- a/TODO.txt +++ b/TODO.txt @@ -10,6 +10,6 @@ ☐ Improve web users ✓ Add insert attachments button ✓ Use XDG desktop for config path -☐ Support SMTP for sending email? +✓ Support SMTP for sending email? ☐ Add config menu ☐ Add denylist filtering
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/cmd/dovel/backend.go b/cmd/dovel/backend.go index f06c52f..998937a 100644 --- a/cmd/dovel/backend.go +++ b/cmd/dovel/backend.go @@ -1,16 +1,17 @@ package main import ( + "bytes" "crypto" "fmt" "io" + "io/ioutil" "net/mail" "strings" "blmayer.dev/x/vault" "git.derelict.garden/dovel/email/interfaces" - "github.com/OfimaticSRL/parsemail" "github.com/emersion/go-smtp" ) @@ -23,7 +24,7 @@ type Session struct { vault vault.Vault[interfaces.WebUser] } -func (s Session) AuthPlain(username, password string) error { +func (s *Session) AuthPlain(username, password string) error { println("connection sent", username, password) if s.vault.Validate(username, password) { s.user = username @@ -31,19 +32,21 @@ func (s Session) AuthPlain(username, password string) error { return nil } -func (s Session) Mail(from string, opts *smtp.MailOptions) error { +func (s *Session) Mail(from string, opts *smtp.MailOptions) error { println("Mail from:", from) s.from = from return nil } -func (s Session) Rcpt(to string) error { +func (s *Session) Rcpt(to string) error { println("Rcpt to:", to) s.tos = append(s.tos, to) return nil } -func (s Session) Data(r io.Reader) error { +func (s *Session) Data(r io.Reader) error { + content, _ := ioutil.ReadAll(r) + from, err := mail.ParseAddress(s.from) if err != nil { println("parse address", err.Error()) @@ -56,16 +59,10 @@ func (s Session) Data(r io.Reader) error { return err } - email, err := parsemail.Parse(r) - if err != nil { - println("parse email", err) - return err - } - m := interfaces.ToEmail(email) for _, to := range tos { localdom := strings.Split(to.Address, "@") if h, ok := s.handlers[localdom[1]]; ok { - err = h.Save(m) + err = h.Save(s.from, to.Address, bytes.NewReader(content)) } else { if s.user == "" { return fmt.Errorf("needs auth") @@ -74,7 +71,7 @@ func (s Session) Data(r io.Reader) error { if !ok { return fmt.Errorf("from is wrong") } - err = h.Send(m, interfaces.Opt{}) + err = h.Send(s.from, to.Address, bytes.NewReader(content)) } if err != nil { println("handler error", err.Error()) @@ -85,9 +82,9 @@ func (s Session) Data(r io.Reader) error { return nil } -func (s Session) Reset() { } +func (s *Session) Reset() { } -func (s Session) Logout() error { +func (s *Session) Logout() error { println("logged out") return nil } @@ -98,6 +95,6 @@ type backend struct { } func (b backend) NewSession(_ *smtp.Conn) (smtp.Session, error) { - return Session{handlers: b.Handlers, tos:[]string{}}, nil + return &Session{handlers: b.Handlers, tos:[]string{}}, nil }
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/cmd/dovel/main.go b/cmd/dovel/main.go index 9f2b4d9..d046520 100644 --- a/cmd/dovel/main.go +++ b/cmd/dovel/main.go @@ -6,13 +6,13 @@ import ( "net/http" "os" "path" - "html/template" "time" "git.derelict.garden/dovel/email/config" "git.derelict.garden/dovel/email/interfaces" "git.derelict.garden/dovel/email/interfaces/file" "git.derelict.garden/dovel/email/interfaces/gwi" + "git.derelict.garden/dovel/email/interfaces/raw" "blmayer.dev/x/vault" @@ -80,21 +80,11 @@ func main() { panic(err) } - web := webHandler{ - assetsPath: hand.Templates, - vault: v, - mailer: mail, - } - if hand.Templates != "" { - web.templates, err = template.ParseGlob(hand.Templates + "/*.html") - if err != nil { - panic(err) - } - http.HandleFunc(hand.Domain+"/", web.indexHandler()) - http.HandleFunc(hand.Domain+"/move", web.moveHandler) - http.HandleFunc(hand.Domain+"/delete", web.deleteHandler) - http.HandleFunc(hand.Domain+"/assets/", web.assetsHandler()) - http.HandleFunc(hand.Domain+"/out", web.sendHandler()) + b.Handlers[hand.Domain] = mail + case "raw": + mail, err := raw.NewRawHandler(hand) + if err != nil { + panic(err) } b.Handlers[hand.Domain] = mail
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/cmd/dovel/web.go b/cmd/dovel/web.go deleted file mode 100644 index 9ffb278..0000000 --- a/cmd/dovel/web.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "html/template" - "io" - "net/http" - "net/url" - "path" - "time" - - "git.derelict.garden/dovel/email/interfaces" - - "blmayer.dev/x/vault" -) - -type webHandler struct { - assetsPath string - templates *template.Template - vault vault.Vault[interfaces.WebUser] - mailer interfaces.Mailer -} - -func (h webHandler) indexHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - user, pass, _ := r.BasicAuth() - if user == "" || !h.vault.Validate(user, pass) { - w.Header().Add("WWW-Authenticate", "Basic") - http.Error(w, "wrong auth", http.StatusUnauthorized) - return - } - - if r.URL.Path == "/" { - r.URL.Path = "/index.html" - } - - u := h.vault.GetUser(user) - err := h.templates.ExecuteTemplate( - w, r.URL.Path[1:], - struct { - Query url.Values - Mailer interfaces.Mailer - User interfaces.WebUser - TimeZone *time.Location - }{ - r.URL.Query(), - h.mailer, - u, - time.FixedZone(u.TimeZone.Name, u.TimeZone.Offset), - }, - ) - if err != nil { - println("execute", err.Error()) - } - } -} - -func (h webHandler) moveHandler(w http.ResponseWriter, r *http.Request) { - user, pass, _ := r.BasicAuth() - if user == "" || !h.vault.Validate(user, pass) { - w.Header().Add("WWW-Authenticate", "Basic") - http.Error(w, "wrong auth", http.StatusUnauthorized) - return - } - - id := r.URL.Query().Get("id") - to := r.URL.Query().Get("to") - if id == "" || to == "" { - http.Error(w, "empty parameters", http.StatusBadRequest) - return - } - - if err := h.mailer.Move(id, to); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/", http.StatusFound) -} - -func (h webHandler) deleteHandler(w http.ResponseWriter, r *http.Request) { - user, pass, _ := r.BasicAuth() - if user == "" || !h.vault.Validate(user, pass) { - w.Header().Add("WWW-Authenticate", "Basic") - http.Error(w, "wrong auth", http.StatusUnauthorized) - return - } - - id := r.URL.Query().Get("id") - if id == "" { - http.Error(w, "empty id", http.StatusBadRequest) - return - } - - if err := h.mailer.Delete(id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/", http.StatusFound) -} - -func (h webHandler) assetsHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, path.Join(h.assetsPath, r.URL.Path[1:])) - } -} - -func (h webHandler) sendHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - println("file handling send email") - user, pass, _ := r.BasicAuth() - if user == "" || !h.vault.Validate(user, pass) { - w.Header().Add("WWW-Authenticate", "Basic") - http.Error(w, "wrong auth", http.StatusUnauthorized) - return - } - - if err := r.ParseMultipartForm(20 * 1024 * 1024); err != nil { - println("form error: " + err.Error()) - http.Error(w, "form error"+err.Error(), http.StatusNotAcceptable) - return - } - fo := r.MultipartForm - - email := interfaces.Email{ - From: fo.Value["from"][0], - To: fo.Value["to"], - Cc: fo.Value["cc"], - Subject: fo.Value["subject"][0], - Date: time.Now(), - Body: fo.Value["body"][0], - Attachments: map[string]interfaces.Attachment{}, - } - - for _, fi := range fo.File { - attach, err := fi[0].Open() - if err != nil { - println("error getting attachment: " + err.Error()) - continue - } - defer attach.Close() - - content, err := io.ReadAll(attach) - if err != nil { - println("error getting attachment: " + err.Error()) - continue - } - email.Attachments[fi[0].Filename] = interfaces.Attachment{ - Name: fi[0].Filename, - Data: content, - ContentType: fi[0].Header.Get("Content-Type"), - } - } - - opts := interfaces.Opt{ - Encrypt: len(fo.Value["encrypt"]) > 0, - } - if len(fo.Value["key"]) > 0 { - opts.Key = fo.Value["key"][0] - } - err := h.mailer.Send(email, opts) - if err != nil { - println("send error: " + err.Error()) - http.Error(w, "send error"+err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/", http.StatusFound) - } -}
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/dovel b/dovel new file mode 100755 index 0000000..c4466aa Binary files /dev/null and b/dovel differ
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/index.html b/index.html deleted file mode 100644 index f4e7e72..0000000 --- a/index.html +++ /dev/null @@ -1,198 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <title>dovel email</title> - <meta charset="UTF-8"> - <meta name="author" content="Brian Lee Mayer"> - <meta name="description" content="Dovel is a simple and extensible SMTP server that let's you send and receive email using a self hosted instance with minimum configuration. Dovel is free and open source software written in Go."> - <meta name="language" content="english"> - <meta name="keywords" content="email, dovel, blmayer, self host, software, smtp, web, interface"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="color-scheme" content="light dark"> - <style> -body { - max-width: 600px; - margin: 60px auto; - padding: 20px; - font-family: sans-serif; -} - -pre { - font-weight: bold; - background: lightgray; - padding: 8px; - border-radius: 8px; -} - -ol li { - padding-bottom: 10px; -} - -tt { - font-weight: bold; -} - -@media (prefers-color-scheme: dark) { - pre { - background: darkslategray; -} - </style> -</head> -<body> - <center> -<pre style="background:none"> -_________ ______ -______ /________ _________ / -_ __ /_ __ \_ | / / _ \_ / -/ /_/ / / /_/ /_ |/ // __/ / -\__,_/ \____/_____/ \___//_/ ________ - | ~~ # | - | --- | - '--------' - - - -</pre> - <i>Self host your email in 4 easy steps.</i> - <p> - <a href=https://goreportcard.com/report/blmayer.dev/x/dovel><img src=https://goreportcard.com/badge/blmayer.dev/x/dovel></a> -   - <a href=https://pkg.go.dev/blmayer.dev/x/dovel><img src=https://pkg.go.dev/badge/blmayer.dev/x/dovel.svg></a> - </p> - </center> - - <h2>About</h2> - Dovel is a SMTP server that sends and receives emails acording to a - simple configuration file. It also comes with an optional web interface - that you can use to browse your emails. It is designed to be simple and - easy to use, and will remain so. Dovel is great because: - - <ul> - <li> - Is lightweight: Uses only a few MBs of RAM and loads - pages super fast. - </li> - <li> - Is easy to configure: Needs just one JSON file. - </li> - <li> - Is easy to use: The design is simple and extensible. - </li> - <li> - Is customizable: Pages use <i>templates</i>, so - everything can be changed. - </li> - <li> - Has a GWI interface: Colaborate on git repositories - using <a href=//blmayer.dev/x/gwi>gwi</a>. - </li> - <li>Supports DKIM</li> - </ul> - This project is under the BSD-3-Clause license, its code can be - found in our <a href=//git.derelict.garden/dovel/email>git repository</a>. - - <h3>Roadmap</h3> - Dovel is in early stages and in active development, this means there - is a lot to be done. The major features planned are as follows: - <ul> - <li><s>Support DKIM.</s></li> - <li><s>Multiple inboxes.</s></li> - <li><s>Support moving and deleting email on web.</s></li> - <li><s>Dark theme</s></li> - <li><s>PGP support.</s></li> - <li>Multiple users.</li> - <li>Implement mailing lists behaviour.</li> - </ul> - - The detailed development features and progress can be found on our - <a href=//blmayer.dev/x/dovel/files/TODO.txt>TODO</a> file. - - <h2>Installation</h2> - To install <tt>dovel</tt> you need <tt>golang</tt> propperly installed, - and a port, normally 25, this guide works for MacOS, Linux and BSD - systems. - <ol> - <li> - Install dovel by running: - <tt>go install git.derelict.garden/dovel/email/cmd/dovel</tt>. - </li> - <li> - Configure dovel by creating the configuration file, an - example is provided bellow. Running <tt>dovel</tt> - is enough to start. - </li> - <li> - Configure your MX record in your domain registrar to - point to your server. This is needed to receive email. - </li> - <li> - Configure other DNS records as you wish in your domain - registrar such as PTR, SPF, DKIM or DMARC to improve - sending emails. Dovel supports DKIM by passing the key - path in the configuration file. - </li> - </ol> - - <h3>Example configuration</h3> - Dovel needs the file <tt>~/.config/dovel/config.json</tt> is order to - work correctly, otherwise it will use default settings which may not - suit you. - <pre> -{ - "webport": "8000", - "server": { - "address": ":25", - "domain": "dovel.email", - "inboxes": [ - { - "domain": "dovel.email", - "handler": "file", - "root": "mail/user", - "dKIMKeyPath": "dkim.priv", - "templates": "www" - } - ] - } -}</pre> - - Dovel can handle email for different domains, so you can have multiple - inboxes. This configuration uses the <i>file</i> handler, which saves - emails separated by the receiver and subject. For example, emails - addressed to <i>joe@user.dovel.email</i> with subject <i>test</i> will - be saved in the <tt>mail/user/joe/test/</tt> folder. - - <p> - To see all options please refer to the - <a href=//pkg.go.dev/git.derelict.garden/dovel/email> go docs</a>. - </p> - - <h2>Using</h2> - Using <tt>dovel</tt> email server and the web interface is meant to be - super easy: the server uses the configuration above, you can add more - inboxes. The web interface is much more interesting: It uses the - Golang <tt>template</tt> package to build pages. - - <p> - As a starting point, look at the <tt>www/</tt> folder in our - git repo. They are templates that you can customize to your - needs. Once you're done simply restart the <tt>dovel</tt> - application to apply changes. - </p> - - <h3>Functions</h3> - In addition to the template features provided by golang, this project - defines some functions to improve its capabilities, the go - documentation explains this throughtly. - - <h2>Contributing</h2> - Sending bug reports, starting discussions and sending patches can be - done easily by email, this project follows the convention defined - on <a href=//git.derelict.garden>derelict garden's git</a>. - - <p> - Financial support is also greatly appreciated, you can donate - any amount by using this <a href=//ko-fi.com/blmayer>ko-fi</a> - link. Thank you! - </p> -</body> -</html>
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/interfaces/file/file.go b/interfaces/file/file.go index 579c275..5db9a65 100644 --- a/interfaces/file/file.go +++ b/interfaces/file/file.go @@ -4,20 +4,22 @@ package file import ( + "bufio" "bytes" "crypto" "crypto/x509" "encoding/pem" "fmt" + "io" "io/ioutil" - "mime/multipart" "net" + "net/mail" "net/smtp" + "net/textproto" "os" "path" "sort" "strings" - "time" "git.derelict.garden/dovel/email/config" "git.derelict.garden/dovel/email/interfaces" @@ -28,8 +30,8 @@ import ( "github.com/OfimaticSRL/parsemail" "github.com/emersion/go-msgauth/dkim" - "github.com/ProtonMail/gopenpgp/v2/helper" pgp "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/ProtonMail/gopenpgp/v2/helper" ) // FileHandler is used to configure the file email handler. @@ -77,7 +79,14 @@ func NewFileHandler(c config.InboxConfig, v vault.Vault[interfaces.WebUser], fs return f, err } -func (f FileHandler) Save(email interfaces.Email) error { +func (f FileHandler) Save(from, to string, r io.Reader) error { + m, err := parsemail.Parse(r) + if err != nil { + println("mail parse", err.Error()) + return err + } + + email := interfaces.ToEmail(m) mailDir := path.Join(f.root, email.To[0], email.Subject) os.MkdirAll(mailDir, os.ModeDir|0o700) @@ -117,39 +126,29 @@ func (f FileHandler) SaveSent(email interfaces.Email) error { return nil } -func (f FileHandler) Send(mail interfaces.Email, opts interfaces.Opt) error { - mail.ID = fmt.Sprintf("%d@%s", mail.Date.Unix(), f.domain) - - body := bytes.Buffer{} - form := multipart.NewWriter(&body) - - // headers - body.WriteString("MIME-Version: 1.0\r\n") - body.WriteString("From: " + mail.From + "\r\n") - body.WriteString("To: " + strings.Join(mail.To, ", ") + "\r\n") - if len(mail.Cc) > 0 { - body.WriteString("Cc: " + strings.Join(mail.Cc, ", ") + "\r\n") +func (h FileHandler) Send(from, to string, raw io.Reader) error { + email, err := mail.ReadMessage(raw) + if err != nil { + return err + } + addrs, err := mail.ParseAddressList(to) + if err != nil { + return err + } + tos := []string{} + for _, addr := range addrs { + tos = append(tos, addr.Address) } - body.WriteString(fmt.Sprintf("Message-ID: <%s>\r\n", mail.ID)) - - body.WriteString("Date: " + mail.Date.Format(time.RFC1123Z) + "\r\n") - body.WriteString("Subject: " + mail.Subject + "\r\n") - body.WriteString("Content-Type: multipart/mixed; boundary=" + form.Boundary() + "\r\n\r\n") - - for name, fi := range mail.Attachments { - part, err := form.CreateFormFile(name, name) - if err != nil { - println("error creating form file: " + err.Error()) - continue - } - - part.Write(fi.Data) + body, err := ioutil.ReadAll(email.Body) + if err != nil { + return err } + for _, to := range addrs { + msg := string(body) - for _, to := range mail.To { // dns mx for email - addr := strings.Split(to, "@") + addr := strings.Split(to.Address, "@") mxs, err := net.LookupMX(addr[1]) if err != nil { return err @@ -158,67 +157,52 @@ func (f FileHandler) Send(mail interfaces.Email, opts interfaces.Opt) error { return err } - if opts.Encrypt { - text, err := form.CreatePart( - map[string][]string{ - "Content-Type": {"application/pgp-encrypted"}, - }, - ) + key, err := wkd.FetchPGPKey(addr[0], addr[1]) + if err != nil { + return err + } + if key != "" { + email.Header["Content-Type"] = []string{"application/pgp-encrypted"} + msg, err = helper.EncryptMessageArmored(key, msg) if err != nil { return err } + } - // grab public key if wanted - if opts.Key == "" { - opts.Key, err = wkd.FetchPGPKey(addr[0], addr[1]) - if err != nil { - return err - } - } - cypher, err := helper.EncryptMessageArmored(opts.Key, mail.Body) - if err != nil { - return err - } - text.Write([]byte(cypher)) - } else { - text, err := form.CreatePart( - map[string][]string{ - "Content-Type": {"text/plain; charset=\"UTF-8\""}, - }, - ) - if err != nil { - return err - } - text.Write([]byte(mail.Body)) + payload := bytes.Buffer{} + writer := textproto.NewWriter(bufio.NewWriter(&payload)) + for k, v := range email.Header { + writer.PrintfLine("%s: %s", k, strings.Join(v, ", ")) } - form.Close() + writer.PrintfLine("") + payload.Write([]byte(msg)) // dkim - payload := bytes.Buffer{} + res := bytes.Buffer{} options := &dkim.SignOptions{ - Domain: f.domain, + Domain: h.domain, Selector: "dkim", - Signer: f.privateKey, + Signer: h.privateKey, } - if err := dkim.Sign(&payload, &body, options); err != nil { + err = dkim.Sign(&res, &payload, options) + if err != nil { println("failed to sign body:", err.Error()) } - mail.Raw = payload.Bytes() server := mxs[0].Host + ":smtp" err = smtp.SendMail( server, nil, - mail.From, - []string{to}, - mail.Raw, + from, + tos, + res.Bytes(), ) if err != nil { return err } } - return f.SaveSent(mail) + return h.Save(from, to, raw) } func (f FileHandler) Mailboxes(folder string) ([]interfaces.Mailbox, error) {
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/interfaces/gwi/gwi.go b/interfaces/gwi/gwi.go index b1dc3e8..f8356f5 100644 --- a/interfaces/gwi/gwi.go +++ b/interfaces/gwi/gwi.go @@ -2,6 +2,7 @@ package gwi import ( + "bufio" "bytes" "crypto" "crypto/x509" @@ -10,21 +11,23 @@ import ( "fmt" "io" "io/ioutil" - "mime/multipart" "net" + "net/mail" "net/smtp" + "net/textproto" "os" "path" "sort" "strings" - "time" "git.derelict.garden/dovel/email/config" "git.derelict.garden/dovel/email/interfaces" + "git.derelict.garden/dovel/email/util/wkd" "blmayer.dev/x/vault" - + "github.com/OfimaticSRL/parsemail" + "github.com/ProtonMail/gopenpgp/v2/helper" "github.com/emersion/go-msgauth/dkim" ) @@ -65,7 +68,14 @@ func NewGWIHandler(c config.InboxConfig, vault vault.Vault[interfaces.WebUser]) // Save saves emails to the correct repo using the subject to // separate them. Subject field must be of form "[%s] %s". -func (g GWIHandler) Save(email interfaces.Email) error { +func (g GWIHandler) Save(from, to string, r io.Reader) error { + m, err := parsemail.Parse(r) + if err != nil { + println("mail parse", err.Error()) + return err + } + + email := interfaces.ToEmail(m) userRepoDomain := strings.Split(email.To[0], "@") userRepo := strings.Split(userRepoDomain[0], "/") if len(userRepo) != 2 { @@ -140,65 +150,32 @@ The GWI team.`, ) email.Subject = "New mail on project " + repo email.From = fmt.Sprintf("%s/%s@%s", user, repo, g.domain) - return g.Send(email, interfaces.Opt{}) + return g.Send(email.From, strings.Join(email.To, ", "), strings.NewReader(email.Body)) } -func (g GWIHandler) Send(mail interfaces.Email, opts interfaces.Opt) error { - mail.ID = fmt.Sprintf("%d@%s", mail.Date.Unix(), g.domain) - - body := bytes.Buffer{} - form := multipart.NewWriter(&body) - - // headers - body.WriteString("MIME-Version: 1.0\r\n") - body.WriteString("From: " + mail.From + "\r\n") - body.WriteString("To: " + strings.Join(mail.To, ", ") + "\r\n") - if len(mail.Cc) > 0 { - body.WriteString("Cc: " + strings.Join(mail.Cc, ", ") + "\r\n") - } - - body.WriteString(fmt.Sprintf("Message-ID: <%s>\r\n", mail.ID)) - - body.WriteString("Date: " + mail.Date.Format(time.RFC1123Z) + "\r\n") - body.WriteString("Subject: " + mail.Subject + "\r\n") - body.WriteString("Content-Type: multipart/mixed; boundary=" + form.Boundary() + "\r\n\r\n") - - text, err := form.CreatePart( - map[string][]string{ - "Content-Type": {"text/plain; charset=\"UTF-8\""}, - }, - ) +func (h GWIHandler) Send(from, to string, raw io.Reader) error { + email, err := mail.ReadMessage(raw) if err != nil { return err } - text.Write([]byte(mail.Body)) - - for name, fi := range mail.Attachments { - part, err := form.CreateFormFile(name, name) - if err != nil { - println("error creating form file: " + err.Error()) - continue - } - - part.Write(fi.Data) + addrs, err := mail.ParseAddressList(to) + if err != nil { + return err } - form.Close() - - // dkim - payload := bytes.Buffer{} - options := &dkim.SignOptions{ - Domain: g.domain, - Selector: "dkim", - Signer: g.privateKey, + tos := []string{} + for _, addr := range addrs { + tos = append(tos, addr.Address) } - if err := dkim.Sign(&payload, &body, options); err != nil { - println("failed to sign body:", err.Error()) + + body, err := ioutil.ReadAll(email.Body) + if err != nil { + return err } - mail.Body = payload.String() + for _, to := range addrs { + msg := string(body) - // dns mx for email - for _, to := range mail.To { - addr := strings.Split(to, "@") + // dns mx for email + addr := strings.Split(to.Address, "@") mxs, err := net.LookupMX(addr[1]) if err != nil { return err @@ -207,19 +184,52 @@ func (g GWIHandler) Send(mail interfaces.Email, opts interfaces.Opt) error { return err } + key, err := wkd.FetchPGPKey(addr[0], addr[1]) + if err != nil { + return err + } + if key != "" { + email.Header["Content-Type"] = []string{"application/pgp-encrypted"} + msg, err = helper.EncryptMessageArmored(key, msg) + if err != nil { + return err + } + } + + payload := bytes.Buffer{} + writer := textproto.NewWriter(bufio.NewWriter(&payload)) + for k, v := range email.Header { + writer.PrintfLine("%s: %s", k, strings.Join(v, ", ")) + } + writer.PrintfLine("") + payload.Write([]byte(msg)) + + // dkim + res := bytes.Buffer{} + options := &dkim.SignOptions{ + Domain: h.domain, + Selector: "dkim", + Signer: h.privateKey, + } + err = dkim.Sign(&res, &payload, options) + if err != nil { + println("failed to sign body:", err.Error()) + } + server := mxs[0].Host + ":smtp" err = smtp.SendMail( server, nil, - mail.From, - []string{to}, - []byte(mail.Body), + from, + tos, + res.Bytes(), ) if err != nil { return err } + } - return nil + return h.Save(from, to, raw) } func (f GWIHandler) Mailboxes(folder string) ([]interfaces.Mailbox, error) {
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/interfaces/main.go b/interfaces/main.go index ad6713b..a09c95b 100644 --- a/interfaces/main.go +++ b/interfaces/main.go @@ -115,8 +115,8 @@ func ToEmail(mail parsemail.Email) Email { } type Mailer interface { - Send(mail Email, opts Opt) error - Save(mail Email) error + Save(from, to string, raw io.Reader) error + Send(from, to string, raw io.Reader) error Mailboxes(folder string) ([]Mailbox, error) Mails(folder string) ([]Email, error) Mail(file string) (Email, error)
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/interfaces/raw/raw.go b/interfaces/raw/raw.go new file mode 100644 index 0000000..175a7e4 --- /dev/null +++ b/interfaces/raw/raw.go @@ -0,0 +1,335 @@ +// web adds the common email file handling, but with a twist: here we save +// emails first by inbox and then subject. This package also delivers some +// functions to be used in the templates, for the web interface. +package raw + +import ( + "bufio" + "bytes" + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "io/ioutil" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "os" + "path" + "sort" + "strings" + "text/template" + "time" + + "git.derelict.garden/dovel/email/config" + "git.derelict.garden/dovel/email/interfaces" + "git.derelict.garden/dovel/email/util/wkd" + + "github.com/OfimaticSRL/parsemail" + "github.com/emersion/go-msgauth/dkim" + + pgp "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/ProtonMail/gopenpgp/v2/helper" +) + +// RawHandler is used to configure the file email handler. +// Root is where mail should be saved, Templates is an optional field that +// is the path to templates, to be used in the web server; Password is the +// password for the x user, for now this is very simple; Domain is used to +// filter and separate emails, only emails sent to one of your domains are +// saved, each according to its configuration. +type RawHandler struct { + root string + domain string + privateKey crypto.Signer + keyText string + templ *template.Template +} + +func NewRawHandler(c config.InboxConfig) (RawHandler, error) { + h := RawHandler{root: c.Root, domain: c.Domain} + + var err error + h.templ, err = template.ParseFiles(c.Templates) + if err != nil { + return h, err + } + + if c.DKIMKeyPath != "" { + key, err := ioutil.ReadFile(c.DKIMKeyPath) + if err != nil { + return h, err + } + h.keyText = string(key) + + block, _ := pem.Decode(key) + if block == nil { + return h, err + } + privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return h, err + } + h.privateKey = privKey.(crypto.Signer) + } + + return h, err +} + +func (h RawHandler) Save(from, to string, raw io.Reader) error { + mail, err := mail.ReadMessage(raw) + if err != nil { + return err + } + + content, err := ioutil.ReadAll(mail.Body) + if err != nil { + println("file read", err.Error()) + return err + } + + // try to decrypt + if h.privateKey != nil && pgp.IsPGPMessage(string(content)) { + println("decrypting") + txt, err := helper.DecryptMessageArmored( + h.keyText, + nil, + string(content), + ) + if err != nil { + println(err) + } + content = []byte(txt) + } + + now := time.Now().Format(time.RFC3339) + subj := mail.Header.Get("Subject") + mailDir := path.Join(h.root, to, subj) + os.MkdirAll(mailDir, os.ModeDir|0o700) + name := fmt.Sprintf("%s:%s.mail", from, now) + + file, err := os.Create(path.Join(mailDir, name)) + if err != nil { + return err + } + + writer := textproto.NewWriter(bufio.NewWriter(file)) + for k, v := range mail.Header { + writer.PrintfLine("%s: %s", k, strings.Join(v, ", ")) + } + writer.PrintfLine("") + file.Write(content) + + return file.Close() +} + + +func (f RawHandler) SaveSent(email interfaces.Email) error { + mailDir := path.Join(f.root, email.From, email.Subject) + os.MkdirAll(mailDir, os.ModeDir|0o700) + + file, err := os.Create(path.Join(mailDir, email.ID)) + if err != nil { + println("file create", err.Error()) + return err + } + defer file.Close() + + _, err = file.Write(email.Raw) + if err != nil { + println("file write", err.Error()) + return err + } + + return nil +} + +func (h RawHandler) Send(from, to string, raw io.Reader) error { + email, err := mail.ReadMessage(raw) + if err != nil { + return err + } + addrs, err := mail.ParseAddressList(to) + if err != nil { + return err + } + tos := []string{} + for _, addr := range addrs { + tos = append(tos, addr.Address) + } + + body, err := ioutil.ReadAll(email.Body) + if err != nil { + return err + } + for _, to := range addrs { + msg := string(body) + + // 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 + } + + key, err := wkd.FetchPGPKey(addr[0], addr[1]) + if err != nil { + return err + } + if key != "" { + email.Header["Content-Type"] = []string{"application/pgp-encrypted"} + msg, err = helper.EncryptMessageArmored(key, msg) + if err != nil { + return err + } + } + + payload := bytes.Buffer{} + writer := textproto.NewWriter(bufio.NewWriter(&payload)) + for k, v := range email.Header { + writer.PrintfLine("%s: %s", k, strings.Join(v, ", ")) + } + writer.PrintfLine("") + payload.Write([]byte(msg)) + + // dkim + res := bytes.Buffer{} + options := &dkim.SignOptions{ + Domain: h.domain, + Selector: "dkim", + Signer: h.privateKey, + } + err = dkim.Sign(&res, &payload, options) + if err != nil { + println("failed to sign body:", err.Error()) + } + + server := mxs[0].Host + ":smtp" + err = smtp.SendMail( + server, + nil, + from, + tos, + res.Bytes(), + ) + if err != nil { + return err + } + + } + return h.Save(from, to, raw) +} + +func (f RawHandler) Mailboxes(folder string) ([]interfaces.Mailbox, error) { + fmt.Println("mailer mailboxes for", folder) + dir, err := os.ReadDir(path.Join(f.root, folder)) + if err != nil { + fmt.Println("readDir error:", err.Error()) + return nil, err + } + + var threads []interfaces.Mailbox + for _, d := range dir { + if !d.IsDir() { + continue + } + + info, err := d.Info() + if err != nil { + fmt.Println("dir info", err.Error()) + continue + } + t := interfaces.Mailbox{ + Title: d.Name(), + LastMod: info.ModTime(), + } + + threads = append(threads, t) + } + sort.Slice( + threads, + func(i, j int) bool { + return threads[i].LastMod.After(threads[j].LastMod) + }, + ) + + return threads, nil +} + +func (f RawHandler) Mails(folder string) ([]interfaces.Email, error) { + fmt.Println("mailer mails for", folder) + + dir := path.Join(f.root, folder) + threadDir, err := os.ReadDir(dir) + if err != nil { + fmt.Println("readDir error:", err.Error()) + return nil, err + } + + var emails []interfaces.Email + for _, t := range threadDir { + mail, err := f.Mail(path.Join(folder, t.Name())) + if err != nil { + fmt.Println("mail error:", err.Error()) + continue + } + + emails = append(emails, mail) + } + sort.Slice( + emails, + func(i, j int) bool { + return emails[i].Date.Before(emails[j].Date) + }, + ) + fmt.Println("found", len(emails), "emails") + + return emails, err +} + +func (f RawHandler) Mail(file string) (interfaces.Email, error) { + fmt.Println("mailer getting", file) + + mailFile, err := os.Open(path.Join(f.root, file)) + if err != nil { + fmt.Println("open mail error:", err.Error()) + return interfaces.Email{}, err + } + defer mailFile.Close() + + mail, err := parsemail.Parse(mailFile) + if err != nil { + fmt.Println("email parse error:", err.Error()) + return interfaces.Email{}, err + } + + // try to decrypt + if f.privateKey != nil && pgp.IsPGPMessage(mail.TextBody) { + txt, err := helper.DecryptMessageArmored(f.keyText, nil, mail.TextBody) + if err != nil { + println(err) + } + mail.TextBody = txt + } + return interfaces.ToEmail(mail), nil +} + +// Move is used to move messages between folders. The id parameter +// is the message id to be moved and to is the destination folder. +// Root is added automatically. +func (f RawHandler) Move(id, to string) error { + return os.Rename(path.Join(f.root, id), path.Join(f.root, to, "/")) +} + +// Delete is used to delete messages. The id parameter +// is the message id to be deleted. +// Root is added automatically. +func (f RawHandler) Delete(id string) error { + return os.Remove(path.Join(f.root, id)) +} +
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/assets/apple-touch-icon.png b/www/assets/apple-touch-icon.png deleted file mode 100644 index 13b9018..0000000 Binary files a/www/assets/apple-touch-icon.png and /dev/null differ
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/assets/favicon.ico b/www/assets/favicon.ico deleted file mode 100644 index 2e1f8df..0000000 Binary files a/www/assets/favicon.ico and /dev/null differ
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/assets/icon.png b/www/assets/icon.png deleted file mode 100644 index 2798922..0000000 Binary files a/www/assets/icon.png and /dev/null differ
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/assets/logo.png b/www/assets/logo.png deleted file mode 100644 index de112e2..0000000 Binary files a/www/assets/logo.png and /dev/null differ
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/assets/manifest.json b/www/assets/manifest.json deleted file mode 100644 index 59ade4c..0000000 --- a/www/assets/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "short_name": "dovel", - "name": "Dovel", - "icons": [ - { - "src": "/assets/icon.png", - "type": "image/png", - "purpose": "maskable", - "sizes": "512x512" - } - ], - "start_url": "../index.html", - "background_color": "#FFF", - "display": "standalone" -}
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/compose.html b/www/compose.html deleted file mode 100644 index 09bf97e..0000000 --- a/www/compose.html +++ /dev/null @@ -1,50 +0,0 @@ -<!DOCTYPE html> -<html> - <head> -{{template "head.html"}} - </head> - <body> -<b>composing</b><br> -<br> -<form action=/out method=post enctype=multipart/form-data> - <table> - <tr> - <td style="padding-left: 0"><label for="from">From: </label></td> - <td><input style="width:100%" name=from {{if .Query.inbox}}value="{{.Query.Get "inbox" }}"{{end}}></td> - </tr> - <tr> - <td style="padding-left: 0"><label>To: </label></td> - <td><input style="width:100%" name=to {{if .Query.to}}value="{{.Query.Get "to"}}"{{end}}></td> - </tr> - <tr> - <td style="padding-left: 0"><label>CC: </label></td> - <td><input style="width:100%" name=cc></td> - </tr> - <tr> - <td style="padding-left: 0"><label>CCo: </label></td> - <td><input style="width:100%" name=cco></td> - </tr> - <tr> - <td style="padding-left: 0"><label>Subject: </label></td> - <td><input style="width:100%" name=subject {{if .Query.subj}}value="{{.Query.Get "subj"}}"{{end}}></td> - </tr> - <tr> - <td colspan=2><textarea style="width:100%" name=body rows="7" cols="50" placeholder="Body:"></textarea></td> - </tr> - <tr> - <td colspan=2><label>Attachments: </label><input type=file name=files multiple></td> - </tr> - <tr> - <td colspan=2> - <details> - <summary><label>Encrypt? </label><input name=encrypt type=checkbox></summary> - Override OpenPGP key (optional):<br> - <textarea style="width:100%" name=key rows="7" cols="50" placeholder="Begin OpenGPG key..."></textarea> - </details> - </td> - </tr> - </table> - <input type=submit value=send> -</form> - </body> -</html>
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/head.html b/www/head.html deleted file mode 100644 index e569140..0000000 --- a/www/head.html +++ /dev/null @@ -1,5 +0,0 @@ -<title>dovel</title> -<link rel=icon href=/assets/favicon.ico> -<meta name="viewport" content="width=device-width, initial-scale=1"/> -<meta name="color-scheme" content="light dark"/> -{{template "style.html"}}
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/inboxes.html b/www/inboxes.html deleted file mode 100644 index 470e079..0000000 --- a/www/inboxes.html +++ /dev/null @@ -1,21 +0,0 @@ -<!DOCTYPE html> -<html> -<head> -{{template "head.html"}} -</head> -<body> -<h2>{{.Query.Get "inbox"}}</h2> - -<a href="index.html">index</a>  -<a href="compose.html?inbox={{.Query.Get "inbox"}}">compose</a>  -<a href="delete?id={{.Query.Get "inbox"}}">delete</a> - -{{$format := "02 Jan 2006 15:04:05"}} -{{range (.Mailer.Mailboxes (.Query.Get "inbox"))}} -<p> - <a href="mails.html?inbox={{$.Query.Get "inbox"}}&subj={{.Title}}">{{.Title}}</a> - <small>{{(.LastMod.In $.TimeZone).Format $format}}</small> -</p> -{{end}} -</body> -</html>
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/index.html b/www/index.html deleted file mode 100644 index a7cd774..0000000 --- a/www/index.html +++ /dev/null @@ -1,30 +0,0 @@ -<!DOCTYPE html> -<html> -<head> - {{template "head.html"}} - <link rel="manifest" href="/assets/manifest.json"> - <link rel="apple-touch-icon" sizes="200x200" href="/logo.png"> -</head> -<body> - <center> -<pre> -_________ ______ -______ /________ _________ / -_ __ /_ __ \_ | / / _ \_ / -/ /_/ / / /_/ /_ |/ // __/ / -\__,_/ \____/_____/ \___//_/ - -</pre> - </center> -<a href=compose.html>compose</a>  -<a href=logout.html>logout</a> - -{{$format := "02 Jan 2006 15:04:05"}} -{{range (.Mailer.Mailboxes ".")}} -<p> - <a href="inboxes.html?inbox={{.Title}}">{{.Title}}</a> - <small>{{(.LastMod.In $.TimeZone).Format $format}}</small> -</p> -{{end}} -</body> -</html>
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/mails.html b/www/mails.html deleted file mode 100644 index 5a8164f..0000000 --- a/www/mails.html +++ /dev/null @@ -1,49 +0,0 @@ -<!DOCTYPE html> -<html> - <head> -{{template "head.html"}} - </head> - <body> -<h2>{{.Query.Get "subj"}}</h2> - -{{$inbox := printf "%s/%s" (.Query.Get "inbox") (.Query.Get "subj")}} -<a href="inboxes.html?inbox={{.Query.Get "inbox"}}">inbox</a>  -<a href="compose.html?inbox={{.Query.Get "inbox"}}&subj={{.Query.Get "subj"}}">compose</a>  -<a href="delete?id={{$inbox}}">delete</a> -{{range (.Mailer.Mails $inbox)}} -<p> - <details> - <summary> - {{if eq ($.Query.Get "inbox") .From}} - To: {{index .To 0}} - {{else}} - From: {{.From}} - {{end}}<br> - Date: {{.Date.Format "Mon, 02 Jan 2006 15:04:05 MST"}}<br> - {{if .Cc}}Cc: .Cc{{end}} - </summary> - <ul> - {{range $k, $vs := .Headers}} - <li>{{$k}}: {{$vs}}</li> - {{end}} - </ul> - </details> - {{if .Attachments}} - Attachments: - <ul> - {{range .Attachments}} - <li> - <a href="data:{{.ContentType}};base64,{{.Data}}" download="{{.Name}}">{{.Name}}</a> - </li> - {{end}} - </ul> - {{end}} - {{if .Body}} - <pre>{{.Body}}</pre> - {{end}} - <a href="compose.html?inbox={{$.Query.Get "inbox"}}&subj={{.Subject}}&to={{.From}}">reply</a>  - <a href="delete?id={{printf "%s/%s" $inbox .ID}}">delete</a> -</p> -{{end}} - </body> -</html>
commit 1db678f848e36eda4dc38cf5f6c73428f44ab42e Author: b <git@mail.blmayer.dev> Date: Tue Jul 18 20:03:41 2023 -0300 Moved web part out - Added raw handler diff --git a/www/style.html b/www/style.html deleted file mode 100644 index 4cce72c..0000000 --- a/www/style.html +++ /dev/null @@ -1,34 +0,0 @@ -<style> - body { - max-width: 600px; - padding: 0 15px; - margin: 40px auto; - font-family: sans-serif; - } - table { - width: 100%; - } - a { - color: #888 - } - small { - float: right; - } - input, textarea { - color: #888; - font-family: monospace; - } - input { - border: none; - border-bottom: 1px solid #888; - } - input[type="submit"] { - text-decoration: underline; - cursor: pointer; - color: #888; - } - pre { - white-space: pre-wrap; - } -</style> -