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/
# go
mengpost-backend/.env
mengpost-backend/.env.docker
# 生产 SQLite 数据文件(保留 data/.gitkeep
/data/*.db
/data/*.db-*
*.exe
*.test
/mengpost-backend/mengpost-backend*
@@ -16,3 +22,4 @@ mengpost.db-*
.vscode/
.idea/
.DS_Store
.codex

View File

@@ -98,10 +98,17 @@ build.bat
| QQ | `smtp.qq.com:465` | `imap.qq.com:993` |
| 163 | `smtp.163.com:465` | `imap.163.com:993` |
| Gmail | `smtp.gmail.com:465` | `imap.gmail.com:993` |
| Outlook | `smtp.office365.com:587` | `outlook.office365.com:993` |
| 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 一览
所有受保护接口均需要请求头 `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,6 +10,13 @@ type Config struct {
DBPath string // SQLite 数据库路径
Token string // 管理员访问 token
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 读取环境变量生成配置, 未设置时使用默认值.
@@ -19,9 +26,19 @@ func Load() *Config {
DBPath: env("MP_DB", "mengpost.db"),
Token: env("MP_TOKEN", "shumengya520"),
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 {
if v := os.Getenv(k); v != "" {
return v

View File

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

View File

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

View File

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

View File

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

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.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-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/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=
@@ -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/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/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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
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()})
return
}
if a.Email == "" || a.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email 和 password 必填"})
if a.Email == "" {
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
}
if err := models.CreateAccount(&a); err != nil {

View File

@@ -28,7 +28,7 @@ func ListMailboxes(c *gin.Context) {
}
boxes, err := services.ListMailboxes(acc)
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
}
c.JSON(http.StatusOK, boxes)
@@ -44,7 +44,7 @@ func ListMessages(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
list, err := services.FetchMessages(acc, mailbox, limit)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
return
}
c.JSON(http.StatusOK, list)
@@ -60,7 +60,7 @@ func GetMessage(c *gin.Context) {
mailbox := c.DefaultQuery("mailbox", "INBOX")
detail, err := services.FetchMessage(acc, mailbox, uint32(uid64))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
return
}
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}
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.Error = err.Error()
log.Error = wrapped.Error()
_ = models.AddSentLog(log)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": wrapped.Error()})
return
}
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 (
"log"
"github.com/joho/godotenv"
"mengpost-backend/config"
"mengpost-backend/database"
"mengpost-backend/router"
"mengpost-backend/services"
)
func main() {
// 从工作目录加载 .envKEY=value缺失则忽略仍可用系统环境变量。
_ = godotenv.Load()
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)

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package router
import (
"strings"
"time"
"github.com/gin-contrib/cors"
@@ -11,25 +12,55 @@ import (
"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 构建所有路由.
func New(cfg *config.Config) *gin.Engine {
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Auth-Token"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
r.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)

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
import (
"context"
"crypto/tls"
"fmt"
"io"
@@ -36,11 +37,52 @@ type MailDetail struct {
func dial(acc *models.Account) (*client.Client, error) {
addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort)
if acc.UseTLS {
return client.DialTLS(addr, &tls.Config{ServerName: acc.IMAPHost})
cfg := IMAPTLSConfig(acc)
if cfg == nil {
cfg = &tls.Config{MinVersion: tls.VersionTLS12, ServerName: acc.IMAPHost}
}
return client.DialTLS(addr, cfg)
}
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
func imapSendClientID(c *client.Client) error {
ic := imapid.NewClient(c)
@@ -60,10 +102,7 @@ func ListMailboxes(acc *models.Account) ([]string, error) {
return nil, err
}
defer c.Logout()
if err := c.Login(acc.Email, acc.Password); err != nil {
return nil, err
}
if err := imapSendClientID(c); err != nil {
if err := imapAuthenticate(c, acc); err != nil {
return nil, err
}
ch := make(chan *imap.MailboxInfo, 20)
@@ -92,10 +131,7 @@ func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummar
return nil, err
}
defer c.Logout()
if err := c.Login(acc.Email, acc.Password); err != nil {
return nil, err
}
if err := imapSendClientID(c); err != nil {
if err := imapAuthenticate(c, acc); err != nil {
return nil, err
}
mbox, err := c.Select(mailbox, true)
@@ -153,10 +189,7 @@ func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail,
return nil, err
}
defer c.Logout()
if err := c.Login(acc.Email, acc.Password); err != nil {
return nil, err
}
if err := imapSendClientID(c); err != nil {
if err := imapAuthenticate(c, acc); err != nil {
return nil, err
}
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.
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.SetHeader("From", acc.Email)
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.SSL = acc.UseTLS && acc.SMTPPort == 465
if tc := SMTPTLSConfig(acc); tc != nil {
d.TLSConfig = tc
}
return d.DialAndSend(m)
}

View File

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

View File

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

View File

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

View File

@@ -106,4 +106,6 @@ export const api = {
send: (id, body) => request('POST', `/api/accounts/${id}/send`, body),
listSent: (id, limit = 50) =>
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 { 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() {
const loc = useLocation()
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
}, [loc.pathname])
}
@@ -35,8 +57,22 @@ export default function MainLayout() {
const location = useLocation()
const [accounts, setAccounts] = useState([])
const [boxes, setBoxes] = useState([])
const [domainCollapsed, setDomainCollapsed] = useState({})
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(() => {
api
.listAccounts()
@@ -44,6 +80,19 @@ export default function MainLayout() {
.catch(() => setAccounts([]))
}, [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(() => {
if (!accountId) {
setBoxes([])
@@ -59,8 +108,38 @@ export default function MainLayout() {
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-section">
<div className="sidebar-label-row">
<div className="sidebar-label">邮箱</div>
{accounts.map((a) => (
{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}`}
@@ -72,13 +151,22 @@ export default function MainLayout() {
<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) => (
@@ -89,11 +177,8 @@ export default function MainLayout() {
写邮件
</Link>
</div>
)}
</aside>
<main className="main-area">
<Outlet />
</main>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client.js'
const empty = {
@@ -12,7 +13,12 @@ const empty = {
use_tls: true,
}
function authLabel(t) {
return t === 'oauth_microsoft' ? 'OAuth微软' : '密码'
}
export default function Accounts() {
const [searchParams, setSearchParams] = useSearchParams()
const [list, setList] = useState([])
const [form, setForm] = useState(empty)
const [editing, setEditing] = useState(null)
@@ -30,6 +36,59 @@ export default function Accounts() {
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 = () => {
setEditing(null)
setForm(empty)
@@ -105,10 +164,15 @@ export default function Accounts() {
<div className="main-inner">
<div className="page-toolbar">
<h1 className="page-title">邮箱账户</h1>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn-primary" onClick={startMicrosoftOAuth}>
微软邮箱 OAuth 登录
</button>
<button type="button" className="btn-primary" onClick={openNew}>
新增账户
</button>
</div>
</div>
{msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>}
{err && !modalOpen && (
@@ -125,6 +189,7 @@ export default function Accounts() {
<tr>
<th>名称</th>
<th>邮箱</th>
<th>认证</th>
<th>SMTP</th>
<th>IMAP</th>
<th></th>
@@ -135,6 +200,7 @@ export default function Accounts() {
<tr key={a.id}>
<td>{a.name}</td>
<td>{a.email}</td>
<td>{authLabel(a.auth_type)}</td>
<td>
{a.smtp_host}:{a.smtp_port}
</td>
@@ -165,6 +231,26 @@ export default function Accounts() {
<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 />
@@ -179,7 +265,7 @@ export default function Accounts() {
value={form.password}
onChange={set('password')}
type="password"
placeholder={editing ? '留空不修改' : ''}
placeholder={editing ? '留空不修改' : '各邮箱授权码或密码'}
/>
</div>
<div className="form-row">

View File

@@ -90,6 +90,11 @@ input:focus, select:focus, textarea:focus {
overflow-y: auto;
}
.sidebar-right {
border-right: none;
border-left: 1px solid var(--border);
}
.sidebar-section {
padding: 0 12px 16px;
margin-bottom: 8px;
@@ -99,14 +104,100 @@ input:focus, select:focus, textarea:focus {
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 {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
padding: 0 8px;
margin-bottom: 0;
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 {
@@ -186,6 +277,14 @@ input:focus, select:focus, textarea:focus {
border-bottom: 1px solid var(--border);
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 {
display: flex;
flex-wrap: wrap;
@@ -193,7 +292,10 @@ input:focus, select:focus, textarea:focus {
gap: 8px;
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-foot { width: 100%; }
}

View File

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