diff --git a/plugins/base/public/message.html b/plugins/base/public/message.html index f019acf..b537ff0 100644 --- a/plugins/base/public/message.html +++ b/plugins/base/public/message.html @@ -115,7 +115,8 @@ {{if .Message.HasFlag "\\Draft"}} Edit draft {{else}} - Reply + Reply · + Forward {{end}}

{{.View}} diff --git a/plugins/base/routes.go b/plugins/base/routes.go index 4503fb9..85034de 100644 --- a/plugins/base/routes.go +++ b/plugins/base/routes.go @@ -47,6 +47,9 @@ func registerRoutes(p *koushin.GoPlugin) { p.GET("/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.POST("/message/:mbox/:uid/edit", handleEdit) @@ -289,9 +292,15 @@ type messagePath struct { Uid uint32 } +type composeOptions struct { + Draft *messagePath + Forward *messagePath + InReplyTo *messagePath +} + // Send message, append it to the Sent mailbox, mark the original message as // 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 { 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) } - if inReplyTo != nil { + if inReplyTo := options.InReplyTo; inReplyTo != nil { err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { 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 { return err } - if draft != nil { + if draft := options.Draft; draft != nil { if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil { return err } @@ -329,7 +338,7 @@ func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat 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(), '@') { 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) } - // Fetch previous attachments from draft - if draft != nil { + // Fetch previous attachments from original message + 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"] { path, err := parsePartPath(s) 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 err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { var err error - _, part, err = getMessagePart(c, draft.Mailbox, draft.Uid, path) + _, part, err = getMessagePart(c, original.Mailbox, original.Uid, path) return err }) 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 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} mimeType, _, _ := h.ContentType() filename, _ := h.Filename() msg.Attachments = append(msg.Attachments, &imapAttachment{ - Mailbox: draft.Mailbox, - Uid: draft.Uid, + Mailbox: original.Mailbox, + Uid: original.Uid, Node: &IMAPPartNode{ Path: path, MIMEType: mimeType, @@ -390,7 +405,7 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat }) } } 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"] { @@ -406,7 +421,7 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat if !copied { 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 { return err } @@ -418,7 +433,7 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat } return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") } 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"), Text: ctx.QueryParam("body"), InReplyTo: ctx.QueryParam("in-reply-to"), - }, nil, nil) + }, &composeOptions{}) } 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 { @@ -561,18 +640,17 @@ func handleEdit(ctx *koushin.Context) error { attachments := source.Attachments() for i := range attachments { - att := &attachments[i] // 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: att, + Node: &attachments[i], }) } } - return handleCompose(ctx, &msg, &sourcePath, nil) + return handleCompose(ctx, &msg, &composeOptions{Draft: &sourcePath}) } func handleMove(ctx *koushin.Context) error { diff --git a/themes/sourcehut/message.html b/themes/sourcehut/message.html index 4a9fbc8..af39b15 100644 --- a/themes/sourcehut/message.html +++ b/themes/sourcehut/message.html @@ -22,6 +22,12 @@ href="{{.Message.URL}}/reply?part={{.Part.PathString}}" >Reply +