Skip to content
This repository has been archived by the owner on May 11, 2022. It is now read-only.

Commit

Permalink
Track read vs unread mail
Browse files Browse the repository at this point in the history
  • Loading branch information
dcposch committed Dec 2, 2013
1 parent 83ed0a9 commit 8d28945
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 69 deletions.
42 changes: 25 additions & 17 deletions src/scramble/handlers_rest.go
Expand Up @@ -205,7 +205,7 @@ func emailHandler(w http.ResponseWriter, r *http.Request, userID *UserID) {
if r.Method == "GET" {
emailFetchHandler(w, r, userID)
} else if r.Method == "PUT" {
emailBoxHandler(w, r, userID)
emailPutHandler(w, r, userID)
} else if r.Method == "POST" {
emailSendHandler(w, r, userID)
}
Expand All @@ -229,24 +229,23 @@ func emailFetchHandler(w http.ResponseWriter, r *http.Request, userID *UserID) {
}

// PUT /email/id can change things about an email, eg what box it's in
func emailBoxHandler(w http.ResponseWriter, r *http.Request, userID *UserID) {
func emailPutHandler(w http.ResponseWriter, r *http.Request, userID *UserID) {
id := validateMessageID(r.URL.Path[len("/email/"):])
newBox := validateBox(r.FormValue("box"))
moveThread := (r.FormValue("moveThread") == "true")
if r.FormValue("box") != "" {
newBox := validateBox(r.FormValue("box"))
threadMoveBox(id, userID, newBox)
}
if r.FormValue("isRead") != "" {
isRead := r.FormValue("isRead") == "true"
ThreadMarkAsRead(userID.EmailAddress, id, isRead)
}
}

// For now just delete emails instead of moving to "trash".
func threadMoveBox(id string, userID *UserID, newBox string) {
if newBox == "trash" {
if moveThread {
DeleteThreadFromBoxes(userID.EmailAddress, id)
} else {
DeleteFromBoxes(userID.EmailAddress, id)
}
DeleteThreadFromBoxes(userID.EmailAddress, id)
} else {
if moveThread {
MoveThread(userID.EmailAddress, id, newBox)
} else {
MoveEmail(userID.EmailAddress, id, newBox)
}
MoveThread(userID.EmailAddress, id, newBox)
}
}

Expand All @@ -266,9 +265,9 @@ func emailSendHandler(w http.ResponseWriter, r *http.Request, userID *UserID) {

// fail immediately if any address cannot be resolved.
if len(failedHostAddrs) != 0 {
// TODO: better error handling
errMessage := fmt.Sprintf("MX record lookup failed for %v", failedHostAddrs.String())
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("MX record lookup failed for %v", failedHostAddrs.String())))
w.Write([]byte(errMessage))
return
}

Expand Down Expand Up @@ -722,3 +721,12 @@ func reverseQueryHandler(w http.ResponseWriter, r *http.Request, userID *UserID)
}
w.Write(resJSON)
}

func writeJson(w http.ResponseWriter, res *interface{}) {
resJSON, err := json.Marshal(res)
if err != nil {
panic(err)
}
w.Header().Set("Content-Type", "application/json")
w.Write(resJSON)
}
8 changes: 8 additions & 0 deletions src/scramble/migrations.go
Expand Up @@ -25,6 +25,7 @@ var migrations = []func() error{
migrateAddNameResolutionTimestamp,
migrateBoxRemoveError,
migrateAddUserSecondaryEmail,
migrateAddUnreadEmail,
}

func migrateDb() {
Expand Down Expand Up @@ -441,3 +442,10 @@ func migrateAddUserSecondaryEmail() error {
`)
return err
}

func migrateAddUnreadEmail() error {
_, err := db.Exec(`ALTER TABLE box ADD COLUMN
is_read BOOLEAN NOT NULL DEFAULT FALSE;
`)
return err
}
1 change: 1 addition & 0 deletions src/scramble/models.go
Expand Up @@ -27,6 +27,7 @@ type EmailHeader struct {
UnixTime int64
From string
To string
IsRead bool
CipherSubject string
}

Expand Down
26 changes: 23 additions & 3 deletions src/scramble/repo.go
Expand Up @@ -206,7 +206,7 @@ func SaveContacts(token string, cipherContacts string) {
// That are encrypted for a given user
func LoadBox(address string, box string, offset, limit int) []EmailHeader {
rows, err := db.Query("SELECT m.message_id, m.unix_time, "+
" m.from_email, m.to_email, m.cipher_subject, m.thread_id "+
" m.from_email, m.to_email, b.is_read, m.cipher_subject, m.thread_id "+
" FROM email AS m INNER JOIN box AS b "+
" ON b.message_id = m.message_id "+
" WHERE b.address = ? and b.box=? "+
Expand All @@ -223,9 +223,9 @@ func LoadBox(address string, box string, offset, limit int) []EmailHeader {
// Like LoadBox(), but only returns the latest mail in the box for each thread.
func LoadBoxByThread(address string, box string, offset, limit int) []EmailHeader {
rows, err := db.Query("SELECT e.message_id, e.unix_time, "+
"e.from_email, e.to_email, e.cipher_subject, e.thread_id "+
"e.from_email, e.to_email, m.is_read, e.cipher_subject, e.thread_id "+
"FROM email AS e INNER JOIN ( "+
" SELECT box.message_id FROM box INNER JOIN ( "+
" SELECT box.message_id, box.is_read FROM box INNER JOIN ( "+
" SELECT MAX(unix_time) AS unix_time, thread_id FROM box "+
" WHERE address = ? AND box = ? GROUP BY thread_id "+
" ORDER BY unix_time DESC "+
Expand Down Expand Up @@ -263,6 +263,7 @@ func rowsToHeaders(rows *sql.Rows) []EmailHeader {
&header.UnixTime,
&header.From,
&header.To,
&header.IsRead,
&header.CipherSubject,
&header.ThreadID,
)
Expand Down Expand Up @@ -510,6 +511,25 @@ func MoveThread(address string, messageID string, newBox string) {
}
}

// Marks a given set of emails as read (or unread)
func ThreadMarkAsRead(address string, messageID string, isRead bool) {
_, err := db.Exec(
"UPDATE box AS b "+
"INNER JOIN ( "+
"SELECT thread_id, unix_time FROM email "+
"WHERE message_id = ? "+
") AS e ON "+
"b.thread_id = e.thread_id "+
"SET is_read = ? "+
"WHERE "+
"b.address = ? AND "+
"b.unix_time <= e.unix_time",
messageID, isRead, address)
if err != nil {
panic(err)
}
}

// Deletes messages of a thread from any of a user's box.
// If the email is no longer referenced, it gets deleted
// from the email table as well.
Expand Down
13 changes: 12 additions & 1 deletion static/css/my-style.styl
@@ -1,3 +1,9 @@


/*
* ALL PAGES
*/

html, body
height 100%

Expand Down Expand Up @@ -142,4 +148,9 @@ $sidebar-percent-width = 40%




/*
* BOX VIEW
*/
.js-box-item.js-unread
font-weight bold

3 changes: 3 additions & 0 deletions static/css/style.css
Expand Up @@ -5840,6 +5840,9 @@ hr.invis {
-o-animation: spin 3s linear infinite;
animation: spin 3s linear infinite;
}
.js-box-item.js-unread {
font-weight: bold;
}
@-moz-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
Expand Down
4 changes: 2 additions & 2 deletions static/index.html
Expand Up @@ -176,7 +176,7 @@ <h4 class="modal-title" id="myModalLabel">Keyboard shortcuts</h4>
{{#each emailHeaders}}
{{!-- data-msg-id becomes .data("msgID") --}}
<li id="subject-{{HexMessageID}}"
class="js-box-item list-group-item"
class="js-box-item list-group-item {{#if IsRead}}js-read{{else}}js-unread{{/if}}"
data-msg-id="{{MessageID}}"
data-thread-id="{{ThreadID}}"
data-time="{{UnixTime}}"
Expand Down Expand Up @@ -240,7 +240,7 @@ <h4 class="modal-title" id="myModalLabel">Keyboard shortcuts</h4>
<div class="js-email" data-msg-id="{{msgID}}">
<div class="panel panel-default">
<div class="panel-heading">
<small class="received pull-right">{{formatDate time format="MMM D YYYY, hh:mm"}}</small>
<small class="received pull-right">{{formatDate time format="MMM D YYYY, HH:mm"}}</small>
<div class="from">
{{> email-address-partial fromAddress}}
</div>
Expand Down
99 changes: 53 additions & 46 deletions static/js/app.js
Expand Up @@ -272,8 +272,8 @@ var keyMap = {
"r":function(){emailReply(viewState.getLastEmailFromAnother())},
"a":function(){emailReplyAll(viewState.getLastEmail())},
"f":function(){emailForward(viewState.getLastEmail())},
"y":function(){emailMove(viewState.getLastEmail(), "archive", true)},
"d":function(){emailMove(viewState.getLastEmail(), "trash", true)},
"y":function(){threadMove(viewState.getLastEmail(), "archive")},
"d":function(){threadMove(viewState.getLastEmail(), "trash")},
};

function bindKeyboardShortcuts() {
Expand Down Expand Up @@ -673,9 +673,6 @@ function bindEmailEvents() {
$(".js-email-control .js-reply-button").click(withEmail(emailReply));
$(".js-email-control .js-reply-all-button").click(withEmail(emailReplyAll));
$(".js-email-control .js-forward-button").click(withEmail(emailForward));
$(".js-email-control .js-archive-button").click(withEmail(function(email){emailMove(email, "archive", false)}));
$(".js-email-control .js-move-to-inbox-button").click(withEmail(function(email){emailMove(email, "inbox", false)}));
$(".js-email-control .js-delete-button").click(withEmail(function(email){emailMove(email, "trash", false)}));
$(".email .js-enter-add-contact-button").click(addContact);

var withLastEmail = function(cb) {
Expand All @@ -692,9 +689,9 @@ function bindEmailEvents() {
$(".js-thread-control .js-reply-button").click(withLastEmailFromAnother(emailReply));
$(".js-thread-control .js-reply-all-button").click(withLastEmail(emailReplyAll));
$(".js-thread-control .js-forward-button").click(withLastEmail(emailForward));
$(".js-thread-control .js-archive-button").click(withLastEmail(function(email){emailMove(email, "archive", true)}));
$(".js-thread-control .js-move-to-inbox-button").click(withLastEmail(function(email){emailMove(email, "inbox", true)}));
$(".js-thread-control .js-delete-button").click(withLastEmail(function(email){emailMove(email, "trash", true)}));
$(".js-thread-control .js-archive-button").click(withLastEmail(function(email){threadMove(email, "archive")}));
$(".js-thread-control .js-move-to-inbox-button").click(withLastEmail(function(email){threadMove(email, "inbox")}));
$(".js-thread-control .js-delete-button").click(withLastEmail(function(email){threadMove(email, "trash")}));
}

function addContact() {
Expand Down Expand Up @@ -832,15 +829,22 @@ function createEmailViewModel(data) {
to: trimToLower(data.To),
toAddresses: toAddresses,
hexMsgID: bin2hex(data.MessageID),
isRead: data.IsRead,
cipherSubject: data.cipherSubject
// missing subject, htmlBody, plainBody
// those are decrypted asynchronously
};
}

function showEmailThread(emailDatas) {
// Construct view modls
// Construct view models
var emails = emailDatas.map(createEmailViewModel);

// Mark as read
// (any more recent emails on the same thread will not be marked)
markAsRead(emails, true);

// Render HTML
var tid = emails[emails.length-1].threadID;
var subj = cache.plaintextCache[tid+" subject"]
var thread = {
Expand All @@ -860,6 +864,35 @@ function showEmailThread(emailDatas) {
$("#content").empty().append(elThread);
}

function markAsRead(emails, isRead){
var msgID = emails[emails.length-1].msgID;
var hexMsgID = emails[emails.length-1].hexMsgID;

// Update the view models
var changed = false;
for(var i = 0; i < emails.length; i++){
changed |= (emails[i].isRead != isRead);
emails[i].isRead = isRead;
}

// Update the view
$("#subject-"+hexMsgID)
.addClass("js-read")
.removeClass("js-unread");

// Persist the read/unread status
$.ajax({
url: HOST_PREFIX+'/email/'+encodeURI(msgID),
type: 'PUT',
data: {isRead: isRead},
}).done(function() {
// Marked as read
}).fail(function(xhr) {
console.log("Marking thread for "+msgID+" as "+
(isRead?"read":"unread")+" failed: "+xhr.responseText);
});
}

// Turns URLS into links in the plaintext.
// Returns HTML
function createHyperlinks(text) {
Expand Down Expand Up @@ -917,49 +950,35 @@ function emailForward(email) {
displayComposeInline(email, "", email.subject, email.plainBody);
}

// email: the email object
// moveThread: if true, moves all emails in box for thread up to email.unixTime.
// (that way, server doesn't move new emails that the user hasn't seen)
function emailMove(email, box, moveThread) {
// Moves all emails in box for thread up to email.unixTime.
// That way, server doesn't move new emails that the user hasn't seen.
function threadMove(email, box) {
if (keepUnsavedWork()) { return; }
// Do nothing if already moved.
if (email._movedThread || (email._moved && !moveThread)) {
if (email._movedThread) {
return;
}
// Confirm if deleting
if (box == "trash") {
if (moveThread) {
if (!confirm("Are you sure you want to delete this thread?")) return;
} else {
if (!confirm("Are you sure you want to delete this email?")) return;
}
if (!confirm("Are you sure you want to delete this thread?")) return;
}
// Disable buttons while moving
email._moved = true;
email._movedThread = moveThread;
email._movedThread = true;
var elEmail = getEmailElement(email.msgID);
var elThread = elEmail.closest("#thread");
if (moveThread) {
elThread.find(".js-thread-control button").prop("disabled", true);
} else {
elEmail.find(".js-email-control button").prop("disabled", true);
}
elThread.find(".js-thread-control button").prop("disabled", true);

// Send request
var params = {
box: box,
moveThread: (moveThread || false)
box: box
};
$.ajax({
url: HOST_PREFIX+'/email/'+encodeURI(email.msgID),
type: 'PUT',
data: params,
}).done(function() {
if (moveThread) {
$("#thread").remove();
showNextThread();
} else {
removeEmailFromThread(email);
}
$("#thread").remove();
showNextThread();
displayStatus("Moved to "+box);
}).fail(function(xhr) {
alert("Move to "+box+" failed: "+xhr.responseText);
Expand All @@ -975,18 +994,6 @@ function getEmailElement(msgID) {
return elEmail;
}

// Remove the email from the thread.
// If thread is empty, show the next thread.
function removeEmailFromThread(email) {
var elEmail = getEmailElement(email.msgID);
var elThread = elEmail.closest("#thread");
elEmail.remove();
// If this thread has no emails left, then show the next thread.
if (elThread.find("#thread-emails .js-email").length == 0) {
showNextThread();
}
}

function showNextThread() {
var newSelection = $(".box .current").next();
if (newSelection.length == 0) {
Expand Down

0 comments on commit 8d28945

Please sign in to comment.