aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--imap.go155
-rw-r--r--public/mailbox.html6
-rw-r--r--server.go56
5 files changed, 211 insertions, 9 deletions
diff --git a/go.mod b/go.mod
index 07d3dac..669cb67 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index bd3e888..0e963f9 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/imap.go b/imap.go
index 869d457..3da0447 100644
--- a/imap.go
+++ b/imap.go
@@ -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>
diff --git a/server.go b/server.go
index 8245e08..86a1963 100644
--- a/server.go
+++ b/server.go
@@ -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)