From 85c01b87a99233fcc273e810b4ad48bb0d33096f Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 28 Jan 2020 12:30:07 +0100 Subject: [PATCH] plugins/base: support attachments in drafts References: https://todo.sr.ht/~sircmpwn/koushin/16 --- plugins/base/imap.go | 22 ++++++++++++ plugins/base/public/compose.html | 7 ++++ plugins/base/routes.go | 62 ++++++++++++++++++++++++++++++-- plugins/base/smtp.go | 58 +++++++++++++++++++++++++++--- 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/plugins/base/imap.go b/plugins/base/imap.go index 1b41265..0c36baa 100755 --- a/plugins/base/imap.go +++ b/plugins/base/imap.go @@ -144,6 +144,28 @@ func (msg *IMAPMessage) TextPartName() string { return strings.Join(l, ".") } +func (msg *IMAPMessage) Attachments() []IMAPPartNode { + if msg.BodyStructure == nil { + return nil + } + + var attachments []IMAPPartNode + msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool { + if !strings.EqualFold(part.Disposition, "attachment") { + return true + } + + filename, _ := part.Filename() + attachments = append(attachments, IMAPPartNode{ + Path: path, + MIMEType: strings.ToLower(part.MIMEType + "/" + part.MIMESubType), + Filename: filename, + }) + return true + }) + return attachments +} + type IMAPPartNode struct { Path []int MIMEType string diff --git a/plugins/base/public/compose.html b/plugins/base/public/compose.html index 0db84c0..dd3c7aa 100644 --- a/plugins/base/public/compose.html +++ b/plugins/base/public/compose.html @@ -25,6 +25,13 @@

+ {{range .Message.Attachments}} +
+ + {{end}}

diff --git a/plugins/base/routes.go b/plugins/base/routes.go index bf0bc5a..f7b85e6 100644 --- a/plugins/base/routes.go +++ b/plugins/base/routes.go @@ -1,7 +1,9 @@ package koushinbase import ( + "bytes" "fmt" + "io" "io/ioutil" "mime" "net/http" @@ -14,6 +16,7 @@ import ( imapmove "github.com/emersion/go-imap-move" imapclient "github.com/emersion/go-imap/client" "github.com/emersion/go-message" + "github.com/emersion/go-message/mail" "github.com/emersion/go-smtp" "github.com/labstack/echo/v4" ) @@ -348,7 +351,51 @@ func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePat if err != nil { return fmt.Errorf("failed to get multipart form: %v", err) } - msg.Attachments = form.File["attachments"] + + // Fetch previous attachments from draft + if draft != 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) + } + + 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) + return err + }) + if err != nil { + return fmt.Errorf("failed to fetch attachment from draft: %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) + } + + h := mail.AttachmentHeader{part.Header} + mimeType, _, _ := h.ContentType() + filename, _ := h.Filename() + msg.Attachments = append(msg.Attachments, &imapAttachment{ + Mailbox: draft.Mailbox, + Uid: draft.Uid, + Node: &IMAPPartNode{ + Path: path, + MIMEType: mimeType, + Filename: filename, + }, + Body: buf.Bytes(), + }) + } + } else if len(form.Value["prev_attachments"]) > 0 { + return fmt.Errorf("previous attachments specified but no draft available") + } + + for _, fh := range form.File["attachments"] { + msg.Attachments = append(msg.Attachments, &formAttachment{fh}) + } if saveAsDraft { err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { @@ -510,7 +557,18 @@ func handleEdit(ctx *koushin.Context) error { msg.Subject = source.Envelope.Subject msg.InReplyTo = source.Envelope.InReplyTo // TODO: preserve Message-Id - // TODO: preserve attachments + + 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, + }) + } } return handleCompose(ctx, &msg, &sourcePath, nil) diff --git a/plugins/base/smtp.go b/plugins/base/smtp.go index 663283d..489a6a1 100644 --- a/plugins/base/smtp.go +++ b/plugins/base/smtp.go @@ -2,8 +2,11 @@ package koushinbase import ( "bufio" + "bytes" "fmt" "io" + "io/ioutil" + "mime" "mime/multipart" "strings" "time" @@ -26,23 +29,70 @@ func quote(r io.Reader) (string, error) { return builder.String(), nil } +type Attachment interface { + MIMEType() string + Filename() string + Open() (io.ReadCloser, error) +} + +type formAttachment struct { + *multipart.FileHeader +} + +func (att *formAttachment) Open() (io.ReadCloser, error) { + return att.FileHeader.Open() +} + +func (att *formAttachment) MIMEType() string { + // TODO: retain params, e.g. "charset"? + t, _, _ := mime.ParseMediaType(att.FileHeader.Header.Get("Content-Type")) + return t +} + +func (att *formAttachment) Filename() string { + return att.FileHeader.Filename +} + +type imapAttachment struct { + Mailbox string + Uid uint32 + Node *IMAPPartNode + + Body []byte +} + +func (att *imapAttachment) Open() (io.ReadCloser, error) { + if att.Body == nil { + return nil, fmt.Errorf("IMAP attachment has not been pre-fetched") + } + return ioutil.NopCloser(bytes.NewReader(att.Body)), nil +} + +func (att *imapAttachment) MIMEType() string { + return att.Node.MIMEType +} + +func (att *imapAttachment) Filename() string { + return att.Node.Filename +} + type OutgoingMessage struct { From string To []string Subject string InReplyTo string Text string - Attachments []*multipart.FileHeader + Attachments []Attachment } func (msg *OutgoingMessage) ToString() string { return strings.Join(msg.To, ", ") } -func writeAttachment(mw *mail.Writer, att *multipart.FileHeader) error { +func writeAttachment(mw *mail.Writer, att Attachment) error { var h mail.AttachmentHeader - h.Set("Content-Type", att.Header.Get("Content-Type")) - h.SetFilename(att.Filename) + h.SetContentType(att.MIMEType(), nil) + h.SetFilename(att.Filename()) aw, err := mw.CreateAttachment(h) if err != nil {