aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDrew DeVault <sir@cmpwn.com>2020-10-29 15:18:36 -0400
committerDrew DeVault <sir@cmpwn.com>2020-10-29 15:18:36 -0400
commita393429f01e63aa37f58f8cbe4a810e59852fa61 (patch)
treece24cbc869e3cdda0b13e9fa3d9e34ff192c868a
parent490420726952bb3834e6a1cdda7a26c90ba9a7cb (diff)
downloadalps-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.go2
-rw-r--r--plugins/base/routes.go50
-rw-r--r--plugins/base/smtp.go47
-rw-r--r--server.go4
-rw-r--r--session.go73
-rw-r--r--themes/alps/assets/attachments.js146
-rw-r--r--themes/alps/assets/style.css96
-rw-r--r--themes/alps/compose.html41
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)
diff --git a/server.go b/server.go
index dc2708e..7a82d35 100644
--- a/server.go
+++ b/server.go
@@ -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
diff --git a/session.go b/session.go
index 9428cd9..0aca9ed 100644
--- a/session.go
+++ b/session.go
@@ -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 = "&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];
+}
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>&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"}}