feat: add Docker deployment and Microsoft OAuth support

This commit is contained in:
2026-04-18 14:02:24 +08:00
parent 6704cfb418
commit e69c0dd226
53 changed files with 5139 additions and 4029 deletions

35
.gitignore vendored
View File

@@ -1,18 +1,25 @@
# node # node
node_modules/ node_modules/
dist/ dist/
.vite/ .vite/
# go # go
*.exe mengpost-backend/.env
*.test mengpost-backend/.env.docker
/mengpost-backend/mengpost-backend*
/mengpost-backend/*.db # 生产 SQLite 数据文件(保留 data/.gitkeep
/mengpost-backend/*.db-* /data/*.db
mengpost.db /data/*.db-*
mengpost.db-* *.exe
*.test
/mengpost-backend/mengpost-backend*
/mengpost-backend/*.db
/mengpost-backend/*.db-*
mengpost.db
mengpost.db-*
# editors # editors
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
.codex

315
README.md
View File

@@ -1,154 +1,161 @@
# 萌邮 MengPost · 多邮箱管理面板 # 萌邮 MengPost · 多邮箱管理面板
基于 **SMTP / IMAP** 协议的轻量多邮箱管理后台。 基于 **SMTP / IMAP** 协议的轻量多邮箱管理后台。
- 前端: React 18 + Vite + React Router - 前端: React 18 + Vite + React Router
- 后端: Go 1.22 + Gin + SQLite (`modernc.org/sqlite`, 纯 Go, 无需 CGO) - 后端: Go 1.22 + Gin + SQLite (`modernc.org/sqlite`, 纯 Go, 无需 CGO)
- 邮件: `gopkg.in/gomail.v2` (SMTP)、`github.com/emersion/go-imap` (IMAP) - 邮件: `gopkg.in/gomail.v2` (SMTP)、`github.com/emersion/go-imap` (IMAP)
- UI: 文档风、简洁, 自适应手机 / 电脑 - UI: 文档风、简洁, 自适应手机 / 电脑
- 鉴权: 打开站点时若本地未保存密钥,先显示 **访问密钥** 输入框(与后端 `MP_TOKEN` 一致,默认 `shumengya520`);通过后写入浏览器本地存储,后续请求带 `X-Auth-Token` - 鉴权: 打开站点时若本地未保存密钥,先显示 **访问密钥** 输入框(与后端 `MP_TOKEN` 一致,默认 `shumengya520`);通过后写入浏览器本地存储,后续请求带 `X-Auth-Token`
## 目录结构 ## 目录结构
``` ```
mengpost/ mengpost/
dev.bat / dev.sh # 一键本地开发 (双窗口启动前后端) dev.bat / dev.sh # 一键本地开发 (双窗口启动前后端)
build.bat / build.sh # 构建前端 build.bat / build.sh # 构建前端
mengpost-backend/ # Go + Gin 后端 mengpost-backend/ # Go + Gin 后端
config/ # 配置(端口/DB/token) config/ # 配置(端口/DB/token)
database/ # SQLite 初始化 & 迁移 database/ # SQLite 初始化 & 迁移
models/ # 数据模型 (accounts / sent_logs) models/ # 数据模型 (accounts / sent_logs)
services/ # smtp.go / imap.go services/ # smtp.go / imap.go
middleware/ # token 鉴权中间件 middleware/ # token 鉴权中间件
handlers/ # HTTP 处理器 handlers/ # HTTP 处理器
router/ # 路由聚合 router/ # 路由聚合
main.go # 入口 main.go # 入口
mengpost-frontend/ # React + Vite 前端 mengpost-frontend/ # React + Vite 前端
public/ # favicon.ico、logo.png 等静态资源 public/ # favicon.ico、logo.png 等静态资源
src/ src/
api/ # 统一 fetch 封装, token 存 localStorage api/ # 统一 fetch 封装, token 存 localStorage
components/ # AccessGate / MainLayout(侧栏) / Topbar components/ # AccessGate / MainLayout(侧栏) / Topbar
pages/ # Home / Admin / Accounts / Mailbox / Compose pages/ # Home / Admin / Accounts / Mailbox / Compose
styles/global.css styles/global.css
scripts/ # publish-gitea用 tea 创建远端仓库的辅助脚本 scripts/ # publish-gitea用 tea 创建远端仓库的辅助脚本
``` ```
## 本地开发 ## 本地开发
依赖: 依赖:
- Go ≥ 1.22、Node.js ≥ 18 (SQLite 使用纯 Go 实现, **无需 CGO / gcc**) - Go ≥ 1.22、Node.js ≥ 18 (SQLite 使用纯 Go 实现, **无需 CGO / gcc**)
```bash ```bash
# Windows # Windows
dev.bat dev.bat
# macOS / Linux # macOS / Linux
chmod +x dev.sh && ./dev.sh chmod +x dev.sh && ./dev.sh
``` ```
- 前端: http://127.0.0.1:5173 - 前端: http://127.0.0.1:5173
- 后端: http://127.0.0.1:8787 (Vite 已代理 `/api`) - 后端: http://127.0.0.1:8787 (Vite 已代理 `/api`)
## 构建 ## 构建
```bash ```bash
# Windows # Windows
build.bat build.bat
# macOS / Linux # macOS / Linux
./build.sh ./build.sh
``` ```
产物在 `mengpost-frontend/dist``npm run preview` 已配置与开发环境相同的 `/api` 代理。 产物在 `mengpost-frontend/dist``npm run preview` 已配置与开发环境相同的 `/api` 代理。
若静态文件与后端**不同源**部署,构建前设置 `VITE_API_URL` 指向后端根地址(如 `http://127.0.0.1:8787`),再执行 `build` 若静态文件与后端**不同源**部署,构建前设置 `VITE_API_URL` 指向后端根地址(如 `http://127.0.0.1:8787`),再执行 `build`
## 环境变量 ## 环境变量
| 变量名 | 默认值 | 说明 | | 变量名 | 默认值 | 说明 |
| ---- | ---- | ---- | | ---- | ---- | ---- |
| `MP_PORT` | `8787` | 后端 HTTP 端口 | | `MP_PORT` | `8787` | 后端 HTTP 端口 |
| `MP_DB` | `mengpost.db` | SQLite 文件路径 | | `MP_DB` | `mengpost.db` | SQLite 文件路径 |
| `MP_TOKEN` | `shumengya520` | 访问密钥(与前端输入一致) | | `MP_TOKEN` | `shumengya520` | 访问密钥(与前端输入一致) |
| `MP_LOG` | `info` | 日志级别 | | `MP_LOG` | `info` | 日志级别 |
### 前端 (Vite) ### 前端 (Vite)
| 变量名 | 说明 | | 变量名 | 说明 |
| ---- | ---- | | ---- | ---- |
| `VITE_API_URL` | 可选。留空时请求相对路径 `/api`(开发 / preview 走 Vite 代理)。独立部署静态站时设为后端地址。 | | `VITE_API_URL` | 可选。留空时请求相对路径 `/api`(开发 / preview 走 Vite 代理)。独立部署静态站时设为后端地址。 |
## 使用 ## 使用
1.`dev.bat` / `dev.sh` 启动后,浏览器打开 **http://127.0.0.1:5173**(勿用 `file://` 打开构建产物)。 1.`dev.bat` / `dev.sh` 启动后,浏览器打开 **http://127.0.0.1:5173**(勿用 `file://` 打开构建产物)。
2. 首次访问输入 **访问密钥**(与 `MP_TOKEN` 相同);顶部可 **登出** 清除本地密钥。 2. 首次访问输入 **访问密钥**(与 `MP_TOKEN` 相同);顶部可 **登出** 清除本地密钥。
3. 在「账户」页新增邮箱 (SMTP / IMAP 与授权码)。 3. 在「账户」页新增邮箱 (SMTP / IMAP 与授权码)。
4. 「收件箱」拉取邮件;「写信」发信。 4. 「收件箱」拉取邮件;「写信」发信。
### 常见问题 ### 常见问题
- **未启动后端** 或端口不是 8787密钥验证会失败或提示无法连接。 - **未启动后端** 或端口不是 8787密钥验证会失败或提示无法连接。
- **未通过 Vite 访问**:直接双击 `dist/index.html` 没有 `/api` 代理,需 `npm run preview` 或配置 `VITE_API_URL` 后重新构建。 - **未通过 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** 再刷新。 - **使用了 `@vitejs/plugin-pwa` / Workbox**:勿让 Service Worker 缓存 `/api/*`。开发时可在 DevTools → Application → Service Workers 勾选 **Bypass for network** 或先 **Unregister** 再刷新。
## 常见邮箱参考 ## 常见邮箱参考
| 服务商 | SMTP | IMAP | | 服务商 | SMTP | IMAP |
| ---- | ---- | ---- | | ---- | ---- | ---- |
| QQ | `smtp.qq.com:465` | `imap.qq.com:993` | | QQ | `smtp.qq.com:465` | `imap.qq.com:993` |
| 163 | `smtp.163.com:465` | `imap.163.com:993` | | 163 | `smtp.163.com:465` | `imap.163.com:993` |
| Gmail | `smtp.gmail.com:465` | `imap.gmail.com:993` | | Gmail | `smtp.gmail.com:465` | `imap.gmail.com:993` |
| Outlook | `smtp.office365.com:587` | `outlook.office365.com:993` | | Outlook / Hotmail微软个人邮箱 | `smtp.office365.com:587`STARTTLS | `outlook.office365.com:993`SSL |
> 使用 **应用专用密码 / 授权码**, 不要直接使用邮箱登录密码。 > 使用 **应用专用密码 / 授权码**, 不要直接使用邮箱登录密码。
## API 一览 ### 微软 Outlook / Hotmail / Live2026说明
所有受保护接口均需要请求头 `X-Auth-Token: <token>` - **IMAP 默认关闭**:登录 [outlook.live.com](https://outlook.live.com) → 设置 → 邮件 → 同步电子邮件,开启 **IMAP**不同界面可能为「POP和 IMAP」
- **服务器**:收信 `outlook.office365.com:993`SSL/TLS发信 `smtp.office365.com:587`**STARTTLS**,勿填 465 除非你知道在做什么)。萌邮账户弹窗内可点 **「微软 Outlook / Hotmail」** 一键填入。
``` - **身份验证**:微软正推动 **新式验证OAuth 2.0**,传统 **用户名+密码(基础认证)****SMTP AUTH** 等场景将逐步停用(约 2026 年)。当前萌邮仍使用「应用密码 / 仍有效的账户密码」走基础认证;若发信被拒,需改用支持微软 OAuth 的客户端,或等待后续版本接入 OAuth。
POST /api/auth/verify # 校验 token - **后端**:对 `@outlook.com``@hotmail.com``@live.com``@msn.com` 会自动使用合适的 TLS `ServerName`,并在 IMAP/SMTP 报错时附加简要排查说明。
GET /api/accounts # 列出账户
POST /api/accounts # 新增 ## API 一览
PUT /api/accounts/:id # 更新 (password 为空则保留)
DELETE /api/accounts/:id # 删除 所有受保护接口均需要请求头 `X-Auth-Token: <token>`
GET /api/accounts/:id/mailboxes # IMAP 邮件夹
GET /api/accounts/:id/messages # 列出邮件摘要 ?mailbox=&limit= ```
GET /api/accounts/:id/messages/:uid # 邮件详情 POST /api/auth/verify # 校验 token
POST /api/accounts/:id/send # 发送邮件 GET /api/accounts # 列出账户
GET /api/accounts/:id/sent # 发送日志 POST /api/accounts # 新增
``` PUT /api/accounts/:id # 更新 (password 为空则保留)
DELETE /api/accounts/:id # 删除
## 推送到自建 Giteatea CLI GET /api/accounts/:id/mailboxes # IMAP 邮件夹
GET /api/accounts/:id/messages # 列出邮件摘要 ?mailbox=&limit=
前置:安装 [tea](https://gitea.com/gitea/tea/releases),在 Gitea **设置 → 应用 → 生成令牌** 创建访问令牌。 GET /api/accounts/:id/messages/:uid # 邮件详情
POST /api/accounts/:id/send # 发送邮件
```bash GET /api/accounts/:id/sent # 发送日志
# 1. 添加登录(只需一次;将 URL、令牌换成你的实例 ```
tea login add --url https://你的Gitea地址 --token <访问令牌> --name mygitea
tea login default mygitea ## 推送到自建 Giteatea CLI
tea whoami
``` 前置:安装 [tea](https://gitea.com/gitea/tea/releases),在 Gitea **设置 → 应用 → 生成令牌** 创建访问令牌。
在本项目**根目录**执行: ```bash
# 1. 添加登录(只需一次;将 URL、令牌换成你的实例
```bash tea login add --url https://你的Gitea地址 --token <访问令牌> --name mygitea
# 2. 在 Gitea 上创建空仓库(名称可改;若已存在同名库请先删除或换名) tea login default mygitea
tea repos create --name mengpost --description "萌邮 MengPost基于 SMTP/IMAP 的多邮箱管理面板React + Vite + Go + Gin + SQLite" tea whoami
```
# 3. 添加远程并推送(将下面 URL 换成 Gitea 仓库页的 HTTPS 克隆地址)
git remote add origin https://你的Gitea/用户名/mengpost.git 在本项目**根目录**执行:
git branch -M main
git push -u origin main ```bash
``` # 2. 在 Gitea 上创建空仓库(名称可改;若已存在同名库请先删除或换名)
tea repos create --name mengpost --description "萌邮 MengPost基于 SMTP/IMAP 的多邮箱管理面板React + Vite + Go + Gin + SQLite"
若本地已有 `origin`,可改为:`git remote set-url origin <HTTPS 地址>``git push -u origin main`
# 3. 添加远程并推送(将下面 URL 换成 Gitea 仓库页的 HTTPS 克隆地址)
**辅助脚本**(仅执行第 2 步 `tea repos create`,推送仍需手动 `git remote` + `git push` git remote add origin https://你的Gitea/用户名/mengpost.git
git branch -M main
- Windows: `powershell -ExecutionPolicy Bypass -File scripts/publish-gitea.ps1` git push -u origin main
- Linux / macOS: `chmod +x scripts/publish-gitea.sh && ./scripts/publish-gitea.sh` ```
## 许可 若本地已有 `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`
## 许可
内部自用项目, 请按需修改。

View File

@@ -1,16 +1,16 @@
@echo off @echo off
rem 构建前端静态产物到 mengpost-frontend\dist rem 构建前端静态产物到 mengpost-frontend\dist
setlocal setlocal
set ROOT=%~dp0 set ROOT=%~dp0
cd /d %ROOT%mengpost-frontend cd /d %ROOT%mengpost-frontend
if not exist node_modules ( if not exist node_modules (
call npm install || goto :err call npm install || goto :err
) )
call npm run build || goto :err call npm run build || goto :err
echo. echo.
echo 前端已构建: %ROOT%mengpost-frontend\dist echo 前端已构建: %ROOT%mengpost-frontend\dist
endlocal & exit /b 0 endlocal & exit /b 0
:err :err
echo 构建失败 echo 构建失败
endlocal & exit /b 1 endlocal & exit /b 1

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 构建前端静态产物到 mengpost-frontend/dist # 构建前端静态产物到 mengpost-frontend/dist
set -e set -e
ROOT="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT/mengpost-frontend" cd "$ROOT/mengpost-frontend"
[ -d node_modules ] || npm install [ -d node_modules ] || npm install
npm run build npm run build
echo "" echo ""
echo " 前端已构建: $ROOT/mengpost-frontend/dist" echo " 前端已构建: $ROOT/mengpost-frontend/dist"

0
data/.gitkeep Normal file
View File

26
dev.bat
View File

@@ -1,13 +1,13 @@
@echo off @echo off
rem 一键启动本地开发: 后端 Go 和 前端 Vite, 各自独立窗口. rem 一键启动本地开发: 后端 Go 和 前端 Vite, 各自独立窗口.
setlocal setlocal
set ROOT=%~dp0 set ROOT=%~dp0
start "mengpost-backend" cmd /k "cd /d %ROOT%mengpost-backend && go mod tidy && go run ." 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" start "mengpost-frontend" cmd /k "cd /d %ROOT%mengpost-frontend && (if not exist node_modules npm install) && npm run dev"
echo. echo.
echo backend : http://127.0.0.1:8787 echo backend : http://127.0.0.1:8787
echo frontend: http://127.0.0.1:5173 echo frontend: http://127.0.0.1:5173
echo. echo.
endlocal endlocal

32
dev.sh
View File

@@ -1,16 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 一键启动本地开发: 后端 Go 和 前端 Vite. Ctrl-C 同时退出. # 一键启动本地开发: 后端 Go 和 前端 Vite. Ctrl-C 同时退出.
set -e set -e
ROOT="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$(dirname "$0")" && pwd)"
cleanup() { kill 0 2>/dev/null || true; } cleanup() { kill 0 2>/dev/null || true; }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
( cd "$ROOT/mengpost-backend" && go mod tidy && go run . ) & ( cd "$ROOT/mengpost-backend" && go mod tidy && go run . ) &
( cd "$ROOT/mengpost-frontend" && { [ -d node_modules ] || npm install; } && npm run dev ) & ( cd "$ROOT/mengpost-frontend" && { [ -d node_modules ] || npm install; } && npm run dev ) &
echo "" echo ""
echo " backend : http://127.0.0.1:8787" echo " backend : http://127.0.0.1:8787"
echo " frontend: http://127.0.0.1:5173" echo " frontend: http://127.0.0.1:5173"
echo "" echo ""
wait wait

View File

@@ -0,0 +1,11 @@
.git
.env
.env.*
*.md
*.db
*.db-*
data/
dist/
__debug_bin
*.exe
*.test

View File

@@ -0,0 +1,24 @@
# 多阶段构建,纯 GoCGO_ENABLED=0+ modernc.org/sqlite适合 Alpine运行。
# 对外映射建议:主机 28088 ->容器 8088见 docker-compose.yml
FROM golang:alpine AS build
WORKDIR /src
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=0
RUN go build -trimpath -ldflags="-s -w" -o /out/mengpost-api .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Shanghai
WORKDIR /app
RUN mkdir -p /app/data
COPY --from=build /out/mengpost-api /app/mengpost-api
ENV GIN_MODE=release
ENV MP_PORT=8088
ENV MP_DB=/app/data/mengpost.db
EXPOSE 8088
ENTRYPOINT ["/app/mengpost-api"]

View File

@@ -1,30 +1,47 @@
package config package config
import ( import (
"os" "os"
) )
// Config 存放运行期配置, 所有值均可通过环境变量覆盖. // Config 存放运行期配置, 所有值均可通过环境变量覆盖.
type Config struct { type Config struct {
Port string // HTTP 监听端口 Port string // HTTP 监听端口
DBPath string // SQLite 数据库路径 DBPath string // SQLite 数据库路径
Token string // 管理员访问 token Token string // 管理员访问 token
LogLevel string LogLevel string
} // 微软 OAuth个人 Outlook/HotmailMP_MS_CLIENT_ID 非空则启用
MSClientID string
// Load 读取环境变量生成配置, 未设置时使用默认值. MSClientSecret string
func Load() *Config { MSRedirectURL string // 须与 Azure 应用重定向 URI 完全一致,默认本机回调
return &Config{ FrontendURL string // OAuth 完成后浏览器跳回的前端根地址
Port: env("MP_PORT", "8787"), // CORSMP_CORS_ORIGINS 为空或 * 时允许任意 Origin否则为英文逗号分隔的来源列表
DBPath: env("MP_DB", "mengpost.db"), CORSAllowOrigins string
Token: env("MP_TOKEN", "shumengya520"), }
LogLevel: env("MP_LOG", "info"),
} // Load 读取环境变量生成配置, 未设置时使用默认值.
} func Load() *Config {
return &Config{
func env(k, def string) string { Port: env("MP_PORT", "8787"),
if v := os.Getenv(k); v != "" { DBPath: env("MP_DB", "mengpost.db"),
return v Token: env("MP_TOKEN", "shumengya520"),
} LogLevel: env("MP_LOG", "info"),
return def MSClientID: env("MP_MS_CLIENT_ID", ""),
} MSClientSecret: env("MP_MS_CLIENT_SECRET", ""),
MSRedirectURL: env("MP_MS_OAUTH_REDIRECT", "http://127.0.0.1:8787/api/oauth/microsoft/callback"),
FrontendURL: env("MP_FRONTEND_URL", "http://127.0.0.1:5173"),
CORSAllowOrigins: env("MP_CORS_ORIGINS", "*"),
}
}
// MicrosoftOAuthEnabled 是否配置了微软 OAuth需同时配置 Client ID.
func (c *Config) MicrosoftOAuthEnabled() bool {
return c.MSClientID != ""
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}

View File

@@ -1,59 +1,78 @@
package database package database
import ( import (
"database/sql" "database/sql"
"log" "log"
"strings"
_ "modernc.org/sqlite"
) _ "modernc.org/sqlite"
)
var DB *sql.DB
var DB *sql.DB
// Init 打开 SQLite 并执行必要的表结构初始化.
// 使用纯 Go 的 modernc.org/sqlite, 无需 CGO. // Init 打开 SQLite 并执行必要的表结构初始化.
func Init(path string) { func Init(path string) {
dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn) db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
log.Fatalf("open sqlite: %v", err) log.Fatalf("open sqlite: %v", err)
} }
DB = db DB = db
if err := migrate(); err != nil { if err := migrate(); err != nil {
log.Fatalf("migrate: %v", err) log.Fatalf("migrate: %v", err)
} }
} }
// migrate 创建所需表. 统一放在一处便于维护. func migrate() error {
func migrate() error { stmts := []string{
stmts := []string{ `CREATE TABLE IF NOT EXISTS accounts (
`CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
name TEXT NOT NULL, email TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE, password TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL, smtp_host TEXT NOT NULL,
smtp_host TEXT NOT NULL, smtp_port INTEGER NOT NULL,
smtp_port INTEGER NOT NULL, imap_host TEXT NOT NULL,
imap_host TEXT NOT NULL, imap_port INTEGER NOT NULL,
imap_port INTEGER NOT NULL, use_tls INTEGER DEFAULT 1,
use_tls INTEGER DEFAULT 1, auth_type TEXT DEFAULT 'basic',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP oauth_refresh_token TEXT,
);`, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
`CREATE TABLE IF NOT EXISTS sent_logs ( );`,
id INTEGER PRIMARY KEY AUTOINCREMENT, `CREATE TABLE IF NOT EXISTS sent_logs (
account_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
to_addr TEXT NOT NULL, account_id INTEGER NOT NULL,
subject TEXT, to_addr TEXT NOT NULL,
body TEXT, subject TEXT,
status TEXT, body TEXT,
error TEXT, status TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, error TEXT,
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
);`, FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
} );`,
for _, s := range stmts { `CREATE TABLE IF NOT EXISTS oauth_microsoft_pending (
if _, err := DB.Exec(s); err != nil { state TEXT PRIMARY KEY,
return err refresh_token TEXT,
} email TEXT,
} created_at DATETIME DEFAULT CURRENT_TIMESTAMP
return nil );`,
} }
for _, s := range stmts {
if _, err := DB.Exec(s); err != nil {
return err
}
}
// 旧库升级:逐列添加(已存在则忽略错误)
alters := []string{
`ALTER TABLE accounts ADD COLUMN auth_type TEXT DEFAULT 'basic'`,
`ALTER TABLE accounts ADD COLUMN oauth_refresh_token TEXT`,
}
for _, s := range alters {
if _, err := DB.Exec(s); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
log.Printf("migrate alter note: %v", err)
}
}
}
return nil
}

View File

@@ -0,0 +1,24 @@
# 在 mengpost-backend 目录执行: docker compose up -d
# 数据目录:仓库根目录下的 data/(与 backend 并列),挂载到容器 /app/data
services:
mengpost-api:
build: .
image: mengpost-api:local
restart: unless-stopped
ports:
# 主机 28088 -> 容器 8088内网穿透可把 post.api.smyhub.com 指到主机 28088
- "28088:8088"
environment:
GIN_MODE: release
MP_PORT: "8088"
MP_DB: /app/data/mengpost.db
# 生产务必改为强随机字符串,或通过 .env 覆盖
MP_TOKEN: ${MP_TOKEN:-change-me-in-production}
MP_FRONTEND_URL: ${MP_FRONTEND_URL:-https://post.smyhub.com}
MP_MS_OAUTH_REDIRECT: ${MP_MS_OAUTH_REDIRECT:-https://post.api.smyhub.com/api/oauth/microsoft/callback}
MP_MS_CLIENT_ID: ${MP_MS_CLIENT_ID:-}
MP_MS_CLIENT_SECRET: ${MP_MS_CLIENT_SECRET:-}
# 留空或 * 表示允许任意 Origin可改为 https://post.smyhub.com
MP_CORS_ORIGINS: ${MP_CORS_ORIGINS:-}
volumes:
- ../data:/app/data

View File

@@ -0,0 +1,6 @@
# 复制为 .env 后由 docker compose 读取(与 docker-compose.yml 同目录)
MP_TOKEN=请改为强随机字符串
MP_FRONTEND_URL=https://post.smyhub.com
MP_MS_OAUTH_REDIRECT=https://post.api.smyhub.com/api/oauth/microsoft/callback
MP_MS_CLIENT_ID=
MP_MS_CLIENT_SECRET=

View File

@@ -1,13 +1,16 @@
module mengpost-backend module mengpost-backend
go 1.22 go 1.25.0
require ( require (
github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap v1.2.1
github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde
github.com/emersion/go-message v0.18.1 github.com/emersion/go-message v0.18.1
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
modernc.org/sqlite v1.33.1 modernc.org/sqlite v1.33.1
) )
@@ -18,7 +21,6 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect

View File

@@ -19,8 +19,11 @@ github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde/go.mod h1:sPwp
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= 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 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= 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-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= 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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
@@ -49,6 +52,8 @@ 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/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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=

View File

@@ -1,61 +1,65 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengpost-backend/models" "mengpost-backend/models"
) )
func ListAccounts(c *gin.Context) { func ListAccounts(c *gin.Context) {
list, err := models.ListAccounts() list, err := models.ListAccounts()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, list) c.JSON(http.StatusOK, list)
} }
func CreateAccount(c *gin.Context) { func CreateAccount(c *gin.Context) {
var a models.Account var a models.Account
if err := c.ShouldBindJSON(&a); err != nil { if err := c.ShouldBindJSON(&a); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if a.Email == "" || a.Password == "" { if a.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email 和 password 必填"}) c.JSON(http.StatusBadRequest, gin.H{"error": "email 必填"})
return return
} }
if err := models.CreateAccount(&a); err != nil { if a.AuthType != models.AuthTypeOAuthMicrosoft && a.Password == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "普通账户需要填写 password"})
return return
} }
a.Password = "" if err := models.CreateAccount(&a); err != nil {
c.JSON(http.StatusOK, a) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} return
}
func UpdateAccount(c *gin.Context) { a.Password = ""
id, _ := strconv.ParseInt(c.Param("id"), 10, 64) c.JSON(http.StatusOK, a)
var a models.Account }
if err := c.ShouldBindJSON(&a); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) func UpdateAccount(c *gin.Context) {
return id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
} var a models.Account
a.ID = id if err := c.ShouldBindJSON(&a); err != nil {
if err := models.UpdateAccount(&a); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return
return }
} a.ID = id
c.JSON(http.StatusOK, gin.H{"ok": true}) if err := models.UpdateAccount(&a); err != nil {
} c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
func DeleteAccount(c *gin.Context) { }
id, _ := strconv.ParseInt(c.Param("id"), 10, 64) c.JSON(http.StatusOK, gin.H{"ok": true})
if err := models.DeleteAccount(id); err != nil { }
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return func DeleteAccount(c *gin.Context) {
} id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
c.JSON(http.StatusOK, gin.H{"ok": true}) 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})
}

View File

@@ -1,27 +1,27 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengpost-backend/config" "mengpost-backend/config"
) )
// VerifyToken 供前端 token 弹窗使用: 仅返回 token 是否正确. // VerifyToken 供前端 token 弹窗使用: 仅返回 token 是否正确.
func VerifyToken(cfg *config.Config) gin.HandlerFunc { func VerifyToken(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var body struct { var body struct {
Token string `json:"token"` Token string `json:"token"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return return
} }
if body.Token != cfg.Token { if body.Token != cfg.Token {
c.JSON(http.StatusUnauthorized, gin.H{"ok": false}) c.JSON(http.StatusUnauthorized, gin.H{"ok": false})
return return
} }
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
} }

View File

@@ -1,111 +1,112 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengpost-backend/models" "mengpost-backend/models"
"mengpost-backend/services" "mengpost-backend/services"
) )
func getAccount(c *gin.Context) *models.Account { func getAccount(c *gin.Context) *models.Account {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64) id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
acc, err := models.GetAccount(id) acc, err := models.GetAccount(id)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "account not found"})
return nil return nil
} }
return acc return acc
} }
// GET /api/accounts/:id/mailboxes // GET /api/accounts/:id/mailboxes
func ListMailboxes(c *gin.Context) { func ListMailboxes(c *gin.Context) {
acc := getAccount(c) acc := getAccount(c)
if acc == nil { if acc == nil {
return return
} }
boxes, err := services.ListMailboxes(acc) boxes, err := services.ListMailboxes(acc)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
return return
} }
c.JSON(http.StatusOK, boxes) c.JSON(http.StatusOK, boxes)
} }
// GET /api/accounts/:id/messages?mailbox=INBOX&limit=30 // GET /api/accounts/:id/messages?mailbox=INBOX&limit=30
func ListMessages(c *gin.Context) { func ListMessages(c *gin.Context) {
acc := getAccount(c) acc := getAccount(c)
if acc == nil { if acc == nil {
return return
} }
mailbox := c.DefaultQuery("mailbox", "INBOX") mailbox := c.DefaultQuery("mailbox", "INBOX")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
list, err := services.FetchMessages(acc, mailbox, limit) list, err := services.FetchMessages(acc, mailbox, limit)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
return return
} }
c.JSON(http.StatusOK, list) c.JSON(http.StatusOK, list)
} }
// GET /api/accounts/:id/messages/:uid?mailbox=INBOX // GET /api/accounts/:id/messages/:uid?mailbox=INBOX
func GetMessage(c *gin.Context) { func GetMessage(c *gin.Context) {
acc := getAccount(c) acc := getAccount(c)
if acc == nil { if acc == nil {
return return
} }
uid64, _ := strconv.ParseUint(c.Param("uid"), 10, 32) uid64, _ := strconv.ParseUint(c.Param("uid"), 10, 32)
mailbox := c.DefaultQuery("mailbox", "INBOX") mailbox := c.DefaultQuery("mailbox", "INBOX")
detail, err := services.FetchMessage(acc, mailbox, uint32(uid64)) detail, err := services.FetchMessage(acc, mailbox, uint32(uid64))
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
return return
} }
c.JSON(http.StatusOK, detail) c.JSON(http.StatusOK, detail)
} }
// POST /api/accounts/:id/send // POST /api/accounts/:id/send
func SendMessage(c *gin.Context) { func SendMessage(c *gin.Context) {
acc := getAccount(c) acc := getAccount(c)
if acc == nil { if acc == nil {
return return
} }
var body struct { var body struct {
To string `json:"to"` To string `json:"to"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
HTML bool `json:"html"` HTML bool `json:"html"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
log := &models.SentLog{AccountID: acc.ID, To: body.To, Subject: body.Subject, Body: body.Body} 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 { if err := services.SendMail(acc, body.To, body.Subject, body.Body, body.HTML); err != nil {
log.Status = "failed" wrapped := services.WrapMicrosoftMailErr(acc.Email, err)
log.Error = err.Error() log.Status = "failed"
_ = models.AddSentLog(log) log.Error = wrapped.Error()
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) _ = models.AddSentLog(log)
return c.JSON(http.StatusInternalServerError, gin.H{"error": wrapped.Error()})
} return
log.Status = "ok" }
_ = models.AddSentLog(log) log.Status = "ok"
c.JSON(http.StatusOK, gin.H{"ok": true}) _ = models.AddSentLog(log)
} c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/accounts/:id/sent?limit=50
func ListSentLogs(c *gin.Context) { // GET /api/accounts/:id/sent?limit=50
acc := getAccount(c) func ListSentLogs(c *gin.Context) {
if acc == nil { acc := getAccount(c)
return if acc == nil {
} return
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) }
list, err := models.ListSentLogs(acc.ID, limit) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if err != nil { list, err := models.ListSentLogs(acc.ID, limit)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} return
c.JSON(http.StatusOK, list) }
} c.JSON(http.StatusOK, list)
}

View File

@@ -0,0 +1,171 @@
package handlers
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"mengpost-backend/config"
"mengpost-backend/models"
"mengpost-backend/services"
)
const (
msSMTPHost = "smtp.office365.com"
msSMTPPort = 587
msIMAPHost = "outlook.office365.com"
msIMAPPort = 993
oauthPath = "/admin/accounts"
tokenTimeout = 90 * time.Second
)
func randomOAuthState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// MicrosoftOAuthEnabled GET /api/oauth/microsoft/enabled
func MicrosoftOAuthEnabled(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"enabled": cfg.MicrosoftOAuthEnabled()})
}
}
// MicrosoftOAuthStart GET /api/oauth/microsoft/start
func MicrosoftOAuthStart(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
if !cfg.MicrosoftOAuthEnabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "未配置微软 OAuthMP_MS_CLIENT_ID"})
return
}
state, err := randomOAuthState()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := models.InsertOAuthPendingState(state); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
authURL := services.MicrosoftAuthCodeURL(cfg, state)
c.JSON(http.StatusOK, gin.H{"url": authURL, "state": state})
}
}
// MicrosoftOAuthCallback GET /api/oauth/microsoft/callback微软公网回调无 token
func MicrosoftOAuthCallback(cfg *config.Config) gin.HandlerFunc {
frontendAccounts := strings.TrimSuffix(cfg.FrontendURL, "/") + oauthPath
return func(c *gin.Context) {
q := c.Request.URL.Query()
if errMsg := q.Get("error"); errMsg != "" {
desc := q.Get("error_description")
msg := errMsg
if desc != "" {
msg = errMsg + ": " + desc
}
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(msg))
return
}
state := q.Get("state")
code := q.Get("code")
if state == "" || code == "" {
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape("缺少 code 或 state"))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), tokenTimeout)
defer cancel()
email, refresh, _, _, err := services.ExchangeMicrosoftCode(ctx, cfg, code)
if err != nil {
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error()))
return
}
if err := models.UpdateOAuthPendingTokens(state, refresh, email); err != nil {
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error()))
return
}
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_ms="+url.QueryEscape(state))
}
}
type microsoftFinishBody struct {
State string `json:"state"`
}
// MicrosoftOAuthFinish POST /api/oauth/microsoft/finish
func MicrosoftOAuthFinish() gin.HandlerFunc {
return func(c *gin.Context) {
var body microsoftFinishBody
if err := c.ShouldBindJSON(&body); err != nil || body.State == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 JSON: {\"state\":\"...\"}"})
return
}
refresh, email, err := models.TakeOAuthPending(body.State)
if err != nil {
if errors.Is(err, models.ErrOAuthPendingNotFound) {
c.JSON(http.StatusBadRequest, gin.H{"error": "登录会话无效或已使用,请重新发起 OAuth"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
email = strings.ToLower(strings.TrimSpace(email))
acc, err := models.GetAccountByEmail(email)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err == nil {
acc.AuthType = models.AuthTypeOAuthMicrosoft
acc.OAuthRefreshToken = refresh
acc.SMTPHost = msSMTPHost
acc.SMTPPort = msSMTPPort
acc.IMAPHost = msIMAPHost
acc.IMAPPort = msIMAPPort
acc.UseTLS = true
if err := models.UpdateAccount(acc); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
acc.Password = ""
acc.OAuthRefreshToken = ""
c.JSON(http.StatusOK, acc)
return
}
local := email
if at := strings.IndexByte(email, '@'); at > 0 {
local = email[:at]
}
na := &models.Account{
Name: local,
Email: email,
Password: "",
SMTPHost: msSMTPHost,
SMTPPort: msSMTPPort,
IMAPHost: msIMAPHost,
IMAPPort: msIMAPPort,
UseTLS: true,
AuthType: models.AuthTypeOAuthMicrosoft,
OAuthRefreshToken: refresh,
}
if err := models.CreateAccount(na); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
na.Password = ""
na.OAuthRefreshToken = ""
c.JSON(http.StatusOK, na)
}
}

View File

@@ -1,19 +1,26 @@
package main package main
import ( import (
"log" "log"
"mengpost-backend/config" "github.com/joho/godotenv"
"mengpost-backend/database"
"mengpost-backend/router" "mengpost-backend/config"
) "mengpost-backend/database"
"mengpost-backend/router"
func main() { "mengpost-backend/services"
cfg := config.Load() )
database.Init(cfg.DBPath)
r := router.New(cfg) func main() {
log.Printf("萌邮 MengPost backend listening on :%s (token=%s)", cfg.Port, cfg.Token) // 从工作目录加载 .envKEY=value缺失则忽略仍可用系统环境变量。
if err := r.Run(":" + cfg.Port); err != nil { _ = godotenv.Load()
log.Fatal(err)
} cfg := config.Load()
} services.SetAppConfig(cfg)
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)
}
}

View File

@@ -1,24 +1,24 @@
package middleware package middleware
import ( import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// TokenAuth 校验请求头中的 X-Auth-Token 或 Authorization: Bearer <token>. // TokenAuth 校验请求头中的 X-Auth-Token 或 Authorization: Bearer <token>.
func TokenAuth(expected string) gin.HandlerFunc { func TokenAuth(expected string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
got := c.GetHeader("X-Auth-Token") got := c.GetHeader("X-Auth-Token")
if got == "" { if got == "" {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
got = strings.TrimPrefix(auth, "Bearer ") got = strings.TrimPrefix(auth, "Bearer ")
} }
if got == "" || got != expected { if got == "" || got != expected {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return return
} }
c.Next() c.Next()
} }
} }

View File

@@ -1,85 +1,139 @@
package models package models
import ( import (
"time" "database/sql"
"time"
"mengpost-backend/database"
) "mengpost-backend/database"
)
// Account 代表一个邮箱账户的完整连接信息.
type Account struct { // Account 邮箱账户basic 为密码oauth_microsoft 使用刷新令牌).
ID int64 `json:"id"` type Account struct {
Name string `json:"name"` ID int64 `json:"id"`
Email string `json:"email"` Name string `json:"name"`
Password string `json:"password,omitempty"` Email string `json:"email"`
SMTPHost string `json:"smtp_host"` Password string `json:"password,omitempty"`
SMTPPort int `json:"smtp_port"` SMTPHost string `json:"smtp_host"`
IMAPHost string `json:"imap_host"` SMTPPort int `json:"smtp_port"`
IMAPPort int `json:"imap_port"` IMAPHost string `json:"imap_host"`
UseTLS bool `json:"use_tls"` IMAPPort int `json:"imap_port"`
CreatedAt time.Time `json:"created_at"` UseTLS bool `json:"use_tls"`
} AuthType string `json:"auth_type"`
OAuthRefreshToken string `json:"-"`
func ListAccounts() ([]Account, error) { CreatedAt time.Time `json:"created_at"`
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 func readAuthType(ns sql.NullString) string {
} if ns.Valid && ns.String != "" {
defer rows.Close() return ns.String
var out []Account }
for rows.Next() { return AuthTypeBasic
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 { func ListAccounts() ([]Account, error) {
return nil, err rows, err := database.DB.Query(`
} SELECT id,name,email,smtp_host,smtp_port,imap_host,imap_port,use_tls,
a.UseTLS = tls == 1 IFNULL(auth_type,'basic'), created_at
out = append(out, a) FROM accounts ORDER BY id DESC`)
} if err != nil {
return out, nil return nil, err
} }
defer rows.Close()
func GetAccount(id int64) (*Account, error) { var out []Account
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) for rows.Next() {
var a Account var a Account
var tls int 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 { var authType sql.NullString
return nil, err if err := rows.Scan(&a.ID, &a.Name, &a.Email, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &authType, &a.CreatedAt); err != nil {
} return nil, err
a.UseTLS = tls == 1 }
return &a, nil a.UseTLS = tls == 1
} a.AuthType = readAuthType(authType)
out = append(out, a)
func CreateAccount(a *Account) error { }
tls := 0 return out, nil
if a.UseTLS { }
tls = 1
} func scanFullAccount(row *sql.Row) (*Account, error) {
res, err := database.DB.Exec( var a Account
`INSERT INTO accounts(name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls) VALUES(?,?,?,?,?,?,?,?)`, var tls int
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, var authType, oauthRefresh sql.NullString
) err := row.Scan(&a.ID, &a.Name, &a.Email, &a.Password, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &authType, &oauthRefresh, &a.CreatedAt)
if err != nil { if err != nil {
return err return nil, err
} }
id, _ := res.LastInsertId() a.UseTLS = tls == 1
a.ID = id a.AuthType = readAuthType(authType)
return nil if oauthRefresh.Valid {
} a.OAuthRefreshToken = oauthRefresh.String
}
func UpdateAccount(a *Account) error { return &a, nil
tls := 0 }
if a.UseTLS {
tls = 1 func GetAccount(id int64) (*Account, error) {
} row := database.DB.QueryRow(`
_, err := database.DB.Exec( SELECT id,name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls,
`UPDATE accounts SET name=?,email=?,password=COALESCE(NULLIF(?, ''), password),smtp_host=?,smtp_port=?,imap_host=?,imap_port=?,use_tls=? WHERE id=?`, IFNULL(auth_type,'basic'), oauth_refresh_token, created_at
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.ID, FROM accounts WHERE id=?`, id)
) return scanFullAccount(row)
return err }
}
func GetAccountByEmail(email string) (*Account, error) {
func DeleteAccount(id int64) error { row := database.DB.QueryRow(`
_, err := database.DB.Exec(`DELETE FROM accounts WHERE id=?`, id) SELECT id,name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls,
return err IFNULL(auth_type,'basic'), oauth_refresh_token, created_at
} FROM accounts WHERE email=?`, email)
return scanFullAccount(row)
}
func CreateAccount(a *Account) error {
if a.AuthType == "" {
a.AuthType = AuthTypeBasic
}
tls := 0
if a.UseTLS {
tls = 1
}
var oauth any
if a.OAuthRefreshToken != "" {
oauth = a.OAuthRefreshToken
}
res, err := database.DB.Exec(
`INSERT INTO accounts(name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls,auth_type,oauth_refresh_token)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.AuthType, oauth,
)
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
}
if a.AuthType == "" {
a.AuthType = AuthTypeBasic
}
_, err := database.DB.Exec(
`UPDATE accounts SET name=?,email=?,password=COALESCE(NULLIF(?, ''), password),smtp_host=?,smtp_port=?,imap_host=?,imap_port=?,use_tls=?,auth_type=?,
oauth_refresh_token=COALESCE(NULLIF(?, ''), oauth_refresh_token) WHERE id=?`,
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.AuthType,
a.OAuthRefreshToken, a.ID,
)
return err
}
func UpdateAccountOAuthRefresh(id int64, refresh string) error {
_, err := database.DB.Exec(`UPDATE accounts SET oauth_refresh_token=? WHERE id=?`, refresh, id)
return err
}
func DeleteAccount(id int64) error {
_, err := database.DB.Exec(`DELETE FROM accounts WHERE id=?`, id)
return err
}

View File

@@ -0,0 +1,6 @@
package models
const (
AuthTypeBasic = "basic"
AuthTypeOAuthMicrosoft = "oauth_microsoft"
)

View File

@@ -0,0 +1,47 @@
package models
import (
"database/sql"
"errors"
"mengpost-backend/database"
)
// ErrOAuthPendingNotFound state 不存在或尚未完成微软回调.
var ErrOAuthPendingNotFound = errors.New("oauth state 无效或已过期")
func InsertOAuthPendingState(state string) error {
_, err := database.DB.Exec(`INSERT INTO oauth_microsoft_pending(state) VALUES(?)`, state)
return err
}
func UpdateOAuthPendingTokens(state, refresh, email string) error {
res, err := database.DB.Exec(
`UPDATE oauth_microsoft_pending SET refresh_token=?, email=? WHERE state=?`,
refresh, email, state,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrOAuthPendingNotFound
}
return nil
}
// TakeOAuthPending 取出并删除 pending用于完成账户创建.
func TakeOAuthPending(state string) (refresh, email string, err error) {
row := database.DB.QueryRow(
`SELECT refresh_token, email FROM oauth_microsoft_pending WHERE state=? AND refresh_token IS NOT NULL AND email IS NOT NULL`,
state,
)
if err := row.Scan(&refresh, &email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrOAuthPendingNotFound
}
return "", "", err
}
_, _ = database.DB.Exec(`DELETE FROM oauth_microsoft_pending WHERE state=?`, state)
return refresh, email, nil
}

View File

@@ -1,50 +1,50 @@
package models package models
import ( import (
"time" "time"
"mengpost-backend/database" "mengpost-backend/database"
) )
// SentLog 记录一次外发邮件的结果. // SentLog 记录一次外发邮件的结果.
type SentLog struct { type SentLog struct {
ID int64 `json:"id"` ID int64 `json:"id"`
AccountID int64 `json:"account_id"` AccountID int64 `json:"account_id"`
To string `json:"to"` To string `json:"to"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error"` Error string `json:"error"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
func AddSentLog(l *SentLog) error { func AddSentLog(l *SentLog) error {
_, err := database.DB.Exec( _, err := database.DB.Exec(
`INSERT INTO sent_logs(account_id,to_addr,subject,body,status,error) VALUES(?,?,?,?,?,?)`, `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, l.AccountID, l.To, l.Subject, l.Body, l.Status, l.Error,
) )
return err return err
} }
func ListSentLogs(accountID int64, limit int) ([]SentLog, error) { func ListSentLogs(accountID int64, limit int) ([]SentLog, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
rows, err := database.DB.Query( 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 ?`, `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, accountID, limit,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []SentLog var out []SentLog
for rows.Next() { for rows.Next() {
var l SentLog 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 { 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 return nil, err
} }
out = append(out, l) out = append(out, l)
} }
return out, nil return out, nil
} }

View File

@@ -1,45 +1,76 @@
package router package router
import ( import (
"time" "strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"mengpost-backend/config"
"mengpost-backend/handlers" "mengpost-backend/config"
"mengpost-backend/middleware" "mengpost-backend/handlers"
) "mengpost-backend/middleware"
)
// New 构建所有路由.
func New(cfg *config.Config) *gin.Engine { func corsConfig(cfg *config.Config) cors.Config {
r := gin.Default() c := cors.Config{
AllowMethods: []string{
r.Use(cors.New(cors.Config{ "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
AllowOrigins: []string{"*"}, },
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Auth-Token"}, "Origin", "Content-Length", "Content-Type", "Accept",
ExposeHeaders: []string{"Content-Length"}, "Authorization", "X-Auth-Token", "X-Requested-With",
AllowCredentials: false, },
MaxAge: 12 * time.Hour, 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)) raw := strings.TrimSpace(cfg.CORSAllowOrigins)
if raw == "" || raw == "*" {
auth := r.Group("/api") // 生产/跨域穿透:允许任意 Origin与 AllowCredentials=false 搭配,适合前后端分域名)
auth.Use(middleware.TokenAuth(cfg.Token)) c.AllowOriginFunc = func(origin string) bool { return true }
{ return c
auth.GET("/accounts", handlers.ListAccounts) }
auth.POST("/accounts", handlers.CreateAccount) parts := strings.Split(raw, ",")
auth.PUT("/accounts/:id", handlers.UpdateAccount) for _, p := range parts {
auth.DELETE("/accounts/:id", handlers.DeleteAccount) p = strings.TrimSpace(p)
if p != "" {
auth.GET("/accounts/:id/mailboxes", handlers.ListMailboxes) c.AllowOrigins = append(c.AllowOrigins, p)
auth.GET("/accounts/:id/messages", handlers.ListMessages) }
auth.GET("/accounts/:id/messages/:uid", handlers.GetMessage) }
auth.POST("/accounts/:id/send", handlers.SendMessage) if len(c.AllowOrigins) == 0 {
auth.GET("/accounts/:id/sent", handlers.ListSentLogs) c.AllowOriginFunc = func(origin string) bool { return true }
} }
return r return c
} }
// New 构建所有路由.
func New(cfg *config.Config) *gin.Engine {
r := gin.Default()
r.Use(cors.New(corsConfig(cfg)))
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
r.POST("/api/auth/verify", handlers.VerifyToken(cfg))
r.GET("/api/oauth/microsoft/enabled", handlers.MicrosoftOAuthEnabled(cfg))
r.GET("/api/oauth/microsoft/callback", handlers.MicrosoftOAuthCallback(cfg))
auth := r.Group("/api")
auth.Use(middleware.TokenAuth(cfg.Token))
{
auth.GET("/oauth/microsoft/start", handlers.MicrosoftOAuthStart(cfg))
auth.POST("/oauth/microsoft/finish", handlers.MicrosoftOAuthFinish())
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
}

View File

@@ -0,0 +1,10 @@
package services
import "mengpost-backend/config"
// AppCfg 供 IMAP/SMTP 刷新微软令牌等使用main 启动时注入).
var AppCfg *config.Config
func SetAppConfig(c *config.Config) {
AppCfg = c
}

View File

@@ -1,225 +1,258 @@
package services package services
import ( import (
"crypto/tls" "context"
"fmt" "crypto/tls"
"io" "fmt"
"strings" "io"
"time" "strings"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client" "github.com/emersion/go-imap"
"github.com/emersion/go-message/mail" "github.com/emersion/go-imap/client"
imapid "github.com/emersion/go-imap-id" "github.com/emersion/go-message/mail"
imapid "github.com/emersion/go-imap-id"
"mengpost-backend/models"
) "mengpost-backend/models"
)
// MailSummary 是列表页展示的邮件摘要.
type MailSummary struct { // MailSummary 是列表页展示的邮件摘要.
UID uint32 `json:"uid"` type MailSummary struct {
Subject string `json:"subject"` UID uint32 `json:"uid"`
From string `json:"from"` Subject string `json:"subject"`
Date time.Time `json:"date"` From string `json:"from"`
Seen bool `json:"seen"` Date time.Time `json:"date"`
Mailbox string `json:"mailbox"` Seen bool `json:"seen"`
} Mailbox string `json:"mailbox"`
}
// MailDetail 邮件详情, 包含正文.
type MailDetail struct { // MailDetail 邮件详情, 包含正文.
MailSummary type MailDetail struct {
To []string `json:"to"` MailSummary
Text string `json:"text"` To []string `json:"to"`
HTML string `json:"html"` 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) func dial(acc *models.Account) (*client.Client, error) {
if acc.UseTLS { addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort)
return client.DialTLS(addr, &tls.Config{ServerName: acc.IMAPHost}) if acc.UseTLS {
} cfg := IMAPTLSConfig(acc)
return client.Dial(addr) if cfg == nil {
} cfg = &tls.Config{MinVersion: tls.VersionTLS12, ServerName: acc.IMAPHost}
}
// imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID网易 163/126/yeah 等要求,否则报 Unsafe Login return client.DialTLS(addr, cfg)
func imapSendClientID(c *client.Client) error { }
ic := imapid.NewClient(c) return client.Dial(addr)
_, err := ic.ID(imapid.ID{ }
imapid.FieldName: "MengPost",
imapid.FieldVersion: "1.0", func imapAuthenticate(c *client.Client, acc *models.Account) error {
imapid.FieldVendor: "MengPost", if acc.AuthType == models.AuthTypeOAuthMicrosoft {
imapid.FieldOS: "Go", if AppCfg == nil {
}) return fmt.Errorf("服务未配置微软 OAuth")
return err }
} mt, err := RefreshMicrosoftAccess(context.Background(), AppCfg, acc.OAuthRefreshToken)
if err != nil {
// ListMailboxes 列出账户下可用的邮件夹名称. return err
func ListMailboxes(acc *models.Account) ([]string, error) { }
c, err := dial(acc) if mt.RefreshToken != "" {
if err != nil { _ = models.UpdateAccountOAuthRefresh(acc.ID, mt.RefreshToken)
return nil, err }
} if err := c.Authenticate(NewXOAUTH2Client(acc.Email, mt.AccessToken)); err != nil {
defer c.Logout() return err
if err := c.Login(acc.Email, acc.Password); err != nil { }
return nil, err } else {
} if err := c.Login(acc.Email, acc.Password); err != nil {
if err := imapSendClientID(c); err != nil { return err
return nil, err }
} }
ch := make(chan *imap.MailboxInfo, 20) // Outlook/Exchange IMAP 在 AUTH 后对 RFC2971 ID 常返回 “Command Argument Error.12”;网易等仍需 ID。
done := make(chan error, 1) if !imapShouldSendClientID(acc) {
go func() { done <- c.List("", "*", ch) }() return nil
var out []string }
for m := range ch { return imapSendClientID(c)
out = append(out, m.Name) }
}
if err := <-done; err != nil { func imapShouldSendClientID(acc *models.Account) bool {
return nil, err if acc.AuthType == models.AuthTypeOAuthMicrosoft {
} return false
return out, nil }
} if IsMicrosoftConsumerEmail(acc.Email) && strings.EqualFold(acc.IMAPHost, "outlook.office365.com") {
return false
// FetchMessages 获取指定邮件夹中最近 n 封邮件摘要. }
func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummary, error) { return true
if mailbox == "" { }
mailbox = "INBOX"
} // imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID网易 163/126/yeah 等要求,否则报 Unsafe Login
if limit <= 0 { func imapSendClientID(c *client.Client) error {
limit = 30 ic := imapid.NewClient(c)
} _, err := ic.ID(imapid.ID{
c, err := dial(acc) imapid.FieldName: "MengPost",
if err != nil { imapid.FieldVersion: "1.0",
return nil, err imapid.FieldVendor: "MengPost",
} imapid.FieldOS: "Go",
defer c.Logout() })
if err := c.Login(acc.Email, acc.Password); err != nil { return err
return nil, err }
}
if err := imapSendClientID(c); err != nil { // ListMailboxes 列出账户下可用的邮件夹名称.
return nil, err func ListMailboxes(acc *models.Account) ([]string, error) {
} c, err := dial(acc)
mbox, err := c.Select(mailbox, true) if err != nil {
if err != nil { return nil, err
return nil, err }
} defer c.Logout()
if mbox.Messages == 0 { if err := imapAuthenticate(c, acc); err != nil {
return []MailSummary{}, nil return nil, err
} }
from := uint32(1) ch := make(chan *imap.MailboxInfo, 20)
if mbox.Messages > uint32(limit) { done := make(chan error, 1)
from = mbox.Messages - uint32(limit) + 1 go func() { done <- c.List("", "*", ch) }()
} var out []string
seq := new(imap.SeqSet) for m := range ch {
seq.AddRange(from, mbox.Messages) out = append(out, m.Name)
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid} }
ch := make(chan *imap.Message, limit) if err := <-done; err != nil {
done := make(chan error, 1) return nil, err
go func() { done <- c.Fetch(seq, items, ch) }() }
var out []MailSummary return out, nil
for msg := range ch { }
sum := MailSummary{UID: msg.Uid, Mailbox: mailbox}
if msg.Envelope != nil { // FetchMessages 获取指定邮件夹中最近 n 封邮件摘要.
sum.Subject = msg.Envelope.Subject func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummary, error) {
sum.Date = msg.Envelope.Date if mailbox == "" {
if len(msg.Envelope.From) > 0 { mailbox = "INBOX"
a := msg.Envelope.From[0] }
sum.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) if limit <= 0 {
} limit = 30
} }
for _, f := range msg.Flags { c, err := dial(acc)
if f == imap.SeenFlag { if err != nil {
sum.Seen = true return nil, err
} }
} defer c.Logout()
out = append(out, sum) if err := imapAuthenticate(c, acc); err != nil {
} return nil, err
if err := <-done; err != nil { }
return nil, err mbox, err := c.Select(mailbox, true)
} if 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] if mbox.Messages == 0 {
} return []MailSummary{}, nil
return out, nil }
} from := uint32(1)
if mbox.Messages > uint32(limit) {
// FetchMessage 根据 UID 获取单封邮件完整内容. from = mbox.Messages - uint32(limit) + 1
func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail, error) { }
if mailbox == "" { seq := new(imap.SeqSet)
mailbox = "INBOX" seq.AddRange(from, mbox.Messages)
} items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}
c, err := dial(acc) ch := make(chan *imap.Message, limit)
if err != nil { done := make(chan error, 1)
return nil, err go func() { done <- c.Fetch(seq, items, ch) }()
} var out []MailSummary
defer c.Logout() for msg := range ch {
if err := c.Login(acc.Email, acc.Password); err != nil { sum := MailSummary{UID: msg.Uid, Mailbox: mailbox}
return nil, err if msg.Envelope != nil {
} sum.Subject = msg.Envelope.Subject
if err := imapSendClientID(c); err != nil { sum.Date = msg.Envelope.Date
return nil, err if len(msg.Envelope.From) > 0 {
} a := msg.Envelope.From[0]
if _, err := c.Select(mailbox, false); err != nil { sum.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
return nil, err }
} }
seq := new(imap.SeqSet) for _, f := range msg.Flags {
seq.AddNum(uid) if f == imap.SeenFlag {
section := &imap.BodySectionName{} sum.Seen = true
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()} }
ch := make(chan *imap.Message, 1) }
done := make(chan error, 1) out = append(out, sum)
go func() { done <- c.UidFetch(seq, items, ch) }() }
msg := <-ch if err := <-done; err != nil {
if err := <-done; err != nil { return nil, err
return nil, err }
} // 最新的在前
if msg == nil { for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
return nil, fmt.Errorf("message not found") out[i], out[j] = out[j], out[i]
} }
d := &MailDetail{MailSummary: MailSummary{UID: msg.Uid, Mailbox: mailbox}} return out, nil
if msg.Envelope != nil { }
d.Subject = msg.Envelope.Subject
d.Date = msg.Envelope.Date // FetchMessage 根据 UID 获取单封邮件完整内容.
if len(msg.Envelope.From) > 0 { func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail, error) {
a := msg.Envelope.From[0] if mailbox == "" {
d.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) mailbox = "INBOX"
} }
for _, a := range msg.Envelope.To { c, err := dial(acc)
d.To = append(d.To, fmt.Sprintf("%s@%s", a.MailboxName, a.HostName)) if err != nil {
} return nil, err
} }
for _, f := range msg.Flags { defer c.Logout()
if f == imap.SeenFlag { if err := imapAuthenticate(c, acc); err != nil {
d.Seen = true return nil, err
} }
} if _, err := c.Select(mailbox, false); err != nil {
r := msg.GetBody(section) return nil, err
if r == nil { }
return d, nil seq := new(imap.SeqSet)
} seq.AddNum(uid)
mr, err := mail.CreateReader(r) section := &imap.BodySectionName{}
if err != nil { items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()}
b, _ := io.ReadAll(r) ch := make(chan *imap.Message, 1)
d.Text = string(b) done := make(chan error, 1)
return d, nil go func() { done <- c.UidFetch(seq, items, ch) }()
} msg := <-ch
for { if err := <-done; err != nil {
p, err := mr.NextPart() return nil, err
if err == io.EOF { }
break if msg == nil {
} else if err != nil { return nil, fmt.Errorf("message not found")
break }
} d := &MailDetail{MailSummary: MailSummary{UID: msg.Uid, Mailbox: mailbox}}
switch h := p.Header.(type) { if msg.Envelope != nil {
case *mail.InlineHeader: d.Subject = msg.Envelope.Subject
ct, _, _ := h.ContentType() d.Date = msg.Envelope.Date
b, _ := io.ReadAll(p.Body) if len(msg.Envelope.From) > 0 {
if strings.Contains(ct, "html") { a := msg.Envelope.From[0]
d.HTML = string(b) d.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
} else { }
d.Text = string(b) for _, a := range msg.Envelope.To {
} d.To = append(d.To, fmt.Sprintf("%s@%s", a.MailboxName, a.HostName))
} }
} }
return d, nil 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
}

View File

@@ -0,0 +1,66 @@
package services
import (
"crypto/tls"
"fmt"
"strings"
"mengpost-backend/models"
)
// 微软个人邮箱常见后缀Outlook / Hotmail / Live 等).
var microsoftConsumerSuffixes = []string{
"@outlook.com",
"@hotmail.com",
"@live.com",
"@msn.com",
}
// IsMicrosoftConsumerEmail 判断是否为消费者微软邮箱(非 Exchange 自建域).
func IsMicrosoftConsumerEmail(email string) bool {
e := strings.ToLower(strings.TrimSpace(email))
for _, suf := range microsoftConsumerSuffixes {
if strings.HasSuffix(e, suf) {
return true
}
}
return false
}
// IMAPTLSConfig 为 IMAP TLS 握手提供 ServerName微软等场景与证书一致.
func IMAPTLSConfig(acc *models.Account) *tls.Config {
if acc == nil || !acc.UseTLS {
return nil
}
sn := acc.IMAPHost
if IsMicrosoftConsumerEmail(acc.Email) && strings.EqualFold(acc.IMAPHost, "outlook.office365.com") {
sn = "outlook.office365.com"
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: sn,
}
}
// SMTPTLSConfig 发信使用 587 STARTTLS 时校验证书(微软 smtp.office365.com.
func SMTPTLSConfig(acc *models.Account) *tls.Config {
if acc == nil || !IsMicrosoftConsumerEmail(acc.Email) {
return nil
}
if strings.EqualFold(acc.SMTPHost, "smtp.office365.com") {
return &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: "smtp.office365.com",
}
}
return nil
}
// WrapMicrosoftMailErr 为微软邮箱附加常见配置说明IMAP 开关、基础认证与 OAuth.
func WrapMicrosoftMailErr(email string, err error) error {
if err == nil || !IsMicrosoftConsumerEmail(email) {
return err
}
hint := "(微软邮箱:网页端需开启 IMAPOAuth 账户无需应用密码。若仍失败请检查令牌是否过期、Azure 权限是否含 IMAP。"
return fmt.Errorf("%w %s", err, hint)
}

View File

@@ -0,0 +1,165 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"mengpost-backend/config"
)
var msTokenURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
var msAuthURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
// MicrosoftOAuthScopes IMAP + SMTP + 刷新令牌.
var MicrosoftOAuthScopes = []string{
"openid",
"email",
"offline_access",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
}
// MicrosoftAuthCodeURL 生成跳转微软登录的 URL.
func MicrosoftAuthCodeURL(cfg *config.Config, state string) string {
q := url.Values{}
q.Set("client_id", cfg.MSClientID)
q.Set("response_type", "code")
q.Set("redirect_uri", cfg.MSRedirectURL)
q.Set("response_mode", "query")
q.Set("scope", strings.Join(MicrosoftOAuthScopes, " "))
q.Set("state", state)
return msAuthURL + "?" + q.Encode()
}
type msTokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
Description string `json:"error_description"`
}
// ExchangeMicrosoftCode 用授权码换令牌并解析邮箱id_token.
func ExchangeMicrosoftCode(ctx context.Context, cfg *config.Config, code string) (email, refresh, access string, accessExpiry time.Time, err error) {
form := url.Values{}
form.Set("client_id", cfg.MSClientID)
if cfg.MSClientSecret != "" {
form.Set("client_secret", cfg.MSClientSecret)
}
form.Set("code", code)
form.Set("redirect_uri", cfg.MSRedirectURL)
form.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", "", "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", time.Time{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tr msTokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return "", "", "", time.Time{}, fmt.Errorf("token 响应解析失败: %w", err)
}
if tr.Error != "" {
return "", "", "", time.Time{}, fmt.Errorf("微软 token错误: %s %s", tr.Error, tr.Description)
}
if tr.RefreshToken == "" {
return "", "", "", time.Time{}, fmt.Errorf("未返回 refresh_token请确认 scope 含 offline_access")
}
email, err = emailFromIDToken(tr.IDToken)
if err != nil || email == "" {
return "", "", "", time.Time{}, fmt.Errorf("无法从 id_token 解析邮箱: %w", err)
}
exp := time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
return strings.ToLower(strings.TrimSpace(email)), tr.RefreshToken, tr.AccessToken, exp, nil
}
func emailFromIDToken(idt string) (string, error) {
if idt == "" {
return "", fmt.Errorf("无 id_token")
}
parts := strings.Split(idt, ".")
if len(parts) < 2 {
return "", fmt.Errorf("id_token 格式异常")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
var claims struct {
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
UPN string `json:"upn"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", err
}
if claims.Email != "" {
return claims.Email, nil
}
if claims.PreferredUsername != "" {
return claims.PreferredUsername, nil
}
if claims.UPN != "" {
return claims.UPN, nil
}
return "", fmt.Errorf("claims 中无邮箱字段")
}
// MicrosoftAccessToken 用 refresh_token 换新 access_token及可能的新 refresh.
type MicrosoftAccessToken struct {
AccessToken string
RefreshToken string // 若微软返回新的则替换存库
Expiry time.Time
}
func RefreshMicrosoftAccess(ctx context.Context, cfg *config.Config, refresh string) (*MicrosoftAccessToken, error) {
form := url.Values{}
form.Set("client_id", cfg.MSClientID)
if cfg.MSClientSecret != "" {
form.Set("client_secret", cfg.MSClientSecret)
}
form.Set("refresh_token", refresh)
form.Set("grant_type", "refresh_token")
form.Set("scope", strings.Join(MicrosoftOAuthScopes, " "))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tr msTokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return nil, err
}
if tr.Error != "" {
return nil, fmt.Errorf("%s: %s", tr.Error, tr.Description)
}
out := &MicrosoftAccessToken{
AccessToken: tr.AccessToken,
Expiry: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
}
if tr.RefreshToken != "" {
out.RefreshToken = tr.RefreshToken
}
return out, nil
}

View File

@@ -1,23 +1,29 @@
package services package services
import ( import (
"gopkg.in/gomail.v2" "gopkg.in/gomail.v2"
"mengpost-backend/models" "mengpost-backend/models"
) )
// SendMail 通过指定账户发送邮件. body 支持纯文本或 HTML. // SendMail 通过指定账户发送邮件. body 支持纯文本或 HTML.
func SendMail(acc *models.Account, to, subject, body string, html bool) error { func SendMail(acc *models.Account, to, subject, body string, html bool) error {
m := gomail.NewMessage() if acc.AuthType == models.AuthTypeOAuthMicrosoft {
m.SetHeader("From", acc.Email) return sendMailMicrosoftOAuth(acc, to, subject, body, html)
m.SetHeader("To", to) }
m.SetHeader("Subject", subject) m := gomail.NewMessage()
if html { m.SetHeader("From", acc.Email)
m.SetBody("text/html", body) m.SetHeader("To", to)
} else { m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) if html {
} m.SetBody("text/html", body)
d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password) } else {
d.SSL = acc.UseTLS && acc.SMTPPort == 465 m.SetBody("text/plain", body)
return d.DialAndSend(m) }
} d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password)
d.SSL = acc.UseTLS && acc.SMTPPort == 465
if tc := SMTPTLSConfig(acc); tc != nil {
d.TLSConfig = tc
}
return d.DialAndSend(m)
}

View File

@@ -0,0 +1,73 @@
package services
import (
"bytes"
"context"
"crypto/tls"
"fmt"
smtpp "github.com/emersion/go-smtp"
"gopkg.in/gomail.v2"
"mengpost-backend/models"
)
// sendMailMicrosoftOAuth 使用 XOAUTH2 + STARTTLS 连接 smtp.office365.com:587.
func sendMailMicrosoftOAuth(acc *models.Account, to, subject, body string, html bool) error {
if AppCfg == nil {
return fmt.Errorf("服务未配置 OAuth")
}
mt, err := RefreshMicrosoftAccess(context.Background(), AppCfg, acc.OAuthRefreshToken)
if err != nil {
return err
}
if mt.RefreshToken != "" {
_ = models.UpdateAccountOAuthRefresh(acc.ID, mt.RefreshToken)
}
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)
}
var buf bytes.Buffer
if _, err := m.WriteTo(&buf); err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", acc.SMTPHost, acc.SMTPPort)
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: acc.SMTPHost,
}
c, err := smtpp.DialStartTLS(addr, tlsCfg)
if err != nil {
return err
}
defer c.Close()
if err := c.Auth(NewXOAUTH2Client(acc.Email, mt.AccessToken)); err != nil {
return err
}
if err := c.Mail(acc.Email, nil); err != nil {
return err
}
if err := c.Rcpt(to, nil); err != nil {
return err
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(buf.Bytes()); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}

View File

@@ -0,0 +1,35 @@
package services
import (
"fmt"
"strings"
"github.com/emersion/go-sasl"
)
// xoauth2SASL 实现 Microsoft / Google 邮件使用的 XOAUTH2与 RFC7628 OAUTHBEARER 不同)。
type xoauth2SASL struct {
username string
token string
}
// NewXOAUTH2Client 供 IMAP AUTHENTICATE / SMTP AUTH 使用username 一般为完整邮箱。
func NewXOAUTH2Client(username, accessToken string) sasl.Client {
return &xoauth2SASL{
username: strings.TrimSpace(username),
token: strings.TrimSpace(accessToken),
}
}
func (x *xoauth2SASL) Start() (mech string, ir []byte, err error) {
// https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
s := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.username, x.token)
return "XOAUTH2", []byte(s), nil
}
func (x *xoauth2SASL) Next(challenge []byte) ([]byte, error) {
if len(challenge) == 0 {
return nil, nil
}
return nil, fmt.Errorf("xoauth2: %s", string(challenge))
}

View File

@@ -0,0 +1,2 @@
# 生产构建npm run build 时注入。静态站点域名 post.smyhub.comAPI 走 post.api.smyhub.com
VITE_API_URL=https://post.api.smyhub.com

View File

@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<title>萌邮 MengPost · 多邮箱管理</title> <title>萌邮 MengPost · 多邮箱管理</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,20 @@
{ {
"name": "mengpost-frontend", "name": "mengpost-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 5173" "preview": "vite preview --port 5173"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.6" "vite": "^5.4.6"
} }
} }

View File

@@ -1,35 +1,35 @@
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { tokenStore } from './api/client.js' import { tokenStore } from './api/client.js'
import AccessGate from './components/AccessGate.jsx' import AccessGate from './components/AccessGate.jsx'
import MainLayout from './components/MainLayout.jsx' import MainLayout from './components/MainLayout.jsx'
import Topbar from './components/Topbar.jsx' import Topbar from './components/Topbar.jsx'
import Home from './pages/Home.jsx' import Home from './pages/Home.jsx'
import Admin from './pages/Admin.jsx' import Admin from './pages/Admin.jsx'
import Accounts from './pages/Accounts.jsx' import Accounts from './pages/Accounts.jsx'
import Mailbox from './pages/Mailbox.jsx' import Mailbox from './pages/Mailbox.jsx'
import Compose from './pages/Compose.jsx' import Compose from './pages/Compose.jsx'
function useToken() { function useToken() {
return useSyncExternalStore(tokenStore.subscribe, () => tokenStore.get(), () => '') return useSyncExternalStore(tokenStore.subscribe, () => tokenStore.get(), () => '')
} }
export default function App() { export default function App() {
const token = useToken() const token = useToken()
if (!token) return <AccessGate /> if (!token) return <AccessGate />
return ( return (
<> <>
<Topbar /> <Topbar />
<Routes> <Routes>
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} /> <Route path="/admin" element={<Admin />} />
<Route path="/admin/accounts" element={<Accounts />} /> <Route path="/admin/accounts" element={<Accounts />} />
<Route path="/admin/mailbox/:id" element={<Mailbox />} /> <Route path="/admin/mailbox/:id" element={<Mailbox />} />
<Route path="/admin/compose/:id" element={<Compose />} /> <Route path="/admin/compose/:id" element={<Compose />} />
</Route> </Route>
</Routes> </Routes>
</> </>
) )
} }

View File

@@ -1,109 +1,111 @@
const TOKEN_KEY = 'mengpost_token' const TOKEN_KEY = 'mengpost_token'
const EVT = 'mengpost-token' const EVT = 'mengpost-token'
/** 生产环境前后端分离时设 VITE_API_URL, 如 http://127.0.0.1:8787 */ /** 生产环境前后端分离时设 VITE_API_URL, 如 http://127.0.0.1:8787 */
const base = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') const base = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
export function apiUrl(path) { export function apiUrl(path) {
if (!path.startsWith('/')) path = '/' + path if (!path.startsWith('/')) path = '/' + path
return base ? base + path : path return base ? base + path : path
} }
export const tokenStore = { export const tokenStore = {
get: () => localStorage.getItem(TOKEN_KEY) || '', get: () => localStorage.getItem(TOKEN_KEY) || '',
set: (t) => { set: (t) => {
localStorage.setItem(TOKEN_KEY, t) localStorage.setItem(TOKEN_KEY, t)
window.dispatchEvent(new Event(EVT)) window.dispatchEvent(new Event(EVT))
}, },
clear: () => { clear: () => {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY)
window.dispatchEvent(new Event(EVT)) window.dispatchEvent(new Event(EVT))
}, },
subscribe: (fn) => { subscribe: (fn) => {
window.addEventListener(EVT, fn) window.addEventListener(EVT, fn)
return () => window.removeEventListener(EVT, fn) return () => window.removeEventListener(EVT, fn)
}, },
} }
function parseJsonSafe(txt) { function parseJsonSafe(txt) {
if (!txt || !txt.trim()) return null if (!txt || !txt.trim()) return null
try { try {
return JSON.parse(txt) return JSON.parse(txt)
} catch { } catch {
return { error: txt.length > 120 ? txt.slice(0, 120) + '…' : txt } return { error: txt.length > 120 ? txt.slice(0, 120) + '…' : txt }
} }
} }
async function request(method, path, body) { async function request(method, path, body) {
const headers = { 'Content-Type': 'application/json' } const headers = { 'Content-Type': 'application/json' }
const t = tokenStore.get() const t = tokenStore.get()
if (t) headers['X-Auth-Token'] = t if (t) headers['X-Auth-Token'] = t
let res let res
try { try {
res = await fetch(apiUrl(path), { res = await fetch(apiUrl(path), {
method, method,
headers, headers,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}) })
} catch (e) { } catch (e) {
throw new Error( throw new Error(
e.message?.includes('Failed to fetch') e.message?.includes('Failed to fetch')
? '无法连接后端:请确认已启动 mengpost-backend (默认 8787),且本页通过 Vite 开发服务器打开或已配置 VITE_API_URL' ? '无法连接后端:请确认已启动 mengpost-backend (默认 8787),且本页通过 Vite 开发服务器打开或已配置 VITE_API_URL'
: e.message, : e.message,
) )
} }
const txt = await res.text() const txt = await res.text()
const data = parseJsonSafe(txt) const data = parseJsonSafe(txt)
if (!res.ok) { if (!res.ok) {
const msg = (data && data.error) || res.statusText || '请求失败' const msg = (data && data.error) || res.statusText || '请求失败'
throw new Error(msg) throw new Error(msg)
} }
return data return data
} }
/** 列表接口在空 body / SW 缓存异常时可能得到 null统一成数组 */ /** 列表接口在空 body / SW 缓存异常时可能得到 null统一成数组 */
function asArray(v) { function asArray(v) {
return Array.isArray(v) ? v : [] return Array.isArray(v) ? v : []
} }
/** 校验 token 时不带 X-Auth-Token避免旧 token 干扰 */ /** 校验 token 时不带 X-Auth-Token避免旧 token 干扰 */
export async function verifyToken(token) { export async function verifyToken(token) {
let res let res
try { try {
res = await fetch(apiUrl('/api/auth/verify'), { res = await fetch(apiUrl('/api/auth/verify'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
}) })
} catch (e) { } catch (e) {
throw new Error( throw new Error(
e.message?.includes('Failed to fetch') e.message?.includes('Failed to fetch')
? '无法连接后端:请先运行 dev.bat / dev.sh 启动后端,或设置 VITE_API_URL' ? '无法连接后端:请先运行 dev.bat / dev.sh 启动后端,或设置 VITE_API_URL'
: e.message, : e.message,
) )
} }
const txt = await res.text() const txt = await res.text()
const data = parseJsonSafe(txt) const data = parseJsonSafe(txt)
if (!res.ok) { if (!res.ok) {
if (data && data.ok === false) return { ok: false } if (data && data.ok === false) return { ok: false }
throw new Error((data && data.error) || res.statusText || '验证失败') throw new Error((data && data.error) || res.statusText || '验证失败')
} }
return data return data
} }
export const api = { export const api = {
listAccounts: () => request('GET', '/api/accounts').then(asArray), listAccounts: () => request('GET', '/api/accounts').then(asArray),
createAccount: (a) => request('POST', '/api/accounts', a), createAccount: (a) => request('POST', '/api/accounts', a),
updateAccount: (id, a) => request('PUT', `/api/accounts/${id}`, a), updateAccount: (id, a) => request('PUT', `/api/accounts/${id}`, a),
deleteAccount: (id) => request('DELETE', `/api/accounts/${id}`), deleteAccount: (id) => request('DELETE', `/api/accounts/${id}`),
listMailboxes: (id) => request('GET', `/api/accounts/${id}/mailboxes`).then(asArray), listMailboxes: (id) => request('GET', `/api/accounts/${id}/mailboxes`).then(asArray),
listMessages: (id, mailbox = 'INBOX', limit = 30) => listMessages: (id, mailbox = 'INBOX', limit = 30) =>
request('GET', `/api/accounts/${id}/messages?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`).then( request('GET', `/api/accounts/${id}/messages?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`).then(
asArray, asArray,
), ),
getMessage: (id, uid, mailbox = 'INBOX') => getMessage: (id, uid, mailbox = 'INBOX') =>
request('GET', `/api/accounts/${id}/messages/${uid}?mailbox=${encodeURIComponent(mailbox)}`), request('GET', `/api/accounts/${id}/messages/${uid}?mailbox=${encodeURIComponent(mailbox)}`),
send: (id, body) => request('POST', `/api/accounts/${id}/send`, body), send: (id, body) => request('POST', `/api/accounts/${id}/send`, body),
listSent: (id, limit = 50) => listSent: (id, limit = 50) =>
request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray), request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray),
} microsoftOAuthStart: () => request('GET', '/api/oauth/microsoft/start'),
microsoftOAuthFinish: (state) => request('POST', '/api/oauth/microsoft/finish', { state }),
}

View File

@@ -1,64 +1,64 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { verifyToken, tokenStore } from '../api/client.js' import { verifyToken, tokenStore } from '../api/client.js'
/** 未携带有效密钥时全屏展示, 校验通过后写入 localStorage 并刷新应用状态 */ /** 未携带有效密钥时全屏展示, 校验通过后写入 localStorage 并刷新应用状态 */
export default function AccessGate() { export default function AccessGate() {
const [key, setKey] = useState('') const [key, setKey] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const inputRef = useRef(null) const inputRef = useRef(null)
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
}, []) }, [])
const submit = async () => { const submit = async () => {
const v = key.trim() const v = key.trim()
if (!v) return if (!v) return
setLoading(true) setLoading(true)
setErr('') setErr('')
try { try {
const r = await verifyToken(v) const r = await verifyToken(v)
if (r && r.ok) { if (r && r.ok) {
tokenStore.set(v) tokenStore.set(v)
} else { } else {
setErr('密钥不正确') setErr('密钥不正确')
} }
} catch (e) { } catch (e) {
setErr(e.message || '验证失败') setErr(e.message || '验证失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<div className="access-gate"> <div className="access-gate">
<div className="card" style={{ width: '100%', maxWidth: 360 }}> <div className="card" style={{ width: '100%', maxWidth: 360 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<img src="/logo.png" alt="" width={36} height={36} style={{ borderRadius: 8 }} /> <img src="/logo.png" alt="" width={36} height={36} style={{ borderRadius: 8 }} />
<h1 style={{ fontSize: 18, margin: 0 }}>萌邮 MengPost</h1> <h1 style={{ fontSize: 18, margin: 0 }}>萌邮 MengPost</h1>
</div> </div>
<p className="muted" style={{ marginBottom: 16, fontSize: 14 }}> <p className="muted" style={{ marginBottom: 16, fontSize: 14 }}>
请输入访问密钥与后端 <code>MP_TOKEN</code> 一致 请输入访问密钥与后端 <code>MP_TOKEN</code> 一致
</p> </p>
<input <input
ref={inputRef} ref={inputRef}
type="password" type="password"
autoComplete="off" autoComplete="off"
placeholder="访问密钥" placeholder="访问密钥"
value={key} value={key}
onChange={(e) => setKey(e.target.value)} onChange={(e) => setKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()} onKeyDown={(e) => e.key === 'Enter' && submit()}
/> />
{err && ( {err && (
<div style={{ color: 'var(--danger)', fontSize: 13, marginTop: 10 }}>{err}</div> <div style={{ color: 'var(--danger)', fontSize: 13, marginTop: 10 }}>{err}</div>
)} )}
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<button type="button" className="btn-primary" disabled={loading} onClick={submit}> <button type="button" className="btn-primary" disabled={loading} onClick={submit}>
{loading ? '验证中…' : '进入'} {loading ? '验证中…' : '进入'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@@ -1,99 +1,184 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
import { api } from '../api/client.js' import { api } from '../api/client.js'
function useMailboxAccountId() { function emailDomain(email) {
const loc = useLocation() const s = (email || '').trim().toLowerCase()
return useMemo(() => { const at = s.lastIndexOf('@')
const m = loc.pathname.match(/\/admin\/mailbox\/(\d+)/) if (at < 0 || at === s.length - 1) return '其他'
return m ? Number(m[1]) : null return s.slice(at + 1)
}, [loc.pathname]) }
}
function groupAccountsByDomain(accounts) {
function accountActive(location, id) { const map = new Map()
return ( for (const a of accounts) {
location.pathname === `/admin/mailbox/${id}` || location.pathname === `/admin/compose/${id}` const d = emailDomain(a.email)
) if (!map.has(d)) map.set(d, [])
} map.get(d).push(a)
}
function SidebarMailboxLink({ accountId, name }) { const groups = Array.from(map.entries()).map(([domain, items]) => ({
const location = useLocation() domain,
const current = new URLSearchParams(location.search).get('mailbox') || 'INBOX' items: items.slice().sort((x, y) => (x.email || '').localeCompare(y.email || '', 'zh-CN')),
const active = }))
location.pathname === `/admin/mailbox/${accountId}` && current === name groups.sort((a, b) => a.domain.localeCompare(b.domain, 'zh-CN'))
return ( return groups
<Link }
to={`/admin/mailbox/${accountId}?mailbox=${encodeURIComponent(name)}`}
className={'sidebar-item' + (active ? ' active' : '')} function useMailboxAccountId() {
> const loc = useLocation()
{name} return useMemo(() => {
</Link> const m = loc.pathname.match(/\/admin\/(?:mailbox|compose)\/(\d+)/)
) return m ? Number(m[1]) : null
} }, [loc.pathname])
}
export default function MainLayout() {
const location = useLocation() function accountActive(location, id) {
const [accounts, setAccounts] = useState([]) return (
const [boxes, setBoxes] = useState([]) location.pathname === `/admin/mailbox/${id}` || location.pathname === `/admin/compose/${id}`
const accountId = useMailboxAccountId() )
}
useEffect(() => {
api function SidebarMailboxLink({ accountId, name }) {
.listAccounts() const location = useLocation()
.then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) const current = new URLSearchParams(location.search).get('mailbox') || 'INBOX'
.catch(() => setAccounts([])) const active =
}, [location.pathname]) location.pathname === `/admin/mailbox/${accountId}` && current === name
return (
useEffect(() => { <Link
if (!accountId) { to={`/admin/mailbox/${accountId}?mailbox=${encodeURIComponent(name)}`}
setBoxes([]) className={'sidebar-item' + (active ? ' active' : '')}
return >
} {name}
api </Link>
.listMailboxes(accountId) )
.then((b) => setBoxes(Array.isArray(b) ? b : [])) }
.catch(() => setBoxes([]))
}, [accountId]) export default function MainLayout() {
const location = useLocation()
return ( const [accounts, setAccounts] = useState([])
<div className="app-shell"> const [boxes, setBoxes] = useState([])
<aside className="sidebar"> const [domainCollapsed, setDomainCollapsed] = useState({})
<div className="sidebar-section"> const accountId = useMailboxAccountId()
<div className="sidebar-label">邮箱</div>
{accounts.map((a) => ( const groupedAccounts = useMemo(() => groupAccountsByDomain(accounts), [accounts])
<NavLink
key={a.id} const expandAllDomains = useCallback(() => setDomainCollapsed({}), [])
to={`/admin/mailbox/${a.id}`} const collapseAllDomains = useCallback(() => {
className={() => const next = {}
'sidebar-item' + (accountActive(location, a.id) ? ' active' : '') for (const g of groupedAccounts) next[g.domain] = true
} setDomainCollapsed(next)
> }, [groupedAccounts])
<span className="sidebar-item-title">{a.name || '未命名'}</span>
<span className="sidebar-item-sub muted">{a.email}</span> const toggleDomain = useCallback((domain) => {
</NavLink> setDomainCollapsed((c) => ({ ...c, [domain]: !c[domain] }))
))} }, [])
{accounts.length === 0 && <p className="sidebar-muted">暂无账户</p>}
<Link to="/admin/accounts" className="sidebar-foot"> useEffect(() => {
管理账户 api
</Link> .listAccounts()
</div> .then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
.catch(() => setAccounts([]))
{accountId != null && ( }, [location.pathname])
<div className="sidebar-section">
<div className="sidebar-label">邮件夹</div> // 当前选中的账户所在分组自动展开
{boxes.map((b) => ( useEffect(() => {
<SidebarMailboxLink key={b} accountId={accountId} name={b} /> const active = accounts.find((a) => accountActive(location, a.id))
))} if (!active) return
{boxes.length === 0 && <p className="sidebar-muted">暂无或加载中</p>} const d = emailDomain(active.email)
<Link to={`/admin/compose/${accountId}`} className="sidebar-foot"> setDomainCollapsed((c) => {
写邮件 if (!c[d]) return c
</Link> const next = { ...c }
</div> delete next[d]
)} return next
</aside> })
<main className="main-area"> }, [location.pathname, accounts])
<Outlet />
</main> useEffect(() => {
</div> 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-row">
<div className="sidebar-label">邮箱</div>
{accounts.length > 0 && (
<div className="sidebar-label-actions">
<button type="button" className="sidebar-mini-btn" onClick={expandAllDomains}>
全部展开
</button>
<button type="button" className="sidebar-mini-btn" onClick={collapseAllDomains}>
全部收起
</button>
</div>
)}
</div>
{groupedAccounts.map(({ domain, items }) => {
const collapsed = !!domainCollapsed[domain]
return (
<div key={domain} className="sidebar-group">
<button
type="button"
className="sidebar-group-header"
onClick={() => toggleDomain(domain)}
aria-expanded={!collapsed}
>
<span className="sidebar-group-chevron" aria-hidden>
{collapsed ? '\u25B8' : '\u25BE'}
</span>
<span className="sidebar-group-title">@{domain}</span>
<span className="sidebar-group-count">{items.length}</span>
</button>
{!collapsed && (
<div className="sidebar-group-body">
{items.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>
))}
</div>
)}
</div>
)
})}
{accounts.length === 0 && <p className="sidebar-muted">暂无账户</p>}
<Link to="/admin/accounts" className="sidebar-foot">
管理账户
</Link>
</div>
</aside>
<main className="main-area">
<Outlet />
</main>
{accountId != null && (
<aside className="sidebar sidebar-right" aria-label="邮件夹">
<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>
)}
</div>
)
}

View File

@@ -1,29 +1,29 @@
import { NavLink, useNavigate } from 'react-router-dom' import { NavLink, useNavigate } from 'react-router-dom'
import { tokenStore } from '../api/client.js' import { tokenStore } from '../api/client.js'
export default function Topbar() { export default function Topbar() {
const nav = useNavigate() const nav = useNavigate()
const logout = () => { const logout = () => {
tokenStore.clear() tokenStore.clear()
nav('/', { replace: true }) nav('/', { replace: true })
} }
return ( return (
<header className="topbar"> <header className="topbar">
<NavLink to="/admin" className="brand" style={{ textDecoration: 'none', color: 'inherit' }}> <NavLink to="/admin" className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
<img className="logo" src="/logo.png" alt="" width={28} height={28} /> <img className="logo" src="/logo.png" alt="" width={28} height={28} />
<span>萌邮 MengPost</span> <span>萌邮 MengPost</span>
</NavLink> </NavLink>
<nav> <nav>
<NavLink to="/admin" end> <NavLink to="/admin" end>
概览 概览
</NavLink> </NavLink>
<NavLink to="/admin/accounts">账户</NavLink> <NavLink to="/admin/accounts">账户</NavLink>
<a onClick={logout} style={{ cursor: 'pointer' }}> <a onClick={logout} style={{ cursor: 'pointer' }}>
登出 登出
</a> </a>
</nav> </nav>
</header> </header>
) )
} }

View File

@@ -1,13 +1,13 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import './styles/global.css' import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@@ -1,225 +1,311 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { api } from '../api/client.js' import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client.js'
const empty = {
name: '', const empty = {
email: '', name: '',
password: '', email: '',
smtp_host: '', password: '',
smtp_port: 465, smtp_host: '',
imap_host: '', smtp_port: 465,
imap_port: 993, imap_host: '',
use_tls: true, imap_port: 993,
} use_tls: true,
}
export default function Accounts() {
const [list, setList] = useState([]) function authLabel(t) {
const [form, setForm] = useState(empty) return t === 'oauth_microsoft' ? 'OAuth微软' : '密码'
const [editing, setEditing] = useState(null) }
const [modalOpen, setModalOpen] = useState(false)
const [err, setErr] = useState('') export default function Accounts() {
const [msg, setMsg] = useState('') const [searchParams, setSearchParams] = useSearchParams()
const [list, setList] = useState([])
const reload = () => const [form, setForm] = useState(empty)
api const [editing, setEditing] = useState(null)
.listAccounts() const [modalOpen, setModalOpen] = useState(false)
.then((rows) => setList(Array.isArray(rows) ? rows : [])) const [err, setErr] = useState('')
.catch((e) => setErr(e.message)) const [msg, setMsg] = useState('')
useEffect(() => { const reload = () =>
reload() api
}, []) .listAccounts()
.then((rows) => setList(Array.isArray(rows) ? rows : []))
const openNew = () => { .catch((e) => setErr(e.message))
setEditing(null)
setForm(empty) useEffect(() => {
setErr('') reload()
setMsg('') }, [])
setModalOpen(true)
} useEffect(() => {
const ms = searchParams.get('oauth_ms')
const openEdit = (a) => { const oe = searchParams.get('oauth_err')
setEditing(a.id) if (!ms && !oe) return
setForm({ ...a, password: '' })
setErr('') const next = new URLSearchParams(searchParams)
setMsg('') if (oe) {
setModalOpen(true) setErr(decodeURIComponent(oe.replace(/\+/g, ' ')))
} next.delete('oauth_err')
}
const closeModal = useCallback(() => { if (ms) {
setModalOpen(false) next.delete('oauth_ms')
setEditing(null) }
setForm(empty) setSearchParams(next, { replace: true })
setErr('')
}, []) if (ms) {
const gate = `mp_ms_oauth_${ms}`
useEffect(() => { let skip = false
if (!modalOpen) return try {
const onKey = (e) => { if (sessionStorage.getItem(gate)) skip = true
if (e.key === 'Escape') closeModal() else sessionStorage.setItem(gate, '1')
} } catch {
window.addEventListener('keydown', onKey) /* ignore */
return () => window.removeEventListener('keydown', onKey) }
}, [modalOpen, closeModal]) if (skip) return
api
const save = async (e) => { .microsoftOAuthFinish(ms)
e.preventDefault() .then(() => {
setErr('') setMsg('微软账户已通过 OAuth 连接')
try { reload()
if (editing) { })
await api.updateAccount(editing, form) .catch((e) => {
setMsg('已更新') try {
} else { sessionStorage.removeItem(gate)
await api.createAccount(form) } catch {
setMsg('已创建') /* ignore */
} }
closeModal() setErr(e.message)
reload() })
} catch (e) { }
setErr(e.message) }, [searchParams, setSearchParams])
}
} const startMicrosoftOAuth = async () => {
setErr('')
const del = async (id) => { setMsg('')
if (!confirm('确定删除该账户?')) return try {
try { const { url } = await api.microsoftOAuthStart()
await api.deleteAccount(id) if (url) window.location.href = url
setMsg('已删除') } catch (e) {
reload() setErr(e.message)
} catch (e) { }
setErr(e.message) }
}
} const openNew = () => {
setEditing(null)
const set = (k) => (e) => { setForm(empty)
const v = setErr('')
e.target.type === 'checkbox' setMsg('')
? e.target.checked setModalOpen(true)
: e.target.type === 'number' }
? Number(e.target.value)
: e.target.value const openEdit = (a) => {
setForm({ ...form, [k]: v }) setEditing(a.id)
} setForm({ ...a, password: '' })
setErr('')
return ( setMsg('')
<div className="main-inner"> setModalOpen(true)
<div className="page-toolbar"> }
<h1 className="page-title">邮箱账户</h1>
<button type="button" className="btn-primary" onClick={openNew}> const closeModal = useCallback(() => {
新增账户 setModalOpen(false)
</button> setEditing(null)
</div> setForm(empty)
setErr('')
{msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>} }, [])
{err && !modalOpen && (
<p style={{ color: 'var(--danger)', margin: '0 0 12px' }}>{err}</p> useEffect(() => {
)} if (!modalOpen) return
const onKey = (e) => {
<div className="card"> if (e.key === 'Escape') closeModal()
<h2 style={{ marginTop: 0 }}>已有账户</h2> }
{list.length === 0 ? ( window.addEventListener('keydown', onKey)
<p className="muted">暂无账户</p> return () => window.removeEventListener('keydown', onKey)
) : ( }, [modalOpen, closeModal])
<table>
<thead> const save = async (e) => {
<tr> e.preventDefault()
<th>名称</th> setErr('')
<th>邮箱</th> try {
<th>SMTP</th> if (editing) {
<th>IMAP</th> await api.updateAccount(editing, form)
<th></th> setMsg('已更新')
</tr> } else {
</thead> await api.createAccount(form)
<tbody> setMsg('已创建')
{list.map((a) => ( }
<tr key={a.id}> closeModal()
<td>{a.name}</td> reload()
<td>{a.email}</td> } catch (e) {
<td> setErr(e.message)
{a.smtp_host}:{a.smtp_port} }
</td> }
<td>
{a.imap_host}:{a.imap_port} const del = async (id) => {
</td> if (!confirm('确定删除该账户?')) return
<td> try {
<button type="button" onClick={() => openEdit(a)}> await api.deleteAccount(id)
编辑 setMsg('已删除')
</button>{' '} reload()
<button type="button" className="btn-danger" onClick={() => del(a.id)}> } catch (e) {
删除 setErr(e.message)
</button> }
</td> }
</tr>
))} const set = (k) => (e) => {
</tbody> const v =
</table> e.target.type === 'checkbox'
)} ? e.target.checked
</div> : e.target.type === 'number'
? Number(e.target.value)
{modalOpen && ( : e.target.value
<div setForm({ ...form, [k]: v })
className="modal-mask" }
role="presentation"
onClick={(e) => e.target === e.currentTarget && closeModal()} return (
> <div className="main-inner">
<div className="modal modal-wide" onClick={(e) => e.stopPropagation()}> <div className="page-toolbar">
<h2>{editing ? `编辑账户 #${editing}` : '新增账户'}</h2> <h1 className="page-title">邮箱账户</h1>
<form onSubmit={save}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div className="form-row"> <button type="button" className="btn-primary" onClick={startMicrosoftOAuth}>
<label>名称</label> 微软邮箱 OAuth 登录
<input value={form.name} onChange={set('name')} placeholder="工作 / 私人" required /> </button>
</div> <button type="button" className="btn-primary" onClick={openNew}>
<div className="form-row"> 新增账户
<label>邮箱</label> </button>
<input value={form.email} onChange={set('email')} type="email" required /> </div>
</div> </div>
<div className="form-row">
<label>密码 / 授权码</label> {msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>}
<input {err && !modalOpen && (
value={form.password} <p style={{ color: 'var(--danger)', margin: '0 0 12px' }}>{err}</p>
onChange={set('password')} )}
type="password"
placeholder={editing ? '留空不修改' : ''} <div className="card">
/> <h2 style={{ marginTop: 0 }}>已有账户</h2>
</div> {list.length === 0 ? (
<div className="form-row"> <p className="muted">暂无账户</p>
<label>SMTP 主机</label> ) : (
<input value={form.smtp_host} onChange={set('smtp_host')} placeholder="smtp.example.com" required /> <table>
</div> <thead>
<div className="form-row"> <tr>
<label>SMTP 端口</label> <th>名称</th>
<input value={form.smtp_port} onChange={set('smtp_port')} type="number" required /> <th>邮箱</th>
</div> <th>认证</th>
<div className="form-row"> <th>SMTP</th>
<label>IMAP 主机</label> <th>IMAP</th>
<input value={form.imap_host} onChange={set('imap_host')} placeholder="imap.example.com" required /> <th></th>
</div> </tr>
<div className="form-row"> </thead>
<label>IMAP 端口</label> <tbody>
<input value={form.imap_port} onChange={set('imap_port')} type="number" required /> {list.map((a) => (
</div> <tr key={a.id}>
<div className="form-row"> <td>{a.name}</td>
<label>使用 TLS/SSL</label> <td>{a.email}</td>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <td>{authLabel(a.auth_type)}</td>
<input type="checkbox" checked={form.use_tls} onChange={set('use_tls')} style={{ width: 'auto' }} /> <td>
<span className="muted">建议开启</span> {a.smtp_host}:{a.smtp_port}
</label> </td>
</div> <td>
{err && ( {a.imap_host}:{a.imap_port}
<div style={{ color: 'var(--danger)', marginTop: 8, fontSize: 14 }}>{err}</div> </td>
)} <td>
<div className="actions" style={{ marginTop: 16 }}> <button type="button" onClick={() => openEdit(a)}>
<button type="button" onClick={closeModal}> 编辑
取消 </button>{' '}
</button> <button type="button" className="btn-danger" onClick={() => del(a.id)}>
<button type="submit" className="btn-primary"> 删除
{editing ? '保存' : '创建'} </button>
</button> </td>
</div> </tr>
</form> ))}
</div> </tbody>
</div> </table>
)} )}
</div> </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>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<button
type="button"
onClick={() =>
setForm((f) => ({
...f,
smtp_host: '',
smtp_port: 465,
imap_host: '',
imap_port: 993,
use_tls: true,
}))
}
>
清空主机手动填
</button>
</div>
</div>
<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>
)
}

View File

@@ -1,57 +1,57 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api } from '../api/client.js' import { api } from '../api/client.js'
export default function Admin() { export default function Admin() {
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [err, setErr] = useState('') const [err, setErr] = useState('')
useEffect(() => { useEffect(() => {
api api
.listAccounts() .listAccounts()
.then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) .then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
.catch((e) => setErr(e.message)) .catch((e) => setErr(e.message))
}, []) }, [])
return ( return (
<div className="main-inner"> <div className="main-inner">
<h1 className="page-title">概览</h1> <h1 className="page-title">概览</h1>
<p className="muted"> {accounts.length} 个邮箱账户</p> <p className="muted"> {accounts.length} 个邮箱账户</p>
{err && ( {err && (
<div className="card" style={{ color: 'var(--danger)' }}> <div className="card" style={{ color: 'var(--danger)' }}>
{err} {err}
</div> </div>
)} )}
<div className="card"> <div className="card">
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<p className="muted"> <p className="muted">
暂无账户前往 <Link to="/admin/accounts">管理账户</Link> 添加 暂无账户前往 <Link to="/admin/accounts">管理账户</Link> 添加
</p> </p>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>名称</th> <th>名称</th>
<th>邮箱</th> <th>邮箱</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{accounts.map((a) => ( {accounts.map((a) => (
<tr key={a.id}> <tr key={a.id}>
<td>{a.name}</td> <td>{a.name}</td>
<td>{a.email}</td> <td>{a.email}</td>
<td> <td>
<Link to={`/admin/mailbox/${a.id}`}>打开</Link> <Link to={`/admin/mailbox/${a.id}`}>打开</Link>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)} )}
</div> </div>
</div> </div>
) )
} }

View File

@@ -1,78 +1,78 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api/client.js' import { api } from '../api/client.js'
export default function Compose() { export default function Compose() {
const { id } = useParams() const { id } = useParams()
const accountId = Number(id) const accountId = Number(id)
const nav = useNavigate() const nav = useNavigate()
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [form, setForm] = useState({ to: '', subject: '', body: '', html: false }) const [form, setForm] = useState({ to: '', subject: '', body: '', html: false })
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
useEffect(() => { useEffect(() => {
api api
.listAccounts() .listAccounts()
.then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) .then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
.catch(() => {}) .catch(() => {})
}, []) }, [])
const set = (k) => (e) => { const set = (k) => (e) => {
const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value
setForm({ ...form, [k]: v }) setForm({ ...form, [k]: v })
} }
const send = async (e) => { const send = async (e) => {
e.preventDefault() e.preventDefault()
setErr(''); setMsg(''); setSending(true) setErr(''); setMsg(''); setSending(true)
try { try {
await api.send(accountId, form) await api.send(accountId, form)
setMsg('发送成功') setMsg('发送成功')
setForm({ to: '', subject: '', body: '', html: false }) setForm({ to: '', subject: '', body: '', html: false })
} catch (e) { setErr(e.message) } } catch (e) { setErr(e.message) }
finally { setSending(false) } finally { setSending(false) }
} }
const acc = accounts.find((a) => a.id === accountId) const acc = accounts.find((a) => a.id === accountId)
return ( return (
<div className="main-inner"> <div className="main-inner">
<h1 className="page-title">撰写</h1> <h1 className="page-title">撰写</h1>
<p className="muted">{acc ? acc.email : `#${accountId}`}</p> <p className="muted">{acc ? acc.email : `#${accountId}`}</p>
<div className="card"> <div className="card">
<form onSubmit={send}> <form onSubmit={send}>
<div className="form-row"><label>切换账户</label> <div className="form-row"><label>切换账户</label>
<select value={accountId} onChange={(e) => nav(`/admin/compose/${e.target.value}`)}> <select value={accountId} onChange={(e) => nav(`/admin/compose/${e.target.value}`)}>
{accounts.map((a) => ( {accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name} &lt;{a.email}&gt;</option> <option key={a.id} value={a.id}>{a.name} &lt;{a.email}&gt;</option>
))} ))}
</select> </select>
</div> </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.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>主题</label><input value={form.subject} onChange={set('subject')} required /></div>
<div className="form-row"> <div className="form-row">
<label>HTML</label> <label>HTML</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="checkbox" checked={form.html} onChange={set('html')} style={{ width: 'auto' }} /> <input type="checkbox" checked={form.html} onChange={set('html')} style={{ width: 'auto' }} />
<span className="muted"> HTML 发送</span> <span className="muted"> HTML 发送</span>
</label> </label>
</div> </div>
<div className="form-row" style={{ alignItems: 'start' }}> <div className="form-row" style={{ alignItems: 'start' }}>
<label>正文</label> <label>正文</label>
<textarea value={form.body} onChange={set('body')} rows={10} required /> <textarea value={form.body} onChange={set('body')} rows={10} required />
</div> </div>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<button type="submit" className="btn-primary" disabled={sending}> <button type="submit" className="btn-primary" disabled={sending}>
{sending ? '发送中…' : '发送'} {sending ? '发送中…' : '发送'}
</button> </button>
</div> </div>
{err && <div style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>} {err && <div style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>}
{msg && <div style={{ color: '#16a34a', marginTop: 8 }}>{msg}</div>} {msg && <div style={{ color: '#16a34a', marginTop: 8 }}>{msg}</div>}
</form> </form>
</div> </div>
</div> </div>
) )
} }

View File

@@ -1,5 +1,5 @@
import { Navigate } from 'react-router-dom' import { Navigate } from 'react-router-dom'
export default function Home() { export default function Home() {
return <Navigate to="/admin" replace /> return <Navigate to="/admin" replace />
} }

View File

@@ -1,108 +1,108 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom' import { useParams, useSearchParams } from 'react-router-dom'
import { api } from '../api/client.js' import { api } from '../api/client.js'
export default function Mailbox() { export default function Mailbox() {
const { id } = useParams() const { id } = useParams()
const accountId = Number(id) const accountId = Number(id)
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const mailbox = searchParams.get('mailbox') || 'INBOX' const mailbox = searchParams.get('mailbox') || 'INBOX'
const [list, setList] = useState([]) const [list, setList] = useState([])
const [active, setActive] = useState(null) const [active, setActive] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [err, setErr] = useState('') const [err, setErr] = useState('')
useEffect(() => { useEffect(() => {
setActive(null) setActive(null)
setLoading(true) setLoading(true)
setErr('') setErr('')
api api
.listMessages(accountId, mailbox) .listMessages(accountId, mailbox)
.then((l) => setList(l || [])) .then((l) => setList(l || []))
.catch((e) => setErr(e.message)) .catch((e) => setErr(e.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [accountId, mailbox]) }, [accountId, mailbox])
const closeDetail = useCallback(() => setActive(null), []) const closeDetail = useCallback(() => setActive(null), [])
useEffect(() => { useEffect(() => {
if (!active) return if (!active) return
const onKey = (e) => { const onKey = (e) => {
if (e.key === 'Escape') closeDetail() if (e.key === 'Escape') closeDetail()
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [active, closeDetail]) }, [active, closeDetail])
const open = async (m) => { const open = async (m) => {
setActive({ ...m, loading: true }) setActive({ ...m, loading: true })
try { try {
const d = await api.getMessage(accountId, m.uid, mailbox) const d = await api.getMessage(accountId, m.uid, mailbox)
setActive(d) setActive(d)
} catch (e) { } catch (e) {
setActive({ ...m, error: e.message, loading: false }) setActive({ ...m, error: e.message, loading: false })
} }
} }
return ( return (
<div className="main-inner"> <div className="main-inner">
<div className="page-toolbar"> <div className="page-toolbar">
<h1 className="page-title">{mailbox}</h1> <h1 className="page-title">{mailbox}</h1>
</div> </div>
<div className="card" style={{ padding: 0 }}> <div className="card" style={{ padding: 0 }}>
{loading && <p className="muted" style={{ padding: 12 }}>加载中</p>} {loading && <p className="muted" style={{ padding: 12 }}>加载中</p>}
{err && <p style={{ color: 'var(--danger)', padding: 12 }}>{err}</p>} {err && <p style={{ color: 'var(--danger)', padding: 12 }}>{err}</p>}
{!loading && !err && list.length === 0 && ( {!loading && !err && list.length === 0 && (
<p className="muted" style={{ padding: 12 }}>暂无邮件</p> <p className="muted" style={{ padding: 12 }}>暂无邮件</p>
)} )}
<ul className="mail-list"> <ul className="mail-list">
{list.map((m) => ( {list.map((m) => (
<li key={m.uid} className={m.seen ? '' : 'unseen'} onClick={() => open(m)}> <li key={m.uid} className={m.seen ? '' : 'unseen'} onClick={() => open(m)}>
<div className="subject">{m.subject || '(无主题)'}</div> <div className="subject">{m.subject || '(无主题)'}</div>
<div className="from"> <div className="from">
{m.from} · {m.date && new Date(m.date).toLocaleString()} {m.from} · {m.date && new Date(m.date).toLocaleString()}
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
{active && ( {active && (
<div <div
className="modal-mask" className="modal-mask"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="mail-subject" aria-labelledby="mail-subject"
onClick={(e) => e.target === e.currentTarget && closeDetail()} onClick={(e) => e.target === e.currentTarget && closeDetail()}
> >
<div className="modal modal-mail" onClick={(e) => e.stopPropagation()}> <div className="modal modal-mail" onClick={(e) => e.stopPropagation()}>
<div className="modal-mail-toolbar"> <div className="modal-mail-toolbar">
<h2 id="mail-subject">{active.subject || '(无主题)'}</h2> <h2 id="mail-subject">{active.subject || '(无主题)'}</h2>
<button type="button" onClick={closeDetail}> <button type="button" onClick={closeDetail}>
关闭 关闭
</button> </button>
</div> </div>
<div className="modal-mail-meta muted"> <div className="modal-mail-meta muted">
<p>发件人: {active.from || '—'}</p> <p>发件人: {active.from || '—'}</p>
{active.to && active.to.length > 0 && ( {active.to && active.to.length > 0 && (
<p>收件人: {active.to.join(', ')}</p> <p>收件人: {active.to.join(', ')}</p>
)} )}
</div> </div>
<div className="modal-mail-body"> <div className="modal-mail-body">
{active.loading && <p className="muted">加载中</p>} {active.loading && <p className="muted">加载中</p>}
{active.error && <p style={{ color: 'var(--danger)' }}>{active.error}</p>} {active.error && <p style={{ color: 'var(--danger)' }}>{active.error}</p>}
{!active.loading && !active.error && active.html && ( {!active.loading && !active.error && active.html && (
<iframe title="mail-html" srcDoc={active.html} /> <iframe title="mail-html" srcDoc={active.html} />
)} )}
{!active.loading && !active.error && !active.html && ( {!active.loading && !active.error && !active.html && (
<pre>{active.text || ''}</pre> <pre>{active.text || ''}</pre>
)} )}
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
) )
} }

View File

@@ -1,363 +1,465 @@
:root { :root {
--bg: #fafafa; --bg: #fafafa;
--surface: #ffffff; --surface: #ffffff;
--border: #e5e7eb; --border: #e5e7eb;
--text: #1f2328; --text: #1f2328;
--muted: #6b7280; --muted: #6b7280;
--accent: #2563eb; --accent: #2563eb;
--accent-hover: #1d4ed8; --accent-hover: #1d4ed8;
--danger: #dc2626; --danger: #dc2626;
--radius: 6px; --radius: 6px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", Roboto, sans-serif; "Hiragino Sans GB", "Microsoft YaHei", Roboto, sans-serif;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { height: 100%; } html, body, #root { height: 100%; }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: var(--sans); font-family: var(--sans);
font-size: 15px; font-size: 15px;
line-height: 1.6; line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
button, button,
.btn { .btn {
font: inherit; font: inherit;
padding: 6px 14px; padding: 6px 14px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
transition: background .15s, border-color .15s; transition: background .15s, border-color .15s;
} }
button:hover, button:hover,
.btn:hover { border-color: #c5c9d0; background: #f3f4f6; } .btn:hover { border-color: #c5c9d0; background: #f3f4f6; }
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
color: #fff; color: #fff;
} }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } .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 { color: var(--danger); border-color: var(--border); background: var(--surface); }
.btn-danger:hover { background: #fef2f2; border-color: #fca5a5; } .btn-danger:hover { background: #fef2f2; border-color: #fca5a5; }
input, select, textarea { input, select, textarea {
font: inherit; font: inherit;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
width: 100%; width: 100%;
} }
input:focus, select:focus, textarea:focus { input:focus, select:focus, textarea:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, .15); box-shadow: 0 0 0 3px rgba(37, 99, 235, .15);
} }
.container { .container {
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: 24px 16px 64px; padding: 24px 16px 64px;
} }
.app-shell { .app-shell {
display: flex; display: flex;
min-height: calc(100vh - 54px); min-height: calc(100vh - 54px);
align-items: stretch; align-items: stretch;
} }
.sidebar { .sidebar {
width: 240px; width: 240px;
flex-shrink: 0; flex-shrink: 0;
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 16px 0; padding: 16px 0;
overflow-y: auto; overflow-y: auto;
} }
.sidebar-section { .sidebar-right {
padding: 0 12px 16px; border-right: none;
margin-bottom: 8px; border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border); }
}
.sidebar-section:last-of-type { .sidebar-section {
border-bottom: none; padding: 0 12px 16px;
} margin-bottom: 8px;
border-bottom: 1px solid var(--border);
.sidebar-label { }
font-size: 11px; .sidebar-section:last-of-type {
font-weight: 600; border-bottom: none;
letter-spacing: 0.04em; }
text-transform: uppercase;
color: var(--muted); .sidebar-label-row {
margin-bottom: 8px; display: flex;
padding: 0 8px; align-items: flex-start;
} justify-content: space-between;
gap: 8px;
.sidebar-item { margin-bottom: 8px;
display: flex; padding: 0 8px;
flex-direction: column; }
align-items: flex-start;
gap: 2px; .sidebar-label {
padding: 8px 10px; font-size: 11px;
margin-bottom: 2px; font-weight: 600;
border-radius: var(--radius); letter-spacing: 0.04em;
color: var(--text); text-transform: uppercase;
text-decoration: none; color: var(--muted);
font-size: 14px; margin-bottom: 0;
line-height: 1.35; padding: 0;
} }
.sidebar-item:hover {
background: #f3f4f6; .sidebar-label-actions {
text-decoration: none; display: flex;
} flex-wrap: wrap;
.sidebar-item.active { gap: 4px 8px;
background: #eff6ff; justify-content: flex-end;
color: var(--accent); }
font-weight: 500;
} .sidebar-mini-btn {
.sidebar-item-title { font-weight: 500; } font-size: 11px;
.sidebar-item-sub { font-size: 12px; word-break: break-all; } padding: 2px 6px;
border: none;
.sidebar-muted { background: transparent;
font-size: 13px; color: var(--accent);
color: var(--muted); cursor: pointer;
padding: 4px 8px; border-radius: 4px;
margin: 0; white-space: nowrap;
} }
.sidebar-mini-btn:hover {
.sidebar-foot { background: #eff6ff;
display: block; text-decoration: none;
font-size: 13px; }
color: var(--muted);
padding: 8px; .sidebar-group {
margin-top: 6px; margin-bottom: 4px;
} }
.sidebar-foot:hover { color: var(--accent); }
.sidebar-group-header {
.main-area { display: flex;
flex: 1; align-items: center;
min-width: 0; gap: 6px;
background: var(--bg); width: 100%;
} padding: 6px 8px;
margin: 0 0 2px;
/* 主内容区占满侧栏右侧可用宽度(不再限制 900px */ border: none;
.main-inner { border-radius: var(--radius);
width: 100%; background: transparent;
max-width: none; font: inherit;
margin: 0; font-size: 13px;
padding: 20px 28px 48px; color: var(--text);
box-sizing: border-box; cursor: pointer;
} text-align: left;
}
.page-toolbar { .sidebar-group-header:hover {
display: flex; background: #f3f4f6;
align-items: baseline; }
justify-content: space-between;
gap: 12px; .sidebar-group-chevron {
margin-bottom: 16px; flex-shrink: 0;
} width: 1em;
.page-title { color: var(--muted);
margin: 0; font-size: 12px;
font-size: 20px; line-height: 1;
font-weight: 600; }
}
.sidebar-group-title {
@media (max-width: 768px) { flex: 1;
.app-shell { flex-direction: column; } min-width: 0;
.sidebar { font-weight: 500;
width: 100%; word-break: break-all;
border-right: none; }
border-bottom: 1px solid var(--border);
max-height: none; .sidebar-group-count {
} flex-shrink: 0;
.sidebar-section { font-size: 11px;
display: flex; color: var(--muted);
flex-wrap: wrap; background: #f3f4f6;
align-items: flex-start; padding: 1px 6px;
gap: 8px; border-radius: 999px;
padding-bottom: 12px; }
}
.sidebar-label { width: 100%; margin-bottom: 0; } .sidebar-group-body {
.sidebar-item { flex: 1 1 auto; min-width: 44%; margin-bottom: 0; } padding: 0 0 4px 4px;
.sidebar-foot { width: 100%; } }
} .sidebar-group-body .sidebar-item {
margin-left: 4px;
.topbar { }
height: 54px;
background: var(--surface); .sidebar-item {
border-bottom: 1px solid var(--border); display: flex;
display: flex; flex-direction: column;
align-items: center; align-items: flex-start;
padding: 0 16px; gap: 2px;
position: sticky; padding: 8px 10px;
top: 0; margin-bottom: 2px;
z-index: 10; border-radius: var(--radius);
} color: var(--text);
.topbar .brand { text-decoration: none;
display: flex; align-items: center; gap: 8px; font-size: 14px;
cursor: pointer; user-select: none; font-weight: 600; line-height: 1.35;
} }
.topbar .logo { .sidebar-item:hover {
width: 28px; background: #f3f4f6;
height: 28px; text-decoration: none;
border-radius: 6px; }
object-fit: contain; .sidebar-item.active {
display: block; background: #eff6ff;
flex-shrink: 0; color: var(--accent);
} font-weight: 500;
.topbar nav { margin-left: auto; display: flex; gap: 14px; } }
.topbar nav a { color: var(--muted); } .sidebar-item-title { font-weight: 500; }
.topbar nav a.active { color: var(--text); font-weight: 600; } .sidebar-item-sub { font-size: 12px; word-break: break-all; }
.card { .sidebar-muted {
background: var(--surface); font-size: 13px;
border: 1px solid var(--border); color: var(--muted);
border-radius: var(--radius); padding: 4px 8px;
padding: 16px; margin: 0;
margin-bottom: 16px; }
}
.sidebar-foot {
h1, h2, h3 { margin: 0 0 12px; font-weight: 600; } display: block;
h1 { font-size: 22px; } font-size: 13px;
h2 { font-size: 18px; } color: var(--muted);
h3 { font-size: 15px; color: var(--muted); } padding: 8px;
margin-top: 6px;
.muted { color: var(--muted); } }
.sidebar-foot:hover { color: var(--accent); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); } .main-area {
th { color: var(--muted); font-weight: 500; background: #fafbfc; } flex: 1;
tr:hover td { background: #f9fafb; } min-width: 0;
background: var(--bg);
.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; } /* 主内容区占满侧栏右侧可用宽度(不再限制 900px */
.topbar nav { gap: 10px; } .main-inner {
.container { padding: 16px 12px 48px; } width: 100%;
th:nth-child(3), td:nth-child(3), max-width: none;
th:nth-child(4), td:nth-child(4) { display: none; } margin: 0;
} padding: 20px 28px 48px;
box-sizing: border-box;
.grid-2 { display: grid; grid-template-columns: 260px 1fr; gap: 16px; } }
@media (max-width: 720px) { .grid-2 { grid-template-columns: 1fr; } }
.page-toolbar {
.mail-list { list-style: none; margin: 0; padding: 0; } display: flex;
.mail-list li { align-items: baseline;
padding: 10px 12px; justify-content: space-between;
border-bottom: 1px solid var(--border); gap: 12px;
cursor: pointer; margin-bottom: 16px;
} }
.mail-list li.unseen { background: #f8fafc; } .page-title {
.mail-list li:hover { background: #f3f4f6; } margin: 0;
.mail-list .subject { font-weight: 500; } font-size: 20px;
.mail-list .from { color: var(--muted); font-size: 13px; } font-weight: 600;
}
/* token 弹框 */
.modal-mask { @media (max-width: 768px) {
position: fixed; inset: 0; .app-shell { flex-direction: column; }
background: rgba(17, 24, 39, .45); .sidebar {
display: flex; align-items: center; justify-content: center; width: 100%;
z-index: 100; border-right: none;
} border-bottom: 1px solid var(--border);
.modal { max-height: none;
background: var(--surface); }
border-radius: 8px; .sidebar-right {
width: min(360px, 92vw); border-left: none;
padding: 18px; border-top: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0,0,0,.18); border-bottom: none;
} order: 3;
.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; } .main-area { order: 2; }
.sidebar:not(.sidebar-right) { order: 1; }
.modal.modal-wide { .sidebar-section {
width: min(640px, 94vw); display: flex;
max-height: min(92vh, 880px); flex-wrap: wrap;
overflow-y: auto; align-items: flex-start;
padding: 20px 22px; gap: 8px;
} padding-bottom: 12px;
.modal.modal-wide h2 { }
margin: 0 0 16px; .sidebar-label-row { width: 100%; flex-wrap: wrap; }
font-size: 17px; .sidebar-label { width: auto; }
font-weight: 600; .sidebar-label-actions { width: 100%; justify-content: flex-start; }
} .sidebar-group { width: 100%; }
.sidebar-item { flex: 1 1 auto; min-width: 44%; margin-bottom: 0; }
/* 阅读邮件:宽弹窗,正文区域独立滚动 */ .sidebar-foot { width: 100%; }
.modal.modal-mail { }
width: min(1320px, 98vw);
max-height: 96vh; .topbar {
display: flex; height: 54px;
flex-direction: column; background: var(--surface);
padding: 0; border-bottom: 1px solid var(--border);
overflow: hidden; display: flex;
} align-items: center;
.modal-mail-toolbar { padding: 0 16px;
display: flex; position: sticky;
align-items: flex-start; top: 0;
justify-content: space-between; z-index: 10;
gap: 12px; }
padding: 16px 20px; .topbar .brand {
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px;
flex-shrink: 0; cursor: pointer; user-select: none; font-weight: 600;
} }
.modal-mail-toolbar h2 { .topbar .logo {
margin: 0; width: 28px;
font-size: 17px; height: 28px;
font-weight: 600; border-radius: 6px;
line-height: 1.4; object-fit: contain;
} display: block;
.modal-mail-meta { flex-shrink: 0;
padding: 0 20px 12px; }
font-size: 14px; .topbar nav { margin-left: auto; display: flex; gap: 14px; }
} .topbar nav a { color: var(--muted); }
.modal-mail-meta p { .topbar nav a.active { color: var(--text); font-weight: 600; }
margin: 4px 0;
} .card {
.modal-mail-body { background: var(--surface);
flex: 1; border: 1px solid var(--border);
min-height: 0; border-radius: var(--radius);
overflow-y: auto; padding: 16px;
padding: 0 20px 20px; margin-bottom: 16px;
} }
.modal-mail-body iframe {
display: block; h1, h2, h3 { margin: 0 0 12px; font-weight: 600; }
width: 100%; h1 { font-size: 22px; }
min-height: min(720px, 72vh); h2 { font-size: 18px; }
border: 0; h3 { font-size: 15px; color: var(--muted); }
}
.modal-mail-body pre { .muted { color: var(--muted); }
margin: 0;
white-space: pre-wrap; table { width: 100%; border-collapse: collapse; font-size: 14px; }
word-break: break-word; 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; }
.tag {
display: inline-block; font-size: 12px; color: var(--muted); .form-row { display: grid; grid-template-columns: 120px 1fr; gap: 10px 16px; align-items: center; margin-bottom: 10px; }
padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px; @media (max-width: 640px) {
} .form-row { grid-template-columns: 1fr; gap: 4px; }
.topbar nav { gap: 10px; }
pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; font-family: var(--mono); } .container { padding: 16px 12px 48px; }
th:nth-child(3), td:nth-child(3),
.access-gate { th:nth-child(4), td:nth-child(4) { display: none; }
min-height: 100vh; }
display: flex;
align-items: center; .grid-2 { display: grid; grid-template-columns: 260px 1fr; gap: 16px; }
justify-content: center; @media (max-width: 720px) { .grid-2 { grid-template-columns: 1fr; } }
padding: 24px;
box-sizing: border-box; .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;
}

View File

@@ -1,18 +1,20 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ // 生产:`.env.production` 中 VITE_API_URL=https://post.api.smyhub.com
plugins: [react()], // 开发:未设置时走相对路径 + server.proxy
server: { export default defineConfig({
port: 5173, plugins: [react()],
proxy: { server: {
'/api': 'http://127.0.0.1:8787', port: 5173,
}, proxy: {
}, '/api': 'http://127.0.0.1:8787',
preview: { },
port: 5173, },
proxy: { preview: {
'/api': 'http://127.0.0.1:8787', port: 5173,
}, proxy: {
}, '/api': 'http://127.0.0.1:8787',
}) },
},
})

View File

@@ -1,23 +1,23 @@
# 使用 Gitea tea CLI 创建远端仓库并推送(需在已 git init 且已 commit 的项目根目录执行) # 使用 Gitea tea CLI 创建远端仓库并推送(需在已 git init 且已 commit 的项目根目录执行)
# 前置: tea login add --url https://你的Gitea --token <令牌> --name <别名> # 前置: tea login add --url https://你的Gitea --token <令牌> --name <别名>
# tea login default <别名> # tea login default <别名>
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
Set-Location $root Set-Location $root
if (-not (Get-Command tea -ErrorAction SilentlyContinue)) { if (-not (Get-Command tea -ErrorAction SilentlyContinue)) {
Write-Error "未找到 tea请安装: https://gitea.com/gitea/tea/releases" Write-Error "未找到 tea请安装: https://gitea.com/gitea/tea/releases"
} }
$desc = "萌邮 MengPost基于 SMTP/IMAP 的多邮箱管理面板React + Vite + Go + Gin + SQLite" $desc = "萌邮 MengPost基于 SMTP/IMAP 的多邮箱管理面板React + Vite + Go + Gin + SQLite"
Write-Host ">>> tea repos create --name mengpost ..." Write-Host ">>> tea repos create --name mengpost ..."
tea repos create --name mengpost --description $desc tea repos create --name mengpost --description $desc
Write-Host "" Write-Host ""
Write-Host "请在 Gitea 网页打开新建的 mengpost 仓库,复制 HTTPS 克隆地址,然后执行:" Write-Host "请在 Gitea 网页打开新建的 mengpost 仓库,复制 HTTPS 克隆地址,然后执行:"
Write-Host " git remote add origin <粘贴地址>" Write-Host " git remote add origin <粘贴地址>"
Write-Host " git branch -M main" Write-Host " git branch -M main"
Write-Host " git push -u origin main" Write-Host " git push -u origin main"
Write-Host "" Write-Host ""
Write-Host "若已存在 origin可改为: git remote set-url origin <地址>" Write-Host "若已存在 origin可改为: git remote set-url origin <地址>"

View File

@@ -1,19 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 使用 Gitea tea CLI 创建远端仓库(需在已 git init 且已 commit 的项目根目录执行) # 使用 Gitea tea CLI 创建远端仓库(需在已 git init 且已 commit 的项目根目录执行)
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
if ! command -v tea >/dev/null 2>&1; then if ! command -v tea >/dev/null 2>&1; then
echo "未找到 tea请安装: https://gitea.com/gitea/tea/releases" >&2 echo "未找到 tea请安装: https://gitea.com/gitea/tea/releases" >&2
exit 1 exit 1
fi fi
DESC='萌邮 MengPost基于 SMTP/IMAP 的多邮箱管理面板React + Vite + Go + Gin + SQLite' DESC='萌邮 MengPost基于 SMTP/IMAP 的多邮箱管理面板React + Vite + Go + Gin + SQLite'
echo ">>> tea repos create --name mengpost ..." echo ">>> tea repos create --name mengpost ..."
tea repos create --name mengpost --description "$DESC" tea repos create --name mengpost --description "$DESC"
echo "" echo ""
echo "请在 Gitea 网页打开新建的 mengpost 仓库,复制 HTTPS 克隆地址,然后执行:" echo "请在 Gitea 网页打开新建的 mengpost 仓库,复制 HTTPS 克隆地址,然后执行:"
echo " git remote add origin <粘贴地址>" echo " git remote add origin <粘贴地址>"
echo " git branch -M main" echo " git branch -M main"
echo " git push -u origin main" echo " git push -u origin main"

View File

@@ -1,9 +1,9 @@
# 在已执行 tea repos create 创建 mengpost 后,关联 https://shumengya.top/shumengya/mengpost 并推送 main # 在已执行 tea repos create 创建 mengpost 后,关联 https://shumengya.top/shumengya/mengpost 并推送 main
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$Origin = "https://shumengya.top/shumengya/mengpost.git" $Origin = "https://shumengya.top/shumengya/mengpost.git"
Set-Location (Split-Path -Parent $PSScriptRoot) Set-Location (Split-Path -Parent $PSScriptRoot)
git remote remove origin 2>$null git remote remove origin 2>$null
git remote add origin $Origin git remote add origin $Origin
git branch -M main git branch -M main
git push -u origin main git push -u origin main