907 lines
13 KiB
Go
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,
|
|
)
|
|
}
|