package main import ( "bytes" "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "html/template" "mime" "mime/quotedprintable" "net" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/kardianos/service" ) const sessionCookieName = "ric930_session" var errInvalidEmailID = errors.New( "invalid email id", ) 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 webSession struct { LastActivity time.Time CSRFToken string } type sessionStore struct { mu sync.Mutex sessions map[string]*webSession timeout time.Duration } type loginAttempt struct { Failures int WindowStart time.Time BlockedUntil time.Time LastSeen time.Time } type loginRateLimiter struct { mu sync.Mutex attempts map[string]*loginAttempt maxAttempts int window time.Duration lockout time.Duration } type WebServer struct { config *Config smtpServer *SMTPServer logger service.Logger server *http.Server template *template.Template templateErr error loginTemplate *template.Template loginTemplateErr error staticFiles map[string]cachedStaticFile sessions *sessionStore loginLimiter *loginRateLimiter } func newSessionStore( timeout time.Duration, ) *sessionStore { return &sessionStore{ sessions: make( map[string]*webSession, ), timeout: timeout, } } func generateSecureToken() ( string, error, ) { data := make([]byte, 32) if _, err := rand.Read(data); err != nil { return "", fmt.Errorf( "failed to generate secure token: %w", err, ) } return base64.RawURLEncoding. EncodeToString(data), nil } func (s *sessionStore) create() ( string, error, ) { sessionID, err := generateSecureToken() if err != nil { return "", err } csrfToken, err := generateSecureToken() if err != nil { return "", err } now := time.Now() s.mu.Lock() defer s.mu.Unlock() for id, session := range s.sessions { if now.Sub(session.LastActivity) > s.timeout { delete( s.sessions, id, ) } } s.sessions[sessionID] = &webSession{ LastActivity: now, CSRFToken: csrfToken, } return sessionID, nil } func (s *sessionStore) validate( sessionID string, ) bool { if sessionID == "" { return false } now := time.Now() s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[sessionID] if !ok { return false } if now.Sub(session.LastActivity) > s.timeout { delete( s.sessions, sessionID, ) return false } // Sliding inactivity timeout. session.LastActivity = now return true } func (s *sessionStore) csrfToken( sessionID string, ) (string, bool) { if sessionID == "" { return "", false } s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[sessionID] if !ok { return "", false } return session.CSRFToken, true } func (s *sessionStore) validateCSRF( sessionID string, token string, ) bool { if sessionID == "" || token == "" { return false } s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[sessionID] if !ok { return false } return constantTimeStringEqual( session.CSRFToken, token, ) } func (s *sessionStore) delete( sessionID string, ) { if sessionID == "" { return } s.mu.Lock() defer s.mu.Unlock() delete( s.sessions, sessionID, ) } func newLoginRateLimiter( maxAttempts int, window time.Duration, lockout time.Duration, ) *loginRateLimiter { return &loginRateLimiter{ attempts: make( map[string]*loginAttempt, ), maxAttempts: maxAttempts, window: window, lockout: lockout, } } func (l *loginRateLimiter) allow( key string, ) (bool, time.Duration) { now := time.Now() l.mu.Lock() defer l.mu.Unlock() for id, attempt := range l.attempts { if attempt.BlockedUntil.After(now) { continue } if now.Sub(attempt.LastSeen) > l.window+l.lockout { delete( l.attempts, id, ) } } attempt, ok := l.attempts[key] if !ok { return true, 0 } attempt.LastSeen = now if attempt.BlockedUntil.After(now) { return false, time.Until( attempt.BlockedUntil, ) } if now.Sub(attempt.WindowStart) > l.window { attempt.Failures = 0 attempt.WindowStart = now attempt.BlockedUntil = time.Time{} } return true, 0 } func (l *loginRateLimiter) failure( key string, ) { now := time.Now() l.mu.Lock() defer l.mu.Unlock() attempt, ok := l.attempts[key] if !ok { attempt = &loginAttempt{ WindowStart: now, } l.attempts[key] = attempt } if now.Sub(attempt.WindowStart) > l.window { attempt.Failures = 0 attempt.WindowStart = now attempt.BlockedUntil = time.Time{} } attempt.Failures++ attempt.LastSeen = now if attempt.Failures >= l.maxAttempts { attempt.BlockedUntil = now.Add(l.lockout) } } func (l *loginRateLimiter) success( key string, ) { l.mu.Lock() defer l.mu.Unlock() delete( l.attempts, key, ) } func remoteIP( remoteAddr string, ) string { host, _, err := net.SplitHostPort( remoteAddr, ) if err == nil { return host } return remoteAddr } func NewWebServer( cfg *Config, smtp *SMTPServer, ) *WebServer { sessionTimeout := 30 * time.Minute if cfg.Web.Auth.SessionTimeout != "" { if parsed, err := time.ParseDuration( cfg.Web.Auth.SessionTimeout, ); err == nil && parsed > 0 { sessionTimeout = parsed } } loginWindow := 5 * time.Minute if cfg.Web.Auth.LoginWindow != "" { if parsed, err := time.ParseDuration( cfg.Web.Auth.LoginWindow, ); err == nil && parsed > 0 { loginWindow = parsed } } loginLockout := 15 * time.Minute if cfg.Web.Auth.LoginLockout != "" { if parsed, err := time.ParseDuration( cfg.Web.Auth.LoginLockout, ); err == nil && parsed > 0 { loginLockout = parsed } } maxAttempts := cfg.Web.Auth.LoginMaxAttempts if maxAttempts <= 0 { maxAttempts = 5 } return &WebServer{ config: cfg, smtpServer: smtp, staticFiles: make( map[string]cachedStaticFile, ), sessions: newSessionStore( sessionTimeout, ), loginLimiter: newLoginRateLimiter( maxAttempts, loginWindow, loginLockout, ), } } func decodeRFC2047( encoded string, ) string { decoder := new( mime.WordDecoder, ) decoded, err := decoder.Decode(encoded) if err == nil { return decoded } 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 } 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 extractMIMEPartBody( part string, ) string { if idx := strings.Index( part, "\r\n\r\n", ); idx != -1 { return strings.TrimSuffix( part[idx+4:], "--", ) } if idx := strings.Index( part, "\n\n", ); idx != -1 { return strings.TrimSuffix( part[idx+2:], "--", ) } 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 { boundary = strings.Trim( line[idx+9:], "\" \r\n", ) break } if i+1 < len(lines) && strings.Contains( strings.ToLower( lines[i+1], ), "boundary=", ) { nextLine := lines[i+1] if idx := strings.Index( nextLine, "boundary=", ); idx != -1 { boundary = strings.Trim( nextLine[idx+9:], "\" \r\n", ) } } } if boundary != "" { parts := strings.Split( str, "--"+boundary, ) // Сначала text/plain. 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, ) } // Затем text/html. 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, ) } } 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 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 file.Type()& os.ModeSymlink != 0 { 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( "%w: empty id", errInvalidEmailID, ) } if strings.Contains( id, "\\", ) { return "", fmt.Errorf( "%w: backslash is not allowed", errInvalidEmailID, ) } parts := strings.Split( id, "/", ) if len(parts) != 2 { return "", fmt.Errorf( "%w: invalid path structure", errInvalidEmailID, ) } day := parts[0] filename := parts[1] if _, err := time.Parse( "2006-01-02", day, ); err != nil { return "", fmt.Errorf( "%w: invalid date directory", errInvalidEmailID, ) } if filename == "" || filename == "." || filename == ".." || filepath.Base(filename) != filename { return "", fmt.Errorf( "%w: invalid filename", errInvalidEmailID, ) } if !strings.EqualFold( filepath.Ext(filename), ".eml", ) { return "", fmt.Errorf( "%w: only .eml is allowed", errInvalidEmailID, ) } storageAbs, err := filepath.Abs(storageDir) if err != nil { return "", fmt.Errorf( "cannot resolve storage directory: %w", err, ) } storageResolved, err := filepath.EvalSymlinks( storageAbs, ) 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, ) } candidateResolved, err := filepath.EvalSymlinks( candidateAbs, ) if err != nil { if os.IsNotExist(err) { return "", os.ErrNotExist } return "", fmt.Errorf( "cannot resolve email file: %w", err, ) } rel, err := filepath.Rel( storageResolved, candidateResolved, ) 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( "%w: path escapes storage directory", errInvalidEmailID, ) } info, err := os.Stat( candidateResolved, ) if err != nil { if os.IsNotExist(err) { return "", os.ErrNotExist } return "", fmt.Errorf( "cannot access email: %w", err, ) } if info.IsDir() { return "", fmt.Errorf( "%w: email points to directory", errInvalidEmailID, ) } return candidateResolved, nil } func (w *WebServer) loadWebAssets() { w.staticFiles = make( map[string]cachedStaticFile, ) webDir := w.config.Web.Directory // 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", ).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), ) } } // login.html loginTemplatePath := filepath.Join( webDir, "login.html", ) loginTemplateData, err := os.ReadFile( loginTemplatePath, ) if err != nil { w.loginTemplate = nil w.loginTemplateErr = fmt.Errorf( "login template not found: %s: %w", loginTemplatePath, err, ) w.logger.Errorf( "%v", w.loginTemplateErr, ) } else { tmpl, err := template.New( "login.html", ).Parse( string( loginTemplateData, ), ) if err != nil { w.loginTemplate = nil w.loginTemplateErr = fmt.Errorf( "failed to parse login template %s: %w", loginTemplatePath, err, ) w.logger.Errorf( "%v", w.loginTemplateErr, ) } else { w.loginTemplate = tmpl w.loginTemplateErr = nil w.logger.Infof( "Login template loaded: %s (%d bytes)", loginTemplatePath, len( loginTemplateData, ), ) } } 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) setSessionCookie( wr http.ResponseWriter, sessionID string, ) { http.SetCookie( wr, &http.Cookie{ Name: sessionCookieName, Value: sessionID, Path: "/", HttpOnly: true, Secure: w.config.Web.Auth. CookieSecure, SameSite: http.SameSiteStrictMode, }, ) } func (w *WebServer) clearSessionCookie( wr http.ResponseWriter, ) { http.SetCookie( wr, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: w.config.Web.Auth. CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0), }, ) } type loginPageData struct { Title string Error string } func (w *WebServer) renderLogin( wr http.ResponseWriter, status int, errorMessage string, ) { if w.loginTemplate == nil { message := "Login template is unavailable" if w.loginTemplateErr != nil { message = w.loginTemplateErr. Error() } http.Error( wr, message, http.StatusInternalServerError, ) return } wr.Header().Set( "Content-Type", "text/html; charset=utf-8", ) wr.Header().Set( "Cache-Control", "no-store", ) wr.WriteHeader(status) if err := w.loginTemplate.Execute( wr, loginPageData{ Title: "Fake SMTP Server - Login", Error: errorMessage, }, ); err != nil { w.logger.Errorf( "Login template execution error: %v", err, ) } } func (w *WebServer) handleLogin( wr http.ResponseWriter, r *http.Request, ) { if !w.config.Web.Auth.Enabled { http.Redirect( wr, r, "/", http.StatusSeeOther, ) return } switch r.Method { case http.MethodGet: if cookie, err := r.Cookie( sessionCookieName, ); err == nil { if w.sessions.validate( cookie.Value, ) { http.Redirect( wr, r, "/", http.StatusSeeOther, ) return } w.clearSessionCookie( wr, ) } w.renderLogin( wr, http.StatusOK, "", ) case http.MethodPost: clientIP := remoteIP( r.RemoteAddr, ) allowed, retryAfter := w.loginLimiter.allow( clientIP, ) if !allowed { seconds := int64( (retryAfter + time.Second - 1) / time.Second, ) if seconds < 1 { seconds = 1 } wr.Header().Set( "Retry-After", strconv.FormatInt( seconds, 10, ), ) w.logger.Warningf( "Web login temporarily blocked for %s", clientIP, ) w.renderLogin( wr, http.StatusTooManyRequests, "Too many failed login attempts. Try again later.", ) return } r.Body = http.MaxBytesReader( wr, r.Body, 16*1024, ) if err := r.ParseForm(); err != nil { w.renderLogin( wr, http.StatusBadRequest, "Invalid request", ) return } username := r.FormValue( "username", ) password := r.FormValue( "password", ) validUsername := constantTimeStringEqual( username, w.config.Web.Auth. Username, ) validPassword := constantTimeStringEqual( password, w.config.Web.Auth. Password, ) if !validUsername || !validPassword { w.loginLimiter.failure( clientIP, ) w.logger.Warningf( "Failed web login from %s", clientIP, ) w.renderLogin( wr, http.StatusUnauthorized, "Invalid username or password", ) return } w.loginLimiter.success( clientIP, ) // Защита от session fixation: // старую сессию удаляем. if oldCookie, err := r.Cookie( sessionCookieName, ); err == nil { w.sessions.delete( oldCookie.Value, ) } sessionID, err := w.sessions.create() if err != nil { w.logger.Errorf( "Failed to create session: %v", err, ) http.Error( wr, "Failed to create session", http.StatusInternalServerError, ) return } w.setSessionCookie( wr, sessionID, ) w.logger.Infof( "Web login successful from %s", clientIP, ) http.Redirect( wr, r, "/", http.StatusSeeOther, ) default: wr.Header().Set( "Allow", "GET, POST", ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) } } func (w *WebServer) handleLogout( wr http.ResponseWriter, r *http.Request, ) { if r.Method != http.MethodPost { wr.Header().Set( "Allow", http.MethodPost, ) http.Error( wr, "Method not allowed", http.StatusMethodNotAllowed, ) return } r.Body = http.MaxBytesReader( wr, r.Body, 8*1024, ) if err := r.ParseForm(); err != nil { http.Error( wr, "Invalid request", http.StatusBadRequest, ) return } cookie, err := r.Cookie( sessionCookieName, ) if err != nil { w.clearSessionCookie( wr, ) http.Redirect( wr, r, "/login", http.StatusSeeOther, ) return } csrfToken := r.FormValue( "csrf_token", ) if !w.sessions.validateCSRF( cookie.Value, csrfToken, ) { w.logger.Warningf( "Rejected logout with invalid CSRF token from %s", r.RemoteAddr, ) http.Error( wr, "Invalid CSRF token", http.StatusForbidden, ) return } w.sessions.delete( cookie.Value, ) w.clearSessionCookie( wr, ) http.Redirect( wr, r, "/login", http.StatusSeeOther, ) } func (w *WebServer) sessionAuth( next http.Handler, ) http.Handler { return http.HandlerFunc( func( wr http.ResponseWriter, r *http.Request, ) { if !w.config.Web.Auth.Enabled { next.ServeHTTP( wr, r, ) return } // Login должен быть публичным. if r.URL.Path == "/login" { next.ServeHTTP( wr, r, ) return } // Статика нужна странице login. if strings.HasPrefix( r.URL.Path, "/static/", ) { next.ServeHTTP( wr, r, ) return } cookie, err := r.Cookie( sessionCookieName, ) if err == nil && w.sessions.validate( cookie.Value, ) { next.ServeHTTP( wr, r, ) return } w.clearSessionCookie( wr, ) if strings.HasPrefix( r.URL.Path, "/api/", ) { http.Error( wr, "Session expired", http.StatusUnauthorized, ) return } http.Redirect( wr, r, "/login", http.StatusSeeOther, ) }, ) } 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 errors.Is( err, errInvalidEmailID, ) { 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 } csrfToken := "" if w.config.Web.Auth.Enabled { if cookie, err := r.Cookie( sessionCookieName, ); err == nil { if token, ok := w.sessions.csrfToken( cookie.Value, ); ok { csrfToken = token } } } data := struct { Days []DayEmails Title string CSRFToken string }{ Days: days, Title: "Fake SMTP Server - Emails", CSRFToken: csrfToken, } wr.Header().Set( "Content-Type", "text/html; charset=utf-8", ) wr.Header().Set( "Cache-Control", "no-store", ) 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, ) disposition := mime.FormatMediaType( "attachment", map[string]string{ "filename": filename, }, ) wr.Header().Set( "Content-Type", "message/rfc822", ) wr.Header().Set( "Content-Disposition", disposition, ) wr.Header().Set( "Content-Length", strconv.Itoa( 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 } // HTML/CSS/JS читаются один раз при запуске. w.loadWebAssets() mux := http.NewServeMux() mux.HandleFunc( "/static/", w.handleStatic, ) mux.HandleFunc( "/login", w.handleLogin, ) mux.HandleFunc( "/logout", w.handleLogout, ) 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.sessionAuth(mux), ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, ReadHeaderTimeout: 5 * time.Second, } // net.Listen выполняется синхронно, // поэтому занятый порт сразу вернёт ошибку из Start(). listener, err := net.Listen( "tcp", addr, ) if err != nil { w.server = nil return fmt.Errorf( "failed to listen on %s: %w", addr, err, ) } 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 Auth enabled: %v", w.config.Web.Auth.Enabled, ) if w.config.Web.Auth.Enabled { w.logger.Infof( "Web session inactivity timeout: %s", w.config.Web.Auth.SessionTimeout, ) w.logger.Infof( "Web session cookie Secure: %v", w.config.Web.Auth.CookieSecure, ) w.logger.Infof( "Web login rate limit: %d attempts / %s, lockout %s", w.config.Web.Auth.LoginMaxAttempts, w.config.Web.Auth.LoginWindow, w.config.Web.Auth.LoginLockout, ) } go func() { err := w.server.Serve( listener, ) 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...", ) ctx, cancel := context.WithTimeout( context.Background(), 5*time.Second, ) defer cancel() err := w.server.Shutdown(ctx) w.server = nil if err != nil { return fmt.Errorf( "failed to stop web server: %w", err, ) } return nil }