plugins/base: edit drafts

Note that attachments will be lost. This is a TODO.
This commit is contained in:
Simon Ser 2020-01-24 20:07:29 +01:00
parent 2e367efe58
commit d31c56ec98
No known key found for this signature in database
GPG key ID: 0FDE7BE0E88F5E48
3 changed files with 163 additions and 81 deletions

View file

@ -111,7 +111,13 @@
<hr>
{{if .Body}}
<p><a href="{{.Message.Uid}}/reply?part={{.PartPath}}">Reply</a></p>
<p>
{{if .Message.HasFlag "\\Draft"}}
<a href="{{.Message.Uid}}/edit?part={{.PartPath}}">Edit draft</a>
{{else}}
<a href="{{.Message.Uid}}/reply?part={{.PartPath}}">Reply</a>
{{end}}
</p>
{{if .IsHTML}}
<!-- allow-same-origin is required to resize the frame with its content -->
<!-- allow-popups is required for target="_blank" links -->

View file

@ -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,79 +319,11 @@ 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")
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"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
}
if ctx.Request().Method == http.MethodGet && inReplyToUid != 0 {
// Populate fields from original message
partPath, err := parsePartPath(ctx.QueryParam("part"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var inReplyTo *IMAPMessage
var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
inReplyTo, part, err = getMessagePart(c, inReplyToMboxName, inReplyToUid, partPath)
return err
})
if err != nil {
return err
}
mimeType, _, err := part.Header.ContentType()
if err != nil {
return fmt.Errorf("failed to parse part Content-Type: %v", err)
}
if !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
err := fmt.Errorf("cannot reply to \"%v\" part", mimeType)
return echo.NewHTTPError(http.StatusBadRequest, err)
}
msg.Text, err = quote(part.Body)
if err != nil {
return err
}
msg.InReplyTo = inReplyTo.Envelope.MessageId
// TODO: populate From from known user addresses and inReplyTo.Envelope.To
replyTo := inReplyTo.Envelope.ReplyTo
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.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()
if err != nil {
@ -405,7 +345,7 @@ func handleCompose(ctx *koushin.Context) error {
if saveAsDraft {
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
copied, err := appendMessage(c, &msg, mailboxDrafts)
copied, err := appendMessage(c, msg, mailboxDrafts)
if err != nil {
return err
}
@ -418,16 +358,151 @@ func handleCompose(ctx *koushin.Context) error {
return fmt.Errorf("failed to save message to Draft mailbox: %v", err)
}
} else {
return submitCompose(ctx, &msg, inReplyToMboxName, inReplyToUid)
return submitCompose(ctx, msg, inReplyTo)
}
}
return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
BaseRenderData: *koushin.NewBaseRenderData(ctx),
Message: &msg,
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 {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var inReplyTo *IMAPMessage
var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
inReplyTo, part, err = getMessagePart(c, inReplyToPath.Mailbox, inReplyToPath.Uid, partPath)
return err
})
if err != nil {
return err
}
mimeType, _, err := part.Header.ContentType()
if err != nil {
return fmt.Errorf("failed to parse part Content-Type: %v", err)
}
if !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
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
}
msg.InReplyTo = inReplyTo.Envelope.MessageId
// TODO: populate From from known user addresses and inReplyTo.Envelope.To
replyTo := inReplyTo.Envelope.ReplyTo
if len(replyTo) == 0 {
replyTo = inReplyTo.Envelope.From
}
msg.To = unwrapIMAPAddressList(replyTo)
msg.Subject = inReplyTo.Envelope.Subject
if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
msg.Subject = "Re: " + msg.Subject
}
}
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 echo.NewHTTPError(http.StatusBadRequest, err)
}
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
}
mimeType, _, err := part.Header.ContentType()
if err != nil {
return fmt.Errorf("failed to parse part Content-Type: %v", err)
}
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 handleCompose(ctx, &msg, &sourcePath, nil)
}
func handleMove(ctx *koushin.Context) error {
mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
if err != nil {

View file

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