From d897eeee5c4d163891d0b6a8f85d328ccada7575 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Mon, 16 Dec 2019 12:51:42 +0100 Subject: [PATCH] Introduce base plugin This plugin offers base IMAP/SMTP functionality. References: https://todo.sr.ht/~sircmpwn/koushin/39 --- .gitignore | 1 + README.md | 4 +- cmd/koushin/main.go | 2 + handlers.go | 288 ----------------------------------- imap.go | 271 +------------------------------- plugin_go.go | 2 +- plugin_lua.go | 3 + plugins/base/handlers.go | 285 ++++++++++++++++++++++++++++++++++ plugins/base/imap.go | 277 +++++++++++++++++++++++++++++++++ plugins/base/plugin.go | 48 ++++++ plugins/base/public/assets/style.css | 1 + plugins/base/public/compose.html | 26 ++++ plugins/base/public/foot.html | 2 + plugins/base/public/head.html | 8 + plugins/base/public/login.html | 14 ++ plugins/base/public/mailbox.html | 45 ++++++ plugins/base/public/message.html | 58 +++++++ plugins/base/smtp.go | 114 ++++++++++++++ plugins/base/strconv.go | 57 +++++++ public/assets/style.css | 1 - public/compose.html | 26 ---- public/foot.html | 2 - public/head.html | 8 - public/login.html | 14 -- public/mailbox.html | 45 ------ public/message.html | 58 ------- server.go | 35 +---- session.go | 41 ++++- smtp.go | 109 +------------ strconv.go | 57 ------- template.go | 15 +- 31 files changed, 994 insertions(+), 923 deletions(-) delete mode 100644 handlers.go create mode 100644 plugins/base/handlers.go create mode 100644 plugins/base/imap.go create mode 100644 plugins/base/plugin.go create mode 100644 plugins/base/public/assets/style.css create mode 100644 plugins/base/public/compose.html create mode 100644 plugins/base/public/foot.html create mode 100644 plugins/base/public/head.html create mode 100644 plugins/base/public/login.html create mode 100644 plugins/base/public/mailbox.html create mode 100644 plugins/base/public/message.html create mode 100644 plugins/base/smtp.go create mode 100644 plugins/base/strconv.go delete mode 100644 public/assets/style.css delete mode 100644 public/compose.html delete mode 100644 public/foot.html delete mode 100644 public/head.html delete mode 100644 public/login.html delete mode 100644 public/mailbox.html delete mode 100644 public/message.html delete mode 100644 strconv.go diff --git a/.gitignore b/.gitignore index c7cfe55..b7d2b50 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /public/themes/* !/public/themes/sourcehut /plugins/* +!/plugins/base diff --git a/README.md b/README.md index 74706d3..1ca62d5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ They should be put in `public/themes//`. Templates in `public/themes//*.html` override default templates in `public/*.html`. Assets in `public/themes//assets/*` are served by the -HTTP server at `themes//assets/*`. +HTTP server at `/themes//assets/*`. ## Plugins @@ -29,6 +29,8 @@ API: called with the HTTP context Plugins can provide their own templates in `plugins//public/*.html`. +Assets in `plugins//public/assets/*` are served by the HTTP server at +`/plugins//assets/*`. ## Contributing diff --git a/cmd/koushin/main.go b/cmd/koushin/main.go index e644884..c9df12b 100644 --- a/cmd/koushin/main.go +++ b/cmd/koushin/main.go @@ -8,6 +8,8 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/labstack/gommon/log" + + _ "git.sr.ht/~emersion/koushin/plugins/base" ) func main() { diff --git a/handlers.go b/handlers.go deleted file mode 100644 index f53085c..0000000 --- a/handlers.go +++ /dev/null @@ -1,288 +0,0 @@ -package koushin - -import ( - "fmt" - "io/ioutil" - "mime" - "net/http" - "net/url" - "strconv" - "strings" - - "github.com/emersion/go-imap" - imapclient "github.com/emersion/go-imap/client" - "github.com/emersion/go-message" - "github.com/emersion/go-sasl" - "github.com/labstack/echo/v4" -) - -type MailboxRenderData struct { - RenderData - Mailbox *imap.MailboxStatus - Mailboxes []*imap.MailboxInfo - Messages []imapMessage - PrevPage, NextPage int -} - -func handleGetMailbox(ectx echo.Context) error { - ctx := ectx.(*Context) - - mboxName, err := url.PathUnescape(ctx.Param("mbox")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err) - } - - page := 0 - if pageStr := ctx.QueryParam("page"); pageStr != "" { - var err error - if page, err = strconv.Atoi(pageStr); err != nil || page < 0 { - return echo.NewHTTPError(http.StatusBadRequest, "invalid page index") - } - } - - var mailboxes []*imap.MailboxInfo - var msgs []imapMessage - var mbox *imap.MailboxStatus - err = ctx.Session.Do(func(c *imapclient.Client) error { - var err error - if mailboxes, err = listMailboxes(c); err != nil { - return err - } - if msgs, err = listMessages(c, mboxName, page); err != nil { - return err - } - mbox = c.Mailbox() - return nil - }) - if err != nil { - return err - } - - prevPage, nextPage := -1, -1 - if page > 0 { - prevPage = page - 1 - } - if (page+1)*messagesPerPage < int(mbox.Messages) { - nextPage = page + 1 - } - - return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{ - RenderData: *NewRenderData(ctx), - Mailbox: mbox, - Mailboxes: mailboxes, - Messages: msgs, - PrevPage: prevPage, - NextPage: nextPage, - }) -} - -func handleLogin(ectx echo.Context) error { - ctx := ectx.(*Context) - - username := ctx.FormValue("username") - password := ctx.FormValue("password") - if username != "" && password != "" { - s, err := ctx.Server.Sessions.Put(username, password) - if err != nil { - if _, ok := err.(AuthError); ok { - return ctx.Render(http.StatusOK, "login.html", nil) - } - return fmt.Errorf("failed to put connection in pool: %v", err) - } - ctx.SetSession(s) - - return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") - } - - return ctx.Render(http.StatusOK, "login.html", NewRenderData(ctx)) -} - -func handleLogout(ectx echo.Context) error { - ctx := ectx.(*Context) - - ctx.Session.Close() - ctx.SetSession(nil) - return ctx.Redirect(http.StatusFound, "/login") -} - -type MessageRenderData struct { - RenderData - Mailbox *imap.MailboxStatus - Message *imapMessage - Body string - PartPath string - MailboxPage int -} - -func handleGetPart(ctx *Context, raw bool) error { - mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err) - } - partPathString := ctx.QueryParam("part") - partPath, err := parsePartPath(partPathString) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err) - } - - var msg *imapMessage - var part *message.Entity - var mbox *imap.MailboxStatus - err = ctx.Session.Do(func(c *imapclient.Client) error { - var err error - msg, part, err = getMessagePart(c, mboxName, uid, partPath) - mbox = c.Mailbox() - return err - }) - if err != nil { - return err - } - - mimeType, _, err := part.Header.ContentType() - if err != nil { - return fmt.Errorf("failed to parse part Content-Type: %v", err) - } - if len(partPath) == 0 { - mimeType = "message/rfc822" - } - - if raw { - disp, dispParams, _ := part.Header.ContentDisposition() - filename := dispParams["filename"] - - // TODO: set Content-Length if possible - - if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") { - dispParams := make(map[string]string) - if filename != "" { - dispParams["filename"] = filename - } - disp := mime.FormatMediaType("attachment", dispParams) - ctx.Response().Header().Set("Content-Disposition", disp) - } - return ctx.Stream(http.StatusOK, mimeType, part.Body) - } - - var body string - if strings.HasPrefix(strings.ToLower(mimeType), "text/") { - b, err := ioutil.ReadAll(part.Body) - if err != nil { - return fmt.Errorf("failed to read part body: %v", err) - } - body = string(b) - } - - return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{ - RenderData: *NewRenderData(ctx), - Mailbox: mbox, - Message: msg, - Body: body, - PartPath: partPathString, - MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage, - }) -} - -type ComposeRenderData struct { - RenderData - Message *OutgoingMessage -} - -func handleCompose(ectx echo.Context) error { - ctx := ectx.(*Context) - - var msg OutgoingMessage - if strings.ContainsRune(ctx.Session.username, '@') { - msg.From = ctx.Session.username - } - - if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" { - // This is a reply - mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err) - } - partPath, err := parsePartPath(ctx.QueryParam("part")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err) - } - - var inReplyTo *imapMessage - var part *message.Entity - err = ctx.Session.Do(func(c *imapclient.Client) error { - var err error - inReplyTo, part, err = getMessagePart(c, mboxName, uid, partPath) - return err - }) - if err != nil { - return err - } - - mimeType, _, err := part.Header.ContentType() - if err != nil { - return fmt.Errorf("failed to parse part Content-Type: %v", err) - } - - if !strings.HasPrefix(strings.ToLower(mimeType), "text/") { - err := fmt.Errorf("cannot reply to \"%v\" part", mimeType) - return echo.NewHTTPError(http.StatusBadRequest, err) - } - - msg.Text, err = quote(part.Body) - if err != nil { - return err - } - - msg.InReplyTo = inReplyTo.Envelope.MessageId - // TODO: populate From from known user addresses and inReplyTo.Envelope.To - replyTo := inReplyTo.Envelope.ReplyTo - if len(replyTo) == 0 { - replyTo = inReplyTo.Envelope.From - } - if len(replyTo) > 0 { - msg.To = make([]string, len(replyTo)) - for i, to := range replyTo { - msg.To[i] = to.Address() - } - } - msg.Subject = inReplyTo.Envelope.Subject - if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") { - msg.Subject = "Re: " + msg.Subject - } - } - - if ctx.Request().Method == http.MethodPost { - msg.From = ctx.FormValue("from") - msg.To = parseAddressList(ctx.FormValue("to")) - msg.Subject = ctx.FormValue("subject") - msg.Text = ctx.FormValue("text") - msg.InReplyTo = ctx.FormValue("in_reply_to") - - c, err := ctx.Server.connectSMTP() - if err != nil { - return err - } - defer c.Close() - - auth := sasl.NewPlainClient("", ctx.Session.username, ctx.Session.password) - if err := c.Auth(auth); err != nil { - return echo.NewHTTPError(http.StatusForbidden, err) - } - - if err := sendMessage(c, &msg); err != nil { - return err - } - - if err := c.Quit(); err != nil { - return fmt.Errorf("QUIT failed: %v", err) - } - - // TODO: append to IMAP Sent mailbox - - return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") - } - - return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{ - RenderData: *NewRenderData(ctx), - Message: &msg, - }) -} diff --git a/imap.go b/imap.go index 0e0edc1..2a43dd1 100644 --- a/imap.go +++ b/imap.go @@ -1,24 +1,18 @@ package koushin import ( - "bufio" "fmt" - "sort" - "strconv" - "strings" "github.com/emersion/go-imap" imapclient "github.com/emersion/go-imap/client" - "github.com/emersion/go-message" "github.com/emersion/go-message/charset" - "github.com/emersion/go-message/textproto" ) func init() { imap.CharsetReader = charset.Reader } -func (s *Server) connectIMAP() (*imapclient.Client, error) { +func (s *Server) dialIMAP() (*imapclient.Client, error) { var c *imapclient.Client var err error if s.imap.tls { @@ -41,266 +35,3 @@ func (s *Server) connectIMAP() (*imapclient.Client, error) { return c, err } - -func listMailboxes(conn *imapclient.Client) ([]*imap.MailboxInfo, error) { - ch := make(chan *imap.MailboxInfo, 10) - done := make(chan error, 1) - go func() { - done <- conn.List("", "*", ch) - }() - - var mailboxes []*imap.MailboxInfo - for mbox := range ch { - mailboxes = append(mailboxes, mbox) - } - - if err := <-done; err != nil { - return nil, fmt.Errorf("failed to list mailboxes: %v", err) - } - - sort.Slice(mailboxes, func(i, j int) bool { - return mailboxes[i].Name < mailboxes[j].Name - }) - return mailboxes, nil -} - -func ensureMailboxSelected(conn *imapclient.Client, mboxName string) error { - mbox := conn.Mailbox() - if mbox == nil || mbox.Name != mboxName { - if _, err := conn.Select(mboxName, false); err != nil { - return fmt.Errorf("failed to select mailbox: %v", err) - } - } - return nil -} - -type imapMessage struct { - *imap.Message -} - -func textPartPath(bs *imap.BodyStructure) ([]int, bool) { - if bs.Disposition != "" && !strings.EqualFold(bs.Disposition, "inline") { - return nil, false - } - - if strings.EqualFold(bs.MIMEType, "text") { - return []int{1}, true - } - - if !strings.EqualFold(bs.MIMEType, "multipart") { - return nil, false - } - - textPartNum := -1 - for i, part := range bs.Parts { - num := i + 1 - - if strings.EqualFold(part.MIMEType, "multipart") { - if subpath, ok := textPartPath(part); ok { - return append([]int{num}, subpath...), true - } - } - if !strings.EqualFold(part.MIMEType, "text") { - continue - } - - var pick bool - switch strings.ToLower(part.MIMESubType) { - case "plain": - pick = true - case "html": - pick = textPartNum < 0 - } - - if pick { - textPartNum = num - } - } - - if textPartNum > 0 { - return []int{textPartNum}, true - } - return nil, false -} - -func (msg *imapMessage) TextPartName() string { - if msg.BodyStructure == nil { - return "" - } - - path, ok := textPartPath(msg.BodyStructure) - if !ok { - return "" - } - - l := make([]string, len(path)) - for i, partNum := range path { - l[i] = strconv.Itoa(partNum) - } - - return strings.Join(l, ".") -} - -type IMAPPartNode struct { - Path []int - MIMEType string - Filename string - Children []IMAPPartNode -} - -func (node IMAPPartNode) PathString() string { - l := make([]string, len(node.Path)) - for i, partNum := range node.Path { - l[i] = strconv.Itoa(partNum) - } - - return strings.Join(l, ".") -} - -func (node IMAPPartNode) IsText() bool { - return strings.HasPrefix(strings.ToLower(node.MIMEType), "text/") -} - -func (node IMAPPartNode) String() string { - if node.Filename != "" { - return fmt.Sprintf("%s (%s)", node.Filename, node.MIMEType) - } else { - return node.MIMEType - } -} - -func imapPartTree(bs *imap.BodyStructure, path []int) *IMAPPartNode { - if !strings.EqualFold(bs.MIMEType, "multipart") && len(path) == 0 { - path = []int{1} - } - - filename, _ := bs.Filename() - - node := &IMAPPartNode{ - Path: path, - MIMEType: strings.ToLower(bs.MIMEType + "/" + bs.MIMESubType), - Filename: filename, - Children: make([]IMAPPartNode, len(bs.Parts)), - } - - for i, part := range bs.Parts { - num := i + 1 - - partPath := append([]int(nil), path...) - partPath = append(partPath, num) - - node.Children[i] = *imapPartTree(part, partPath) - } - - return node -} - -func (msg *imapMessage) PartTree() *IMAPPartNode { - if msg.BodyStructure == nil { - return nil - } - - return imapPartTree(msg.BodyStructure, nil) -} - -func listMessages(conn *imapclient.Client, mboxName string, page int) ([]imapMessage, error) { - if err := ensureMailboxSelected(conn, mboxName); err != nil { - return nil, err - } - - mbox := conn.Mailbox() - to := int(mbox.Messages) - page*messagesPerPage - from := to - messagesPerPage + 1 - if from <= 0 { - from = 1 - } - if to <= 0 { - return nil, nil - } - - seqSet := new(imap.SeqSet) - seqSet.AddRange(uint32(from), uint32(to)) - - fetch := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchBodyStructure} - - ch := make(chan *imap.Message, 10) - done := make(chan error, 1) - go func() { - done <- conn.Fetch(seqSet, fetch, ch) - }() - - msgs := make([]imapMessage, 0, to-from) - for msg := range ch { - msgs = append(msgs, imapMessage{msg}) - } - - if err := <-done; err != nil { - return nil, fmt.Errorf("failed to fetch message list: %v", err) - } - - // Reverse list of messages - for i := len(msgs)/2 - 1; i >= 0; i-- { - opp := len(msgs) - 1 - i - msgs[i], msgs[opp] = msgs[opp], msgs[i] - } - - return msgs, nil -} - -func getMessagePart(conn *imapclient.Client, mboxName string, uid uint32, partPath []int) (*imapMessage, *message.Entity, error) { - if err := ensureMailboxSelected(conn, mboxName); err != nil { - return nil, nil, err - } - - seqSet := new(imap.SeqSet) - seqSet.AddNum(uid) - - var partHeaderSection imap.BodySectionName - partHeaderSection.Peek = true - if len(partPath) > 0 { - partHeaderSection.Specifier = imap.MIMESpecifier - } else { - partHeaderSection.Specifier = imap.HeaderSpecifier - } - partHeaderSection.Path = partPath - - var partBodySection imap.BodySectionName - partBodySection.Peek = true - if len(partPath) > 0 { - partBodySection.Specifier = imap.EntireSpecifier - } else { - partBodySection.Specifier = imap.TextSpecifier - } - partBodySection.Path = partPath - - fetch := []imap.FetchItem{ - imap.FetchEnvelope, - imap.FetchUid, - imap.FetchBodyStructure, - partHeaderSection.FetchItem(), - partBodySection.FetchItem(), - } - - ch := make(chan *imap.Message, 1) - if err := conn.UidFetch(seqSet, fetch, ch); err != nil { - return nil, nil, fmt.Errorf("failed to fetch message: %v", err) - } - - msg := <-ch - if msg == nil { - return nil, nil, fmt.Errorf("server didn't return message") - } - - headerReader := bufio.NewReader(msg.GetBody(&partHeaderSection)) - h, err := textproto.ReadHeader(headerReader) - if err != nil { - return nil, nil, fmt.Errorf("failed to read part header: %v", err) - } - - part, err := message.New(message.Header{h}, msg.GetBody(&partBodySection)) - if err != nil { - return nil, nil, fmt.Errorf("failed to create message reader: %v", err) - } - - return &imapMessage{msg}, part, nil -} diff --git a/plugin_go.go b/plugin_go.go index 30858b5..1ae0562 100644 --- a/plugin_go.go +++ b/plugin_go.go @@ -37,7 +37,7 @@ func (p *goPlugin) SetRoutes(group *echo.Group) { group.Add(r.Method, r.Path, r.Handler) } - group.Static("/assets", pluginDir + "/" + p.p.Name + "/public/assets") + group.Static("/plugins/" + p.p.Name + "/assets", pluginDir + "/" + p.p.Name + "/public/assets") } func (p *goPlugin) Inject(name string, data interface{}) error { diff --git a/plugin_lua.go b/plugin_lua.go index 9354de7..55c1d10 100644 --- a/plugin_lua.go +++ b/plugin_lua.go @@ -117,6 +117,9 @@ func (p *luaPlugin) SetRoutes(group *echo.Group) { return nil }) } + + _, name := filepath.Split(filepath.Dir(p.filename)) + group.Static("/plugins/" + name + "/assets", filepath.Dir(p.filename) + "/public/assets") } func (p *luaPlugin) Close() error { diff --git a/plugins/base/handlers.go b/plugins/base/handlers.go new file mode 100644 index 0000000..3160026 --- /dev/null +++ b/plugins/base/handlers.go @@ -0,0 +1,285 @@ +package koushinbase + +import ( + "fmt" + "io/ioutil" + "mime" + "net/http" + "net/url" + "strconv" + "strings" + + "git.sr.ht/~emersion/koushin" + "github.com/emersion/go-imap" + imapclient "github.com/emersion/go-imap/client" + "github.com/emersion/go-message" + "github.com/labstack/echo/v4" +) + +type MailboxRenderData struct { + koushin.RenderData + Mailbox *imap.MailboxStatus + Mailboxes []*imap.MailboxInfo + Messages []imapMessage + PrevPage, NextPage int +} + +func handleGetMailbox(ectx echo.Context) error { + ctx := ectx.(*koushin.Context) + + mboxName, err := url.PathUnescape(ctx.Param("mbox")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + page := 0 + if pageStr := ctx.QueryParam("page"); pageStr != "" { + var err error + if page, err = strconv.Atoi(pageStr); err != nil || page < 0 { + return echo.NewHTTPError(http.StatusBadRequest, "invalid page index") + } + } + + var mailboxes []*imap.MailboxInfo + var msgs []imapMessage + var mbox *imap.MailboxStatus + err = ctx.Session.Do(func(c *imapclient.Client) error { + var err error + if mailboxes, err = listMailboxes(c); err != nil { + return err + } + if msgs, err = listMessages(c, mboxName, page); err != nil { + return err + } + mbox = c.Mailbox() + return nil + }) + if err != nil { + return err + } + + prevPage, nextPage := -1, -1 + if page > 0 { + prevPage = page - 1 + } + if (page+1)*messagesPerPage < int(mbox.Messages) { + nextPage = page + 1 + } + + return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{ + RenderData: *koushin.NewRenderData(ctx), + Mailbox: mbox, + Mailboxes: mailboxes, + Messages: msgs, + PrevPage: prevPage, + NextPage: nextPage, + }) +} + +func handleLogin(ectx echo.Context) error { + ctx := ectx.(*koushin.Context) + + username := ctx.FormValue("username") + password := ctx.FormValue("password") + if username != "" && password != "" { + s, err := ctx.Server.Sessions.Put(username, password) + if err != nil { + if _, ok := err.(koushin.AuthError); ok { + return ctx.Render(http.StatusOK, "login.html", nil) + } + return fmt.Errorf("failed to put connection in pool: %v", err) + } + ctx.SetSession(s) + + return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") + } + + return ctx.Render(http.StatusOK, "login.html", koushin.NewRenderData(ctx)) +} + +func handleLogout(ectx echo.Context) error { + ctx := ectx.(*koushin.Context) + + ctx.Session.Close() + ctx.SetSession(nil) + return ctx.Redirect(http.StatusFound, "/login") +} + +type MessageRenderData struct { + koushin.RenderData + Mailbox *imap.MailboxStatus + Message *imapMessage + Body string + PartPath string + MailboxPage int +} + +func handleGetPart(ctx *koushin.Context, raw bool) error { + mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + partPathString := ctx.QueryParam("part") + partPath, err := parsePartPath(partPathString) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + var msg *imapMessage + var part *message.Entity + var mbox *imap.MailboxStatus + err = ctx.Session.Do(func(c *imapclient.Client) error { + var err error + msg, part, err = getMessagePart(c, mboxName, uid, partPath) + mbox = c.Mailbox() + return err + }) + if err != nil { + return err + } + + mimeType, _, err := part.Header.ContentType() + if err != nil { + return fmt.Errorf("failed to parse part Content-Type: %v", err) + } + if len(partPath) == 0 { + mimeType = "message/rfc822" + } + + if raw { + disp, dispParams, _ := part.Header.ContentDisposition() + filename := dispParams["filename"] + + // TODO: set Content-Length if possible + + if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") { + dispParams := make(map[string]string) + if filename != "" { + dispParams["filename"] = filename + } + disp := mime.FormatMediaType("attachment", dispParams) + ctx.Response().Header().Set("Content-Disposition", disp) + } + return ctx.Stream(http.StatusOK, mimeType, part.Body) + } + + var body string + if strings.HasPrefix(strings.ToLower(mimeType), "text/") { + b, err := ioutil.ReadAll(part.Body) + if err != nil { + return fmt.Errorf("failed to read part body: %v", err) + } + body = string(b) + } + + return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{ + RenderData: *koushin.NewRenderData(ctx), + Mailbox: mbox, + Message: msg, + Body: body, + PartPath: partPathString, + MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage, + }) +} + +type ComposeRenderData struct { + koushin.RenderData + Message *OutgoingMessage +} + +func handleCompose(ectx echo.Context) error { + ctx := ectx.(*koushin.Context) + + var msg OutgoingMessage + if strings.ContainsRune(ctx.Session.Username(), '@') { + msg.From = ctx.Session.Username() + } + + if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" { + // This is a reply + mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + partPath, err := parsePartPath(ctx.QueryParam("part")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + var inReplyTo *imapMessage + var part *message.Entity + err = ctx.Session.Do(func(c *imapclient.Client) error { + var err error + inReplyTo, part, err = getMessagePart(c, mboxName, uid, partPath) + return err + }) + if err != nil { + return err + } + + mimeType, _, err := part.Header.ContentType() + if err != nil { + return fmt.Errorf("failed to parse part Content-Type: %v", err) + } + + if !strings.HasPrefix(strings.ToLower(mimeType), "text/") { + err := fmt.Errorf("cannot reply to \"%v\" part", mimeType) + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + msg.Text, err = quote(part.Body) + if err != nil { + return err + } + + msg.InReplyTo = inReplyTo.Envelope.MessageId + // TODO: populate From from known user addresses and inReplyTo.Envelope.To + replyTo := inReplyTo.Envelope.ReplyTo + if len(replyTo) == 0 { + replyTo = inReplyTo.Envelope.From + } + if len(replyTo) > 0 { + msg.To = make([]string, len(replyTo)) + for i, to := range replyTo { + msg.To[i] = to.Address() + } + } + msg.Subject = inReplyTo.Envelope.Subject + if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") { + msg.Subject = "Re: " + msg.Subject + } + } + + if ctx.Request().Method == http.MethodPost { + msg.From = ctx.FormValue("from") + msg.To = parseAddressList(ctx.FormValue("to")) + msg.Subject = ctx.FormValue("subject") + msg.Text = ctx.FormValue("text") + msg.InReplyTo = ctx.FormValue("in_reply_to") + + c, err := ctx.Session.ConnectSMTP() + if err != nil { + if _, ok := err.(koushin.AuthError); ok { + return echo.NewHTTPError(http.StatusForbidden, err) + } + return err + } + + if err := sendMessage(c, &msg); err != nil { + return err + } + + if err := c.Quit(); err != nil { + return fmt.Errorf("QUIT failed: %v", err) + } + + // TODO: append to IMAP Sent mailbox + + return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") + } + + return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{ + RenderData: *koushin.NewRenderData(ctx), + Message: &msg, + }) +} diff --git a/plugins/base/imap.go b/plugins/base/imap.go new file mode 100644 index 0000000..93f3c4e --- /dev/null +++ b/plugins/base/imap.go @@ -0,0 +1,277 @@ +package koushinbase + +import ( + "bufio" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/emersion/go-imap" + imapclient "github.com/emersion/go-imap/client" + "github.com/emersion/go-message" + "github.com/emersion/go-message/textproto" +) + +func listMailboxes(conn *imapclient.Client) ([]*imap.MailboxInfo, error) { + ch := make(chan *imap.MailboxInfo, 10) + done := make(chan error, 1) + go func() { + done <- conn.List("", "*", ch) + }() + + var mailboxes []*imap.MailboxInfo + for mbox := range ch { + mailboxes = append(mailboxes, mbox) + } + + if err := <-done; err != nil { + return nil, fmt.Errorf("failed to list mailboxes: %v", err) + } + + sort.Slice(mailboxes, func(i, j int) bool { + return mailboxes[i].Name < mailboxes[j].Name + }) + return mailboxes, nil +} + +func ensureMailboxSelected(conn *imapclient.Client, mboxName string) error { + mbox := conn.Mailbox() + if mbox == nil || mbox.Name != mboxName { + if _, err := conn.Select(mboxName, false); err != nil { + return fmt.Errorf("failed to select mailbox: %v", err) + } + } + return nil +} + +type imapMessage struct { + *imap.Message +} + +func textPartPath(bs *imap.BodyStructure) ([]int, bool) { + if bs.Disposition != "" && !strings.EqualFold(bs.Disposition, "inline") { + return nil, false + } + + if strings.EqualFold(bs.MIMEType, "text") { + return []int{1}, true + } + + if !strings.EqualFold(bs.MIMEType, "multipart") { + return nil, false + } + + textPartNum := -1 + for i, part := range bs.Parts { + num := i + 1 + + if strings.EqualFold(part.MIMEType, "multipart") { + if subpath, ok := textPartPath(part); ok { + return append([]int{num}, subpath...), true + } + } + if !strings.EqualFold(part.MIMEType, "text") { + continue + } + + var pick bool + switch strings.ToLower(part.MIMESubType) { + case "plain": + pick = true + case "html": + pick = textPartNum < 0 + } + + if pick { + textPartNum = num + } + } + + if textPartNum > 0 { + return []int{textPartNum}, true + } + return nil, false +} + +func (msg *imapMessage) TextPartName() string { + if msg.BodyStructure == nil { + return "" + } + + path, ok := textPartPath(msg.BodyStructure) + if !ok { + return "" + } + + l := make([]string, len(path)) + for i, partNum := range path { + l[i] = strconv.Itoa(partNum) + } + + return strings.Join(l, ".") +} + +type IMAPPartNode struct { + Path []int + MIMEType string + Filename string + Children []IMAPPartNode +} + +func (node IMAPPartNode) PathString() string { + l := make([]string, len(node.Path)) + for i, partNum := range node.Path { + l[i] = strconv.Itoa(partNum) + } + + return strings.Join(l, ".") +} + +func (node IMAPPartNode) IsText() bool { + return strings.HasPrefix(strings.ToLower(node.MIMEType), "text/") +} + +func (node IMAPPartNode) String() string { + if node.Filename != "" { + return fmt.Sprintf("%s (%s)", node.Filename, node.MIMEType) + } else { + return node.MIMEType + } +} + +func imapPartTree(bs *imap.BodyStructure, path []int) *IMAPPartNode { + if !strings.EqualFold(bs.MIMEType, "multipart") && len(path) == 0 { + path = []int{1} + } + + filename, _ := bs.Filename() + + node := &IMAPPartNode{ + Path: path, + MIMEType: strings.ToLower(bs.MIMEType + "/" + bs.MIMESubType), + Filename: filename, + Children: make([]IMAPPartNode, len(bs.Parts)), + } + + for i, part := range bs.Parts { + num := i + 1 + + partPath := append([]int(nil), path...) + partPath = append(partPath, num) + + node.Children[i] = *imapPartTree(part, partPath) + } + + return node +} + +func (msg *imapMessage) PartTree() *IMAPPartNode { + if msg.BodyStructure == nil { + return nil + } + + return imapPartTree(msg.BodyStructure, nil) +} + +func listMessages(conn *imapclient.Client, mboxName string, page int) ([]imapMessage, error) { + if err := ensureMailboxSelected(conn, mboxName); err != nil { + return nil, err + } + + mbox := conn.Mailbox() + to := int(mbox.Messages) - page*messagesPerPage + from := to - messagesPerPage + 1 + if from <= 0 { + from = 1 + } + if to <= 0 { + return nil, nil + } + + seqSet := new(imap.SeqSet) + seqSet.AddRange(uint32(from), uint32(to)) + + fetch := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchBodyStructure} + + ch := make(chan *imap.Message, 10) + done := make(chan error, 1) + go func() { + done <- conn.Fetch(seqSet, fetch, ch) + }() + + msgs := make([]imapMessage, 0, to-from) + for msg := range ch { + msgs = append(msgs, imapMessage{msg}) + } + + if err := <-done; err != nil { + return nil, fmt.Errorf("failed to fetch message list: %v", err) + } + + // Reverse list of messages + for i := len(msgs)/2 - 1; i >= 0; i-- { + opp := len(msgs) - 1 - i + msgs[i], msgs[opp] = msgs[opp], msgs[i] + } + + return msgs, nil +} + +func getMessagePart(conn *imapclient.Client, mboxName string, uid uint32, partPath []int) (*imapMessage, *message.Entity, error) { + if err := ensureMailboxSelected(conn, mboxName); err != nil { + return nil, nil, err + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uid) + + var partHeaderSection imap.BodySectionName + partHeaderSection.Peek = true + if len(partPath) > 0 { + partHeaderSection.Specifier = imap.MIMESpecifier + } else { + partHeaderSection.Specifier = imap.HeaderSpecifier + } + partHeaderSection.Path = partPath + + var partBodySection imap.BodySectionName + partBodySection.Peek = true + if len(partPath) > 0 { + partBodySection.Specifier = imap.EntireSpecifier + } else { + partBodySection.Specifier = imap.TextSpecifier + } + partBodySection.Path = partPath + + fetch := []imap.FetchItem{ + imap.FetchEnvelope, + imap.FetchUid, + imap.FetchBodyStructure, + partHeaderSection.FetchItem(), + partBodySection.FetchItem(), + } + + ch := make(chan *imap.Message, 1) + if err := conn.UidFetch(seqSet, fetch, ch); err != nil { + return nil, nil, fmt.Errorf("failed to fetch message: %v", err) + } + + msg := <-ch + if msg == nil { + return nil, nil, fmt.Errorf("server didn't return message") + } + + headerReader := bufio.NewReader(msg.GetBody(&partHeaderSection)) + h, err := textproto.ReadHeader(headerReader) + if err != nil { + return nil, nil, fmt.Errorf("failed to read part header: %v", err) + } + + part, err := message.New(message.Header{h}, msg.GetBody(&partBodySection)) + if err != nil { + return nil, nil, fmt.Errorf("failed to create message reader: %v", err) + } + + return &imapMessage{msg}, part, nil +} diff --git a/plugins/base/plugin.go b/plugins/base/plugin.go new file mode 100644 index 0000000..906730d --- /dev/null +++ b/plugins/base/plugin.go @@ -0,0 +1,48 @@ +package koushinbase + +import ( + "html/template" + "net/url" + + "git.sr.ht/~emersion/koushin" + "github.com/labstack/echo/v4" +) + +const messagesPerPage = 50 + +func init() { + p := koushin.GoPlugin{Name: "base"} + + p.TemplateFuncs(template.FuncMap{ + "tuple": func(values ...interface{}) []interface{} { + return values + }, + "pathescape": func(s string) string { + return url.PathEscape(s) + }, + }) + + p.GET("/mailbox/:mbox", handleGetMailbox) + + p.GET("/message/:mbox/:uid", func(ectx echo.Context) error { + ctx := ectx.(*koushin.Context) + return handleGetPart(ctx, false) + }) + p.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error { + ctx := ectx.(*koushin.Context) + return handleGetPart(ctx, true) + }) + + p.GET("/login", handleLogin) + p.POST("/login", handleLogin) + + p.GET("/logout", handleLogout) + + p.GET("/compose", handleCompose) + p.POST("/compose", handleCompose) + + p.GET("/message/:mbox/:uid/reply", handleCompose) + p.POST("/message/:mbox/:uid/reply", handleCompose) + + koushin.RegisterPlugin(p.Plugin()) +} diff --git a/plugins/base/public/assets/style.css b/plugins/base/public/assets/style.css new file mode 100644 index 0000000..8f414f5 --- /dev/null +++ b/plugins/base/public/assets/style.css @@ -0,0 +1 @@ +/* TODO */ diff --git a/plugins/base/public/compose.html b/plugins/base/public/compose.html new file mode 100644 index 0000000..2a52675 --- /dev/null +++ b/plugins/base/public/compose.html @@ -0,0 +1,26 @@ +{{template "head.html"}} + +

koushin

+ +

+ Back +

+ +

Compose new message

+ +
+ + +

From:

+ +

To:

+ +

Subject:

+ +

Body:

+ +

+ +
+ +{{template "foot.html"}} diff --git a/plugins/base/public/foot.html b/plugins/base/public/foot.html new file mode 100644 index 0000000..b605728 --- /dev/null +++ b/plugins/base/public/foot.html @@ -0,0 +1,2 @@ + + diff --git a/plugins/base/public/head.html b/plugins/base/public/head.html new file mode 100644 index 0000000..bed1bb3 --- /dev/null +++ b/plugins/base/public/head.html @@ -0,0 +1,8 @@ + + + + + koushin + + + diff --git a/plugins/base/public/login.html b/plugins/base/public/login.html new file mode 100644 index 0000000..6ae1737 --- /dev/null +++ b/plugins/base/public/login.html @@ -0,0 +1,14 @@ +{{template "head.html"}} + +

koushin

+ +
+ + + + +

+ +
+ +{{template "foot.html"}} diff --git a/plugins/base/public/mailbox.html b/plugins/base/public/mailbox.html new file mode 100644 index 0000000..ddd1260 --- /dev/null +++ b/plugins/base/public/mailbox.html @@ -0,0 +1,45 @@ +{{template "head.html"}} + +

koushin

+ +

+ Logout · Compose +

+ +

{{.Mailbox.Name}}

+ +

Mailboxes:

+
    + {{range .Mailboxes}} +
  • {{.Name}}
  • + {{end}} +
+ +{{if .Messages}} +

Messages:

+ + +

+ {{if ge .PrevPage 0}} + Prev + {{end}} + {{if and (ge .PrevPage 0) (ge .NextPage 0)}}·{{end}} + {{if ge .NextPage 0}} + Next + {{end}} +

+{{else}} +

Mailbox is empty.

+{{end}} + +{{template "foot.html"}} diff --git a/plugins/base/public/message.html b/plugins/base/public/message.html new file mode 100644 index 0000000..729937d --- /dev/null +++ b/plugins/base/public/message.html @@ -0,0 +1,58 @@ +{{template "head.html"}} + +

koushin

+ +

+ + Back + +

+ +

+ {{if .Message.Envelope.Subject}} + {{.Message.Envelope.Subject}} + {{else}} + (No subject) + {{end}} +

+ +{{define "message-part-tree"}} + {{/* nested templates can't access the parent's context */}} + {{$ = index . 0}} + {{with index . 1}} + + {{if eq $.PartPath .PathString}}{{end}} + {{.String}} + {{if eq $.PartPath .PathString}}{{end}} + + {{if gt (len .Children) 0}} +
    + {{range .Children}} +
  • {{template "message-part-tree" (tuple $ .)}}
  • + {{end}} +
+ {{end}} + {{end}} +{{end}} + +

Parts:

+ +{{template "message-part-tree" (tuple $ .Message.PartTree)}} + +
+ +{{if .Body}} +

Reply

+
{{.Body}}
+{{else}} +

Can't preview this message part.

+ Download +{{end}} + +{{template "foot.html"}} diff --git a/plugins/base/smtp.go b/plugins/base/smtp.go new file mode 100644 index 0000000..9ade78f --- /dev/null +++ b/plugins/base/smtp.go @@ -0,0 +1,114 @@ +package koushinbase + +import ( + "bufio" + "fmt" + "io" + "strings" + "time" + + "github.com/emersion/go-message/mail" + "github.com/emersion/go-smtp" +) + +func quote(r io.Reader) (string, error) { + scanner := bufio.NewScanner(r) + var builder strings.Builder + for scanner.Scan() { + builder.WriteString("> ") + builder.Write(scanner.Bytes()) + builder.WriteString("\n") + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("quote: failed to read original message: %s", err) + } + return builder.String(), nil +} + +type OutgoingMessage struct { + From string + To []string + Subject string + InReplyTo string + Text string +} + +func (msg *OutgoingMessage) ToString() string { + return strings.Join(msg.To, ", ") +} + +func (msg *OutgoingMessage) WriteTo(w io.Writer) error { + from := []*mail.Address{{"", msg.From}} + + to := make([]*mail.Address, len(msg.To)) + for i, addr := range msg.To { + to[i] = &mail.Address{"", addr} + } + + var h mail.Header + h.SetDate(time.Now()) + h.SetAddressList("From", from) + h.SetAddressList("To", to) + if msg.Subject != "" { + h.SetText("Subject", msg.Subject) + } + if msg.InReplyTo != "" { + h.Set("In-Reply-To", msg.InReplyTo) + } + + mw, err := mail.CreateWriter(w, h) + if err != nil { + return fmt.Errorf("failed to create mail writer: %v", err) + } + + var th mail.InlineHeader + th.Set("Content-Type", "text/plain; charset=utf-8") + + tw, err := mw.CreateSingleInline(th) + if err != nil { + return fmt.Errorf("failed to create text part: %v", err) + } + defer tw.Close() + + if _, err := io.WriteString(tw, msg.Text); err != nil { + return fmt.Errorf("failed to write text part: %v", err) + } + + if err := tw.Close(); err != nil { + return fmt.Errorf("failed to close text part: %v", err) + } + + if err := mw.Close(); err != nil { + return fmt.Errorf("failed to close mail writer: %v", err) + } + + return nil +} + +func sendMessage(c *smtp.Client, msg *OutgoingMessage) error { + if err := c.Mail(msg.From, nil); err != nil { + return fmt.Errorf("MAIL FROM failed: %v", err) + } + + for _, to := range msg.To { + if err := c.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO failed: %v", err) + } + } + + w, err := c.Data() + if err != nil { + return fmt.Errorf("DATA failed: %v", err) + } + defer w.Close() + + if err := msg.WriteTo(w); err != nil { + return fmt.Errorf("failed to write outgoing message: %v", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("failed to close SMTP data writer: %v", err) + } + + return nil +} diff --git a/plugins/base/strconv.go b/plugins/base/strconv.go new file mode 100644 index 0000000..1a32e75 --- /dev/null +++ b/plugins/base/strconv.go @@ -0,0 +1,57 @@ +package koushinbase + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +func parseUid(s string) (uint32, error) { + uid, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid UID: %v", err) + } + if uid == 0 { + return 0, fmt.Errorf("UID must be non-zero") + } + return uint32(uid), nil +} + +func parseMboxAndUid(mboxString, uidString string) (string, uint32, error) { + mboxName, err := url.PathUnescape(mboxString) + if err != nil { + return "", 0, fmt.Errorf("invalid mailbox name: %v", err) + } + uid, err := parseUid(uidString) + return mboxName, uid, err +} + +func parsePartPath(s string) ([]int, error) { + if s == "" { + return nil, nil + } + + l := strings.Split(s, ".") + path := make([]int, len(l)) + for i, s := range l { + var err error + path[i], err = strconv.Atoi(s) + if err != nil { + return nil, err + } + + if path[i] <= 0 { + return nil, fmt.Errorf("part num must be strictly positive") + } + } + return path, nil +} + +func parseAddressList(s string) []string { + l := strings.Split(s, ",") + for i, addr := range l { + l[i] = strings.TrimSpace(addr) + } + return l +} diff --git a/public/assets/style.css b/public/assets/style.css deleted file mode 100644 index 8f414f5..0000000 --- a/public/assets/style.css +++ /dev/null @@ -1 +0,0 @@ -/* TODO */ diff --git a/public/compose.html b/public/compose.html deleted file mode 100644 index 2a52675..0000000 --- a/public/compose.html +++ /dev/null @@ -1,26 +0,0 @@ -{{template "head.html"}} - -

koushin

- -

- Back -

- -

Compose new message

- -
- - -

From:

- -

To:

- -

Subject:

- -

Body:

- -

- -
- -{{template "foot.html"}} diff --git a/public/foot.html b/public/foot.html deleted file mode 100644 index b605728..0000000 --- a/public/foot.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/public/head.html b/public/head.html deleted file mode 100644 index 35dda42..0000000 --- a/public/head.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - koushin - - - diff --git a/public/login.html b/public/login.html deleted file mode 100644 index 6ae1737..0000000 --- a/public/login.html +++ /dev/null @@ -1,14 +0,0 @@ -{{template "head.html"}} - -

koushin

- -
- - - - -

- -
- -{{template "foot.html"}} diff --git a/public/mailbox.html b/public/mailbox.html deleted file mode 100644 index ddd1260..0000000 --- a/public/mailbox.html +++ /dev/null @@ -1,45 +0,0 @@ -{{template "head.html"}} - -

koushin

- -

- Logout · Compose -

- -

{{.Mailbox.Name}}

- -

Mailboxes:

-
    - {{range .Mailboxes}} -
  • {{.Name}}
  • - {{end}} -
- -{{if .Messages}} -

Messages:

- - -

- {{if ge .PrevPage 0}} - Prev - {{end}} - {{if and (ge .PrevPage 0) (ge .NextPage 0)}}·{{end}} - {{if ge .NextPage 0}} - Next - {{end}} -

-{{else}} -

Mailbox is empty.

-{{end}} - -{{template "foot.html"}} diff --git a/public/message.html b/public/message.html deleted file mode 100644 index 729937d..0000000 --- a/public/message.html +++ /dev/null @@ -1,58 +0,0 @@ -{{template "head.html"}} - -

koushin

- -

- - Back - -

- -

- {{if .Message.Envelope.Subject}} - {{.Message.Envelope.Subject}} - {{else}} - (No subject) - {{end}} -

- -{{define "message-part-tree"}} - {{/* nested templates can't access the parent's context */}} - {{$ = index . 0}} - {{with index . 1}} - - {{if eq $.PartPath .PathString}}{{end}} - {{.String}} - {{if eq $.PartPath .PathString}}{{end}} - - {{if gt (len .Children) 0}} -
    - {{range .Children}} -
  • {{template "message-part-tree" (tuple $ .)}}
  • - {{end}} -
- {{end}} - {{end}} -{{end}} - -

Parts:

- -{{template "message-part-tree" (tuple $ .Message.PartTree)}} - -
- -{{if .Body}} -

Reply

-
{{.Body}}
-{{else}} -

Can't preview this message part.

- Download -{{end}} - -{{template "foot.html"}} diff --git a/server.go b/server.go index 98b5fb2..f2d4c11 100644 --- a/server.go +++ b/server.go @@ -12,8 +12,6 @@ import ( const cookieName = "koushin_session" -const messagesPerPage = 50 - // Server holds all the koushin server state. type Server struct { Sessions *SessionManager @@ -76,7 +74,6 @@ func (s *Server) parseSMTPURL(smtpURL string) error { func newServer(imapURL, smtpURL string) (*Server, error) { s := &Server{} - s.Sessions = newSessionManager(s.connectIMAP) if err := s.parseIMAPURL(imapURL); err != nil { return nil, err @@ -88,6 +85,8 @@ func newServer(imapURL, smtpURL string) (*Server, error) { } } + s.Sessions = newSessionManager(s.dialIMAP, s.dialSMTP) + return s, nil } @@ -121,8 +120,11 @@ func (ctx *Context) SetSession(s *Session) { } func isPublic(path string) bool { - return path == "/login" || strings.HasPrefix(path, "/assets/") || - strings.HasPrefix(path, "/themes/") + if strings.HasPrefix(path, "/plugins/") { + parts := strings.Split(path, "/") + return len(parts) >= 4 && parts[3] == "assets" + } + return path == "/login" || strings.HasPrefix(path, "/themes/") } type Options struct { @@ -194,29 +196,6 @@ func New(e *echo.Echo, options *Options) error { } }) - e.GET("/mailbox/:mbox", handleGetMailbox) - - e.GET("/message/:mbox/:uid", func(ectx echo.Context) error { - ctx := ectx.(*Context) - return handleGetPart(ctx, false) - }) - e.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error { - ctx := ectx.(*Context) - return handleGetPart(ctx, true) - }) - - e.GET("/login", handleLogin) - e.POST("/login", handleLogin) - - e.GET("/logout", handleLogout) - - e.GET("/compose", handleCompose) - e.POST("/compose", handleCompose) - - e.GET("/message/:mbox/:uid/reply", handleCompose) - e.POST("/message/:mbox/:uid/reply", handleCompose) - - e.Static("/assets", "public/assets") e.Static("/themes", "public/themes") for _, p := range s.Plugins { diff --git a/session.go b/session.go index 8fafd57..8f3f748 100644 --- a/session.go +++ b/session.go @@ -9,6 +9,8 @@ import ( "time" imapclient "github.com/emersion/go-imap/client" + "github.com/emersion/go-smtp" + "github.com/emersion/go-sasl" ) // TODO: make this configurable @@ -51,6 +53,11 @@ func (s *Session) ping() { s.pings <- struct{}{} } +// Username returns the session's username. +func (s *Session) Username() string { + return s.username +} + // Do executes an IMAP operation on this session. The IMAP client can only be // used from inside f. func (s *Session) Do(f func(*imapclient.Client) error) error { @@ -69,6 +76,23 @@ func (s *Session) Do(f func(*imapclient.Client) error) error { return f(s.imapConn) } +// ConnectSMTP connects to the upstream SMTP server and authenticates this +// session. +func (s *Session) ConnectSMTP() (*smtp.Client, error) { + c, err := s.manager.dialSMTP() + if err != nil { + return nil, err + } + + auth := sasl.NewPlainClient("", s.username, s.password) + if err := c.Auth(auth); err != nil { + c.Close() + return nil, AuthError{err} + } + + return c, nil +} + // Close destroys the session. This can be used to log the user out. func (s *Session) Close() { select { @@ -79,24 +103,33 @@ func (s *Session) Close() { } } +type ( + // DialIMAPFunc connects to the upstream IMAP server. + DialIMAPFunc func() (*imapclient.Client, error) + // DialSMTPFunc connects to the upstream SMTP server. + DialSMTPFunc func() (*smtp.Client, error) +) + // SessionManager keeps track of active sessions. It connects and re-connects // to the upstream IMAP server as necessary. It prunes expired sessions. type SessionManager struct { - newIMAPClient func() (*imapclient.Client, error) + dialIMAP DialIMAPFunc + dialSMTP DialSMTPFunc locker sync.Mutex sessions map[string]*Session // protected by locker } -func newSessionManager(newIMAPClient func() (*imapclient.Client, error)) *SessionManager { +func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc) *SessionManager { return &SessionManager{ sessions: make(map[string]*Session), - newIMAPClient: newIMAPClient, + dialIMAP: dialIMAP, + dialSMTP: dialSMTP, } } func (sm *SessionManager) connect(username, password string) (*imapclient.Client, error) { - c, err := sm.newIMAPClient() + c, err := sm.dialIMAP() if err != nil { return nil, err } diff --git a/smtp.go b/smtp.go index b9d77b2..0eabf4d 100644 --- a/smtp.go +++ b/smtp.go @@ -1,31 +1,16 @@ package koushin import ( - "bufio" "fmt" - "io" - "strings" - "time" - "github.com/emersion/go-message/mail" "github.com/emersion/go-smtp" ) -func quote(r io.Reader) (string, error) { - scanner := bufio.NewScanner(r) - var builder strings.Builder - for scanner.Scan() { - builder.WriteString("> ") - builder.Write(scanner.Bytes()) - builder.WriteString("\n") +func (s *Server) dialSMTP() (*smtp.Client, error) { + if s.smtp.host == "" { + return nil, fmt.Errorf("SMTP is disabled") } - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("quote: failed to read original message: %s", err) - } - return builder.String(), nil -} -func (s *Server) connectSMTP() (*smtp.Client, error) { var c *smtp.Client var err error if s.smtp.tls { @@ -48,91 +33,3 @@ func (s *Server) connectSMTP() (*smtp.Client, error) { return c, err } - -type OutgoingMessage struct { - From string - To []string - Subject string - InReplyTo string - Text string -} - -func (msg *OutgoingMessage) ToString() string { - return strings.Join(msg.To, ", ") -} - -func (msg *OutgoingMessage) WriteTo(w io.Writer) error { - from := []*mail.Address{{"", msg.From}} - - to := make([]*mail.Address, len(msg.To)) - for i, addr := range msg.To { - to[i] = &mail.Address{"", addr} - } - - var h mail.Header - h.SetDate(time.Now()) - h.SetAddressList("From", from) - h.SetAddressList("To", to) - if msg.Subject != "" { - h.SetText("Subject", msg.Subject) - } - if msg.InReplyTo != "" { - h.Set("In-Reply-To", msg.InReplyTo) - } - - mw, err := mail.CreateWriter(w, h) - if err != nil { - return fmt.Errorf("failed to create mail writer: %v", err) - } - - var th mail.InlineHeader - th.Set("Content-Type", "text/plain; charset=utf-8") - - tw, err := mw.CreateSingleInline(th) - if err != nil { - return fmt.Errorf("failed to create text part: %v", err) - } - defer tw.Close() - - if _, err := io.WriteString(tw, msg.Text); err != nil { - return fmt.Errorf("failed to write text part: %v", err) - } - - if err := tw.Close(); err != nil { - return fmt.Errorf("failed to close text part: %v", err) - } - - if err := mw.Close(); err != nil { - return fmt.Errorf("failed to close mail writer: %v", err) - } - - return nil -} - -func sendMessage(c *smtp.Client, msg *OutgoingMessage) error { - if err := c.Mail(msg.From, nil); err != nil { - return fmt.Errorf("MAIL FROM failed: %v", err) - } - - for _, to := range msg.To { - if err := c.Rcpt(to); err != nil { - return fmt.Errorf("RCPT TO failed: %v", err) - } - } - - w, err := c.Data() - if err != nil { - return fmt.Errorf("DATA failed: %v", err) - } - defer w.Close() - - if err := msg.WriteTo(w); err != nil { - return fmt.Errorf("failed to write outgoing message: %v", err) - } - - if err := w.Close(); err != nil { - return fmt.Errorf("failed to close SMTP data writer: %v", err) - } - - return nil -} diff --git a/strconv.go b/strconv.go deleted file mode 100644 index 63cbffc..0000000 --- a/strconv.go +++ /dev/null @@ -1,57 +0,0 @@ -package koushin - -import ( - "fmt" - "net/url" - "strconv" - "strings" -) - -func parseUid(s string) (uint32, error) { - uid, err := strconv.ParseUint(s, 10, 32) - if err != nil { - return 0, fmt.Errorf("invalid UID: %v", err) - } - if uid == 0 { - return 0, fmt.Errorf("UID must be non-zero") - } - return uint32(uid), nil -} - -func parseMboxAndUid(mboxString, uidString string) (string, uint32, error) { - mboxName, err := url.PathUnescape(mboxString) - if err != nil { - return "", 0, fmt.Errorf("invalid mailbox name: %v", err) - } - uid, err := parseUid(uidString) - return mboxName, uid, err -} - -func parsePartPath(s string) ([]int, error) { - if s == "" { - return nil, nil - } - - l := strings.Split(s, ".") - path := make([]int, len(l)) - for i, s := range l { - var err error - path[i], err = strconv.Atoi(s) - if err != nil { - return nil, err - } - - if path[i] <= 0 { - return nil, fmt.Errorf("part num must be strictly positive") - } - } - return path, nil -} - -func parseAddressList(s string) []string { - l := strings.Split(s, ",") - for i, addr := range l { - l[i] = strings.TrimSpace(addr) - } - return l -} diff --git a/template.go b/template.go index cdcbf66..e5078c5 100644 --- a/template.go +++ b/template.go @@ -5,7 +5,6 @@ import ( "html/template" "io" "io/ioutil" - "net/url" "os" "github.com/labstack/echo/v4" @@ -86,19 +85,7 @@ func loadTheme(name string, base *template.Template) (*template.Template, error) } func loadTemplates(logger echo.Logger, defaultTheme string, plugins []Plugin) (*renderer, error) { - base := template.New("").Funcs(template.FuncMap{ - "tuple": func(values ...interface{}) []interface{} { - return values - }, - "pathescape": func(s string) string { - return url.PathEscape(s) - }, - }) - - base, err := base.ParseGlob("public/*.html") - if err != nil { - return nil, err - } + base := template.New("") for _, p := range plugins { if err := p.LoadTemplate(base); err != nil {