commit 715c9c83b399f24076ebc853b84d40c67800dfa7 Author: Nei-Xin <128169338+Nei-Xin@users.noreply.github.com> Date: Sat Oct 18 15:32:52 2025 +0800 init diff --git a/.env b/.env new file mode 100644 index 0000000..421c9c6 --- /dev/null +++ b/.env @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07c51d8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..afc73c4 --- /dev/null +++ b/README.md @@ -0,0 +1,1237 @@ +# Temp Mail - 临时邮箱服务# Temp Mail - 临时邮箱服务# Temp Mail - 临时邮箱服务 + + + +一个轻量级的临时邮箱服务,使用 Go 语言实现。支持收发邮件,提供简洁的 Web 界面。 + + + +## ✨ 特性一个轻量级的临时邮箱服务,使用 Go 语言实现。支持收发邮件,提供简洁的 Web 界面。一个轻量级的临时邮箱服务,使用 Go 语言实现。 + + + +- 📧 **SMTP 邮件接收**:监听 25 端口,接收外部邮件 + +- 📤 **SMTP 邮件发送**:使用临时邮箱地址发送邮件,自动查找 MX 记录 + +- 🎯 **标签页界面**:收件箱和发送邮件切换方便## ✨ 特性## ✨ 特性 + +- 💾 **内存存储**:邮件自动过期(默认 1 小时) + +- 🐳 **Docker 支持**:一键部署,镜像仅 24.5MB + +- 🔧 **灵活配置**:支持 IP 或自定义域名,无需修改代码 + +- 📧 **SMTP 邮件接收**:监听 25 端口,接收外部邮件- 📧 SMTP 邮件接收(端口 25) + +--- + +- 📤 **SMTP 邮件发送**:使用临时邮箱地址发送邮件,自动查找 MX 记录- 📤 SMTP 邮件发送(支持 Gmail、163、QQ 等) + +## 🚀 快速开始 + +- 🎯 **标签页界面**:收件箱和发送邮件切换方便- 🌐 Web 界面(端口 8080) + +### Docker Compose 部署(推荐) + +- 💾 **内存存储**:邮件自动过期(默认 1 小时)- 💾 内存存储,自动过期(默认 30 分钟) + +```bash + +# 1. 克隆项目- 🐳 **Docker 支持**:一键部署,镜像仅 24.5MB- 🐳 Docker 支持,一键部署 + +git clone + +cd temp_mail- 🔧 **灵活配置**:支持 IP 或自定义域名,无需修改代码- 🔧 环境变量配置,无需修改代码 + + + +# 2. 修改配置(可选) + +nano .env # 修改 DOMAIN 为你的域名或 IP + +------ + +# 3. 启动服务 + +docker-compose up -d + + + +# 4. 访问 Web 界面## 🚀 快速开始## 🚀 快速开始 + +http://your-server:8080 + +``` + + + +### Docker Run 部署### Docker Compose 部署(推荐)### 方式 1: Docker Compose(推荐) + + + +```bash + +docker pull neixin/temp-mail:v2.0 + +```bash```bash + +docker run -d \ + + --name temp-mail \# 1. 克隆项目# 1. 修改配置 + + -p 8080:8080 \ + + -p 25:25 \git clone nano .env # 修改 DOMAIN 为你的域名 + + -e DOMAIN=101.44.160.108 \ + + -e MESSAGE_TTL=3600 \cd temp_mail + + neixin/temp-mail:v2.0 + +```# 2. 启动服务 + + + +### 本地编译运行# 2. 修改配置(可选)docker-compose up -d + + + +```bashnano .env # 修改 DOMAIN 为你的域名或 IP + +# 1. 安装依赖 + +go mod tidy# 3. 访问 Web 界面 + + + +# 2. 编译# 3. 启动服务http://localhost:8080 + +go build -o temp-mail ./cmd/temp-mail + +docker-compose up -d``` + +# 3. 运行 + +./temp-mail + + + +# 4. 访问# 4. 访问 Web 界面### 方式 2: Docker Run + +http://localhost:8080 + +```http://your-server:8080 + + + +---``````bash + + + +## ⚙️ 配置docker run -d \ + + + +通过 `.env` 文件或环境变量配置:### Docker Run 部署 --name temp-mail \ + + + +| 变量 | 默认值 | 说明 | -p 8080:8080 \ + +|------|--------|------| + +| `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 |```bash -p 25:25 \ + +| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 | + +| `DOMAIN` | `localhost` | 邮件域名(支持 IP 地址) |docker pull neixin/temp-mail:v2.0 -e DOMAIN=mail.example.com \ + +| `MESSAGE_TTL` | `3600` | 邮件保留时间(秒) | + +| `TZ` | `Asia/Shanghai` | 时区 | neixin/temp-mail:latest + + + +**配置文件示例(.env):**docker run -d \``` + + + +```bash --name temp-mail \ + +HTTP_ADDR=:8080 + +SMTP_ADDR=:25 -p 8080:8080 \### 方式 3: 本地编译运行 + +DOMAIN=mail.example.com + +MESSAGE_TTL=3600 -p 25:25 \ + +TZ=Asia/Shanghai + +``` -e DOMAIN=101.44.160.108 \```bash + + + +--- -e MESSAGE_TTL=3600 \# 1. 编译 + + + +## 📖 使用说明 neixin/temp-mail:v2.0go mod tidy + + + +### 接收邮件```go build ./cmd/temp-mail + + + +1. 访问 Web 界面(http://your-server:8080) + +2. 输入自定义邮箱名称,点击"创建邮箱" + +3. 复制生成的邮箱地址(如:test@your-domain.com)### 本地编译运行# 2. 运行 + +4. 使用该地址接收邮件 + +5. 邮件会自动显示在"📨 收件箱"标签页中./temp-mail + + + +### 发送邮件```bash + + + +1. 切换到"📤 发送邮件"标签页# 1. 安装依赖# 3. 访问 + +2. 填写以下信息: + + - **发件人**:输入邮箱本地部分(如:test)go mod tidyhttp://localhost:8080 + + - **收件人**:目标邮箱地址(多个用逗号分隔) + + - **主题**:邮件主题``` + + - **内容**:邮件正文 + +3. 点击"📤 发送"按钮# 2. 编译 + +4. 如果发件人邮箱不存在,系统会自动创建 + +go build -o temp-mail ./cmd/temp-mail--- + +**工作原理**: + +- 使用您创建的临时邮箱地址作为发件人 + +- 系统自动查找收件人的 MX 记录 + +- 直接连接对方邮件服务器发送邮件# 3. 运行## ⚙️ 配置 + +- 无需配置第三方 SMTP 服务 + +./temp-mail + +**⚠️ 发送限制说明**: + +- ✅ **可以发送**:QQ.com、163.com、126.com 等国内邮箱通过环境变量配置,无需修改代码: + +- ⚠️ **可能失败**:Gmail、Hotmail、Outlook 等国际邮箱(它们有严格的反垃圾邮件策略,会拒绝未认证 IP 的连接) + +- 💡 **提高成功率**:配置 DNS(SPF、PTR 记录)、使用有信誉的服务器 IP# 4. 访问 + + + +---http://localhost:8080### 基础配置 + + + +## 🌐 生产环境部署``` + + + +### 端口要求| 变量 | 默认值 | 说明 | + + + +| 端口 | 用途 | 说明 |---|------|--------|------| + +|------|------|------| + +| **25** | SMTP 邮件接收 | 接收外部邮件(Gmail、Outlook 等) || `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 | + +| **8080** | HTTP Web 界面 | 查看和发送邮件 | + +## ⚙️ 配置| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 | + +### DNS 配置(可选) + +| `DOMAIN` | `tmp.local` | 邮件域名 | + +如果使用自定义域名,建议配置以下 DNS 记录以提高邮件送达率: + +通过 `.env` 文件或环境变量配置:| `MESSAGE_TTL` | `30m` | 邮件保留时间 | + +``` + +类型 名称 值 优先级| `TZ` | `Asia/Shanghai` | 时区 | + +A mail.example.com 服务器IP - + +MX example.com mail.example.com 10| 变量 | 默认值 | 说明 | + +TXT example.com v=spf1 ip4:服务器IP -all - + +```|------|--------|------|**配置文件示例(.env):** + + + +**说明**:| `HTTP_ADDR` | `:8080` | HTTP 服务监听地址 | + +- **A 记录**:域名指向服务器 IP + +- **MX 记录**:邮件路由记录| `SMTP_ADDR` | `:25` | SMTP 接收服务监听地址 |```bash + +- **SPF 记录**:提高发送邮件的可信度,减少被拒绝的概率 + +- **PTR 记录**:反向解析,需要联系服务器提供商配置| `DOMAIN` | `localhost` | 邮件域名(支持 IP 地址) |# 基础配置 + + + +### 防火墙配置| `MESSAGE_TTL` | `3600` | 邮件保留时间(秒) |HTTP_ADDR=:8080 + + + +```bash| `TZ` | `Asia/Shanghai` | 时区 |SMTP_ADDR=:25 + +# 开放端口 + +sudo ufw allow 25/tcpDOMAIN=mail.example.com + +sudo ufw allow 8080/tcp + +sudo ufw enable**配置文件示例(.env):**MESSAGE_TTL=30m + +``` + +TZ=Asia/Shanghai + +--- + +```bash``` + +## 📡 API 接口 + +HTTP_ADDR=:8080 + +### 创建邮箱地址 + +SMTP_ADDR=:25--- + +```http + +POST /api/address?local=testDOMAIN=mail.example.com + +``` + +MESSAGE_TTL=3600## 🌐 生产环境部署 + +**响应**: + +```jsonTZ=Asia/Shanghai + +{ + + "address": "test@example.com",```### 端口要求 + + "local": "test", + + "ttl": 3600 + +} + +```---| 端口 | 用途 | 说明 | + + + +### 获取邮件列表|------|------|------| + + + +```http## 📖 使用说明| **25** | SMTP 邮件接收 | 接收外部邮件(Gmail、Outlook 等) | + +GET /api/messages/{local} + +```| **8080** | HTTP Web 界面 | 查看邮件 | + + + +**响应**:### 接收邮件 + +```json + +[### DNS 配置 + + { + + "id": "abc123",1. 访问 Web 界面(http://your-server:8080) + + "from": "sender@gmail.com", + + "to": ["test@example.com"],2. 输入自定义邮箱名称,点击"创建邮箱"添加以下 DNS 记录: + + "subject": "测试邮件", + + "received": "2025-10-18T10:30:00Z"3. 复制生成的邮箱地址(如:test@your-domain.com) + + } + +]4. 使用该地址接收邮件``` + +``` + +5. 邮件会自动显示在"📨 收件箱"标签页中类型 名称 值 优先级 + +### 获取邮件详情 + +A mail.example.com 服务器IP - + +```http + +GET /api/messages/{local}/{id}### 发送邮件MX example.com mail.example.com 10 + +GET /api/messages/{local}/{id}?format=raw # 获取原始邮件 + +`````` + + + +### 发送邮件1. 切换到"📤 发送邮件"标签页 + + + +```http2. 填写以下信息:### 部署步骤 + +POST /api/send + +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) diff --git a/cmd/temp-mail/main.go b/cmd/temp-mail/main.go new file mode 100644 index 0000000..57f2567 --- /dev/null +++ b/cmd/temp-mail/main.go @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e252bd --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1efe90f --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f8b9750 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/httpapi/http.go b/internal/httpapi/http.go new file mode 100644 index 0000000..10e721d --- /dev/null +++ b/internal/httpapi/http.go @@ -0,0 +1,1558 @@ +package httpapi + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "mime" + "net/http" + stdmail "net/mail" + "strings" + + "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.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 + } + + // 解析请求体 + 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 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 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 + } + + // 验证发件人邮箱是否存在 + 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) + } + + // 构造完整的发件人地址 + fromAddr := fmt.Sprintf("%s@%s", fromLocal, domain) + + // 发送邮件 + msg := smtpclient.Message{ + From: fromAddr, + To: req.To, + Subject: req.Subject, + Body: req.Body, + HTML: req.HTML, + } + + 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 + } + + 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) + } +} + +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() +} + +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, + ) +} + +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 +} + +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 +} + +// 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() +} + +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 +} + +func escapeHTMLAttr(s string) string { + s = escapeHTML(s) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + return s +} + +const indexHTML = ` + + + + + 临时邮箱 - Temp Mail + + + + + + + +
+
+

📬 临时邮箱

+

快速创建临时邮箱,接收验证邮件

+
+ +
+
+
+ +
+ +
+ +
+
+
当前邮箱地址
+
(尚未创建)
+
+ +
+ + +
+ +
+ +
+ + +
+ + +
+
+
+
✨
+

开始使用

+

创建邮箱地址后,邮件将自动出现在这里

+
+
+
+ + +
+
+
+ + + 使用当前创建的邮箱地址 +
+ +
+ + + 多个收件人用逗号分隔 +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+
+ +
+ +` + +const messageDetailTemplate = ` + + + + + %s - 邮件详情 + + + + + + +
+ ← 返回收件箱 + +
+ + + + + +
+
+ +` diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go new file mode 100644 index 0000000..08f16bb --- /dev/null +++ b/internal/smtpclient/client.go @@ -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) +} diff --git a/internal/smtpserver/server.go b/internal/smtpserver/server.go new file mode 100644 index 0000000..1e657fe --- /dev/null +++ b/internal/smtpserver/server.go @@ -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() +} diff --git a/internal/storage/store.go b/internal/storage/store.go new file mode 100644 index 0000000..4e01354 --- /dev/null +++ b/internal/storage/store.go @@ -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) +} diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go new file mode 100644 index 0000000..1a27404 --- /dev/null +++ b/internal/storage/store_test.go @@ -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") } +}