release: 1.1.0 - YAML config, web server, web auth and UI improvements

This commit is contained in:
2026-10-05 20:48:34 +03:00
parent 47ee7c554a
commit 1cd3cb9a2e
11 changed files with 3412 additions and 448 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Binaries and builds
/builds/
builds/
*.exe
*.exe~
*.dll
+161
View File
@@ -0,0 +1,161 @@
APP_NAME := ric930-fake-smtp
BUILD_DIR := builds
LINUX_DIR := $(BUILD_DIR)/linux
WINDOWS_DIR := $(BUILD_DIR)/windows
LINUX_BIN := $(LINUX_DIR)/$(APP_NAME)
WINDOWS_BIN := $(WINDOWS_DIR)/$(APP_NAME).exe
GO := go
BUILD_FLAGS := -trimpath
LDFLAGS := -s -w
.PHONY: all
.PHONY: build
.PHONY: build-all
.PHONY: build-linux
.PHONY: build-windows
.PHONY: clean
.PHONY: fmt
.PHONY: tidy
.PHONY: check
# ------------------------------------------------------------
# Host OS
# ------------------------------------------------------------
ifeq ($(OS),Windows_NT)
define remove_dir
@if exist "$(subst /,\,$(1))" rmdir /S /Q "$(subst /,\,$(1))"
endef
define make_dir
@if not exist "$(subst /,\,$(1))" mkdir "$(subst /,\,$(1))"
endef
define copy_file
@copy /Y "$(subst /,\,$(1))" "$(subst /,\,$(2))" >NUL
endef
define copy_dir
@if exist "$(subst /,\,$(2))" rmdir /S /Q "$(subst /,\,$(2))"
@xcopy /E /I /Y "$(subst /,\,$(1))" "$(subst /,\,$(2))" >NUL
endef
else
define remove_dir
@rm -rf "$(1)"
endef
define make_dir
@mkdir -p "$(1)"
endef
define copy_file
@cp "$(1)" "$(2)"
endef
define copy_dir
@rm -rf "$(2)"
@cp -R "$(1)" "$(2)"
endef
endif
# ------------------------------------------------------------
# Default
# ------------------------------------------------------------
all: build-all
build: build-all
build-all: build-linux build-windows
# ------------------------------------------------------------
# Linux
# ------------------------------------------------------------
build-linux:
@echo "==> Building Linux amd64"
$(call remove_dir,$(LINUX_DIR))
$(call make_dir,$(LINUX_DIR))
ifeq ($(OS),Windows_NT)
@set "CGO_ENABLED=0" && \
set "GOOS=linux" && \
set "GOARCH=amd64" && \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(LINUX_BIN))" .
else
@CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(LINUX_BIN)" .
endif
$(call copy_file,config.yaml,$(LINUX_DIR)/config.yaml)
$(call copy_dir,web,$(LINUX_DIR)/web)
@echo "==> Linux build created: $(LINUX_DIR)"
# ------------------------------------------------------------
# Windows
# ------------------------------------------------------------
build-windows:
@echo "==> Building Windows amd64"
$(call remove_dir,$(WINDOWS_DIR))
$(call make_dir,$(WINDOWS_DIR))
ifeq ($(OS),Windows_NT)
@set "CGO_ENABLED=0" && \
set "GOOS=windows" && \
set "GOARCH=amd64" && \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(WINDOWS_BIN))" .
else
@CGO_ENABLED=0 \
GOOS=windows \
GOARCH=amd64 \
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(WINDOWS_BIN)" .
endif
$(call copy_file,config.yaml,$(WINDOWS_DIR)/config.yaml)
$(call copy_dir,web,$(WINDOWS_DIR)/web)
@echo "==> Windows build created: $(WINDOWS_DIR)"
# ------------------------------------------------------------
# Maintenance
# ------------------------------------------------------------
clean:
@echo "==> Cleaning builds"
$(call remove_dir,$(BUILD_DIR))
fmt:
@echo "==> Formatting Go sources"
@$(GO) fmt ./...
tidy:
@echo "==> Updating Go modules"
@$(GO) mod tidy
check:
@echo "==> Checking project"
@$(GO) vet ./...
@$(GO) test ./...
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
SMTP SMTPConfig `yaml:"smtp"`
Web WebConfig `yaml:"web"`
}
type SMTPConfig struct {
ListenAddress string `yaml:"listen_address"`
ListenPort int `yaml:"listen_port"`
StorageDir string `yaml:"storage_dir"`
DomainName string `yaml:"domain_name"`
MaxMessageSize int64 `yaml:"max_message_size"`
EnableAuth bool `yaml:"enable_auth"`
}
type WebConfig struct {
Enabled bool `yaml:"enabled"`
Port int `yaml:"port"`
Directory string `yaml:"directory"`
BasicAuth BasicAuthConfig `yaml:"basic_auth"`
}
type BasicAuthConfig struct {
Enabled bool `yaml:"enabled"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
func getExecutableDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(exe), nil
}
func defaultConfig() *Config {
return &Config{
SMTP: SMTPConfig{
ListenAddress: "0.0.0.0",
ListenPort: 1025,
StorageDir: "emails",
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) {
if !filepath.IsAbs(cfg.SMTP.StorageDir) {
cfg.SMTP.StorageDir = filepath.Join(exeDir, cfg.SMTP.StorageDir)
}
if !filepath.IsAbs(cfg.Web.Directory) {
cfg.Web.Directory = filepath.Join(exeDir, cfg.Web.Directory)
}
}
func validateConfig(cfg *Config) error {
if cfg.SMTP.ListenPort < 1 || cfg.SMTP.ListenPort > 65535 {
return fmt.Errorf("invalid smtp.listen_port: %d", cfg.SMTP.ListenPort)
}
if cfg.SMTP.StorageDir == "" {
return fmt.Errorf("smtp.storage_dir must not be empty")
}
if cfg.SMTP.DomainName == "" {
return fmt.Errorf("smtp.domain_name must not be empty")
}
if cfg.SMTP.MaxMessageSize <= 0 {
return fmt.Errorf("smtp.max_message_size must be greater than zero")
}
if cfg.Web.Enabled {
if cfg.Web.Port < 1 || cfg.Web.Port > 65535 {
return fmt.Errorf("invalid web.port: %d", cfg.Web.Port)
}
if cfg.Web.Directory == "" {
return fmt.Errorf("web.directory must not be empty")
}
if cfg.Web.BasicAuth.Enabled {
if cfg.Web.BasicAuth.Username == "" {
return fmt.Errorf("web.basic_auth.username must not be empty")
}
if cfg.Web.BasicAuth.Password == "" {
return fmt.Errorf("web.basic_auth.password must not be empty")
}
}
}
return nil
}
func LoadConfig(configPath string) (*Config, error) {
exeDir, err := getExecutableDir()
if err != nil {
log.Printf(
"Warning: cannot get executable directory: %v, using current directory",
err,
)
exeDir = "."
}
finalConfigPath := configPath
if !filepath.IsAbs(finalConfigPath) {
finalConfigPath = filepath.Join(exeDir, finalConfigPath)
}
data, err := os.ReadFile(finalConfigPath)
if err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read config: %w", err)
}
cfg := defaultConfig()
cfgData, marshalErr := yaml.Marshal(cfg)
if marshalErr != nil {
return nil, fmt.Errorf(
"failed to create default config data: %w",
marshalErr,
)
}
if writeErr := os.WriteFile(finalConfigPath, cfgData, 0600); writeErr != nil {
return nil, fmt.Errorf(
"failed to create default config: %w",
writeErr,
)
}
log.Printf("Created default config at %s", finalConfigPath)
log.Printf(
"WARNING: change the default web Basic Auth password in %s",
finalConfigPath,
)
resolveConfigPaths(cfg, exeDir)
if err := validateConfig(cfg); err != nil {
return nil, err
}
return cfg, nil
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf(
"failed to parse YAML config %s: %w",
finalConfigPath,
err,
)
}
if err := validateConfig(&cfg); err != nil {
return nil, fmt.Errorf(
"invalid config %s: %w",
finalConfigPath,
err,
)
}
resolveConfigPaths(&cfg, exeDir)
return &cfg, nil
}
+17
View File
@@ -0,0 +1,17 @@
smtp:
listen_address: 0.0.0.0
listen_port: 1025
storage_dir: emails
domain_name: m.ric930.ru
max_message_size: 52428800
enable_auth: false
web:
enabled: true
port: 8089
directory: web
basic_auth:
enabled: true
username: admin
password: admin
+119 -447
View File
@@ -1,485 +1,132 @@
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/kardianos/service"
)
type Config struct {
ListenAddress string `json:"listen_address"`
ListenPort int `json:"listen_port"`
StorageDir string `json:"storage_dir"`
DomainName string `json:"domain_name"`
MaxMessageSize int64 `json:"max_message_size"`
EnableAuth bool `json:"enable_auth"`
}
type SMTPState struct {
heloName string
from string
to []string
authenticated bool
mailFromReceived bool
authType string
authStep int // for LOGIN: 0=waiting username, 1=waiting password
}
type FakeSMTPServer struct {
config Config
logger service.Logger
}
type Program struct {
server *FakeSMTPServer
smtpServer *SMTPServer
webServer *WebServer
}
func getExecutableDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(exe), nil
}
func loadConfig(configPath string) (Config, error) {
var cfg Config
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
cfg = Config{
ListenAddress: "0.0.0.0",
ListenPort: 25,
StorageDir: "emails",
DomainName: "m.ric930.ru",
MaxMessageSize: 50 * 1024 * 1024, // 50MB
EnableAuth: true,
}
cfgData, _ := json.MarshalIndent(cfg, "", " ")
os.WriteFile(configPath, cfgData, 0644)
log.Printf("Created default config at %s", configPath)
return cfg, nil
}
return cfg, err
}
err = json.Unmarshal(data, &cfg)
return cfg, err
}
func (s *FakeSMTPServer) ensureStorageDir() error {
return os.MkdirAll(s.config.StorageDir, 0755)
}
func (s *FakeSMTPServer) getEmailFilePath(from string, to []string, receivedTime time.Time) (string, error) {
yearMonthDay := receivedTime.Format("2006-01-02")
dir := filepath.Join(s.config.StorageDir, yearMonthDay)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
func (p *Program) Start(
s service.Service,
) error {
if err := p.smtpServer.Start(); err != nil {
return fmt.Errorf(
"failed to start SMTP server: %w",
err,
)
}
fromClean := strings.ReplaceAll(from, "<", "")
fromClean = strings.ReplaceAll(fromClean, ">", "")
fromClean = strings.ReplaceAll(fromClean, "@", "_")
if len(fromClean) > 30 {
fromClean = fromClean[:30]
}
timestamp := receivedTime.Format("20060102_150405")
filename := fmt.Sprintf("%s_%s.eml", timestamp, fromClean)
return filepath.Join(dir, filename), nil
}
func (s *FakeSMTPServer) saveEmail(from string, to []string, data []byte, receivedTime time.Time) error {
filePath, err := s.getEmailFilePath(from, to, receivedTime)
if err != nil {
return err
}
receivedHeader := fmt.Sprintf("Received: from %s\r\n\tby %s (ric930-fake-smtp)\r\n\tfor <%s>;\r\n\t%s\r\n",
s.config.DomainName, s.config.DomainName, strings.Join(to, ","), receivedTime.Format(time.RFC1123Z))
finalData := []byte(receivedHeader + string(data))
return os.WriteFile(filePath, finalData, 0644)
}
func (s *FakeSMTPServer) sendReply(writer *bufio.Writer, code int, message string) {
response := fmt.Sprintf("%d %s\r\n", code, message)
writer.WriteString(response)
writer.Flush()
s.logger.Infof("S: %d %s", code, message)
}
func parseAddress(arg string) (string, error) {
start := strings.Index(arg, ":")
if start == -1 {
return "", fmt.Errorf("invalid address format")
}
addr := strings.TrimSpace(arg[start+1:])
addr = strings.Trim(addr, "<>")
if !strings.Contains(addr, "@") {
return "", fmt.Errorf("invalid email address")
}
return addr, nil
}
func (s *FakeSMTPServer) handleConnection(conn net.Conn) {
defer conn.Close()
state := &SMTPState{
to: make([]string, 0),
authenticated: !s.config.EnableAuth,
mailFromReceived: false,
authStep: 0,
}
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
s.sendReply(writer, 220, fmt.Sprintf("%s ESMTP ric930-fake-smtp", s.config.DomainName))
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
s.logger.Warningf("Read error: %v", err)
}
break
if err := p.webServer.Start(); err != nil {
if stopErr := p.smtpServer.Stop(); stopErr != nil {
return errors.Join(
fmt.Errorf(
"failed to start web server: %w",
err,
),
fmt.Errorf(
"failed to stop SMTP server after web startup failure: %w",
stopErr,
),
)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
continue
}
s.logger.Infof("C: %s", line)
var cmd string
var arg string
if idx := strings.Index(line, " "); idx != -1 {
cmd = strings.ToUpper(line[:idx])
arg = strings.TrimSpace(line[idx+1:])
} else {
cmd = strings.ToUpper(line)
arg = ""
}
switch cmd {
case "EHLO":
if state.heloName != "" {
s.sendReply(writer, 503, "Bad sequence")
continue
}
state.heloName = arg
fmt.Fprintf(writer, "250-%s Hello %s\r\n", s.config.DomainName, arg)
fmt.Fprintf(writer, "250-PIPELINING\r\n")
fmt.Fprintf(writer, "250-SIZE %d\r\n", s.config.MaxMessageSize)
if s.config.EnableAuth {
// libcurl ожидает именно AUTH PLAIN LOGIN
fmt.Fprintf(writer, "250-AUTH PLAIN LOGIN\r\n")
}
fmt.Fprintf(writer, "250-8BITMIME\r\n")
fmt.Fprintf(writer, "250 SMTPUTF8\r\n")
writer.Flush()
case "HELO":
if state.heloName != "" {
s.sendReply(writer, 503, "Bad sequence")
continue
}
state.heloName = arg
s.sendReply(writer, 250, fmt.Sprintf("%s Hello %s", s.config.DomainName, arg))
case "AUTH":
if !s.config.EnableAuth {
s.logger.Infof("AUTH ignored because enable_auth=false")
state.authenticated = true
s.sendReply(writer, 235, "Authentication successful (bypass)")
continue
}
parts := strings.SplitN(arg, " ", 2)
method := strings.ToUpper(parts[0])
switch method {
case "PLAIN":
var authResp string
if len(parts) == 2 {
authResp = parts[1]
} else {
s.sendReply(writer, 334, "")
respLine, err := reader.ReadString('\n')
if err != nil {
s.sendReply(writer, 501, "Auth failed")
continue
}
authResp = strings.TrimSpace(respLine)
s.logger.Infof("C: %s", authResp)
}
decoded, err := base64.StdEncoding.DecodeString(authResp)
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
authParts := strings.Split(string(decoded), "\x00")
if len(authParts) >= 3 {
username := authParts[1]
password := authParts[2]
s.logger.Infof("AUTH PLAIN - Username: %s, Password: %s", username, password)
}
state.authenticated = true
s.sendReply(writer, 235, "Authentication successful")
case "LOGIN":
state.authType = "LOGIN"
state.authStep = 0
s.sendReply(writer, 334, "VXNlcm5hbWU6") // "Username:" in base64
usernameLine, err := reader.ReadString('\n')
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
usernameLine = strings.TrimSpace(usernameLine)
s.logger.Infof("C: %s", usernameLine)
usernameBytes, err := base64.StdEncoding.DecodeString(usernameLine)
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
username := string(usernameBytes)
s.sendReply(writer, 334, "UGFzc3dvcmQ6") // "Password:" in base64
passwordLine, err := reader.ReadString('\n')
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
passwordLine = strings.TrimSpace(passwordLine)
s.logger.Infof("C: %s", passwordLine)
passwordBytes, err := base64.StdEncoding.DecodeString(passwordLine)
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
password := string(passwordBytes)
s.logger.Infof("AUTH LOGIN - Username: %s, Password: %s (accepted)", username, password)
state.authenticated = true
s.sendReply(writer, 235, "Authentication successful")
default:
s.sendReply(writer, 504, "Unsupported auth mechanism")
}
case "MAIL":
if !state.authenticated && s.config.EnableAuth {
s.sendReply(writer, 530, "Authentication required")
continue
}
if !strings.HasPrefix(strings.ToUpper(arg), "FROM:") {
s.sendReply(writer, 501, "Syntax error")
continue
}
from, err := parseAddress(arg)
if err != nil {
s.sendReply(writer, 501, "Invalid address")
continue
}
state.from = from
state.mailFromReceived = true
state.to = make([]string, 0)
s.sendReply(writer, 250, "OK")
case "RCPT":
if !state.mailFromReceived {
s.sendReply(writer, 503, "MAIL FROM required first")
continue
}
if !strings.HasPrefix(strings.ToUpper(arg), "TO:") {
s.sendReply(writer, 501, "Syntax error")
continue
}
to, err := parseAddress(arg)
if err != nil {
s.sendReply(writer, 501, "Invalid address")
continue
}
state.to = append(state.to, to)
s.sendReply(writer, 250, "OK")
case "DATA":
if !state.mailFromReceived || len(state.to) == 0 {
s.sendReply(writer, 503, "MAIL and RCPT required")
continue
}
s.sendReply(writer, 354, "Start mail input; end with <CRLF>.<CRLF>")
var messageBody strings.Builder
var dotEncountered bool
for {
line, err := reader.ReadString('\n')
if err != nil {
s.logger.Errorf("Error reading body: %v", err)
break
}
if strings.TrimRight(line, "\r\n") == "." {
dotEncountered = true
break
}
if len(line) > 0 && line[0] == '.' {
line = line[1:]
}
messageBody.WriteString(line)
}
if !dotEncountered {
s.sendReply(writer, 554, "No termination dot")
continue
}
receivedTime := time.Now()
if err := s.saveEmail(state.from, state.to, []byte(messageBody.String()), receivedTime); err != nil {
s.logger.Errorf("Save failed: %v", err)
s.sendReply(writer, 554, "Transaction failed")
} else {
s.logger.Infof("Email saved: %d bytes", messageBody.Len())
// КРИТИЧЕСКИ ВАЖНО для libcurl - отправляем 250
s.sendReply(writer, 250, "OK: message accepted")
}
state.mailFromReceived = false
state.from = ""
state.to = make([]string, 0)
case "RSET":
state.mailFromReceived = false
state.from = ""
state.to = make([]string, 0)
s.sendReply(writer, 250, "OK")
case "NOOP":
s.sendReply(writer, 250, "OK")
case "QUIT":
s.sendReply(writer, 221, "Bye")
return
default:
s.sendReply(writer, 502, "Command not implemented")
}
return fmt.Errorf(
"failed to start web server: %w",
err,
)
}
}
func (s *FakeSMTPServer) Start() error {
if err := s.ensureStorageDir(); err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
s.logger.Infof("SMTP server listening on %s", addr)
s.logger.Infof("Storage: %s", s.config.StorageDir)
s.logger.Infof("Auth enabled: %v", s.config.EnableAuth)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
s.logger.Errorf("Accept error: %v", err)
continue
}
go s.handleConnection(conn)
}
}()
return nil
}
func (p *Program) Start(s service.Service) error {
go p.server.Start()
return nil
}
func (p *Program) Stop(
s service.Service,
) error {
var errs []error
func (p *Program) Stop(s service.Service) error {
return nil
if p.webServer != nil {
if err := p.webServer.Stop(); err != nil {
errs = append(
errs,
fmt.Errorf(
"failed to stop web server: %w",
err,
),
)
}
}
if p.smtpServer != nil {
if err := p.smtpServer.Stop(); err != nil {
errs = append(
errs,
fmt.Errorf(
"failed to stop SMTP server: %w",
err,
),
)
}
}
return errors.Join(errs...)
}
func main() {
exeDir, err := getExecutableDir()
if err != nil {
log.Printf("Warning: cannot get executable directory: %v, using current directory", err)
exeDir = "."
}
configPath := flag.String(
"config",
"config.yaml",
"Path to YAML config file",
)
defaultConfigPath := filepath.Join(exeDir, "config.json")
configPath := flag.String("config", defaultConfigPath, "Path to config file")
flag.Parse()
finalConfigPath := *configPath
if !filepath.IsAbs(finalConfigPath) {
finalConfigPath = filepath.Join(exeDir, finalConfigPath)
}
cfg, err := loadConfig(finalConfigPath)
cfg, err := LoadConfig(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
log.Fatalf(
"Failed to load config: %v",
err,
)
}
if !filepath.IsAbs(cfg.StorageDir) {
cfg.StorageDir = filepath.Join(exeDir, cfg.StorageDir)
}
smtpServer := NewSMTPServer(cfg)
webServer := NewWebServer(
cfg,
smtpServer,
)
svcConfig := &service.Config{
Name: "ric930-fake-smtp",
Name: "ric930-fake-smtp",
DisplayName: "Ric930 Fake SMTP Server",
Description: "RIC930 Fake SMTP server for Consultant IV",
Description: "Fake SMTP server for testing with web interface",
Arguments: []string{
"-config",
*configPath,
},
}
server := &FakeSMTPServer{config: cfg}
prg := &Program{server: server}
prg := &Program{
smtpServer: smtpServer,
webServer: webServer,
}
s, err := service.New(
prg,
svcConfig,
)
s, err := service.New(prg, svcConfig)
if err != nil {
log.Fatal(err)
}
@@ -488,12 +135,37 @@ func main() {
if err != nil {
log.Fatal(err)
}
server.logger = logger
if len(os.Args) > 1 {
service.Control(s, os.Args[1])
smtpServer.logger = logger
webServer.logger = logger
// ric930-fake-smtp install
//
// ric930-fake-smtp \
// -config config.yaml \
// install
//
args := flag.Args()
if len(args) > 1 {
log.Fatalf(
"Unexpected arguments: %v",
args,
)
}
if len(args) == 1 {
if err := service.Control(
s,
args[0],
); err != nil {
log.Fatal(err)
}
return
}
s.Run()
if err := s.Run(); err != nil {
logger.Error(err)
}
}
+906
View File
@@ -0,0 +1,906 @@
package main
import (
"bufio"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/kardianos/service"
)
// SMTPState хранит состояние SMTP-сессии.
type SMTPState struct {
heloName string
from string
to []string
authenticated bool
mailFromReceived bool
authType string
authStep int
}
// SMTPServer представляет SMTP-сервер.
type SMTPServer struct {
config *Config
logger service.Logger
listener net.Listener
}
// NewSMTPServer создаёт новый SMTP-сервер.
func NewSMTPServer(cfg *Config) *SMTPServer {
return &SMTPServer{
config: cfg,
}
}
// ensureStorageDir создаёт директорию для хранения писем.
func (s *SMTPServer) ensureStorageDir() error {
return os.MkdirAll(
s.config.SMTP.StorageDir,
0755,
)
}
// getEmailFilePath генерирует путь к файлу письма.
func (s *SMTPServer) getEmailFilePath(
from string,
to []string,
receivedTime time.Time,
) (string, error) {
yearMonthDay := receivedTime.Format(
"2006-01-02",
)
dir := filepath.Join(
s.config.SMTP.StorageDir,
yearMonthDay,
)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
fromClean := strings.ReplaceAll(
from,
"<",
"",
)
fromClean = strings.ReplaceAll(
fromClean,
">",
"",
)
fromClean = strings.ReplaceAll(
fromClean,
"@",
"_",
)
if len(fromClean) > 30 {
fromClean = fromClean[:30]
}
timestamp := receivedTime.Format(
"20060102_150405.000",
)
filename := fmt.Sprintf(
"%s_%s.eml",
timestamp,
fromClean,
)
return filepath.Join(
dir,
filename,
), nil
}
// saveEmail сохраняет письмо в файл.
func (s *SMTPServer) saveEmail(
from string,
to []string,
data []byte,
receivedTime time.Time,
) error {
filePath, err := s.getEmailFilePath(
from,
to,
receivedTime,
)
if err != nil {
return err
}
receivedHeader := fmt.Sprintf(
"Received: from %s\r\n"+
"\tby %s (ric930-fake-smtp)\r\n"+
"\tfor <%s>;\r\n"+
"\t%s\r\n",
s.config.SMTP.DomainName,
s.config.SMTP.DomainName,
strings.Join(to, ","),
receivedTime.Format(time.RFC1123Z),
)
finalData := []byte(
receivedHeader + string(data),
)
return os.WriteFile(
filePath,
finalData,
0644,
)
}
// sendReply отправляет SMTP-ответ.
func (s *SMTPServer) sendReply(
writer *bufio.Writer,
code int,
message string,
) {
response := fmt.Sprintf(
"%d %s\r\n",
code,
message,
)
_, _ = writer.WriteString(response)
_ = writer.Flush()
s.logger.Infof(
"S: %d %s",
code,
message,
)
}
// parseAddress разбирает SMTP-адрес.
func parseAddress(arg string) (string, error) {
start := strings.Index(arg, ":")
if start == -1 {
return "", fmt.Errorf(
"invalid address format",
)
}
addr := strings.TrimSpace(
arg[start+1:],
)
addr = strings.Trim(
addr,
"<>",
)
if !strings.Contains(addr, "@") {
return "", fmt.Errorf(
"invalid email address",
)
}
return addr, nil
}
// handleConnection обрабатывает SMTP-соединение.
func (s *SMTPServer) handleConnection(
conn net.Conn,
) {
defer conn.Close()
state := &SMTPState{
to: make([]string, 0),
authenticated: !s.config.SMTP.EnableAuth,
mailFromReceived: false,
authStep: 0,
}
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
s.sendReply(
writer,
220,
fmt.Sprintf(
"%s ESMTP ric930-fake-smtp",
s.config.SMTP.DomainName,
),
)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
s.logger.Warningf(
"Read error: %v",
err,
)
}
break
}
line = strings.TrimRight(
line,
"\r\n",
)
if line == "" {
continue
}
s.logger.Infof(
"C: %s",
line,
)
var cmd string
var arg string
if idx := strings.Index(
line,
" ",
); idx != -1 {
cmd = strings.ToUpper(
line[:idx],
)
arg = strings.TrimSpace(
line[idx+1:],
)
} else {
cmd = strings.ToUpper(line)
arg = ""
}
switch cmd {
case "EHLO":
if state.heloName != "" {
s.sendReply(
writer,
503,
"Bad sequence",
)
continue
}
state.heloName = arg
_, _ = fmt.Fprintf(
writer,
"250-%s Hello %s\r\n",
s.config.SMTP.DomainName,
arg,
)
_, _ = fmt.Fprintf(
writer,
"250-PIPELINING\r\n",
)
_, _ = fmt.Fprintf(
writer,
"250-SIZE %d\r\n",
s.config.SMTP.MaxMessageSize,
)
if s.config.SMTP.EnableAuth {
_, _ = fmt.Fprintf(
writer,
"250-AUTH PLAIN LOGIN\r\n",
)
}
_, _ = fmt.Fprintf(
writer,
"250-8BITMIME\r\n",
)
_, _ = fmt.Fprintf(
writer,
"250 SMTPUTF8\r\n",
)
_ = writer.Flush()
case "HELO":
if state.heloName != "" {
s.sendReply(
writer,
503,
"Bad sequence",
)
continue
}
state.heloName = arg
s.sendReply(
writer,
250,
fmt.Sprintf(
"%s Hello %s",
s.config.SMTP.DomainName,
arg,
),
)
case "AUTH":
if !s.config.SMTP.EnableAuth {
s.logger.Infof(
"AUTH ignored because enable_auth=false",
)
state.authenticated = true
s.sendReply(
writer,
235,
"Authentication successful (bypass)",
)
continue
}
parts := strings.SplitN(
arg,
" ",
2,
)
if len(parts) == 0 {
s.sendReply(
writer,
504,
"Unsupported auth mechanism",
)
continue
}
method := strings.ToUpper(
parts[0],
)
switch method {
case "PLAIN":
var authResp string
if len(parts) == 2 {
authResp = parts[1]
} else {
s.sendReply(
writer,
334,
"",
)
respLine, err :=
reader.ReadString('\n')
if err != nil {
s.sendReply(
writer,
501,
"Auth failed",
)
continue
}
authResp =
strings.TrimSpace(
respLine,
)
s.logger.Infof(
"C: %s",
authResp,
)
}
decoded, err :=
base64.StdEncoding.DecodeString(
authResp,
)
if err != nil {
s.sendReply(
writer,
535,
"Auth failed",
)
continue
}
authParts := strings.Split(
string(decoded),
"\x00",
)
if len(authParts) >= 3 {
username := authParts[1]
password := authParts[2]
s.logger.Infof(
"AUTH PLAIN - Username: %s, Password: %s (accepted)",
username,
password,
)
}
state.authenticated = true
s.sendReply(
writer,
235,
"Authentication successful",
)
case "LOGIN":
s.sendReply(
writer,
334,
"VXNlcm5hbWU6",
)
usernameLine, err :=
reader.ReadString('\n')
if err != nil {
s.sendReply(
writer,
535,
"Auth failed",
)
continue
}
usernameLine =
strings.TrimSpace(
usernameLine,
)
s.logger.Infof(
"C: %s",
usernameLine,
)
usernameBytes, err :=
base64.StdEncoding.DecodeString(
usernameLine,
)
if err != nil {
s.sendReply(
writer,
535,
"Auth failed",
)
continue
}
username := string(
usernameBytes,
)
s.sendReply(
writer,
334,
"UGFzc3dvcmQ6",
)
passwordLine, err :=
reader.ReadString('\n')
if err != nil {
s.sendReply(
writer,
535,
"Auth failed",
)
continue
}
passwordLine =
strings.TrimSpace(
passwordLine,
)
s.logger.Infof(
"C: %s",
passwordLine,
)
passwordBytes, err :=
base64.StdEncoding.DecodeString(
passwordLine,
)
if err != nil {
s.sendReply(
writer,
535,
"Auth failed",
)
continue
}
password := string(
passwordBytes,
)
s.logger.Infof(
"AUTH LOGIN - Username: %s, Password: %s (accepted)",
username,
password,
)
state.authenticated = true
s.sendReply(
writer,
235,
"Authentication successful",
)
default:
s.sendReply(
writer,
504,
"Unsupported auth mechanism",
)
}
case "MAIL":
if s.config.SMTP.EnableAuth &&
!state.authenticated {
s.sendReply(
writer,
530,
"Authentication required",
)
continue
}
if !strings.HasPrefix(
strings.ToUpper(arg),
"FROM:",
) {
s.sendReply(
writer,
501,
"Syntax error",
)
continue
}
from, err := parseAddress(arg)
if err != nil {
s.sendReply(
writer,
501,
"Invalid address",
)
continue
}
state.from = from
state.mailFromReceived = true
state.to = make([]string, 0)
s.sendReply(
writer,
250,
"OK",
)
case "RCPT":
if !state.mailFromReceived {
s.sendReply(
writer,
503,
"MAIL FROM required first",
)
continue
}
if !strings.HasPrefix(
strings.ToUpper(arg),
"TO:",
) {
s.sendReply(
writer,
501,
"Syntax error",
)
continue
}
to, err := parseAddress(arg)
if err != nil {
s.sendReply(
writer,
501,
"Invalid address",
)
continue
}
state.to = append(
state.to,
to,
)
s.sendReply(
writer,
250,
"OK",
)
case "DATA":
if !state.mailFromReceived ||
len(state.to) == 0 {
s.sendReply(
writer,
503,
"MAIL and RCPT required",
)
continue
}
s.sendReply(
writer,
354,
"Start mail input; end with <CRLF>.<CRLF>",
)
var messageBody strings.Builder
var dotEncountered bool
for {
line, err :=
reader.ReadString('\n')
if err != nil {
s.logger.Errorf(
"Error reading body: %v",
err,
)
break
}
if strings.TrimRight(
line,
"\r\n",
) == "." {
dotEncountered = true
break
}
// SMTP dot-stuffing.
if len(line) > 0 &&
line[0] == '.' {
line = line[1:]
}
messageBody.WriteString(line)
}
if !dotEncountered {
s.sendReply(
writer,
554,
"No termination dot",
)
continue
}
receivedTime := time.Now()
err := s.saveEmail(
state.from,
state.to,
[]byte(messageBody.String()),
receivedTime,
)
if err != nil {
s.logger.Errorf(
"Save failed: %v",
err,
)
s.sendReply(
writer,
554,
"Transaction failed",
)
} else {
s.logger.Infof(
"Email saved: %d bytes",
messageBody.Len(),
)
s.sendReply(
writer,
250,
"OK: message accepted",
)
}
state.mailFromReceived = false
state.from = ""
state.to = make([]string, 0)
case "RSET":
state.mailFromReceived = false
state.from = ""
state.to = make([]string, 0)
s.sendReply(
writer,
250,
"OK",
)
case "NOOP":
s.sendReply(
writer,
250,
"OK",
)
case "QUIT":
s.sendReply(
writer,
221,
"Bye",
)
return
default:
s.sendReply(
writer,
502,
"Command not implemented",
)
}
}
}
// Start запускает SMTP-сервер.
func (s *SMTPServer) Start() error {
if err := s.ensureStorageDir(); err != nil {
return fmt.Errorf(
"failed to prepare storage directory: %w",
err,
)
}
addr := fmt.Sprintf(
"%s:%d",
s.config.SMTP.ListenAddress,
s.config.SMTP.ListenPort,
)
listener, err := net.Listen(
"tcp",
addr,
)
if err != nil {
return fmt.Errorf(
"failed to listen on %s: %w",
addr,
err,
)
}
s.listener = listener
s.logger.Infof(
"SMTP server listening on %s",
addr,
)
s.logger.Infof(
"Storage: %s",
s.config.SMTP.StorageDir,
)
s.logger.Infof(
"SMTP Auth enabled: %v",
s.config.SMTP.EnableAuth,
)
go func(listener net.Listener) {
for {
conn, err := listener.Accept()
if err != nil {
// Нормальный выход после Stop().
if errors.Is(
err,
net.ErrClosed,
) {
return
}
s.logger.Errorf(
"SMTP accept error: %v",
err,
)
continue
}
go s.handleConnection(conn)
}
}(listener)
return nil
}
// Stop прекращает приём новых SMTP-соединений.
func (s *SMTPServer) Stop() error {
if s.listener == nil {
return nil
}
s.logger.Info(
"Stopping SMTP server...",
)
listener := s.listener
s.listener = nil
if err := listener.Close(); err != nil &&
!errors.Is(err, net.ErrClosed) {
return fmt.Errorf(
"failed to stop SMTP server: %w",
err,
)
}
return nil
}
// ListEmails возвращает список сохранённых писем.
func (s *SMTPServer) ListEmails() (
[]DayEmails,
error,
) {
return listEmails(
s.config.SMTP.StorageDir,
)
}
+64
View File
@@ -0,0 +1,64 @@
.email-summary {
outline: none;
}
.email-summary:focus-visible {
outline: 2px solid #9ca3af;
outline-offset: -2px;
}
.email-content-wrapper {
position: relative;
width: 100%;
}
.email-content-frame {
display: block;
width: 100%;
min-height: 200px;
border: none;
background: white;
}
.toggle {
display: inline-block;
transition: transform 0.2s ease;
}
.toggle.rotated {
transform: rotate(180deg);
}
.download-icon {
display: inline-block;
padding: 0;
margin: 0;
border: none;
background: transparent;
cursor: pointer;
line-height: 1;
transition:
transform 0.1s ease,
opacity 0.1s ease;
}
.download-icon:hover {
opacity: 0.8;
}
.download-icon:active {
transform: scale(0.85);
opacity: 0.6;
}
.download-icon:focus-visible {
outline: 2px solid #9ca3af;
outline-offset: 2px;
}
+391
View File
@@ -0,0 +1,391 @@
'use strict';
document.addEventListener('DOMContentLoaded', function () {
initializeEmailList();
});
function initializeEmailList() {
var emailItems = document.querySelectorAll('.email-item');
emailItems.forEach(function (item) {
var summary = item.querySelector('.email-summary');
var downloadButton = item.querySelector('.download-icon');
if (summary) {
summary.addEventListener('click', function (event) {
if (event.target.closest('.download-icon')) {
return;
}
toggleEmail(item);
});
summary.addEventListener('keydown', function (event) {
if (
event.key !== 'Enter' &&
event.key !== ' '
) {
return;
}
if (event.target.closest('.download-icon')) {
return;
}
event.preventDefault();
toggleEmail(item);
});
}
if (downloadButton) {
downloadButton.addEventListener('click', function (event) {
event.stopPropagation();
var id = item.dataset.id;
if (!id) {
console.error('Email ID is missing');
return;
}
downloadEmail(event, id);
});
}
});
}
function toggleEmail(item) {
var id = item.dataset.id;
if (!id) {
console.error('Email ID is missing');
return;
}
var summary = item.querySelector('.email-summary');
var detail = item.querySelector('.email-detail');
var toggleIcon = item.querySelector('.toggle');
var iframe = item.querySelector('.email-content-frame');
if (!detail || !iframe) {
console.error(
'Email UI elements are missing for:',
id
);
return;
}
var isHidden = detail.classList.contains('hidden');
if (isHidden) {
detail.classList.remove('hidden');
if (toggleIcon) {
toggleIcon.classList.add('rotated');
}
if (summary) {
summary.setAttribute(
'aria-expanded',
'true'
);
}
loadEmailContent(
id,
iframe
);
} else {
detail.classList.add('hidden');
if (toggleIcon) {
toggleIcon.classList.remove('rotated');
}
if (summary) {
summary.setAttribute(
'aria-expanded',
'false'
);
}
}
}
function loadEmailContent(id, iframe) {
if (iframe.dataset.loaded === 'true') {
resizeIframe(iframe);
return;
}
iframe.dataset.loading = 'true';
fetch(
'/api/view?id=' +
encodeURIComponent(id),
{
method: 'GET',
credentials: 'same-origin'
}
)
.then(function (response) {
if (!response.ok) {
throw new Error(
'HTTP error ' +
response.status
);
}
return response.text();
})
.then(function (data) {
writeEmailToIframe(
iframe,
data
);
iframe.dataset.loaded = 'true';
delete iframe.dataset.loading;
resizeIframe(iframe);
})
.catch(function (error) {
delete iframe.dataset.loading;
writeErrorToIframe(
iframe,
error.message
);
console.error(
'Failed to load email:',
id,
error
);
});
}
function writeEmailToIframe(iframe, emailBody) {
var doc = iframe.contentWindow.document;
doc.open();
doc.write(
'<!DOCTYPE html>' +
'<html>' +
'<head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width, initial-scale=1.0">' +
'<base target="_blank">' +
'<style>' +
'html {' +
'margin: 0 !important;' +
'padding: 0 !important;' +
'background: white !important;' +
'}' +
'body {' +
'margin: 0 !important;' +
'padding: 15px !important;' +
'font-family: monospace !important;' +
'font-size: 12px !important;' +
'line-height: 1.4 !important;' +
'background: white !important;' +
'color: #333 !important;' +
'overflow-wrap: anywhere !important;' +
'}' +
'* {' +
'box-sizing: border-box !important;' +
'max-width: 100% !important;' +
'}' +
'img {' +
'max-width: 100% !important;' +
'height: auto !important;' +
'}' +
'table {' +
'max-width: 100% !important;' +
'}' +
'pre {' +
'margin: 0 !important;' +
'font-family: inherit !important;' +
'white-space: pre-wrap !important;' +
'overflow-wrap: anywhere !important;' +
'}' +
'a {' +
'color: #2563eb !important;' +
'text-decoration: underline !important;' +
'}' +
'a:visited {' +
'color: #7c3aed !important;' +
'}' +
'a:hover {' +
'color: #1d4ed8 !important;' +
'}' +
'</style>' +
'</head>' +
'<body>' +
emailBody +
'</body>' +
'</html>'
);
doc.close();
}
function writeErrorToIframe(iframe, message) {
var doc = iframe.contentWindow.document;
doc.open();
doc.write(
'<!DOCTYPE html>' +
'<html>' +
'<head>' +
'<meta charset="UTF-8">' +
'</head>' +
'<body style="' +
'padding:15px;' +
'font-family:monospace;' +
'font-size:12px;' +
'color:#b91c1c;' +
'">' +
'Error loading email: ' +
escapeHTML(message) +
'</body>' +
'</html>'
);
doc.close();
resizeIframe(iframe);
}
function resizeIframe(iframe) {
if (!iframe || !iframe.contentWindow) {
return;
}
try {
var doc = iframe.contentWindow.document;
if (!doc || !doc.body) {
return;
}
var height = Math.max(
doc.body.scrollHeight,
doc.documentElement
? doc.documentElement.scrollHeight
: 0
);
iframe.style.height =
Math.max(height + 20, 200) +
'px';
if (
!iframe.dataset.resizeObserver &&
typeof ResizeObserver !== 'undefined'
) {
var observer = new ResizeObserver(
function () {
try {
var body =
iframe.contentWindow.document.body;
var html =
iframe.contentWindow.document.documentElement;
var newHeight = Math.max(
body
? body.scrollHeight
: 0,
html
? html.scrollHeight
: 0
);
iframe.style.height =
Math.max(
newHeight + 20,
200
) +
'px';
} catch (error) {
}
}
);
observer.observe(doc.body);
iframe.dataset.resizeObserver =
'true';
}
} catch (error) {
console.warn(
'Cannot resize email iframe:',
error
);
}
}
function downloadEmail(event, id) {
var icon = event.currentTarget;
if (!id) {
return;
}
var originalTransform =
icon.style.transform;
var originalOpacity =
icon.style.opacity;
icon.style.transform =
'scale(0.8)';
icon.style.opacity =
'0.5';
window.location.href =
'/api/download?id=' +
encodeURIComponent(id);
window.setTimeout(
function () {
icon.style.transform =
originalTransform;
icon.style.opacity =
originalOpacity;
},
150
);
}
function escapeHTML(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
+161
View File
@@ -0,0 +1,161 @@
<!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">
<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">
<h1 class="text-2xl font-normal">
Fake SMTP Server
</h1>
<p class="text-gray-400 text-sm mt-1">
Email storage
</p>
</header>
<!-- Content -->
<main class="p-4">
{{range .Days}}
<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">
<h2 class="text-base font-semibold text-gray-700">
{{.Date}}
</h2>
<span class="bg-gray-400 text-white px-2 py-0.5 text-xs">
{{len .Emails}} emails
</span>
</div>
<!-- Emails -->
<div class="divide-y divide-gray-100">
{{range .Emails}}
<article
class="email-item"
data-id="{{.ID}}"
>
<!-- Email summary -->
<div
class="email-summary px-4 py-2 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
role="button"
tabindex="0"
aria-expanded="false"
>
<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]">
{{.From}}
</div>
<div class="font-mono text-xs text-gray-400 min-w-[180px]">
→ {{.To}}
</div>
<div class="text-sm text-gray-600 flex-1">
{{.Subject}}
</div>
</div>
<div class="flex items-center gap-3 text-gray-400 text-xs">
<span class="font-mono">
{{.ModTime.Format "15:04:05"}}
</span>
<span class="font-mono">
{{.Size}} bytes
</span>
<button
type="button"
class="download-icon text-base cursor-pointer hover:text-gray-600"
title="Download EML"
aria-label="Download email"
>
💾
</button>
<span
class="toggle text-xs"
aria-hidden="true"
>
▼
</span>
</div>
</div>
<!-- Email detail -->
<div class="email-detail hidden bg-gray-50 border-t border-gray-200 p-4">
<div class="email-content-wrapper">
<iframe
class="email-content-frame"
title="Email content"
sandbox="allow-same-origin allow-popups"
srcdoc="<html><body style='margin:0;padding:15px;font-family:monospace;font-size:12px;'>Loading...</body></html>"
></iframe>
</div>
</div>
</article>
{{end}}
</div>
</section>
{{else}}
<div class="text-center py-16 text-gray-400">
<div class="text-4xl mb-3">
📭
</div>
<h3 class="text-lg text-gray-500 mb-2">
No emails
</h3>
<p class="text-sm">
No emails have been saved yet
</p>
</div>
{{end}}
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+83
View File
File diff suppressed because one or more lines are too long
+1314
View File
File diff suppressed because it is too large Load Diff