Implement JavaScript UI for attachments

This one is a bit of a doozy. A summary of the changes:

- Session has grown storage for attachments which have been uploaded but
  not yet sent.
- The list of attachments on a message is refcounted so that we can
  clean up the temporary files only after it's done with - i.e. after
  copying to Sent and after all of the SMTP attempts are done.
- Abandoned attachments are cleared out on process shutdown.

Future work:
- Add a limit to the maximum number of pending attachments the user can
  have in the session.
- Periodically clean out abandoned attachments?
This commit is contained in:
Drew DeVault 2020-10-29 15:18:36 -04:00
parent 4904207269
commit a393429f01
8 changed files with 429 additions and 32 deletions

View file

@ -101,4 +101,6 @@ func main() {
e.Logger.Print("Waiting for work queues to finish...")
s.Queue.Shutdown()
e.Logger.Print("Shut down.")
s.Close()
}

View file

@ -46,6 +46,8 @@ func registerRoutes(p *alps.GoPlugin) {
p.GET("/compose", handleComposeNew)
p.POST("/compose", handleComposeNew)
p.POST("/compose/attachment", handleComposeAttachment)
p.GET("/message/:mbox/:uid/reply", handleReply)
p.POST("/message/:mbox/:uid/reply", handleReply)
@ -414,11 +416,19 @@ type composeOptions struct {
// Send message, append it to the Sent mailbox, mark the original message as
// answered
func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
msg.Ref()
msg.Ref()
task := work.NewTask(func(_ context.Context) error {
return ctx.Session.DoSMTP(func (c *smtp.Client) error {
err := ctx.Session.DoSMTP(func (c *smtp.Client) error {
return sendMessage(c, msg)
})
}).Retries(5)
if err != nil {
ctx.Logger().Printf("Error sending email: %v\n", err)
}
return err
}).Retries(5).After(func(_ context.Context, task *work.Task) {
msg.Unref()
})
err := ctx.Server.Queue.Enqueue(task)
if err != nil {
if _, ok := err.(alps.AuthError); ok {
@ -440,6 +450,7 @@ func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti
if _, err := appendMessage(c, msg, mailboxSent); err != nil {
return err
}
msg.Unref()
if draft := options.Draft; draft != nil {
if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
return err
@ -533,6 +544,19 @@ func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti
msg.Attachments = append(msg.Attachments, &formAttachment{fh})
}
uuids := ctx.FormValue("attachment-uuids")
for _, uuid := range strings.Split(uuids, ",") {
attachment := ctx.Session.PopAttachment(uuid)
if attachment == nil {
return fmt.Errorf("Unable to retrieve message attachments from session")
}
msg.Attachments = append(msg.Attachments, &refcountedAttachment{
attachment.File,
attachment.Form,
0,
})
}
if saveAsDraft {
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
copied, err := appendMessage(c, msg, mailboxDrafts)
@ -575,6 +599,28 @@ func handleComposeNew(ctx *alps.Context) error {
}, &composeOptions{})
}
func handleComposeAttachment(ctx *alps.Context) error {
reader, err := ctx.Request().MultipartReader()
if err != nil {
return fmt.Errorf("failed to get multipart form: %v", err)
}
form, err := reader.ReadForm(32 << 20) // 32 MB
if err != nil {
return fmt.Errorf("failed to decode multipart form: %v", err)
}
var uuids []string
for _, fh := range form.File["attachments"] {
uuid, err := ctx.Session.PutAttachment(fh, form)
if err != nil {
return err
}
uuids = append(uuids, uuid)
}
return ctx.JSON(http.StatusOK, &uuids)
}
func unwrapIMAPAddressList(addrs []*imap.Address) []string {
l := make([]string, len(addrs))
for i, addr := range addrs {

View file

@ -53,6 +53,37 @@ func (att *formAttachment) Filename() string {
return att.FileHeader.Filename
}
type refcountedAttachment struct {
*multipart.FileHeader
*multipart.Form
refs int
}
func (att *refcountedAttachment) Open() (io.ReadCloser, error) {
return att.FileHeader.Open()
}
func (att *refcountedAttachment) MIMEType() string {
// TODO: retain params, e.g. "charset"?
t, _, _ := mime.ParseMediaType(att.FileHeader.Header.Get("Content-Type"))
return t
}
func (att *refcountedAttachment) Filename() string {
return att.FileHeader.Filename
}
func (att *refcountedAttachment) Ref() {
att.refs += 1
}
func (att *refcountedAttachment) Unref() {
att.refs -= 1
if att.refs == 0 {
att.Form.RemoveAll()
}
}
type imapAttachment struct {
Mailbox string
Uid uint32
@ -176,6 +207,22 @@ func (msg *OutgoingMessage) WriteTo(w io.Writer) error {
return nil
}
func (msg *OutgoingMessage) Ref() {
for _, a := range msg.Attachments {
if a, ok := a.(*refcountedAttachment); ok {
a.Ref()
}
}
}
func (msg *OutgoingMessage) Unref() {
for _, a := range msg.Attachments {
if a, ok := a.(*refcountedAttachment); ok {
a.Unref()
}
}
}
func sendMessage(c *smtp.Client, msg *OutgoingMessage) error {
if err := c.Mail(msg.From, nil); err != nil {
return fmt.Errorf("MAIL FROM failed: %v", err)

View file

@ -73,6 +73,10 @@ func newServer(e *echo.Echo, options *Options) (*Server, error) {
return s, nil
}
func (s *Server) Close() {
s.Sessions.Close()
}
func parseUpstream(s string) (*url.URL, error) {
if !strings.ContainsAny(s, ":/") {
// This is a raw domain name, make it an URL with an empty scheme

View file

@ -5,6 +5,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"mime/multipart"
"net/http"
"os"
"sync"
@ -13,6 +14,7 @@ import (
imapclient "github.com/emersion/go-imap/client"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
)
@ -54,6 +56,14 @@ type Session struct {
imapLocker sync.Mutex
imapConn *imapclient.Client // protected by locker, can be nil
attachmentsLocker sync.Mutex
attachments map[string]*Attachment // protected by attachmentsLocker
}
type Attachment struct {
File *multipart.FileHeader
Form *multipart.Form
}
func (s *Session) ping() {
@ -117,6 +127,13 @@ func (s *Session) SetHTTPBasicAuth(req *http.Request) {
// Close destroys the session. This can be used to log the user out.
func (s *Session) Close() {
s.attachmentsLocker.Lock()
defer s.attachmentsLocker.Unlock()
for _, f := range s.attachments {
f.Form.RemoveAll()
}
select {
case <-s.closed:
// This space is intentionally left blank
@ -125,6 +142,41 @@ func (s *Session) Close() {
}
}
// Puts an attachment and returns a generated UUID
func (s *Session) PutAttachment(in *multipart.FileHeader,
form *multipart.Form) (string, error) {
// TODO: Prevent users from uploading too many attachments, or too large
//
// Probably just set a cap on the maximum combined size of all files in the
// user's session
//
// TODO: Figure out what to do if the user abandons the compose window
// after adding some attachments
id := uuid.New()
s.attachmentsLocker.Lock()
s.attachments[id.String()] = &Attachment{
File: in,
Form: form,
}
s.attachmentsLocker.Unlock()
return id.String(), nil
}
// Removes an attachment from the session. Returns nil if there was no such
// attachment.
func (s *Session) PopAttachment(uuid string) *Attachment {
s.attachmentsLocker.Lock()
defer s.attachmentsLocker.Unlock()
a, ok := s.attachments[uuid]
if !ok {
return nil
}
delete(s.attachments, uuid)
return a
}
// Store returns a store suitable for storing persistent user data.
func (s *Session) Store() Store {
return s.store
@ -159,6 +211,12 @@ func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc, logger echo
}
}
func (sm *SessionManager) Close() {
for _, s := range sm.sessions {
s.Close()
}
}
func (sm *SessionManager) connectIMAP(username, password string) (*imapclient.Client, error) {
c, err := sm.dialIMAP()
if err != nil {
@ -213,13 +271,14 @@ func (sm *SessionManager) Put(username, password string) (*Session, error) {
}
s := &Session{
manager: sm,
closed: make(chan struct{}),
pings: make(chan struct{}, 5),
imapConn: c,
username: username,
password: password,
token: token,
manager: sm,
closed: make(chan struct{}),
pings: make(chan struct{}, 5),
imapConn: c,
username: username,
password: password,
token: token,
attachments: make(map[string]*Attachment),
}
s.store, err = newStore(s, sm.logger)

View file

@ -0,0 +1,146 @@
let attachments = [];
const headers = document.querySelector(".create-update .headers");
headers.classList.remove("no-js");
const attachmentsNode = document.getElementById("attachment-list");
attachmentsNode.style.display = '';
const helpNode = attachmentsNode.querySelector(".help");
const attachmentsInput = headers.querySelector("input[type='file']");
attachmentsInput.removeAttribute("name");
attachmentsInput.addEventListener("input", ev => {
const files = attachmentsInput.files;
for (let i = 0; i < files.length; i++) {
attachFile(files[i]);
}
});
document.body.addEventListener("drop", ev => {
ev.preventDefault();
const files = ev.dataTransfer.files;
for (let i = 0; i < files.length; i++) {
attachFile(files[i]);
}
});
const sendButton = document.getElementById("send-button"),
saveButton = document.getElementById("save-button");
const XHR_UNSENT = 0,
XHR_OPENED = 1,
XHR_HEADERS_RECEIVED = 2,
XHR_LOADING = 3,
XHR_DONE = 4;
const attachmentUUIDsNode = document.getElementById("attachment-uuids");
function updateState() {
let complete = true;
for (let i = 0; i < attachments.length; i++) {
const a = attachments[i];
const progress = a.node.querySelector(".progress");
progress.style.width = `${Math.floor(a.progress * 100)}%`;
complete &= a.progress === 1.0;
if (a.progress === 1.0) {
progress.style.display = 'none';
}
}
if (complete) {
sendButton.removeAttribute("disabled");
saveButton.removeAttribute("disabled");
} else {
sendButton.setAttribute("disabled", "disabled");
saveButton.setAttribute("disabled", "disabled");
}
attachmentUUIDsNode.value = attachments.
filter(a => a.progress === 1.0).
map(a => a.uuid).
join(",");
}
function attachFile(file) {
helpNode.remove();
const xhr = new XMLHttpRequest();
const node = attachmentNodeFor(file);
const attachment = {
node: node,
progress: 0,
xhr: xhr,
};
attachments.push(attachment);
attachmentsNode.appendChild(node);
let formData = new FormData();
formData.append("attachments", file);
xhr.open("POST", "/compose/attachment");
xhr.upload.addEventListener("progress", ev => {
attachment.progress = ev.loaded / ev.total;
updateState();
});
xhr.addEventListener("load", () => {
// TODO: Handle errors
const resp = JSON.parse(xhr.responseText);
attachment.uuid = resp[0];
updateState();
});
xhr.send(formData);
updateState();
}
function attachmentNodeFor(file) {
const node = document.createElement("div"),
progress = document.createElement("span"),
filename = document.createElement("span"),
size = document.createElement("span"),
button = document.createElement("button");
node.classList.add("upload");
progress.classList.add("progress");
node.appendChild(progress);
filename.classList.add("filename");
filename.innerText = file.name;
node.appendChild(filename);
size.classList.add("size");
size.innerText = formatSI(file.size) + "B";
node.appendChild(size);
button.innerHTML = "&times";
node.appendChild(button);
return node;
}
// via https://github.com/ThomWright/format-si-prefix; MIT license
// Copyright (c) 2015 Thom Wright
const PREFIXES = {
'24': 'Y', '21': 'Z', '18': 'E', '15': 'P', '12': 'T', '9': 'G', '6': 'M',
'3': 'k', '0': '', '-3': 'm', '-6': 'µ', '-9': 'n', '-12': 'p', '-15': 'f',
'-18': 'a', '-21': 'z', '-24': 'y'
};
function formatSI(num) {
if (num === 0) {
return '0';
}
let sig = Math.abs(num); // significand
let exponent = 0;
while (sig >= 1000 && exponent < 24) {
sig /= 1000;
exponent += 3;
}
while (sig < 1 && exponent > -24) {
sig *= 1000;
exponent -= 3;
}
const signPrefix = num < 0 ? '-' : '';
if (sig > 1000) {
return signPrefix + sig.toFixed(0) + PREFIXES[exponent];
}
return signPrefix + parseFloat(sig.toPrecision(3)) + PREFIXES[exponent];
}

View file

@ -166,19 +166,89 @@ main.create-update {
}
main.create-update { flex: 1 auto; padding: 1rem; }
main.create-update form { flex: 1 auto; display: flex; flex-direction: column; }
main.create-update form label { margin-top: 5px; }
/* TODO: CSS grid this */
main.create-update form label span {
display: inline-block;
font-weight: bold;
min-width: 150px;
main.create-update form {
flex: 1 auto;
display: flex;
flex-direction: column;
}
main.create-update form input { width: 80%; }
main.create-update form textarea { flex: 1 auto; resize: none; margin-top: 1rem; }
main.create-update h1 { margin: 0; }
main.create-update .headers {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto auto auto;
grid-gap: 0.5rem;
align-items: center;
}
main.create-update .headers.no-js {
grid-template-columns: auto 1fr;
}
main.create-update .headers label {
grid-column-start: 1;
}
main.create-update .headers input {
grid-column-start: 2;
grid-column-end: 3;
}
main.create-update #attachment-list {
grid-column-start: 3;
grid-row-start: 1;
grid-row-end: 5;
width: 25rem;
height: 100%;
background: #eee;
overflow-y: scroll;
border: 1px solid #eee;
display: flex;
flex-direction: column;
}
main.create-update #attachment-list .help {
text-align: center;
color: #555;
margin-top: 1rem;
}
main.create-update #attachment-list .upload {
width: calc(100% - 1rem);
position: relative;
display: flex;
margin: 0.5rem;
padding: 0.25rem 0.5rem;
background: white;
align-items: center;
}
main.create-update #attachment-list *:not(:last-child) {
margin-right: 0.25rem;
}
main.create-update #attachment-list .upload .filename {
flex-grow: 1;
}
main.create-update #attachment-list .upload button {
padding: inherit;
}
main.create-update #attachment-list .upload .progress {
position: absolute;
height: 5px;
background: #50C878;
bottom: 0;
left: 0;
}
main.create-update textarea {
flex: 1 auto;
resize: none;
margin-top: 1rem;
}
main table { border-collapse: collapse; width: 100%; border: 1px solid #eee; }
main table td {
@ -683,6 +753,12 @@ button:hover,
text-decoration: none;
}
button[disabled], button[disabled]:hover {
color: #555;
background-color: #c5c5c5;
cursor: default;
}
.button:active,
button:active,
.button-link:active {

View file

@ -8,15 +8,14 @@
<div class="container">
<main class="create-update">
<form method="post" action="" enctype="multipart/form-data">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="in_reply_to" value="{{.Message.InReplyTo}}">
<label>
<span>From</span>
<div class="headers no-js">
<label>From</label>
<input type="email" name="from" id="from" value="{{.Message.From}}" />
</label>
<label>
<span>To</span>
<label>To</label>
<input
type="email"
name="to"
@ -26,9 +25,27 @@
list="emails"
{{ if not .Message.To }} autofocus{{ end }}
/>
</label>
<label><span>Subject</span><input type="text" name="subject" id="subject" value="{{.Message.Subject}}" {{ if .Message.To }} autofocus{{ end }}/></label>
<label><span>Attachments</span><input type="file" name="attachments" id="attachments" multiple></label>
<label>Subject</label>
<input type="text" name="subject" id="subject" value="{{.Message.Subject}}" {{ if .Message.To }} autofocus{{ end }}/>
<label>Attachments</label>
<input type="file" name="attachments" id="attachments" multiple>
<div id="attachment-list" style="display: none;">
<div class="help">Drag and drop attachments here</div>
<!--
<div class="upload">
<span class="progress"></span>
<span class="filename">foobar.pdf</span>
<span class="size">1234 KiB</span>
<button>&times;</button>
</div>
-->
</div>
<input type="hidden" id="attachment-uuids" name="attachment-uuids" value="" />
</div>
<!-- TODO: list of previous attachments (needs design) -->
<textarea name="text" class="body">{{.Message.Text}}</textarea>
@ -40,8 +57,8 @@
</datalist>
<div class="actions">
<button type="submit">Send Message</button>
<button type="submit" name="save_as_draft">Save as draft</button>
<button id="send-button" type="submit">Send Message</button>
<button id="save-button" type="submit" name="save_as_draft">Save as draft</button>
<a class="button-link" href="/mailbox/INBOX">Cancel</a>
</div>
</form>
@ -49,6 +66,6 @@
</main>
</div>
</div>
<script src="/themes/alps/assets/attachments.js"></script>
{{template "foot.html"}}