release: 1.1.1 - session auth and security improvements

This commit is contained in:
2026-10-05 22:36:24 +03:00
parent 1cd3cb9a2e
commit b1cd985720
8 changed files with 2539 additions and 539 deletions
+105 -21
View File
@@ -1,31 +1,40 @@
APP_NAME := ric930-fake-smtp APP_NAME := ric930-fake-smtp
VERSION ?= 1.1.0
GO := go
BUILD_DIR := builds BUILD_DIR := builds
LINUX_DIR := $(BUILD_DIR)/linux LINUX_DIR := $(BUILD_DIR)/linux
WINDOWS_DIR := $(BUILD_DIR)/windows WINDOWS_DIR := $(BUILD_DIR)/windows
RELEASE_DIR := $(BUILD_DIR)/releases
LINUX_BIN := $(LINUX_DIR)/$(APP_NAME) LINUX_BIN := $(LINUX_DIR)/$(APP_NAME)
WINDOWS_BIN := $(WINDOWS_DIR)/$(APP_NAME).exe WINDOWS_BIN := $(WINDOWS_DIR)/$(APP_NAME).exe
GO := go LINUX_ARCHIVE := $(RELEASE_DIR)/$(APP_NAME)_$(VERSION)_linux_amd64.tar.gz
WINDOWS_ARCHIVE := $(RELEASE_DIR)/$(APP_NAME)_$(VERSION)_windows_amd64.zip
BUILD_FLAGS := -trimpath BUILD_FLAGS := -trimpath
LDFLAGS := -s -w LDFLAGS := -s -w
.PHONY: all .PHONY: all
.PHONY: build .PHONY: build
.PHONY: build-all .PHONY: build-all
.PHONY: build-linux .PHONY: build-linux
.PHONY: build-windows .PHONY: build-windows
.PHONY: release
.PHONY: package-linux
.PHONY: package-windows
.PHONY: clean .PHONY: clean
.PHONY: fmt .PHONY: fmt
.PHONY: tidy .PHONY: tidy
.PHONY: check .PHONY: check
# ------------------------------------------------------------ # ============================================================
# Host OS # Host OS helpers
# ------------------------------------------------------------ # ============================================================
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
@@ -68,9 +77,9 @@ endef
endif endif
# ------------------------------------------------------------ # ============================================================
# Default # Default targets
# ------------------------------------------------------------ # ============================================================
all: build-all all: build-all
@@ -79,9 +88,9 @@ build: build-all
build-all: build-linux build-windows build-all: build-linux build-windows
# ------------------------------------------------------------ # ============================================================
# Linux # Linux build
# ------------------------------------------------------------ # ============================================================
build-linux: build-linux:
@echo "==> Building Linux amd64" @echo "==> Building Linux amd64"
@@ -93,12 +102,20 @@ ifeq ($(OS),Windows_NT)
@set "CGO_ENABLED=0" && \ @set "CGO_ENABLED=0" && \
set "GOOS=linux" && \ set "GOOS=linux" && \
set "GOARCH=amd64" && \ set "GOARCH=amd64" && \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(LINUX_BIN))" . $(GO) build \
$(BUILD_FLAGS) \
-ldflags="$(LDFLAGS)" \
-o "$(subst /,\,$(LINUX_BIN))" \
.
else else
@CGO_ENABLED=0 \ @CGO_ENABLED=0 \
GOOS=linux \ GOOS=linux \
GOARCH=amd64 \ GOARCH=amd64 \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(LINUX_BIN)" . $(GO) build \
$(BUILD_FLAGS) \
-ldflags="$(LDFLAGS)" \
-o "$(LINUX_BIN)" \
.
endif endif
$(call copy_file,config.yaml,$(LINUX_DIR)/config.yaml) $(call copy_file,config.yaml,$(LINUX_DIR)/config.yaml)
@@ -107,9 +124,9 @@ endif
@echo "==> Linux build created: $(LINUX_DIR)" @echo "==> Linux build created: $(LINUX_DIR)"
# ------------------------------------------------------------ # ============================================================
# Windows # Windows build
# ------------------------------------------------------------ # ============================================================
build-windows: build-windows:
@echo "==> Building Windows amd64" @echo "==> Building Windows amd64"
@@ -121,12 +138,20 @@ ifeq ($(OS),Windows_NT)
@set "CGO_ENABLED=0" && \ @set "CGO_ENABLED=0" && \
set "GOOS=windows" && \ set "GOOS=windows" && \
set "GOARCH=amd64" && \ set "GOARCH=amd64" && \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(WINDOWS_BIN))" . $(GO) build \
$(BUILD_FLAGS) \
-ldflags="$(LDFLAGS)" \
-o "$(subst /,\,$(WINDOWS_BIN))" \
.
else else
@CGO_ENABLED=0 \ @CGO_ENABLED=0 \
GOOS=windows \ GOOS=windows \
GOARCH=amd64 \ GOARCH=amd64 \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(WINDOWS_BIN)" . $(GO) build \
$(BUILD_FLAGS) \
-ldflags="$(LDFLAGS)" \
-o "$(WINDOWS_BIN)" \
.
endif endif
$(call copy_file,config.yaml,$(WINDOWS_DIR)/config.yaml) $(call copy_file,config.yaml,$(WINDOWS_DIR)/config.yaml)
@@ -135,12 +160,71 @@ endif
@echo "==> Windows build created: $(WINDOWS_DIR)" @echo "==> Windows build created: $(WINDOWS_DIR)"
# ------------------------------------------------------------ # ============================================================
# Release
# ============================================================
release: build-all package-linux package-windows
@echo ""
@echo "==> Release $(VERSION) created"
@echo " Linux: $(LINUX_ARCHIVE)"
@echo " Windows: $(WINDOWS_ARCHIVE)"
# ============================================================
# Linux package
# ============================================================
package-linux:
@echo "==> Creating Linux release archive"
$(call make_dir,$(RELEASE_DIR))
ifeq ($(OS),Windows_NT)
@tar \
-C "$(subst /,\,$(LINUX_DIR))" \
-czf "$(subst /,\,$(LINUX_ARCHIVE))" \
.
else
@tar \
-C "$(LINUX_DIR)" \
-czf "$(LINUX_ARCHIVE)" \
.
endif
@echo "==> Linux archive created: $(LINUX_ARCHIVE)"
# ============================================================
# Windows package
# ============================================================
package-windows:
@echo "==> Creating Windows release archive"
$(call make_dir,$(RELEASE_DIR))
ifeq ($(OS),Windows_NT)
@powershell -NoProfile -Command \
"Compress-Archive \
-Path '$(subst /,\,$(WINDOWS_DIR))\*' \
-DestinationPath '$(subst /,\,$(WINDOWS_ARCHIVE))' \
-Force"
else
@rm -f "$(WINDOWS_ARCHIVE)"
@cd "$(WINDOWS_DIR)" && \
zip -qr \
"../releases/$(APP_NAME)_$(VERSION)_windows_amd64.zip" \
.
endif
@echo "==> Windows archive created: $(WINDOWS_ARCHIVE)"
# ============================================================
# Maintenance # Maintenance
# ------------------------------------------------------------ # ============================================================
clean: clean:
@echo "==> Cleaning builds" @echo "==> Cleaning build directory"
$(call remove_dir,$(BUILD_DIR)) $(call remove_dir,$(BUILD_DIR))
@@ -155,7 +239,7 @@ tidy:
check: check:
@echo "==> Checking project" @echo "==> Running Go checks"
@$(GO) vet ./... @$(GO) vet ./...
@$(GO) test ./... @$(GO) test ./...
+295 -77
View File
@@ -1,10 +1,12 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -24,16 +26,52 @@ type SMTPConfig struct {
} }
type WebConfig struct { type WebConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Port int `yaml:"port"` Port int `yaml:"port"`
Directory string `yaml:"directory"` Directory string `yaml:"directory"`
BasicAuth BasicAuthConfig `yaml:"basic_auth"` Auth AuthConfig `yaml:"auth"`
} }
type BasicAuthConfig struct { type AuthConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
SessionTimeout string `yaml:"session_timeout"`
CookieSecure bool `yaml:"cookie_secure"`
LoginMaxAttempts int `yaml:"login_max_attempts"`
LoginWindow string `yaml:"login_window"`
LoginLockout string `yaml:"login_lockout"`
}
func defaultConfig() *Config {
return &Config{
SMTP: SMTPConfig{
ListenAddress: "0.0.0.0",
ListenPort: 25,
StorageDir: "emails",
DomainName: "m.ric930.ru",
MaxMessageSize: 50 * 1024 * 1024,
EnableAuth: true,
},
Web: WebConfig{
Enabled: true,
Port: 8089,
Directory: "web",
Auth: AuthConfig{
Enabled: true,
Username: "admin",
Password: "admin",
SessionTimeout: "15m",
CookieSecure: false,
LoginMaxAttempts: 3,
LoginWindow: "5m",
LoginLockout: "15m",
},
},
}
} }
func getExecutableDir() (string, error) { func getExecutableDir() (string, error) {
@@ -42,138 +80,308 @@ func getExecutableDir() (string, error) {
return "", err return "", err
} }
exe, err = filepath.Abs(exe)
if err != nil {
return "", err
}
return filepath.Dir(exe), nil return filepath.Dir(exe), nil
} }
func defaultConfig() *Config { func resolveConfigPaths(
return &Config{ cfg *Config,
SMTP: SMTPConfig{ exeDir string,
ListenAddress: "0.0.0.0", ) {
ListenPort: 1025, if cfg.SMTP.StorageDir != "" &&
StorageDir: "emails", !filepath.IsAbs(cfg.SMTP.StorageDir) {
DomainName: "m.ric930.ru",
MaxMessageSize: 50 * 1024 * 1024,
EnableAuth: true,
},
Web: WebConfig{
Enabled: true,
Port: 8089,
Directory: "web",
BasicAuth: BasicAuthConfig{
Enabled: true,
Username: "admin",
Password: "admin",
},
},
}
}
func resolveConfigPaths(cfg *Config, exeDir string) { cfg.SMTP.StorageDir = filepath.Join(
if !filepath.IsAbs(cfg.SMTP.StorageDir) { exeDir,
cfg.SMTP.StorageDir = filepath.Join(exeDir, cfg.SMTP.StorageDir) cfg.SMTP.StorageDir,
)
} }
if !filepath.IsAbs(cfg.Web.Directory) { if cfg.Web.Directory != "" &&
cfg.Web.Directory = filepath.Join(exeDir, cfg.Web.Directory) !filepath.IsAbs(cfg.Web.Directory) {
cfg.Web.Directory = filepath.Join(
exeDir,
cfg.Web.Directory,
)
} }
} }
func validateConfig(cfg *Config) error { func validateConfig(cfg *Config) error {
if cfg.SMTP.ListenPort < 1 || cfg.SMTP.ListenPort > 65535 { if cfg.SMTP.ListenPort < 1 ||
return fmt.Errorf("invalid smtp.listen_port: %d", cfg.SMTP.ListenPort) cfg.SMTP.ListenPort > 65535 {
return fmt.Errorf(
"smtp.listen_port must be between 1 and 65535",
)
} }
if cfg.SMTP.StorageDir == "" { if cfg.SMTP.StorageDir == "" {
return fmt.Errorf("smtp.storage_dir must not be empty") return fmt.Errorf(
"smtp.storage_dir must not be empty",
)
} }
if cfg.SMTP.DomainName == "" { if cfg.SMTP.DomainName == "" {
return fmt.Errorf("smtp.domain_name must not be empty") return fmt.Errorf(
"smtp.domain_name must not be empty",
)
} }
if cfg.SMTP.MaxMessageSize <= 0 { if cfg.SMTP.MaxMessageSize <= 0 {
return fmt.Errorf("smtp.max_message_size must be greater than zero") return fmt.Errorf(
"smtp.max_message_size must be greater than zero",
)
} }
if cfg.Web.Enabled { if !cfg.Web.Enabled {
if cfg.Web.Port < 1 || cfg.Web.Port > 65535 { return nil
return fmt.Errorf("invalid web.port: %d", cfg.Web.Port) }
}
if cfg.Web.Directory == "" { if cfg.Web.Port < 1 ||
return fmt.Errorf("web.directory must not be empty") cfg.Web.Port > 65535 {
}
if cfg.Web.BasicAuth.Enabled { return fmt.Errorf(
if cfg.Web.BasicAuth.Username == "" { "web.port must be between 1 and 65535",
return fmt.Errorf("web.basic_auth.username must not be empty") )
} }
if cfg.Web.BasicAuth.Password == "" { if cfg.Web.Directory == "" {
return fmt.Errorf("web.basic_auth.password must not be empty") return fmt.Errorf(
} "web.directory must not be empty",
} )
}
if !cfg.Web.Auth.Enabled {
return nil
}
if cfg.Web.Auth.Username == "" {
return fmt.Errorf(
"web.auth.username must not be empty",
)
}
if cfg.Web.Auth.Password == "" {
return fmt.Errorf(
"web.auth.password must not be empty",
)
}
if cfg.Web.Auth.SessionTimeout == "" {
return fmt.Errorf(
"web.auth.session_timeout must not be empty",
)
}
sessionTimeout, err := time.ParseDuration(
cfg.Web.Auth.SessionTimeout,
)
if err != nil {
return fmt.Errorf(
"invalid web.auth.session_timeout %q: %w",
cfg.Web.Auth.SessionTimeout,
err,
)
}
if sessionTimeout <= 0 {
return fmt.Errorf(
"web.auth.session_timeout must be greater than zero",
)
}
if cfg.Web.Auth.LoginMaxAttempts <= 0 {
return fmt.Errorf(
"web.auth.login_max_attempts must be greater than zero",
)
}
loginWindow, err := time.ParseDuration(
cfg.Web.Auth.LoginWindow,
)
if err != nil {
return fmt.Errorf(
"invalid web.auth.login_window %q: %w",
cfg.Web.Auth.LoginWindow,
err,
)
}
if loginWindow <= 0 {
return fmt.Errorf(
"web.auth.login_window must be greater than zero",
)
}
loginLockout, err := time.ParseDuration(
cfg.Web.Auth.LoginLockout,
)
if err != nil {
return fmt.Errorf(
"invalid web.auth.login_lockout %q: %w",
cfg.Web.Auth.LoginLockout,
err,
)
}
if loginLockout <= 0 {
return fmt.Errorf(
"web.auth.login_lockout must be greater than zero",
)
} }
return nil return nil
} }
func LoadConfig(configPath string) (*Config, error) { func LoadConfig(
configPath string,
) (*Config, error) {
exeDir, err := getExecutableDir() exeDir, err := getExecutableDir()
if err != nil { if err != nil {
log.Printf( log.Printf(
"Warning: cannot get executable directory: %v, using current directory", "Warning: cannot get executable directory: %v, using current directory",
err, err,
) )
exeDir = "." exeDir = "."
} }
finalConfigPath := configPath if configPath == "" {
if !filepath.IsAbs(finalConfigPath) { configPath = "config.yaml"
finalConfigPath = filepath.Join(exeDir, finalConfigPath)
} }
data, err := os.ReadFile(finalConfigPath) finalConfigPath := configPath
if !filepath.IsAbs(finalConfigPath) {
finalConfigPath = filepath.Join(
exeDir,
finalConfigPath,
)
}
finalConfigPath, err = filepath.Abs(
finalConfigPath,
)
if err != nil {
return nil, fmt.Errorf(
"failed to resolve config path: %w",
err,
)
}
log.Printf(
"Loading config from %s",
finalConfigPath,
)
data, err := os.ReadFile(
finalConfigPath,
)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read config: %w", err) return nil, fmt.Errorf(
"failed to read config %s: %w",
finalConfigPath,
err,
)
} }
// Конфига нет вообще:
// создаём новый default config.
cfg := defaultConfig() cfg := defaultConfig()
cfgData, marshalErr := yaml.Marshal(cfg) if err := validateConfig(cfg); err != nil {
if marshalErr != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"failed to create default config data: %w", "default config is invalid: %w",
marshalErr, err,
) )
} }
if writeErr := os.WriteFile(finalConfigPath, cfgData, 0600); writeErr != nil { cfgData, err := yaml.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"failed to create default config: %w", "failed to encode default config: %w",
writeErr, err,
) )
} }
log.Printf("Created default config at %s", finalConfigPath) configDir := filepath.Dir(
log.Printf(
"WARNING: change the default web Basic Auth password in %s",
finalConfigPath, finalConfigPath,
) )
resolveConfigPaths(cfg, exeDir) if err := os.MkdirAll(
configDir,
if err := validateConfig(cfg); err != nil { 0755,
return nil, err ); err != nil {
return nil, fmt.Errorf(
"failed to create config directory %s: %w",
configDir,
err,
)
} }
if err := os.WriteFile(
finalConfigPath,
cfgData,
0600,
); err != nil {
return nil, fmt.Errorf(
"failed to create default config %s: %w",
finalConfigPath,
err,
)
}
log.Printf(
"Created default config at %s",
finalConfigPath,
)
resolveConfigPaths(
cfg,
exeDir,
)
return cfg, nil return cfg, nil
} }
/*
ВАЖНО:
Если config.yaml существует, НЕ используем defaultConfig()
как основу.
Иначе ошибочное/отсутствующее поле может незаметно получить
значение, зашитое в бинарник.
*/
var cfg Config var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil { decoder := yaml.NewDecoder(
bytes.NewReader(data),
)
/*
Запрещаем неизвестные поля.
Например старое:
basic_auth:
вместо:
auth:
теперь вызовет ошибку при запуске.
*/
decoder.KnownFields(true)
if err := decoder.Decode(&cfg); err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"failed to parse YAML config %s: %w", "failed to parse YAML config %s: %w",
finalConfigPath, finalConfigPath,
@@ -189,7 +397,17 @@ func LoadConfig(configPath string) (*Config, error) {
) )
} }
resolveConfigPaths(&cfg, exeDir) log.Printf(
"Config loaded: web_auth=%v username=%q session_timeout=%s",
cfg.Web.Auth.Enabled,
cfg.Web.Auth.Username,
cfg.Web.Auth.SessionTimeout,
)
resolveConfigPaths(
&cfg,
exeDir,
)
return &cfg, nil return &cfg, nil
} }
+8 -1
View File
@@ -11,7 +11,14 @@ web:
port: 8089 port: 8089
directory: web directory: web
basic_auth: auth:
enabled: true enabled: true
session_timeout: 15m
cookie_secure: false
username: admin username: admin
password: admin password: admin
login_max_attempts: 5
login_window: 5m
login_lockout: 15m
-1
View File
@@ -14,7 +14,6 @@ type Program struct {
webServer *WebServer webServer *WebServer
} }
func (p *Program) Start( func (p *Program) Start(
s service.Service, s service.Service,
) error { ) error {
+382 -129
View File
@@ -1,56 +1,88 @@
'use strict'; 'use strict';
document.addEventListener('DOMContentLoaded', function () { var iframeObservers = new WeakMap();
initializeEmailList();
}); document.addEventListener(
'DOMContentLoaded',
function () {
initializeEmailList();
}
);
function initializeEmailList() { function initializeEmailList() {
var emailItems = document.querySelectorAll('.email-item'); var emailItems =
document.querySelectorAll('.email-item');
emailItems.forEach(function (item) { emailItems.forEach(function (item) {
var summary = item.querySelector('.email-summary'); var summary =
var downloadButton = item.querySelector('.download-icon'); item.querySelector('.email-summary');
var downloadButton =
item.querySelector('.download-icon');
if (summary) { if (summary) {
summary.addEventListener('click', function (event) { summary.addEventListener(
if (event.target.closest('.download-icon')) { 'click',
return; function (event) {
if (
event.target.closest(
'.download-icon'
)
) {
return;
}
toggleEmail(item);
} }
);
toggleEmail(item); summary.addEventListener(
}); 'keydown',
function (event) {
if (
event.key !== 'Enter' &&
event.key !== ' '
) {
return;
}
summary.addEventListener('keydown', function (event) { if (
if ( event.target.closest(
event.key !== 'Enter' && '.download-icon'
event.key !== ' ' )
) { ) {
return; return;
}
event.preventDefault();
toggleEmail(item);
} }
);
if (event.target.closest('.download-icon')) {
return;
}
event.preventDefault();
toggleEmail(item);
});
} }
if (downloadButton) { if (downloadButton) {
downloadButton.addEventListener('click', function (event) { downloadButton.addEventListener(
event.stopPropagation(); 'click',
function (event) {
event.stopPropagation();
var id = item.dataset.id; var id = item.dataset.id;
if (!id) { if (!id) {
console.error('Email ID is missing'); console.error(
return; 'Email ID is missing'
);
return;
}
downloadEmail(
event,
id
);
} }
);
downloadEmail(event, id);
});
} }
}); });
} }
@@ -59,14 +91,26 @@ function toggleEmail(item) {
var id = item.dataset.id; var id = item.dataset.id;
if (!id) { if (!id) {
console.error('Email ID is missing'); console.error(
'Email ID is missing'
);
return; return;
} }
var summary = item.querySelector('.email-summary'); var summary =
var detail = item.querySelector('.email-detail'); item.querySelector('.email-summary');
var toggleIcon = item.querySelector('.toggle');
var iframe = item.querySelector('.email-content-frame'); var detail =
item.querySelector('.email-detail');
var toggleIcon =
item.querySelector('.toggle');
var iframe =
item.querySelector(
'.email-content-frame'
);
if (!detail || !iframe) { if (!detail || !iframe) {
console.error( console.error(
@@ -77,13 +121,16 @@ function toggleEmail(item) {
return; return;
} }
var isHidden = detail.classList.contains('hidden'); var isHidden =
detail.classList.contains('hidden');
if (isHidden) { if (isHidden) {
detail.classList.remove('hidden'); detail.classList.remove('hidden');
if (toggleIcon) { if (toggleIcon) {
toggleIcon.classList.add('rotated'); toggleIcon.classList.add(
'rotated'
);
} }
if (summary) { if (summary) {
@@ -101,7 +148,9 @@ function toggleEmail(item) {
detail.classList.add('hidden'); detail.classList.add('hidden');
if (toggleIcon) { if (toggleIcon) {
toggleIcon.classList.remove('rotated'); toggleIcon.classList.remove(
'rotated'
);
} }
if (summary) { if (summary) {
@@ -114,11 +163,19 @@ function toggleEmail(item) {
} }
function loadEmailContent(id, iframe) { function loadEmailContent(id, iframe) {
if (iframe.dataset.loaded === 'true') { if (
iframe.dataset.loaded === 'true'
) {
resizeIframe(iframe); resizeIframe(iframe);
return; return;
} }
if (
iframe.dataset.loading === 'true'
) {
return;
}
iframe.dataset.loading = 'true'; iframe.dataset.loading = 'true';
fetch( fetch(
@@ -130,6 +187,8 @@ function loadEmailContent(id, iframe) {
} }
) )
.then(function (response) { .then(function (response) {
handleUnauthorized(response);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
'HTTP error ' + 'HTTP error ' +
@@ -140,12 +199,15 @@ function loadEmailContent(id, iframe) {
return response.text(); return response.text();
}) })
.then(function (data) { .then(function (data) {
resetIframeObserver(iframe);
writeEmailToIframe( writeEmailToIframe(
iframe, iframe,
data data
); );
iframe.dataset.loaded = 'true'; iframe.dataset.loaded =
'true';
delete iframe.dataset.loading; delete iframe.dataset.loading;
@@ -154,6 +216,17 @@ function loadEmailContent(id, iframe) {
.catch(function (error) { .catch(function (error) {
delete iframe.dataset.loading; delete iframe.dataset.loading;
// При 401 уже выполняется redirect.
if (
error &&
error.message ===
'Session expired'
) {
return;
}
resetIframeObserver(iframe);
writeErrorToIframe( writeErrorToIframe(
iframe, iframe,
error.message error.message
@@ -167,71 +240,94 @@ function loadEmailContent(id, iframe) {
}); });
} }
function writeEmailToIframe(iframe, emailBody) { function handleUnauthorized(response) {
var doc = iframe.contentWindow.document; if (response.status !== 401) {
return;
}
window.location.href = '/login';
throw new Error(
'Session expired'
);
}
function writeEmailToIframe(
iframe,
emailBody
) {
var doc =
iframe.contentWindow.document;
doc.open(); doc.open();
doc.write( doc.write(
'<!DOCTYPE html>' + '<!DOCTYPE html>' +
'<html>' + '<html>' +
'<head>' + '<head>' +
'<meta charset="UTF-8">' + '<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width, initial-scale=1.0">' +
'<meta ' +
'name="viewport" ' +
'content="width=device-width, initial-scale=1.0"' +
'>' +
'<base target="_blank">' + '<base target="_blank">' +
'<style>' + '<style>' +
'html {' + 'html {' +
'margin: 0 !important;' + 'margin:0 !important;' +
'padding: 0 !important;' + 'padding:0 !important;' +
'background: white !important;' + 'background:white !important;' +
'}' + '}' +
'body {' + 'body {' +
'margin: 0 !important;' + 'margin:0 !important;' +
'padding: 15px !important;' + 'padding:15px !important;' +
'font-family: monospace !important;' + 'font-family:monospace !important;' +
'font-size: 12px !important;' + 'font-size:12px !important;' +
'line-height: 1.4 !important;' + 'line-height:1.4 !important;' +
'background: white !important;' + 'background:white !important;' +
'color: #333 !important;' + 'color:#333 !important;' +
'overflow-wrap: anywhere !important;' + 'overflow-wrap:anywhere !important;' +
'}' + '}' +
'* {' + '* {' +
'box-sizing: border-box !important;' + 'box-sizing:border-box !important;' +
'max-width: 100% !important;' + 'max-width:100% !important;' +
'}' + '}' +
'img {' + 'img {' +
'max-width: 100% !important;' + 'max-width:100% !important;' +
'height: auto !important;' + 'height:auto !important;' +
'}' + '}' +
'table {' + 'table {' +
'max-width: 100% !important;' + 'max-width:100% !important;' +
'}' + '}' +
'pre {' + 'pre {' +
'margin: 0 !important;' + 'margin:0 !important;' +
'font-family: inherit !important;' + 'font-family:inherit !important;' +
'white-space: pre-wrap !important;' + 'white-space:pre-wrap !important;' +
'overflow-wrap: anywhere !important;' + 'overflow-wrap:anywhere !important;' +
'}' + '}' +
'a {' + 'a {' +
'color: #2563eb !important;' + 'color:#2563eb !important;' +
'text-decoration: underline !important;' + 'text-decoration:underline !important;' +
'}' + '}' +
'a:visited {' + 'a:visited {' +
'color: #7c3aed !important;' + 'color:#7c3aed !important;' +
'}' + '}' +
'a:hover {' + 'a:hover {' +
'color: #1d4ed8 !important;' + 'color:#1d4ed8 !important;' +
'}' + '}' +
'</style>' + '</style>' +
@@ -239,7 +335,7 @@ function writeEmailToIframe(iframe, emailBody) {
'</head>' + '</head>' +
'<body>' + '<body>' +
emailBody + emailBody +
'</body>' + '</body>' +
'</html>' '</html>'
@@ -248,26 +344,36 @@ function writeEmailToIframe(iframe, emailBody) {
doc.close(); doc.close();
} }
function writeErrorToIframe(iframe, message) { function writeErrorToIframe(
var doc = iframe.contentWindow.document; iframe,
message
) {
var doc =
iframe.contentWindow.document;
doc.open(); doc.open();
doc.write( doc.write(
'<!DOCTYPE html>' + '<!DOCTYPE html>' +
'<html>' + '<html>' +
'<head>' + '<head>' +
'<meta charset="UTF-8">' + '<meta charset="UTF-8">' +
'</head>' + '</head>' +
'<body style="' + '<body style="' +
'padding:15px;' + 'padding:15px;' +
'font-family:monospace;' + 'font-family:monospace;' +
'font-size:12px;' + 'font-size:12px;' +
'color:#b91c1c;' + 'color:#b91c1c;' +
'">' + '">' +
'Error loading email: ' + 'Error loading email: ' +
escapeHTML(message) + escapeHTML(message) +
'</body>' + '</body>' +
'</html>' '</html>'
); );
@@ -276,68 +382,81 @@ function writeErrorToIframe(iframe, message) {
resizeIframe(iframe); resizeIframe(iframe);
} }
function resetIframeObserver(iframe) {
var observer =
iframeObservers.get(iframe);
if (observer) {
observer.disconnect();
iframeObservers.delete(
iframe
);
}
}
function resizeIframe(iframe) { function resizeIframe(iframe) {
if (!iframe || !iframe.contentWindow) { if (
!iframe ||
!iframe.contentWindow
) {
return; return;
} }
try { try {
var doc = iframe.contentWindow.document; var doc =
iframe.contentWindow.document;
if (!doc || !doc.body) { if (!doc || !doc.body) {
return; return;
} }
var height = Math.max( updateIframeHeight(
doc.body.scrollHeight, iframe,
doc.documentElement doc
? doc.documentElement.scrollHeight
: 0
); );
iframe.style.height =
Math.max(height + 20, 200) +
'px';
if ( if (
!iframe.dataset.resizeObserver && typeof ResizeObserver ===
typeof ResizeObserver !== 'undefined' 'undefined'
) { ) {
var observer = new ResizeObserver( return;
}
var existing =
iframeObservers.get(iframe);
if (existing) {
return;
}
var observer =
new ResizeObserver(
function () { function () {
try { try {
var body = var currentDoc =
iframe.contentWindow.document.body; iframe
.contentWindow
.document;
var html = updateIframeHeight(
iframe.contentWindow.document.documentElement; iframe,
currentDoc
var newHeight = Math.max(
body
? body.scrollHeight
: 0,
html
? html.scrollHeight
: 0
); );
iframe.style.height =
Math.max(
newHeight + 20,
200
) +
'px';
} catch (error) { } catch (error) {
// Iframe may have been replaced.
} }
} }
); );
observer.observe(doc.body); observer.observe(
doc.body
);
iframe.dataset.resizeObserver = iframeObservers.set(
'true'; iframe,
} observer
);
} catch (error) { } catch (error) {
console.warn( console.warn(
'Cannot resize email iframe:', 'Cannot resize email iframe:',
@@ -346,39 +465,173 @@ function resizeIframe(iframe) {
} }
} }
function downloadEmail(event, id) { function updateIframeHeight(
var icon = event.currentTarget; iframe,
doc
) {
if (!doc || !doc.body) {
return;
}
var bodyHeight =
doc.body.scrollHeight;
var htmlHeight =
doc.documentElement
? doc.documentElement.scrollHeight
: 0;
var height = Math.max(
bodyHeight,
htmlHeight,
180
);
iframe.style.height =
height + 20 + 'px';
}
function downloadEmail(
event,
id
) {
var button =
event.currentTarget;
if (!id) { if (!id) {
return; return;
} }
var originalTransform = var originalTransform =
icon.style.transform; button.style.transform;
var originalOpacity = var originalOpacity =
icon.style.opacity; button.style.opacity;
icon.style.transform = button.disabled = true;
button.style.transform =
'scale(0.8)'; 'scale(0.8)';
icon.style.opacity = button.style.opacity =
'0.5'; '0.5';
window.location.href = fetch(
'/api/download?id=' + '/api/download?id=' +
encodeURIComponent(id); encodeURIComponent(id),
{
method: 'GET',
credentials: 'same-origin'
}
)
.then(function (response) {
handleUnauthorized(response);
window.setTimeout( if (!response.ok) {
function () { throw new Error(
icon.style.transform = 'HTTP error ' +
response.status
);
}
return Promise.all([
response.blob(),
Promise.resolve(
getDownloadFilename(
response,
id
)
)
]);
})
.then(function (result) {
var blob = result[0];
var filename = result[1];
var objectURL =
URL.createObjectURL(blob);
var link =
document.createElement('a');
link.href = objectURL;
link.download = filename;
document.body.appendChild(
link
);
link.click();
link.remove();
window.setTimeout(
function () {
URL.revokeObjectURL(
objectURL
);
},
1000
);
})
.catch(function (error) {
if (
error &&
error.message ===
'Session expired'
) {
return;
}
console.error(
'Failed to download email:',
id,
error
);
window.alert(
'Failed to download email'
);
})
.finally(function () {
button.disabled = false;
button.style.transform =
originalTransform; originalTransform;
icon.style.opacity = button.style.opacity =
originalOpacity; originalOpacity;
}, });
150 }
);
function getDownloadFilename(
response,
id
) {
var contentDisposition =
response.headers.get(
'Content-Disposition'
);
if (contentDisposition) {
var match =
contentDisposition.match(
/filename="([^"]+)"/i
);
if (match && match[1]) {
return match[1];
}
}
var parts = id.split('/');
if (parts.length > 0) {
return parts[
parts.length - 1
];
}
return 'email.eml';
} }
function escapeHTML(value) { function escapeHTML(value) {
+40 -13
View File
@@ -19,28 +19,49 @@
</head> </head>
<body class="bg-gray-100"> <body class="bg-gray-100">
<div class="max-w-6xl mx-auto bg-white min-h-screen shadow-sm"> <div class="max-w-6xl mx-auto bg-white min-h-screen shadow-sm">
<!-- Header --> <header class="bg-gray-800 text-white px-6 py-4 border-b-4 border-red-600 flex justify-between items-center">
<header class="bg-gray-800 text-white px-6 py-4 border-b-4 border-red-600">
<h1 class="text-2xl font-normal"> <div>
Fake SMTP Server <h1 class="text-2xl font-normal">
</h1> Fake SMTP Server
</h1>
<p class="text-gray-400 text-sm mt-1">
Email storage
</p>
</div>
<form
method="post"
action="/logout"
>
<input
type="hidden"
name="csrf_token"
value="{{.CSRFToken}}"
>
<button
type="submit"
class="text-sm text-gray-300 hover:text-white px-3 py-2 border border-gray-600 hover:border-gray-400"
>
Logout
</button>
</form>
<p class="text-gray-400 text-sm mt-1">
Email storage
</p>
</header> </header>
<!-- Content -->
<main class="p-4"> <main class="p-4">
{{range .Days}} {{range .Days}}
<section class="mb-6 border border-gray-200"> <section class="mb-6 border border-gray-200">
<!-- Day header -->
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center"> <div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-base font-semibold text-gray-700"> <h2 class="text-base font-semibold text-gray-700">
{{.Date}} {{.Date}}
</h2> </h2>
@@ -48,9 +69,9 @@
<span class="bg-gray-400 text-white px-2 py-0.5 text-xs"> <span class="bg-gray-400 text-white px-2 py-0.5 text-xs">
{{len .Emails}} emails {{len .Emails}} emails
</span> </span>
</div> </div>
<!-- Emails -->
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Emails}} {{range .Emails}}
@@ -60,13 +81,13 @@
data-id="{{.ID}}" data-id="{{.ID}}"
> >
<!-- Email summary -->
<div <div
class="email-summary px-4 py-2 hover:bg-gray-50 cursor-pointer flex justify-between items-center" class="email-summary px-4 py-2 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
role="button" role="button"
tabindex="0" tabindex="0"
aria-expanded="false" aria-expanded="false"
> >
<div class="flex-1 flex flex-wrap gap-4 items-baseline"> <div class="flex-1 flex flex-wrap gap-4 items-baseline">
<div class="font-mono text-sm font-semibold text-gray-700 min-w-[180px]"> <div class="font-mono text-sm font-semibold text-gray-700 min-w-[180px]">
@@ -110,20 +131,23 @@
</span> </span>
</div> </div>
</div> </div>
<!-- Email detail -->
<div class="email-detail hidden bg-gray-50 border-t border-gray-200 p-4"> <div class="email-detail hidden bg-gray-50 border-t border-gray-200 p-4">
<div class="email-content-wrapper"> <div class="email-content-wrapper">
<iframe <iframe
class="email-content-frame" class="email-content-frame"
title="Email content" title="Email content"
sandbox="allow-same-origin allow-popups" sandbox="allow-same-origin allow-popups"
referrerpolicy="no-referrer"
srcdoc="<html><body style='margin:0;padding:15px;font-family:monospace;font-size:12px;'>Loading...</body></html>" srcdoc="<html><body style='margin:0;padding:15px;font-family:monospace;font-size:12px;'>Loading...</body></html>"
></iframe> ></iframe>
</div> </div>
</div> </div>
</article> </article>
@@ -131,6 +155,7 @@
{{end}} {{end}}
</div> </div>
</section> </section>
{{else}} {{else}}
@@ -154,8 +179,10 @@
{{end}} {{end}}
</main> </main>
</div> </div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>{{.Title}}</title>
<script src="/static/tailwind.js"></script>
<link
rel="stylesheet"
href="/static/app.css"
>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
<main class="w-full max-w-sm bg-white shadow border border-gray-200">
<header class="bg-gray-800 text-white px-6 py-4 border-b-4 border-red-600">
<h1 class="text-xl font-normal">
Fake SMTP Server
</h1>
<p class="text-gray-400 text-sm mt-1">
Sign in
</p>
</header>
<div class="p-6">
{{if .Error}}
<div class="mb-4 px-3 py-2 bg-red-50 border border-red-200 text-red-700 text-sm">
{{.Error}}
</div>
{{end}}
<form
method="post"
action="/login"
autocomplete="on"
>
<div class="mb-4">
<label
for="username"
class="block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<input
id="username"
name="username"
type="text"
required
autofocus
autocomplete="username"
class="w-full border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:border-gray-500"
>
</div>
<div class="mb-5">
<label
for="password"
class="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
name="password"
type="password"
required
autocomplete="current-password"
class="w-full border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:border-gray-500"
>
</div>
<button
type="submit"
class="w-full bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 text-sm"
>
Sign in
</button>
</form>
</div>
</main>
</body>
</html>
+1608 -297
View File
File diff suppressed because it is too large Load Diff