diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | cmd/koushin/main.go | 2 | ||||
-rw-r--r-- | imap.go | 271 | ||||
-rw-r--r-- | plugin_go.go | 2 | ||||
-rw-r--r-- | plugin_lua.go | 3 | ||||
-rw-r--r-- | plugins/base/handlers.go (renamed from handlers.go) | 45 | ||||
-rw-r--r-- | plugins/base/imap.go | 277 | ||||
-rw-r--r-- | plugins/base/plugin.go | 48 | ||||
-rw-r--r-- | plugins/base/public/assets/style.css (renamed from public/assets/style.css) | 0 | ||||
-rw-r--r-- | plugins/base/public/compose.html (renamed from public/compose.html) | 0 | ||||
-rw-r--r-- | plugins/base/public/foot.html (renamed from public/foot.html) | 0 | ||||
-rw-r--r-- | plugins/base/public/head.html (renamed from public/head.html) | 2 | ||||
-rw-r--r-- | plugins/base/public/login.html (renamed from public/login.html) | 0 | ||||
-rw-r--r-- | plugins/base/public/mailbox.html (renamed from public/mailbox.html) | 0 | ||||
-rw-r--r-- | plugins/base/public/message.html (renamed from public/message.html) | 0 | ||||
-rw-r--r-- | plugins/base/smtp.go | 114 | ||||
-rw-r--r-- | plugins/base/strconv.go (renamed from strconv.go) | 2 | ||||
-rw-r--r-- | server.go | 35 | ||||
-rw-r--r-- | session.go | 41 | ||||
-rw-r--r-- | smtp.go | 109 | ||||
-rw-r--r-- | template.go | 15 |
22 files changed, 521 insertions, 450 deletions
@@ -2,3 +2,4 @@ /public/themes/* !/public/themes/sourcehut /plugins/* +!/plugins/base @@ -14,7 +14,7 @@ They should be put in `public/themes/<name>/`. Templates in `public/themes/<name>/*.html` override default templates in `public/*.html`. Assets in `public/themes/<name>/assets/*` are served by the -HTTP server at `themes/<name>/assets/*`. +HTTP server at `/themes/<name>/assets/*`. ## Plugins @@ -29,6 +29,8 @@ API: called with the HTTP context Plugins can provide their own templates in `plugins/<name>/public/*.html`. +Assets in `plugins/<name>/public/assets/*` are served by the HTTP server at +`/plugins/<name>/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() { @@ -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/handlers.go b/plugins/base/handlers.go index f53085c..3160026 100644 --- a/handlers.go +++ b/plugins/base/handlers.go @@ -1,4 +1,4 @@ -package koushin +package koushinbase import ( "fmt" @@ -9,15 +9,15 @@ import ( "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/emersion/go-sasl" "github.com/labstack/echo/v4" ) type MailboxRenderData struct { - RenderData + koushin.RenderData Mailbox *imap.MailboxStatus Mailboxes []*imap.MailboxInfo Messages []imapMessage @@ -25,7 +25,7 @@ type MailboxRenderData struct { } func handleGetMailbox(ectx echo.Context) error { - ctx := ectx.(*Context) + ctx := ectx.(*koushin.Context) mboxName, err := url.PathUnescape(ctx.Param("mbox")) if err != nil { @@ -67,7 +67,7 @@ func handleGetMailbox(ectx echo.Context) error { } return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{ - RenderData: *NewRenderData(ctx), + RenderData: *koushin.NewRenderData(ctx), Mailbox: mbox, Mailboxes: mailboxes, Messages: msgs, @@ -77,14 +77,14 @@ func handleGetMailbox(ectx echo.Context) error { } func handleLogin(ectx echo.Context) error { - ctx := ectx.(*Context) + 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.(AuthError); ok { + 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) @@ -94,11 +94,11 @@ func handleLogin(ectx echo.Context) error { return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") } - return ctx.Render(http.StatusOK, "login.html", NewRenderData(ctx)) + return ctx.Render(http.StatusOK, "login.html", koushin.NewRenderData(ctx)) } func handleLogout(ectx echo.Context) error { - ctx := ectx.(*Context) + ctx := ectx.(*koushin.Context) ctx.Session.Close() ctx.SetSession(nil) @@ -106,7 +106,7 @@ func handleLogout(ectx echo.Context) error { } type MessageRenderData struct { - RenderData + koushin.RenderData Mailbox *imap.MailboxStatus Message *imapMessage Body string @@ -114,7 +114,7 @@ type MessageRenderData struct { MailboxPage int } -func handleGetPart(ctx *Context, raw bool) error { +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) @@ -173,7 +173,7 @@ func handleGetPart(ctx *Context, raw bool) error { } return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{ - RenderData: *NewRenderData(ctx), + RenderData: *koushin.NewRenderData(ctx), Mailbox: mbox, Message: msg, Body: body, @@ -183,16 +183,16 @@ func handleGetPart(ctx *Context, raw bool) error { } type ComposeRenderData struct { - RenderData + koushin.RenderData Message *OutgoingMessage } func handleCompose(ectx echo.Context) error { - ctx := ectx.(*Context) + ctx := ectx.(*koushin.Context) var msg OutgoingMessage - if strings.ContainsRune(ctx.Session.username, '@') { - msg.From = ctx.Session.username + if strings.ContainsRune(ctx.Session.Username(), '@') { + msg.From = ctx.Session.Username() } if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" { @@ -257,16 +257,13 @@ func handleCompose(ectx echo.Context) error { msg.Text = ctx.FormValue("text") msg.InReplyTo = ctx.FormValue("in_reply_to") - c, err := ctx.Server.connectSMTP() + c, err := ctx.Session.ConnectSMTP() if err != nil { + if _, ok := err.(koushin.AuthError); ok { + return echo.NewHTTPError(http.StatusForbidden, err) + } 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 @@ -282,7 +279,7 @@ func handleCompose(ectx echo.Context) error { } return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{ - RenderData: *NewRenderData(ctx), + 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/public/assets/style.css b/plugins/base/public/assets/style.css index 8f414f5..8f414f5 100644 --- a/public/assets/style.css +++ b/plugins/base/public/assets/style.css diff --git a/public/compose.html b/plugins/base/public/compose.html index 2a52675..2a52675 100644 --- a/public/compose.html +++ b/plugins/base/public/compose.html diff --git a/public/foot.html b/plugins/base/public/foot.html index b605728..b605728 100644 --- a/public/foot.html +++ b/plugins/base/public/foot.html diff --git a/public/head.html b/plugins/base/public/head.html index 35dda42..bed1bb3 100644 --- a/public/head.html +++ b/plugins/base/public/head.html @@ -3,6 +3,6 @@ <head> <meta charset="utf-8"> <title>koushin</title> - <link rel="stylesheet" href="/assets/style.css"> + <link rel="stylesheet" href="/plugins/base/assets/style.css"> </head> <body> diff --git a/public/login.html b/plugins/base/public/login.html index 6ae1737..6ae1737 100644 --- a/public/login.html +++ b/plugins/base/public/login.html diff --git a/public/mailbox.html b/plugins/base/public/mailbox.html index ddd1260..ddd1260 100644 --- a/public/mailbox.html +++ b/plugins/base/public/mailbox.html diff --git a/public/message.html b/plugins/base/public/message.html index 729937d..729937d 100644 --- a/public/message.html +++ b/plugins/base/public/message.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/strconv.go b/plugins/base/strconv.go index 63cbffc..1a32e75 100644 --- a/strconv.go +++ b/plugins/base/strconv.go @@ -1,4 +1,4 @@ -package koushin +package koushinbase import ( "fmt" @@ -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 { @@ -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 } @@ -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/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 { |