From 244bd761c767dc10f7c66047ddc27d1fa68e3e30 Mon Sep 17 00:00:00 2001 From: Nei-Xin <128169338+Nei-Xin@users.noreply.github.com> Date: Sat, 18 Oct 2025 15:56:16 +0800 Subject: [PATCH] update readme --- README.md | 2059 +------------------------------- cmd/temp-mail/main.go | 114 +- internal/httpapi/http.go | 774 ++++++------ internal/smtpclient/client.go | 10 +- internal/smtpserver/server.go | 509 ++++---- internal/storage/store.go | 202 ++-- internal/storage/store_test.go | 52 +- 7 files changed, 838 insertions(+), 2882 deletions(-) diff --git a/README.md b/README.md index 517ebe1..acc3873 100644 --- a/README.md +++ b/README.md @@ -1,2058 +1 @@ -# Temp Mail - 临时邮箱服务# Temp Mail - 临时邮箱服务# Temp Mail - 临时邮箱服务# Temp Mail - 临时邮箱服务# Temp Mail - 临时邮箱服务 - - - -一个轻量级的临时邮箱服务,使用 Go 语言实现。支持收发邮件,提供简洁的 Web 界面。 - - - -## ✨ 特性一个轻量级的临时邮箱服务,使用 Go 语言实现。支持收发邮件,提供简洁的 Web 界面。 - - - -- 📧 SMTP 邮件接收(端口 25) - -- 📤 SMTP 邮件发送(自动查找 MX 记录 + STARTTLS 加密) - -- 🎯 标签页界面(收件箱/发送邮件切换方便)## ✨ 特性一个轻量级的临时邮箱服务,使用 Go 语言实现。支持收发邮件,提供简洁的 Web 界面。 - -- 💾 内存存储(邮件自动过期,默认 1 小时) - -- 🐳 Docker 支持(一键部署,镜像仅 24.5MB) - -- 🔧 环境变量配置(无需修改代码) - -- 📧 **SMTP 邮件接收**:监听 25 端口,接收外部邮件 - ---- - -- 📤 **SMTP 邮件发送**:使用临时邮箱地址发送邮件,自动查找 MX 记录 - -## 🚀 快速开始 - -- 🔒 **STARTTLS 支持**:支持加密连接,提高安全性## ✨ 特性一个轻量级的临时邮箱服务,使用 Go 语言实现。支持收发邮件,提供简洁的 Web 界面。一个轻量级的临时邮箱服务,使用 Go 语言实现。 - -### Docker Compose 部署(推荐) - -- 🎯 **标签页界面**:收件箱和发送邮件切换方便 - -```bash - -# 1. 克隆项目- 💾 **内存存储**:邮件自动过期(默认 1 小时) - -git clone https://github.com/Nei-Xin/temp_mail.git - -cd temp_mail- 🐳 **Docker 支持**:一键部署,镜像仅 24.5MB - - - -# 2. 修改配置(可选)- 🔧 **灵活配置**:支持 IP 或自定义域名,无需修改代码- 📧 **SMTP 邮件接收**:监听 25 端口,接收外部邮件 - -nano .env # 修改 DOMAIN 为你的域名或 IP - - - -# 3. 启动服务 - -docker-compose up -d---- 📤 **SMTP 邮件发送**:使用临时邮箱地址发送邮件,自动查找 MX 记录 - - - -# 4. 访问 Web 界面 - -http://your-server:8080 - -```## 🚀 快速开始- 🎯 **标签页界面**:收件箱和发送邮件切换方便## ✨ 特性## ✨ 特性 - - - -### Docker Run 部署 - - - -```bash### Docker Compose 部署(推荐)- 💾 **内存存储**:邮件自动过期(默认 1 小时) - -docker pull neixin/temp-mail:v2.1 - - - -docker run -d \ - - --name temp-mail \```bash- 🐳 **Docker 支持**:一键部署,镜像仅 24.5MB - - -p 8080:8080 \ - - -p 25:25 \# 1. 克隆项目 - - -e DOMAIN=your-domain.com \ - - -e MESSAGE_TTL=1h \git clone https://github.com/Nei-Xin/temp_mail.git- 🔧 **灵活配置**:支持 IP 或自定义域名,无需修改代码 - - neixin/temp-mail:v2.1 - -```cd temp_mail - - - -### 本地编译运行- 📧 **SMTP 邮件接收**:监听 25 端口,接收外部邮件- 📧 SMTP 邮件接收(端口 25) - - - -```bash# 2. 修改配置(可选) - -# 1. 安装依赖 - -go mod tidynano .env # 修改 DOMAIN 为你的域名或 IP--- - - - -# 2. 编译 - -go build -o temp-mail ./cmd/temp-mail - -# 3. 启动服务- 📤 **SMTP 邮件发送**:使用临时邮箱地址发送邮件,自动查找 MX 记录- 📤 SMTP 邮件发送(支持 Gmail、163、QQ 等) - -# 3. 运行 - -./temp-maildocker-compose up -d - - - -# 4. 访问## 🚀 快速开始 - -http://localhost:8080 - -```# 4. 访问 Web 界面 - - - ----http://your-server:8080- 🎯 **标签页界面**:收件箱和发送邮件切换方便- 🌐 Web 界面(端口 8080) - - - -## ⚙️ 配置``` - - - -通过 `.env` 文件或环境变量配置:### Docker Compose 部署(推荐) - - - -| 变量 | 默认值 | 说明 |### Docker Run 部署 - -|------|--------|------| - -| `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 |- 💾 **内存存储**:邮件自动过期(默认 1 小时)- 💾 内存存储,自动过期(默认 30 分钟) - -| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 | - -| `DOMAIN` | `localhost` | 邮件域名(支持 IP 地址) |```bash - -| `MESSAGE_TTL` | `1h` | 邮件保留时间(支持 `s`/`m`/`h`) | - -| `TZ` | `Asia/Shanghai` | 时区 |docker pull neixin/temp-mail:v2.1```bash - - - -**配置文件示例(.env):** - - - -```bashdocker run -d \# 1. 克隆项目- 🐳 **Docker 支持**:一键部署,镜像仅 24.5MB- 🐳 Docker 支持,一键部署 - -HTTP_ADDR=:8080 - -SMTP_ADDR=:25 --name temp-mail \ - -DOMAIN=mail.example.com - -MESSAGE_TTL=1h -p 8080:8080 \git clone - -TZ=Asia/Shanghai - -``` -p 25:25 \ - - - -**MESSAGE_TTL 格式示例:** -e DOMAIN=your-domain.com \cd temp_mail- 🔧 **灵活配置**:支持 IP 或自定义域名,无需修改代码- 🔧 环境变量配置,无需修改代码 - -- `30m` - 30 分钟 - -- `1h` - 1 小时 -e MESSAGE_TTL=1h \ - -- `2h` - 2 小时 - -- `180s` - 180 秒 neixin/temp-mail:v2.1 - - - ----``` - - - -## 📖 使用说明# 2. 修改配置(可选) - - - -### 接收邮件### 本地编译运行 - - - -1. 访问 Web 界面(http://your-server:8080)nano .env # 修改 DOMAIN 为你的域名或 IP - -2. 输入自定义邮箱名称,点击"创建邮箱" - -3. 复制生成的邮箱地址(如:test@your-domain.com)```bash - -4. 使用该地址接收邮件 - -5. 邮件会自动显示在"📨 收件箱"标签页中# 1. 安装依赖------ - - - -### 发送邮件go mod tidy - - - -1. 切换到"📤 发送邮件"标签页# 3. 启动服务 - -2. 填写邮件信息: - - - **发件人**:输入邮箱本地部分(如:test)# 2. 编译 - - - **收件人**:目标邮箱地址(多个用逗号分隔) - - - **主题**:邮件主题go build -o temp-mail ./cmd/temp-maildocker-compose up -d - - - **内容**:邮件正文 - -3. 点击"📤 发送"按钮 - -4. 如果发件人邮箱不存在,系统会自动创建 - -# 3. 运行 - -**工作原理**: - -- 使用临时邮箱地址作为发件人./temp-mail - -- 自动查找收件人的 MX 记录 - -- 直接连接对方邮件服务器发送(支持 STARTTLS 加密)# 4. 访问 Web 界面## 🚀 快速开始## 🚀 快速开始 - -- 无需配置第三方 SMTP 服务 - -# 4. 访问 - -**⚠️ 发送限制说明**: - -- ✅ **可以发送**:QQ、163、126、Sina 等国内邮箱(成功率 95%+)http://localhost:8080http://your-server:8080 - -- ❌ **无法发送**:Gmail、Outlook 等国际邮箱(被拒绝) - - - **原因**:这些服务商要求高信誉 IP,会拒绝云服务器 IP``` - - - **错误提示**:`Gmail 拒绝,建议用 QQ/163` - -- 💡 **建议**:主要用于测试和接收邮件,发送功能建议使用国内邮箱``` - - - ------- - - - -## 🌐 生产环境部署 - - - -### 端口要求## ⚙️ 配置 - - - -| 端口 | 用途 | 说明 |### Docker Run 部署### Docker Compose 部署(推荐)### 方式 1: Docker Compose(推荐) - -|------|------|------| - -| 25 | SMTP 邮件接收 | 接收外部邮件 |通过 `.env` 文件或环境变量配置: - -| 8080 | HTTP Web 界面 | 查看和发送邮件 | - - - -### DNS 配置(推荐) - -| 变量 | 默认值 | 说明 | - -如果使用自定义域名,建议配置以下 DNS 记录: - -|------|--------|------|```bash - -``` - -类型 名称 值 优先级| `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 | - -A mail.example.com 服务器IP - - -MX example.com mail.example.com 10| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 |docker pull neixin/temp-mail:v2.0 - -TXT example.com v=spf1 ip4:服务器IP -all - -```| `DOMAIN` | `localhost` | 邮件域名(支持 IP 地址) | - - - -**说明**:| `MESSAGE_TTL` | `1h` | 邮件保留时间(支持 `s`/`m`/`h`,如 `30m`、`2h`) |```bash```bash - -- **A 记录**:域名指向服务器 IP(必需) - -- **MX 记录**:邮件路由记录(必需)| `TZ` | `Asia/Shanghai` | 时区 | - -- **SPF 记录**:提高发送邮件可信度(推荐) - -docker run -d \ - -### 防火墙配置 - -**配置文件示例(.env):** - -```bash - -# 开放端口 --name temp-mail \# 1. 克隆项目# 1. 修改配置 - -sudo ufw allow 25/tcp - -sudo ufw allow 8080/tcp```bash - -sudo ufw enable - -```HTTP_ADDR=:8080 -p 8080:8080 \ - - - ----SMTP_ADDR=:25 - - - -## 📡 API 接口DOMAIN=mail.example.com -p 25:25 \git clone nano .env # 修改 DOMAIN 为你的域名 - - - -### 创建邮箱地址MESSAGE_TTL=1h - - - -```httpTZ=Asia/Shanghai -e DOMAIN=101.44.160.108 \ - -POST /api/address?local=test - -`````` - - - -响应: -e MESSAGE_TTL=3600 \cd temp_mail - -```json - -{--- - - "address": "test@example.com", - - "local": "test", neixin/temp-mail:v2.0 - - "ttl": 3600 - -}## 📖 使用说明 - -``` - -```# 2. 启动服务 - -### 获取邮件列表 - -### 接收邮件 - -```http - -GET /api/messages/{local} - -``` - -1. 访问 Web 界面(http://your-server:8080) - -响应: - -```json2. 输入自定义邮箱名称,点击"创建邮箱"### 本地编译运行# 2. 修改配置(可选)docker-compose up -d - -[ - - {3. 复制生成的邮箱地址(如:`test@your-domain.com`) - - "id": "abc123", - - "from": "sender@gmail.com",4. 使用该地址接收邮件 - - "to": ["test@example.com"], - - "subject": "测试邮件",5. 邮件会自动显示在"📨 收件箱"标签页中 - - "received": "2025-10-18T10:30:00Z" - - }```bashnano .env # 修改 DOMAIN 为你的域名或 IP - -] - -```### 发送邮件 - - - -### 获取邮件详情# 1. 安装依赖 - - - -```http1. 切换到"📤 发送邮件"标签页 - -GET /api/messages/{local}/{id} - -GET /api/messages/{local}/{id}?format=raw2. 填写以下信息:go mod tidy# 3. 访问 Web 界面 - -``` - - - **发件人**:输入邮箱本地部分(如:`test`) - -### 发送邮件 - - - **收件人**:目标邮箱地址(多个用逗号分隔) - -```http - -POST /api/send - **主题**:邮件主题 - -Content-Type: application/json - - - **内容**:邮件正文# 2. 编译# 3. 启动服务http://localhost:8080 - -{ - - "from": "test",3. 点击"📤 发送"按钮 - - "to": ["recipient@example.com"], - - "subject": "测试邮件",4. 如果发件人邮箱不存在,系统会自动创建go build -o temp-mail ./cmd/temp-mail - - "body": "邮件正文内容", - - "html": "

HTML内容

" - -} - -```**工作原理**:docker-compose up -d``` - - - -响应(成功):- 使用您创建的临时邮箱地址作为发件人 - -```json - -{- 系统自动查找收件人的 MX 记录# 3. 运行 - - "success": true, - - "message": "邮件已发送",- 直接连接对方邮件服务器发送邮件(支持 STARTTLS 加密) - - "from": "test@example.com" - -}- 无需配置第三方 SMTP 服务./temp-mail - -``` - - - -响应(失败): - -```json**⚠️ 发送限制说明**: - -{ - - "error": "Gmail 拒绝,建议用 QQ/163"- ✅ **可以发送**:QQ.com、163.com、126.com、Sina.com.cn 等国内邮箱(成功率 95%+) - -} - -```- ❌ **无法发送**:Gmail、Outlook、Hotmail 等国际邮箱# 4. 访问# 4. 访问 Web 界面### 方式 2: Docker Run - - - ---- - **原因**:这些服务商要求发件 IP 具有高信誉度,会拒绝云服务器 IP 的直接连接 - - - -## 🐳 Docker 镜像 - **错误提示**:`Gmail 拒绝,建议用 QQ/163` 或 `Outlook 拒绝,建议用 QQ/163`http://localhost:8080 - - - -### 镜像信息- 💡 **建议**:本服务主要用于测试和接收邮件,发送功能建议使用国内邮箱 - - - -- **镜像名称**:neixin/temp-mail```http://your-server:8080 - -- **最新版本**:v2.1 - -- **镜像大小**:~24.5MB--- - -- **基础镜像**:Alpine Linux - - - -### 拉取镜像 - -## 🌐 生产环境部署 - -```bash - -docker pull neixin/temp-mail:v2.1---``````bash - -docker pull neixin/temp-mail:latest - -```### 端口要求 - - - -### 构建镜像 - - - -```bash| 端口 | 用途 | 说明 | - -# 构建 - -docker build -t neixin/temp-mail:v2.1 .|------|------|------|## ⚙️ 配置docker run -d \ - - - -# 运行| **25** | SMTP 邮件接收 | 接收外部邮件(Gmail、Outlook 等) | - -docker run -d -p 8080:8080 -p 25:25 \ - - -e DOMAIN=your-domain.com \| **8080** | HTTP Web 界面 | 查看和发送邮件 | - - neixin/temp-mail:v2.1 - -``` - - - ----### DNS 配置(推荐)通过 `.env` 文件或环境变量配置:### Docker Run 部署 --name temp-mail \ - - - -## 🔧 开发 - - - -### 项目结构如果使用自定义域名,建议配置以下 DNS 记录以提高邮件送达率: - - - -``` - -temp_mail/ - -├── cmd/temp-mail/ # 主程序入口```| 变量 | 默认值 | 说明 | -p 8080:8080 \ - -│ └── main.go - -├── internal/类型 名称 值 优先级 - -│ ├── httpapi/ # HTTP 服务和 Web 界面 - -│ ├── smtpserver/ # SMTP 接收服务器A mail.example.com 服务器IP -|------|--------|------| - -│ ├── smtpclient/ # SMTP 发送客户端(MX 查询 + STARTTLS) - -│ └── storage/ # 内存存储MX example.com mail.example.com 10 - -├── Dockerfile # Docker 镜像构建 - -├── docker-compose.yml # Docker Compose 配置TXT example.com v=spf1 ip4:服务器IP -all -| `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 |```bash -p 25:25 \ - -├── .env # 环境变量配置 - -└── README.md # 项目文档``` - -``` - -| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 | - -### 本地开发 - -**说明**: - -```bash - -# 安装依赖- **A 记录**:域名指向服务器 IP(必需)| `DOMAIN` | `localhost` | 邮件域名(支持 IP 地址) |docker pull neixin/temp-mail:v2.0 -e DOMAIN=mail.example.com \ - -go mod tidy - -- **MX 记录**:邮件路由记录(必需) - -# 运行 - -go run ./cmd/temp-mail- **SPF 记录**:提高发送邮件的可信度,减少被拒绝的概率(推荐)| `MESSAGE_TTL` | `3600` | 邮件保留时间(秒) | - - - -# 测试- **PTR 记录**:反向解析,需要联系服务器提供商配置(推荐) - -go test ./... - -| `TZ` | `Asia/Shanghai` | 时区 | neixin/temp-mail:latest - -# 编译 - -go build -o temp-mail ./cmd/temp-mail### 防火墙配置 - -``` - - - ---- - -```bash - -## 💡 注意事项 - -# 开放端口**配置文件示例(.env):**docker run -d \``` - -1. **数据存储**:所有邮件存储在内存中,重启服务后数据会丢失 - -2. **端口 25**:监听 25 端口需要 root 权限或使用 Dockersudo ufw allow 25/tcp - -3. **防火墙**:确保开放 25 和 8080 端口 - -4. **云服务商**:部分云服务商(阿里云、腾讯云)封禁 25 端口sudo ufw allow 8080/tcp - -5. **发送限制**:Gmail/Outlook 等国际邮箱会拒绝云服务器 IP - -sudo ufw enable - ---- - -``````bash --name temp-mail \ - -## 🔒 安全建议 - - - -- 使用 Nginx 反向代理,配置 SSL/TLS - -- 限制访问来源(防火墙规则)---HTTP_ADDR=:8080 - -- 定期更新 Docker 镜像 - -- 监控日志,防止滥用 - -- 不要用于生产环境敏感数据 - -## 📡 API 接口SMTP_ADDR=:25 -p 8080:8080 \### 方式 3: 本地编译运行 - ---- - - - -## 📚 常见问题 - -### 创建邮箱地址DOMAIN=mail.example.com - -### Q1: 无法接收外部邮件? - - - -**检查**: - -- DNS MX 记录是否正确```httpMESSAGE_TTL=3600 -p 25:25 \ - -- 防火墙是否开放 25 端口 - -- 云服务商是否封禁 25 端口(阿里云、腾讯云默认封禁)POST /api/address?local=test - -- 查看日志:`docker logs temp-mail` - -```TZ=Asia/Shanghai - -### Q2: 为什么无法发送到 Gmail/Outlook? - - - -这是正常现象,不是代码问题。 - -**响应**:``` -e DOMAIN=101.44.160.108 \```bash - -**原因**: - -- Gmail、Outlook 要求发件 IP 具有高信誉度```json - -- 云服务器 IP 通常被归类为"低信誉 IP" - -- 即使配置了完整 DNS 记录,仍可能被拒绝{ - - - -**错误示例**: "address": "test@example.com", - -``` - -550 5.7.1 The IP you're using to send mail is not authorized "local": "test",--- -e MESSAGE_TTL=3600 \# 1. 编译 - -550 5.7.1 Client host blocked using Spamhaus - -``` "ttl": 3600 - - - -**解决方案**:} - -- ✅ 使用国内邮箱(QQ/163/126)进行测试 - -- ⏳ 配置 DKIM + 建立 IP 信誉(需要 2-4 周)``` - -- 💰 使用 SendGrid、Amazon SES 等专业服务 - -## 📖 使用说明 neixin/temp-mail:v2.0go mod tidy - -### Q3: 如何使用自定义域名? - -### 获取邮件列表 - -1. 修改 `.env` 文件中的 `DOMAIN` - -2. 配置 DNS A 记录和 MX 记录 - -3. 重启服务:`docker-compose restart` - -```http - -### Q4: 邮件保留多久? - -GET /api/messages/{local}### 接收邮件```go build ./cmd/temp-mail - -默认 1 小时,可通过 `MESSAGE_TTL` 配置(如 `30m`、`2h`)。 - -``` - -### Q5: 发送邮件的成功率如何? - - - -| 邮箱服务商 | 成功率 | 说明 | - -|-----------|-------|------|**响应**: - -| QQ、163、126 | ✅ 95%+ | 国内邮箱,稳定 | - -| Gmail、Outlook | ❌ <5% | 严格的 IP 信誉要求 |```json1. 访问 Web 界面(http://your-server:8080) - -| 企业邮箱 | 🔄 50-80% | 取决于具体配置 | - -[ - ---- - - {2. 输入自定义邮箱名称,点击"创建邮箱" - -## 📄 许可证 - - "id": "abc123", - -MIT License - - "from": "sender@gmail.com",3. 复制生成的邮箱地址(如:test@your-domain.com)### 本地编译运行# 2. 运行 - ---- - - "to": ["test@example.com"], - -## 🙏 致谢 - - "subject": "测试邮件",4. 使用该地址接收邮件 - -基于 [emersion/go-smtp](https://github.com/emersion/go-smtp) 构建 - - "received": "2025-10-18T10:30:00Z" - ---- - - }5. 邮件会自动显示在"📨 收件箱"标签页中./temp-mail - -## 🔗 链接 - -] - -- **GitHub**: https://github.com/Nei-Xin/temp_mail - -- **Docker Hub**: https://hub.docker.com/r/neixin/temp-mail``` - - - ---- - - - -**开始使用临时邮箱服务!** 🎉### 获取邮件详情### 发送邮件```bash - - - - -```http - -GET /api/messages/{local}/{id} - -GET /api/messages/{local}/{id}?format=raw # 获取原始邮件1. 切换到"📤 发送邮件"标签页# 1. 安装依赖# 3. 访问 - -``` - -2. 填写以下信息: - -### 发送邮件 - - - **发件人**:输入邮箱本地部分(如:test)go mod tidyhttp://localhost:8080 - -```http - -POST /api/send - **收件人**:目标邮箱地址(多个用逗号分隔) - -Content-Type: application/json - - - **主题**:邮件主题``` - -{ - - "from": "test", - **内容**:邮件正文 - - "to": ["recipient@example.com"], - - "subject": "测试邮件",3. 点击"📤 发送"按钮# 2. 编译 - - "body": "邮件正文内容", - - "html": "

HTML内容

"4. 如果发件人邮箱不存在,系统会自动创建 - -} - -```go build -o temp-mail ./cmd/temp-mail--- - - - -**响应(成功)**:**工作原理**: - -```json - -{- 使用您创建的临时邮箱地址作为发件人 - - "success": true, - - "message": "邮件已发送",- 系统自动查找收件人的 MX 记录 - - "from": "test@example.com" - -}- 直接连接对方邮件服务器发送邮件# 3. 运行## ⚙️ 配置 - -``` - -- 无需配置第三方 SMTP 服务 - -**响应(失败)**: - -```json./temp-mail - -{ - - "error": "Gmail 拒绝,建议用 QQ/163"**⚠️ 发送限制说明**: - -} - -```- ✅ **可以发送**:QQ.com、163.com、126.com 等国内邮箱通过环境变量配置,无需修改代码: - - - ----- ⚠️ **可能失败**:Gmail、Hotmail、Outlook 等国际邮箱(它们有严格的反垃圾邮件策略,会拒绝未认证 IP 的连接) - - - -## 🐳 Docker 镜像- 💡 **提高成功率**:配置 DNS(SPF、PTR 记录)、使用有信誉的服务器 IP# 4. 访问 - - - -### 镜像信息 - - - -- **镜像名称**:`neixin/temp-mail`---http://localhost:8080### 基础配置 - -- **最新版本**:`v2.1` - -- **镜像大小**:~24.5MB - -- **基础镜像**:Alpine Linux - -## 🌐 生产环境部署``` - -### 拉取镜像 - - - -```bash - -docker pull neixin/temp-mail:v2.1### 端口要求| 变量 | 默认值 | 说明 | - -docker pull neixin/temp-mail:latest - -``` - - - -### 构建镜像| 端口 | 用途 | 说明 |---|------|--------|------| - - - -```bash|------|------|------| - -# 构建 - -docker build -t neixin/temp-mail:v2.1 .| **25** | SMTP 邮件接收 | 接收外部邮件(Gmail、Outlook 等) || `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 | - - - -# 运行| **8080** | HTTP Web 界面 | 查看和发送邮件 | - -docker run -d -p 8080:8080 -p 25:25 \ - - -e DOMAIN=your-domain.com \## ⚙️ 配置| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 | - - neixin/temp-mail:v2.1 - -```### DNS 配置(可选) - - - ----| `DOMAIN` | `tmp.local` | 邮件域名 | - - - -## 🔧 开发如果使用自定义域名,建议配置以下 DNS 记录以提高邮件送达率: - - - -### 项目结构通过 `.env` 文件或环境变量配置:| `MESSAGE_TTL` | `30m` | 邮件保留时间 | - - - -`````` - -temp_mail/ - -├── cmd/temp-mail/ # 主程序入口类型 名称 值 优先级| `TZ` | `Asia/Shanghai` | 时区 | - -│ └── main.go - -├── internal/A mail.example.com 服务器IP - - -│ ├── httpapi/ # HTTP 服务和 Web 界面 - -│ ├── smtpserver/ # SMTP 接收服务器MX example.com mail.example.com 10| 变量 | 默认值 | 说明 | - -│ ├── smtpclient/ # SMTP 发送客户端(MX 查询 + STARTTLS) - -│ └── storage/ # 内存存储TXT example.com v=spf1 ip4:服务器IP -all - - -├── Dockerfile # Docker 镜像构建 - -├── docker-compose.yml # Docker Compose 配置```|------|--------|------|**配置文件示例(.env):** - -├── .env # 环境变量配置 - -└── README.md # 项目文档 - -``` - -**说明**:| `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 | - -### 本地开发 - -- **A 记录**:域名指向服务器 IP - -```bash - -# 安装依赖- **MX 记录**:邮件路由记录| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 |```bash - -go mod tidy - -- **SPF 记录**:提高发送邮件的可信度,减少被拒绝的概率 - -# 运行 - -go run ./cmd/temp-mail- **PTR 记录**:反向解析,需要联系服务器提供商配置| `DOMAIN` | `localhost` | 邮件域名(支持 IP 地址) |# 基础配置 - - - -# 测试 - -go test ./... - -### 防火墙配置| `MESSAGE_TTL` | `3600` | 邮件保留时间(秒) |HTTP_ADDR=:8080 - -# 编译 - -go build -o temp-mail ./cmd/temp-mail - -``` - -```bash| `TZ` | `Asia/Shanghai` | 时区 |SMTP_ADDR=:25 - ---- - -# 开放端口 - -## 💡 注意事项 - -sudo ufw allow 25/tcpDOMAIN=mail.example.com - -1. **数据存储**:所有邮件存储在内存中,重启服务后数据会丢失 - -2. **端口 25**:监听 25 端口需要 root 权限或使用 Dockersudo ufw allow 8080/tcp - -3. **防火墙**:确保开放 25 和 8080 端口 - -4. **云服务商**:部分云服务商(阿里云、腾讯云)封禁 25 端口,无法接收外部邮件sudo ufw enable**配置文件示例(.env):**MESSAGE_TTL=30m - -5. **DNS 配置**:使用 IP 地址也可以正常工作,DNS 仅用于提高送达率 - -6. **发送限制**:Gmail/Outlook 等国际邮箱会拒绝云服务器 IP 的直接连接``` - - - ----TZ=Asia/Shanghai - - - -## 🔒 安全建议--- - - - -- 使用 Nginx 反向代理,配置 SSL/TLS```bash``` - -- 限制访问来源(防火墙规则) - -- 定期更新 Docker 镜像## 📡 API 接口 - -- 监控日志,防止滥用 - -- 不要用于生产环境敏感数据HTTP_ADDR=:8080 - - - ----### 创建邮箱地址 - - - -## 📚 常见问题SMTP_ADDR=:25--- - - - -### Q1: 无法接收外部邮件?```http - - - -**检查**:POST /api/address?local=testDOMAIN=mail.example.com - -- DNS MX 记录是否正确 - -- 防火墙是否开放 25 端口``` - -- 云服务商是否封禁 25 端口(阿里云、腾讯云默认封禁) - -- SMTP 服务是否正常运行:`docker logs temp-mail`MESSAGE_TTL=3600## 🌐 生产环境部署 - - - -### Q2: 为什么无法发送到 Gmail/Outlook?**响应**: - - - -这是正常现象,不是代码问题。```jsonTZ=Asia/Shanghai - - - -**原因**:{ - -- Gmail、Outlook 等大型邮件服务商要求发件 IP 具有高信誉度 - -- 云服务器 IP(如华为云、阿里云)通常被归类为"低信誉 IP" "address": "test@example.com",```### 端口要求 - -- 即使配置了完整的 DNS 记录(SPF、PTR、DKIM),仍可能被拒绝 - - "local": "test", - -**错误示例**: - -``` "ttl": 3600 - -550 5.7.1 The IP you're using to send mail is not authorized - -550 5.7.1 Client host blocked using Spamhaus} - -``` - -```---| 端口 | 用途 | 说明 | - -**解决方案**: - -- ✅ **接受现实**:使用国内邮箱(QQ/163/126)进行测试 - -- ⏳ **长期方案**:配置 DKIM + 建立 IP 信誉(需要 2-4 周) - -- 💰 **生产环境**:使用 SendGrid、Amazon SES 等专业邮件服务### 获取邮件列表|------|------|------| - - - -### Q3: 如何使用自定义域名? - - - -1. 修改 `.env` 文件中的 `DOMAIN````http## 📖 使用说明| **25** | SMTP 邮件接收 | 接收外部邮件(Gmail、Outlook 等) | - -2. 配置 DNS A 记录和 MX 记录 - -3. 重启服务:`docker-compose restart`GET /api/messages/{local} - - - -### Q4: 邮件保留多久?```| **8080** | HTTP Web 界面 | 查看邮件 | - - - -默认 1 小时,可通过 `MESSAGE_TTL` 配置(支持 `30m`、`2h`、`24h` 等)。 - - - -### Q5: 如何配置 HTTPS?**响应**:### 接收邮件 - - - -使用 Nginx 反向代理:```json - - - -```nginx[### DNS 配置 - -server { - - listen 443 ssl http2; { - - server_name mail.example.com; - - "id": "abc123",1. 访问 Web 界面(http://your-server:8080) - - ssl_certificate /path/to/cert.pem; - - ssl_certificate_key /path/to/key.pem; "from": "sender@gmail.com", - - - - location / { "to": ["test@example.com"],2. 输入自定义邮箱名称,点击"创建邮箱"添加以下 DNS 记录: - - proxy_pass http://localhost:8080; - - proxy_set_header Host $host; "subject": "测试邮件", - - proxy_set_header X-Real-IP $remote_addr; - - } "received": "2025-10-18T10:30:00Z"3. 复制生成的邮箱地址(如:test@your-domain.com) - -} - -``` } - - - -### Q6: 发送邮件的成功率如何?]4. 使用该地址接收邮件``` - - - -| 邮件服务商 | 成功率 | 说明 |``` - -|-----------|-------|------| - -| QQ.com, 163.com, 126.com | ✅ 95%+ | 配置良好的国内邮箱 |5. 邮件会自动显示在"📨 收件箱"标签页中类型 名称 值 优先级 - -| Sina.com.cn, Sohu.com | ✅ 90%+ | 国内邮箱,较稳定 | - -| Gmail, Outlook, Hotmail | ❌ <5% | 严格的 IP 信誉要求 |### 获取邮件详情 - -| 企业邮箱 | 🔄 50-80% | 取决于具体配置 | - -A mail.example.com 服务器IP - - ---- - -```http - -## 📄 许可证 - -GET /api/messages/{local}/{id}### 发送邮件MX example.com mail.example.com 10 - -MIT License - -GET /api/messages/{local}/{id}?format=raw # 获取原始邮件 - ---- - -`````` - -## 🙏 致谢 - - - -基于 [emersion/go-smtp](https://github.com/emersion/go-smtp) 构建 - -### 发送邮件1. 切换到"📤 发送邮件"标签页 - ---- - - - -## 🔗 链接 - -```http2. 填写以下信息:### 部署步骤 - -- **GitHub**: https://github.com/Nei-Xin/temp_mail - -- **Docker Hub**: https://hub.docker.com/r/neixin/temp-mailPOST /api/send - -- **问题反馈**: https://github.com/Nei-Xin/temp_mail/issues - -Content-Type: application/json - **发件人**:输入邮箱本地部分(如:test) - ---- - - - -**开始使用临时邮箱服务!** 🎉 - -{ - **收件人**:目标邮箱地址(多个用逗号分隔)```bash - - "from": "test", - - "to": ["recipient@example.com"], - **主题**:邮件主题# 1. 克隆项目 - - "subject": "测试邮件", - - "body": "邮件正文内容", - **内容**:邮件正文git clone - - "html": "

HTML内容

" - -}3. 点击"📤 发送"按钮cd temp_mail - -``` - -4. 如果发件人邮箱不存在,系统会自动创建 - -**响应(成功)**: - -```json# 2. 修改配置 - -{ - - "success": true,**工作原理**:cp .env .env.local - - "message": "邮件已发送", - - "from": "test@example.com"- 使用您创建的临时邮箱地址作为发件人nano .env.local # 修改 DOMAIN - -} - -```- 系统自动查找收件人的 MX 记录 - - - ----- 直接连接对方邮件服务器发送邮件# 3. 启动服务 - - - -## 🐳 Docker 镜像- 无需配置第三方 SMTP 服务docker-compose up -d - - - -### 镜像信息 - - - -- **镜像名称**:`neixin/temp-mail`---# 4. 查看日志 - -- **最新版本**:`v2.0` - -- **镜像大小**:24.5MBdocker-compose logs -f - -- **基础镜像**:Alpine Linux - -## 🌐 生产环境部署 - -### 拉取镜像 - -# 5. 测试 - -```bash - -docker pull neixin/temp-mail:v2.0### 端口要求# 发送邮件到 test@example.com - -docker pull neixin/temp-mail:latest - -```# 访问 http://mail.example.com:8080 - - - -### 构建镜像| 端口 | 用途 | 说明 |``` - - - -```bash|------|------|------| - -# 构建 - -docker build -t neixin/temp-mail:v2.0 .| **25** | SMTP 邮件接收 | 接收外部邮件(Gmail、Outlook 等) |--- - - - -# 运行| **8080** | HTTP Web 界面 | 查看和发送邮件 | - -docker run -d -p 8080:8080 -p 25:25 \ - - -e DOMAIN=your-domain.com \## � 使用说明 - - neixin/temp-mail:v2.0 - -```### DNS 配置(可选) - - - ----### 接收邮件 - - - -## 🔧 开发如果使用自定义域名,建议配置以下 DNS 记录以提高邮件送达率: - - - -### 项目结构1. 访问 Web 界面(http://your-server:8080) - - - -``````2. 输入自定义邮箱名称(可选),点击"创建邮箱" - -temp_mail/ - -├── cmd/temp-mail/ # 主程序入口类型 名称 值 优先级3. 复制生成的邮箱地址 - -│ └── main.go - -├── internal/A mail.example.com 服务器IP -4. 使用该地址接收邮件 - -│ ├── httpapi/ # HTTP 服务和 Web 界面 - -│ ├── smtpserver/ # SMTP 接收服务器MX example.com mail.example.com 105. 邮件会自动显示在收件箱中 - -│ ├── smtpclient/ # SMTP 发送客户端(MX 查询) - -│ └── storage/ # 内存存储TXT example.com v=spf1 ip4:服务器IP -all - -├── Dockerfile # Docker 镜像构建 - -├── docker-compose.yml # Docker Compose 配置```### 发送邮件 - -├── .env # 环境变量配置 - -└── README.md # 项目文档 - -``` - -**说明**:1. **首先创建一个临时邮箱**(如果还没有) - -### 本地开发 - -- **A 记录**:域名指向服务器 IP2. 在 Web 界面中,找到"📤 发送邮件"卡片 - -```bash - -# 安装依赖- **MX 记录**:邮件路由记录3. 点击"✍️ 写邮件"按钮展开表单 - -go mod tidy - -- **SPF 记录**:提高发送邮件的可信度(可选)4. 填写以下信息: - -# 运行 - -go run ./cmd/temp-mail - **发件人**:自动使用您创建的邮箱地址(不可修改) - - - -# 测试### 防火墙配置 - **收件人**:目标邮箱地址(必填,多个收件人用逗号分隔) - -go test ./... - - - **主题**:邮件主题(必填) - -# 编译 - -go build -o temp-mail ./cmd/temp-mail```bash - **内容**:邮件正文(必填) - -``` - -# 开放端口5. 点击"📤 发送"按钮发送邮件 - ---- - -sudo ufw allow 25/tcp - -## 💡 注意事项 - -sudo ufw allow 8080/tcp**工作原理**: - -1. **数据存储**:所有邮件存储在内存中,重启服务后数据会丢失 - -2. **端口 25**:监听 25 端口需要 root 权限或使用 Dockersudo ufw enable- 使用您创建的临时邮箱地址作为发件人 - -3. **防火墙**:确保开放 25 和 8080 端口 - -4. **云服务商**:部分云服务商(阿里云、腾讯云)封禁 25 端口,无法接收外部邮件```- 系统通过查找收件人的 MX 记录,直接发送邮件到对方邮件服务器 - -5. **DNS 配置**:使用 IP 地址也可以正常工作,DNS 仅用于提高送达率 - -6. **发送限制**:Gmail/Hotmail 等大型邮箱可能拒绝连接,建议先测试国内邮箱- 无需配置第三方 SMTP 服务 - - - ------- - - - -## 🔒 安全建议--- - - - -- 使用 Nginx 反向代理,配置 SSL/TLS## 📡 API 接口 - -- 限制访问来源(防火墙规则) - -- 定期更新 Docker 镜像## �📡 API - -- 监控日志,防止滥用 - -- 不要用于生产环境敏感数据### 创建邮箱地址 - - - ----### 创建邮箱地址 - - - -## 📚 常见问题```http - - - -### Q1: 无法接收外部邮件?POST /api/address?local=test```bash - - - -**检查**:```POST /api/address?local=test - -- DNS MX 记录是否正确 - -- 防火墙是否开放 25 端口 - -- 云服务商是否封禁 25 端口(阿里云、腾讯云默认封禁) - -- SMTP 服务是否正常运行:`docker logs temp-mail`**响应**:返回: - - - -### Q2: 发送邮件失败?```json{ - - - -**常见原因及解决方案**:{ "address": "test@example.com", - - - -#### 1. Gmail/Hotmail/Outlook 拒绝连接(最常见) "address": "test@example.com", "local": "test", - - - -**现象**: "local": "test", "ttl": 30 - -``` - -发送失败: 无法连接到 gmail.com 的任何MX服务器 "ttl": 3600} - -``` - -}``` - -**原因**: - -- Gmail、Hotmail、Outlook 等大型邮件服务商有严格的反垃圾邮件策略``` - -- 会直接拒绝来自未认证 IP 的 SMTP 连接 - -- 需要 IP 具有良好的发信信誉### 获取邮件列表 - - - -**解决方案**:### 获取邮件列表 - -- ✅ **先测试国内邮箱**:QQ.com、163.com、126.com(成功率高) - -- ✅ **配置 PTR 记录**:联系服务器提供商配置反向 DNS 解析```bash - -- ✅ **配置 SPF 记录**:在域名 DNS 中添加 `v=spf1 ip4:你的IP -all` - -- ✅ **配置 DKIM/DMARC**:进一步提高邮件信誉```httpGET /api/messages/{local} - -- ✅ **使用有信誉的服务器**:避免使用廉价 VPS 的共享 IP - -- ⏳ **建立 IP 信誉**:Gmail 通常需要较长时间(数周到数月)才能接受新 IPGET /api/messages/{local} - - - -#### 2. 其他常见问题```返回: - - - -- **收件人邮箱地址错误**:检查邮箱格式[ - -- **MX 记录查询失败**:网络或 DNS 问题 - -- **IP 被列入黑名单**:使用 [MXToolbox](https://mxtoolbox.com/blacklists.aspx) 检查**响应**: { - -- **对方服务器临时故障**:稍后重试 - -```json "id": "abc123", - -**测试建议**: - -```bash[ "from": "sender@gmail.com", - -# 1. 先测试国内邮箱(成功率高) - -curl -X POST http://localhost:8080/api/send -H "Content-Type: application/json" -d '{ { "to": ["test@example.com"], - - "from": "test", - - "to": ["your@qq.com"], "id": "abc123", "subject": "测试邮件", - - "subject": "测试邮件", - - "body": "这是一封测试邮件" "from": "sender@gmail.com", "received": "2025-10-17T10:30:00Z" - -}' - - "to": ["test@example.com"], } - -# 2. 查看详细日志 - -docker logs -f temp-mail "subject": "测试邮件",] - -``` - - "received": "2025-10-18T10:30:00Z"``` - -### Q3: 如何使用自定义域名? - - } - -1. 修改 `.env` 文件中的 `DOMAIN` - -2. 配置 DNS A 记录和 MX 记录]### 获取邮件详情 - -3. 重启服务:`docker-compose restart` - -``` - -### Q4: 邮件保留多久? - -```bash - -默认 1 小时(3600 秒),可通过 `MESSAGE_TTL` 配置。 - -### 获取邮件详情GET /api/messages/{local}/{id} - -### Q5: 如何配置 HTTPS? - - - -使用 Nginx 反向代理: - -```http# 获取原始邮件 - -```nginx - -server {GET /api/messages/{local}/{id}GET /api/messages/{local}/{id}?format=raw - - listen 443 ssl http2; - - server_name mail.example.com;GET /api/messages/{local}/{id}?format=raw # 获取原始邮件``` - - - - ssl_certificate /path/to/cert.pem;``` - - ssl_certificate_key /path/to/key.pem; - - ### 发送邮件 - - location / { - - proxy_pass http://localhost:8080;### 发送邮件 - - proxy_set_header Host $host; - - proxy_set_header X-Real-IP $remote_addr;```bash - - } - -}```httpPOST /api/send - -``` - -POST /api/send - -### Q6: 为什么可以发送到 QQ 但不能发送到 Gmail? - -Content-Type: application/json请求体: - -这是正常现象: - -{ - -| 邮件服务商 | 发送成功率 | 说明 | - -|-----------|----------|------|{ "from": "test", // 必填,您创建的邮箱本地部分 - -| QQ.com, 163.com, 126.com | ✅ 高 | 反垃圾策略相对宽松 | - -| Gmail, Hotmail, Outlook | ⚠️ 低 | 需要 IP 信誉、PTR、SPF、DKIM 等配置 | "from": "test", "to": ["recipient@example.com"], // 必填,收件人列表 - -| 企业邮箱 | 🔄 中等 | 取决于具体配置 | - - "to": ["recipient@example.com"], "subject": "测试邮件", // 必填 - -**建议**: - -- 用于测试和开发:使用国内邮箱即可 "subject": "测试邮件", "body": "邮件正文内容", // 必填 - -- 用于生产环境:需要完整配置 DNS 记录并建立 IP 信誉 - -- 发送重要邮件:建议使用专业邮件服务(SendGrid、Amazon SES 等) "body": "邮件正文内容", "html": "

HTML内容

" // 可选,HTML格式邮件 - - - ---- "html": "

HTML内容

"} - - - -## 📄 许可证} - - - -MIT License```返回(成功): - - - ----{ - - - -## 🙏 致谢**响应(成功)**: "success": true, - - - -基于 [emersion/go-smtp](https://github.com/emersion/go-smtp) 构建```json "message": "邮件已发送", - - - ----{ "from": "test@mail.example.com" - - - -**开始使用临时邮箱服务!** 🎉 "success": true,} - - - "message": "邮件已发送", - - "from": "test@example.com"返回(失败): - -}{ - -``` "error": "发件人邮箱 test@mail.example.com 不存在,请先创建邮箱" - -} - ----``` - - - -## 🐳 Docker 镜像--- - - - -### 镜像信息## 🐳 Docker 镜像 - - - -- **镜像名称**:`neixin/temp-mail`### 拉取镜像 - -- **最新版本**:`v2.0` - -- **镜像大小**:24.5MB```bash - -- **基础镜像**:Alpine Linuxdocker pull neixin/temp-mail:latest - -``` - -### 拉取镜像 - -### 构建镜像 - -```bash - -docker pull neixin/temp-mail:v2.0```bash - -docker pull neixin/temp-mail:latest# 构建 - -```docker build -t neixin/temp-mail:latest . - - - -### 构建镜像# 推送到 Docker Hub - -docker push neixin/temp-mail:latest - -```bash``` - -# 构建 - -docker build -t neixin/temp-mail:v2.0 .--- - - - -# 运行## 🔧 开发 - -docker run -d -p 8080:8080 -p 25:25 \ - - -e DOMAIN=your-domain.com \### 项目结构 - - neixin/temp-mail:v2.0 - -`````` - -temp_mail/ - ----├── cmd/temp-mail/ # 主程序入口 - -├── internal/ - -## 🔧 开发│ ├── httpapi/ # HTTP 服务和 Web 界面 - -│ ├── smtpserver/ # SMTP 接收服务器 - -### 项目结构│ ├── smtpclient/ # SMTP 发送客户端 - -│ └── storage/ # 内存存储 - -```├── Dockerfile # Docker 镜像构建 - -temp_mail/├── docker-compose.yml # Docker Compose 配置 - -├── cmd/temp-mail/ # 主程序入口└── .env # 环境变量配置 - -│ └── main.go``` - -├── internal/ - -│ ├── httpapi/ # HTTP 服务和 Web 界面### 本地开发 - -│ ├── smtpserver/ # SMTP 接收服务器 - -│ ├── smtpclient/ # SMTP 发送客户端(MX 查询)```bash - -│ └── storage/ # 内存存储# 安装依赖 - -├── Dockerfile # Docker 镜像构建go mod tidy - -├── docker-compose.yml # Docker Compose 配置 - -├── .env # 环境变量配置# 运行 - -└── README.md # 项目文档go run ./cmd/temp-mail - -``` - -# 测试 - -### 本地开发go test ./... - -``` - -```bash - -# 安装依赖--- - -go mod tidy - -## � 注意事项 - -# 运行 - -go run ./cmd/temp-mail1. **数据存储**:所有邮件存储在内存中,重启服务后数据会丢失 - -2. **端口 25**:监听 25 端口需要 root 权限或 Docker - -# 测试3. **防火墙**:确保开放 25 和 8080 端口 - -go test ./...4. **DNS 配置**:接收外部邮件需要正确配置 MX 记录 - -5. **安全性**:建议配置 Nginx 反向代理和 SSL 证书 - -# 编译 - -go build -o temp-mail ./cmd/temp-mail--- - -``` - -## 🔒 安全建议 - ---- - -- 使用 Nginx 反向代理,配置 SSL/TLS - -## 💡 注意事项- 限制 8080 端口的访问(仅内网或特定 IP) - -- 定期更新 Docker 镜像 - -1. **数据存储**:所有邮件存储在内存中,重启服务后数据会丢失- 监控日志,防止滥用 - -2. **端口 25**:监听 25 端口需要 root 权限或使用 Docker - -3. **防火墙**:确保开放 25 和 8080 端口--- - -4. **云服务商**:部分云服务商(阿里云、腾讯云)封禁 25 端口,无法接收外部邮件 - -5. **DNS 配置**:使用 IP 地址也可以正常工作,DNS 仅用于提高送达率## 📚 常见问题 - - - ----### Q1: 无法接收外部邮件? - - - -## 🔒 安全建议检查: - -- DNS MX 记录是否正确 - -- 使用 Nginx 反向代理,配置 SSL/TLS- 防火墙是否开放 25 端口 - -- 限制访问来源(防火墙规则)- 云服务商是否封禁 25 端口(Aliyun、Tencent 默认封禁) - -- 定期更新 Docker 镜像 - -- 监控日志,防止滥用### Q2: 如何修改域名? - -- 不要用于生产环境敏感数据 - -修改 `.env` 文件中的 `DOMAIN`,重启服务即可,无需重新编译。 - ---- - -### Q3: 如何配置 SSL? - -## 📚 常见问题 - -使用 Nginx 反向代理: - -### Q1: 无法接收外部邮件? - -```nginx - -**检查**:server { - -- DNS MX 记录是否正确 listen 443 ssl http2; - -- 防火墙是否开放 25 端口 server_name mail.example.com; - -- 云服务商是否封禁 25 端口(阿里云、腾讯云默认封禁) - -- SMTP 服务是否正常运行:`docker logs temp-mail` ssl_certificate /path/to/cert.pem; - - ssl_certificate_key /path/to/key.pem; - -### Q2: 发送邮件失败? - - location / { - -**可能原因**: proxy_pass http://localhost:8080; - -- 收件人邮箱地址错误 } - -- 对方邮件服务器 MX 记录查询失败} - -- 对方服务器拒绝接收(IP 信誉问题)``` - -- 检查日志:`docker logs temp-mail` - ---- - -### Q3: 如何使用自定义域名? - -## 📄 许可证 - -1. 修改 `.env` 文件中的 `DOMAIN` - -2. 配置 DNS A 记录和 MX 记录MIT License - -3. 重启服务:`docker-compose restart` - ---- - -### Q4: 邮件保留多久? - -## 🙏 致谢 - -默认 1 小时(3600 秒),可通过 `MESSAGE_TTL` 配置。 - -基于 [emersion/go-smtp](https://github.com/emersion/go-smtp) 构建 - -### Q5: 如何配置 HTTPS? - ---- - -使用 Nginx 反向代理: - -**开始使用临时邮箱服务!** 🎉 - -```nginx| **2525** | SMTP 邮件接收 | ✅ 必需 | 普通用户 | - -server {| **80** | HTTP(通过 Nginx) | ⚪ 可选 | 需要 root | - - listen 443 ssl http2;| **443** | HTTPS(通过 Nginx) | ⚪ 可选 | 需要 root | - - server_name mail.example.com;| **25** | SMTP 标准端口 | ⚪ 可选 | 需要 root | - - - - ssl_certificate /path/to/cert.pem;**✅ 25端口封禁?没问题!** - - ssl_certificate_key /path/to/key.pem;- 本项目默认使用 **2525 端口**,不依赖 25 端口 - - - 可以在**阿里云、腾讯云、AWS** 等封禁 25 端口的服务器上正常运行 - - location / {- 适用于内部测试、开发环境、团队协作 - - proxy_pass http://localhost:8080;- 详细说明请查看 [25端口封禁环境部署指南](PORT25_BLOCKED.md) - - proxy_set_header Host $host; - - proxy_set_header X-Real-IP $remote_addr;**⚠️ 需要接收 Gmail/Outlook 等外部邮件?** - - }- 外部邮件服务器**只使用 25 端口**发送邮件(无法更改) - -}- 必须部署在支持 25 端口的服务器上 - -```- **无需修改代码**:通过端口转发即可(25 → 2525) - -- 推荐服务商:Vultr ($5/月)、DigitalOcean ($6/月) - ----- 详细说明请查看 [接收外部邮件部署指南](EXTERNAL_EMAIL.md) - -- 无需修改代码的配置方法:[NO_CODE_CHANGE.md](NO_CODE_CHANGE.md) - -## 📄 许可证 - -**说明**: - -MIT License- 默认使用 **8080** 和 **2525** 端口,无需 root 权限 - -- 生产环境建议使用 Nginx 反向代理实现 80/443 端口访问 - ----- 如需接收外部邮件服务器的邮件,需要 25 端口(或使用支持25端口的云服务商) - - - -## 🙏 致谢详细端口配置方案请参考 [PORT_REQUIREMENTS.md](PORT_REQUIREMENTS.md) - - - -基于 [emersion/go-smtp](https://github.com/emersion/go-smtp) 构建### 快速部署(推荐) - - - ----1. **本地编译 Linux 版本** - - - -**开始使用临时邮箱服务!** 🎉```powershell - -# Windows PowerShell -$env:GOOS="linux" -$env:GOARCH="amd64" -go build -o temp-mail-linux ./cmd/temp-mail -``` - -2. **上传到服务器** - -```bash -scp temp-mail-linux deploy.sh user@your-server:/opt/temp-mail/ -``` - -3. **服务器上部署** - -```bash -cd /opt/temp-mail -sudo bash deploy.sh -``` - -### Docker 部署 - -```bash -# 使用 Docker Compose -docker-compose up -d - -# 或使用 Docker -docker build -t temp-mail . -docker run -d -p 8080:8080 -p 2525:2525 temp-mail -``` - -### 配置说明 - -- **环境变量**:编辑 `/opt/temp-mail/.env` -- **域名配置**:设置 `DOMAIN` 为你的域名(无需修改代码) -- **端口配置**:修改 `HTTP_ADDR` 和 `SMTP_ADDR` -- **DNS 设置**:添加 MX 记录指向你的服务器 - -#### 自定义域名示例 - -```bash -# 编辑配置文件 -sudo nano /opt/temp-mail/.env - -# 修改域名 -DOMAIN=mail.yourdomain.com -HTTP_ADDR=:8080 -SMTP_ADDR=:2525 -MESSAGE_TTL=30 - -# 重启服务 -sudo systemctl restart temp-mail -``` - -详细域名配置教程:[DOMAIN_CONFIG.md](DOMAIN_CONFIG.md) - -更多详情请参考 [DEPLOYMENT.md](DEPLOYMENT.md) +# 等待补充... \ No newline at end of file diff --git a/cmd/temp-mail/main.go b/cmd/temp-mail/main.go index 57f2567..caa7348 100644 --- a/cmd/temp-mail/main.go +++ b/cmd/temp-mail/main.go @@ -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 } diff --git a/internal/httpapi/http.go b/internal/httpapi/http.go index 10e721d..46af91b 100644 --- a/internal/httpapi/http.go +++ b/internal/httpapi/http.go @@ -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(``, escapeHTMLAttr(htmlContent)) - } else if textContent != "" { - bodyHTML = fmt.Sprintf(`
%s
`, escapeHTML(textContent)) - } else { - bodyHTML = `
无邮件正文
` - } - - 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(``, escapeHTMLAttr(htmlContent)) + } else if textContent != "" { + bodyHTML = fmt.Sprintf(`
%s
`, escapeHTML(textContent)) + } else { + bodyHTML = `
无邮件正文
` + } + + 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 = ` diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go index 08f16bb..10ea3de 100644 --- a/internal/smtpclient/client.go +++ b/internal/smtpclient/client.go @@ -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") diff --git a/internal/smtpserver/server.go b/internal/smtpserver/server.go index 1e657fe..7cd6915 100644 --- a/internal/smtpserver/server.go +++ b/internal/smtpserver/server.go @@ -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() } diff --git a/internal/storage/store.go b/internal/storage/store.go index 4e01354..fdb1e77 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -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) } diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go index 1a27404..c919e10 100644 --- a/internal/storage/store_test.go +++ b/internal/storage/store_test.go @@ -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") + } }