Browse Source

Add new email functionality

- Implement basic forms to compose mail
- Implement server-side sendmail, not tested yet
- Refactor sasl authentication
- Complete authentication for sasl
- Add mydomain setting read from postfix config. This host will
  be used to send emails.
Alexey Edelev 5 years ago
parent
commit
d97685e16e

+ 10 - 1
config/config.go

@@ -47,6 +47,7 @@ const (
 )
 
 const (
+	PostfixKeyMyDomain              = "mydomain"
 	PostfixKeyVirtualMailboxMaps    = "virtual_mailbox_maps"
 	PostfixKeyVirtualMailboxBase    = "virtual_mailbox_base"
 	PostfixKeyVirtualMailboxDomains = "virtual_mailbox_domains"
@@ -69,6 +70,7 @@ func ConfigInstance() *GostfixConfig {
 }
 
 type gostfixConfig struct {
+	MyDomain        string
 	VMailboxMaps    string
 	VMailboxBase    string
 	VMailboxDomains []string
@@ -128,11 +130,17 @@ func newConfig() (config *gostfixConfig, err error) {
 		}
 	}
 
-	if len(validDomains) < 0 {
+	if len(validDomains) <= 0 {
 		log.Fatalf("Virtual mailbox domains %s are not configured proper way, check %s in %s\n", domains, PostfixKeyVirtualMailboxDomains, postfixConfigPath)
 		return
 	}
 
+	myDomain := postfixCfg.Section("").Key(PostfixKeyMyDomain).String()
+
+	if len(myDomain) <= 0 {
+		myDomain = "localhost"
+	}
+
 	mongoUser := cfg.Section("").Key(KeyMongoUser).String()
 
 	mongoPassword := cfg.Section("").Key(KeyMongoPassword).String()
@@ -150,6 +158,7 @@ func newConfig() (config *gostfixConfig, err error) {
 	}
 
 	config = &gostfixConfig{
+		MyDomain:        myDomain,
 		VMailboxBase:    baseDir,
 		VMailboxMaps:    mapsList[1],
 		VMailboxDomains: validDomains,

+ 70 - 40
sasl/sasl.go

@@ -30,6 +30,7 @@ import (
 	"bytes"
 	"encoding/base64"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"io"
 	"log"
@@ -38,12 +39,14 @@ import (
 	"strconv"
 	"strings"
 
+	"git.semlanik.org/semlanik/gostfix/auth"
 	"github.com/google/uuid"
 )
 
 type SaslServer struct {
-	pid  int
-	cuid int
+	pid           int
+	cuid          int
+	authenticator *auth.Authenticator
 }
 
 const (
@@ -67,8 +70,9 @@ const (
 
 func NewSaslServer() *SaslServer {
 	return &SaslServer{
-		pid:  os.Getpid(),
-		cuid: 0,
+		pid:           os.Getpid(),
+		cuid:          0,
+		authenticator: auth.NewAuthenticator(),
 	}
 }
 
@@ -103,6 +107,8 @@ func (s *SaslServer) handleRequest(conn net.Conn) {
 
 		if err == io.EOF {
 			break
+			// time.Sleep(100 * time.Millisecond)
+			// continue
 		}
 
 		if err != nil {
@@ -110,14 +116,20 @@ func (s *SaslServer) handleRequest(conn net.Conn) {
 		}
 
 		currentMessage := fullbuf
-		if strings.Index(currentMessage, Version) == 0 {
-			versionIds := strings.Split(currentMessage, "\t")
+		fmt.Printf("SASL: %s\n", fullbuf)
 
-			if len(versionIds) < 3 {
+		ids := strings.Split(currentMessage, "\t")
+		if len(ids) < 2 {
+			break
+		}
+
+		switch ids[0] {
+		case Version:
+			if len(ids) < 3 {
 				break
 			}
 
-			if major, err := strconv.Atoi(versionIds[1]); err != nil || major != 1 {
+			if major, err := strconv.Atoi(ids[1]); err != nil || major != 1 {
 				break
 			}
 
@@ -131,53 +143,71 @@ func (s *SaslServer) handleRequest(conn net.Conn) {
 
 			fmt.Fprintf(conn, "%s\t%s\n", Cookie, hex.EncodeToString(cookieUuid[:]))
 			fmt.Fprintf(conn, "%s\n", Done)
-		} else if strings.Index(currentMessage, Auth) == 0 {
-			authIds := strings.Split(currentMessage, "\t")
-			if len(authIds) < 2 {
-				break
+		case Auth:
+			for _, authId := range ids {
+				if strings.Index(authId, "resp=") == 0 {
+					login, err := s.checkCredentials(authId[5:])
+					if err != nil {
+						fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, ids[1], err.Error())
+					} else {
+						fmt.Fprintf(conn, "%s\t%s\tuser=%s\n", Ok, ids[1], login)
+					}
+					continueState = ContinueStateNone
+					return
+				}
 			}
-			fmt.Fprintf(conn, "%s\t%s\t%s\n", Cont, authIds[1], base64.StdEncoding.EncodeToString([]byte("Username:")))
+
+			fmt.Fprintf(conn, "%s\t%s\t%s\n", Cont, ids[1], base64.StdEncoding.EncodeToString([]byte("Username:")))
 			continueState = ContinueStateCredentials
-		} else if strings.Index(currentMessage, Cont) == 0 {
-			contIds := strings.Split(currentMessage, "\t")
-			if len(contIds) < 2 {
+		case Cont:
+			if len(ids) < 2 {
 				break
 			}
 
 			if continueState == ContinueStateCredentials {
-				if len(contIds) < 3 {
-					fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, contIds[1], "invalid base64 data")
+				if len(ids) < 3 {
+					fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, ids[1], "invalid base64 data")
 					return
 				}
 
-				credentials, err := base64.StdEncoding.DecodeString(contIds[2])
+				login, err := s.checkCredentials(ids[2])
 				if err != nil {
-					fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, contIds[1], "invalid base64 data")
-					return
-				}
-
-				credentialList := bytes.Split(credentials, []byte{0})
-				if len(credentialList) < 3 {
-					fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, contIds[1], "invalid user or password")
-					return
+					fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, ids[1], err.Error())
+				} else {
+					fmt.Fprintf(conn, "%s\t%s\tuser=%s\n", Ok, ids[1], login)
 				}
-
-				// identity := string(credentialList[0])
-				login := string(credentialList[1])
-				// password := string(credentialList[2])
-				//TODO: Use auth here
-				// if login != "semlanik@semlanik.org" || password != "test" {
-				if true {
-					fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, contIds[1], "invalid user or password")
-					return
-				}
-
-				fmt.Fprintf(conn, "%s\t%s\tuser=%s\n", Ok, contIds[1], login)
 				continueState = ContinueStateNone
 			} else {
-				fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, contIds[1], "invalid user or password")
+				fmt.Fprintf(conn, "%s\t%s\treason=%s\n", Fail, ids[1], "invalid user or password")
 			}
 		}
 	}
 	conn.Close()
 }
+
+func (s *SaslServer) checkCredentials(credentialsBase64 string) (string, error) {
+	credentials, err := base64.StdEncoding.DecodeString(credentialsBase64)
+	if err != nil {
+		return "", errors.New("invalid base64 data")
+	}
+
+	credentialList := bytes.Split(credentials, []byte{0})
+	if len(credentialList) < 3 {
+		return "", errors.New("invalid user or password")
+	}
+
+	identity := string(credentialList[0])
+	login := string(credentialList[1])
+	password := string(credentialList[2])
+	if identity == "token" {
+		if s.authenticator.Verify(login, password) {
+			return login, nil
+		}
+	} else {
+		if _, ok := s.authenticator.Authenticate(login, password); ok {
+			return login, nil
+		}
+	}
+
+	return "", errors.New("invalid user or password")
+}

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


+ 34 - 0
web/assets/send.svg

@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   height="1792"
+   viewBox="0 0 1792 1792"
+   width="1792"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="send.svg"
+   inkscape:version="0.92.4 5da689c313, 2019-01-14">
+  <metadata
+     id="metadata10">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs8" />
+  <path
+     d="M1764 11q33 24 27 64l-256 1536q-5 29-32 45-14 8-31 8-11 0-24-5l-453-185-242 295q-18 23-49 23-13 0-22-4-19-7-30.5-23.5t-11.5-36.5v-349l864-1059-1069 925-395-162q-37-14-40-55-2-40 32-59l1664-960q15-9 32-9 20 0 36 11z"
+     id="path2"
+     style="fill:#ffffff;fill-opacity:1" />
+</svg>

+ 13 - 0
web/css/index.css

@@ -92,6 +92,19 @@ html, body {
     padding: 5pt;
 }
 
+#mailNew {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+
+    overflow: hidden;
+    display: none;
+    background-color: var(--bg-color);
+    padding: 5pt;
+}
+
 .mailHeader {
     display: flex;
     flex-direction: row;

+ 50 - 3
web/js/index.js

@@ -68,9 +68,15 @@ $(document).ready(function(){
     if (mailbox != "") {
         clearInterval(updateTimerId)
     }
+
+    $("#mailNewButton").click(mailNew)
 })
 
-function openEmail(id) {
+function mailNew(e) {
+    window.location.hash = currentFolder + currentPage + "/mailNew"
+}
+
+function mailOpen(id) {
     window.location.hash = currentFolder + currentPage + "/" + id
 }
 
@@ -97,17 +103,25 @@ function onHashChanged() {
     }
 
     if (hashParts.length >= 2 && (hashParts[1] != currentFolder || currentPage != page) && hashParts[1] != "") {
-
         updateMailList(hashParts[1], page)
     }
 
-    if (hashParts.length >= 4 && hashParts[3] != "") {
+    if (hashParts.length >= 4 && hashParts[3] != "" && hashParts[3] != "/mailNew") {
         if (currentMail != hashParts[3]) {
             requestMail(hashParts[3])
         }
     } else {
         setDetailsVisible(false)
     }
+
+    hashParts = hashLocation.split("/")
+    if (hashParts.length == 2 && hashParts[1] == "mailNew") {
+        console.log("hashParts: " + hashParts + " length" + hashParts.length + " hashParts[1] " + hashParts[1])
+        setMailNewVisible(true)
+    } else {
+        console.log("!hashParts: " + hashParts)
+        setMailNewVisible(false)
+    }
 }
 
 function requestMail(mailId) {
@@ -181,6 +195,10 @@ function closeDetails() {
     window.location.hash = currentFolder + currentPage
 }
 
+function closeMailNew() {
+    window.location.hash = currentFolder + currentPage
+}
+
 function loadStatusLine() {
     $.ajax({
         url: mailbox + "/statusLine",
@@ -303,6 +321,18 @@ function setDetailsVisible(visible) {
     }
 }
 
+function setMailNewVisible(visible) {
+    if (visible) {
+        $("#mailNew").show()
+        $("#mailList").css({pointerEvents: "none"})
+        clearInterval(updateTimerId)
+    } else {
+        currentMail = ""
+        $("#mailNew").hide()
+        $("#mailList").css({pointerEvents: "auto"})
+    }
+}
+
 function updateMailList(folder, page) {
     if (mailbox == "" || folder == "") {
         if ($("#mailList")) {
@@ -354,4 +384,21 @@ function prevPage() {
 
 function toggleDropDown(dd) {
     $("#"+dd).toggle()
+}
+
+function sendNewMail() {
+    var formValue = $("#mailNewForm").serialize()
+    $.ajax({
+        url: mailbox + "/sendNewMail",
+        data: formValue,
+        success: function(result) {
+            $("#newMailEditor").val("")
+            $("#newMailSubject").val("")
+            $("#newMailTo").val("")
+            closeMailNew()
+        },
+        error: function(jqXHR, textStatus, errorThrown) {
+            //TODO: some toast message here once implemented
+        }
+    })
 }

+ 99 - 13
web/mailbox.go

@@ -27,34 +27,31 @@ package web
 
 import (
 	"crypto/md5"
+	"crypto/tls"
 	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	template "html/template"
 	"log"
 	"net/http"
+	"net/smtp"
 	"strconv"
 	"strings"
+	"time"
 
 	common "git.semlanik.org/semlanik/gostfix/common"
+	"git.semlanik.org/semlanik/gostfix/config"
 )
 
 func (s *Server) handleMailbox(w http.ResponseWriter, user, email string) {
-	mailList, err := s.storage.GetMailList(user, email, common.Inbox, common.Frame{Skip: 0, Limit: 50})
-
-	if err != nil {
-		s.error(http.StatusInternalServerError, "Couldn't read email database", w)
-		return
-	}
-
 	fmt.Fprint(w, s.templater.ExecuteIndex(&struct {
-		Folders  template.HTML
-		MailList template.HTML
-		Version  template.HTML
+		Folders template.HTML
+		MailNew template.HTML
+		Version template.HTML
 	}{
-		MailList: template.HTML(s.templater.ExecuteMailList(mailList)),
-		Folders:  "Folders",
-		Version:  common.Version,
+		MailNew: template.HTML(s.templater.ExecuteNewMail("")),
+		Folders: "Folders",
+		Version: common.Version,
 	}))
 }
 
@@ -87,6 +84,8 @@ func (s *Server) handleMailboxRequest(path, user string, mailbox int, w http.Res
 		s.handleStatusLine(w, user, emails[mailbox])
 	case "mailList":
 		s.handleMailList(w, r, user, emails[mailbox])
+	case "sendNewMail":
+		s.handleNewMail(w, r, user, emails[mailbox])
 	default:
 		http.Redirect(w, r, "/m0", http.StatusTemporaryRedirect)
 	}
@@ -228,3 +227,90 @@ func (s *Server) extractFolder(email string, r *http.Request) string {
 
 	return folder
 }
+
+func (s *Server) handleNewMail(w http.ResponseWriter, r *http.Request, user, email string) {
+	to := r.FormValue("to")
+	resultEmail := s.templater.ExecuteMail(&struct {
+		From    string
+		Subject string
+		Date    template.HTML
+		To      string
+		Body    template.HTML
+	}{
+		From:    email,
+		To:      to,
+		Subject: r.FormValue("subject"),
+		Date:    template.HTML(time.Now().Format(time.RFC1123Z)),
+		Body:    template.HTML(r.FormValue("body")),
+	})
+
+	host := config.ConfigInstance().MyDomain
+	server := host + ":25"
+	_, token := s.extractAuth(w, r)
+	auth := smtp.PlainAuth("token", user, token, host)
+
+	tlsconfig := &tls.Config{
+		InsecureSkipVerify: true,
+		ServerName:         host,
+	}
+
+	client, err := smtp.Dial(server)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Printf("Dial %s \n", err)
+		return
+	}
+
+	err = client.StartTLS(tlsconfig)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Printf("StartTLS %s \n", err)
+		return
+	}
+
+	err = client.Auth(auth)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Printf("Auth %s \n", err)
+		return
+	}
+
+	err = client.Mail(email)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Printf("Mail %s \n", err)
+		return
+	}
+
+	err = client.Rcpt(to)
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Println(err)
+		return
+	}
+
+	mailWriter, err := client.Data()
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Println(err)
+		return
+	}
+
+	_, err = mailWriter.Write([]byte(resultEmail))
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Println(err)
+		return
+	}
+
+	err = mailWriter.Close()
+	if err != nil {
+		s.error(http.StatusInternalServerError, "Unable to send message", w)
+		log.Println(err)
+		return
+	}
+
+	client.Quit()
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte{0})
+}

+ 24 - 0
web/templater.go

@@ -40,6 +40,8 @@ const (
 	LoginTemplateName      = "login.html"
 	StatusLineTemplateName = "statusline.html"
 	FoldersTemplateName    = "folders.html"
+	MailNewTemplateName    = "mailnew.html"
+	MailTemplateName       = "mailTemplate.eml"
 )
 
 type Templater struct {
@@ -50,6 +52,8 @@ type Templater struct {
 	loginTemplate      *template.Template
 	statusLineTemplate *template.Template
 	foldersTemaplate   *template.Template
+	mailNewTemplate    *template.Template
+	mailTemplate       *template.Template
 }
 
 func NewTemplater(templatesPath string) (t *Templater) {
@@ -89,6 +93,16 @@ func NewTemplater(templatesPath string) (t *Templater) {
 		log.Fatal(err)
 	}
 
+	mailNew, err := parseTemplate(templatesPath + "/" + MailNewTemplateName)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	mail, err := parseTemplate(templatesPath + "/" + MailTemplateName)
+	if err != nil {
+		log.Fatal(err)
+	}
+
 	t = &Templater{
 		indexTemplate:      index,
 		mailListTemplate:   maillist,
@@ -97,6 +111,8 @@ func NewTemplater(templatesPath string) (t *Templater) {
 		loginTemplate:      login,
 		statusLineTemplate: statusLine,
 		foldersTemaplate:   folders,
+		mailNewTemplate:    mailNew,
+		mailTemplate:       mail,
 	}
 	return
 }
@@ -138,6 +154,14 @@ func (t *Templater) ExecuteFolders(data interface{}) string {
 	return executeTemplateCommon(t.foldersTemaplate, data)
 }
 
+func (t *Templater) ExecuteNewMail(data interface{}) string {
+	return executeTemplateCommon(t.mailNewTemplate, data)
+}
+
+func (t *Templater) ExecuteMail(data interface{}) string {
+	return executeTemplateCommon(t.mailTemplate, data)
+}
+
 func executeTemplateCommon(t *template.Template, values interface{}) string {
 	buffer := &bytes.Buffer{}
 	err := t.Execute(buffer, values)

+ 1 - 3
web/templates/details.html

@@ -9,9 +9,7 @@
             <img id="readIcon{{.MailId}}" class="iconBtn" style="width: 20pt; margin-right: 10pt;" onclick="toggleRead('{{.MailId}}', 'readIcon');" src="/assets/read.svg"/>
             <img id="restoreIcon" class="iconBtn" style="display:{{if .Trash}}block{{else}}none{{end}}; width: 20pt; margin-right: 10pt;" onclick="restoreMail({{.MailId}}, closeDetails);" src="/assets/restore.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>
+            <img class="iconBtn" style="width: 20pt; margin-left:10pt; margin-right: 10pt;" onclick="closeDetails();" src="/assets/back.svg"/>
         </div>
     </div>
     <div id="mailBody" class="horizontalPaddingBox">

+ 2 - 1
web/templates/index.html

@@ -27,13 +27,14 @@
             <div class="horizontalPaddingBox">
                 <div id="contentBox">
                     <div id="foldersBox">
-                        <div class="btn materialLevel1" style="margin: 5pt;">New email</div>
+                        <div id="mailNewButton" class="btn materialLevel1" style="margin: 5pt;">New email</div>
                         <div id="folders"></div>
                     </div>
                     <div class="verticalPaddingBox">
                         <div id="mailInnerBox" class="materialLevel1">
                             <div id="mailList"></div>
                             <div id="mailDetails"></div>
+                            <div id="mailNew">{{.MailNew}}</div>
                         </div>
                     </div>
                 </div>

+ 7 - 0
web/templates/mailTemplate.eml

@@ -0,0 +1,7 @@
+To: {{.To}}
+Date: {{.Date}}
+Subject: {{.Subject}}
+From: {{.From}}
+Content-Type: text/plain; charset="utf8"
+
+{{.Body}}

+ 1 - 1
web/templates/maillist.html

@@ -1,7 +1,7 @@
 <!-- <div class="fadeIn" style="position: absolute; top: 5pt; left: 0; right: 0; height: 10pt"></div> -->
 {{range .}}
 <div id="mail{{.Id}}" class="mailHeaderContainer {{if .Read}}read{{else}}unread{{end}}" style="position: relative;" onmouseover="$('#mailControlPanel{{.Id}}').show()" onmouseout="$('#mailControlPanel{{.Id}}').hide()">
-    <div class="mailHeader noselect" onclick="openEmail('{{.Id}}');" >
+    <div class="mailHeader noselect" onclick="mailOpen('{{.Id}}');" >
         <div class="mailFrom noselect">{{.Mail.Header.From}}</div>
         <div class="mailSubject noselect">{{.Mail.Header.Subject}}</div>
         <div id="mailDate{{.Id}}" class="mailDate noselect" onload="$('#mailDate{{.Id}}').html(localDate({{.Mail.Header.Date}}))">{{.Mail.Header.Subject}}</div>

+ 18 - 0
web/templates/mailnew.html

@@ -0,0 +1,18 @@
+<form id="mailNewForm" style="height: 100%; width: 100%; display:flex; flex-direction: column;">
+    <div class="horizontalPaddingBox" style="flex-grow: 0!important;">
+        <div style="width: 100%; display: flex; flex-direction: row;">
+            <div class="btn materialLevel1" style="margin-right: 10pt;" onclick="sendNewMail();">
+                <img src="/assets/send.svg" style="width: 40pt"/>
+                Send
+            </div>
+            <div style="display: block; overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto;">
+                <span class="primaryText">To: <input type="text" name="to" id="newMailTo"/></span></br>
+                <div style="display: flex; flex-direction: row;"><span style="flex: 0 1 auto" class="primaryText">Subject:</span><input id="newMailSubject" type="text" name="subject" style="flex: 1 1 auto"/></div></br>
+            </div>
+            <img class="iconBtn" style="width: 20pt; margin-left:10pt; margin-right: 10pt;" onclick="closeDetails();" src="/assets/back.svg"/>
+        </div>
+    </div>
+    <div class="horizontalPaddingBox">
+        <textarea id="newMailEditor" name="body" style="height: 100%; width: 100%; border-radius: 3pt; resize: none; border: 1pt solid var(--primary-color);"></textarea>
+    </div>
+</form>

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