plugins/base: add route to forward messages

Only inline forwarding is supported for now.

References: https://todo.sr.ht/~sircmpwn/koushin/37
This commit is contained in:
Simon Ser 2020-03-18 15:01:15 +01:00
parent 4b887f5771
commit ae8658f468
No known key found for this signature in database
GPG key ID: 0FDE7BE0E88F5E48
3 changed files with 106 additions and 21 deletions

View file

@ -115,7 +115,8 @@
{{if .Message.HasFlag "\\Draft"}} {{if .Message.HasFlag "\\Draft"}}
<a href="{{.Message.URL}}/edit?part={{.Part.PathString}}">Edit draft</a> <a href="{{.Message.URL}}/edit?part={{.Part.PathString}}">Edit draft</a>
{{else}} {{else}}
<a href="{{.Message.URL}}/reply?part={{.Part.PathString}}">Reply</a> <a href="{{.Message.URL}}/reply?part={{.Part.PathString}}">Reply</a> &middot;
<a href="{{.Message.URL}}/forward?part={{.Part.PathString}}">Forward</a>
{{end}} {{end}}
</p> </p>
{{.View}} {{.View}}

View file

@ -47,6 +47,9 @@ func registerRoutes(p *koushin.GoPlugin) {
p.GET("/message/:mbox/:uid/reply", handleReply) p.GET("/message/:mbox/:uid/reply", handleReply)
p.POST("/message/:mbox/:uid/reply", handleReply) p.POST("/message/:mbox/:uid/reply", handleReply)
p.GET("/message/:mbox/:uid/forward", handleForward)
p.POST("/message/:mbox/:uid/forward", handleForward)
p.GET("/message/:mbox/:uid/edit", handleEdit) p.GET("/message/:mbox/:uid/edit", handleEdit)
p.POST("/message/:mbox/:uid/edit", handleEdit) p.POST("/message/:mbox/:uid/edit", handleEdit)
@ -289,9 +292,15 @@ type messagePath struct {
Uid uint32 Uid uint32
} }
type composeOptions struct {
Draft *messagePath
Forward *messagePath
InReplyTo *messagePath
}
// Send message, append it to the Sent mailbox, mark the original message as // Send message, append it to the Sent mailbox, mark the original message as
// answered // answered
func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePath, inReplyTo *messagePath) error { func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, options *composeOptions) error {
err := ctx.Session.DoSMTP(func(c *smtp.Client) error { err := ctx.Session.DoSMTP(func(c *smtp.Client) error {
return sendMessage(c, msg) return sendMessage(c, msg)
}) })
@ -302,7 +311,7 @@ func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat
return fmt.Errorf("failed to send message: %v", err) return fmt.Errorf("failed to send message: %v", err)
} }
if inReplyTo != nil { if inReplyTo := options.InReplyTo; inReplyTo != nil {
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid) return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid)
}) })
@ -315,7 +324,7 @@ func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat
if _, err := appendMessage(c, msg, mailboxSent); err != nil { if _, err := appendMessage(c, msg, mailboxSent); err != nil {
return err return err
} }
if draft != nil { if draft := options.Draft; draft != nil {
if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil { if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
return err return err
} }
@ -329,7 +338,7 @@ func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
} }
func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePath, inReplyTo *messagePath) error { func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, options *composeOptions) error {
if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') { if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') {
msg.From = ctx.Session.Username() msg.From = ctx.Session.Username()
} }
@ -352,35 +361,41 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat
return fmt.Errorf("failed to get multipart form: %v", err) return fmt.Errorf("failed to get multipart form: %v", err)
} }
// Fetch previous attachments from draft // Fetch previous attachments from original message
if draft != nil { var original *messagePath
if options.Draft != nil {
original = options.Draft
} else if options.Forward != nil {
original = options.Forward
}
if original != nil {
for _, s := range form.Value["prev_attachments"] { for _, s := range form.Value["prev_attachments"] {
path, err := parsePartPath(s) path, err := parsePartPath(s)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse draft attachment path: %v", err) return fmt.Errorf("failed to parse original attachment path: %v", err)
} }
var part *message.Entity var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error var err error
_, part, err = getMessagePart(c, draft.Mailbox, draft.Uid, path) _, part, err = getMessagePart(c, original.Mailbox, original.Uid, path)
return err return err
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch attachment from draft: %v", err) return fmt.Errorf("failed to fetch attachment from original message: %v", err)
} }
var buf bytes.Buffer var buf bytes.Buffer
if _, err := io.Copy(&buf, part.Body); err != nil { if _, err := io.Copy(&buf, part.Body); err != nil {
return fmt.Errorf("failed to copy attachment from draft: %v", err) return fmt.Errorf("failed to copy attachment from original message: %v", err)
} }
h := mail.AttachmentHeader{part.Header} h := mail.AttachmentHeader{part.Header}
mimeType, _, _ := h.ContentType() mimeType, _, _ := h.ContentType()
filename, _ := h.Filename() filename, _ := h.Filename()
msg.Attachments = append(msg.Attachments, &imapAttachment{ msg.Attachments = append(msg.Attachments, &imapAttachment{
Mailbox: draft.Mailbox, Mailbox: original.Mailbox,
Uid: draft.Uid, Uid: original.Uid,
Node: &IMAPPartNode{ Node: &IMAPPartNode{
Path: path, Path: path,
MIMEType: mimeType, MIMEType: mimeType,
@ -390,7 +405,7 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat
}) })
} }
} else if len(form.Value["prev_attachments"]) > 0 { } else if len(form.Value["prev_attachments"]) > 0 {
return fmt.Errorf("previous attachments specified but no draft available") return fmt.Errorf("previous attachments specified but no original message available")
} }
for _, fh := range form.File["attachments"] { for _, fh := range form.File["attachments"] {
@ -406,7 +421,7 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat
if !copied { if !copied {
return fmt.Errorf("no Draft mailbox found") return fmt.Errorf("no Draft mailbox found")
} }
if draft != nil { if draft := options.Draft; draft != nil {
if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil { if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
return err return err
} }
@ -418,7 +433,7 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat
} }
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
} else { } else {
return submitCompose(ctx, msg, draft, inReplyTo) return submitCompose(ctx, msg, options)
} }
} }
@ -436,7 +451,7 @@ func handleComposeNew(ctx *koushin.Context) error {
Subject: ctx.QueryParam("subject"), Subject: ctx.QueryParam("subject"),
Text: ctx.QueryParam("body"), Text: ctx.QueryParam("body"),
InReplyTo: ctx.QueryParam("in-reply-to"), InReplyTo: ctx.QueryParam("in-reply-to"),
}, nil, nil) }, &composeOptions{})
} }
func unwrapIMAPAddressList(addrs []*imap.Address) []string { func unwrapIMAPAddressList(addrs []*imap.Address) []string {
@ -503,7 +518,71 @@ func handleReply(ctx *koushin.Context) error {
} }
} }
return handleCompose(ctx, &msg, nil, &inReplyToPath) return handleCompose(ctx, &msg, &composeOptions{InReplyTo: &inReplyToPath})
}
func handleForward(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)
}
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 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 forward %q part", mimeType)
return echo.NewHTTPError(http.StatusBadRequest, err)
}
msg.Text, err = quote(part.Body)
if err != nil {
return err
}
msg.Subject = source.Envelope.Subject
if !strings.HasPrefix(strings.ToLower(msg.Subject), "fwd:") &&
!strings.HasPrefix(strings.ToLower(msg.Subject), "fw:") {
msg.Subject = "Fwd: " + msg.Subject
}
msg.InReplyTo = source.Envelope.InReplyTo
attachments := source.Attachments()
for i := range attachments {
// No need to populate attachment body here, we just need the
// metadata
msg.Attachments = append(msg.Attachments, &imapAttachment{
Mailbox: sourcePath.Mailbox,
Uid: sourcePath.Uid,
Node: &attachments[i],
})
}
}
return handleCompose(ctx, &msg, &composeOptions{Forward: &sourcePath})
} }
func handleEdit(ctx *koushin.Context) error { func handleEdit(ctx *koushin.Context) error {
@ -561,18 +640,17 @@ func handleEdit(ctx *koushin.Context) error {
attachments := source.Attachments() attachments := source.Attachments()
for i := range attachments { for i := range attachments {
att := &attachments[i]
// No need to populate attachment body here, we just need the // No need to populate attachment body here, we just need the
// metadata // metadata
msg.Attachments = append(msg.Attachments, &imapAttachment{ msg.Attachments = append(msg.Attachments, &imapAttachment{
Mailbox: sourcePath.Mailbox, Mailbox: sourcePath.Mailbox,
Uid: sourcePath.Uid, Uid: sourcePath.Uid,
Node: att, Node: &attachments[i],
}) })
} }
} }
return handleCompose(ctx, &msg, &sourcePath, nil) return handleCompose(ctx, &msg, &composeOptions{Draft: &sourcePath})
} }
func handleMove(ctx *koushin.Context) error { func handleMove(ctx *koushin.Context) error {

View file

@ -22,6 +22,12 @@
href="{{.Message.URL}}/reply?part={{.Part.PathString}}" href="{{.Message.URL}}/reply?part={{.Part.PathString}}"
>Reply</a> >Reply</a>
</li> </li>
<li class="nav-item">
<a
class="nav-link"
href="{{.Message.URL}}/forward?part={{.Part.PathString}}"
>Forward</a>
</li>
<li class="mr-auto d-none d-sm-flex"></li> <li class="mr-auto d-none d-sm-flex"></li>
<li class="nav-item"> <li class="nav-item">
<a <a