Browse Source

Implement folder statistic

- Add page and up-to-date folder statistic
- Split delete and remove functionality
- Add unified folder list
Alexey Edelev 5 years ago
parent
commit
fc4c7cb837

+ 32 - 0
common/folders.go

@@ -0,0 +1,32 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020 Alexey Edelev <semlanik@gmail.com>
+ *
+ * This file is part of gostfix project https://git.semlanik.org/semlanik/gostfix
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this
+ * software and associated documentation files (the "Software"), to deal in the Software
+ * without restriction, including without limitation the rights to use, copy, modify,
+ * merge, publish, distribute, sublicense, and/or sell copies of the Software, and
+ * to permit persons to whom the Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies
+ * or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+ * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+ * FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+package common
+
+const (
+	Inbox = "Inbox"
+	Trash = "Trash"
+	Spam  = "Spam"
+)

+ 5 - 4
common/metadata.go

@@ -26,8 +26,9 @@
 package common
 
 type MailMetadata struct {
-	Id   string `bson:"_id"`
-	User string
-	Mail *Mail
-	Read bool
+	Id     string `bson:"_id"`
+	User   string
+	Mail   *Mail
+	Read   bool
+	Folder string
 }

+ 18 - 6
db/db.go

@@ -273,7 +273,19 @@ func (s *Storage) SaveMail(email, folder string, m *common.Mail) error {
 	return nil
 }
 
-func (s *Storage) RemoveMail(user string, mailId string) error {
+func (s *Storage) MoveMail(user string, mailId string, folder string) error {
+	mailsCollection := s.db.Collection(qualifiedMailCollection(user))
+
+	oId, err := primitive.ObjectIDFromHex(mailId)
+	if err != nil {
+		return err
+	}
+
+	_, err = mailsCollection.UpdateOne(context.Background(), bson.M{"_id": oId}, bson.M{"$set": bson.M{"folder": folder}})
+	return err
+}
+
+func (s *Storage) DeleteMail(user string, mailId string) error {
 	mailsCollection := s.db.Collection(qualifiedMailCollection(user))
 
 	oId, err := primitive.ObjectIDFromHex(mailId)
@@ -338,14 +350,14 @@ func (s *Storage) GetEmailStats(user string, email string, folder string) (unrea
 		Unread int
 	}{}
 
-	cur, err := mailsCollection.Aggregate(context.Background(), bson.A{bson.M{"$match": bson.M{"email": email, "read": false}}, bson.M{"$count": "unread"}})
+	cur, err := mailsCollection.Aggregate(context.Background(), bson.A{bson.M{"$match": bson.M{"email": email, "folder": folder, "read": false}}, bson.M{"$count": "unread"}})
 	if err == nil && cur.Next(context.Background()) {
 		cur.Decode(result)
 	} else {
 		return 0, 0, err
 	}
 
-	cur, err = mailsCollection.Aggregate(context.Background(), bson.A{bson.M{"$match": bson.M{"email": email}}, bson.M{"$count": "total"}})
+	cur, err = mailsCollection.Aggregate(context.Background(), bson.A{bson.M{"$match": bson.M{"email": email, "folder": folder}}, bson.M{"$count": "total"}})
 	if err == nil && cur.Next(context.Background()) {
 		cur.Decode(result)
 	} else {
@@ -423,9 +435,9 @@ func (s *Storage) GetAllEmails() (emails []string, err error) {
 
 func (s *Storage) GetFolders(email string) (folders []*common.Folder) {
 	folders = []*common.Folder{
-		&common.Folder{Name: "Inbox", Custom: false},
-		&common.Folder{Name: "Trash", Custom: false},
-		&common.Folder{Name: "Spam", Custom: false},
+		&common.Folder{Name: common.Inbox, Custom: false},
+		&common.Folder{Name: common.Trash, Custom: false},
+		&common.Folder{Name: common.Spam, Custom: false},
 	}
 	return
 }

+ 2 - 2
scanner/mailscanner.go

@@ -167,7 +167,7 @@ func (ms *MailScanner) readEmailMaps() {
 
 		mails := ms.readMailFile(mailPath)
 		for _, mail := range mails {
-			ms.storage.SaveMail(mailbox, "Inbox", mail)
+			ms.storage.SaveMail(mailbox, common.Inbox, mail)
 		}
 		log.Printf("New email for %s, emails read %d", mailPath, len(mails))
 
@@ -207,7 +207,7 @@ func (ms *MailScanner) Run() {
 					if mailbox != "" {
 						mails := ms.readMailFile(mailPath)
 						for _, mail := range mails {
-							ms.storage.SaveMail(mailbox, "Inbox", mail)
+							ms.storage.SaveMail(mailbox, common.Inbox, mail)
 						}
 						log.Printf("New email for %s, emails read %d", mailPath, len(mails))
 					} else {

File diff suppressed because it is too large
+ 0 - 0
web/assets/next.svg


File diff suppressed because it is too large
+ 0 - 0
web/assets/prev.svg


+ 7 - 5
web/css/controls.css

@@ -199,12 +199,14 @@
 }
 
 .folderBtn {
-    padding-left: 5pt;
-    padding-top: 5pt;
-    padding-bottom: 5pt;
-    width: 100%;
+    display: flex;
+    flex-direction: row;
+    padding: 2pt;
+    left: 0;
+    right: 0;
+    margin-top: 5pt;
     border-bottom-right-radius: var(--default-radius);
-    border-bottom-left-radius: var(--default-radius);
+    border-top-right-radius: var(--default-radius);
     background-color: var(--secondary-color);
 }
 

+ 16 - 2
web/css/index.css

@@ -20,14 +20,29 @@ html, body {
     flex-direction: column;
 }
 
-#statusLine {
+#headerBox {
+    display: flex;
+    flex-direction: row;
     flex: 0 1 auto;
+}
+
+#statusLine {
+    flex: 1 1 auto;
     text-overflow: ellipsis;
     overflow: hidden;
     white-space: nowrap;
     padding: var(--base-text-padding);
 }
 
+#pager {
+    display: flex;
+    flex-direction: row;
+    flex: 0 1 auto;
+    width: 100pt;
+    min-width: 100pt;
+    max-width: 100pt;
+}
+
 #contentBox {
     display: flex;
     flex-direction: row;
@@ -42,7 +57,6 @@ html, body {
     min-width: 150pt;
     height: 100%;
     max-height: 100%;
-    background-color: honeydew;
 }
 
 #mailInnerBox {

+ 54 - 14
web/js/index.js

@@ -31,6 +31,7 @@ var updateInterval = 50000
 var mailbox = ""
 var pageMax = 10
 const mailboxRegex = /^(\/m\d+)/g
+var folders = new Array()
 
 $(document).ready(function(){
     $.ajaxSetup({
@@ -52,7 +53,6 @@ $(document).ready(function(){
 
     if (mailbox != "") {
         clearInterval(updateTimerId)
-        updateTimerId = setInterval(updateMailList, updateInterval, currentFolder+currentPage)
     }
 })
 
@@ -74,11 +74,12 @@ function onHashChanged() {
 
     hashRegex = /^#([a-zA-Z]+)(\d*)\/?([A-Fa-f\d]*)/g
     hashParts = hashRegex.exec(hashLocation)
-    console.log("Hash parts: " + hashParts)
     page = 0
     if (hashParts.length >= 3 && hashParts[2] != "") {
-        console.log("page found: " + hashParts[2])
-        page = hashParts[2]
+        page = parseInt(hashParts[2])
+        if (typeof page != "number" || page > pageMax || page < 0) {
+            page = 0
+        }
     }
 
     if (hashParts.length >= 2 && (hashParts[1] != currentFolder || currentPage != page) && hashParts[1] != "") {
@@ -93,8 +94,6 @@ function onHashChanged() {
     } else {
         setDetailsVisible(false)
     }
-
-
 }
 
 function requestMail(mailId) {
@@ -110,6 +109,7 @@ function requestMail(mailId) {
                 $("#mail"+mailId).addClass("read")
                 $("#mailDetails").html(result);
                 setDetailsVisible(true);
+                folderStat(currentFolder);//TODO: receive statistic from websocket
             },
             error: function(jqXHR, textStatus, errorThrown) {
                 $("#mailDetails").html(textStatus)
@@ -128,7 +128,34 @@ function loadFolders() {
     $.ajax({
         url: mailbox + "/folders",
         success: function(result) {
-            $("#folders").html(result)
+            folderList = jQuery.parseJSON(result)
+            for(var i = 0; i < folderList.folders.length; i++) {
+                folders.push(folderList.folders[i].name)
+                folderStat(folderList.folders[i].name)
+            }
+            $("#folders").html(folderList.html)
+        },
+        error: function(jqXHR, textStatus, errorThrown) {
+            //TODO: some toast message here once implemented
+        }
+    })
+}
+
+function folderStat(folder) {
+    $.ajax({
+        url: mailbox + "/folderStat",
+        data: {
+            folder: folder
+        },
+        success: function(result) {
+            var stats = jQuery.parseJSON(result)
+            if (stats.unread > 0) {
+                $("#folderStats"+folder).text(stats.unread)
+                $("#folder"+folder).addClass("unread")
+            } else {
+                $("#folder"+folder).removeClass("unread")
+                $("#folderStats"+folder).text("")
+            }
         },
         error: function(jqXHR, textStatus, errorThrown) {
             //TODO: some toast message here once implemented
@@ -190,6 +217,7 @@ function setRead(mailId, read) {
                 $("#mail"+mailId).removeClass("read")
                 $("#mail"+mailId).addClass("unread")
             }
+            folderStat(currentFolder);//TODO: receive statistic from websocket
         },
         error: function(jqXHR, textStatus, errorThrown) {
         }
@@ -202,13 +230,18 @@ function toggleRead(mailId) {
     }
 }
 
-function removeMail(mailId) {
+function removeMail(mailId, callback) {
+    var url = currentFolder != "Trash" ? "/remove" : "/delete"
     $.ajax({
-        url: "/remove",
+        url: url,
         data: {mailId: mailId},
         success: function(result) {
             $("#mail"+mailId).remove();
-            closeDetails()
+            if (callback) {
+                callback();
+            }
+            folderStat(currentFolder);//TODO: receive statistic from websocket
+            folderStat("Trash");//TODO: receive statistic from websocket
         },
         error: function(jqXHR, textStatus, errorThrown) {
         }
@@ -225,7 +258,6 @@ function setDetailsVisible(visible) {
         $("#mailDetails").hide()
         $("#mailDetails").html("")
         $("#mailList").css({pointerEvents: "auto"})
-        updateTimerId = setInterval(updateMailList, updateInterval, currentFolder+currentPage)
     }
 }
 
@@ -245,12 +277,20 @@ function updateMailList(folder, page) {
         },
         success: function(result) {
             var data = jQuery.parseJSON(result)
-            var mailCount = data.total
+            pageMax = Math.floor(data.total/50)
+
             if ($("#mailList")) {
                 $("#mailList").html(data.html)
             }
             currentFolder = folder
             currentPage = page
+
+            if($("#currentPageIndex")) {
+                $("#currentPageIndex").text(currentPage + 1)
+            }
+            if($("#totalPageCount")) {
+                $("#totalPageCount").text(pageMax + 1)
+            }
         },
         error: function(jqXHR, textStatus, errorThrown) {
             if ($("#mailList")) {
@@ -261,11 +301,11 @@ function updateMailList(folder, page) {
 }
 
 function nextPage() {
-    var newPage = currentPage > 0 ? currentPage - 1 : 0
+    var newPage = currentPage < (pageMax - 1) ? currentPage + 1 : pageMax
     window.location.hash = currentFolder + newPage
 }
 
 function prevPage() {
-    var newPage = currentPage < (pageMax - 1) ? currentPage + 1 : pageMax
+    var newPage = currentPage > 0 ? currentPage - 1 : 0
     window.location.hash = currentFolder + newPage
 }

+ 17 - 1
web/mail.go

@@ -28,7 +28,10 @@ package web
 import (
 	"fmt"
 	template "html/template"
+	"log"
 	"net/http"
+
+	"git.semlanik.org/semlanik/gostfix/common"
 )
 
 func (s *Server) handleMailRequest(w http.ResponseWriter, r *http.Request) {
@@ -52,6 +55,8 @@ func (s *Server) handleMailRequest(w http.ResponseWriter, r *http.Request) {
 		s.handleSetRead(w, r, user, mailId)
 	case "/remove":
 		s.handleRemove(w, user, mailId)
+	case "/delete":
+		s.handleDelete(w, user, mailId)
 	}
 }
 
@@ -87,5 +92,16 @@ func (s *Server) handleSetRead(w http.ResponseWriter, r *http.Request, user, mai
 }
 
 func (s *Server) handleRemove(w http.ResponseWriter, user, mailId string) {
-	s.storage.RemoveMail(user, mailId)
+	err := s.storage.MoveMail(user, mailId, common.Trash)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Could not move email to trash", w)
+	}
+}
+
+func (s *Server) handleDelete(w http.ResponseWriter, user, mailId string) {
+	log.Printf("Delete mail")
+	err := s.storage.DeleteMail(user, mailId)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Could not delete email", w)
+	}
 }

+ 78 - 22
web/mailbox.go

@@ -26,6 +26,7 @@
 package web
 
 import (
+	"encoding/json"
 	"fmt"
 	template "html/template"
 	"log"
@@ -36,7 +37,7 @@ import (
 )
 
 func (s *Server) handleMailbox(w http.ResponseWriter, user, email string) {
-	mailList, err := s.storage.MailList(user, email, "Inbox", common.Frame{Skip: 0, Limit: 50})
+	mailList, err := s.storage.MailList(user, email, common.Inbox, common.Frame{Skip: 0, Limit: 50})
 
 	if err != nil {
 		s.error(http.StatusInternalServerError, "Couldn't read email database", w)
@@ -77,6 +78,8 @@ func (s *Server) handleMailboxRequest(path, user string, mailbox int, w http.Res
 		s.handleMailbox(w, user, emails[mailbox])
 	case "folders":
 		s.handleFolders(w, user, emails[mailbox])
+	case "folderStat":
+		s.handleFolderStat(w, r, user, emails[mailbox])
 	case "statusLine":
 		s.handleStatusLine(w, user, emails[mailbox])
 	case "mailList":
@@ -87,29 +90,60 @@ func (s *Server) handleMailboxRequest(path, user string, mailbox int, w http.Res
 }
 
 func (s *Server) handleFolders(w http.ResponseWriter, user, email string) {
-	fmt.Fprintf(w, s.templater.ExecuteFolders(s.storage.GetFolders(email)))
+	folders := s.storage.GetFolders(email)
+
+	out, err := json.Marshal(&struct {
+		Folders []*common.Folder `json:"folders"`
+		Html    string           `json:"html"`
+	}{
+		Folders: folders,
+		Html:    s.templater.ExecuteFolders(s.storage.GetFolders(email)),
+	})
+
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Could not fetch folder list", w)
+	}
+
+	w.Write(out)
+}
+
+func (s *Server) handleFolderStat(w http.ResponseWriter, r *http.Request, user, email string) {
+	unread, total, err := s.storage.GetEmailStats(user, email, s.extractFolder(email, r))
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Couldn't read mailbox stat", w)
+		return
+	}
+
+	out, err := json.Marshal(&struct {
+		Total  int `json:"total"`
+		Unread int `json:"unread"`
+	}{
+		Total:  total,
+		Unread: unread,
+	})
+
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Couldn't parse mailbox stat", w)
+		return
+	}
+
+	w.Write(out)
 }
 
 func (s *Server) handleMailList(w http.ResponseWriter, r *http.Request, user, email string) {
-	folder := r.FormValue("folder")
+	folder := s.extractFolder(email, r)
 	page, err := strconv.Atoi(r.FormValue("page"))
+
 	if err != nil {
 		page = 0
 	}
 
-	folders := s.storage.GetFolders(email)
-	ok := false
-	for _, existFolder := range folders {
-		if folder == existFolder.Name {
-			ok = true
-			break
-		}
+	_, total, err := s.storage.GetEmailStats(user, email, folder)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Couldn't read email database", w)
+		return
 	}
 
-	if !ok {
-		folder = "Inbox"
-	}
-	_, total, err := s.storage.GetEmailStats(user, email, folder)
 	mailList, err := s.storage.MailList(user, email, folder, common.Frame{Skip: int32(50 * page), Limit: 50})
 
 	if err != nil {
@@ -117,8 +151,18 @@ func (s *Server) handleMailList(w http.ResponseWriter, r *http.Request, user, em
 		return
 	}
 
-	out := fmt.Sprintf("{total: %d, html: \"%s\", }", total, s.templater.ExecuteMailList(mailList))
-	fmt.Fprint(w, out)
+	out, err := json.Marshal(&struct {
+		Total int    `json:"total"`
+		Html  string `json:"html"`
+	}{
+		Total: total,
+		Html:  s.templater.ExecuteMailList(mailList),
+	})
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Could not perform maillist", w)
+		return
+	}
+	w.Write(out)
 }
 
 func (s *Server) handleStatusLine(w http.ResponseWriter, user, email string) {
@@ -128,12 +172,6 @@ func (s *Server) handleStatusLine(w http.ResponseWriter, user, email string) {
 		return
 	}
 
-	// unread, total, err := s.storage.GetEmailStats(user, email)
-	// if err != nil {
-	// 	s.error(http.StatusInternalServerError, "Could not read user stats", w)
-	// 	return
-	// }
-
 	fmt.Fprint(w, s.templater.ExecuteStatusLine(&struct {
 		Name  string
 		Email string
@@ -142,3 +180,21 @@ func (s *Server) handleStatusLine(w http.ResponseWriter, user, email string) {
 		Email: email,
 	}))
 }
+
+func (s *Server) extractFolder(email string, r *http.Request) string {
+	folder := r.FormValue("folder")
+	folders := s.storage.GetFolders(email)
+	ok := false
+	for _, existFolder := range folders {
+		if folder == existFolder.Name {
+			ok = true
+			break
+		}
+	}
+
+	if !ok {
+		folder = common.Inbox
+	}
+
+	return folder
+}

+ 2 - 0
web/server.go

@@ -131,6 +131,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		case "/setRead":
 			fallthrough
 		case "/remove":
+			fallthrough
+		case "/delete":
 			s.handleMailRequest(w, r)
 		default:
 			http.Redirect(w, r, "/m0", http.StatusTemporaryRedirect)

+ 1 - 1
web/templates/details.html

@@ -7,7 +7,7 @@
                 <span class="primaryText">Subject: {{.Subject}}</span></br>
             </div>
             <img id="readIcon{{.MailId}}" class="iconBtn" style="width: 20pt; margin-right: 10pt;" onclick="toggleRead({{.MailId}});" src="/assets/read.svg"/>
-            <img id="deleteIcon" class="iconBtn" style="width: 20pt; margin-right: 10pt;" onclick="removeMail({{.MailId}});" src="/assets/remove.svg"/>
+            <img id="deleteIcon" class="iconBtn" style="width: 20pt; margin-right: 10pt;" onclick="removeMail({{.MailId}}, closeDetails);" src="/assets/remove.svg"/>
             <div class="btn materialLevel1" style="width: 40pt; right: 0pt; top: 0pt; margin: auto;" onclick="closeDetails();">
                 <img src="/assets/back.svg" style="width: 20pt"/>
             </div>

+ 2 - 2
web/templates/folders.html

@@ -1,3 +1,3 @@
 {{range .}}
-<div class="folderBtn" onclick="openFolder('{{.Name}}')">{{.Name}}</div>
-{{end}}
+<div id="folder{{.Name}}" class="folderBtn" onclick="openFolder('{{.Name}}')"><span style="flex: 1 1 auto;">{{.Name}}</span><span id="folderStats{{.Name}}" style="width: 40pt; min-width: 40pt; flex: 1 1 auto; text-align: right;"></span></div>
+{{end}}

+ 12 - 1
web/templates/index.html

@@ -12,7 +12,18 @@
     </head>
     <body>
         <div id="main">
-            <div id="statusLine"></div>
+            <div id="headerBox">
+                <div id="statusLine"></div>
+                <div id="pager">
+                    <img style="width: 20pt;" src="/assets/prev.svg" onclick="prevPage()">
+                    <div style="width: 60pt;display: flex;">
+                        <span id="currentPageIndex" style="margin:auto"></span>
+                        <span style="margin:auto">/</span>
+                        <span id="totalPageCount" style="margin:auto"></span>
+                    </div>
+                    <img style="width: 20pt;" src="/assets/next.svg" onclick="nextPage()">
+                </div>
+            </div>
             <div class="horizontalPaddingBox">
                 <div id="contentBox">
                     <div id="foldersBox">

Some files were not shown because too many files changed in this diff