update readme
This commit is contained in:
1 parent
44b635cb18
commit
244bd761c7
7 files changed
+838
-2882
No files matched your search
+57
-57
@@ -1,76 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"temp_mail/internal/httpapi"
|
||||
"temp_mail/internal/smtpclient"
|
||||
"temp_mail/internal/smtpserver"
|
||||
"temp_mail/internal/storage"
|
||||
"temp_mail/internal/httpapi"
|
||||
"temp_mail/internal/smtpclient"
|
||||
"temp_mail/internal/smtpserver"
|
||||
"temp_mail/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Config via env with defaults
|
||||
httpAddr := getenv("HTTP_ADDR", ":8080")
|
||||
smtpAddr := getenv("SMTP_ADDR", ":2525")
|
||||
domain := getenv("DOMAIN", "tmp.local")
|
||||
ttlStr := getenv("MESSAGE_TTL", "30m")
|
||||
ttl, err := time.ParseDuration(ttlStr)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid MESSAGE_TTL: %v", err)
|
||||
}
|
||||
// Config via env with defaults
|
||||
httpAddr := getenv("HTTP_ADDR", ":8080")
|
||||
smtpAddr := getenv("SMTP_ADDR", ":2525")
|
||||
domain := getenv("DOMAIN", "tmp.local")
|
||||
ttlStr := getenv("MESSAGE_TTL", "30m")
|
||||
ttl, err := time.ParseDuration(ttlStr)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid MESSAGE_TTL: %v", err)
|
||||
}
|
||||
|
||||
store := storage.NewMemoryStore(ttl)
|
||||
store := storage.NewMemoryStore(ttl)
|
||||
|
||||
// SMTP客户端配置(用于发送邮件)
|
||||
// 使用本地域名创建发送客户端
|
||||
smtpClient := smtpclient.NewClient(domain)
|
||||
log.Printf("SMTP发送客户端已启用,使用域名: %s", domain)
|
||||
// SMTP客户端配置(用于发送邮件)
|
||||
// 使用本地域名创建发送客户端
|
||||
smtpClient := smtpclient.NewClient(domain)
|
||||
log.Printf("SMTP发送客户端已启用,使用域名: %s", domain)
|
||||
|
||||
// HTTP server
|
||||
mux := httpapi.NewMux(store, domain, smtpClient)
|
||||
httpSrv := &http.Server{Addr: httpAddr, Handler: mux}
|
||||
// HTTP server
|
||||
mux := httpapi.NewMux(store, domain, smtpClient)
|
||||
httpSrv := &http.Server{Addr: httpAddr, Handler: mux}
|
||||
|
||||
// SMTP server
|
||||
smtpSrv := smtpserver.NewServer(store, domain)
|
||||
// SMTP server
|
||||
smtpSrv := smtpserver.NewServer(store, domain)
|
||||
|
||||
// Run servers
|
||||
go func() {
|
||||
log.Printf("HTTP listening on %s", httpAddr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}()
|
||||
// Run servers
|
||||
go func() {
|
||||
log.Printf("HTTP listening on %s", httpAddr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
log.Printf("SMTP listening on %s for domain %s", smtpAddr, domain)
|
||||
if err := smtpSrv.ListenAndServe(smtpAddr); err != nil {
|
||||
log.Fatalf("smtp server: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
log.Printf("SMTP listening on %s for domain %s", smtpAddr, domain)
|
||||
if err := smtpSrv.ListenAndServe(smtpAddr); err != nil {
|
||||
log.Fatalf("smtp server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("Shutting down...")
|
||||
// Graceful shutdown
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("Shutting down...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(ctx)
|
||||
_ = smtpSrv.Shutdown()
|
||||
store.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(ctx)
|
||||
_ = smtpSrv.Shutdown()
|
||||
store.Close()
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
+387
-387
@@ -1,436 +1,436 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
stdmail "net/mail"
|
||||
"strings"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
stdmail "net/mail"
|
||||
"strings"
|
||||
|
||||
"temp_mail/internal/smtpclient"
|
||||
"temp_mail/internal/storage"
|
||||
"temp_mail/internal/smtpclient"
|
||||
"temp_mail/internal/storage"
|
||||
)
|
||||
|
||||
func NewMux(store storage.Store, domain string, smtpClient *smtpclient.Client) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
// API
|
||||
mux.HandleFunc("/api/address", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
local := r.URL.Query().Get("local")
|
||||
local = sanitizeLocal(local)
|
||||
created := store.CreateAddress(local)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"address": fmt.Sprintf("%s@%s", created, domain),
|
||||
"local": created,
|
||||
"ttl": int(store.TTL().Minutes()),
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
mux := http.NewServeMux()
|
||||
// API
|
||||
mux.HandleFunc("/api/address", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
local := r.URL.Query().Get("local")
|
||||
local = sanitizeLocal(local)
|
||||
created := store.CreateAddress(local)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"address": fmt.Sprintf("%s@%s", created, domain),
|
||||
"local": created,
|
||||
"ttl": int(store.TTL().Minutes()),
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/messages/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// /api/messages/{local} or /api/messages/{local}/{id}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/messages/")
|
||||
parts := strings.Split(path, "/")
|
||||
if parts[0] == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
local := sanitizeLocal(parts[0])
|
||||
if len(parts) == 1 {
|
||||
msgs := store.List(local)
|
||||
writeJSON(w, msgs)
|
||||
return
|
||||
}
|
||||
id := parts[1]
|
||||
msg, ok := store.Get(local, id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.URL.Query().Get("format") {
|
||||
case "raw":
|
||||
w.Header().Set("Content-Type", "message/rfc822")
|
||||
_, _ = w.Write(msg.Raw)
|
||||
default:
|
||||
writeJSON(w, msg)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/messages/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// /api/messages/{local} or /api/messages/{local}/{id}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/messages/")
|
||||
parts := strings.Split(path, "/")
|
||||
if parts[0] == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
local := sanitizeLocal(parts[0])
|
||||
if len(parts) == 1 {
|
||||
msgs := store.List(local)
|
||||
writeJSON(w, msgs)
|
||||
return
|
||||
}
|
||||
id := parts[1]
|
||||
msg, ok := store.Get(local, id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.URL.Query().Get("format") {
|
||||
case "raw":
|
||||
w.Header().Set("Content-Type", "message/rfc822")
|
||||
_, _ = w.Write(msg.Raw)
|
||||
default:
|
||||
writeJSON(w, msg)
|
||||
}
|
||||
})
|
||||
|
||||
// 发送邮件API
|
||||
mux.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// 发送邮件API
|
||||
mux.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求体
|
||||
var req struct {
|
||||
From string `json:"from"` // 发件人本地部分(如 "test"),将拼接域名
|
||||
To []string `json:"to"` // 收件人列表(完整邮箱地址)
|
||||
Subject string `json:"subject"` // 邮件主题
|
||||
Body string `json:"body"` // 邮件正文
|
||||
HTML string `json:"html"` // HTML正文(可选)
|
||||
}
|
||||
// 解析请求体
|
||||
var req struct {
|
||||
From string `json:"from"` // 发件人本地部分(如 "test"),将拼接域名
|
||||
To []string `json:"to"` // 收件人列表(完整邮箱地址)
|
||||
Subject string `json:"subject"` // 邮件主题
|
||||
Body string `json:"body"` // 邮件正文
|
||||
HTML string `json:"html"` // HTML正文(可选)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "无效的请求格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "无效的请求格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.From == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "发件人不能为空(请输入您创建的邮箱名称)",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 验证必填字段
|
||||
if req.From == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "发件人不能为空(请输入您创建的邮箱名称)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.To) == 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "收件人不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(req.To) == 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "收件人不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Subject == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "主题不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Subject == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "主题不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Body == "" && req.HTML == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "邮件内容不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Body == "" && req.HTML == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "邮件内容不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证发件人邮箱是否存在
|
||||
fromLocal := sanitizeLocal(req.From)
|
||||
if fromLocal == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "发件人不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果地址不存在,自动创建
|
||||
if !store.AddressExists(fromLocal) {
|
||||
store.CreateAddress(fromLocal)
|
||||
log.Printf("自动创建发件邮箱: %s@%s", fromLocal, domain)
|
||||
}
|
||||
// 验证发件人邮箱是否存在
|
||||
fromLocal := sanitizeLocal(req.From)
|
||||
if fromLocal == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": "发件人不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构造完整的发件人地址
|
||||
fromAddr := fmt.Sprintf("%s@%s", fromLocal, domain)
|
||||
// 如果地址不存在,自动创建
|
||||
if !store.AddressExists(fromLocal) {
|
||||
store.CreateAddress(fromLocal)
|
||||
log.Printf("自动创建发件邮箱: %s@%s", fromLocal, domain)
|
||||
}
|
||||
|
||||
// 发送邮件
|
||||
msg := smtpclient.Message{
|
||||
From: fromAddr,
|
||||
To: req.To,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
HTML: req.HTML,
|
||||
}
|
||||
// 构造完整的发件人地址
|
||||
fromAddr := fmt.Sprintf("%s@%s", fromLocal, domain)
|
||||
|
||||
if err := smtpClient.Send(msg); err != nil {
|
||||
log.Printf("发送邮件失败 (from=%s): %v", fromAddr, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": fmt.Sprintf("发送失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
// 发送邮件
|
||||
msg := smtpclient.Message{
|
||||
From: fromAddr,
|
||||
To: req.To,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
HTML: req.HTML,
|
||||
}
|
||||
|
||||
log.Printf("邮件已发送: from=%s, to=%v, subject=%s", fromAddr, req.To, req.Subject)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "邮件已发送",
|
||||
"from": fromAddr,
|
||||
})
|
||||
})
|
||||
if err := smtpClient.Send(msg); err != nil {
|
||||
log.Printf("发送邮件失败 (from=%s): %v", fromAddr, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"error": fmt.Sprintf("发送失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// UI
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(indexHTML))
|
||||
})
|
||||
|
||||
// Message detail page
|
||||
mux.HandleFunc("/view/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// /view/{local}/{id}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/view/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
local := sanitizeLocal(parts[0])
|
||||
id := parts[1]
|
||||
|
||||
msg, ok := store.Get(local, id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(renderMessageDetailPage(msg, local, domain)))
|
||||
})
|
||||
|
||||
return mux
|
||||
log.Printf("邮件已发送: from=%s, to=%v, subject=%s", fromAddr, req.To, req.Subject)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "邮件已发送",
|
||||
"from": fromAddr,
|
||||
})
|
||||
})
|
||||
|
||||
// UI
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(indexHTML))
|
||||
})
|
||||
|
||||
// Message detail page
|
||||
mux.HandleFunc("/view/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// /view/{local}/{id}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/view/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
local := sanitizeLocal(parts[0])
|
||||
id := parts[1]
|
||||
|
||||
msg, ok := store.Get(local, id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(renderMessageDetailPage(msg, local, domain)))
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("writeJSON: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("writeJSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeLocal(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.Trim(s, "@ ")
|
||||
if i := strings.IndexByte(s, '@'); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.IndexByte(s, '+'); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// allow [a-z0-9-.]
|
||||
b := strings.Builder{}
|
||||
for _, ch := range s {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.' || ch == '_' {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.Trim(s, "@ ")
|
||||
if i := strings.IndexByte(s, '@'); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.IndexByte(s, '+'); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// allow [a-z0-9-.]
|
||||
b := strings.Builder{}
|
||||
for _, ch := range s {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.' || ch == '_' {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderMessageDetailPage(msg storage.Message, local, domain string) string {
|
||||
// Parse email content from raw bytes
|
||||
htmlContent, textContent, _ := parseEmailContent(msg.Raw)
|
||||
|
||||
// Choose which content to display
|
||||
bodyHTML := ""
|
||||
if htmlContent != "" {
|
||||
bodyHTML = fmt.Sprintf(`<iframe id="html-frame" srcdoc="%s" sandbox="allow-same-origin"></iframe>`, escapeHTMLAttr(htmlContent))
|
||||
} else if textContent != "" {
|
||||
bodyHTML = fmt.Sprintf(`<pre class="text-content">%s</pre>`, escapeHTML(textContent))
|
||||
} else {
|
||||
bodyHTML = `<div class="no-content">无邮件正文</div>`
|
||||
}
|
||||
|
||||
timeStr := msg.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
|
||||
return fmt.Sprintf(messageDetailTemplate,
|
||||
escapeHTML(msg.Subject),
|
||||
local,
|
||||
escapeHTML(msg.Subject),
|
||||
escapeHTML(msg.From),
|
||||
timeStr,
|
||||
bodyHTML,
|
||||
local,
|
||||
local,
|
||||
msg.ID,
|
||||
)
|
||||
// Parse email content from raw bytes
|
||||
htmlContent, textContent, _ := parseEmailContent(msg.Raw)
|
||||
|
||||
// Choose which content to display
|
||||
bodyHTML := ""
|
||||
if htmlContent != "" {
|
||||
bodyHTML = fmt.Sprintf(`<iframe id="html-frame" srcdoc="%s" sandbox="allow-same-origin"></iframe>`, escapeHTMLAttr(htmlContent))
|
||||
} else if textContent != "" {
|
||||
bodyHTML = fmt.Sprintf(`<pre class="text-content">%s</pre>`, escapeHTML(textContent))
|
||||
} else {
|
||||
bodyHTML = `<div class="no-content">无邮件正文</div>`
|
||||
}
|
||||
|
||||
timeStr := msg.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
|
||||
return fmt.Sprintf(messageDetailTemplate,
|
||||
escapeHTML(msg.Subject),
|
||||
local,
|
||||
escapeHTML(msg.Subject),
|
||||
escapeHTML(msg.From),
|
||||
timeStr,
|
||||
bodyHTML,
|
||||
local,
|
||||
local,
|
||||
msg.ID,
|
||||
)
|
||||
}
|
||||
|
||||
func parseEmailContent(raw []byte) (htmlContent, textContent string, headers map[string]string) {
|
||||
headers = make(map[string]string)
|
||||
|
||||
if len(raw) == 0 {
|
||||
return "", "", headers
|
||||
}
|
||||
|
||||
// Parse email using net/mail
|
||||
m, err := stdmail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
textContent = string(raw)
|
||||
return "", textContent, headers
|
||||
}
|
||||
|
||||
// Extract and decode headers using mime.WordDecoder
|
||||
dec := new(mime.WordDecoder)
|
||||
for k := range m.Header {
|
||||
rawValue := m.Header.Get(k)
|
||||
// Decode MIME encoded-words (=?charset?encoding?text?=)
|
||||
if decoded, err := dec.DecodeHeader(rawValue); err == nil {
|
||||
headers[k] = decoded
|
||||
} else {
|
||||
headers[k] = rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(m.Body)
|
||||
if err != nil {
|
||||
return "", "", headers
|
||||
}
|
||||
|
||||
// Check Content-Transfer-Encoding and decode body
|
||||
contentTransferEncoding := strings.ToLower(m.Header.Get("Content-Transfer-Encoding"))
|
||||
decodedBody := body
|
||||
|
||||
switch contentTransferEncoding {
|
||||
case "base64":
|
||||
if decoded, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
||||
decodedBody = decoded
|
||||
}
|
||||
case "quoted-printable":
|
||||
// Quoted-printable decoding
|
||||
decodedBody = decodeQuotedPrintable(body)
|
||||
}
|
||||
|
||||
bodyStr := string(decodedBody)
|
||||
|
||||
// Check if it's multipart
|
||||
contentType := m.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "multipart") {
|
||||
// Simple multipart parsing with encoding support
|
||||
htmlContent, textContent = parseMultipart(bodyStr, contentType)
|
||||
} else if strings.Contains(contentType, "text/html") {
|
||||
htmlContent = bodyStr
|
||||
} else {
|
||||
textContent = bodyStr
|
||||
}
|
||||
|
||||
return htmlContent, textContent, headers
|
||||
headers = make(map[string]string)
|
||||
|
||||
if len(raw) == 0 {
|
||||
return "", "", headers
|
||||
}
|
||||
|
||||
// Parse email using net/mail
|
||||
m, err := stdmail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
textContent = string(raw)
|
||||
return "", textContent, headers
|
||||
}
|
||||
|
||||
// Extract and decode headers using mime.WordDecoder
|
||||
dec := new(mime.WordDecoder)
|
||||
for k := range m.Header {
|
||||
rawValue := m.Header.Get(k)
|
||||
// Decode MIME encoded-words (=?charset?encoding?text?=)
|
||||
if decoded, err := dec.DecodeHeader(rawValue); err == nil {
|
||||
headers[k] = decoded
|
||||
} else {
|
||||
headers[k] = rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(m.Body)
|
||||
if err != nil {
|
||||
return "", "", headers
|
||||
}
|
||||
|
||||
// Check Content-Transfer-Encoding and decode body
|
||||
contentTransferEncoding := strings.ToLower(m.Header.Get("Content-Transfer-Encoding"))
|
||||
decodedBody := body
|
||||
|
||||
switch contentTransferEncoding {
|
||||
case "base64":
|
||||
if decoded, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
||||
decodedBody = decoded
|
||||
}
|
||||
case "quoted-printable":
|
||||
// Quoted-printable decoding
|
||||
decodedBody = decodeQuotedPrintable(body)
|
||||
}
|
||||
|
||||
bodyStr := string(decodedBody)
|
||||
|
||||
// Check if it's multipart
|
||||
contentType := m.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "multipart") {
|
||||
// Simple multipart parsing with encoding support
|
||||
htmlContent, textContent = parseMultipart(bodyStr, contentType)
|
||||
} else if strings.Contains(contentType, "text/html") {
|
||||
htmlContent = bodyStr
|
||||
} else {
|
||||
textContent = bodyStr
|
||||
}
|
||||
|
||||
return htmlContent, textContent, headers
|
||||
}
|
||||
|
||||
func parseMultipart(body, contentType string) (html, text string) {
|
||||
// Extract boundary
|
||||
boundary := ""
|
||||
if idx := strings.Index(contentType, "boundary="); idx >= 0 {
|
||||
boundary = contentType[idx+9:]
|
||||
boundary = strings.Trim(boundary, `"`)
|
||||
if idx2 := strings.IndexAny(boundary, "; \t\r\n"); idx2 >= 0 {
|
||||
boundary = boundary[:idx2]
|
||||
}
|
||||
}
|
||||
|
||||
if boundary == "" {
|
||||
return "", body
|
||||
}
|
||||
|
||||
parts := strings.Split(body, "--"+boundary)
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "--" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split headers and body
|
||||
splitIdx := strings.Index(part, "\r\n\r\n")
|
||||
if splitIdx < 0 {
|
||||
splitIdx = strings.Index(part, "\n\n")
|
||||
if splitIdx < 0 {
|
||||
continue
|
||||
}
|
||||
splitIdx += 2
|
||||
} else {
|
||||
splitIdx += 4
|
||||
}
|
||||
|
||||
headers := part[:splitIdx]
|
||||
content := part[splitIdx:]
|
||||
|
||||
// Decode content based on Content-Transfer-Encoding
|
||||
if strings.Contains(headers, "base64") {
|
||||
content = strings.ReplaceAll(content, "\r\n", "")
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
|
||||
content = string(decoded)
|
||||
}
|
||||
} else if strings.Contains(headers, "quoted-printable") {
|
||||
content = string(decodeQuotedPrintable([]byte(content)))
|
||||
}
|
||||
|
||||
if strings.Contains(headers, "text/html") {
|
||||
html = content
|
||||
} else if strings.Contains(headers, "text/plain") && text == "" {
|
||||
text = content
|
||||
}
|
||||
}
|
||||
|
||||
return html, text
|
||||
// Extract boundary
|
||||
boundary := ""
|
||||
if idx := strings.Index(contentType, "boundary="); idx >= 0 {
|
||||
boundary = contentType[idx+9:]
|
||||
boundary = strings.Trim(boundary, `"`)
|
||||
if idx2 := strings.IndexAny(boundary, "; \t\r\n"); idx2 >= 0 {
|
||||
boundary = boundary[:idx2]
|
||||
}
|
||||
}
|
||||
|
||||
if boundary == "" {
|
||||
return "", body
|
||||
}
|
||||
|
||||
parts := strings.Split(body, "--"+boundary)
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "--" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split headers and body
|
||||
splitIdx := strings.Index(part, "\r\n\r\n")
|
||||
if splitIdx < 0 {
|
||||
splitIdx = strings.Index(part, "\n\n")
|
||||
if splitIdx < 0 {
|
||||
continue
|
||||
}
|
||||
splitIdx += 2
|
||||
} else {
|
||||
splitIdx += 4
|
||||
}
|
||||
|
||||
headers := part[:splitIdx]
|
||||
content := part[splitIdx:]
|
||||
|
||||
// Decode content based on Content-Transfer-Encoding
|
||||
if strings.Contains(headers, "base64") {
|
||||
content = strings.ReplaceAll(content, "\r\n", "")
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
|
||||
content = string(decoded)
|
||||
}
|
||||
} else if strings.Contains(headers, "quoted-printable") {
|
||||
content = string(decodeQuotedPrintable([]byte(content)))
|
||||
}
|
||||
|
||||
if strings.Contains(headers, "text/html") {
|
||||
html = content
|
||||
} else if strings.Contains(headers, "text/plain") && text == "" {
|
||||
text = content
|
||||
}
|
||||
}
|
||||
|
||||
return html, text
|
||||
}
|
||||
|
||||
// decodeQuotedPrintable decodes quoted-printable encoded text
|
||||
func decodeQuotedPrintable(data []byte) []byte {
|
||||
var result bytes.Buffer
|
||||
i := 0
|
||||
for i < len(data) {
|
||||
if data[i] == '=' {
|
||||
if i+2 < len(data) {
|
||||
// Soft line break
|
||||
if data[i+1] == '\r' && data[i+2] == '\n' {
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if data[i+1] == '\n' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Hex encoding
|
||||
if i+2 < len(data) {
|
||||
hex := string(data[i+1:i+3])
|
||||
var b byte
|
||||
if _, err := fmt.Sscanf(hex, "%02X", &b); err == nil {
|
||||
result.WriteByte(b)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalid encoding, keep the =
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
} else {
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result.Bytes()
|
||||
var result bytes.Buffer
|
||||
i := 0
|
||||
for i < len(data) {
|
||||
if data[i] == '=' {
|
||||
if i+2 < len(data) {
|
||||
// Soft line break
|
||||
if data[i+1] == '\r' && data[i+2] == '\n' {
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if data[i+1] == '\n' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Hex encoding
|
||||
if i+2 < len(data) {
|
||||
hex := string(data[i+1 : i+3])
|
||||
var b byte
|
||||
if _, err := fmt.Sscanf(hex, "%02X", &b); err == nil {
|
||||
result.WriteByte(b)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalid encoding, keep the =
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
} else {
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result.Bytes()
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
func escapeHTMLAttr(s string) string {
|
||||
s = escapeHTML(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
return s
|
||||
s = escapeHTML(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
return s
|
||||
}
|
||||
|
||||
const indexHTML = `<!doctype html>
|
||||
|
||||
@@ -55,12 +55,12 @@ func (c *Client) Send(msg Message) error {
|
||||
if err := c.sendToDomain(domain, msg.From, recipients, body); err != nil {
|
||||
// 简化错误提示
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
|
||||
|
||||
// 检测常见的拒绝原因
|
||||
if strings.Contains(errMsg, "not authorized") ||
|
||||
strings.Contains(errMsg, "blocked") ||
|
||||
strings.Contains(errMsg, "spamhaus") ||
|
||||
strings.Contains(errMsg, "relay") {
|
||||
if strings.Contains(errMsg, "not authorized") ||
|
||||
strings.Contains(errMsg, "blocked") ||
|
||||
strings.Contains(errMsg, "spamhaus") ||
|
||||
strings.Contains(errMsg, "relay") {
|
||||
// 国际邮箱拒绝
|
||||
if domain == "gmail.com" || domain == "googlemail.com" {
|
||||
lastErr = fmt.Errorf("Gmail 拒绝,建议用 QQ/163")
|
||||
|
||||
+255
-254
@@ -1,305 +1,306 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
stdmail "net/mail"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
stdmail "net/mail"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"temp_mail/internal/storage"
|
||||
"temp_mail/internal/storage"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
srv *smtp.Server
|
||||
ln net.Listener
|
||||
domain string
|
||||
open atomic.Bool
|
||||
srv *smtp.Server
|
||||
ln net.Listener
|
||||
domain string
|
||||
open atomic.Bool
|
||||
}
|
||||
|
||||
func NewServer(store storage.Store, domain string) *Server {
|
||||
be := &backend{store: store, domain: strings.ToLower(domain)}
|
||||
s := smtp.NewServer(be)
|
||||
s.Domain = domain
|
||||
s.ReadTimeout = 0
|
||||
s.WriteTimeout = 0
|
||||
s.MaxMessageBytes = 20 * 1024 * 1024
|
||||
s.AllowInsecureAuth = true
|
||||
return &Server{srv: s, domain: domain}
|
||||
be := &backend{store: store, domain: strings.ToLower(domain)}
|
||||
s := smtp.NewServer(be)
|
||||
s.Domain = domain
|
||||
s.ReadTimeout = 0
|
||||
s.WriteTimeout = 0
|
||||
s.MaxMessageBytes = 20 * 1024 * 1024
|
||||
s.AllowInsecureAuth = true
|
||||
return &Server{srv: s, domain: domain}
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.ln = ln
|
||||
s.open.Store(true)
|
||||
return s.srv.Serve(ln)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.ln = ln
|
||||
s.open.Store(true)
|
||||
return s.srv.Serve(ln)
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown() error {
|
||||
if s.open.Swap(false) {
|
||||
if s.ln != nil {
|
||||
_ = s.ln.Close()
|
||||
}
|
||||
s.srv.Close()
|
||||
}
|
||||
return nil
|
||||
if s.open.Swap(false) {
|
||||
if s.ln != nil {
|
||||
_ = s.ln.Close()
|
||||
}
|
||||
s.srv.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type backend struct {
|
||||
store storage.Store
|
||||
domain string
|
||||
store storage.Store
|
||||
domain string
|
||||
}
|
||||
|
||||
func (b *backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||
return &session{store: b.store, domain: b.domain}, nil
|
||||
return &session{store: b.store, domain: b.domain}, nil
|
||||
}
|
||||
|
||||
type session struct {
|
||||
store storage.Store
|
||||
domain string
|
||||
from string
|
||||
rcptLocal string
|
||||
store storage.Store
|
||||
domain string
|
||||
from string
|
||||
rcptLocal string
|
||||
}
|
||||
|
||||
func (s *session) AuthPlain(username, password string) error { return nil }
|
||||
func (s *session) Mail(from string, opts *smtp.MailOptions) error {
|
||||
s.from = from
|
||||
return nil
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error {
|
||||
// Accept recipient if domain matches or if no domain provided (catch-all)
|
||||
addr, err := stdmail.ParseAddress(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.Split(addr.Address, "@")
|
||||
local := parts[0]
|
||||
if len(parts) == 2 {
|
||||
dom := strings.ToLower(parts[1])
|
||||
if s.domain != "" && dom != strings.ToLower(s.domain) {
|
||||
return fmt.Errorf("recipient domain not accepted: %s", dom)
|
||||
}
|
||||
}
|
||||
// Normalize plus addressing (local+tag)
|
||||
if i := strings.IndexByte(local, '+'); i > 0 {
|
||||
local = local[:i]
|
||||
}
|
||||
s.rcptLocal = s.store.CreateAddress(local)
|
||||
return nil
|
||||
// Accept recipient if domain matches or if no domain provided (catch-all)
|
||||
addr, err := stdmail.ParseAddress(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.Split(addr.Address, "@")
|
||||
local := parts[0]
|
||||
if len(parts) == 2 {
|
||||
dom := strings.ToLower(parts[1])
|
||||
if s.domain != "" && dom != strings.ToLower(s.domain) {
|
||||
return fmt.Errorf("recipient domain not accepted: %s", dom)
|
||||
}
|
||||
}
|
||||
// Normalize plus addressing (local+tag)
|
||||
if i := strings.IndexByte(local, '+'); i > 0 {
|
||||
local = local[:i]
|
||||
}
|
||||
s.rcptLocal = s.store.CreateAddress(local)
|
||||
return nil
|
||||
}
|
||||
func (s *session) Data(r io.Reader) error {
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := io.Copy(buf, r); err != nil {
|
||||
return err
|
||||
}
|
||||
raw := buf.Bytes()
|
||||
// Parse headers using net/mail to get From and Subject
|
||||
var subj string
|
||||
var snippet string
|
||||
from := s.from
|
||||
dec := new(mime.WordDecoder)
|
||||
|
||||
if msg, err := stdmail.ReadMessage(bytes.NewReader(raw)); err == nil {
|
||||
// Decode MIME encoded subject
|
||||
if rawSubj := msg.Header.Get("Subject"); rawSubj != "" {
|
||||
if decodedSubj, err := dec.DecodeHeader(rawSubj); err == nil {
|
||||
subj = decodedSubj
|
||||
} else {
|
||||
subj = rawSubj
|
||||
}
|
||||
}
|
||||
|
||||
// Decode MIME encoded from
|
||||
if h := msg.Header.Get("From"); h != "" {
|
||||
if decodedFrom, err := dec.DecodeHeader(h); err == nil {
|
||||
from = decodedFrom
|
||||
} else {
|
||||
from = h
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet from body with encoding support
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
contentTransferEncoding := strings.ToLower(msg.Header.Get("Content-Transfer-Encoding"))
|
||||
|
||||
if b, err := io.ReadAll(msg.Body); err == nil {
|
||||
bodyBytes := b
|
||||
bodyText := ""
|
||||
|
||||
// Handle multipart messages
|
||||
if strings.Contains(contentType, "multipart") {
|
||||
bodyText = extractTextFromMultipart(string(b), contentType)
|
||||
} else {
|
||||
// Decode body if encoded
|
||||
switch contentTransferEncoding {
|
||||
case "base64":
|
||||
bodyStr := strings.ReplaceAll(string(b), "\r\n", "")
|
||||
bodyStr = strings.ReplaceAll(bodyStr, "\n", "")
|
||||
if decoded, err := base64.StdEncoding.DecodeString(bodyStr); err == nil {
|
||||
bodyBytes = decoded
|
||||
}
|
||||
case "quoted-printable":
|
||||
bodyBytes = decodeQuotedPrintableSimple(b)
|
||||
}
|
||||
bodyText = string(bodyBytes)
|
||||
}
|
||||
|
||||
// Clean up text for snippet
|
||||
t := strings.ReplaceAll(bodyText, "\r", "")
|
||||
t = strings.ReplaceAll(t, "\n", " ")
|
||||
t = strings.TrimSpace(t)
|
||||
|
||||
// Remove HTML tags if present
|
||||
if strings.Contains(strings.ToLower(contentType), "html") {
|
||||
t = stripHTMLTags(t)
|
||||
}
|
||||
|
||||
if len(t) > 160 {
|
||||
t = t[:160] + "..."
|
||||
}
|
||||
snippet = t
|
||||
}
|
||||
}
|
||||
_, saveErr := s.store.Save(s.rcptLocal, storage.Message{
|
||||
From: from,
|
||||
Subject: subj,
|
||||
Snippet: snippet,
|
||||
Raw: raw,
|
||||
})
|
||||
return saveErr
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := io.Copy(buf, r); err != nil {
|
||||
return err
|
||||
}
|
||||
raw := buf.Bytes()
|
||||
// Parse headers using net/mail to get From and Subject
|
||||
var subj string
|
||||
var snippet string
|
||||
from := s.from
|
||||
dec := new(mime.WordDecoder)
|
||||
|
||||
if msg, err := stdmail.ReadMessage(bytes.NewReader(raw)); err == nil {
|
||||
// Decode MIME encoded subject
|
||||
if rawSubj := msg.Header.Get("Subject"); rawSubj != "" {
|
||||
if decodedSubj, err := dec.DecodeHeader(rawSubj); err == nil {
|
||||
subj = decodedSubj
|
||||
} else {
|
||||
subj = rawSubj
|
||||
}
|
||||
}
|
||||
|
||||
// Decode MIME encoded from
|
||||
if h := msg.Header.Get("From"); h != "" {
|
||||
if decodedFrom, err := dec.DecodeHeader(h); err == nil {
|
||||
from = decodedFrom
|
||||
} else {
|
||||
from = h
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet from body with encoding support
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
contentTransferEncoding := strings.ToLower(msg.Header.Get("Content-Transfer-Encoding"))
|
||||
|
||||
if b, err := io.ReadAll(msg.Body); err == nil {
|
||||
bodyBytes := b
|
||||
bodyText := ""
|
||||
|
||||
// Handle multipart messages
|
||||
if strings.Contains(contentType, "multipart") {
|
||||
bodyText = extractTextFromMultipart(string(b), contentType)
|
||||
} else {
|
||||
// Decode body if encoded
|
||||
switch contentTransferEncoding {
|
||||
case "base64":
|
||||
bodyStr := strings.ReplaceAll(string(b), "\r\n", "")
|
||||
bodyStr = strings.ReplaceAll(bodyStr, "\n", "")
|
||||
if decoded, err := base64.StdEncoding.DecodeString(bodyStr); err == nil {
|
||||
bodyBytes = decoded
|
||||
}
|
||||
case "quoted-printable":
|
||||
bodyBytes = decodeQuotedPrintableSimple(b)
|
||||
}
|
||||
bodyText = string(bodyBytes)
|
||||
}
|
||||
|
||||
// Clean up text for snippet
|
||||
t := strings.ReplaceAll(bodyText, "\r", "")
|
||||
t = strings.ReplaceAll(t, "\n", " ")
|
||||
t = strings.TrimSpace(t)
|
||||
|
||||
// Remove HTML tags if present
|
||||
if strings.Contains(strings.ToLower(contentType), "html") {
|
||||
t = stripHTMLTags(t)
|
||||
}
|
||||
|
||||
if len(t) > 160 {
|
||||
t = t[:160] + "..."
|
||||
}
|
||||
snippet = t
|
||||
}
|
||||
}
|
||||
_, saveErr := s.store.Save(s.rcptLocal, storage.Message{
|
||||
From: from,
|
||||
Subject: subj,
|
||||
Snippet: snippet,
|
||||
Raw: raw,
|
||||
})
|
||||
return saveErr
|
||||
}
|
||||
func (s *session) Reset() {}
|
||||
func (s *session) Reset() {}
|
||||
func (s *session) Logout() error { return nil }
|
||||
|
||||
// Optional: STARTTLS config placeholder (not used for local dev)
|
||||
func (s *Server) SetTLSConfig(cfg *tls.Config) {
|
||||
s.srv.TLSConfig = cfg
|
||||
s.srv.TLSConfig = cfg
|
||||
}
|
||||
|
||||
// extractTextFromMultipart extracts plain text from multipart email
|
||||
func extractTextFromMultipart(body, contentType string) string {
|
||||
// Extract boundary
|
||||
boundary := ""
|
||||
if idx := strings.Index(contentType, "boundary="); idx >= 0 {
|
||||
boundary = contentType[idx+9:]
|
||||
boundary = strings.Trim(boundary, `"`)
|
||||
if idx2 := strings.IndexAny(boundary, "; \t\r\n"); idx2 >= 0 {
|
||||
boundary = boundary[:idx2]
|
||||
}
|
||||
}
|
||||
|
||||
if boundary == "" {
|
||||
return body
|
||||
}
|
||||
|
||||
parts := strings.Split(body, "--"+boundary)
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "--" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split headers and body
|
||||
splitIdx := strings.Index(part, "\r\n\r\n")
|
||||
if splitIdx < 0 {
|
||||
splitIdx = strings.Index(part, "\n\n")
|
||||
if splitIdx < 0 {
|
||||
continue
|
||||
}
|
||||
splitIdx += 2
|
||||
} else {
|
||||
splitIdx += 4
|
||||
}
|
||||
|
||||
headers := part[:splitIdx]
|
||||
content := part[splitIdx:]
|
||||
|
||||
// Only process text/plain parts
|
||||
if !strings.Contains(headers, "text/plain") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode content based on Content-Transfer-Encoding
|
||||
if strings.Contains(headers, "base64") {
|
||||
content = strings.ReplaceAll(content, "\r\n", "")
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
|
||||
return string(decoded)
|
||||
}
|
||||
} else if strings.Contains(headers, "quoted-printable") {
|
||||
return string(decodeQuotedPrintableSimple([]byte(content)))
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
return ""
|
||||
// Extract boundary
|
||||
boundary := ""
|
||||
if idx := strings.Index(contentType, "boundary="); idx >= 0 {
|
||||
boundary = contentType[idx+9:]
|
||||
boundary = strings.Trim(boundary, `"`)
|
||||
if idx2 := strings.IndexAny(boundary, "; \t\r\n"); idx2 >= 0 {
|
||||
boundary = boundary[:idx2]
|
||||
}
|
||||
}
|
||||
|
||||
if boundary == "" {
|
||||
return body
|
||||
}
|
||||
|
||||
parts := strings.Split(body, "--"+boundary)
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "--" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split headers and body
|
||||
splitIdx := strings.Index(part, "\r\n\r\n")
|
||||
if splitIdx < 0 {
|
||||
splitIdx = strings.Index(part, "\n\n")
|
||||
if splitIdx < 0 {
|
||||
continue
|
||||
}
|
||||
splitIdx += 2
|
||||
} else {
|
||||
splitIdx += 4
|
||||
}
|
||||
|
||||
headers := part[:splitIdx]
|
||||
content := part[splitIdx:]
|
||||
|
||||
// Only process text/plain parts
|
||||
if !strings.Contains(headers, "text/plain") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode content based on Content-Transfer-Encoding
|
||||
if strings.Contains(headers, "base64") {
|
||||
content = strings.ReplaceAll(content, "\r\n", "")
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
|
||||
return string(decoded)
|
||||
}
|
||||
} else if strings.Contains(headers, "quoted-printable") {
|
||||
return string(decodeQuotedPrintableSimple([]byte(content)))
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripHTMLTags removes HTML tags from text
|
||||
func stripHTMLTags(s string) string {
|
||||
// Simple HTML tag removal
|
||||
inTag := false
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
// Simple HTML tag removal
|
||||
inTag := false
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// decodeQuotedPrintableSimple decodes quoted-printable encoded text
|
||||
func decodeQuotedPrintableSimple(data []byte) []byte {
|
||||
var result bytes.Buffer
|
||||
i := 0
|
||||
for i < len(data) {
|
||||
if data[i] == '=' {
|
||||
if i+2 < len(data) {
|
||||
// Soft line break
|
||||
if data[i+1] == '\r' && data[i+2] == '\n' {
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if data[i+1] == '\n' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Hex encoding
|
||||
if i+2 < len(data) {
|
||||
hex := string(data[i+1 : i+3])
|
||||
var b byte
|
||||
if _, err := fmt.Sscanf(hex, "%02X", &b); err == nil {
|
||||
result.WriteByte(b)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalid encoding, keep the =
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
} else {
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result.Bytes()
|
||||
var result bytes.Buffer
|
||||
i := 0
|
||||
for i < len(data) {
|
||||
if data[i] == '=' {
|
||||
if i+2 < len(data) {
|
||||
// Soft line break
|
||||
if data[i+1] == '\r' && data[i+2] == '\n' {
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if data[i+1] == '\n' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Hex encoding
|
||||
if i+2 < len(data) {
|
||||
hex := string(data[i+1 : i+3])
|
||||
var b byte
|
||||
if _, err := fmt.Sscanf(hex, "%02X", &b); err == nil {
|
||||
result.WriteByte(b)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalid encoding, keep the =
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
} else {
|
||||
result.WriteByte(data[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result.Bytes()
|
||||
}
|
||||
+101
-101
@@ -1,144 +1,144 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
Snippet string `json:"snippet"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
// Raw MIME for full fetch
|
||||
Raw []byte `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
Snippet string `json:"snippet"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
// Raw MIME for full fetch
|
||||
Raw []byte `json:"-"`
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
CreateAddress(local string) string
|
||||
AddressExists(local string) bool
|
||||
Save(addr string, msg Message) (Message, error)
|
||||
List(addr string) []Message
|
||||
Get(addr, id string) (Message, bool)
|
||||
PurgeExpired()
|
||||
TTL() time.Duration
|
||||
Close()
|
||||
CreateAddress(local string) string
|
||||
AddressExists(local string) bool
|
||||
Save(addr string, msg Message) (Message, error)
|
||||
List(addr string) []Message
|
||||
Get(addr, id string) (Message, bool)
|
||||
PurgeExpired()
|
||||
TTL() time.Duration
|
||||
Close()
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
messages map[string]map[string]Message // addr -> id -> message
|
||||
stopCh chan struct{}
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
messages map[string]map[string]Message // addr -> id -> message
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewMemoryStore(ttl time.Duration) *MemoryStore {
|
||||
ms := &MemoryStore{
|
||||
ttl: ttl,
|
||||
messages: make(map[string]map[string]Message),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go ms.gcLoop()
|
||||
return ms
|
||||
ms := &MemoryStore{
|
||||
ttl: ttl,
|
||||
messages: make(map[string]map[string]Message),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go ms.gcLoop()
|
||||
return ms
|
||||
}
|
||||
|
||||
func (m *MemoryStore) CreateAddress(local string) string {
|
||||
if local == "" {
|
||||
local = uuid.NewString()
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.messages[local]; !ok {
|
||||
m.messages[local] = make(map[string]Message)
|
||||
}
|
||||
return local
|
||||
if local == "" {
|
||||
local = uuid.NewString()
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.messages[local]; !ok {
|
||||
m.messages[local] = make(map[string]Message)
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
func (m *MemoryStore) AddressExists(local string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, exists := m.messages[local]
|
||||
return exists
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, exists := m.messages[local]
|
||||
return exists
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Save(addr string, msg Message) (Message, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.messages[addr]; !ok {
|
||||
m.messages[addr] = make(map[string]Message)
|
||||
}
|
||||
if msg.ID == "" {
|
||||
msg.ID = uuid.NewString()
|
||||
}
|
||||
now := time.Now()
|
||||
msg.CreatedAt = now
|
||||
msg.ExpiresAt = now.Add(m.ttl)
|
||||
m.messages[addr][msg.ID] = msg
|
||||
return msg, nil
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.messages[addr]; !ok {
|
||||
m.messages[addr] = make(map[string]Message)
|
||||
}
|
||||
if msg.ID == "" {
|
||||
msg.ID = uuid.NewString()
|
||||
}
|
||||
now := time.Now()
|
||||
msg.CreatedAt = now
|
||||
msg.ExpiresAt = now.Add(m.ttl)
|
||||
m.messages[addr][msg.ID] = msg
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) List(addr string) []Message {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []Message
|
||||
for _, msg := range m.messages[addr] {
|
||||
out = append(out, msg)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []Message
|
||||
for _, msg := range m.messages[addr] {
|
||||
out = append(out, msg)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Get(addr, id string) (Message, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
msgs, ok := m.messages[addr]
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
msg, ok := msgs[id]
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
return msg, true
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
msgs, ok := m.messages[addr]
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
msg, ok := msgs[id]
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
return msg, true
|
||||
}
|
||||
|
||||
func (m *MemoryStore) PurgeExpired() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now()
|
||||
for addr, msgs := range m.messages {
|
||||
for id, msg := range msgs {
|
||||
if now.After(msg.ExpiresAt) {
|
||||
delete(msgs, id)
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
delete(m.messages, addr)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now()
|
||||
for addr, msgs := range m.messages {
|
||||
for id, msg := range msgs {
|
||||
if now.After(msg.ExpiresAt) {
|
||||
delete(msgs, id)
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
delete(m.messages, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryStore) TTL() time.Duration { return m.ttl }
|
||||
|
||||
func (m *MemoryStore) gcLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.PurgeExpired()
|
||||
case <-m.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.PurgeExpired()
|
||||
case <-m.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Close() {
|
||||
close(m.stopCh)
|
||||
close(m.stopCh)
|
||||
}
|
||||
@@ -1,33 +1,45 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryStore_SaveListGet(t *testing.T) {
|
||||
ms := NewMemoryStore(500 * time.Millisecond)
|
||||
defer ms.Close()
|
||||
ms := NewMemoryStore(500 * time.Millisecond)
|
||||
defer ms.Close()
|
||||
|
||||
addr := ms.CreateAddress("test")
|
||||
saved, err := ms.Save(addr, Message{From: "a@b", Subject: "hello", Snippet: "world"})
|
||||
if err != nil { t.Fatal(err) }
|
||||
if saved.ID == "" { t.Fatal("no id") }
|
||||
addr := ms.CreateAddress("test")
|
||||
saved, err := ms.Save(addr, Message{From: "a@b", Subject: "hello", Snippet: "world"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ID == "" {
|
||||
t.Fatal("no id")
|
||||
}
|
||||
|
||||
list := ms.List(addr)
|
||||
if len(list) != 1 { t.Fatalf("want 1, got %d", len(list)) }
|
||||
list := ms.List(addr)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("want 1, got %d", len(list))
|
||||
}
|
||||
|
||||
got, ok := ms.Get(addr, saved.ID)
|
||||
if !ok { t.Fatal("not found") }
|
||||
if got.Subject != "hello" { t.Fatalf("bad subject: %s", got.Subject) }
|
||||
got, ok := ms.Get(addr, saved.ID)
|
||||
if !ok {
|
||||
t.Fatal("not found")
|
||||
}
|
||||
if got.Subject != "hello" {
|
||||
t.Fatalf("bad subject: %s", got.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStore_TTL(t *testing.T) {
|
||||
ms := NewMemoryStore(100 * time.Millisecond)
|
||||
defer ms.Close()
|
||||
addr := ms.CreateAddress("ttl")
|
||||
_, _ = ms.Save(addr, Message{Subject: "x"})
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
ms.PurgeExpired()
|
||||
if len(ms.List(addr)) != 0 { t.Fatal("expected expired") }
|
||||
ms := NewMemoryStore(100 * time.Millisecond)
|
||||
defer ms.Close()
|
||||
addr := ms.CreateAddress("ttl")
|
||||
_, _ = ms.Save(addr, Message{Subject: "x"})
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
ms.PurgeExpired()
|
||||
if len(ms.List(addr)) != 0 {
|
||||
t.Fatal("expected expired")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user