feat: add Docker deployment and Microsoft OAuth support
This commit is contained in:
35
.gitignore
vendored
35
.gitignore
vendored
@@ -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
315
README.md
@@ -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 / Live(2026说明)
|
||||||
|
|
||||||
所有受保护接口均需要请求头 `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 # 删除
|
||||||
## 推送到自建 Gitea(tea 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
|
## 推送到自建 Gitea(tea 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`
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
内部自用项目, 请按需修改。
|
||||||
|
|||||||
32
build.bat
32
build.bat
@@ -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
|
||||||
|
|||||||
18
build.sh
18
build.sh
@@ -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
0
data/.gitkeep
Normal file
26
dev.bat
26
dev.bat
@@ -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
32
dev.sh
@@ -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
|
||||||
|
|||||||
11
mengpost-backend/.dockerignore
Normal file
11
mengpost-backend/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
data/
|
||||||
|
dist/
|
||||||
|
__debug_bin
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
24
mengpost-backend/Dockerfile
Normal file
24
mengpost-backend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 多阶段构建,纯 Go(CGO_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"]
|
||||||
@@ -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/Hotmail);MP_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"),
|
// CORS:MP_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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
24
mengpost-backend/docker-compose.yml
Normal file
24
mengpost-backend/docker-compose.yml
Normal 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
|
||||||
6
mengpost-backend/env.docker.example
Normal file
6
mengpost-backend/env.docker.example
Normal 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=
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
171
mengpost-backend/handlers/oauth_microsoft.go
Normal file
171
mengpost-backend/handlers/oauth_microsoft.go
Normal 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": "未配置微软 OAuth(MP_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
// 从工作目录加载 .env(KEY=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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
6
mengpost-backend/models/auth.go
Normal file
6
mengpost-backend/models/auth.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthTypeBasic = "basic"
|
||||||
|
AuthTypeOAuthMicrosoft = "oauth_microsoft"
|
||||||
|
)
|
||||||
47
mengpost-backend/models/oauth_pending.go
Normal file
47
mengpost-backend/models/oauth_pending.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
10
mengpost-backend/services/appcfg.go
Normal file
10
mengpost-backend/services/appcfg.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
66
mengpost-backend/services/microsoft.go
Normal file
66
mengpost-backend/services/microsoft.go
Normal 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 := "(微软邮箱:网页端需开启 IMAP;OAuth 账户无需应用密码。若仍失败请检查令牌是否过期、Azure 权限是否含 IMAP。)"
|
||||||
|
return fmt.Errorf("%w %s", err, hint)
|
||||||
|
}
|
||||||
165
mengpost-backend/services/microsoft_oauth.go
Normal file
165
mengpost-backend/services/microsoft_oauth.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
73
mengpost-backend/services/smtp_oauth.go
Normal file
73
mengpost-backend/services/smtp_oauth.go
Normal 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()
|
||||||
|
}
|
||||||
35
mengpost-backend/services/xoauth2.go
Normal file
35
mengpost-backend/services/xoauth2.go
Normal 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))
|
||||||
|
}
|
||||||
2
mengpost-frontend/.env.production
Normal file
2
mengpost-frontend/.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 生产构建:npm run build 时注入。静态站点域名 post.smyhub.com,API 走 post.api.smyhub.com
|
||||||
|
VITE_API_URL=https://post.api.smyhub.com
|
||||||
@@ -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>
|
||||||
|
|||||||
3516
mengpost-frontend/package-lock.json
generated
3516
mengpost-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} <{a.email}></option>
|
<option key={a.id} value={a.id}>{a.name} <{a.email}></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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 <地址>"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user