1315 lines
20 KiB
Go
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()
|
|
}
|