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 .") 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() }