Browse Source

Add configuration reader

- Add configuration reader and conjunct it with native postfix
  config
- Add error and details templates
- Implement opening/closing of message details
- Add mailbox reading based on postfix configuration
Alexey Edelev 4 years ago
parent
commit
24841aeafd

+ 38 - 0
auth/authenticator.go

@@ -0,0 +1,38 @@
+/*
+ * 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 auth
+
+import (
+	utils "../utils"
+)
+
+func Authenticate(email string, password string) bool {
+	if !utils.RegExpUtilsInstance().EmailChecker.MatchString(email) {
+		return false
+	}
+
+	return false
+}

+ 26 - 0
auth/registrar.go

@@ -0,0 +1,26 @@
+/*
+ * 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 auth

+ 132 - 0
config/config.go

@@ -0,0 +1,132 @@
+/*
+ * 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 config
+
+import (
+	"log"
+	"strings"
+	"sync"
+
+	utils "../utils"
+	ini "gopkg.in/go-ini/ini.v1"
+)
+
+const configPath = "data/main.ini"
+
+const (
+	KeyPostfixConfig = "postfix_config"
+)
+
+const (
+	PostfixKeyVirtualMailboxMaps    = "virtual_mailbox_maps"
+	PostfixKeyVirtualMailboxBase    = "virtual_mailbox_base"
+	PostfixKeyVirtualMailboxDomains = "virtual_mailbox_domains"
+)
+
+type GostfixConfig gostfixConfig
+
+var (
+	once     sync.Once
+	instance *gostfixConfig
+)
+
+func ConfigInstance() *GostfixConfig {
+
+	once.Do(func() {
+		instance, _ = newConfig()
+	})
+
+	return (*GostfixConfig)(instance)
+}
+
+type gostfixConfig struct {
+	VMailboxMaps    string
+	VMailboxBase    string
+	VMailboxDomains []string
+}
+
+func newConfig() (config *gostfixConfig, err error) {
+
+	cfg, err := ini.Load(configPath)
+	if err != nil {
+		log.Fatalf("Unable to load %s\n", configPath)
+		return
+	}
+
+	postfixConfigPath := cfg.Section("").Key(KeyPostfixConfig).String()
+	if !utils.FileExists(postfixConfigPath) {
+		log.Fatalf("Unable to find postfix config %s\n", postfixConfigPath)
+		return
+	}
+
+	postfixCfg, err := ini.Load(postfixConfigPath)
+
+	if err != nil {
+		log.Fatalf("Unable to load %s: %s\n", postfixConfigPath, err)
+		return
+	}
+
+	baseDir := postfixCfg.Section("").Key(PostfixKeyVirtualMailboxBase).String()
+
+	if !utils.DirectoryExists(baseDir) {
+		log.Fatalf("Base dir %s doesn't exist, postfix is not configured proper way, check %s in %s\n", baseDir, PostfixKeyVirtualMailboxBase, postfixConfigPath)
+		return
+	}
+
+	maps := postfixCfg.Section("").Key(PostfixKeyVirtualMailboxMaps).String()
+	mapsList := strings.Split(maps, ":")
+
+	if len(mapsList) != 2 || mapsList[0] != "hash" {
+		log.Fatalf("%s is not set proper way in %s. Should be hash:<path/to/virtualmailbox/map>, but %s provided\n", PostfixKeyVirtualMailboxMaps, postfixConfigPath, maps)
+		return
+	}
+
+	if !utils.FileExists(mapsList[1]) {
+		log.Fatalf("Virtual mailbox map %s doesn't exist, postfix is not configured proper way, check %s in %s\n", mapsList[1], PostfixKeyVirtualMailboxMaps, postfixConfigPath)
+		return
+	}
+
+	domains := postfixCfg.Section("").Key(PostfixKeyVirtualMailboxDomains).String()
+	domainsList := strings.Split(domains, " ")
+	var validDomains []string
+	for _, domain := range domainsList {
+		if utils.RegExpUtilsInstance().DomainChecker.MatchString(domain) {
+			validDomains = append(validDomains, domain)
+		}
+	}
+
+	if len(validDomains) < 0 {
+		log.Fatalf("Virtual mailbox domains %s are not configured proper way, check %s in %s\n", domains, PostfixKeyVirtualMailboxDomains, postfixConfigPath)
+		return
+	}
+
+	config = &gostfixConfig{
+		VMailboxBase:    baseDir,
+		VMailboxMaps:    mapsList[1],
+		VMailboxDomains: validDomains,
+	}
+	return
+}

+ 1 - 4
config/main.ini.default

@@ -1,4 +1 @@
-#Path to virtual maildir
-virtual_mailbox_base=
-#Virtual mailboxes maps
-virtual_mailbox_maps=
+postfix_config = /etc/postfix/main.cf

+ 4 - 10
main.go

@@ -26,8 +26,6 @@
 package main
 
 import (
-	"os"
-
 	scanner "./scanner"
 	web "./web"
 )
@@ -37,10 +35,10 @@ type GofixEngine struct {
 	web     *web.Server
 }
 
-func NewGofixEngine(mailPath string) (e *GofixEngine) {
+func NewGofixEngine() (e *GofixEngine) {
 	e = &GofixEngine{
-		scanner: scanner.NewMailScanner(mailPath),
-		web:     web.NewServer(mailPath),
+		scanner: scanner.NewMailScanner(),
+		web:     web.NewServer(),
 	}
 
 	return
@@ -53,10 +51,6 @@ func (e *GofixEngine) Run() {
 }
 
 func main() {
-	mailPath := "."
-	if len(os.Args) >= 2 {
-		mailPath = os.Args[1]
-	}
-	engine := NewGofixEngine(mailPath)
+	engine := NewGofixEngine()
 	engine.Run()
 }

+ 38 - 11
scanner/mailscanner.go

@@ -26,19 +26,24 @@
 package scanner
 
 import (
+	"bufio"
 	"fmt"
-	ioutil "io/ioutil"
 	"log"
+	"os"
+	"strings"
 
+	config "../config"
 	utils "../utils"
 	fsnotify "github.com/fsnotify/fsnotify"
 )
 
 type MailScanner struct {
-	watcher *fsnotify.Watcher
+	watcher  *fsnotify.Watcher
+	mailMaps map[string]string
 }
 
-func NewMailScanner(mailPath string) (ms *MailScanner) {
+func NewMailScanner() (ms *MailScanner) {
+	mailPath := config.ConfigInstance().VMailboxBase
 	fmt.Printf("Add mail folder %s for watching\n", mailPath)
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
@@ -46,16 +51,12 @@ func NewMailScanner(mailPath string) (ms *MailScanner) {
 	}
 
 	ms = &MailScanner{
-		watcher: watcher,
+		watcher:  watcher,
+		mailMaps: readMailMaps(),
 	}
 
-	files, err := ioutil.ReadDir(mailPath)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	for _, f := range files {
-		fullPath := mailPath + "/" + f.Name()
+	for _, f := range ms.mailMaps {
+		fullPath := mailPath + "/" + f
 		if utils.FileExists(fullPath) {
 			fmt.Printf("Add mail file %s for watching\n", fullPath)
 			watcher.Add(fullPath)
@@ -65,6 +66,32 @@ func NewMailScanner(mailPath string) (ms *MailScanner) {
 	return
 }
 
+func readMailMaps() map[string]string {
+	mailMaps := make(map[string]string)
+	mapsFile := config.ConfigInstance().VMailboxMaps
+	if !utils.FileExists(mapsFile) {
+		return mailMaps
+	}
+
+	file, err := os.Open(mapsFile)
+	if err != nil {
+		log.Fatalf("Unable to open virtual mailbox maps %s\n", mapsFile)
+	}
+
+	scanner := bufio.NewScanner(file)
+
+	for scanner.Scan() {
+		mailPathPair := strings.Split(scanner.Text(), " ")
+		if len(mailPathPair) != 2 {
+			log.Printf("Invalid record in virtual mailbox maps %s", scanner.Text())
+			continue
+		}
+		mailMaps[mailPathPair[0]] = mailPathPair[1]
+	}
+
+	return mailMaps
+}
+
 func (ms *MailScanner) Run() {
 	go func() {
 		for {

+ 13 - 4
utils/regexp.go

@@ -40,7 +40,8 @@ const (
 )
 
 const (
-	UserRegExp = "^[a-zA-Z][\\w0-9\\._]*"
+	DomainRegExp = "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]"
+	EmailRegExp  = "(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\\])"
 )
 
 type RegExpUtils regExpUtils
@@ -60,7 +61,8 @@ func RegExpUtilsInstance() *RegExpUtils {
 }
 
 type regExpUtils struct {
-	UserChecker         *regexp.Regexp
+	DomainChecker       *regexp.Regexp
+	EmailChecker        *regexp.Regexp
 	HeaderFinder        *regexp.Regexp
 	FoldingFinder       *regexp.Regexp
 	BoundaryStartFinder *regexp.Regexp
@@ -99,19 +101,26 @@ func newRegExpUtils() (*regExpUtils, error) {
 		return nil, err
 	}
 
-	userChecker, err := regexp.Compile(UserRegExp)
+	domainChecker, err := regexp.Compile(DomainRegExp)
+	if err != nil {
+		log.Fatalf("Invalid regexp %s\n", err)
+		return nil, err
+	}
+
+	emailChecker, err := regexp.Compile(EmailRegExp)
 	if err != nil {
 		log.Fatalf("Invalid regexp %s\n", err)
 		return nil, err
 	}
 
 	ru := &regExpUtils{
-		UserChecker:         userChecker,
+		EmailChecker:        emailChecker,
 		HeaderFinder:        headerFinder,
 		FoldingFinder:       foldingFinder,
 		BoundaryStartFinder: boundaryStartFinder,
 		BoundaryEndFinder:   boundaryEndFinder,
 		BoundaryFinder:      boundaryFinder,
+		DomainChecker:       domainChecker,
 	}
 
 	return ru, nil

+ 0 - 0
assets/back.svg → web/assets/back.svg


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


+ 31 - 11
web/js/index.js

@@ -23,23 +23,43 @@
  * DEALINGS IN THE SOFTWARE.
  */
 
+var detailsUrl = "details/"
+
 $(document).ready(function(){
     $.ajaxSetup({
         global: false,
         type: "POST"
-    });
+    })
+    $(window).bind('hashchange', requestDetails);
+    requestDetails();
 })
 
 function openEmail(id) {
-    $("#details").show();
-    $.ajax({
-        url: "/mail",
-        data: {"mail": id},
-        success: function(result) {
-            $("#details").html(result);
-        },
-        error: function(jqXHR, textStatus, errorThrown) {
-            $("#details").html(errorThrown);
+    window.location.hash = detailsUrl + id
+}
+
+function requestDetails() {
+    var hashLocation = window.location.hash
+    if (hashLocation.startsWith("#" + detailsUrl)) {
+        var messageId = hashLocation.replace(/^#details\//, "")
+        if (messageId != "") {
+            $.ajax({
+                url: "/messageDetails",
+                data: {detailsUrl: messageId},
+                success: function(result) {
+                    $("#details").html(result);
+                    $("#details").show()
+                },
+                error: function(jqXHR, textStatus, errorThrown) {
+                    window.location.hash = ""
+                }
+            })
         }
-    });
+    } else {
+        $("#details").hide()
+    }
+}
+
+function closeDetails() {
+    window.location.hash = ""
 }

+ 78 - 22
web/server.go

@@ -32,9 +32,11 @@ import (
 	ioutil "io/ioutil"
 	"log"
 	"net/http"
+	"os"
 	"strings"
 
 	common "../common"
+	config "../config"
 	utils "../utils"
 	"github.com/gorilla/sessions"
 )
@@ -63,17 +65,17 @@ func NewEmail() *common.Mail {
 }
 
 type Server struct {
+	mailMaps     map[string]string //TODO: Temporary
 	fileServer   http.Handler
 	templater    *Templater
-	mailPath     string
 	sessionStore *sessions.CookieStore
 }
 
-func NewServer(mailPath string) *Server {
+func NewServer() *Server {
 	return &Server{
+		mailMaps:     readMailMaps(), //TODO: Temporary
 		templater:    NewTemplater("./data/templates"),
 		fileServer:   http.FileServer(http.Dir("./data")),
-		mailPath:     mailPath,
 		sessionStore: sessions.NewCookieStore(make([]byte, 32)),
 	}
 }
@@ -92,24 +94,26 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	} else if r.URL.Path == "/login" {
 		session, _ := s.sessionStore.Get(r, "token")
 		user, ok := session.Values["user"].(string)
-		if ok && utils.RegExpUtilsInstance().UserChecker.FindString(user) == user && user != "" {
+		if !ok || user == "" {
+			user = ""
+			if err := r.ParseForm(); err == nil {
+				user = r.FormValue("user")
+			}
+		}
+
+		_, ok = s.mailMaps[user]
+		if utils.RegExpUtilsInstance().EmailChecker.MatchString(user) &&
+			user != "" && ok {
+			session.Values["user"] = user
+			session.Save(r, w)
 			http.Redirect(w, r, "/mailbox", http.StatusTemporaryRedirect)
 			return
 		}
 
-		if err := r.ParseForm(); err == nil {
-			user = r.FormValue("user")
-			fmt.Printf("User form: %s\n", user)
-
-			// password := r.FormValue("password")
-			if user == "semlanik" {
-				session.Values["user"] = "semlanik"
-				session.Save(r, w)
-				http.Redirect(w, r, "/mailbox", http.StatusTemporaryRedirect)
-				return
-			}
-		}
+		session.Values["user"] = ""
+		session.Save(r, w)
 
+		fmt.Printf("Actual user: %s, Actual map: %v\n", user, s.mailMaps)
 		data, _ := ioutil.ReadFile("./data/templates/login.html")
 		w.Write(data)
 	} else if r.URL.Path == "/logout" {
@@ -119,30 +123,56 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		session.Save(r, w)
 		fmt.Printf("Reset user session: %s\n", session.Values["user"])
 		http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
+	} else if r.URL.Path == "/messageDetails" {
+		fmt.Fprint(w, s.templater.ExecuteDetails(""))
 	} else {
 		session, _ := s.sessionStore.Get(r, "token")
 		user, ok := session.Values["user"].(string)
 
 		fmt.Printf("User session: %s\n", user)
 
-		if !ok || utils.RegExpUtilsInstance().UserChecker.FindString(user) != user || user == "" {
+		if !ok || utils.RegExpUtilsInstance().EmailChecker.FindString(user) != user || user == "" {
+			fmt.Print("Invalid user")
+			http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
+			return
+		}
+
+		mailRelPath, ok := s.mailMaps[user]
+		if !ok {
 			fmt.Print("Invalid user")
 			http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
 			return
 		}
 
-		// mailPath = config.mailPath + "/" + r.URL.Query().Get("user")
-		mailPath := "tmp" + "/" + user
+		mailPath := config.ConfigInstance().VMailboxBase + "/" + mailRelPath
 		if !utils.FileExists(mailPath) {
-			w.WriteHeader(http.StatusForbidden)
-			fmt.Fprint(w, "403 Unknown user")
+			fmt.Printf("logout")
+			session, _ := s.sessionStore.Get(r, "token")
+			session.Values["user"] = ""
+			session.Save(r, w)
+			fmt.Printf("Reset user session: %s\n", session.Values["user"])
+
+			w.WriteHeader(http.StatusInternalServerError)
+			fmt.Fprint(w, s.templater.ExecuteError(&Error{
+				Code:   http.StatusInternalServerError,
+				String: "Unable to access your mailbox. Please contact Administrator.",
+			}))
 			return
 		}
 
 		file, err := utils.OpenAndLockWait(mailPath)
 		if err != nil {
+			fmt.Printf("logout")
+			session, _ := s.sessionStore.Get(r, "token")
+			session.Values["user"] = ""
+			session.Save(r, w)
+			fmt.Printf("Reset user session: %s\n", session.Values["user"])
+
 			w.WriteHeader(http.StatusInternalServerError)
-			fmt.Fprint(w, "500 Internal server error")
+			fmt.Fprint(w, s.templater.ExecuteError(&Error{
+				Code:   http.StatusInternalServerError,
+				String: "Unable to access your mailbox. Please contact Administrator.",
+			}))
 			return
 		}
 
@@ -265,3 +295,29 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		}))
 	}
 }
+
+func readMailMaps() map[string]string { //TODO: Temporary
+	mailMaps := make(map[string]string)
+	mapsFile := config.ConfigInstance().VMailboxMaps
+	if !utils.FileExists(mapsFile) {
+		return mailMaps
+	}
+
+	file, err := os.Open(mapsFile)
+	if err != nil {
+		log.Fatalf("Unable to open virtual mailbox maps %s\n", mapsFile)
+	}
+
+	scanner := bufio.NewScanner(file)
+
+	for scanner.Scan() {
+		mailPathPair := strings.Split(scanner.Text(), " ")
+		if len(mailPathPair) != 2 {
+			log.Printf("Invalid record in virtual mailbox maps %s", scanner.Text())
+			continue
+		}
+		mailMaps[mailPathPair[0]] = mailPathPair[1]
+	}
+
+	return mailMaps
+}

+ 30 - 0
web/templater.go

@@ -35,11 +35,15 @@ import (
 const (
 	IndexTemplateName    = "index.html"
 	MailListTemplateName = "maillist.html"
+	DetailsTemplateName  = "details.html"
+	ErrorTemplateName    = "error.html"
 )
 
 type Templater struct {
 	indexTemplate    *template.Template
 	mailListTemplate *template.Template
+	detailsTemplate  *template.Template
+	errorTemplate    *template.Template
 }
 
 type Index struct {
@@ -48,6 +52,12 @@ type Index struct {
 	Version  template.HTML
 }
 
+type Error struct {
+	Code    int
+	String  string
+	Version string
+}
+
 func NewTemplater(templatesPath string) (t *Templater) {
 	t = nil
 	index, err := parseTemplate(templatesPath + "/" + IndexTemplateName)
@@ -60,9 +70,21 @@ func NewTemplater(templatesPath string) (t *Templater) {
 		log.Fatal(err)
 	}
 
+	details, err := parseTemplate(templatesPath + "/" + DetailsTemplateName)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	errors, err := parseTemplate(templatesPath + "/" + ErrorTemplateName)
+	if err != nil {
+		log.Fatal(err)
+	}
+
 	t = &Templater{
 		indexTemplate:    index,
 		mailListTemplate: maillist,
+		detailsTemplate:  details,
+		errorTemplate:    errors,
 	}
 	return
 }
@@ -84,6 +106,14 @@ func (t *Templater) ExecuteMailList(mailList interface{}) string {
 	return executeTemplateCommon(t.mailListTemplate, mailList)
 }
 
+func (t *Templater) ExecuteDetails(details interface{}) string {
+	return executeTemplateCommon(t.detailsTemplate, details)
+}
+
+func (t *Templater) ExecuteError(err interface{}) string {
+	return executeTemplateCommon(t.errorTemplate, err)
+}
+
 func executeTemplateCommon(t *template.Template, values interface{}) string {
 	buffer := &bytes.Buffer{}
 	err := t.Execute(buffer, values)

+ 5 - 0
web/templates/details.html

@@ -0,0 +1,5 @@
+<div class="btn materialLevel1" style="width: 40pt;" onclick="closeDetails();">
+    <img src="assets/back.svg" style="width: 20pt"/>
+</div>
+<div id="emailDetails">
+</div>

+ 0 - 5
web/templates/email.html

@@ -1,5 +0,0 @@
-<div class="btn materialLevel1" style="width: 40pt;" onclick="$('#details').hide();">
-    <img src="assets/back.svg" style="width: 20pt"/>
-</div>
-<div id="emailDetails">
-</div>

+ 22 - 0
web/templates/error.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8"/>
+        <link href="https://fonts.googleapis.com/css?family=Titillium+Web&display=swap" rel="stylesheet">
+        <link type="text/css" href="css/index.css" rel="stylesheet">
+        <link type="text/css" href="css/styles.css" rel="stylesheet">
+        <link type="text/css" href="css/controls.css" rel="stylesheet">
+        <script src="js/jquery-3.4.1.min.js"></script>
+        <script src="js/index.js" type="text/javascript"></script>
+    </head>
+    <body>
+        <div style="width: 100%; height: 100%; display:flex; justify-content: center; align-items: center;">
+            <img src="assets/error.svg" height="100pt"/>
+            <div style="display: block;">
+                <span style="font-size: 24pt;">{{.Code}}</span></br>
+                <span style="font-size: 14pt;">{{.String}}</span>
+            </div>
+        </div>
+        <p class="copyrights">gostfix {{.Version}} Web interface. Copyright (c) 2020 Alexey Edelev &lt;semlanik@gmail.com&gt;</p>
+    </body>
+</html>

+ 2 - 2
web/templates/login.html

@@ -17,9 +17,9 @@
                         <input name="user" type="text" required>
                         <span class="highlight"></span>
                         <span class="bar"></span>
-                        <label>Login</label>
+                        <label>Email</label>
                     </div>
-                    <div class="inpt">      
+                    <div class="inpt">
                         <input name="password" type="password" required>
                         <span class="highlight"></span>
                         <span class="bar"></span>

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