From 7899d15d607cd9122e731cd2d2a8e52ee523ce0c Mon Sep 17 00:00:00 2001 From: Galen Abell Date: Tue, 16 Jul 2019 16:48:25 -0400 Subject: [PATCH] Add :attach command for compose Allow users to add attachments to emails in the Compose view. Syntax is :attach , where path is a valid file. Attachments will show up in the pre-send review screen. --- commands/compose/attach.go | 56 ++++++++++++++ doc/aerc.1.scd | 5 ++ widgets/compose.go | 146 ++++++++++++++++++++++++++++++++----- 3 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 commands/compose/attach.go diff --git a/commands/compose/attach.go b/commands/compose/attach.go new file mode 100644 index 0000000..43aa32d --- /dev/null +++ b/commands/compose/attach.go @@ -0,0 +1,56 @@ +package compose + +import ( + "fmt" + "os" + "time" + + "git.sr.ht/~sircmpwn/aerc/widgets" + "github.com/gdamore/tcell" + "github.com/mitchellh/go-homedir" +) + +type Attach struct{} + +func init() { + register(Attach{}) +} + +func (_ Attach) Aliases() []string { + return []string{"attach"} +} + +func (_ Attach) Complete(aerc *widgets.Aerc, args []string) []string { + return nil +} + +func (_ Attach) Execute(aerc *widgets.Aerc, args []string) error { + if len(args) != 2 { + return fmt.Errorf("Usage: :attach ") + } + + path := args[1] + + path, err := homedir.Expand(path) + if err != nil { + aerc.PushError(" " + err.Error()) + return err + } + + pathinfo, err := os.Stat(path) + if err != nil { + aerc.PushError(" " + err.Error()) + return err + } else if pathinfo.IsDir() { + aerc.PushError("Attachment must be a file, not a directory") + return nil + } + + composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer.AddAttachment(path) + + aerc.PushStatus(fmt.Sprintf("Attached %s", pathinfo.Name()), 10*time.Second). + Color(tcell.ColorDefault, tcell.ColorGreen) + + return nil +} diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd index 750d2da..de82394 100644 --- a/doc/aerc.1.scd +++ b/doc/aerc.1.scd @@ -158,6 +158,11 @@ message list, the message in the message viewer, etc). *close* Closes the message viewer. +## MESSAGE COMPOSE COMMANDS + +*attach* + Attaches the file at the given path to the email. + ## TERMINAL COMMANDS *close* diff --git a/widgets/compose.go b/widgets/compose.go index a68bbe1..f1c8014 100644 --- a/widgets/compose.go +++ b/widgets/compose.go @@ -1,11 +1,15 @@ package widgets import ( + "bufio" "io" "io/ioutil" + "mime" + "net/http" gomail "net/mail" "os" "os/exec" + "path/filepath" "time" "github.com/emersion/go-message" @@ -29,12 +33,13 @@ type Composer struct { acct *config.AccountConfig config *config.AercConfig - defaults map[string]string - editor *Terminal - email *os.File - grid *ui.Grid - review *reviewMessage - worker *types.Worker + defaults map[string]string + editor *Terminal + email *os.File + attachments []string + grid *ui.Grid + review *reviewMessage + worker *types.Worker focusable []ui.DrawableInteractive focused int @@ -211,7 +216,6 @@ func (c *Composer) PrepareHeader() (*mail.Header, []string, error) { } // Update headers mhdr := (*message.Header)(&header.Header) - mhdr.SetContentType("text/plain", map[string]string{"charset": "UTF-8"}) mhdr.SetText("Message-Id", mail.GenerateMessageID()) if subject, _ := header.Subject(); subject == "" { header.SetSubject(c.headers.subject.input.String()) @@ -302,18 +306,117 @@ func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error { c.email.Seek(0, os.SEEK_SET) body = c.email } - // TODO: attachments - w, err := mail.CreateSingleInlineWriter(writer, *header) + + if len(c.attachments) == 0 { + // don't create a multipart email if we only have text + header.SetContentType("text/plain", map[string]string{"charset": "UTF-8"}) + w, err := mail.CreateSingleInlineWriter(writer, *header) + if err != nil { + return errors.Wrap(err, "CreateSingleInlineWriter") + } + defer w.Close() + + return writeBody(body, w) + } + + // otherwise create a multipart email, + // with a multipart/alternative part for the text + w, err := mail.CreateWriter(writer, *header) if err != nil { - return errors.Wrap(err, "CreateSingleInlineWriter") + return errors.Wrap(err, "CreateWriter") } defer w.Close() + + bh := mail.InlineHeader{} + bh.SetContentType("text/plain", map[string]string{"charset": "UTF-8"}) + + bi, err := w.CreateInline() + if err != nil { + return errors.Wrap(err, "CreateInline") + } + defer bi.Close() + + bw, err := bi.CreatePart(bh) + if err != nil { + return errors.Wrap(err, "CreatePart") + } + defer bw.Close() + + if err := writeBody(body, bw); err != nil { + return err + } + + for _, a := range c.attachments { + writeAttachment(a, w) + } + + return nil +} + +func writeBody(body io.Reader, w io.Writer) error { if _, err := io.Copy(w, body); err != nil { return errors.Wrap(err, "io.Copy") } + return nil } +// write the attachment specified by path to the message +func writeAttachment(path string, writer *mail.Writer) error { + filename := filepath.Base(path) + + f, err := os.Open(path) + if err != nil { + return errors.Wrap(err, "os.Open") + } + defer f.Close() + + reader := bufio.NewReader(f) + + // determine the MIME type + // http.DetectContentType only cares about the first 512 bytes + head, err := reader.Peek(512) + if err != nil { + return errors.Wrap(err, "Peek") + } + + mimeString := http.DetectContentType(head) + // mimeString can contain type and params (like text encoding), + // so we need to break them apart before passing them to the headers + mimeType, params, err := mime.ParseMediaType(mimeString) + if err != nil { + return errors.Wrap(err, "ParseMediaType") + } + params["name"] = filename + + // set header fields + ah := mail.AttachmentHeader{} + ah.SetContentType(mimeType, params) + // setting the filename auto sets the content disposition + ah.SetFilename(filename) + + aw, err := writer.CreateAttachment(ah) + if err != nil { + return errors.Wrap(err, "CreateAttachment") + } + defer aw.Close() + + if _, err := reader.WriteTo(aw); err != nil { + return errors.Wrap(err, "reader.WriteTo") + } + + return nil +} + +func (c *Composer) AddAttachment(path string) { + c.attachments = append(c.attachments, path) + if c.review != nil { + c.grid.RemoveChild(c.review) + c.review = newReviewMessage(c, nil) + c.grid.AddChild(c.review).At(1, 0) + } +} + func (c *Composer) termClosed(err error) { c.grid.RemoveChild(c.editor) c.review = newReviewMessage(c, err) @@ -412,13 +515,17 @@ type reviewMessage struct { } func newReviewMessage(composer *Composer, err error) *reviewMessage { - grid := ui.NewGrid().Rows([]ui.GridSpec{ - {ui.SIZE_EXACT, 2}, - {ui.SIZE_EXACT, 1}, - {ui.SIZE_WEIGHT, 1}, - }).Columns([]ui.GridSpec{ + spec := []ui.GridSpec{{ui.SIZE_EXACT, 2}, {ui.SIZE_EXACT, 1}} + for range composer.attachments { + spec = append(spec, ui.GridSpec{ui.SIZE_EXACT, 1}) + } + // make the last element fill remaining space + spec = append(spec, ui.GridSpec{ui.SIZE_WEIGHT, 1}) + + grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{ {ui.SIZE_WEIGHT, 1}, }) + if err != nil { grid.AddChild(ui.NewText(err.Error()). Color(tcell.ColorRed, tcell.ColorDefault)) @@ -429,8 +536,13 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage { "Send this email? [y]es/[n]o/[e]dit")).At(0, 0) grid.AddChild(ui.NewText("Attachments:"). Reverse(true)).At(1, 0) - // TODO: Attachments - grid.AddChild(ui.NewText("(none)")).At(2, 0) + if len(composer.attachments) == 0 { + grid.AddChild(ui.NewText("(none)")).At(2, 0) + } else { + for i, a := range composer.attachments { + grid.AddChild(ui.NewText(a)).At(i+2, 0) + } + } } return &reviewMessage{