This is the main dovel repository, it has the Go code to run dovel SMTP server.
Author: b (git@mail.blmayer.dev)
Date: Thu Jul 20 20:35:59 2023 -0300
Parent: 1db678f
Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/Makefile b/Makefile index b9f0f8c..4038943 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: clean deploy deploy-site deploy-assets deploy-pages -dovel: $(wildcard cmd/dovel/*.go interfaces/*/*.go config/*.go interfaces/*.go) +dovel: $(wildcard cmd/dovel/*.go model/*/*.go model/*.go util/*/*.go) GOARCH=arm GOARM=6 go build -o $@ cmd/dovel/*.go deploy: dovel
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/cmd/dovel/backend.go b/cmd/dovel/backend.go index 998937a..421a6f2 100644 --- a/cmd/dovel/backend.go +++ b/cmd/dovel/backend.go @@ -1,27 +1,33 @@ package main import ( + "bufio" "bytes" - "crypto" "fmt" "io" "io/ioutil" + "net" "net/mail" + "net/textproto" "strings" "blmayer.dev/x/vault" - "git.derelict.garden/dovel/email/interfaces" + "git.derelict.garden/dovel/email/model" + "git.derelict.garden/dovel/email/util/wkd" + "github.com/ProtonMail/gopenpgp/v2/helper" + "github.com/emersion/go-msgauth/dkim" "github.com/emersion/go-smtp" ) // A Session is returned after EHLO. type Session struct { + domain string user string - handlers map[string]interfaces.Mailer - from string - tos []string - vault vault.Vault[interfaces.WebUser] + handlers map[string]model.Mailer + from string + tos []string + vault vault.Vault[model.WebUser] } func (s *Session) AuthPlain(username, password string) error { @@ -52,49 +58,129 @@ func (s *Session) Data(r io.Reader) error { println("parse address", err.Error()) return err } - fromDomain := strings.Split(from.Address, "@")[1] - tos, err := mail.ParseAddressList(strings.Join(s.tos, ", ")) + s.domain = strings.Split(from.Address, "@")[1] + toList := strings.Join(s.tos, ", ") + if _, ok := s.handlers[s.domain]; ok { + if s.user == "" { + return fmt.Errorf("needs auth") + } + err = s.Send(s.from, toList, bytes.NewReader(content)) + if err != nil { + println("send error", err.Error()) + return err + } + } + + tos, err := mail.ParseAddressList(toList) if err != nil { println("parse addresslist", err.Error()) return err } - for _, to := range tos { localdom := strings.Split(to.Address, "@") + if h, ok := s.handlers[localdom[1]]; ok { err = h.Save(s.from, to.Address, bytes.NewReader(content)) - } else { - if s.user == "" { - return fmt.Errorf("needs auth") - } - h, ok = s.handlers[fromDomain] - if !ok { - return fmt.Errorf("from is wrong") + if err != nil { + println("handler error", err.Error()) + return err } - err = h.Send(s.from, to.Address, bytes.NewReader(content)) - } - if err != nil { - println("handler error", err.Error()) - return err } } return nil } -func (s *Session) Reset() { } +func (s *Session) Reset() {} func (s *Session) Logout() error { println("logged out") return nil } +func (s *Session) 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: s.domain, + Selector: "dkim", + Signer: s.vault.GetUser(s.user).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, + ) + if err != nil { + return err + } + + } + return nil +} + type backend struct { - Handlers map[string]interfaces.Mailer - PrivateKey crypto.Signer + Handlers map[string]model.Mailer } 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 c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/cmd/dovel/main.go b/cmd/dovel/main.go index d046520..a4effde 100644 --- a/cmd/dovel/main.go +++ b/cmd/dovel/main.go @@ -8,11 +8,11 @@ import ( "path" "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" + "git.derelict.garden/dovel/email/model" + "git.derelict.garden/dovel/email/model/file" + "git.derelict.garden/dovel/email/model/gwi" + "git.derelict.garden/dovel/email/model/html" + "git.derelict.garden/dovel/email/model/raw" "blmayer.dev/x/vault" @@ -20,25 +20,9 @@ import ( ) var ( - defaultPort = "8080" - defaultConfig = config.Config{ - WebPort: &defaultPort, - Server: config.ServerConfig{ - Domain: "dovel.email", - Address: ":2525", - Inboxes: []config.InboxConfig{ - { - Domain: "localhost", - DKIMKeyPath: "dkim.priv", - Templates: "www", - Handler: "file", - Root: "mail", - }, - }, - }, - } - cfg config.Config - b = backend{Handlers: map[string]interfaces.Mailer{}} + defaultPort = "8080" + cfg model.Config + b = backend{Handlers: map[string]model.Mailer{}} ) func main() { @@ -50,39 +34,46 @@ func main() { configFile, err := os.Open(path.Join(configPath, "dovel", "config.json")) if err != nil { println("open config", err.Error(), ". Using defaults") - cfg = defaultConfig - } else { - json.NewDecoder(configFile).Decode(&cfg) + return } + json.NewDecoder(configFile).Decode(&cfg) for _, hand := range cfg.Server.Inboxes { switch hand.Handler { case "gwi": // load gwi user file - v, err := vault.NewJSONPlainTextVault[interfaces.WebUser](path.Join(hand.Root, "users.json")) + v, err := vault.NewJSONPlainTextVault[model.WebUser](path.Join(hand.Root, "users.json")) if err != nil { panic(err) } - g, err := gwi.NewGWIHandler(hand, v) + g, err := gwi.NewGWIHandler(hand.CommonConfig, v) if err != nil { panic(err) } b.Handlers[hand.Domain] = g case "file": - v, err := vault.NewJSONPlainTextVault[interfaces.WebUser](path.Join(hand.Root, "users.json")) + v, err := vault.NewJSONPlainTextVault[model.WebUser](path.Join(hand.Root, "users.json")) if err != nil { panic(err) } - mail, err := file.NewFileHandler(hand, v, nil) + mail, err := file.NewFileHandler(hand.CommonConfig, v) if err != nil { panic(err) } b.Handlers[hand.Domain] = mail case "raw": - mail, err := raw.NewRawHandler(hand) + mail, err := raw.NewRawHandler(hand.CommonConfig) + if err != nil { + panic(err) + } + + b.Handlers[hand.Domain] = mail + case "html": + hand.HTMLConfig.CommonConfig = hand.CommonConfig + mail, err := html.NewHTMLHandler(hand.HTMLConfig) if err != nil { panic(err) }
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 667a948..0000000 --- a/config/config.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -// Config is the configuration structure used to set up dovel -// server and web interface. This should be a JSON file located -// in $HOME/.dovel-config.json -// Server field configures the server, and WebPort is an optional field -// used to specify the HTTP server port for the web interface, if present -// the HTTP web server is started. -type Config struct { - Server ServerConfig - WebPort *string -} - -type InboxConfig struct { - Domain string - DKIMKeyPath string - PrivateKey string - Templates string - Handler string - Root string - Password string -} - -// ServerConfig is used to configure your email server. -// Address is used to specify which address the server should listen -// to connections, a typicall value is :2525. -// Domain is what the server should respond in a HELO or EHLO request. -// Inboxes are the accounts available is this server, i.e. the accepted -// domains on emails: "example@example.domain". Each domain is handled -// separetly, so you can receive email for multiple domains. -// Lastly DKIM fields should be the path to the keys, they are optional. -type ServerConfig struct { - Address string - Domain string - Inboxes []InboxConfig -}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/dovel b/dovel deleted file mode 100755 index c4466aa..0000000 Binary files a/dovel and /dev/null differ
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/interfaces/file/file.go b/interfaces/file/file.go deleted file mode 100644 index 5db9a65..0000000 --- a/interfaces/file/file.go +++ /dev/null @@ -1,314 +0,0 @@ -// file 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 file - -import ( - "bufio" - "bytes" - "crypto" - "crypto/x509" - "encoding/pem" - "fmt" - "io" - "io/ioutil" - "net" - "net/mail" - "net/smtp" - "net/textproto" - "os" - "path" - "sort" - "strings" - - "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/emersion/go-msgauth/dkim" - - pgp "github.com/ProtonMail/gopenpgp/v2/crypto" - "github.com/ProtonMail/gopenpgp/v2/helper" -) - -// FileHandler 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 FileHandler struct { - root string - domain string - privateKey crypto.Signer - keyText string - vault vault.Vault[interfaces.WebUser] -} - -func NewFileHandler(c config.InboxConfig, v vault.Vault[interfaces.WebUser], fs map[string]any) (FileHandler, error) { - f := FileHandler{root: c.Root, vault: v, domain: c.Domain} - if fs == nil { - fs = map[string]any{} - } - fs["inboxes"] = f.Mailboxes - fs["mails"] = f.Mails - fs["mail"] = f.Mail - - var err error - if c.DKIMKeyPath != "" { - key, err := ioutil.ReadFile(c.DKIMKeyPath) - if err != nil { - return f, err - } - f.keyText = string(key) - - block, _ := pem.Decode(key) - if block == nil { - return f, err - } - privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return f, err - } - f.privateKey = privKey.(crypto.Signer) - } - - return f, err -} - -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) - - 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 (f FileHandler) 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 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, 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 FileHandler) 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 FileHandler) 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 FileHandler) 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 FileHandler) 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 FileHandler) Delete(id string) error { - return os.Remove(path.Join(f.root, id)) -}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/interfaces/gwi/gwi.go b/interfaces/gwi/gwi.go deleted file mode 100644 index f8356f5..0000000 --- a/interfaces/gwi/gwi.go +++ /dev/null @@ -1,365 +0,0 @@ -// gwi contains the email handler for interacting with gwi. -package gwi - -import ( - "bufio" - "bytes" - "crypto" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "fmt" - "io" - "io/ioutil" - "net" - "net/mail" - "net/smtp" - "net/textproto" - "os" - "path" - "sort" - "strings" - - "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" -) - -// GWIConfig is used to configure the GWI interface for dovel. Root is the -// same root as in GWI: the path to the repositories. -// Commands lets you add functions that will run for each received email, the -// key of the map specifies a trigger in the form "key!". That is, if the email -// body starts with key! then the command key is run. -type GWIHandler struct { - root string - notify bool - Commands map[string]func(email interfaces.Email) error - domain string - privateKey crypto.Signer - vault vault.Vault[interfaces.WebUser] -} - -func NewGWIHandler(c config.InboxConfig, vault vault.Vault[interfaces.WebUser]) (GWIHandler, error) { - g := GWIHandler{root: c.Root, domain: c.Domain, vault: vault} - if c.DKIMKeyPath != "" { - key, err := ioutil.ReadFile(c.DKIMKeyPath) - if err != nil { - return g, err - } - - block, _ := pem.Decode(key) - if block == nil { - return g, err - } - privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return g, err - } - g.privateKey = privKey.(crypto.Signer) - } - return g, nil -} - -// 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(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 { - println("received bad to", userRepo) - return fmt.Errorf("received bad to: %s", userRepo) - } - - user, repo := userRepo[0], userRepo[1] - if _, err := os.Stat(path.Join(g.root, user, repo)); err != nil { - println("stat repo", err.Error()) - return err - } - - // split by subject - start := strings.Index(email.Subject, "[") - if start == -1 { - println("received bad subject", email.Subject) - return fmt.Errorf("Malformed subject field") - } - title := strings.TrimSpace(email.Subject[start:]) - - mailDir := path.Join(g.root, user, repo, "mail", title) - os.MkdirAll(mailDir, os.ModeDir|0o700) - - mailFile, err := os.Create(path.Join(mailDir, email.ID)) - if err != nil { - println("create mail file", err.Error()) - return err - } - defer mailFile.Close() - - _, err = mailFile.Write(email.Raw) - if err != nil { - println("write mail file", err.Error()) - return err - } - - // apply commands - //go func() { - // println("gwi applying commands") - // for com, f := range g.Commands { - // println("gwi applying", com) - // if !strings.HasPrefix(email.Body, "!"+com) { - // continue - // } - // if err := f(email); err != nil { - // println(com, "error", err.Error()) - // continue - // } - // println("gwi", com, "applied") - // } - //}() - - // notify owner - owner := g.vault.GetUser(user) - if owner.Name == "" { - return nil - } - email.To = []string{owner.Email} - email.Body = fmt.Sprintf( - `You received an email with the subject %s. - -Check you project by visiting https://%s/%s/%s - -Yours. - -The GWI team.`, - email.Subject, - g.domain, - user, - repo, - ) - email.Subject = "New mail on project " + repo - email.From = fmt.Sprintf("%s/%s@%s", user, repo, g.domain) - return g.Send(email.From, strings.Join(email.To, ", "), strings.NewReader(email.Body)) -} - -func (h GWIHandler) 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 GWIHandler) 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 GWIHandler) 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 (g GWIHandler) Mail(file string) (interfaces.Email, error) { - fmt.Println("mailer getting", file) - - mailFile, err := os.Open(path.Join(g.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 - } - - email := interfaces.Email{ - ID: mail.MessageID, - From: mail.From[0].Address, - Date: mail.Date, - Subject: mail.Subject, - Body: mail.TextBody, - Attachments: map[string]interfaces.Attachment{}, - } - for _, to := range mail.To { - email.To = append(email.To, to.Address) - } - - if len(mail.Cc) > 0 { - for _, cc := range mail.Cc { - email.Cc = append(email.Cc, cc.Address) - } - } - - for _, a := range mail.Attachments { - content, err := io.ReadAll(a.Data) - if err != nil { - fmt.Println("read attachment", err.Error()) - continue - } - - encContent := base64.StdEncoding.EncodeToString(content) - email.Attachments[a.Filename] = interfaces.Attachment{ - Name: a.Filename, - ContentType: a.ContentType, - Data: []byte(encContent), - } - } - return email, 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 (g GWIHandler) Move(id, to string) error { - return os.Rename(path.Join(g.root, id), path.Join(g.root, to, "/")) -} - -// Delete is used to delete messages. The id parameter -// is the message id to be deleted. -// Root is added automatically. -func (g GWIHandler) Delete(id string) error { - return os.Remove(path.Join(g.root, id)) -}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/interfaces/raw/raw.go b/interfaces/raw/raw.go deleted file mode 100644 index 175a7e4..0000000 --- a/interfaces/raw/raw.go +++ /dev/null @@ -1,335 +0,0 @@ -// 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 c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/model/file/file.go b/model/file/file.go new file mode 100644 index 0000000..755c245 --- /dev/null +++ b/model/file/file.go @@ -0,0 +1,72 @@ +// file 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 file + +import ( + "io" + "io/ioutil" + "os" + "path" + + "git.derelict.garden/dovel/email/model" + + "blmayer.dev/x/vault" + + "github.com/OfimaticSRL/parsemail" +) + +// FileHandler 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 FileHandler struct { + root string + domain string + privateKey string + vault vault.Vault[model.WebUser] +} + +func NewFileHandler(c model.CommonConfig, v vault.Vault[model.WebUser]) (FileHandler, error) { + f := FileHandler{root: c.Root, vault: v, domain: c.Domain} + var err error + if c.PrivateKey != "" { + key, err := ioutil.ReadFile(c.PrivateKey) + if err != nil { + return f, err + } + f.privateKey = string(key) + } + + return f, err +} + +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 := model.ToEmail(m) + mailDir := path.Join(f.root, email.To[0], 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 +} +
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/model/gwi/gwi.go b/model/gwi/gwi.go new file mode 100644 index 0000000..0373693 --- /dev/null +++ b/model/gwi/gwi.go @@ -0,0 +1,132 @@ +// gwi contains the email handler for interacting with gwi. +package gwi + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path" + "strings" + + "git.derelict.garden/dovel/email/model" + + "blmayer.dev/x/vault" + + "github.com/OfimaticSRL/parsemail" +) + +// GWIHandler is used to configure the GWI interface for dovel. Root is the +// same root as in GWI: the path to the repositories. +// Commands lets you add functions that will run for each received email, the +// key of the map specifies a trigger in the form "key!". That is, if the email +// body starts with key! then the command key is run. +type GWIHandler struct { + root string + notify bool + Commands map[string]func(email model.Email) error + domain string + privateKey string + vault vault.Vault[model.WebUser] +} + +func NewGWIHandler(c model.CommonConfig, vault vault.Vault[model.WebUser]) (GWIHandler, error) { + g := GWIHandler{root: c.Root, domain: c.Domain, vault: vault} + if c.PrivateKey != "" { + key, err := ioutil.ReadFile(c.PrivateKey) + if err != nil { + return g, err + } + + g.privateKey = string(key) + } + return g, nil +} + +// 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(from, to string, r io.Reader) error { + m, err := parsemail.Parse(r) + if err != nil { + println("mail parse", err.Error()) + return err + } + + email := model.ToEmail(m) + userRepoDomain := strings.Split(email.To[0], "@") + userRepo := strings.Split(userRepoDomain[0], "/") + if len(userRepo) != 2 { + println("received bad to", userRepo) + return fmt.Errorf("received bad to: %s", userRepo) + } + + user, repo := userRepo[0], userRepo[1] + if _, err := os.Stat(path.Join(g.root, user, repo)); err != nil { + println("stat repo", err.Error()) + return err + } + + // split by subject + start := strings.Index(email.Subject, "[") + if start == -1 { + println("received bad subject", email.Subject) + return fmt.Errorf("Malformed subject field") + } + title := strings.TrimSpace(email.Subject[start:]) + + mailDir := path.Join(g.root, user, repo, "mail", title) + os.MkdirAll(mailDir, os.ModeDir|0o700) + + mailFile, err := os.Create(path.Join(mailDir, email.ID)) + if err != nil { + println("create mail file", err.Error()) + return err + } + defer mailFile.Close() + + _, err = mailFile.Write(email.Raw) + if err != nil { + println("write mail file", err.Error()) + return err + } + + // apply commands + //go func() { + // println("gwi applying commands") + // for com, f := range g.Commands { + // println("gwi applying", com) + // if !strings.HasPrefix(email.Body, "!"+com) { + // continue + // } + // if err := f(email); err != nil { + // println(com, "error", err.Error()) + // continue + // } + // println("gwi", com, "applied") + // } + //}() + + // notify owner + owner := g.vault.GetUser(user) + if owner.Name == "" { + return nil + } + email.To = []string{owner.Email} + email.Body = fmt.Sprintf( + `You received an email with the subject %s. + +Check you project by visiting https://%s/%s/%s + +Yours. + +The GWI team.`, + email.Subject, + g.domain, + user, + repo, + ) + email.Subject = "New mail on project " + repo + email.From = fmt.Sprintf("%s/%s@%s", user, repo, g.domain) + return nil +} +
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/model/html/html.go b/model/html/html.go new file mode 100644 index 0000000..b6bd6e0 --- /dev/null +++ b/model/html/html.go @@ -0,0 +1,164 @@ +// file 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 html + +import ( + "bufio" + "fmt" + "io" + "io/ioutil" + "net/mail" + "net/textproto" + "os" + "path" + "strings" + "text/template" + "time" + + "git.derelict.garden/dovel/email/model" + "git.derelict.garden/dovel/email/util" + + "github.com/ProtonMail/gopenpgp/v2/helper" + + pgp "github.com/ProtonMail/gopenpgp/v2/crypto" +) + +// HTMLHandler 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 HTMLHandler struct { + root string + domain string + privateKey string + indexTpl *template.Template + listTpl *template.Template + mailTpl *template.Template +} + +func NewHTMLHandler(c model.HTMLConfig) (HTMLHandler, error) { + f := HTMLHandler{root: c.Root, domain: c.Domain} + + var err error + f.mailTpl, err = template.ParseFiles(c.MailTpl) + if err != nil { + return f, err + } + + if c.PrivateKey != "" { + key, err := ioutil.ReadFile(c.PrivateKey) + if err != nil { + return f, err + } + f.privateKey = string(key) + } + return f, nil +} + +func (h HTMLHandler) Save(from, to string, r io.Reader) error { + mail, err := mail.ReadMessage(r) + 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 != "" && pgp.IsPGPMessage(string(content)) { + txt, err := helper.DecryptMessageArmored( + h.privateKey, + nil, + string(content), + ) + if err != nil { + println(err) + } + content = []byte(txt) + } + + date := time.Now() + if dt, err := mail.Header.Date(); err != nil { + date = dt + } + 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, date.Format(time.RFC3339)) + + 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) + file.Close() + + payload := model.Email{ + Headers: mail.Header, + Date: date, + From: from, + To: mail.Header["To"], + Subject: subj, + Body: string(content), + } + if mail.Header.Get("MIME-Version") != "" { + payload.Body, payload.Attachments, err = util.Decode( + mail.Header.Get("Content-Type"), + string(content), + ) + if err != nil { + println("decode", err.Error()) + } + } + + htmlName := fmt.Sprintf("%s:%s.html", from, date.Format(time.RFC3339)) + file, err = os.Create(path.Join(mailDir, htmlName)) + if err != nil { + return err + } + err = h.mailTpl.Execute(file, payload) + if err != nil { + return err + } + return file.Close() + + // TODO: Update index and lists +} + +func (f HTMLHandler) Mailboxes(folder string) ([]model.Mailbox, error) { + return nil, nil +} + +func (f HTMLHandler) Mails(folder string) ([]model.Email, error) { + return nil, nil +} + +func (f HTMLHandler) Mail(file string) (model.Email, error) { + return model.Email{}, 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 HTMLHandler) Move(id, to string) error { + return nil +} + +// Delete is used to delete messages. The id parameter +// is the message id to be deleted. +// Root is added automatically. +func (f HTMLHandler) Delete(id string) error { + return nil +}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/model/main.go b/model/main.go new file mode 100644 index 0000000..595ebac --- /dev/null +++ b/model/main.go @@ -0,0 +1,164 @@ +// package model contains interfaces that are passed to the common +// functions on the server and web projects. You can import this to use +// on templates and handlers. +package model + +import ( + "crypto" + "encoding/base64" + "fmt" + "io" + "time" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + + "github.com/OfimaticSRL/parsemail" +) + +// Config is the configuration structure used to set up dovel +// server and web interface. This should be a JSON file located +// in $HOME/.dovel-config.json +// Server field configures the server, and WebPort is an optional field +// used to specify the HTTP server port for the web interface, if present +// the HTTP web server is started. +type Config struct { + Server ServerConfig + WebPort *string +} + +// ServerConfig is used to configure your email server. +// Address is used to specify which address the server should listen +// to connections, a typicall value is :2525. +// Domain is what the server should respond in a HELO or EHLO request. +// Inboxes are the accounts available is this server, i.e. the accepted +// domains on emails: "example@example.domain". Each domain is handled +// separetly, so you can receive email for multiple domains. +// Lastly DKIM fields should be the path to the keys, they are optional. +type ServerConfig struct { + Address string + Domain string + Inboxes []InboxConfig +} + +type InboxConfig struct { + Handler string + HTMLConfig +} + +type HTMLConfig struct { + CommonConfig + IndexTpl string + ListTpl string + MailTpl string +} + +type tz struct { + Name string + Offset int +} + +type CommonConfig struct { + Domain string + PrivateKey string + Root string +} + +type WebUser struct { + Name string + Email string + Password string + TimeZone tz + PrivateKey crypto.Signer +} + +func (w WebUser) Login() string { + return w.Name +} + +func (w WebUser) Pass() string { + return w.Password +} + +type File struct { + *object.File + Size int64 +} + +type Info struct { + User string + Repo string + Ref plumbing.Hash + RefName string + Args string +} + +type Mailbox struct { + Title string + LastMod time.Time + Length int +} + +type Attachment struct { + Name string + ContentType string + Data []byte +} + +type Opt struct { + Encrypt bool + Key string +} + +type Email struct { + Headers map[string][]string + ID string + From string + To []string + Cc []string + BCc []string + Date time.Time + Subject string + Body string + Attachments map[string]Attachment + Raw []byte +} + +func ToEmail(mail parsemail.Email) Email { + m := Email{ + Headers: mail.Header, + From: mail.From[0].Address, + To: []string{}, + Cc: []string{}, + Subject: mail.Subject, + ID: mail.MessageID, + Date: mail.Date, + Body: mail.TextBody, + Attachments: map[string]Attachment{}, + } + for _, to := range mail.To { + m.To = append(m.To, to.Address) + } + for _, cc := range mail.Cc { + m.Cc = append(m.Cc, cc.Address) + } + for _, a := range mail.Attachments { + content, err := io.ReadAll(a.Data) + if err != nil { + fmt.Println("read attachment", err.Error()) + continue + } + + encContent := base64.StdEncoding.EncodeToString(content) + m.Attachments[a.Filename] = Attachment{ + Name: a.Filename, + ContentType: a.ContentType, + Data: []byte(encContent), + } + } + return m +} + +type Mailer interface { + Save(from, to string, raw io.Reader) error +}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/model/raw/raw.go b/model/raw/raw.go new file mode 100644 index 0000000..d8b8e04 --- /dev/null +++ b/model/raw/raw.go @@ -0,0 +1,121 @@ +// 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" + "fmt" + "io" + "io/ioutil" + "net/mail" + "net/textproto" + "os" + "path" + "strings" + "time" + + "git.derelict.garden/dovel/email/model" + + pgp "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/ProtonMail/gopenpgp/v2/helper" +) + +// EawHandler is used to configure the raw 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 string +} + +func NewRawHandler(c model.CommonConfig) (RawHandler, error) { + h := RawHandler{root: c.Root, domain: c.Domain} + + if c.PrivateKey != "" { + key, err := ioutil.ReadFile(c.PrivateKey) + if err != nil { + return h, err + } + h.privateKey = string(key) + } + + return h, nil +} + +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 != "" && pgp.IsPGPMessage(string(content)) { + println("decrypting") + txt, err := helper.DecryptMessageArmored( + h.privateKey, + 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) Mailboxes(folder string) ([]model.Mailbox, error) { + return nil, nil +} + +func (f RawHandler) Mails(folder string) ([]model.Email, error) { + return nil, nil +} + +func (f RawHandler) Mail(file string) (model.Email, error) { + return model.Email{}, 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 nil +} + +// 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 nil +}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/util/decode/multipart.go b/util/decode/multipart.go new file mode 100644 index 0000000..2df6798 --- /dev/null +++ b/util/decode/multipart.go @@ -0,0 +1,11 @@ +package util + +import "strings" + +func Decode(header, content string) (string, map[string]string) { + if strings.Contains(header, "multipart") { + boundary := strings.Split(header, `"`)[1] + } + + +}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/util/multipart.go b/util/multipart.go new file mode 100644 index 0000000..698bb27 --- /dev/null +++ b/util/multipart.go @@ -0,0 +1,61 @@ +package util + +import ( + "encoding/base64" + "io" + "mime" + "mime/multipart" + "strings" + + "git.derelict.garden/dovel/email/model" +) + +func Decode(header, content string) (string, map[string]model.Attachment, error) { + mediaType, params, err := mime.ParseMediaType(header) + if err != nil { + return content, nil, err + } + if !strings.HasPrefix(mediaType, "multipart/") { + return content, nil, nil + } + + var text string + attach := map[string]model.Attachment{} + r := multipart.NewReader(strings.NewReader(content), params["boundary"]) + for { + p, err := r.NextPart() + if err == io.EOF { + break + } + if err != nil { + return text, attach, err + } + cont, err := io.ReadAll(p) + if err != nil { + return text, attach, err + } + + if p.Header.Get("Content-Transfer-Encoding") == "base64" { + cont, err = base64.RawStdEncoding.DecodeString( + string(cont), + ) + if err != nil { + println("base64", err.Error()) + } + } + + cType := p.Header.Get("Content-Type") + if fileName := p.FileName(); fileName != "" { + attach[fileName] = model.Attachment{ + Name: fileName, + Data: cont, + ContentType: cType, + } + continue + } + if strings.HasPrefix(cType, "text/plain") { + text = string(cont) + } + } + return text, attach, nil +}
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/util/multipart_test.go b/util/multipart_test.go new file mode 100644 index 0000000..2ea92f0 --- /dev/null +++ b/util/multipart_test.go @@ -0,0 +1,32 @@ +package util + +import ( + "testing" +) + +var header = `multipart/alternative; boundary="000000000000b05aa10600f3345f"` +var body = `--000000000000b05aa10600f3345f +Content-Type: text/plain; charset="UTF-8" + +sdfv qergwergwergvd fvds fvv + +--000000000000b05aa10600f3345f +Content-Type: text/html; charset="UTF-8" + +<div dir="ltr"><div class="gmail_default" style="font-family:arial,sans-serif;font-size:small;color:#000000">sdfv qergwergwergvd fvds fvv<br></div></div> + +--000000000000b05aa10600f3345f-- +` + +func TestDecode(t *testing.T) { + text, attach, err := Decode(header, body) + if err != nil { + t.Error(err) + } + if text == "" { + t.Error("text is empty") + } + t.Log(text) + t.Log(attach) +} +
commit c18a9369905bb8a91c13778037afe014fb8dcfc8 Author: b <git@mail.blmayer.dev> Date: Thu Jul 20 20:35:59 2023 -0300 Added html handler - Removed web part - Renamed interfaces to model - Moved sending email to main - Improved configs diff --git a/util/parse_test.go b/util/parse_test.go new file mode 100644 index 0000000..59d917d --- /dev/null +++ b/util/parse_test.go @@ -0,0 +1,28 @@ +package util + +import ( + "testing" + + "git.derelict.garden/dovel/email/model" + "git.derelict.garden/dovel/email/model/file" +) + +func TestParseMail(t *testing.T) { + h, err := file.NewFileHandler(model.CommonConfig{}, nil, nil) + if err != nil { + t.Error(err) + return + } + + mail, err := h.Mail( + "mail/test/attachment/base64.txt", + ) + if err != nil { + t.Error(err) + } + if len(mail.Attachments) == 0 { + t.Error("attachment not parsed") + } + + t.Log(mail) +}