500 lines
12 KiB
Go
500 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
exeDir, err := getExecutableDir()
|
|
if err != nil {
|
|
log.Printf("Warning: cannot get executable directory: %v, using current directory", err)
|
|
exeDir = "."
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
if !filepath.IsAbs(cfg.StorageDir) {
|
|
cfg.StorageDir = filepath.Join(exeDir, cfg.StorageDir)
|
|
}
|
|
|
|
svcConfig := &service.Config{
|
|
Name: "ric930-fake-smtp",
|
|
DisplayName: "Ric930 Fake SMTP Server",
|
|
Description: "RIC930 Fake SMTP server for Consultant IV",
|
|
}
|
|
|
|
server := &FakeSMTPServer{config: cfg}
|
|
prg := &Program{server: server}
|
|
|
|
s, err := service.New(prg, svcConfig)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
logger, err := s.Logger(nil)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
server.logger = logger
|
|
|
|
if len(os.Args) > 1 {
|
|
service.Control(s, os.Args[1])
|
|
return
|
|
}
|
|
|
|
s.Run()
|
|
}
|