~emersion/alps-dev

4 3

Session timeouts discard drafts

Details
Message ID
<0fc4ae1bdc94d3f036f4c8f0001e5205@c3f.net>
DKIM signature
missing
Download raw message
Currently, session timeouts have the potential to cause unexpected email loss.

Recently, one of our users was composing an email and pressed the "save to drafts"
button after her session had already timed out. After pressing this button her
carefully composed email was discarded and she was presented with a login screen.

I'd like to talk about ideas for improving this situation. My immediate thought
was automatically saving emails that are being composed into Drafts every N seconds,
which seems like the obvious solution. But I'm not sure if that jives with what
you'd like to do.

Any thoughts?
Details
Message ID
<vHbPtTqbfPlFFHngJw0Z1vbkrmULxYUjdStwF-9dJSsRn38BsmkHJc5ysRrtOjUlb4hL2H_T9nUyPZvgJY2e8rBUEEEKCk2-Nx_akyM5Ec0=@emersion.fr>
In-Reply-To
<0fc4ae1bdc94d3f036f4c8f0001e5205@c3f.net> (view parent)
DKIM signature
missing
Download raw message
Hi,

On Tuesday, August 4, 2020 6:05 AM, <j3s@c3f.net> wrote:

> Currently, session timeouts have the potential to cause unexpected email loss.
>
> Recently, one of our users was composing an email and pressed the "save to drafts"
> button after her session had already timed out. After pressing this button her
> carefully composed email was discarded and she was presented with a login screen.
>
> I'd like to talk about ideas for improving this situation. My immediate thought
> was automatically saving emails that are being composed into Drafts every N seconds,
> which seems like the obvious solution. But I'm not sure if that jives with what
> you'd like to do.
>
> Any thoughts?

Is the session cookie expiring?

We also have a login cookie which allows users to re-authenticate (and
not loose their session after a server restart). Its lifetime is 30
days. The login cookie is only set if you specify -login-key on the
command line.

Though I'm not sure this will fix your issue. The login cookie is
restricted to the /login path, so the redirect to the login page is
still necessary (even if the login form is skipped).

Overall I agree auto-saving drafts would be a nice feature. I wonder if
there's a meaningful way to implement this without JS. I'm worried
about auto-saving while the user is typing.

If not, then I'm perfectly fine with adding some minimal JS to
implement this feature. Of course the existing functionality still
needs to work without JS.

I wonder how attachments should be handled. If the user adds an
attachment, then auto-save kicks in, then they change their mind and
attach another file, we don't want to end up with two attachments.

Thanks,

Simon
Details
Message ID
<bd3659ce8a8de41c28b0bbbe7dd734c4@c3f.net>
In-Reply-To
<vHbPtTqbfPlFFHngJw0Z1vbkrmULxYUjdStwF-9dJSsRn38BsmkHJc5ysRrtOjUlb4hL2H_T9nUyPZvgJY2e8rBUEEEKCk2-Nx_akyM5Ec0=@emersion.fr> (view parent)
DKIM signature
missing
Download raw message
> Is the session cookie expiring?

Correct. If the session cookie expired, clicking on "Save Draft"
redirects you to the login page. If you try and click "back" you
see only the login page as well, so there's no obvious path to
retrieve your email - it is "lost" at this point.

> -login-key

After some trial and error I enabled this option. I used the following
code to generate a fernet key:

package main

import (
"github.com/fernet/fernet-go"
"fmt"
)

func main () {
key := fernet.Key{}
key.Generate()
enc := key.Encode()
fmt.Println(enc)
}

warning to future viewers of this thread: run the above code on a secure
system if you plan on using the resulting key in production.

If the login-key option is enabled and "remember me" has been checked, the
process is possibly worse:

0: session expires / session cookie is deleted
1: user clicks "save to drafts" and is redirected to their inbox.
2: user clicks "Drafts"
3: draft is not there

So, even with a sticky login key that renews your session, saving things as
drafts will not work if your session has expired.

At least, that's my understanding, so I still see a need for the auto-saving
of drafts.

> Overall I agree auto-saving drafts would be a nice feature. I wonder if
> there's a meaningful way to implement this without JS. I'm worried
> about auto-saving while the user is typing.

I can't think of any obvious way this could happen, have asked a few friends
for inspiration, but it's likely that I'll take the JS path here.

> If not, then I'm perfectly fine with adding some minimal JS to
> implement this feature. Of course the existing functionality still
> needs to work without JS.

I'm thinking that I'll write a tiny JS implementation that just calls
"save to drafts" transparently after the user stops typing for 2 seconds.
Does that seem reasonable?

> I wonder how attachments should be handled. If the user adds an
> attachment, then auto-save kicks in, then they change their mind and
> attach another file, we don't want to end up with two attachments.

I tested this, and it looks like the way Save to Drafts works with
regards to attachments is _currently_ a little wonky.

0: I draft an email with an attachment and press "Save to Drafts"
1: I open Drafts and click the email
2: An attachment is shown in the MIME metadata
3: click Edit Draft
4: attachment disappears
5: send email
6: email is sent without attachment

I imagine that this is the same way the JS autosave feature would work
if implemented today. I think that the way it ought to work is:

0: user composes email with attachment
1: autosave kicks in, that email + attachment are saved to drafts
2: user removes first attachment, adds a second one
3: draft is saved over the previous copy, previous attachment is removed


Sorry for the long email, I'll start working on a teeny JS thing
that implements auto-save and see how it turns out.
Details
Message ID
<a1e201895bb264e4292511582e039b4d@c3f.net>
In-Reply-To
<vHbPtTqbfPlFFHngJw0Z1vbkrmULxYUjdStwF-9dJSsRn38BsmkHJc5ysRrtOjUlb4hL2H_T9nUyPZvgJY2e8rBUEEEKCk2-Nx_akyM5Ec0=@emersion.fr> (view parent)
DKIM signature
missing
Download raw message
Hi! I ran this by my friend Forest, and he came up with the following proof
of concept for auto-saving drafts.

Please note that the following JavaScript is included for feedback purposes
only and it's not the final product - I intend to shrink it down a lot before
final submission.

But first, I'd like your feedback. To use this, start composing a message via
the alps interface, and paste the following code into the console of your
browser. The message should begin auto-saving.

It also does some really gross scraping to get Draft IDs, I'd clean that up and
have the backend return the Draft ID instead.

I am looking for feedback on things like

- does the spinner look alright?
- are you comfortable with the intervals we have set?
- ignoring the CSS and draft scraping sections, is the code simple enough?
- any other feedback you might have

Thanks! I just want to be sure to get this right.


function debounce(func, wait, immediate) {
  var timeout;
  return function () {
    var context = this, args = arguments;
    var later = function () {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    var callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) func.apply(context, args);
  };
};
var minimumMillisecondsBetweenAutoSaves = 7000;
var millisecondsWithoutInputToBeConsideredDoneTyping = 1200;
var minimumAutosaveSpinnerMilliseconds = 800;
var draftId = null;
var autoSaveInputsSelector = "form input[type=text], form input[type=hidden], form input[type=email], form textarea";
var autoSaveInputs = Array.from(document.querySelectorAll(autoSaveInputsSelector));
var saveAsDraftButton = document.querySelector('button[name=save_as_draft]');
var autoSaveSpinner = document.createElement("div");
autoSaveSpinner.style.visibility = "hidden";
autoSaveSpinner.className = "autosave_spinner";
var autoSaveSpinnerStylesheet = document.createElement("style");
autoSaveSpinnerStylesheet.innerText = ''
  + '.autosave_spinner {'
  + '  width: 1.5em;'
  + '  height: 1.5em;'
  + '  background-color: #999;'
  + '  animation-name: autosave_spinner;'
  + '  animation-duration: 500ms;'
  + '  animation-iteration-count: infinite;'
  + '  animation-timing-function: ease-in-out;'
  + '}'
  + ' '
  + 'button[disabled] {'
  + ' color: #aaa;'
  + '}'
  + ' '
  + '@keyframes autosave_spinner {'
  + '  from { transform: scale(1.0, 1.0) rotate(0deg) ; }'
  + '  50% { transform: scale(1.4, 0.8) rotate(45deg) ; }'
  + '  to { transform: scale(1.0, 1.0) rotate(90deg) ; }'
  + '}'
  + '';
document.head.appendChild(autoSaveSpinnerStylesheet);
saveAsDraftButton.insertAdjacentElement("afterend", autoSaveSpinner);

var autoSave = function () {
  var formData = new FormData();
  autoSaveInputs.forEach(function (element) {
    formData.append(element.name, element.value);
  });
  formData.append("save_as_draft", "");
  var request = new XMLHttpRequest();
  var whenRequestStarted = new Date().getTime();
  var isEditingDraft = window.location.pathname.startsWith("/message/Drafts/");
  var needToDiscoverDraftId = draftId == null && !isEditingDraft;
  request.onloadend = function () {
    // let the spinner keep spinning for at least 1 second regardless how long request took.
    // (indication to user that it was auto-saved)
    var duration = new Date().getTime() - whenRequestStarted;
    if (needToDiscoverDraftId) {
      var getDraftIdRequest = new XMLHttpRequest();
      getDraftIdRequest.onloadend = () => {
        var draftLinkRegex = /<a href="\/message\/Drafts\/([0-9]+)\?part=1">/g;
        var matches = getDraftIdRequest.responseText.matchAll(draftLinkRegex);
        var highest = -1;
        for (var match of matches) {
          if(Number(match[1]) != NaN && Number(match[1]) > highest) {
            highest = Number(match[1]);
          }
        }
        if(highest != -1) {
          draftId = highest;
        }
      }

      getDraftIdRequest.open("GET", "/mailbox/Drafts");
      getDraftIdRequest.send();
    }
    setTimeout(function () {
      saveAsDraftButton.disabled = false;
      autoSaveSpinner.style.visibility = "hidden";
    }, Math.max(minimumAutosaveSpinnerMilliseconds - duration, 1));
  };

  saveAsDraftButton.disabled = true;
  autoSaveSpinner.style.visibility = "visible";
  if (window.location.pathname.startsWith("/message/Drafts/")) {
    request.open("POST", window.location.pathname + window.location.search);
  } else if (draftId != null) {
    request.open("POST", "/message/Drafts/" + draftId + "/edit?part=1");
  } else {
    request.open("POST", "/compose");
  }

  request.send(formData);
};
var ref = {};
var lastAutoSave = 0;
function onUserStoppedTyping() {

  var enoughTimeElapsed = (new Date().getTime() - lastAutoSave) > minimumMillisecondsBetweenAutoSaves;
  var currentlyAutosaving = autoSaveSpinner.style.visibility == "visible";
  if (!currentlyAutosaving && enoughTimeElapsed) {
    lastAutoSave = new Date().getTime();
    autoSave();
  } else {
    ref.triggerAgainLater();
  }
};
ref.triggerAgainLater = debounce(onUserStoppedTyping, minimumMillisecondsBetweenAutoSaves);
var onDraftChanged = debounce(onUserStoppedTyping, millisecondsWithoutInputToBeConsideredDoneTyping);
autoSaveInputs.forEach(function (element) {
  element.onchange = onDraftChanged;
  element.onkeyup = onDraftChanged;
});
Details
Message ID
<C54O02BXIEFJ.25IU7D3RWJR5@zach-macbookpro121>
In-Reply-To
<a1e201895bb264e4292511582e039b4d@c3f.net> (view parent)
DKIM signature
missing
Download raw message
> Please note that the following JavaScript is included for feedback purposes
> only and it's not the final product - I intend to shrink it down a lot before
> final submission.
> 
> But first, I'd like your feedback. To use this, start composing a message via
> the alps interface, and paste the following code into the console of your
> browser. The message should begin auto-saving.
> 
> It also does some really gross scraping to get Draft IDs, I'd clean that up and
> have the backend return the Draft ID instead.

I'm happy to see something at least somewhat working for this critical feature in alps.

I started an approach on this a while back, but didn't complete it, basically the idea was

1. change 'save as draft' to go to the message
2. submit form in the background every n seconds
3. push the redirected page + `/edit` to the history stack (url bar).


> I am looking for feedback on things like
> 
> - does the spinner look alright?
eh, no
> - are you comfortable with the intervals we have set?
seems fine
> - ignoring the CSS and draft scraping sections, is the code simple enough?
> - any other feedback you might have

I'm ending up with tons of copies of any particular draft in my Drafts folder.

I'm thinking "Save as drafts" from the compose page behaves differently from "Save as drafts" from the edit draft page,
so there's probably some invisible form element that needs to get edited every time a draft is saved.
(but that could make this a little destructive if the draft id was presumed incorrectly).

Is there a way to get the draft ID from IMAP when you save the draft?

-Zach
Reply to thread Export thread (mbox)