From d31c56ec98cd3f4fc5afbdd8f840538f3ee59429 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Fri, 24 Jan 2020 20:07:29 +0100 Subject: [PATCH] plugins/base: edit drafts Note that attachments will be lost. This is a TODO. --- plugins/base/public/message.html | 8 +- plugins/base/routes.go | 199 +++++++++++++++++++++++++++------------ plugins/base/smtp.go | 1 + 3 files changed, 145 insertions(+), 63 deletions(-) diff --git a/plugins/base/public/message.html b/plugins/base/public/message.html index 9544d0a..3dc7f62 100644 --- a/plugins/base/public/message.html +++ b/plugins/base/public/message.html @@ -111,7 +111,13 @@
{{if .Body}} -

Reply

+

+ {{if .Message.HasFlag "\\Draft"}} + Edit draft + {{else}} + Reply + {{end}} +

{{if .IsHTML}} diff --git a/plugins/base/routes.go b/plugins/base/routes.go index 9eaf18a..49f7674 100644 --- a/plugins/base/routes.go +++ b/plugins/base/routes.go @@ -38,11 +38,14 @@ func registerRoutes(p *koushin.GoPlugin) { p.GET("/logout", handleLogout) - p.GET("/compose", handleCompose) - p.POST("/compose", handleCompose) + p.GET("/compose", handleComposeNew) + p.POST("/compose", handleComposeNew) - p.GET("/message/:mbox/:uid/reply", handleCompose) - p.POST("/message/:mbox/:uid/reply", handleCompose) + p.GET("/message/:mbox/:uid/reply", handleReply) + p.POST("/message/:mbox/:uid/reply", handleReply) + + p.GET("/message/:mbox/:uid/edit", handleEdit) + p.POST("/message/:mbox/:uid/edit", handleEdit) p.POST("/message/:mbox/:uid/move", handleMove) @@ -278,9 +281,14 @@ type ComposeRenderData struct { Message *OutgoingMessage } +type messagePath struct { + Mailbox string + Uid uint32 +} + // Send message, append it to the Sent mailbox, mark the original message as // answered -func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, inReplyToMboxName string, inReplyToUid uint32) error { +func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, inReplyTo *messagePath) error { err := ctx.Session.DoSMTP(func(c *smtp.Client) error { return sendMessage(c, msg) }) @@ -291,9 +299,9 @@ func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, inReplyToMboxName return fmt.Errorf("failed to send message: %v", err) } - if inReplyToUid != 0 { + if inReplyTo != nil { err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { - return markMessageAnswered(c, inReplyToMboxName, inReplyToUid) + return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid) }) if err != nil { return fmt.Errorf("failed to mark original message as answered: %v", err) @@ -311,29 +319,83 @@ func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, inReplyToMboxName return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") } -func handleCompose(ctx *koushin.Context) error { - var msg OutgoingMessage - if strings.ContainsRune(ctx.Session.Username(), '@') { +func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, source *messagePath, inReplyTo *messagePath) error { + if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') { msg.From = ctx.Session.Username() } - msg.To = strings.Split(ctx.QueryParam("to"), ",") - msg.Subject = ctx.QueryParam("subject") - msg.Text = ctx.QueryParam("body") - msg.InReplyTo = ctx.QueryParam("in-reply-to") + if ctx.Request().Method == http.MethodPost { + formParams, err := ctx.FormParams() + if err != nil { + return fmt.Errorf("failed to parse form: %v", err) + } + _, saveAsDraft := formParams["save_as_draft"] - var inReplyToMboxName string - var inReplyToUid uint32 - if ctx.Param("uid") != "" { - // This is a reply - var err error - inReplyToMboxName, inReplyToUid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) + msg.From = ctx.FormValue("from") + msg.To = parseAddressList(ctx.FormValue("to")) + msg.Subject = ctx.FormValue("subject") + msg.Text = ctx.FormValue("text") + msg.InReplyTo = ctx.FormValue("in_reply_to") + + form, err := ctx.MultipartForm() if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err) + return fmt.Errorf("failed to get multipart form: %v", err) + } + msg.Attachments = form.File["attachments"] + + if saveAsDraft { + err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { + copied, err := appendMessage(c, msg, mailboxDrafts) + if err != nil { + return err + } + if !copied { + return fmt.Errorf("no Draft mailbox found") + } + return nil + }) + if err != nil { + return fmt.Errorf("failed to save message to Draft mailbox: %v", err) + } + } else { + return submitCompose(ctx, msg, inReplyTo) } } - if ctx.Request().Method == http.MethodGet && inReplyToUid != 0 { + return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{ + BaseRenderData: *koushin.NewBaseRenderData(ctx), + Message: msg, + }) +} + +func handleComposeNew(ctx *koushin.Context) error { + // These are common mailto URL query parameters + return handleCompose(ctx, &OutgoingMessage{ + To: strings.Split(ctx.QueryParam("to"), ","), + Subject: ctx.QueryParam("subject"), + Text: ctx.QueryParam("body"), + InReplyTo: ctx.QueryParam("in-reply-to"), + }, nil, nil) +} + +func unwrapIMAPAddressList(addrs []*imap.Address) []string { + l := make([]string, len(addrs)) + for i, addr := range addrs { + l[i] = addr.Address() + } + return l +} + +func handleReply(ctx *koushin.Context) error { + var inReplyToPath messagePath + var err error + inReplyToPath.Mailbox, inReplyToPath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + var msg OutgoingMessage + if ctx.Request().Method == http.MethodGet { // Populate fields from original message partPath, err := parsePartPath(ctx.QueryParam("part")) if err != nil { @@ -344,7 +406,7 @@ func handleCompose(ctx *koushin.Context) error { var part *message.Entity err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { var err error - inReplyTo, part, err = getMessagePart(c, inReplyToMboxName, inReplyToUid, partPath) + inReplyTo, part, err = getMessagePart(c, inReplyToPath.Mailbox, inReplyToPath.Uid, partPath) return err }) if err != nil { @@ -357,10 +419,11 @@ func handleCompose(ctx *koushin.Context) error { } if !strings.HasPrefix(strings.ToLower(mimeType), "text/") { - err := fmt.Errorf("cannot reply to \"%v\" part", mimeType) + err := fmt.Errorf("cannot reply to %q part", mimeType) return echo.NewHTTPError(http.StatusBadRequest, err) } + // TODO: strip HTML tags if text/html msg.Text, err = quote(part.Body) if err != nil { return err @@ -372,60 +435,72 @@ func handleCompose(ctx *koushin.Context) error { if len(replyTo) == 0 { replyTo = inReplyTo.Envelope.From } - if len(replyTo) > 0 { - msg.To = make([]string, len(replyTo)) - for i, to := range replyTo { - msg.To[i] = to.Address() - } - } + msg.To = unwrapIMAPAddressList(replyTo) msg.Subject = inReplyTo.Envelope.Subject if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") { msg.Subject = "Re: " + msg.Subject } } - if ctx.Request().Method == http.MethodPost { - formParams, err := ctx.FormParams() + return handleCompose(ctx, &msg, nil, &inReplyToPath) +} + +func handleEdit(ctx *koushin.Context) error { + var sourcePath messagePath + var err error + sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + // TODO: somehow get the path to the In-Reply-To message (with a search?) + + var msg OutgoingMessage + if ctx.Request().Method == http.MethodGet { + // Populate fields from source message + partPath, err := parsePartPath(ctx.QueryParam("part")) if err != nil { - return fmt.Errorf("failed to parse form: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, err) } - _, saveAsDraft := formParams["save_as_draft"] - msg.From = ctx.FormValue("from") - msg.To = parseAddressList(ctx.FormValue("to")) - msg.Subject = ctx.FormValue("subject") - msg.Text = ctx.FormValue("text") - msg.InReplyTo = ctx.FormValue("in_reply_to") + var source *IMAPMessage + var part *message.Entity + err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { + var err error + source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath) + return err + }) + if err != nil { + return err + } - form, err := ctx.MultipartForm() + mimeType, _, err := part.Header.ContentType() if err != nil { - return fmt.Errorf("failed to get multipart form: %v", err) + return fmt.Errorf("failed to parse part Content-Type: %v", err) } - msg.Attachments = form.File["attachments"] - if saveAsDraft { - err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { - copied, err := appendMessage(c, &msg, mailboxDrafts) - if err != nil { - return err - } - if !copied { - return fmt.Errorf("no Draft mailbox found") - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to save message to Draft mailbox: %v", err) - } - } else { - return submitCompose(ctx, &msg, inReplyToMboxName, inReplyToUid) + if !strings.EqualFold(mimeType, "text/plain") { + err := fmt.Errorf("cannot edit %q part", mimeType) + return echo.NewHTTPError(http.StatusBadRequest, err) } + + b, err := ioutil.ReadAll(part.Body) + if err != nil { + return fmt.Errorf("failed to read part body: %v", err) + } + msg.Text = string(b) + + if len(source.Envelope.From) > 0 { + msg.From = source.Envelope.From[0].Address() + } + msg.To = unwrapIMAPAddressList(source.Envelope.To) + msg.Subject = source.Envelope.Subject + msg.InReplyTo = source.Envelope.InReplyTo + // TODO: preserve Message-Id + // TODO: preserve attachments } - return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{ - BaseRenderData: *koushin.NewBaseRenderData(ctx), - Message: &msg, - }) + return handleCompose(ctx, &msg, &sourcePath, nil) } func handleMove(ctx *koushin.Context) error { diff --git a/plugins/base/smtp.go b/plugins/base/smtp.go index 81da6ef..663283d 100644 --- a/plugins/base/smtp.go +++ b/plugins/base/smtp.go @@ -88,6 +88,7 @@ func (msg *OutgoingMessage) WriteTo(w io.Writer) error { if msg.InReplyTo != "" { h.Set("In-Reply-To", msg.InReplyTo) } + // TODO: set Message-ID mw, err := mail.CreateWriter(w, h) if err != nil {