summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorGalen Abell <galen@galenabell.com>2019-07-16 16:48:25 -0400
committerDrew DeVault <sir@cmpwn.com>2019-07-19 10:30:47 -0400
commit7899d15d607cd9122e731cd2d2a8e52ee523ce0c (patch)
treefa83a1ae3721f7201229d1dd5324863561ab8e2f
parentfe7230bb9acc5dc9cc8a982a35196dd6796b5360 (diff)
downloadaerc-7899d15d607cd9122e731cd2d2a8e52ee523ce0c.tar.gz
Add :attach command for compose
Allow users to add attachments to emails in the Compose view. Syntax is
:attach <path>, where path is a valid file. Attachments will show up in
the pre-send review screen.
-rw-r--r--commands/compose/attach.go56
-rw-r--r--doc/aerc.1.scd5
-rw-r--r--widgets/compose.go146
3 files changed, 190 insertions, 17 deletions
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>")
+	}
+
+	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* <path>
+	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{