release: 1.1.0 - YAML config, web server, web auth and UI improvements
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Binaries and builds
|
||||
/builds/
|
||||
builds/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
@@ -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 ./...
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
+161
@@ -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>
|
||||
File diff suppressed because one or more lines are too long
+1314
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user