This commit is contained in:
Nei-Xin committed 2025-10-18 15:32:52 +08:00
commit 715c9c83b3
12 files changed
+3747

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# HTTP服务器地址# Temp Mail 配置文件
HTTP_ADDR=:8080
# HTTP 服务端口
# SMTP服务器地址HTTP_ADDR=:8080
SMTP_ADDR=:25
# SMTP 接收/发送服务端口(标准邮件端口)
# 域名(可以是IP地址或域名)SMTP_ADDR=:25
DOMAIN=101.44.160.108
# 邮件域名(部署时修改为实际域名或IP)
# 消息保留时间(秒)# 示例: mail.example.com 或 101.44.160.108
MESSAGE_TTL=3600DOMAIN=mail.example.com
# 时区# 邮件保留时间
TZ=Asia/ShanghaiMESSAGE_TTL=30m
# 时区
TZ=Asia/Shanghai
+67
View File
@@ -0,0 +1,67 @@
# 多阶段构建 Dockerfile - Temp Mail
# 生产环境镜像,直接监听 25 端口
# ============================================
# 阶段 1: 构建阶段
# ============================================
FROM golang:1.22-alpine AS builder
# 安装构建依赖
RUN apk add --no-cache git ca-certificates tzdata
# 设置工作目录
WORKDIR /build
# 复制 go mod 文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
COPY . .
# 编译程序(静态链接,减小体积)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" \
-o temp-mail \
./cmd/temp-mail
# ============================================
# 阶段 2: 运行阶段
# ============================================
FROM alpine:latest
# 安装运行时依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区
ENV TZ=Asia/Shanghai
# 创建工作目录
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /build/temp-mail /app/temp-mail
# 设置文件权限
RUN chmod +x /app/temp-mail
# 以 root 运行(监听 25 端口需要特权)
# USER root (默认就是 root)
# 暴露端口
EXPOSE 8080 25
# 设置默认环境变量
ENV HTTP_ADDR=:8080 \
SMTP_ADDR=:25 \
DOMAIN=tmp.local \
MESSAGE_TTL=30m
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
# 启动程序
CMD ["/app/temp-mail"]
+1237
View File
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"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"
)
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)
}
store := storage.NewMemoryStore(ttl)
// 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}
// 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)
}
}()
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...")
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
}
+37
View File
@@ -0,0 +1,37 @@
version: '3.8'
services:
temp-mail:
# 使用 Docker Hub 镜像(推荐)
image: neixin/temp-mail:v2.0
# 或本地构建
# build: .
container_name: temp-mail
restart: unless-stopped
ports:
- "8080:8080" # HTTP Web 界面
- "25:25" # SMTP 接收端口
environment:
- HTTP_ADDR=:8080
- SMTP_ADDR=:25
- DOMAIN=${DOMAIN:-101.44.160.108} # 从 .env 读取,默认使用 IP
- MESSAGE_TTL=${MESSAGE_TTL:-3600} # 邮件保留时间(秒),默认 1 小时
- TZ=${TZ:-Asia/Shanghai}
env_file:
- .env
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
default:
driver: bridge
+10
View File
@@ -0,0 +1,10 @@
module temp_mail
go 1.22.0
require (
github.com/emersion/go-smtp v0.20.2
github.com/google/uuid v1.6.0
)
require github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
+6
View File
@@ -0,0 +1,6 @@
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.20.2 h1:peX42Qnh5Q0q3vrAnRy43R/JwTnnv75AebxbkTL7Ia4=
github.com/emersion/go-smtp v0.20.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
File diff suppressed because it is too large. Load diff
+243
View File
@@ -0,0 +1,243 @@
package smtpclient
import (
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// Message 邮件消息
type Message struct {
From string // 发件人
To []string // 收件人列表
Subject string // 主题
Body string // 正文(纯文本)
HTML string // HTML正文(可选)
}
// Client SMTP客户端
type Client struct {
domain string // 本地域名
}
// NewClient 创建SMTP客户端
func NewClient(domain string) *Client {
return &Client{domain: domain}
}
// Send 发送邮件(通过查找MX记录直接发送)
func (c *Client) Send(msg Message) error {
// 验证发件人地址
if msg.From == "" {
return fmt.Errorf("发件人地址不能为空")
}
// 构建邮件内容
body := c.buildMessage(msg)
// 按收件人域名分组发送
recipientsByDomain := make(map[string][]string)
for _, to := range msg.To {
domain := extractDomain(to)
if domain == "" {
return fmt.Errorf("无效的收件人地址: %s", to)
}
recipientsByDomain[domain] = append(recipientsByDomain[domain], to)
}
// 向每个域名发送邮件
var lastErr error
for domain, recipients := range recipientsByDomain {
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 domain == "gmail.com" || domain == "googlemail.com" {
lastErr = fmt.Errorf("Gmail 拒绝,建议用 QQ/163")
} else if domain == "outlook.com" || domain == "hotmail.com" || domain == "live.com" {
lastErr = fmt.Errorf("Outlook 拒绝,建议用 QQ/163")
} else {
lastErr = fmt.Errorf("对方拒绝接收(IP 被限制)")
}
} else if strings.Contains(errMsg, "连接失败") || strings.Contains(errMsg, "timeout") {
lastErr = fmt.Errorf("连接超时,请稍后重试")
} else if strings.Contains(errMsg, "无效") {
lastErr = fmt.Errorf("收件人地址无效")
} else {
// 其他错误,提供通用提示
lastErr = fmt.Errorf("发送失败,建议用 QQ/163")
}
// 继续尝试其他域名
}
}
return lastErr
}
// sendToDomain 向指定域名发送邮件
func (c *Client) sendToDomain(domain string, from string, to []string, body string) error {
// 查找MX记录
mxRecords, err := net.LookupMX(domain)
if err != nil || len(mxRecords) == 0 {
// 如果没有MX记录,尝试使用A记录
return c.sendToHost(domain+":25", from, to, body)
}
// 尝试每个MX记录(按优先级排序)
var lastErr error
for _, mx := range mxRecords {
host := strings.TrimSuffix(mx.Host, ".")
if err := c.sendToHost(host+":25", from, to, body); err == nil {
return nil
} else {
lastErr = err // 只保留最后一个错误
}
}
return lastErr
}
// sendToHost 向指定主机发送邮件
func (c *Client) sendToHost(addr string, from string, to []string, body string) error {
// 连接到SMTP服务器
client, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("连接失败 %s: %w", addr, err)
}
defer client.Close()
// 发送HELO命令
if err = client.Hello(c.domain); err != nil {
return fmt.Errorf("HELO失败 (domain=%s): %w", c.domain, err)
}
// 尝试升级到 TLS(如果服务器支持)
if ok, _ := client.Extension("STARTTLS"); ok {
tlsConfig := &tls.Config{
ServerName: strings.Split(addr, ":")[0],
InsecureSkipVerify: false, // 验证证书
}
if err = client.StartTLS(tlsConfig); err != nil {
// TLS 失败不致命,继续尝试明文发送
// 但记录日志便于调试
// log.Printf("STARTTLS 失败,继续使用明文: %v", err)
}
}
// 设置发件人
if err = client.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM失败 (from=%s): %w", from, err)
}
// 设置收件人
for _, recipient := range to {
if err = client.Rcpt(recipient); err != nil {
return fmt.Errorf("RCPT TO失败 (to=%s): %w", recipient, err)
}
}
// 发送邮件内容
w, err := client.Data()
if err != nil {
return fmt.Errorf("DATA命令失败: %w", err)
}
_, err = w.Write([]byte(body))
if err != nil {
w.Close()
return fmt.Errorf("写入邮件内容失败: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("关闭DATA失败: %w", err)
}
// 退出
return client.Quit()
}
// extractDomain 从邮件地址中提取域名
func extractDomain(email string) string {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return ""
}
return parts[1]
}
// buildMessage 构建邮件内容
func (c *Client) buildMessage(msg Message) string {
var sb strings.Builder
// 邮件头
sb.WriteString(fmt.Sprintf("From: %s\r\n", msg.From))
sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(msg.To, ", ")))
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", encodeSubject(msg.Subject)))
sb.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z)))
sb.WriteString("MIME-Version: 1.0\r\n")
// 如果有HTML内容,使用multipart
if msg.HTML != "" {
boundary := fmt.Sprintf("boundary_%d", time.Now().Unix())
sb.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
sb.WriteString("\r\n")
// 纯文本部分
if msg.Body != "" {
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
sb.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
sb.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
sb.WriteString("\r\n")
sb.WriteString(msg.Body)
sb.WriteString("\r\n")
}
// HTML部分
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
sb.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
sb.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
sb.WriteString("\r\n")
sb.WriteString(msg.HTML)
sb.WriteString("\r\n")
sb.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
} else {
// 只有纯文本
sb.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
sb.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
sb.WriteString("\r\n")
sb.WriteString(msg.Body)
}
return sb.String()
}
// encodeSubject 编码邮件主题(支持中文)
func encodeSubject(subject string) string {
// 检查是否包含非ASCII字符
needEncode := false
for _, r := range subject {
if r > 127 {
needEncode = true
break
}
}
if !needEncode {
return subject
}
// 使用Base64编码
encoded := base64.StdEncoding.EncodeToString([]byte(subject))
return fmt.Sprintf("=?UTF-8?B?%s?=", encoded)
}
+305
View File
@@ -0,0 +1,305 @@
package smtpserver
import (
"bytes"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"mime"
"net"
stdmail "net/mail"
"strings"
"sync/atomic"
"github.com/emersion/go-smtp"
"temp_mail/internal/storage"
)
type Server struct {
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}
}
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)
}
func (s *Server) Shutdown() error {
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
}
func (b *backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
return &session{store: b.store, domain: b.domain}, nil
}
type session struct {
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
}
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
}
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
}
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
}
// 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 ""
}
// 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()
}
// 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()
}
+144
View File
@@ -0,0 +1,144 @@
package storage
import (
"sort"
"sync"
"time"
"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:"-"`
}
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()
}
type MemoryStore 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
}
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
}
func (m *MemoryStore) AddressExists(local string) bool {
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
}
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
}
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
}
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)
}
}
}
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
}
}
}
func (m *MemoryStore) Close() {
close(m.stopCh)
}
+33
View File
@@ -0,0 +1,33 @@
package storage
import (
"testing"
"time"
)
func TestMemoryStore_SaveListGet(t *testing.T) {
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") }
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) }
}
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") }
}