Display & download any message part

This commit is contained in:
Simon Ser 2019-12-03 13:07:25 +01:00
parent 33b8679f1c
commit be14524c33
No known key found for this signature in database
GPG key ID: 0FDE7BE0E88F5E48
5 changed files with 142 additions and 55 deletions

64
imap.go
View file

@ -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
return nil, nil, err
}
text, err := message.New(message.Header{h}, msg.GetBody(&textBodySection))
part, err := message.New(message.Header{h}, msg.GetBody(&partBodySection))
if err != nil {
return nil, "", err
return nil, nil, err
}
b, err := ioutil.ReadAll(text.Body)
if err != nil {
return nil, "", err
}
return &imapMessage{msg}, string(b), nil
return &imapMessage{msg}, part, nil
}

View file

@ -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"}}

View file

@ -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)

View file

@ -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 {

View file

@ -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
}