Files
ric930-fake-smtp/smtp_server.go
T

907 lines
13 KiB
Go

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