References: https://todo.sr.ht/~sircmpwn/koushin/16master
@@ -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 | |||
@@ -25,6 +25,13 @@ | |||
<br><br> | |||
<label for="attachments">Attachments:</label> | |||
<input type="file" name="attachments" id="attachments" multiple> | |||
{{range .Message.Attachments}} | |||
<br> | |||
<label> | |||
<input type="checkbox" name="prev_attachments" value="{{.Node.PathString}}" checked> | |||
{{.Node}} | |||
</label> | |||
{{end}} | |||
<br><br> | |||
<input type="submit" name="save_as_draft" value="Save as draft"> | |||
<input type="submit" value="Send"> | |||
@@ -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) | |||
@@ -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 { | |||