feat: add Docker deployment and Microsoft OAuth support

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

7
.gitignore vendored
View File

@@ -4,6 +4,12 @@ dist/
.vite/ .vite/
# go # go
mengpost-backend/.env
mengpost-backend/.env.docker
# 生产 SQLite 数据文件(保留 data/.gitkeep
/data/*.db
/data/*.db-*
*.exe *.exe
*.test *.test
/mengpost-backend/mengpost-backend* /mengpost-backend/mengpost-backend*
@@ -16,3 +22,4 @@ mengpost.db-*
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
.codex

View File

@@ -98,10 +98,17 @@ build.bat
| 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 |
> 使用 **应用专用密码 / 授权码**, 不要直接使用邮箱登录密码。 > 使用 **应用专用密码 / 授权码**, 不要直接使用邮箱登录密码。
### 微软 Outlook / Hotmail / Live2026说明
- **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。
- **后端**:对 `@outlook.com``@hotmail.com``@live.com``@msn.com` 会自动使用合适的 TLS `ServerName`,并在 IMAP/SMTP 报错时附加简要排查说明。
## API 一览 ## API 一览
所有受保护接口均需要请求头 `X-Auth-Token: <token>` 所有受保护接口均需要请求头 `X-Auth-Token: <token>`

0
data/.gitkeep Normal file
View File

View File

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

View File

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

View File

@@ -10,18 +10,35 @@ type Config struct {
DBPath string // SQLite 数据库路径 DBPath string // SQLite 数据库路径
Token string // 管理员访问 token Token string // 管理员访问 token
LogLevel string LogLevel string
// 微软 OAuth个人 Outlook/HotmailMP_MS_CLIENT_ID 非空则启用
MSClientID string
MSClientSecret string
MSRedirectURL string // 须与 Azure 应用重定向 URI 完全一致,默认本机回调
FrontendURL string // OAuth 完成后浏览器跳回的前端根地址
// CORSMP_CORS_ORIGINS 为空或 * 时允许任意 Origin否则为英文逗号分隔的来源列表
CORSAllowOrigins string
} }
// Load 读取环境变量生成配置, 未设置时使用默认值. // Load 读取环境变量生成配置, 未设置时使用默认值.
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Port: env("MP_PORT", "8787"), Port: env("MP_PORT", "8787"),
DBPath: env("MP_DB", "mengpost.db"), DBPath: env("MP_DB", "mengpost.db"),
Token: env("MP_TOKEN", "shumengya520"), Token: env("MP_TOKEN", "shumengya520"),
LogLevel: env("MP_LOG", "info"), LogLevel: env("MP_LOG", "info"),
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 { func env(k, def string) string {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
return v return v

View File

@@ -3,6 +3,7 @@ package database
import ( import (
"database/sql" "database/sql"
"log" "log"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -10,7 +11,6 @@ import (
var DB *sql.DB var DB *sql.DB
// Init 打开 SQLite 并执行必要的表结构初始化. // Init 打开 SQLite 并执行必要的表结构初始化.
// 使用纯 Go 的 modernc.org/sqlite, 无需 CGO.
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)
@@ -23,19 +23,20 @@ func Init(path string) {
} }
} }
// 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, password TEXT NOT NULL DEFAULT '',
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',
oauth_refresh_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`, );`,
`CREATE TABLE IF NOT EXISTS sent_logs ( `CREATE TABLE IF NOT EXISTS sent_logs (
@@ -48,6 +49,12 @@ func migrate() error {
error TEXT, error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS oauth_microsoft_pending (
state TEXT PRIMARY KEY,
refresh_token TEXT,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`, );`,
} }
for _, s := range stmts { for _, s := range stmts {
@@ -55,5 +62,17 @@ func migrate() error {
return err 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 return nil
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,8 +24,12 @@ func CreateAccount(c *gin.Context) {
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
}
if a.AuthType != models.AuthTypeOAuthMicrosoft && a.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "普通账户需要填写 password"})
return return
} }
if err := models.CreateAccount(&a); err != nil { if err := models.CreateAccount(&a); err != nil {

View File

@@ -28,7 +28,7 @@ func ListMailboxes(c *gin.Context) {
} }
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)
@@ -44,7 +44,7 @@ func ListMessages(c *gin.Context) {
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)
@@ -60,7 +60,7 @@ func GetMessage(c *gin.Context) {
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)
@@ -84,10 +84,11 @@ func SendMessage(c *gin.Context) {
} }
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 {
wrapped := services.WrapMicrosoftMailErr(acc.Email, err)
log.Status = "failed" log.Status = "failed"
log.Error = err.Error() log.Error = wrapped.Error()
_ = models.AddSentLog(log) _ = models.AddSentLog(log)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": wrapped.Error()})
return return
} }
log.Status = "ok" log.Status = "ok"

View File

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

View File

@@ -3,13 +3,20 @@ package main
import ( import (
"log" "log"
"github.com/joho/godotenv"
"mengpost-backend/config" "mengpost-backend/config"
"mengpost-backend/database" "mengpost-backend/database"
"mengpost-backend/router" "mengpost-backend/router"
"mengpost-backend/services"
) )
func main() { func main() {
// 从工作目录加载 .envKEY=value缺失则忽略仍可用系统环境变量。
_ = godotenv.Load()
cfg := config.Load() cfg := config.Load()
services.SetAppConfig(cfg)
database.Init(cfg.DBPath) database.Init(cfg.DBPath)
r := router.New(cfg) r := router.New(cfg)
log.Printf("萌邮 MengPost backend listening on :%s (token=%s)", cfg.Port, cfg.Token) log.Printf("萌邮 MengPost backend listening on :%s (token=%s)", cfg.Port, cfg.Token)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package services package services
import ( import (
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io" "io"
@@ -36,11 +37,52 @@ type MailDetail struct {
func dial(acc *models.Account) (*client.Client, error) { func dial(acc *models.Account) (*client.Client, error) {
addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort) addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort)
if acc.UseTLS { if acc.UseTLS {
return client.DialTLS(addr, &tls.Config{ServerName: acc.IMAPHost}) cfg := IMAPTLSConfig(acc)
if cfg == nil {
cfg = &tls.Config{MinVersion: tls.VersionTLS12, ServerName: acc.IMAPHost}
}
return client.DialTLS(addr, cfg)
} }
return client.Dial(addr) return client.Dial(addr)
} }
func imapAuthenticate(c *client.Client, acc *models.Account) error {
if acc.AuthType == models.AuthTypeOAuthMicrosoft {
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)
}
if err := c.Authenticate(NewXOAUTH2Client(acc.Email, mt.AccessToken)); err != nil {
return err
}
} else {
if err := c.Login(acc.Email, acc.Password); err != nil {
return err
}
}
// Outlook/Exchange IMAP 在 AUTH 后对 RFC2971 ID 常返回 “Command Argument Error.12”;网易等仍需 ID。
if !imapShouldSendClientID(acc) {
return nil
}
return imapSendClientID(c)
}
func imapShouldSendClientID(acc *models.Account) bool {
if acc.AuthType == models.AuthTypeOAuthMicrosoft {
return false
}
if IsMicrosoftConsumerEmail(acc.Email) && strings.EqualFold(acc.IMAPHost, "outlook.office365.com") {
return false
}
return true
}
// imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID网易 163/126/yeah 等要求,否则报 Unsafe Login // imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID网易 163/126/yeah 等要求,否则报 Unsafe Login
func imapSendClientID(c *client.Client) error { func imapSendClientID(c *client.Client) error {
ic := imapid.NewClient(c) ic := imapid.NewClient(c)
@@ -60,10 +102,7 @@ func ListMailboxes(acc *models.Account) ([]string, error) {
return nil, err return nil, err
} }
defer c.Logout() defer c.Logout()
if err := c.Login(acc.Email, acc.Password); err != nil { if err := imapAuthenticate(c, acc); err != nil {
return nil, err
}
if err := imapSendClientID(c); err != nil {
return nil, err return nil, err
} }
ch := make(chan *imap.MailboxInfo, 20) ch := make(chan *imap.MailboxInfo, 20)
@@ -92,10 +131,7 @@ func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummar
return nil, err return nil, err
} }
defer c.Logout() defer c.Logout()
if err := c.Login(acc.Email, acc.Password); err != nil { if err := imapAuthenticate(c, acc); err != nil {
return nil, err
}
if err := imapSendClientID(c); err != nil {
return nil, err return nil, err
} }
mbox, err := c.Select(mailbox, true) mbox, err := c.Select(mailbox, true)
@@ -153,10 +189,7 @@ func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail,
return nil, err return nil, err
} }
defer c.Logout() defer c.Logout()
if err := c.Login(acc.Email, acc.Password); err != nil { if err := imapAuthenticate(c, acc); err != nil {
return nil, err
}
if err := imapSendClientID(c); err != nil {
return nil, err return nil, err
} }
if _, err := c.Select(mailbox, false); err != nil { if _, err := c.Select(mailbox, false); err != nil {

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ import (
// 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 {
if acc.AuthType == models.AuthTypeOAuthMicrosoft {
return sendMailMicrosoftOAuth(acc, to, subject, body, html)
}
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", acc.Email) m.SetHeader("From", acc.Email)
m.SetHeader("To", to) m.SetHeader("To", to)
@@ -19,5 +22,8 @@ func SendMail(acc *models.Account, to, subject, body string, html bool) error {
} }
d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password) d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password)
d.SSL = acc.UseTLS && acc.SMTPPort == 465 d.SSL = acc.UseTLS && acc.SMTPPort == 465
if tc := SMTPTLSConfig(acc); tc != nil {
d.TLSConfig = tc
}
return d.DialAndSend(m) return d.DialAndSend(m)
} }

View File

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

View File

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

View File

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

View File

@@ -106,4 +106,6 @@ export const api = {
send: (id, body) => request('POST', `/api/accounts/${id}/send`, body), send: (id, body) => request('POST', `/api/accounts/${id}/send`, body),
listSent: (id, limit = 50) => listSent: (id, limit = 50) =>
request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray), request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray),
microsoftOAuthStart: () => request('GET', '/api/oauth/microsoft/start'),
microsoftOAuthFinish: (state) => request('POST', '/api/oauth/microsoft/finish', { state }),
} }

View File

@@ -1,11 +1,33 @@
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 emailDomain(email) {
const s = (email || '').trim().toLowerCase()
const at = s.lastIndexOf('@')
if (at < 0 || at === s.length - 1) return '其他'
return s.slice(at + 1)
}
function groupAccountsByDomain(accounts) {
const map = new Map()
for (const a of accounts) {
const d = emailDomain(a.email)
if (!map.has(d)) map.set(d, [])
map.get(d).push(a)
}
const groups = Array.from(map.entries()).map(([domain, items]) => ({
domain,
items: items.slice().sort((x, y) => (x.email || '').localeCompare(y.email || '', 'zh-CN')),
}))
groups.sort((a, b) => a.domain.localeCompare(b.domain, 'zh-CN'))
return groups
}
function useMailboxAccountId() { function useMailboxAccountId() {
const loc = useLocation() const loc = useLocation()
return useMemo(() => { return useMemo(() => {
const m = loc.pathname.match(/\/admin\/mailbox\/(\d+)/) const m = loc.pathname.match(/\/admin\/(?:mailbox|compose)\/(\d+)/)
return m ? Number(m[1]) : null return m ? Number(m[1]) : null
}, [loc.pathname]) }, [loc.pathname])
} }
@@ -35,8 +57,22 @@ export default function MainLayout() {
const location = useLocation() const location = useLocation()
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [boxes, setBoxes] = useState([]) const [boxes, setBoxes] = useState([])
const [domainCollapsed, setDomainCollapsed] = useState({})
const accountId = useMailboxAccountId() const accountId = useMailboxAccountId()
const groupedAccounts = useMemo(() => groupAccountsByDomain(accounts), [accounts])
const expandAllDomains = useCallback(() => setDomainCollapsed({}), [])
const collapseAllDomains = useCallback(() => {
const next = {}
for (const g of groupedAccounts) next[g.domain] = true
setDomainCollapsed(next)
}, [groupedAccounts])
const toggleDomain = useCallback((domain) => {
setDomainCollapsed((c) => ({ ...c, [domain]: !c[domain] }))
}, [])
useEffect(() => { useEffect(() => {
api api
.listAccounts() .listAccounts()
@@ -44,6 +80,19 @@ export default function MainLayout() {
.catch(() => setAccounts([])) .catch(() => setAccounts([]))
}, [location.pathname]) }, [location.pathname])
// 当前选中的账户所在分组自动展开
useEffect(() => {
const active = accounts.find((a) => accountActive(location, a.id))
if (!active) return
const d = emailDomain(active.email)
setDomainCollapsed((c) => {
if (!c[d]) return c
const next = { ...c }
delete next[d]
return next
})
}, [location.pathname, accounts])
useEffect(() => { useEffect(() => {
if (!accountId) { if (!accountId) {
setBoxes([]) setBoxes([])
@@ -59,26 +108,65 @@ export default function MainLayout() {
<div className="app-shell"> <div className="app-shell">
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-label">邮箱</div> <div className="sidebar-label-row">
{accounts.map((a) => ( <div className="sidebar-label">邮箱</div>
<NavLink {accounts.length > 0 && (
key={a.id} <div className="sidebar-label-actions">
to={`/admin/mailbox/${a.id}`} <button type="button" className="sidebar-mini-btn" onClick={expandAllDomains}>
className={() => 全部展开
'sidebar-item' + (accountActive(location, a.id) ? ' active' : '') </button>
} <button type="button" className="sidebar-mini-btn" onClick={collapseAllDomains}>
> 全部收起
<span className="sidebar-item-title">{a.name || '未命名'}</span> </button>
<span className="sidebar-item-sub muted">{a.email}</span> </div>
</NavLink> )}
))} </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>} {accounts.length === 0 && <p className="sidebar-muted">暂无账户</p>}
<Link to="/admin/accounts" className="sidebar-foot"> <Link to="/admin/accounts" className="sidebar-foot">
管理账户 管理账户
</Link> </Link>
</div> </div>
</aside>
{accountId != null && ( <main className="main-area">
<Outlet />
</main>
{accountId != null && (
<aside className="sidebar sidebar-right" aria-label="邮件夹">
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-label">邮件夹</div> <div className="sidebar-label">邮件夹</div>
{boxes.map((b) => ( {boxes.map((b) => (
@@ -89,11 +177,8 @@ export default function MainLayout() {
写邮件 写邮件
</Link> </Link>
</div> </div>
)} </aside>
</aside> )}
<main className="main-area">
<Outlet />
</main>
</div> </div>
) )
} }

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client.js' import { api } from '../api/client.js'
const empty = { const empty = {
@@ -12,7 +13,12 @@ const empty = {
use_tls: true, use_tls: true,
} }
function authLabel(t) {
return t === 'oauth_microsoft' ? 'OAuth微软' : '密码'
}
export default function Accounts() { export default function Accounts() {
const [searchParams, setSearchParams] = useSearchParams()
const [list, setList] = useState([]) const [list, setList] = useState([])
const [form, setForm] = useState(empty) const [form, setForm] = useState(empty)
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
@@ -30,6 +36,59 @@ export default function Accounts() {
reload() reload()
}, []) }, [])
useEffect(() => {
const ms = searchParams.get('oauth_ms')
const oe = searchParams.get('oauth_err')
if (!ms && !oe) return
const next = new URLSearchParams(searchParams)
if (oe) {
setErr(decodeURIComponent(oe.replace(/\+/g, ' ')))
next.delete('oauth_err')
}
if (ms) {
next.delete('oauth_ms')
}
setSearchParams(next, { replace: true })
if (ms) {
const gate = `mp_ms_oauth_${ms}`
let skip = false
try {
if (sessionStorage.getItem(gate)) skip = true
else sessionStorage.setItem(gate, '1')
} catch {
/* ignore */
}
if (skip) return
api
.microsoftOAuthFinish(ms)
.then(() => {
setMsg('微软账户已通过 OAuth 连接')
reload()
})
.catch((e) => {
try {
sessionStorage.removeItem(gate)
} catch {
/* ignore */
}
setErr(e.message)
})
}
}, [searchParams, setSearchParams])
const startMicrosoftOAuth = async () => {
setErr('')
setMsg('')
try {
const { url } = await api.microsoftOAuthStart()
if (url) window.location.href = url
} catch (e) {
setErr(e.message)
}
}
const openNew = () => { const openNew = () => {
setEditing(null) setEditing(null)
setForm(empty) setForm(empty)
@@ -105,9 +164,14 @@ export default function Accounts() {
<div className="main-inner"> <div className="main-inner">
<div className="page-toolbar"> <div className="page-toolbar">
<h1 className="page-title">邮箱账户</h1> <h1 className="page-title">邮箱账户</h1>
<button type="button" className="btn-primary" onClick={openNew}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
新增账户 <button type="button" className="btn-primary" onClick={startMicrosoftOAuth}>
</button> 微软邮箱 OAuth 登录
</button>
<button type="button" className="btn-primary" onClick={openNew}>
新增账户
</button>
</div>
</div> </div>
{msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>} {msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>}
@@ -125,6 +189,7 @@ export default function Accounts() {
<tr> <tr>
<th>名称</th> <th>名称</th>
<th>邮箱</th> <th>邮箱</th>
<th>认证</th>
<th>SMTP</th> <th>SMTP</th>
<th>IMAP</th> <th>IMAP</th>
<th></th> <th></th>
@@ -135,6 +200,7 @@ export default function Accounts() {
<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>{authLabel(a.auth_type)}</td>
<td> <td>
{a.smtp_host}:{a.smtp_port} {a.smtp_host}:{a.smtp_port}
</td> </td>
@@ -165,6 +231,26 @@ export default function Accounts() {
<div className="modal modal-wide" onClick={(e) => e.stopPropagation()}> <div className="modal modal-wide" onClick={(e) => e.stopPropagation()}>
<h2>{editing ? `编辑账户 #${editing}` : '新增账户'}</h2> <h2>{editing ? `编辑账户 #${editing}` : '新增账户'}</h2>
<form onSubmit={save}> <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"> <div className="form-row">
<label>名称</label> <label>名称</label>
<input value={form.name} onChange={set('name')} placeholder="工作 / 私人" required /> <input value={form.name} onChange={set('name')} placeholder="工作 / 私人" required />
@@ -179,7 +265,7 @@ export default function Accounts() {
value={form.password} value={form.password}
onChange={set('password')} onChange={set('password')}
type="password" type="password"
placeholder={editing ? '留空不修改' : ''} placeholder={editing ? '留空不修改' : '各邮箱授权码或密码'}
/> />
</div> </div>
<div className="form-row"> <div className="form-row">

View File

@@ -90,6 +90,11 @@ input:focus, select:focus, textarea:focus {
overflow-y: auto; overflow-y: auto;
} }
.sidebar-right {
border-right: none;
border-left: 1px solid var(--border);
}
.sidebar-section { .sidebar-section {
padding: 0 12px 16px; padding: 0 12px 16px;
margin-bottom: 8px; margin-bottom: 8px;
@@ -99,14 +104,100 @@ input:focus, select:focus, textarea:focus {
border-bottom: none; border-bottom: none;
} }
.sidebar-label-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding: 0 8px;
}
.sidebar-label { .sidebar-label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
margin-bottom: 8px; margin-bottom: 0;
padding: 0 8px; padding: 0;
}
.sidebar-label-actions {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
justify-content: flex-end;
}
.sidebar-mini-btn {
font-size: 11px;
padding: 2px 6px;
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
}
.sidebar-mini-btn:hover {
background: #eff6ff;
text-decoration: none;
}
.sidebar-group {
margin-bottom: 4px;
}
.sidebar-group-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 8px;
margin: 0 0 2px;
border: none;
border-radius: var(--radius);
background: transparent;
font: inherit;
font-size: 13px;
color: var(--text);
cursor: pointer;
text-align: left;
}
.sidebar-group-header:hover {
background: #f3f4f6;
}
.sidebar-group-chevron {
flex-shrink: 0;
width: 1em;
color: var(--muted);
font-size: 12px;
line-height: 1;
}
.sidebar-group-title {
flex: 1;
min-width: 0;
font-weight: 500;
word-break: break-all;
}
.sidebar-group-count {
flex-shrink: 0;
font-size: 11px;
color: var(--muted);
background: #f3f4f6;
padding: 1px 6px;
border-radius: 999px;
}
.sidebar-group-body {
padding: 0 0 4px 4px;
}
.sidebar-group-body .sidebar-item {
margin-left: 4px;
} }
.sidebar-item { .sidebar-item {
@@ -186,6 +277,14 @@ input:focus, select:focus, textarea:focus {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
max-height: none; max-height: none;
} }
.sidebar-right {
border-left: none;
border-top: 1px solid var(--border);
border-bottom: none;
order: 3;
}
.main-area { order: 2; }
.sidebar:not(.sidebar-right) { order: 1; }
.sidebar-section { .sidebar-section {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -193,7 +292,10 @@ input:focus, select:focus, textarea:focus {
gap: 8px; gap: 8px;
padding-bottom: 12px; padding-bottom: 12px;
} }
.sidebar-label { width: 100%; margin-bottom: 0; } .sidebar-label-row { width: 100%; flex-wrap: wrap; }
.sidebar-label { width: auto; }
.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-item { flex: 1 1 auto; min-width: 44%; margin-bottom: 0; }
.sidebar-foot { width: 100%; } .sidebar-foot { width: 100%; }
} }

View File

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