Files
ric930-fake-smtp/web_server.go
T

1315 lines
20 KiB
Go

package main
import (
"bytes"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"mime"
"mime/quotedprintable"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/kardianos/service"
)
type EmailFile struct {
Name string `json:"name"`
ID string `json:"id"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
}
type DayEmails struct {
Date string `json:"date"`
Emails []EmailFile `json:"emails"`
}
type cachedStaticFile struct {
Data []byte
ContentType string
}
type WebServer struct {
config *Config
smtpServer *SMTPServer
logger service.Logger
server *http.Server
template *template.Template
templateErr error
staticFiles map[string]cachedStaticFile
}
func NewWebServer(cfg *Config, smtp *SMTPServer) *WebServer {
return &WebServer{
config: cfg,
smtpServer: smtp,
staticFiles: make(map[string]cachedStaticFile),
}
}
// decodeRFC2047 декодирует строку вида:
//
// =?UTF-8?B?...?=
func decodeRFC2047(encoded string) string {
decoder := new(mime.WordDecoder)
decoded, err := decoder.Decode(encoded)
if err != nil {
// Если стандартный декодер не справился,
// пробуем обработать простой UTF-8 Base64 вручную.
if strings.Contains(encoded, "=?UTF-8?B?") {
parts := strings.Split(encoded, "?B?")
if len(parts) >= 2 {
b64part := strings.TrimSuffix(parts[1], "?=")
if decodedBytes, err := base64.StdEncoding.DecodeString(b64part); err == nil {
return string(decodedBytes)
}
}
}
return encoded
}
return decoded
}
func decodeQuotedPrintable(data string) string {
reader := quotedprintable.NewReader(
strings.NewReader(data),
)
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(reader)
return buf.String()
}
func parseEmailHeaders(content []byte) (
from string,
to string,
subject string,
date time.Time,
) {
from = "Unknown"
to = "Unknown"
subject = "No subject"
date = time.Now()
lines := strings.Split(
string(content),
"\n",
)
for _, line := range lines {
line = strings.TrimSpace(line)
lowerLine := strings.ToLower(line)
switch {
case strings.HasPrefix(lowerLine, "from:"):
raw := strings.TrimSpace(line[5:])
raw = strings.Trim(raw, "\"<> ")
from = decodeRFC2047(raw)
if from == "" {
from = "Unknown"
}
case strings.HasPrefix(lowerLine, "to:"):
raw := strings.TrimSpace(line[3:])
raw = strings.Trim(raw, "\"<> ")
to = decodeRFC2047(raw)
if to == "" {
to = "Unknown"
}
case strings.HasPrefix(lowerLine, "subject:"):
raw := strings.TrimSpace(line[8:])
subject = decodeRFC2047(raw)
if subject == "" {
subject = "No subject"
}
case strings.HasPrefix(lowerLine, "date:"):
dateStr := strings.TrimSpace(line[5:])
formats := []string{
time.RFC1123,
time.RFC1123Z,
time.RFC822,
time.RFC822Z,
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 MST",
"2 Jan 2006 15:04:05 -0700",
}
for _, format := range formats {
parsedDate, err := time.Parse(
format,
dateStr,
)
if err == nil {
date = parsedDate
break
}
}
}
}
return
}
func extractEmailBody(content []byte) string {
str := string(content)
boundary := ""
lines := strings.Split(str, "\n")
for i, line := range lines {
lowerLine := strings.ToLower(line)
if !strings.HasPrefix(
lowerLine,
"content-type:",
) {
continue
}
if idx := strings.Index(
line,
"boundary=",
); idx != -1 {
boundaryPart := line[idx+9:]
boundary = strings.Trim(
boundaryPart,
"\" \r\n",
)
break
}
if i+1 < len(lines) &&
strings.Contains(
strings.ToLower(lines[i+1]),
"boundary=",
) {
boundaryPart := lines[i+1]
if idx := strings.Index(
boundaryPart,
"boundary=",
); idx != -1 {
boundary = strings.Trim(
boundaryPart[idx+9:],
"\" \r\n",
)
}
}
}
// Multipart.
if boundary != "" {
parts := strings.Split(
str,
"--"+boundary,
)
for _, part := range parts {
if !strings.Contains(
strings.ToLower(part),
"content-type: text/plain",
) {
continue
}
body := extractMIMEPartBody(part)
if body == "" {
continue
}
if strings.Contains(
strings.ToLower(part),
"content-transfer-encoding: quoted-printable",
) {
body = decodeQuotedPrintable(body)
}
return strings.TrimSpace(body)
}
for _, part := range parts {
if !strings.Contains(
strings.ToLower(part),
"content-type: text/html",
) {
continue
}
body := extractMIMEPartBody(part)
if body == "" {
continue
}
if strings.Contains(
strings.ToLower(part),
"content-transfer-encoding: quoted-printable",
) {
body = decodeQuotedPrintable(body)
}
return strings.TrimSpace(body)
}
}
// Обычное письмо без multipart.
body := extractMIMEPartBody(str)
if body != "" {
if strings.Contains(
strings.ToLower(str),
"content-transfer-encoding: quoted-printable",
) {
body = decodeQuotedPrintable(body)
}
return strings.TrimSpace(body)
}
return "No content"
}
func extractMIMEPartBody(part string) string {
if idx := strings.Index(
part,
"\r\n\r\n",
); idx != -1 {
body := part[idx+4:]
return strings.TrimSuffix(body, "--")
}
if idx := strings.Index(
part,
"\n\n",
); idx != -1 {
body := part[idx+2:]
return strings.TrimSuffix(body, "--")
}
return ""
}
func listEmails(storageDir string) ([]DayEmails, error) {
days := make([]DayEmails, 0)
entries, err := os.ReadDir(storageDir)
if err != nil {
if os.IsNotExist(err) {
return days, nil
}
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, err := time.Parse(
"2006-01-02",
entry.Name(),
); err != nil {
continue
}
dayPath := filepath.Join(
storageDir,
entry.Name(),
)
files, err := os.ReadDir(dayPath)
if err != nil {
continue
}
emails := make([]EmailFile, 0)
for _, file := range files {
if file.IsDir() {
continue
}
if !strings.EqualFold(
filepath.Ext(file.Name()),
".eml",
) {
continue
}
filePath := filepath.Join(
dayPath,
file.Name(),
)
info, err := file.Info()
if err != nil {
continue
}
content, err := os.ReadFile(filePath)
if err != nil {
continue
}
from, to, subject, date :=
parseEmailHeaders(content)
emailID := filepath.ToSlash(
filepath.Join(
entry.Name(),
file.Name(),
),
)
emails = append(
emails,
EmailFile{
Name: file.Name(),
ID: emailID,
Size: info.Size(),
ModTime: info.ModTime(),
From: from,
To: to,
Subject: subject,
Date: date,
},
)
}
sort.Slice(
emails,
func(i, j int) bool {
return emails[i].ModTime.After(
emails[j].ModTime,
)
},
)
if len(emails) > 0 {
days = append(
days,
DayEmails{
Date: entry.Name(),
Emails: emails,
},
)
}
}
sort.Slice(
days,
func(i, j int) bool {
return days[i].Date > days[j].Date
},
)
return days, nil
}
func resolveEmailPath(
storageDir string,
id string,
) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("empty email id")
}
if strings.Contains(id, "\\") {
return "", fmt.Errorf("invalid email id")
}
parts := strings.Split(id, "/")
// Формат строго:
// YYYY-MM-DD/file.eml
if len(parts) != 2 {
return "", fmt.Errorf("invalid email id")
}
day := parts[0]
filename := parts[1]
if _, err := time.Parse(
"2006-01-02",
day,
); err != nil {
return "", fmt.Errorf(
"invalid email date directory",
)
}
if filename == "" ||
filename == "." ||
filename == ".." ||
filepath.Base(filename) != filename {
return "", fmt.Errorf(
"invalid email filename",
)
}
if !strings.EqualFold(
filepath.Ext(filename),
".eml",
) {
return "", fmt.Errorf(
"invalid email file type",
)
}
storageAbs, err := filepath.Abs(storageDir)
if err != nil {
return "", fmt.Errorf(
"cannot resolve storage directory: %w",
err,
)
}
candidate := filepath.Join(
storageAbs,
day,
filename,
)
candidateAbs, err := filepath.Abs(candidate)
if err != nil {
return "", fmt.Errorf(
"cannot resolve email path: %w",
err,
)
}
rel, err := filepath.Rel(
storageAbs,
candidateAbs,
)
if err != nil {
return "", fmt.Errorf(
"cannot validate email path: %w",
err,
)
}
if rel == ".." ||
strings.HasPrefix(
rel,
".."+string(os.PathSeparator),
) ||
filepath.IsAbs(rel) {
return "", fmt.Errorf(
"email path escapes storage directory",
)
}
info, err := os.Stat(candidateAbs)
if err != nil {
if os.IsNotExist(err) {
return "", os.ErrNotExist
}
return "", fmt.Errorf(
"cannot access email file: %w",
err,
)
}
if info.IsDir() {
return "", fmt.Errorf(
"email id points to a directory",
)
}
return candidateAbs, nil
}
func (w *WebServer) loadWebAssets() {
w.staticFiles = make(
map[string]cachedStaticFile,
)
webDir := w.config.Web.Directory
tmplFuncs := template.FuncMap{
"safeID": func(s string) string {
reg := regexp.MustCompile(
`[^a-zA-Z0-9_-]`,
)
return reg.ReplaceAllString(
s,
"_",
)
},
}
// index.html
templatePath := filepath.Join(
webDir,
"index.html",
)
templateData, err := os.ReadFile(templatePath)
if err != nil {
w.template = nil
w.templateErr = fmt.Errorf(
"web template not found: %s: %w",
templatePath,
err,
)
w.logger.Errorf(
"%v",
w.templateErr,
)
} else {
tmpl, err := template.New(
"index.html",
).
Funcs(tmplFuncs).
Parse(string(templateData))
if err != nil {
w.template = nil
w.templateErr = fmt.Errorf(
"failed to parse web template %s: %w",
templatePath,
err,
)
w.logger.Errorf(
"%v",
w.templateErr,
)
} else {
w.template = tmpl
w.templateErr = nil
w.logger.Infof(
"Web template loaded: %s (%d bytes)",
templatePath,
len(templateData),
)
}
}
assets := []struct {
Name string
ContentType string
}{
{
Name: "app.css",
ContentType: "text/css; charset=utf-8",
},
{
Name: "app.js",
ContentType: "application/javascript; charset=utf-8",
},
{
Name: "tailwind.js",
ContentType: "application/javascript; charset=utf-8",
},
}
for _, asset := range assets {
filePath := filepath.Join(
webDir,
asset.Name,
)
data, err := os.ReadFile(filePath)
if err != nil {
w.logger.Warningf(
"Cannot load web asset %s: %v",
filePath,
err,
)
continue
}
urlPath := "/static/" + asset.Name
w.staticFiles[urlPath] =
cachedStaticFile{
Data: data,
ContentType: asset.ContentType,
}
w.logger.Infof(
"Web asset loaded: %s (%d bytes)",
filePath,
len(data),
)
}
}
func (w *WebServer) handleStatic(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodGet &&
r.Method != http.MethodHead {
wr.Header().Set(
"Allow",
"GET, HEAD",
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
file, ok := w.staticFiles[r.URL.Path]
if !ok {
http.NotFound(wr, r)
return
}
wr.Header().Set(
"Content-Type",
file.ContentType,
)
if r.Method == http.MethodHead {
return
}
if _, err := wr.Write(file.Data); err != nil {
w.logger.Errorf(
"Failed to send static file %s: %v",
r.URL.Path,
err,
)
}
}
func constantTimeStringEqual(
a string,
b string,
) bool {
aHash := sha256.Sum256([]byte(a))
bHash := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(
aHash[:],
bHash[:],
) == 1
}
func (w *WebServer) basicAuth(
next http.Handler,
) http.Handler {
return http.HandlerFunc(
func(
wr http.ResponseWriter,
r *http.Request,
) {
authConfig :=
w.config.Web.BasicAuth
if !authConfig.Enabled {
next.ServeHTTP(wr, r)
return
}
username, password, ok :=
r.BasicAuth()
if !ok ||
!constantTimeStringEqual(
username,
authConfig.Username,
) ||
!constantTimeStringEqual(
password,
authConfig.Password,
) {
wr.Header().Set(
"WWW-Authenticate",
`Basic realm="ric930-fake-smtp", charset="UTF-8"`,
)
http.Error(
wr,
"Unauthorized",
http.StatusUnauthorized,
)
w.logger.Warningf(
"Unauthorized web access from %s to %s",
r.RemoteAddr,
r.URL.Path,
)
return
}
next.ServeHTTP(wr, r)
},
)
}
func (w *WebServer) readEmailByID(
id string,
) ([]byte, string, error) {
filePath, err := resolveEmailPath(
w.config.SMTP.StorageDir,
id,
)
if err != nil {
return nil, "", err
}
content, err := os.ReadFile(filePath)
if err != nil {
return nil, "", err
}
return content, filePath, nil
}
func (w *WebServer) handleEmailError(
wr http.ResponseWriter,
r *http.Request,
id string,
err error,
) {
if errors.Is(err, os.ErrNotExist) {
http.NotFound(wr, r)
return
}
if strings.Contains(
err.Error(),
"invalid email",
) ||
strings.Contains(
err.Error(),
"escapes storage directory",
) ||
strings.Contains(
err.Error(),
"empty email id",
) {
w.logger.Warningf(
"Rejected email id %q from %s: %v",
id,
r.RemoteAddr,
err,
)
http.Error(
wr,
"Invalid email id",
http.StatusBadRequest,
)
return
}
w.logger.Errorf(
"Error accessing email %q: %v",
id,
err,
)
http.Error(
wr,
"Error reading email",
http.StatusInternalServerError,
)
}
func (w *WebServer) handleIndex(
wr http.ResponseWriter,
r *http.Request,
) {
if r.URL.Path != "/" {
http.NotFound(wr, r)
return
}
if r.Method != http.MethodGet &&
r.Method != http.MethodHead {
wr.Header().Set(
"Allow",
"GET, HEAD",
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
if w.template == nil {
message := "Web template is unavailable"
if w.templateErr != nil {
message = w.templateErr.Error()
}
http.Error(
wr,
message,
http.StatusInternalServerError,
)
return
}
days, err := listEmails(
w.config.SMTP.StorageDir,
)
if err != nil {
w.logger.Errorf(
"Error listing emails: %v",
err,
)
http.Error(
wr,
"Error listing emails",
http.StatusInternalServerError,
)
return
}
data := struct {
Days []DayEmails
Title string
}{
Days: days,
Title: "Fake SMTP Server - Emails",
}
wr.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
if r.Method == http.MethodHead {
return
}
if err := w.template.Execute(
wr,
data,
); err != nil {
w.logger.Errorf(
"Template execution error: %v",
err,
)
}
}
func (w *WebServer) handleEmailsAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
days, err := listEmails(
w.config.SMTP.StorageDir,
)
if err != nil {
w.logger.Errorf(
"Error listing emails: %v",
err,
)
wr.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
wr.WriteHeader(
http.StatusInternalServerError,
)
_ = json.NewEncoder(wr).Encode(
map[string]string{
"error": "Error listing emails",
},
)
return
}
wr.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
if err := json.NewEncoder(wr).Encode(days); err != nil {
w.logger.Errorf(
"Failed to encode emails JSON: %v",
err,
)
}
}
func (w *WebServer) handleEmailAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
id := r.URL.Query().Get("id")
if id == "" {
http.Error(
wr,
"Missing id parameter",
http.StatusBadRequest,
)
return
}
content, _, err :=
w.readEmailByID(id)
if err != nil {
w.handleEmailError(
wr,
r,
id,
err,
)
return
}
body := extractEmailBody(content)
wr.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
if _, err := wr.Write([]byte(body)); err != nil {
w.logger.Errorf(
"Failed to send email %q: %v",
id,
err,
)
}
}
func (w *WebServer) handleViewAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
id := r.URL.Query().Get("id")
if id == "" {
http.Error(
wr,
"Missing id parameter",
http.StatusBadRequest,
)
return
}
content, _, err :=
w.readEmailByID(id)
if err != nil {
w.handleEmailError(
wr,
r,
id,
err,
)
return
}
body := extractEmailBody(content)
wr.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
if _, err := wr.Write([]byte(body)); err != nil {
w.logger.Errorf(
"Failed to send email view %q: %v",
id,
err,
)
}
}
func (w *WebServer) handleDownloadAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
id := r.URL.Query().Get("id")
if id == "" {
http.Error(
wr,
"Missing id parameter",
http.StatusBadRequest,
)
return
}
content, filePath, err :=
w.readEmailByID(id)
if err != nil {
w.handleEmailError(
wr,
r,
id,
err,
)
return
}
filename := filepath.Base(filePath)
wr.Header().Set(
"Content-Type",
"message/rfc822",
)
wr.Header().Set(
"Content-Disposition",
fmt.Sprintf(
`attachment; filename="%s"`,
filename,
),
)
wr.Header().Set(
"Content-Length",
fmt.Sprintf(
"%d",
len(content),
),
)
if _, err := wr.Write(content); err != nil {
w.logger.Errorf(
"Failed to download email %q: %v",
id,
err,
)
}
}
func (w *WebServer) Start() error {
if !w.config.Web.Enabled {
w.logger.Info(
"Web server disabled in config",
)
return nil
}
w.loadWebAssets()
mux := http.NewServeMux()
mux.HandleFunc(
"/static/",
w.handleStatic,
)
mux.HandleFunc(
"/api/emails",
w.handleEmailsAPI,
)
mux.HandleFunc(
"/api/email",
w.handleEmailAPI,
)
mux.HandleFunc(
"/api/view",
w.handleViewAPI,
)
mux.HandleFunc(
"/api/download",
w.handleDownloadAPI,
)
mux.HandleFunc(
"/",
w.handleIndex,
)
addr := fmt.Sprintf(
":%d",
w.config.Web.Port,
)
w.server = &http.Server{
Addr: addr,
Handler: w.basicAuth(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
w.logger.Infof(
"Web server listening on http://0.0.0.0:%d",
w.config.Web.Port,
)
w.logger.Infof(
"Web directory: %s",
w.config.Web.Directory,
)
w.logger.Infof(
"Web Basic Auth enabled: %v",
w.config.Web.BasicAuth.Enabled,
)
go func() {
err := w.server.ListenAndServe()
if err != nil &&
!errors.Is(
err,
http.ErrServerClosed,
) {
w.logger.Errorf(
"Web server error: %v",
err,
)
}
}()
return nil
}
func (w *WebServer) Stop() error {
if w.server == nil {
return nil
}
w.logger.Info(
"Stopping web server...",
)
return w.server.Close()
}