diff options
author | Simon Ser <contact@emersion.fr> | 2019-12-02 19:53:09 +0100 |
---|---|---|
committer | Simon Ser <contact@emersion.fr> | 2019-12-02 19:53:09 +0100 |
commit | 25c63d05302fef11f79c30270a6e911da9010a38 (patch) | |
tree | 3dff86e0200dc6ac672093fb853acd095002203f | |
parent | fce17c9733eb38636603e8c508c2e7936426bf2c (diff) | |
download | alps-25c63d05302fef11f79c30270a6e911da9010a38.tar.gz alps-25c63d05302fef11f79c30270a6e911da9010a38.zip |
Add basic message view
-rw-r--r-- | go.mod | 1 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | imap.go | 155 | ||||
-rw-r--r-- | public/mailbox.html | 6 | ||||
-rw-r--r-- | server.go | 56 |
5 files changed, 211 insertions, 9 deletions
@@ -4,5 +4,6 @@ go 1.13 require ( github.com/emersion/go-imap v1.0.1 + github.com/emersion/go-message v0.10.4-0.20190609165112-592ace5bc1ca github.com/labstack/echo/v4 v4.1.11 ) @@ -3,9 +3,11 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/emersion/go-imap v1.0.1 h1:J3duplefIrglQtE63hCGYdGLgMjYWqHvkUUEbimbXY8= github.com/emersion/go-imap v1.0.1/go.mod h1:MEiDDwwQFcZ+L45Pa68jNGv0qU9kbW+SJzwDpvSfX1s= +github.com/emersion/go-message v0.10.4-0.20190609165112-592ace5bc1ca h1:OYhqtJI4eOLvGtRIsUfP87VMJ1J/o6ks1tah9DlYkn4= github.com/emersion/go-message v0.10.4-0.20190609165112-592ace5bc1ca/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c= github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317 h1:tYZxAY8nu3JJQKios9f27Sbvbkfm4XHXT476gVtszu0= github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg= github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= github.com/labstack/echo/v4 v4.1.11 h1:z0BZoArY4FqdpUEl+wlHp4hnr/oSR6MTmQmv8OHSoww= @@ -1,9 +1,16 @@ package koushin import ( + "bufio" + "fmt" + "io/ioutil" "sort" + "strconv" + "strings" "github.com/emersion/go-imap" + "github.com/emersion/go-message" + "github.com/emersion/go-message/textproto" imapclient "github.com/emersion/go-imap/client" ) @@ -53,17 +60,91 @@ func listMailboxes(conn *imapclient.Client) ([]*imap.MailboxInfo, error) { return mailboxes, nil } -func listMessages(conn *imapclient.Client, mboxName string) ([]*imap.Message, error) { +func ensureMailboxSelected(conn *imapclient.Client, mboxName string) error { mbox := conn.Mailbox() if mbox == nil || mbox.Name != mboxName { - var err error - mbox, err = conn.Select(mboxName, false) - if err != nil { - return nil, err + if _, err := conn.Select(mboxName, false); err != nil { + return 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, ".") +} + +func listMessages(conn *imapclient.Client, mboxName string) ([]imapMessage, error) { + if err := ensureMailboxSelected(conn, mboxName); err != nil { + return nil, err } n := uint32(10) + + mbox := conn.Mailbox() from := uint32(1) to := mbox.Messages if mbox.Messages > n { @@ -72,15 +153,17 @@ func listMessages(conn *imapclient.Client, mboxName string) ([]*imap.Message, er seqSet := new(imap.SeqSet) seqSet.AddRange(from, 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, []imap.FetchItem{imap.FetchEnvelope}, ch) + done <- conn.Fetch(seqSet, fetch, ch) }() - msgs := make([]*imap.Message, 0, n) + msgs := make([]imapMessage, 0, n) for msg := range ch { - msgs = append(msgs, msg) + msgs = append(msgs, imapMessage{msg}) } if err := <-done; err != nil { @@ -95,3 +178,59 @@ func listMessages(conn *imapclient.Client, mboxName string) ([]*imap.Message, er return msgs, nil } + +var _ = message.Read + +func getMessage(conn *imapclient.Client, mboxName string, uid uint32, partPath []int) (*imap.Message, string, error) { + if err := ensureMailboxSelected(conn, mboxName); err != nil { + return nil, "", err + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uid) + + var textHeaderSection imap.BodySectionName + textHeaderSection.Peek = true + textHeaderSection.Specifier = imap.HeaderSpecifier + textHeaderSection.Path = partPath + + var textBodySection imap.BodySectionName + textBodySection.Peek = true + textBodySection.Path = partPath + + fetch := []imap.FetchItem{ + imap.FetchEnvelope, + imap.FetchUid, + imap.FetchBodyStructure, + textHeaderSection.FetchItem(), + textBodySection.FetchItem(), + } + + ch := make(chan *imap.Message, 1) + if err := conn.UidFetch(seqSet, fetch, ch); err != nil { + return nil, "", err + } + + msg := <-ch + if msg == nil { + return nil, "", fmt.Errorf("server didn't return message") + } + + headerReader := bufio.NewReader(msg.GetBody(&textHeaderSection)) + h, err := textproto.ReadHeader(headerReader) + if err != nil { + return nil, "", err + } + + text, err := message.New(message.Header{h}, msg.GetBody(&textBodySection)) + if err != nil { + return nil, "", err + } + + b, err := ioutil.ReadAll(text.Body) + if err != nil { + return nil, "", err + } + + return msg, string(b), nil +} diff --git a/public/mailbox.html b/public/mailbox.html index efae0d9..282f462 100644 --- a/public/mailbox.html +++ b/public/mailbox.html @@ -2,6 +2,8 @@ <h1>koushin</h1> +<h2>{{.Mailbox.Name}}</h2> + <p>Mailboxes:</p> <ul> {{range .Mailboxes}} @@ -12,7 +14,9 @@ <p>Messages:</p> <ul> {{range .Messages}} - <li>{{.Envelope.Subject}}</li> + <li><a href="/message/{{$.Mailbox.Name}}/{{.Uid}}?part={{.TextPartName}}"> + {{.Envelope.Subject}} + </a></li> {{end}} </ul> @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "net/url" + "strconv" + "strings" "time" "github.com/labstack/echo/v4" @@ -94,6 +96,34 @@ func handleLogin(ectx echo.Context) error { return ctx.Render(http.StatusOK, "login.html", nil) } +func parseUid(s string) (uint32, error) { + uid, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0, err + } + if uid == 0 { + return 0, fmt.Errorf("UID must be non-zero") + } + return uint32(uid), nil +} + +func parsePartPath(s string) ([]int, error) { + 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 New(imapURL string) *echo.Echo { e := echo.New() @@ -149,11 +179,37 @@ func New(imapURL string) *echo.Echo { } return ctx.Render(http.StatusOK, "mailbox.html", map[string]interface{}{ + "Mailbox": ctx.conn.Mailbox(), "Mailboxes": mailboxes, "Messages": msgs, }) }) + e.GET("/message/:mbox/:uid", func(ectx echo.Context) error { + ctx := ectx.(*context) + + uid, err := parseUid(ctx.Param("uid")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + // TODO: handle messages without a text part + part, err := parsePartPath(ctx.QueryParam("part")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + msg, body, err := getMessage(ctx.conn, ctx.Param("mbox"), uid, part) + if err != nil { + return err + } + + return ctx.Render(http.StatusOK, "message.html", map[string]interface{}{ + "Mailbox": ctx.conn.Mailbox(), + "Message": msg, + "Body": body, + }) + }) + e.GET("/login", handleLogin) e.POST("/login", handleLogin) |