@@ -3,7 +3,6 @@ package koushin | |||
import ( | |||
"bufio" | |||
"fmt" | |||
"io/ioutil" | |||
"sort" | |||
"strconv" | |||
"strings" | |||
@@ -140,10 +139,11 @@ func (msg *imapMessage) TextPartName() string { | |||
type IMAPPartNode struct { | |||
Path []int | |||
MIMEType string | |||
Filename string | |||
Children []IMAPPartNode | |||
} | |||
func (node *IMAPPartNode) PathString() string { | |||
func (node IMAPPartNode) PathString() string { | |||
l := make([]string, len(node.Path)) | |||
for i, partNum := range node.Path { | |||
l[i] = strconv.Itoa(partNum) | |||
@@ -152,14 +152,32 @@ func (node *IMAPPartNode) PathString() string { | |||
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} | |||
} | |||
var filename string | |||
if strings.EqualFold(bs.Disposition, "attachment") { | |||
filename = bs.DispositionParams["filename"] | |||
} | |||
node := &IMAPPartNode{ | |||
Path: path, | |||
MIMEType: strings.ToLower(bs.MIMEType + "/" + bs.MIMESubType), | |||
Filename: filename, | |||
Children: make([]IMAPPartNode, len(bs.Parts)), | |||
} | |||
@@ -225,56 +243,52 @@ func listMessages(conn *imapclient.Client, mboxName string) ([]imapMessage, erro | |||
return msgs, nil | |||
} | |||
func getMessage(conn *imapclient.Client, mboxName string, uid uint32, partPath []int) (*imapMessage, string, error) { | |||
func getMessagePart(conn *imapclient.Client, mboxName string, uid uint32, partPath []int) (*imapMessage, *message.Entity, error) { | |||
if err := ensureMailboxSelected(conn, mboxName); err != nil { | |||
return nil, "", err | |||
return nil, nil, err | |||
} | |||
seqSet := new(imap.SeqSet) | |||
seqSet.AddNum(uid) | |||
var textHeaderSection imap.BodySectionName | |||
textHeaderSection.Peek = true | |||
textHeaderSection.Specifier = imap.HeaderSpecifier | |||
textHeaderSection.Path = partPath | |||
var partHeaderSection imap.BodySectionName | |||
partHeaderSection.Peek = true | |||
partHeaderSection.Specifier = imap.HeaderSpecifier | |||
partHeaderSection.Path = partPath | |||
var textBodySection imap.BodySectionName | |||
textBodySection.Peek = true | |||
textBodySection.Path = partPath | |||
var partBodySection imap.BodySectionName | |||
partBodySection.Peek = true | |||
partBodySection.Specifier = imap.TextSpecifier | |||
partBodySection.Path = partPath | |||
fetch := []imap.FetchItem{ | |||
imap.FetchEnvelope, | |||
imap.FetchUid, | |||
imap.FetchBodyStructure, | |||
textHeaderSection.FetchItem(), | |||
textBodySection.FetchItem(), | |||
partHeaderSection.FetchItem(), | |||
partBodySection.FetchItem(), | |||
} | |||
ch := make(chan *imap.Message, 1) | |||
if err := conn.UidFetch(seqSet, fetch, ch); err != nil { | |||
return nil, "", err | |||
return nil, nil, err | |||
} | |||
msg := <-ch | |||
if msg == nil { | |||
return nil, "", fmt.Errorf("server didn't return message") | |||
return nil, nil, fmt.Errorf("server didn't return message") | |||
} | |||
headerReader := bufio.NewReader(msg.GetBody(&textHeaderSection)) | |||
headerReader := bufio.NewReader(msg.GetBody(&partHeaderSection)) | |||
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 | |||
return nil, nil, err | |||
} | |||
b, err := ioutil.ReadAll(text.Body) | |||
part, err := message.New(message.Header{h}, msg.GetBody(&partBodySection)) | |||
if err != nil { | |||
return nil, "", err | |||
return nil, nil, err | |||
} | |||
return &imapMessage{msg}, string(b), nil | |||
return &imapMessage{msg}, part, nil | |||
} |
@@ -6,21 +6,42 @@ | |||
<h2>{{.Message.Envelope.Subject}}</h2> | |||
{{define "message-part"}} | |||
<a href="?part={{.PathString}}">{{.MIMEType}}</a> | |||
{{if gt (len .Children) 0}} | |||
<ul> | |||
{{range .Children}} | |||
<li>{{template "message-part" .}}</li> | |||
{{define "message-part-tree"}} | |||
{{/* nested templates can't access the parent's context */}} | |||
{{$ = index . 0}} | |||
{{with index . 1}} | |||
<a | |||
{{if .IsText}} | |||
href="{{$.Message.Uid}}?part={{.PathString}}" | |||
{{else}} | |||
href="{{$.Message.Uid}}/raw?part={{.PathString}}" | |||
{{end}} | |||
> | |||
{{if eq $.PartPath .PathString}}<strong>{{end}} | |||
{{.String}} | |||
{{if eq $.PartPath .PathString}}</strong>{{end}} | |||
</a> | |||
{{if gt (len .Children) 0}} | |||
<ul> | |||
{{range .Children}} | |||
<li>{{template "message-part-tree" (tuple $ .)}}</li> | |||
{{end}} | |||
</ul> | |||
{{end}} | |||
</ul> | |||
{{end}} | |||
{{end}} | |||
{{template "message-part" .Message.PartTree}} | |||
<p>Parts:</p> | |||
{{template "message-part-tree" (tuple $ .Message.PartTree)}} | |||
<hr> | |||
{{if .Body}} | |||
<pre>{{.Body}}</pre> | |||
{{else}} | |||
<p>Can't preview this message part.</p> | |||
<a href="{{.Message.Uid}}/raw?part={{.PartPath}}">Download</a> | |||
{{end}} | |||
{{template "foot"}} |
@@ -2,8 +2,11 @@ package koushin | |||
import ( | |||
"fmt" | |||
"io/ioutil" | |||
"mime" | |||
"net/http" | |||
"net/url" | |||
"strings" | |||
"time" | |||
imapclient "github.com/emersion/go-imap/client" | |||
@@ -94,6 +97,63 @@ func handleLogin(ectx echo.Context) error { | |||
return ctx.Render(http.StatusOK, "login.html", nil) | |||
} | |||
func handleGetPart(ctx *context, raw bool) error { | |||
mboxName := ctx.Param("mbox") | |||
uid, err := parseUid(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) | |||
} | |||
msg, part, err := getMessagePart(ctx.conn, mboxName, uid, partPath) | |||
if err != nil { | |||
return err | |||
} | |||
mimeType, _, err := part.Header.ContentType() | |||
if err != nil { | |||
return err | |||
} | |||
if len(partPath) == 0 { | |||
mimeType = "message/rfc822" | |||
} | |||
if raw { | |||
disp, dispParams, _ := part.Header.ContentDisposition() | |||
filename := dispParams["filename"] | |||
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 err | |||
} | |||
body = string(b) | |||
} | |||
return ctx.Render(http.StatusOK, "message.html", map[string]interface{}{ | |||
"Mailbox": ctx.conn.Mailbox(), | |||
"Message": msg, | |||
"Body": body, | |||
"PartPath": partPathString, | |||
}) | |||
} | |||
func New(imapURL string) *echo.Echo { | |||
e := echo.New() | |||
@@ -157,27 +217,11 @@ func New(imapURL string) *echo.Echo { | |||
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, | |||
}) | |||
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) | |||
@@ -18,6 +18,10 @@ func parseUid(s string) (uint32, error) { | |||
} | |||
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 { | |||
@@ -16,6 +16,10 @@ func (t *tmpl) Render(w io.Writer, name string, data interface{}, c echo.Context | |||
} | |||
func loadTemplates() (*tmpl, error) { | |||
t, err := template.New("drmdb").ParseGlob("public/*.html") | |||
t, err := template.New("drmdb").Funcs(template.FuncMap{ | |||
"tuple": func(values ...interface{}) []interface{} { | |||
return values | |||
}, | |||
}).ParseGlob("public/*.html") | |||
return &tmpl{t}, err | |||
} |