diff options
author | Drew DeVault <sir@cmpwn.com> | 2020-10-29 15:18:36 -0400 |
---|---|---|
committer | Drew DeVault <sir@cmpwn.com> | 2020-10-29 15:18:36 -0400 |
commit | a393429f01e63aa37f58f8cbe4a810e59852fa61 (patch) | |
tree | ce24cbc869e3cdda0b13e9fa3d9e34ff192c868a | |
parent | 490420726952bb3834e6a1cdda7a26c90ba9a7cb (diff) | |
download | alps-a393429f01e63aa37f58f8cbe4a810e59852fa61.tar.gz alps-a393429f01e63aa37f58f8cbe4a810e59852fa61.zip |
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?
-rw-r--r-- | cmd/alps/main.go | 2 | ||||
-rw-r--r-- | plugins/base/routes.go | 50 | ||||
-rw-r--r-- | plugins/base/smtp.go | 47 | ||||
-rw-r--r-- | server.go | 4 | ||||
-rw-r--r-- | session.go | 73 | ||||
-rw-r--r-- | themes/alps/assets/attachments.js | 146 | ||||
-rw-r--r-- | themes/alps/assets/style.css | 96 | ||||
-rw-r--r-- | themes/alps/compose.html | 41 |
8 files changed, 428 insertions, 31 deletions
diff --git a/cmd/alps/main.go b/cmd/alps/main.go index dedd6cb..e519332 100644 --- a/cmd/alps/main.go +++ b/cmd/alps/main.go @@ -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() } diff --git a/plugins/base/routes.go b/plugins/base/routes.go index 0277174..c9df564 100644 --- a/plugins/base/routes.go +++ b/plugins/base/routes.go @@ -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 { diff --git a/plugins/base/smtp.go b/plugins/base/smtp.go index dc8211e..a6cfd3d 100644 --- a/plugins/base/smtp.go +++ b/plugins/base/smtp.go @@ -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) @@ -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 @@ -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) diff --git a/themes/alps/assets/attachments.js b/themes/alps/assets/attachments.js new file mode 100644 index 0000000..bd8c78b --- /dev/null +++ b/themes/alps/assets/attachments.js @@ -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 = "×"; + 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]; +} diff --git a/themes/alps/assets/style.css b/themes/alps/assets/style.css index 77aaf3c..389079c 100644 --- a/themes/alps/assets/style.css +++ b/themes/alps/assets/style.css @@ -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; } +main.create-update form { + flex: 1 auto; + display: flex; + flex-direction: column; +} -/* TODO: CSS grid this */ -main.create-update form label span { - display: inline-block; - font-weight: bold; - min-width: 150px; +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 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 #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 { diff --git a/themes/alps/compose.html b/themes/alps/compose.html index fe3c86a..5874748 100644 --- a/themes/alps/compose.html +++ b/themes/alps/compose.html @@ -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>×</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"}} |