package main import ( "bytes" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "html/template" "mime" "mime/quotedprintable" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "time" "github.com/kardianos/service" ) type EmailFile struct { Name string `json:"name"` ID string `json:"id"` Size int64 `json:"size"` ModTime time.Time `json:"mod_time"` From string `json:"from"` To string `json:"to"` Subject string `json:"subject"` Date time.Time `json:"date"` } type DayEmails struct { Date string `json:"date"` Emails []EmailFile `json:"emails"` } type cachedStaticFile struct { Data []byte ContentType string } type WebServer struct { config *Config smtpServer *SMTPServer logger service.Logger server *http.Server template *template.Template templateErr error staticFiles map[string]cachedStaticFile } func NewWebServer(cfg *Config, smtp *SMTPServer) *WebServer { return &WebServer{ config: cfg, smtpServer: smtp, staticFiles: make(map[string]cachedStaticFile), } } // decodeRFC2047 декодирует строку вида: // // =?UTF-8?B?...?= func decodeRFC2047(encoded string) string { decoder := new(mime.WordDecoder) decoded, err := decoder.Decode(encoded) if err != nil { // Если стандартный декодер не справился, // пробуем обработать простой UTF-8 Base64 вручную. if strings.Contains(encoded, "=?UTF-8?B?") { parts := strings.Split(encoded, "?B?") if len(parts) >= 2 { b64part := strings.TrimSuffix(parts[1], "?=") if decodedBytes, err := base64.StdEncoding.DecodeString(b64part); err == nil { return string(decodedBytes) } } } return encoded } return decoded } func decodeQuotedPrintable(data string) string { reader := quotedprintable.NewReader( strings.NewReader(data), ) buf := new(bytes.Buffer) _, _ = buf.ReadFrom(reader) return buf.String() } func parseEmailHeaders(content []byte) ( from string, to string, subject string, date time.Time, ) { from = "Unknown" to = "Unknown" subject = "No subject" date = time.Now() lines := strings.Split( string(content), "\n", ) for _, line := range lines { line = strings.TrimSpace(line) lowerLine := strings.ToLower(line) switch { case strings.HasPrefix(lowerLine, "from:"): raw := strings.TrimSpace(line[5:]) raw = strings.Trim(raw, "\"<> ") from = decodeRFC2047(raw) if from == "" { from = "Unknown" } case strings.HasPrefix(lowerLine, "to:"): raw := strings.TrimSpace(line[3:]) raw = strings.Trim(raw, "\"<> ") to = decodeRFC2047(raw) if to == "" { to = "Unknown" } case strings.HasPrefix(lowerLine, "subject:"): raw := strings.TrimSpace(line[8:]) subject = decodeRFC2047(raw) if subject == "" { subject = "No subject" } case strings.HasPrefix(lowerLine, "date:"): dateStr := strings.TrimSpace(line[5:]) formats := []string{ time.RFC1123, time.RFC1123Z, time.RFC822, time.RFC822Z, "Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 MST", "2 Jan 2006 15:04:05 -0700", } for _, format := range formats { parsedDate, err := time.Parse( format, dateStr, ) if err == nil { date = parsedDate break } } } } return } func extractEmailBody(content []byte) string { str := string(content) boundary := "" lines := strings.Split(str, "\n") for i, line := range lines { lowerLine := strings.ToLower(line) if !strings.HasPrefix( lowerLine, "content-type:", ) { continue } if idx := strings.Index( line, "boundary=", ); idx != -1 { boundaryPart := line[idx+9:] boundary = strings.Trim( boundaryPart, "\" \r\n", ) break } if i+1 < len(lines) && strings.Contains( strings.ToLower(lines[i+1]), "boundary=", ) { boundaryPart := lines[i+1] if idx := strings.Index( boundaryPart, "boundary=", ); idx != -1 { boundary = strings.Trim( boundaryPart[idx+9:], "\" \r\n", ) } } } // Multipart. if boundary != "" { parts := strings.Split( str, "--"+boundary, ) for _, part := range parts { if !strings.Contains( strings.ToLower(part), "content-type: text/plain", ) { continue } body := extractMIMEPartBody(part) if body == "" { continue } if strings.Contains( strings.ToLower(part), "content-transfer-encoding: quoted-printable", ) { body = decodeQuotedPrintable(body) } return strings.TrimSpace(body) } for _, part := range parts { if !strings.Contains( strings.ToLower(part), "content-type: text/html", ) { continue } body := extractMIMEPartBody(part) if body == "" { continue } if strings.Contains( strings.ToLower(part), "content-transfer-encoding: quoted-printable", ) { body = decodeQuotedPrintable(body) } return strings.TrimSpace(body) } } // Обычное письмо без multipart. body := extractMIMEPartBody(str) if body != "" { if strings.Contains( strings.ToLower(str), "content-transfer-encoding: quoted-printable", ) { body = decodeQuotedPrintable(body) } return strings.TrimSpace(body) } return "No content" } func extractMIMEPartBody(part string) string { if idx := strings.Index( part, "\r\n\r\n", ); idx != -1 { body := part[idx+4:] return strings.TrimSuffix(body, "--") } if idx := strings.Index( part, "\n\n", ); idx != -1 { body := part[idx+2:] return strings.TrimSuffix(body, "--") } return "" } func listEmails(storageDir string) ([]DayEmails, error) { days := make([]DayEmails, 0) entries, err := os.ReadDir(storageDir) if err != nil { if os.IsNotExist(err) { return days, nil } return nil, err } for _, entry := range entries { if !entry.IsDir() { continue } if _, err := time.Parse( "2006-01-02", entry.Name(), ); err != nil { continue } dayPath := filepath.Join( storageDir, entry.Name(), ) files, err := os.ReadDir(dayPath) if err != nil { continue } emails := make([]EmailFile, 0) for _, file := range files { if file.IsDir() { continue } if !strings.EqualFold( filepath.Ext(file.Name()), ".eml", ) { continue } filePath := filepath.Join( dayPath, file.Name(), ) info, err := file.Info() if err != nil { continue } content, err := os.ReadFile(filePath) if err != nil { continue } from, to, subject, date := parseEmailHeaders(content) emailID := filepath.ToSlash( filepath.Join( entry.Name(), file.Name(), ), ) emails = append( emails, EmailFile{ Name: file.Name(), ID: emailID, Size: info.Size(), ModTime: info.ModTime(), From: from, To: to, Subject: subject, Date: date, }, ) } sort.Slice( emails, func(i, j int) bool { return emails[i].ModTime.After( emails[j].ModTime, ) }, ) if len(emails) > 0 { days = append( days, DayEmails{ Date: entry.Name(), Emails: emails, }, ) } } sort.Slice( days, func(i, j int) bool { return days[i].Date > days[j].Date }, ) return days, nil } func resolveEmailPath( storageDir string, id string, ) (string, error) { id = strings.TrimSpace(id) if id == "" { return "", fmt.Errorf("empty email id") } if strings.Contains(id, "\\") { return "", fmt.Errorf("invalid email id") } parts := strings.Split(id, "/") // Формат строго: // YYYY-MM-DD/file.eml if len(parts) != 2 { return "", fmt.Errorf("invalid email id") } day := parts[0] filename := parts[1] if _, err := time.Parse( "2006-01-02", day, ); err != nil { return "", fmt.Errorf( "invalid email date directory", ) } if filename == "" || filename == "." || filename == ".." || filepath.Base(filename) != filename { return "", fmt.Errorf( "invalid email filename", ) } if !strings.EqualFold( filepath.Ext(filename), ".eml", ) { return "", fmt.Errorf( "invalid email file type", ) } storageAbs, err := filepath.Abs(storageDir) if err != nil { return "", fmt.Errorf( "cannot resolve storage directory: %w", err, ) } candidate := filepath.Join( storageAbs, day, filename, ) candidateAbs, err := filepath.Abs(candidate) if err != nil { return "", fmt.Errorf( "cannot resolve email path: %w", err, ) } rel, err := filepath.Rel( storageAbs, candidateAbs, ) if err != nil { return "", fmt.Errorf( "cannot validate email path: %w", err, ) } if rel == ".." || strings.HasPrefix( rel, ".."+string(os.PathSeparator), ) || filepath.IsAbs(rel) { return "", fmt.Errorf( "email path escapes storage directory", ) } info, err := os.Stat(candidateAbs) if err != nil { if os.IsNotExist(err) { return "", os.ErrNotExist } return "", fmt.Errorf( "cannot access email file: %w", err, ) } if info.IsDir() { return "", fmt.Errorf( "email id points to a directory", ) } return candidateAbs, nil } func (w *WebServer) loadWebAssets() { w.staticFiles = make( map[string]cachedStaticFile, ) webDir := w.config.Web.Directory tmplFuncs := template.FuncMap{ "safeID": func(s string) string { reg := regexp.MustCompile( `[^a-zA-Z0-9_-]`, ) return reg.ReplaceAllString( s, "_", ) }, } // index.html templatePath := filepath.Join( webDir, "index.html", ) templateData, err := os.ReadFile(templatePath) if err != nil { w.template = nil w.templateErr = fmt.Errorf( "web template not found: %s: %w", templatePath, err, ) w.logger.Errorf( "%v", w.templateErr, ) } else { tmpl, err := template.New( "index.html", ). Funcs(tmplFuncs). Parse(string(templateData)) if err != nil { w.template = nil w.templateErr = fmt.Errorf( "failed to parse web template %s: %w", templatePath, err, ) w.logger.Errorf( "%v", w.templateErr, ) } else { w.template = tmpl w.templateErr = nil w.logger.Infof( "Web template loaded: %s (%d bytes)", templatePath, len(templateData), ) } } assets := []struct { Name string ContentType string }{ { Name: "app.css", ContentType: "text/css; charset=utf-8", }, { Name: "app.js", ContentType: "application/javascript; charset=utf-8", }, { Name: "tailwind.js", ContentType: "application/javascript; charset=utf-8", }, } for _, asset := range assets { filePath := filepath.Join( webDir, asset.Name, ) data, err := os.ReadFile(filePath) if err != nil { w.logger.Warningf( "Cannot load web asset %s: %v", filePath, err, ) continue } urlPath := "/static/" + asset.Name w.staticFiles[urlPath] = cachedStaticFile{ Data: data, ContentType: asset.ContentType, } w.logger.Infof( "Web asset loaded: %s (%d bytes)", filePath, len(data), ) } } func (w *WebServer) handleStatic( wr http.ResponseWriter, r *http.Request, ) { if r.Method != http.MethodGet && r.Method != http.MethodHead { wr.Header().Set( "Allow", "GET, HEAD", ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) return } file, ok := w.staticFiles[r.URL.Path] if !ok { http.NotFound(wr, r) return } wr.Header().Set( "Content-Type", file.ContentType, ) if r.Method == http.MethodHead { return } if _, err := wr.Write(file.Data); err != nil { w.logger.Errorf( "Failed to send static file %s: %v", r.URL.Path, err, ) } } func constantTimeStringEqual( a string, b string, ) bool { aHash := sha256.Sum256([]byte(a)) bHash := sha256.Sum256([]byte(b)) return subtle.ConstantTimeCompare( aHash[:], bHash[:], ) == 1 } func (w *WebServer) basicAuth( next http.Handler, ) http.Handler { return http.HandlerFunc( func( wr http.ResponseWriter, r *http.Request, ) { authConfig := w.config.Web.BasicAuth if !authConfig.Enabled { next.ServeHTTP(wr, r) return } username, password, ok := r.BasicAuth() if !ok || !constantTimeStringEqual( username, authConfig.Username, ) || !constantTimeStringEqual( password, authConfig.Password, ) { wr.Header().Set( "WWW-Authenticate", `Basic realm="ric930-fake-smtp", charset="UTF-8"`, ) http.Error( wr, "Unauthorized", http.StatusUnauthorized, ) w.logger.Warningf( "Unauthorized web access from %s to %s", r.RemoteAddr, r.URL.Path, ) return } next.ServeHTTP(wr, r) }, ) } func (w *WebServer) readEmailByID( id string, ) ([]byte, string, error) { filePath, err := resolveEmailPath( w.config.SMTP.StorageDir, id, ) if err != nil { return nil, "", err } content, err := os.ReadFile(filePath) if err != nil { return nil, "", err } return content, filePath, nil } func (w *WebServer) handleEmailError( wr http.ResponseWriter, r *http.Request, id string, err error, ) { if errors.Is(err, os.ErrNotExist) { http.NotFound(wr, r) return } if strings.Contains( err.Error(), "invalid email", ) || strings.Contains( err.Error(), "escapes storage directory", ) || strings.Contains( err.Error(), "empty email id", ) { w.logger.Warningf( "Rejected email id %q from %s: %v", id, r.RemoteAddr, err, ) http.Error( wr, "Invalid email id", http.StatusBadRequest, ) return } w.logger.Errorf( "Error accessing email %q: %v", id, err, ) http.Error( wr, "Error reading email", http.StatusInternalServerError, ) } func (w *WebServer) handleIndex( wr http.ResponseWriter, r *http.Request, ) { if r.URL.Path != "/" { http.NotFound(wr, r) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { wr.Header().Set( "Allow", "GET, HEAD", ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) return } if w.template == nil { message := "Web template is unavailable" if w.templateErr != nil { message = w.templateErr.Error() } http.Error( wr, message, http.StatusInternalServerError, ) return } days, err := listEmails( w.config.SMTP.StorageDir, ) if err != nil { w.logger.Errorf( "Error listing emails: %v", err, ) http.Error( wr, "Error listing emails", http.StatusInternalServerError, ) return } data := struct { Days []DayEmails Title string }{ Days: days, Title: "Fake SMTP Server - Emails", } wr.Header().Set( "Content-Type", "text/html; charset=utf-8", ) if r.Method == http.MethodHead { return } if err := w.template.Execute( wr, data, ); err != nil { w.logger.Errorf( "Template execution error: %v", err, ) } } func (w *WebServer) handleEmailsAPI( wr http.ResponseWriter, r *http.Request, ) { if r.Method != http.MethodGet { wr.Header().Set( "Allow", http.MethodGet, ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) return } days, err := listEmails( w.config.SMTP.StorageDir, ) if err != nil { w.logger.Errorf( "Error listing emails: %v", err, ) wr.Header().Set( "Content-Type", "application/json; charset=utf-8", ) wr.WriteHeader( http.StatusInternalServerError, ) _ = json.NewEncoder(wr).Encode( map[string]string{ "error": "Error listing emails", }, ) return } wr.Header().Set( "Content-Type", "application/json; charset=utf-8", ) if err := json.NewEncoder(wr).Encode(days); err != nil { w.logger.Errorf( "Failed to encode emails JSON: %v", err, ) } } func (w *WebServer) handleEmailAPI( wr http.ResponseWriter, r *http.Request, ) { if r.Method != http.MethodGet { wr.Header().Set( "Allow", http.MethodGet, ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) return } id := r.URL.Query().Get("id") if id == "" { http.Error( wr, "Missing id parameter", http.StatusBadRequest, ) return } content, _, err := w.readEmailByID(id) if err != nil { w.handleEmailError( wr, r, id, err, ) return } body := extractEmailBody(content) wr.Header().Set( "Content-Type", "text/html; charset=utf-8", ) if _, err := wr.Write([]byte(body)); err != nil { w.logger.Errorf( "Failed to send email %q: %v", id, err, ) } } func (w *WebServer) handleViewAPI( wr http.ResponseWriter, r *http.Request, ) { if r.Method != http.MethodGet { wr.Header().Set( "Allow", http.MethodGet, ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) return } id := r.URL.Query().Get("id") if id == "" { http.Error( wr, "Missing id parameter", http.StatusBadRequest, ) return } content, _, err := w.readEmailByID(id) if err != nil { w.handleEmailError( wr, r, id, err, ) return } body := extractEmailBody(content) wr.Header().Set( "Content-Type", "text/html; charset=utf-8", ) if _, err := wr.Write([]byte(body)); err != nil { w.logger.Errorf( "Failed to send email view %q: %v", id, err, ) } } func (w *WebServer) handleDownloadAPI( wr http.ResponseWriter, r *http.Request, ) { if r.Method != http.MethodGet { wr.Header().Set( "Allow", http.MethodGet, ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) return } id := r.URL.Query().Get("id") if id == "" { http.Error( wr, "Missing id parameter", http.StatusBadRequest, ) return } content, filePath, err := w.readEmailByID(id) if err != nil { w.handleEmailError( wr, r, id, err, ) return } filename := filepath.Base(filePath) wr.Header().Set( "Content-Type", "message/rfc822", ) wr.Header().Set( "Content-Disposition", fmt.Sprintf( `attachment; filename="%s"`, filename, ), ) wr.Header().Set( "Content-Length", fmt.Sprintf( "%d", len(content), ), ) if _, err := wr.Write(content); err != nil { w.logger.Errorf( "Failed to download email %q: %v", id, err, ) } } func (w *WebServer) Start() error { if !w.config.Web.Enabled { w.logger.Info( "Web server disabled in config", ) return nil } w.loadWebAssets() mux := http.NewServeMux() mux.HandleFunc( "/static/", w.handleStatic, ) mux.HandleFunc( "/api/emails", w.handleEmailsAPI, ) mux.HandleFunc( "/api/email", w.handleEmailAPI, ) mux.HandleFunc( "/api/view", w.handleViewAPI, ) mux.HandleFunc( "/api/download", w.handleDownloadAPI, ) mux.HandleFunc( "/", w.handleIndex, ) addr := fmt.Sprintf( ":%d", w.config.Web.Port, ) w.server = &http.Server{ Addr: addr, Handler: w.basicAuth(mux), ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } w.logger.Infof( "Web server listening on http://0.0.0.0:%d", w.config.Web.Port, ) w.logger.Infof( "Web directory: %s", w.config.Web.Directory, ) w.logger.Infof( "Web Basic Auth enabled: %v", w.config.Web.BasicAuth.Enabled, ) go func() { err := w.server.ListenAndServe() if err != nil && !errors.Is( err, http.ErrServerClosed, ) { w.logger.Errorf( "Web server error: %v", err, ) } }() return nil } func (w *WebServer) Stop() error { if w.server == nil { return nil } w.logger.Info( "Stopping web server...", ) return w.server.Close() }