chore: initial import 萌邮 MengPost (React+Vite + Go+Gin)
Made-with: Cursor
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# node
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# go
|
||||
*.exe
|
||||
*.test
|
||||
/mengpost-backend/mengpost-backend*
|
||||
/mengpost-backend/*.db
|
||||
/mengpost-backend/*.db-*
|
||||
mengpost.db
|
||||
mengpost.db-*
|
||||
|
||||
# editors
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
154
README.md
Normal file
154
README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# 萌邮 MengPost · 多邮箱管理面板
|
||||
|
||||
基于 **SMTP / IMAP** 协议的轻量多邮箱管理后台。
|
||||
|
||||
- 前端: React 18 + Vite + React Router
|
||||
- 后端: Go 1.22 + Gin + SQLite (`modernc.org/sqlite`, 纯 Go, 无需 CGO)
|
||||
- 邮件: `gopkg.in/gomail.v2` (SMTP)、`github.com/emersion/go-imap` (IMAP)
|
||||
- UI: 文档风、简洁, 自适应手机 / 电脑
|
||||
- 鉴权: 打开站点时若本地未保存密钥,先显示 **访问密钥** 输入框(与后端 `MP_TOKEN` 一致,默认 `shumengya520`);通过后写入浏览器本地存储,后续请求带 `X-Auth-Token`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
mengpost/
|
||||
dev.bat / dev.sh # 一键本地开发 (双窗口启动前后端)
|
||||
build.bat / build.sh # 构建前端
|
||||
mengpost-backend/ # Go + Gin 后端
|
||||
config/ # 配置(端口/DB/token)
|
||||
database/ # SQLite 初始化 & 迁移
|
||||
models/ # 数据模型 (accounts / sent_logs)
|
||||
services/ # smtp.go / imap.go
|
||||
middleware/ # token 鉴权中间件
|
||||
handlers/ # HTTP 处理器
|
||||
router/ # 路由聚合
|
||||
main.go # 入口
|
||||
mengpost-frontend/ # React + Vite 前端
|
||||
public/ # favicon.ico、logo.png 等静态资源
|
||||
src/
|
||||
api/ # 统一 fetch 封装, token 存 localStorage
|
||||
components/ # AccessGate / MainLayout(侧栏) / Topbar
|
||||
pages/ # Home / Admin / Accounts / Mailbox / Compose
|
||||
styles/global.css
|
||||
scripts/ # publish-gitea:用 tea 创建远端仓库的辅助脚本
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
依赖:
|
||||
- Go ≥ 1.22、Node.js ≥ 18 (SQLite 使用纯 Go 实现, **无需 CGO / gcc**)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
dev.bat
|
||||
|
||||
# macOS / Linux
|
||||
chmod +x dev.sh && ./dev.sh
|
||||
```
|
||||
|
||||
- 前端: http://127.0.0.1:5173
|
||||
- 后端: http://127.0.0.1:8787 (Vite 已代理 `/api`)
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
build.bat
|
||||
|
||||
# macOS / Linux
|
||||
./build.sh
|
||||
```
|
||||
|
||||
产物在 `mengpost-frontend/dist`。`npm run preview` 已配置与开发环境相同的 `/api` 代理。
|
||||
|
||||
若静态文件与后端**不同源**部署,构建前设置 `VITE_API_URL` 指向后端根地址(如 `http://127.0.0.1:8787`),再执行 `build`。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
| ---- | ---- | ---- |
|
||||
| `MP_PORT` | `8787` | 后端 HTTP 端口 |
|
||||
| `MP_DB` | `mengpost.db` | SQLite 文件路径 |
|
||||
| `MP_TOKEN` | `shumengya520` | 访问密钥(与前端输入一致) |
|
||||
| `MP_LOG` | `info` | 日志级别 |
|
||||
|
||||
### 前端 (Vite)
|
||||
|
||||
| 变量名 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `VITE_API_URL` | 可选。留空时请求相对路径 `/api`(开发 / preview 走 Vite 代理)。独立部署静态站时设为后端地址。 |
|
||||
|
||||
## 使用
|
||||
|
||||
1. 用 `dev.bat` / `dev.sh` 启动后,浏览器打开 **http://127.0.0.1:5173**(勿用 `file://` 打开构建产物)。
|
||||
2. 首次访问输入 **访问密钥**(与 `MP_TOKEN` 相同);顶部可 **登出** 清除本地密钥。
|
||||
3. 在「账户」页新增邮箱 (SMTP / IMAP 与授权码)。
|
||||
4. 「收件箱」拉取邮件;「写信」发信。
|
||||
|
||||
### 常见问题
|
||||
|
||||
- **未启动后端** 或端口不是 8787:密钥验证会失败或提示无法连接。
|
||||
- **未通过 Vite 访问**:直接双击 `dist/index.html` 没有 `/api` 代理,需 `npm run preview` 或配置 `VITE_API_URL` 后重新构建。
|
||||
- **使用了 `@vitejs/plugin-pwa` / Workbox**:勿让 Service Worker 缓存 `/api/*`。开发时可在 DevTools → Application → Service Workers 勾选 **Bypass for network** 或先 **Unregister** 再刷新。
|
||||
|
||||
## 常见邮箱参考
|
||||
|
||||
| 服务商 | SMTP | IMAP |
|
||||
| ---- | ---- | ---- |
|
||||
| QQ | `smtp.qq.com:465` | `imap.qq.com:993` |
|
||||
| 163 | `smtp.163.com:465` | `imap.163.com:993` |
|
||||
| Gmail | `smtp.gmail.com:465` | `imap.gmail.com:993` |
|
||||
| Outlook | `smtp.office365.com:587` | `outlook.office365.com:993` |
|
||||
|
||||
> 使用 **应用专用密码 / 授权码**, 不要直接使用邮箱登录密码。
|
||||
|
||||
## API 一览
|
||||
|
||||
所有受保护接口均需要请求头 `X-Auth-Token: <token>`。
|
||||
|
||||
```
|
||||
POST /api/auth/verify # 校验 token
|
||||
GET /api/accounts # 列出账户
|
||||
POST /api/accounts # 新增
|
||||
PUT /api/accounts/:id # 更新 (password 为空则保留)
|
||||
DELETE /api/accounts/:id # 删除
|
||||
GET /api/accounts/:id/mailboxes # IMAP 邮件夹
|
||||
GET /api/accounts/:id/messages # 列出邮件摘要 ?mailbox=&limit=
|
||||
GET /api/accounts/:id/messages/:uid # 邮件详情
|
||||
POST /api/accounts/:id/send # 发送邮件
|
||||
GET /api/accounts/:id/sent # 发送日志
|
||||
```
|
||||
|
||||
## 推送到自建 Gitea(tea CLI)
|
||||
|
||||
前置:安装 [tea](https://gitea.com/gitea/tea/releases),在 Gitea **设置 → 应用 → 生成令牌** 创建访问令牌。
|
||||
|
||||
```bash
|
||||
# 1. 添加登录(只需一次;将 URL、令牌换成你的实例)
|
||||
tea login add --url https://你的Gitea地址 --token <访问令牌> --name mygitea
|
||||
tea login default mygitea
|
||||
tea whoami
|
||||
```
|
||||
|
||||
在本项目**根目录**执行:
|
||||
|
||||
```bash
|
||||
# 2. 在 Gitea 上创建空仓库(名称可改;若已存在同名库请先删除或换名)
|
||||
tea repos create --name mengpost --description "萌邮 MengPost:基于 SMTP/IMAP 的多邮箱管理面板(React + Vite + Go + Gin + SQLite)"
|
||||
|
||||
# 3. 添加远程并推送(将下面 URL 换成 Gitea 仓库页的 HTTPS 克隆地址)
|
||||
git remote add origin https://你的Gitea/用户名/mengpost.git
|
||||
git branch -M main
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
若本地已有 `origin`,可改为:`git remote set-url origin <HTTPS 地址>` 再 `git push -u origin main`。
|
||||
|
||||
**辅助脚本**(仅执行第 2 步 `tea repos create`,推送仍需手动 `git remote` + `git push`):
|
||||
|
||||
- Windows: `powershell -ExecutionPolicy Bypass -File scripts/publish-gitea.ps1`
|
||||
- Linux / macOS: `chmod +x scripts/publish-gitea.sh && ./scripts/publish-gitea.sh`
|
||||
|
||||
## 许可
|
||||
|
||||
内部自用项目, 请按需修改。
|
||||
16
build.bat
Normal file
16
build.bat
Normal file
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
rem 构建前端静态产物到 mengpost-frontend\dist
|
||||
setlocal
|
||||
set ROOT=%~dp0
|
||||
cd /d %ROOT%mengpost-frontend
|
||||
if not exist node_modules (
|
||||
call npm install || goto :err
|
||||
)
|
||||
call npm run build || goto :err
|
||||
echo.
|
||||
echo 前端已构建: %ROOT%mengpost-frontend\dist
|
||||
endlocal & exit /b 0
|
||||
|
||||
:err
|
||||
echo 构建失败
|
||||
endlocal & exit /b 1
|
||||
9
build.sh
Normal file
9
build.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建前端静态产物到 mengpost-frontend/dist
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$ROOT/mengpost-frontend"
|
||||
[ -d node_modules ] || npm install
|
||||
npm run build
|
||||
echo ""
|
||||
echo " 前端已构建: $ROOT/mengpost-frontend/dist"
|
||||
13
dev.bat
Normal file
13
dev.bat
Normal file
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
rem 一键启动本地开发: 后端 Go 和 前端 Vite, 各自独立窗口.
|
||||
setlocal
|
||||
set ROOT=%~dp0
|
||||
|
||||
start "mengpost-backend" cmd /k "cd /d %ROOT%mengpost-backend && go mod tidy && go run ."
|
||||
start "mengpost-frontend" cmd /k "cd /d %ROOT%mengpost-frontend && (if not exist node_modules npm install) && npm run dev"
|
||||
|
||||
echo.
|
||||
echo backend : http://127.0.0.1:8787
|
||||
echo frontend: http://127.0.0.1:5173
|
||||
echo.
|
||||
endlocal
|
||||
16
dev.sh
Normal file
16
dev.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键启动本地开发: 后端 Go 和 前端 Vite. Ctrl-C 同时退出.
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
cleanup() { kill 0 2>/dev/null || true; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
( cd "$ROOT/mengpost-backend" && go mod tidy && go run . ) &
|
||||
( cd "$ROOT/mengpost-frontend" && { [ -d node_modules ] || npm install; } && npm run dev ) &
|
||||
|
||||
echo ""
|
||||
echo " backend : http://127.0.0.1:8787"
|
||||
echo " frontend: http://127.0.0.1:5173"
|
||||
echo ""
|
||||
wait
|
||||
30
mengpost-backend/config/config.go
Normal file
30
mengpost-backend/config/config.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config 存放运行期配置, 所有值均可通过环境变量覆盖.
|
||||
type Config struct {
|
||||
Port string // HTTP 监听端口
|
||||
DBPath string // SQLite 数据库路径
|
||||
Token string // 管理员访问 token
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// Load 读取环境变量生成配置, 未设置时使用默认值.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: env("MP_PORT", "8787"),
|
||||
DBPath: env("MP_DB", "mengpost.db"),
|
||||
Token: env("MP_TOKEN", "shumengya520"),
|
||||
LogLevel: env("MP_LOG", "info"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
59
mengpost-backend/database/db.go
Normal file
59
mengpost-backend/database/db.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
// Init 打开 SQLite 并执行必要的表结构初始化.
|
||||
// 使用纯 Go 的 modernc.org/sqlite, 无需 CGO.
|
||||
func Init(path string) {
|
||||
dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
DB = db
|
||||
if err := migrate(); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrate 创建所需表. 统一放在一处便于维护.
|
||||
func migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
smtp_host TEXT NOT NULL,
|
||||
smtp_port INTEGER NOT NULL,
|
||||
imap_host TEXT NOT NULL,
|
||||
imap_port INTEGER NOT NULL,
|
||||
use_tls INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS sent_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
to_addr TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
);`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := DB.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
56
mengpost-backend/go.mod
Normal file
56
mengpost-backend/go.mod
Normal file
@@ -0,0 +1,56 @@
|
||||
module mengpost-backend
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde
|
||||
github.com/emersion/go-message v0.18.1
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
186
mengpost-backend/go.sum
Normal file
186
mengpost-backend/go.sum
Normal file
@@ -0,0 +1,186 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||
github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde h1:43mBoVwooyLm1+1YVf5nvn1pSFWhw7rOpcrp1Jg/qk0=
|
||||
github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde/go.mod h1:sPwp0FFboaK/bxsrUz1lNrDMUCsZUsKC5YuM4uRVRVs=
|
||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
|
||||
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
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-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
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=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
61
mengpost-backend/handlers/account.go
Normal file
61
mengpost-backend/handlers/account.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
func ListAccounts(c *gin.Context) {
|
||||
list, err := models.ListAccounts()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateAccount(c *gin.Context) {
|
||||
var a models.Account
|
||||
if err := c.ShouldBindJSON(&a); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if a.Email == "" || a.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email 和 password 必填"})
|
||||
return
|
||||
}
|
||||
if err := models.CreateAccount(&a); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
a.Password = ""
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
func UpdateAccount(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var a models.Account
|
||||
if err := c.ShouldBindJSON(&a); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := models.UpdateAccount(&a); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteAccount(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err := models.DeleteAccount(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
27
mengpost-backend/handlers/auth.go
Normal file
27
mengpost-backend/handlers/auth.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/config"
|
||||
)
|
||||
|
||||
// VerifyToken 供前端 token 弹窗使用: 仅返回 token 是否正确.
|
||||
func VerifyToken(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if body.Token != cfg.Token {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"ok": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
111
mengpost-backend/handlers/email.go
Normal file
111
mengpost-backend/handlers/email.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/models"
|
||||
"mengpost-backend/services"
|
||||
)
|
||||
|
||||
func getAccount(c *gin.Context) *models.Account {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
acc, err := models.GetAccount(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "account not found"})
|
||||
return nil
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/mailboxes
|
||||
func ListMailboxes(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
boxes, err := services.ListMailboxes(acc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, boxes)
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/messages?mailbox=INBOX&limit=30
|
||||
func ListMessages(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
mailbox := c.DefaultQuery("mailbox", "INBOX")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||
list, err := services.FetchMessages(acc, mailbox, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/messages/:uid?mailbox=INBOX
|
||||
func GetMessage(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
uid64, _ := strconv.ParseUint(c.Param("uid"), 10, 32)
|
||||
mailbox := c.DefaultQuery("mailbox", "INBOX")
|
||||
detail, err := services.FetchMessage(acc, mailbox, uint32(uid64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// POST /api/accounts/:id/send
|
||||
func SendMessage(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
HTML bool `json:"html"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log := &models.SentLog{AccountID: acc.ID, To: body.To, Subject: body.Subject, Body: body.Body}
|
||||
if err := services.SendMail(acc, body.To, body.Subject, body.Body, body.HTML); err != nil {
|
||||
log.Status = "failed"
|
||||
log.Error = err.Error()
|
||||
_ = models.AddSentLog(log)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log.Status = "ok"
|
||||
_ = models.AddSentLog(log)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/sent?limit=50
|
||||
func ListSentLogs(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
list, err := models.ListSentLogs(acc.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
19
mengpost-backend/main.go
Normal file
19
mengpost-backend/main.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"mengpost-backend/config"
|
||||
"mengpost-backend/database"
|
||||
"mengpost-backend/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
database.Init(cfg.DBPath)
|
||||
r := router.New(cfg)
|
||||
log.Printf("萌邮 MengPost backend listening on :%s (token=%s)", cfg.Port, cfg.Token)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
24
mengpost-backend/middleware/auth.go
Normal file
24
mengpost-backend/middleware/auth.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenAuth 校验请求头中的 X-Auth-Token 或 Authorization: Bearer <token>.
|
||||
func TokenAuth(expected string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
got := c.GetHeader("X-Auth-Token")
|
||||
if got == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
got = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
if got == "" || got != expected {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
85
mengpost-backend/models/account.go
Normal file
85
mengpost-backend/models/account.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mengpost-backend/database"
|
||||
)
|
||||
|
||||
// Account 代表一个邮箱账户的完整连接信息.
|
||||
type Account struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password,omitempty"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
UseTLS bool `json:"use_tls"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ListAccounts() ([]Account, error) {
|
||||
rows, err := database.DB.Query(`SELECT id,name,email,smtp_host,smtp_port,imap_host,imap_port,use_tls,created_at FROM accounts ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
var tls int
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Email, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.UseTLS = tls == 1
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetAccount(id int64) (*Account, error) {
|
||||
row := database.DB.QueryRow(`SELECT id,name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls,created_at FROM accounts WHERE id=?`, id)
|
||||
var a Account
|
||||
var tls int
|
||||
if err := row.Scan(&a.ID, &a.Name, &a.Email, &a.Password, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.UseTLS = tls == 1
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func CreateAccount(a *Account) error {
|
||||
tls := 0
|
||||
if a.UseTLS {
|
||||
tls = 1
|
||||
}
|
||||
res, err := database.DB.Exec(
|
||||
`INSERT INTO accounts(name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
a.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateAccount(a *Account) error {
|
||||
tls := 0
|
||||
if a.UseTLS {
|
||||
tls = 1
|
||||
}
|
||||
_, err := database.DB.Exec(
|
||||
`UPDATE accounts SET name=?,email=?,password=COALESCE(NULLIF(?, ''), password),smtp_host=?,smtp_port=?,imap_host=?,imap_port=?,use_tls=? WHERE id=?`,
|
||||
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAccount(id int64) error {
|
||||
_, err := database.DB.Exec(`DELETE FROM accounts WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
50
mengpost-backend/models/sent_log.go
Normal file
50
mengpost-backend/models/sent_log.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mengpost-backend/database"
|
||||
)
|
||||
|
||||
// SentLog 记录一次外发邮件的结果.
|
||||
type SentLog struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func AddSentLog(l *SentLog) error {
|
||||
_, err := database.DB.Exec(
|
||||
`INSERT INTO sent_logs(account_id,to_addr,subject,body,status,error) VALUES(?,?,?,?,?,?)`,
|
||||
l.AccountID, l.To, l.Subject, l.Body, l.Status, l.Error,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListSentLogs(accountID int64, limit int) ([]SentLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id,account_id,to_addr,subject,body,status,error,created_at FROM sent_logs WHERE account_id=? ORDER BY id DESC LIMIT ?`,
|
||||
accountID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SentLog
|
||||
for rows.Next() {
|
||||
var l SentLog
|
||||
if err := rows.Scan(&l.ID, &l.AccountID, &l.To, &l.Subject, &l.Body, &l.Status, &l.Error, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
45
mengpost-backend/router/router.go
Normal file
45
mengpost-backend/router/router.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/config"
|
||||
"mengpost-backend/handlers"
|
||||
"mengpost-backend/middleware"
|
||||
)
|
||||
|
||||
// New 构建所有路由.
|
||||
func New(cfg *config.Config) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Auth-Token"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
r.POST("/api/auth/verify", handlers.VerifyToken(cfg))
|
||||
|
||||
auth := r.Group("/api")
|
||||
auth.Use(middleware.TokenAuth(cfg.Token))
|
||||
{
|
||||
auth.GET("/accounts", handlers.ListAccounts)
|
||||
auth.POST("/accounts", handlers.CreateAccount)
|
||||
auth.PUT("/accounts/:id", handlers.UpdateAccount)
|
||||
auth.DELETE("/accounts/:id", handlers.DeleteAccount)
|
||||
|
||||
auth.GET("/accounts/:id/mailboxes", handlers.ListMailboxes)
|
||||
auth.GET("/accounts/:id/messages", handlers.ListMessages)
|
||||
auth.GET("/accounts/:id/messages/:uid", handlers.GetMessage)
|
||||
auth.POST("/accounts/:id/send", handlers.SendMessage)
|
||||
auth.GET("/accounts/:id/sent", handlers.ListSentLogs)
|
||||
}
|
||||
return r
|
||||
}
|
||||
225
mengpost-backend/services/imap.go
Normal file
225
mengpost-backend/services/imap.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-message/mail"
|
||||
imapid "github.com/emersion/go-imap-id"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// MailSummary 是列表页展示的邮件摘要.
|
||||
type MailSummary struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
Date time.Time `json:"date"`
|
||||
Seen bool `json:"seen"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
}
|
||||
|
||||
// MailDetail 邮件详情, 包含正文.
|
||||
type MailDetail struct {
|
||||
MailSummary
|
||||
To []string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
func dial(acc *models.Account) (*client.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort)
|
||||
if acc.UseTLS {
|
||||
return client.DialTLS(addr, &tls.Config{ServerName: acc.IMAPHost})
|
||||
}
|
||||
return client.Dial(addr)
|
||||
}
|
||||
|
||||
// imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID(网易 163/126/yeah 等要求,否则报 Unsafe Login)。
|
||||
func imapSendClientID(c *client.Client) error {
|
||||
ic := imapid.NewClient(c)
|
||||
_, err := ic.ID(imapid.ID{
|
||||
imapid.FieldName: "MengPost",
|
||||
imapid.FieldVersion: "1.0",
|
||||
imapid.FieldVendor: "MengPost",
|
||||
imapid.FieldOS: "Go",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMailboxes 列出账户下可用的邮件夹名称.
|
||||
func ListMailboxes(acc *models.Account) ([]string, error) {
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Login(acc.Email, acc.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := imapSendClientID(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan *imap.MailboxInfo, 20)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.List("", "*", ch) }()
|
||||
var out []string
|
||||
for m := range ch {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchMessages 获取指定邮件夹中最近 n 封邮件摘要.
|
||||
func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummary, error) {
|
||||
if mailbox == "" {
|
||||
mailbox = "INBOX"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Login(acc.Email, acc.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := imapSendClientID(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mbox, err := c.Select(mailbox, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mbox.Messages == 0 {
|
||||
return []MailSummary{}, nil
|
||||
}
|
||||
from := uint32(1)
|
||||
if mbox.Messages > uint32(limit) {
|
||||
from = mbox.Messages - uint32(limit) + 1
|
||||
}
|
||||
seq := new(imap.SeqSet)
|
||||
seq.AddRange(from, mbox.Messages)
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}
|
||||
ch := make(chan *imap.Message, limit)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.Fetch(seq, items, ch) }()
|
||||
var out []MailSummary
|
||||
for msg := range ch {
|
||||
sum := MailSummary{UID: msg.Uid, Mailbox: mailbox}
|
||||
if msg.Envelope != nil {
|
||||
sum.Subject = msg.Envelope.Subject
|
||||
sum.Date = msg.Envelope.Date
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
a := msg.Envelope.From[0]
|
||||
sum.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
|
||||
}
|
||||
}
|
||||
for _, f := range msg.Flags {
|
||||
if f == imap.SeenFlag {
|
||||
sum.Seen = true
|
||||
}
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 最新的在前
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchMessage 根据 UID 获取单封邮件完整内容.
|
||||
func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail, error) {
|
||||
if mailbox == "" {
|
||||
mailbox = "INBOX"
|
||||
}
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Login(acc.Email, acc.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := imapSendClientID(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.Select(mailbox, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seq := new(imap.SeqSet)
|
||||
seq.AddNum(uid)
|
||||
section := &imap.BodySectionName{}
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()}
|
||||
ch := make(chan *imap.Message, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.UidFetch(seq, items, ch) }()
|
||||
msg := <-ch
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("message not found")
|
||||
}
|
||||
d := &MailDetail{MailSummary: MailSummary{UID: msg.Uid, Mailbox: mailbox}}
|
||||
if msg.Envelope != nil {
|
||||
d.Subject = msg.Envelope.Subject
|
||||
d.Date = msg.Envelope.Date
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
a := msg.Envelope.From[0]
|
||||
d.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
|
||||
}
|
||||
for _, a := range msg.Envelope.To {
|
||||
d.To = append(d.To, fmt.Sprintf("%s@%s", a.MailboxName, a.HostName))
|
||||
}
|
||||
}
|
||||
for _, f := range msg.Flags {
|
||||
if f == imap.SeenFlag {
|
||||
d.Seen = true
|
||||
}
|
||||
}
|
||||
r := msg.GetBody(section)
|
||||
if r == nil {
|
||||
return d, nil
|
||||
}
|
||||
mr, err := mail.CreateReader(r)
|
||||
if err != nil {
|
||||
b, _ := io.ReadAll(r)
|
||||
d.Text = string(b)
|
||||
return d, nil
|
||||
}
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
break
|
||||
}
|
||||
switch h := p.Header.(type) {
|
||||
case *mail.InlineHeader:
|
||||
ct, _, _ := h.ContentType()
|
||||
b, _ := io.ReadAll(p.Body)
|
||||
if strings.Contains(ct, "html") {
|
||||
d.HTML = string(b)
|
||||
} else {
|
||||
d.Text = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
23
mengpost-backend/services/smtp.go
Normal file
23
mengpost-backend/services/smtp.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// SendMail 通过指定账户发送邮件. body 支持纯文本或 HTML.
|
||||
func SendMail(acc *models.Account, to, subject, body string, html bool) error {
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", acc.Email)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
if html {
|
||||
m.SetBody("text/html", body)
|
||||
} else {
|
||||
m.SetBody("text/plain", body)
|
||||
}
|
||||
d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password)
|
||||
d.SSL = acc.UseTLS && acc.SMTPPort == 465
|
||||
return d.DialAndSend(m)
|
||||
}
|
||||
13
mengpost-frontend/index.html
Normal file
13
mengpost-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<title>萌邮 MengPost · 多邮箱管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1758
mengpost-frontend/package-lock.json
generated
Normal file
1758
mengpost-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
mengpost-frontend/package.json
Normal file
20
mengpost-frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "mengpost-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
BIN
mengpost-frontend/public/favicon.ico
Normal file
BIN
mengpost-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
mengpost-frontend/public/logo.png
Normal file
BIN
mengpost-frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
35
mengpost-frontend/src/App.jsx
Normal file
35
mengpost-frontend/src/App.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { tokenStore } from './api/client.js'
|
||||
import AccessGate from './components/AccessGate.jsx'
|
||||
import MainLayout from './components/MainLayout.jsx'
|
||||
import Topbar from './components/Topbar.jsx'
|
||||
import Home from './pages/Home.jsx'
|
||||
import Admin from './pages/Admin.jsx'
|
||||
import Accounts from './pages/Accounts.jsx'
|
||||
import Mailbox from './pages/Mailbox.jsx'
|
||||
import Compose from './pages/Compose.jsx'
|
||||
|
||||
function useToken() {
|
||||
return useSyncExternalStore(tokenStore.subscribe, () => tokenStore.get(), () => '')
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const token = useToken()
|
||||
if (!token) return <AccessGate />
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar />
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/admin/accounts" element={<Accounts />} />
|
||||
<Route path="/admin/mailbox/:id" element={<Mailbox />} />
|
||||
<Route path="/admin/compose/:id" element={<Compose />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
109
mengpost-frontend/src/api/client.js
Normal file
109
mengpost-frontend/src/api/client.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const TOKEN_KEY = 'mengpost_token'
|
||||
const EVT = 'mengpost-token'
|
||||
|
||||
/** 生产环境前后端分离时设 VITE_API_URL, 如 http://127.0.0.1:8787 */
|
||||
const base = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
export function apiUrl(path) {
|
||||
if (!path.startsWith('/')) path = '/' + path
|
||||
return base ? base + path : path
|
||||
}
|
||||
|
||||
export const tokenStore = {
|
||||
get: () => localStorage.getItem(TOKEN_KEY) || '',
|
||||
set: (t) => {
|
||||
localStorage.setItem(TOKEN_KEY, t)
|
||||
window.dispatchEvent(new Event(EVT))
|
||||
},
|
||||
clear: () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
window.dispatchEvent(new Event(EVT))
|
||||
},
|
||||
subscribe: (fn) => {
|
||||
window.addEventListener(EVT, fn)
|
||||
return () => window.removeEventListener(EVT, fn)
|
||||
},
|
||||
}
|
||||
|
||||
function parseJsonSafe(txt) {
|
||||
if (!txt || !txt.trim()) return null
|
||||
try {
|
||||
return JSON.parse(txt)
|
||||
} catch {
|
||||
return { error: txt.length > 120 ? txt.slice(0, 120) + '…' : txt }
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const t = tokenStore.get()
|
||||
if (t) headers['X-Auth-Token'] = t
|
||||
let res
|
||||
try {
|
||||
res = await fetch(apiUrl(path), {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e.message?.includes('Failed to fetch')
|
||||
? '无法连接后端:请确认已启动 mengpost-backend (默认 8787),且本页通过 Vite 开发服务器打开或已配置 VITE_API_URL'
|
||||
: e.message,
|
||||
)
|
||||
}
|
||||
const txt = await res.text()
|
||||
const data = parseJsonSafe(txt)
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.error) || res.statusText || '请求失败'
|
||||
throw new Error(msg)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/** 列表接口在空 body / SW 缓存异常时可能得到 null,统一成数组 */
|
||||
function asArray(v) {
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
/** 校验 token 时不带 X-Auth-Token,避免旧 token 干扰 */
|
||||
export async function verifyToken(token) {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(apiUrl('/api/auth/verify'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e.message?.includes('Failed to fetch')
|
||||
? '无法连接后端:请先运行 dev.bat / dev.sh 启动后端,或设置 VITE_API_URL'
|
||||
: e.message,
|
||||
)
|
||||
}
|
||||
const txt = await res.text()
|
||||
const data = parseJsonSafe(txt)
|
||||
if (!res.ok) {
|
||||
if (data && data.ok === false) return { ok: false }
|
||||
throw new Error((data && data.error) || res.statusText || '验证失败')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listAccounts: () => request('GET', '/api/accounts').then(asArray),
|
||||
createAccount: (a) => request('POST', '/api/accounts', a),
|
||||
updateAccount: (id, a) => request('PUT', `/api/accounts/${id}`, a),
|
||||
deleteAccount: (id) => request('DELETE', `/api/accounts/${id}`),
|
||||
listMailboxes: (id) => request('GET', `/api/accounts/${id}/mailboxes`).then(asArray),
|
||||
listMessages: (id, mailbox = 'INBOX', limit = 30) =>
|
||||
request('GET', `/api/accounts/${id}/messages?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`).then(
|
||||
asArray,
|
||||
),
|
||||
getMessage: (id, uid, mailbox = 'INBOX') =>
|
||||
request('GET', `/api/accounts/${id}/messages/${uid}?mailbox=${encodeURIComponent(mailbox)}`),
|
||||
send: (id, body) => request('POST', `/api/accounts/${id}/send`, body),
|
||||
listSent: (id, limit = 50) =>
|
||||
request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray),
|
||||
}
|
||||
64
mengpost-frontend/src/components/AccessGate.jsx
Normal file
64
mengpost-frontend/src/components/AccessGate.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { verifyToken, tokenStore } from '../api/client.js'
|
||||
|
||||
/** 未携带有效密钥时全屏展示, 校验通过后写入 localStorage 并刷新应用状态 */
|
||||
export default function AccessGate() {
|
||||
const [key, setKey] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const submit = async () => {
|
||||
const v = key.trim()
|
||||
if (!v) return
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
try {
|
||||
const r = await verifyToken(v)
|
||||
if (r && r.ok) {
|
||||
tokenStore.set(v)
|
||||
} else {
|
||||
setErr('密钥不正确')
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e.message || '验证失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="access-gate">
|
||||
<div className="card" style={{ width: '100%', maxWidth: 360 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<img src="/logo.png" alt="" width={36} height={36} style={{ borderRadius: 8 }} />
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>萌邮 MengPost</h1>
|
||||
</div>
|
||||
<p className="muted" style={{ marginBottom: 16, fontSize: 14 }}>
|
||||
请输入访问密钥(与后端 <code>MP_TOKEN</code> 一致)
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="访问密钥"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
{err && (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 13, marginTop: 10 }}>{err}</div>
|
||||
)}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<button type="button" className="btn-primary" disabled={loading} onClick={submit}>
|
||||
{loading ? '验证中…' : '进入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
mengpost-frontend/src/components/MainLayout.jsx
Normal file
99
mengpost-frontend/src/components/MainLayout.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
function useMailboxAccountId() {
|
||||
const loc = useLocation()
|
||||
return useMemo(() => {
|
||||
const m = loc.pathname.match(/\/admin\/mailbox\/(\d+)/)
|
||||
return m ? Number(m[1]) : null
|
||||
}, [loc.pathname])
|
||||
}
|
||||
|
||||
function accountActive(location, id) {
|
||||
return (
|
||||
location.pathname === `/admin/mailbox/${id}` || location.pathname === `/admin/compose/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMailboxLink({ accountId, name }) {
|
||||
const location = useLocation()
|
||||
const current = new URLSearchParams(location.search).get('mailbox') || 'INBOX'
|
||||
const active =
|
||||
location.pathname === `/admin/mailbox/${accountId}` && current === name
|
||||
return (
|
||||
<Link
|
||||
to={`/admin/mailbox/${accountId}?mailbox=${encodeURIComponent(name)}`}
|
||||
className={'sidebar-item' + (active ? ' active' : '')}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MainLayout() {
|
||||
const location = useLocation()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [boxes, setBoxes] = useState([])
|
||||
const accountId = useMailboxAccountId()
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => setAccounts([]))
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
setBoxes([])
|
||||
return
|
||||
}
|
||||
api
|
||||
.listMailboxes(accountId)
|
||||
.then((b) => setBoxes(Array.isArray(b) ? b : []))
|
||||
.catch(() => setBoxes([]))
|
||||
}, [accountId])
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label">邮箱</div>
|
||||
{accounts.map((a) => (
|
||||
<NavLink
|
||||
key={a.id}
|
||||
to={`/admin/mailbox/${a.id}`}
|
||||
className={() =>
|
||||
'sidebar-item' + (accountActive(location, a.id) ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<span className="sidebar-item-title">{a.name || '未命名'}</span>
|
||||
<span className="sidebar-item-sub muted">{a.email}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{accounts.length === 0 && <p className="sidebar-muted">暂无账户</p>}
|
||||
<Link to="/admin/accounts" className="sidebar-foot">
|
||||
管理账户
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{accountId != null && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label">邮件夹</div>
|
||||
{boxes.map((b) => (
|
||||
<SidebarMailboxLink key={b} accountId={accountId} name={b} />
|
||||
))}
|
||||
{boxes.length === 0 && <p className="sidebar-muted">暂无或加载中</p>}
|
||||
<Link to={`/admin/compose/${accountId}`} className="sidebar-foot">
|
||||
写邮件
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<main className="main-area">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
mengpost-frontend/src/components/Topbar.jsx
Normal file
29
mengpost-frontend/src/components/Topbar.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { tokenStore } from '../api/client.js'
|
||||
|
||||
export default function Topbar() {
|
||||
const nav = useNavigate()
|
||||
|
||||
const logout = () => {
|
||||
tokenStore.clear()
|
||||
nav('/', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<NavLink to="/admin" className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<img className="logo" src="/logo.png" alt="" width={28} height={28} />
|
||||
<span>萌邮 MengPost</span>
|
||||
</NavLink>
|
||||
<nav>
|
||||
<NavLink to="/admin" end>
|
||||
概览
|
||||
</NavLink>
|
||||
<NavLink to="/admin/accounts">账户</NavLink>
|
||||
<a onClick={logout} style={{ cursor: 'pointer' }}>
|
||||
登出
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
13
mengpost-frontend/src/main.jsx
Normal file
13
mengpost-frontend/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
225
mengpost-frontend/src/pages/Accounts.jsx
Normal file
225
mengpost-frontend/src/pages/Accounts.jsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
const empty = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 465,
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
use_tls: true,
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [list, setList] = useState([])
|
||||
const [form, setForm] = useState(empty)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const reload = () =>
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setList(Array.isArray(rows) ? rows : []))
|
||||
.catch((e) => setErr(e.message))
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null)
|
||||
setForm(empty)
|
||||
setErr('')
|
||||
setMsg('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (a) => {
|
||||
setEditing(a.id)
|
||||
setForm({ ...a, password: '' })
|
||||
setErr('')
|
||||
setMsg('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
setForm(empty)
|
||||
setErr('')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') closeModal()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [modalOpen, closeModal])
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
try {
|
||||
if (editing) {
|
||||
await api.updateAccount(editing, form)
|
||||
setMsg('已更新')
|
||||
} else {
|
||||
await api.createAccount(form)
|
||||
setMsg('已创建')
|
||||
}
|
||||
closeModal()
|
||||
reload()
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const del = async (id) => {
|
||||
if (!confirm('确定删除该账户?')) return
|
||||
try {
|
||||
await api.deleteAccount(id)
|
||||
setMsg('已删除')
|
||||
reload()
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const set = (k) => (e) => {
|
||||
const v =
|
||||
e.target.type === 'checkbox'
|
||||
? e.target.checked
|
||||
: e.target.type === 'number'
|
||||
? Number(e.target.value)
|
||||
: e.target.value
|
||||
setForm({ ...form, [k]: v })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<div className="page-toolbar">
|
||||
<h1 className="page-title">邮箱账户</h1>
|
||||
<button type="button" className="btn-primary" onClick={openNew}>
|
||||
新增账户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>}
|
||||
{err && !modalOpen && (
|
||||
<p style={{ color: 'var(--danger)', margin: '0 0 12px' }}>{err}</p>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>已有账户</h2>
|
||||
{list.length === 0 ? (
|
||||
<p className="muted">暂无账户。</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>邮箱</th>
|
||||
<th>SMTP</th>
|
||||
<th>IMAP</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.email}</td>
|
||||
<td>
|
||||
{a.smtp_host}:{a.smtp_port}
|
||||
</td>
|
||||
<td>
|
||||
{a.imap_host}:{a.imap_port}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => openEdit(a)}>
|
||||
编辑
|
||||
</button>{' '}
|
||||
<button type="button" className="btn-danger" onClick={() => del(a.id)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="modal-mask"
|
||||
role="presentation"
|
||||
onClick={(e) => e.target === e.currentTarget && closeModal()}
|
||||
>
|
||||
<div className="modal modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>{editing ? `编辑账户 #${editing}` : '新增账户'}</h2>
|
||||
<form onSubmit={save}>
|
||||
<div className="form-row">
|
||||
<label>名称</label>
|
||||
<input value={form.name} onChange={set('name')} placeholder="工作 / 私人" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>邮箱</label>
|
||||
<input value={form.email} onChange={set('email')} type="email" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>密码 / 授权码</label>
|
||||
<input
|
||||
value={form.password}
|
||||
onChange={set('password')}
|
||||
type="password"
|
||||
placeholder={editing ? '留空不修改' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>SMTP 主机</label>
|
||||
<input value={form.smtp_host} onChange={set('smtp_host')} placeholder="smtp.example.com" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>SMTP 端口</label>
|
||||
<input value={form.smtp_port} onChange={set('smtp_port')} type="number" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>IMAP 主机</label>
|
||||
<input value={form.imap_host} onChange={set('imap_host')} placeholder="imap.example.com" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>IMAP 端口</label>
|
||||
<input value={form.imap_port} onChange={set('imap_port')} type="number" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>使用 TLS/SSL</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" checked={form.use_tls} onChange={set('use_tls')} style={{ width: 'auto' }} />
|
||||
<span className="muted">建议开启</span>
|
||||
</label>
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ color: 'var(--danger)', marginTop: 8, fontSize: 14 }}>{err}</div>
|
||||
)}
|
||||
<div className="actions" style={{ marginTop: 16 }}>
|
||||
<button type="button" onClick={closeModal}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
mengpost-frontend/src/pages/Admin.jsx
Normal file
57
mengpost-frontend/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Admin() {
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch((e) => setErr(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<h1 className="page-title">概览</h1>
|
||||
<p className="muted">共 {accounts.length} 个邮箱账户</p>
|
||||
|
||||
{err && (
|
||||
<div className="card" style={{ color: 'var(--danger)' }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{accounts.length === 0 ? (
|
||||
<p className="muted">
|
||||
暂无账户,前往 <Link to="/admin/accounts">管理账户</Link> 添加。
|
||||
</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>邮箱</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.email}</td>
|
||||
<td>
|
||||
<Link to={`/admin/mailbox/${a.id}`}>打开</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
mengpost-frontend/src/pages/Compose.jsx
Normal file
78
mengpost-frontend/src/pages/Compose.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Compose() {
|
||||
const { id } = useParams()
|
||||
const accountId = Number(id)
|
||||
const nav = useNavigate()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [form, setForm] = useState({ to: '', subject: '', body: '', html: false })
|
||||
const [sending, setSending] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const set = (k) => (e) => {
|
||||
const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value
|
||||
setForm({ ...form, [k]: v })
|
||||
}
|
||||
|
||||
const send = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr(''); setMsg(''); setSending(true)
|
||||
try {
|
||||
await api.send(accountId, form)
|
||||
setMsg('发送成功')
|
||||
setForm({ to: '', subject: '', body: '', html: false })
|
||||
} catch (e) { setErr(e.message) }
|
||||
finally { setSending(false) }
|
||||
}
|
||||
|
||||
const acc = accounts.find((a) => a.id === accountId)
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<h1 className="page-title">撰写</h1>
|
||||
<p className="muted">{acc ? acc.email : `#${accountId}`}</p>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={send}>
|
||||
<div className="form-row"><label>切换账户</label>
|
||||
<select value={accountId} onChange={(e) => nav(`/admin/compose/${e.target.value}`)}>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} <{a.email}></option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row"><label>收件人</label><input value={form.to} onChange={set('to')} type="email" required /></div>
|
||||
<div className="form-row"><label>主题</label><input value={form.subject} onChange={set('subject')} required /></div>
|
||||
<div className="form-row">
|
||||
<label>HTML</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" checked={form.html} onChange={set('html')} style={{ width: 'auto' }} />
|
||||
<span className="muted">按 HTML 发送</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'start' }}>
|
||||
<label>正文</label>
|
||||
<textarea value={form.body} onChange={set('body')} rows={10} required />
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button type="submit" className="btn-primary" disabled={sending}>
|
||||
{sending ? '发送中…' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>}
|
||||
{msg && <div style={{ color: '#16a34a', marginTop: 8 }}>{msg}</div>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
mengpost-frontend/src/pages/Home.jsx
Normal file
5
mengpost-frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
export default function Home() {
|
||||
return <Navigate to="/admin" replace />
|
||||
}
|
||||
108
mengpost-frontend/src/pages/Mailbox.jsx
Normal file
108
mengpost-frontend/src/pages/Mailbox.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Mailbox() {
|
||||
const { id } = useParams()
|
||||
const accountId = Number(id)
|
||||
const [searchParams] = useSearchParams()
|
||||
const mailbox = searchParams.get('mailbox') || 'INBOX'
|
||||
|
||||
const [list, setList] = useState([])
|
||||
const [active, setActive] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setActive(null)
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
api
|
||||
.listMessages(accountId, mailbox)
|
||||
.then((l) => setList(l || []))
|
||||
.catch((e) => setErr(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [accountId, mailbox])
|
||||
|
||||
const closeDetail = useCallback(() => setActive(null), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') closeDetail()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [active, closeDetail])
|
||||
|
||||
const open = async (m) => {
|
||||
setActive({ ...m, loading: true })
|
||||
try {
|
||||
const d = await api.getMessage(accountId, m.uid, mailbox)
|
||||
setActive(d)
|
||||
} catch (e) {
|
||||
setActive({ ...m, error: e.message, loading: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<div className="page-toolbar">
|
||||
<h1 className="page-title">{mailbox}</h1>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
{loading && <p className="muted" style={{ padding: 12 }}>加载中…</p>}
|
||||
{err && <p style={{ color: 'var(--danger)', padding: 12 }}>{err}</p>}
|
||||
{!loading && !err && list.length === 0 && (
|
||||
<p className="muted" style={{ padding: 12 }}>暂无邮件</p>
|
||||
)}
|
||||
<ul className="mail-list">
|
||||
{list.map((m) => (
|
||||
<li key={m.uid} className={m.seen ? '' : 'unseen'} onClick={() => open(m)}>
|
||||
<div className="subject">{m.subject || '(无主题)'}</div>
|
||||
<div className="from">
|
||||
{m.from} · {m.date && new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div
|
||||
className="modal-mask"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mail-subject"
|
||||
onClick={(e) => e.target === e.currentTarget && closeDetail()}
|
||||
>
|
||||
<div className="modal modal-mail" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-mail-toolbar">
|
||||
<h2 id="mail-subject">{active.subject || '(无主题)'}</h2>
|
||||
<button type="button" onClick={closeDetail}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-mail-meta muted">
|
||||
<p>发件人: {active.from || '—'}</p>
|
||||
{active.to && active.to.length > 0 && (
|
||||
<p>收件人: {active.to.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-mail-body">
|
||||
{active.loading && <p className="muted">加载中…</p>}
|
||||
{active.error && <p style={{ color: 'var(--danger)' }}>{active.error}</p>}
|
||||
{!active.loading && !active.error && active.html && (
|
||||
<iframe title="mail-html" srcDoc={active.html} />
|
||||
)}
|
||||
{!active.loading && !active.error && !active.html && (
|
||||
<pre>{active.text || ''}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
363
mengpost-frontend/src/styles/global.css
Normal file
363
mengpost-frontend/src/styles/global.css
Normal file
@@ -0,0 +1,363 @@
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text: #1f2328;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--radius: 6px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
button,
|
||||
.btn {
|
||||
font: inherit;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
button:hover,
|
||||
.btn:hover { border-color: #c5c9d0; background: #f3f4f6; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
.btn-danger { color: var(--danger); border-color: var(--border); background: var(--surface); }
|
||||
.btn-danger:hover { background: #fef2f2; border-color: #fca5a5; }
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .15);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 64px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 54px);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 0 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-section:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: #f3f4f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: #eff6ff;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-item-title { font-weight: 500; }
|
||||
.sidebar-item-sub { font-size: 12px; word-break: break-all; }
|
||||
|
||||
.sidebar-muted {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sidebar-foot:hover { color: var(--accent); }
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 主内容区占满侧栏右侧可用宽度(不再限制 900px) */
|
||||
.main-inner {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 20px 28px 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: none;
|
||||
}
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.sidebar-label { width: 100%; margin-bottom: 0; }
|
||||
.sidebar-item { flex: 1 1 auto; min-width: 44%; margin-bottom: 0; }
|
||||
.sidebar-foot { width: 100%; }
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 54px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.topbar .brand {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
cursor: pointer; user-select: none; font-weight: 600;
|
||||
}
|
||||
.topbar .logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar nav { margin-left: auto; display: flex; gap: 14px; }
|
||||
.topbar nav a { color: var(--muted); }
|
||||
.topbar nav a.active { color: var(--text); font-weight: 600; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0 0 12px; font-weight: 600; }
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 15px; color: var(--muted); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; background: #fafbfc; }
|
||||
tr:hover td { background: #f9fafb; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 120px 1fr; gap: 10px 16px; align-items: center; margin-bottom: 10px; }
|
||||
@media (max-width: 640px) {
|
||||
.form-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
.topbar nav { gap: 10px; }
|
||||
.container { padding: 16px 12px 48px; }
|
||||
th:nth-child(3), td:nth-child(3),
|
||||
th:nth-child(4), td:nth-child(4) { display: none; }
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 260px 1fr; gap: 16px; }
|
||||
@media (max-width: 720px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.mail-list { list-style: none; margin: 0; padding: 0; }
|
||||
.mail-list li {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mail-list li.unseen { background: #f8fafc; }
|
||||
.mail-list li:hover { background: #f3f4f6; }
|
||||
.mail-list .subject { font-weight: 500; }
|
||||
.mail-list .from { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* token 弹框 */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(17, 24, 39, .45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: min(360px, 92vw);
|
||||
padding: 18px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.18);
|
||||
}
|
||||
.modal h3 { margin: 0 0 10px; color: var(--text); font-size: 14px; }
|
||||
.modal .actions { margin-top: 12px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.modal.modal-wide {
|
||||
width: min(640px, 94vw);
|
||||
max-height: min(92vh, 880px);
|
||||
overflow-y: auto;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.modal.modal-wide h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 阅读邮件:宽弹窗,正文区域独立滚动 */
|
||||
.modal.modal-mail {
|
||||
width: min(1320px, 98vw);
|
||||
max-height: 96vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-mail-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-mail-toolbar h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.modal-mail-meta {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-mail-meta p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.modal-mail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
.modal-mail-body iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: min(720px, 72vh);
|
||||
border: 0;
|
||||
}
|
||||
.modal-mail-body pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block; font-size: 12px; color: var(--muted);
|
||||
padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px;
|
||||
}
|
||||
|
||||
pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; font-family: var(--mono); }
|
||||
|
||||
.access-gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
18
mengpost-frontend/vite.config.js
Normal file
18
mengpost-frontend/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787',
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787',
|
||||
},
|
||||
},
|
||||
})
|
||||
23
scripts/publish-gitea.ps1
Normal file
23
scripts/publish-gitea.ps1
Normal file
@@ -0,0 +1,23 @@
|
||||
# 使用 Gitea tea CLI 创建远端仓库并推送(需在已 git init 且已 commit 的项目根目录执行)
|
||||
# 前置: tea login add --url https://你的Gitea --token <令牌> --name <别名>
|
||||
# tea login default <别名>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
if (-not (Get-Command tea -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "未找到 tea,请安装: https://gitea.com/gitea/tea/releases"
|
||||
}
|
||||
|
||||
$desc = "萌邮 MengPost:基于 SMTP/IMAP 的多邮箱管理面板(React + Vite + Go + Gin + SQLite)"
|
||||
Write-Host ">>> tea repos create --name mengpost ..."
|
||||
tea repos create --name mengpost --description $desc
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "请在 Gitea 网页打开新建的 mengpost 仓库,复制 HTTPS 克隆地址,然后执行:"
|
||||
Write-Host " git remote add origin <粘贴地址>"
|
||||
Write-Host " git branch -M main"
|
||||
Write-Host " git push -u origin main"
|
||||
Write-Host ""
|
||||
Write-Host "若已存在 origin,可改为: git remote set-url origin <地址>"
|
||||
19
scripts/publish-gitea.sh
Normal file
19
scripts/publish-gitea.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# 使用 Gitea tea CLI 创建远端仓库(需在已 git init 且已 commit 的项目根目录执行)
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v tea >/dev/null 2>&1; then
|
||||
echo "未找到 tea,请安装: https://gitea.com/gitea/tea/releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DESC='萌邮 MengPost:基于 SMTP/IMAP 的多邮箱管理面板(React + Vite + Go + Gin + SQLite)'
|
||||
echo ">>> tea repos create --name mengpost ..."
|
||||
tea repos create --name mengpost --description "$DESC"
|
||||
|
||||
echo ""
|
||||
echo "请在 Gitea 网页打开新建的 mengpost 仓库,复制 HTTPS 克隆地址,然后执行:"
|
||||
echo " git remote add origin <粘贴地址>"
|
||||
echo " git branch -M main"
|
||||
echo " git push -u origin main"
|
||||
Reference in New Issue
Block a user