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