release: 1.1.0 - YAML config, web server, web auth and UI improvements
This commit is contained in:
+906
@@ -0,0 +1,906 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user