feat: add Docker deployment and Microsoft OAuth support
This commit is contained in:
11
mengpost-backend/.dockerignore
Normal file
11
mengpost-backend/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
*.db
|
||||
*.db-*
|
||||
data/
|
||||
dist/
|
||||
__debug_bin
|
||||
*.exe
|
||||
*.test
|
||||
24
mengpost-backend/Dockerfile
Normal file
24
mengpost-backend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# 多阶段构建,纯 Go(CGO_ENABLED=0)+ modernc.org/sqlite,适合 Alpine运行。
|
||||
# 对外映射建议:主机 28088 ->容器 8088(见 docker-compose.yml)。
|
||||
FROM golang:alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -trimpath -ldflags="-s -w" -o /out/mengpost-api .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
ENV TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/data
|
||||
COPY --from=build /out/mengpost-api /app/mengpost-api
|
||||
|
||||
ENV GIN_MODE=release
|
||||
ENV MP_PORT=8088
|
||||
ENV MP_DB=/app/data/mengpost.db
|
||||
|
||||
EXPOSE 8088
|
||||
ENTRYPOINT ["/app/mengpost-api"]
|
||||
@@ -1,30 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config 存放运行期配置, 所有值均可通过环境变量覆盖.
|
||||
type Config struct {
|
||||
Port string // HTTP 监听端口
|
||||
DBPath string // SQLite 数据库路径
|
||||
Token string // 管理员访问 token
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// Load 读取环境变量生成配置, 未设置时使用默认值.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: env("MP_PORT", "8787"),
|
||||
DBPath: env("MP_DB", "mengpost.db"),
|
||||
Token: env("MP_TOKEN", "shumengya520"),
|
||||
LogLevel: env("MP_LOG", "info"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config 存放运行期配置, 所有值均可通过环境变量覆盖.
|
||||
type Config struct {
|
||||
Port string // HTTP 监听端口
|
||||
DBPath string // SQLite 数据库路径
|
||||
Token string // 管理员访问 token
|
||||
LogLevel string
|
||||
// 微软 OAuth(个人 Outlook/Hotmail);MP_MS_CLIENT_ID 非空则启用
|
||||
MSClientID string
|
||||
MSClientSecret string
|
||||
MSRedirectURL string // 须与 Azure 应用重定向 URI 完全一致,默认本机回调
|
||||
FrontendURL string // OAuth 完成后浏览器跳回的前端根地址
|
||||
// CORS:MP_CORS_ORIGINS 为空或 * 时允许任意 Origin;否则为英文逗号分隔的来源列表
|
||||
CORSAllowOrigins string
|
||||
}
|
||||
|
||||
// Load 读取环境变量生成配置, 未设置时使用默认值.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: env("MP_PORT", "8787"),
|
||||
DBPath: env("MP_DB", "mengpost.db"),
|
||||
Token: env("MP_TOKEN", "shumengya520"),
|
||||
LogLevel: env("MP_LOG", "info"),
|
||||
MSClientID: env("MP_MS_CLIENT_ID", ""),
|
||||
MSClientSecret: env("MP_MS_CLIENT_SECRET", ""),
|
||||
MSRedirectURL: env("MP_MS_OAUTH_REDIRECT", "http://127.0.0.1:8787/api/oauth/microsoft/callback"),
|
||||
FrontendURL: env("MP_FRONTEND_URL", "http://127.0.0.1:5173"),
|
||||
CORSAllowOrigins: env("MP_CORS_ORIGINS", "*"),
|
||||
}
|
||||
}
|
||||
|
||||
// MicrosoftOAuthEnabled 是否配置了微软 OAuth(需同时配置 Client ID).
|
||||
func (c *Config) MicrosoftOAuthEnabled() bool {
|
||||
return c.MSClientID != ""
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
@@ -1,59 +1,78 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
// Init 打开 SQLite 并执行必要的表结构初始化.
|
||||
// 使用纯 Go 的 modernc.org/sqlite, 无需 CGO.
|
||||
func Init(path string) {
|
||||
dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
DB = db
|
||||
if err := migrate(); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrate 创建所需表. 统一放在一处便于维护.
|
||||
func migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
smtp_host TEXT NOT NULL,
|
||||
smtp_port INTEGER NOT NULL,
|
||||
imap_host TEXT NOT NULL,
|
||||
imap_port INTEGER NOT NULL,
|
||||
use_tls INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS sent_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
to_addr TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
);`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := DB.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
// Init 打开 SQLite 并执行必要的表结构初始化.
|
||||
func Init(path string) {
|
||||
dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
DB = db
|
||||
if err := migrate(); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
to_addr TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
);`,
|
||||
`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 {
|
||||
if _, err := DB.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 旧库升级:逐列添加(已存在则忽略错误)
|
||||
alters := []string{
|
||||
`ALTER TABLE accounts ADD COLUMN auth_type TEXT DEFAULT 'basic'`,
|
||||
`ALTER TABLE accounts ADD COLUMN oauth_refresh_token TEXT`,
|
||||
}
|
||||
for _, s := range alters {
|
||||
if _, err := DB.Exec(s); err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
||||
log.Printf("migrate alter note: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
24
mengpost-backend/docker-compose.yml
Normal file
24
mengpost-backend/docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# 在 mengpost-backend 目录执行: docker compose up -d
|
||||
# 数据目录:仓库根目录下的 data/(与 backend 并列),挂载到容器 /app/data
|
||||
services:
|
||||
mengpost-api:
|
||||
build: .
|
||||
image: mengpost-api:local
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 主机 28088 -> 容器 8088(内网穿透可把 post.api.smyhub.com 指到主机 28088)
|
||||
- "28088:8088"
|
||||
environment:
|
||||
GIN_MODE: release
|
||||
MP_PORT: "8088"
|
||||
MP_DB: /app/data/mengpost.db
|
||||
# 生产务必改为强随机字符串,或通过 .env 覆盖
|
||||
MP_TOKEN: ${MP_TOKEN:-change-me-in-production}
|
||||
MP_FRONTEND_URL: ${MP_FRONTEND_URL:-https://post.smyhub.com}
|
||||
MP_MS_OAUTH_REDIRECT: ${MP_MS_OAUTH_REDIRECT:-https://post.api.smyhub.com/api/oauth/microsoft/callback}
|
||||
MP_MS_CLIENT_ID: ${MP_MS_CLIENT_ID:-}
|
||||
MP_MS_CLIENT_SECRET: ${MP_MS_CLIENT_SECRET:-}
|
||||
# 留空或 * 表示允许任意 Origin;可改为 https://post.smyhub.com
|
||||
MP_CORS_ORIGINS: ${MP_CORS_ORIGINS:-}
|
||||
volumes:
|
||||
- ../data:/app/data
|
||||
6
mengpost-backend/env.docker.example
Normal file
6
mengpost-backend/env.docker.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# 复制为 .env 后由 docker compose 读取(与 docker-compose.yml 同目录)
|
||||
MP_TOKEN=请改为强随机字符串
|
||||
MP_FRONTEND_URL=https://post.smyhub.com
|
||||
MP_MS_OAUTH_REDIRECT=https://post.api.smyhub.com/api/oauth/microsoft/callback
|
||||
MP_MS_CLIENT_ID=
|
||||
MP_MS_CLIENT_SECRET=
|
||||
@@ -1,13 +1,16 @@
|
||||
module mengpost-backend
|
||||
|
||||
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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -1,61 +1,65 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
func ListAccounts(c *gin.Context) {
|
||||
list, err := models.ListAccounts()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateAccount(c *gin.Context) {
|
||||
var a models.Account
|
||||
if err := c.ShouldBindJSON(&a); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if a.Email == "" || a.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email 和 password 必填"})
|
||||
return
|
||||
}
|
||||
if err := models.CreateAccount(&a); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
a.Password = ""
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
func UpdateAccount(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var a models.Account
|
||||
if err := c.ShouldBindJSON(&a); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := models.UpdateAccount(&a); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteAccount(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err := models.DeleteAccount(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
func ListAccounts(c *gin.Context) {
|
||||
list, err := models.ListAccounts()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateAccount(c *gin.Context) {
|
||||
var a models.Account
|
||||
if err := c.ShouldBindJSON(&a); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if a.Email == "" {
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
a.Password = ""
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
func UpdateAccount(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var a models.Account
|
||||
if err := c.ShouldBindJSON(&a); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := models.UpdateAccount(&a); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteAccount(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err := models.DeleteAccount(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/config"
|
||||
)
|
||||
|
||||
// VerifyToken 供前端 token 弹窗使用: 仅返回 token 是否正确.
|
||||
func VerifyToken(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if body.Token != cfg.Token {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"ok": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/config"
|
||||
)
|
||||
|
||||
// VerifyToken 供前端 token 弹窗使用: 仅返回 token 是否正确.
|
||||
func VerifyToken(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if body.Token != cfg.Token {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"ok": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/models"
|
||||
"mengpost-backend/services"
|
||||
)
|
||||
|
||||
func getAccount(c *gin.Context) *models.Account {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
acc, err := models.GetAccount(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "account not found"})
|
||||
return nil
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/mailboxes
|
||||
func ListMailboxes(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
boxes, err := services.ListMailboxes(acc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, boxes)
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/messages?mailbox=INBOX&limit=30
|
||||
func ListMessages(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
mailbox := c.DefaultQuery("mailbox", "INBOX")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||
list, err := services.FetchMessages(acc, mailbox, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/messages/:uid?mailbox=INBOX
|
||||
func GetMessage(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
uid64, _ := strconv.ParseUint(c.Param("uid"), 10, 32)
|
||||
mailbox := c.DefaultQuery("mailbox", "INBOX")
|
||||
detail, err := services.FetchMessage(acc, mailbox, uint32(uid64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// POST /api/accounts/:id/send
|
||||
func SendMessage(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
HTML bool `json:"html"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log := &models.SentLog{AccountID: acc.ID, To: body.To, Subject: body.Subject, Body: body.Body}
|
||||
if err := services.SendMail(acc, body.To, body.Subject, body.Body, body.HTML); err != nil {
|
||||
log.Status = "failed"
|
||||
log.Error = err.Error()
|
||||
_ = models.AddSentLog(log)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log.Status = "ok"
|
||||
_ = models.AddSentLog(log)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/sent?limit=50
|
||||
func ListSentLogs(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
list, err := models.ListSentLogs(acc.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/models"
|
||||
"mengpost-backend/services"
|
||||
)
|
||||
|
||||
func getAccount(c *gin.Context) *models.Account {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
acc, err := models.GetAccount(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "account not found"})
|
||||
return nil
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/mailboxes
|
||||
func ListMailboxes(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
boxes, err := services.ListMailboxes(acc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, boxes)
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/messages?mailbox=INBOX&limit=30
|
||||
func ListMessages(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
mailbox := c.DefaultQuery("mailbox", "INBOX")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||
list, err := services.FetchMessages(acc, mailbox, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/messages/:uid?mailbox=INBOX
|
||||
func GetMessage(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
uid64, _ := strconv.ParseUint(c.Param("uid"), 10, 32)
|
||||
mailbox := c.DefaultQuery("mailbox", "INBOX")
|
||||
detail, err := services.FetchMessage(acc, mailbox, uint32(uid64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// POST /api/accounts/:id/send
|
||||
func SendMessage(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
HTML bool `json:"html"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log := &models.SentLog{AccountID: acc.ID, To: body.To, Subject: body.Subject, Body: body.Body}
|
||||
if err := services.SendMail(acc, body.To, body.Subject, body.Body, body.HTML); err != nil {
|
||||
wrapped := services.WrapMicrosoftMailErr(acc.Email, err)
|
||||
log.Status = "failed"
|
||||
log.Error = wrapped.Error()
|
||||
_ = models.AddSentLog(log)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": wrapped.Error()})
|
||||
return
|
||||
}
|
||||
log.Status = "ok"
|
||||
_ = models.AddSentLog(log)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/accounts/:id/sent?limit=50
|
||||
func ListSentLogs(c *gin.Context) {
|
||||
acc := getAccount(c)
|
||||
if acc == nil {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
list, err := models.ListSentLogs(acc.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
171
mengpost-backend/handlers/oauth_microsoft.go
Normal file
171
mengpost-backend/handlers/oauth_microsoft.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/config"
|
||||
"mengpost-backend/models"
|
||||
"mengpost-backend/services"
|
||||
)
|
||||
|
||||
const (
|
||||
msSMTPHost = "smtp.office365.com"
|
||||
msSMTPPort = 587
|
||||
msIMAPHost = "outlook.office365.com"
|
||||
msIMAPPort = 993
|
||||
oauthPath = "/admin/accounts"
|
||||
tokenTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
func randomOAuthState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// MicrosoftOAuthEnabled GET /api/oauth/microsoft/enabled
|
||||
func MicrosoftOAuthEnabled(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": cfg.MicrosoftOAuthEnabled()})
|
||||
}
|
||||
}
|
||||
|
||||
// MicrosoftOAuthStart GET /api/oauth/microsoft/start
|
||||
func MicrosoftOAuthStart(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !cfg.MicrosoftOAuthEnabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "未配置微软 OAuth(MP_MS_CLIENT_ID)"})
|
||||
return
|
||||
}
|
||||
state, err := randomOAuthState()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := models.InsertOAuthPendingState(state); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authURL := services.MicrosoftAuthCodeURL(cfg, state)
|
||||
c.JSON(http.StatusOK, gin.H{"url": authURL, "state": state})
|
||||
}
|
||||
}
|
||||
|
||||
// MicrosoftOAuthCallback GET /api/oauth/microsoft/callback(微软公网回调,无 token)
|
||||
func MicrosoftOAuthCallback(cfg *config.Config) gin.HandlerFunc {
|
||||
frontendAccounts := strings.TrimSuffix(cfg.FrontendURL, "/") + oauthPath
|
||||
return func(c *gin.Context) {
|
||||
q := c.Request.URL.Query()
|
||||
if errMsg := q.Get("error"); errMsg != "" {
|
||||
desc := q.Get("error_description")
|
||||
msg := errMsg
|
||||
if desc != "" {
|
||||
msg = errMsg + ": " + desc
|
||||
}
|
||||
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(msg))
|
||||
return
|
||||
}
|
||||
state := q.Get("state")
|
||||
code := q.Get("code")
|
||||
if state == "" || code == "" {
|
||||
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape("缺少 code 或 state"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), tokenTimeout)
|
||||
defer cancel()
|
||||
email, refresh, _, _, err := services.ExchangeMicrosoftCode(ctx, cfg, code)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := models.UpdateOAuthPendingTokens(state, refresh, email); err != nil {
|
||||
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error()))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, frontendAccounts+"?oauth_ms="+url.QueryEscape(state))
|
||||
}
|
||||
}
|
||||
|
||||
type microsoftFinishBody struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// MicrosoftOAuthFinish POST /api/oauth/microsoft/finish
|
||||
func MicrosoftOAuthFinish() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body microsoftFinishBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.State == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 JSON: {\"state\":\"...\"}"})
|
||||
return
|
||||
}
|
||||
refresh, email, err := models.TakeOAuthPending(body.State)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrOAuthPendingNotFound) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "登录会话无效或已使用,请重新发起 OAuth"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
|
||||
acc, err := models.GetAccountByEmail(email)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
acc.AuthType = models.AuthTypeOAuthMicrosoft
|
||||
acc.OAuthRefreshToken = refresh
|
||||
acc.SMTPHost = msSMTPHost
|
||||
acc.SMTPPort = msSMTPPort
|
||||
acc.IMAPHost = msIMAPHost
|
||||
acc.IMAPPort = msIMAPPort
|
||||
acc.UseTLS = true
|
||||
if err := models.UpdateAccount(acc); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
acc.Password = ""
|
||||
acc.OAuthRefreshToken = ""
|
||||
c.JSON(http.StatusOK, acc)
|
||||
return
|
||||
}
|
||||
|
||||
local := email
|
||||
if at := strings.IndexByte(email, '@'); at > 0 {
|
||||
local = email[:at]
|
||||
}
|
||||
na := &models.Account{
|
||||
Name: local,
|
||||
Email: email,
|
||||
Password: "",
|
||||
SMTPHost: msSMTPHost,
|
||||
SMTPPort: msSMTPPort,
|
||||
IMAPHost: msIMAPHost,
|
||||
IMAPPort: msIMAPPort,
|
||||
UseTLS: true,
|
||||
AuthType: models.AuthTypeOAuthMicrosoft,
|
||||
OAuthRefreshToken: refresh,
|
||||
}
|
||||
if err := models.CreateAccount(na); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
na.Password = ""
|
||||
na.OAuthRefreshToken = ""
|
||||
c.JSON(http.StatusOK, na)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"mengpost-backend/config"
|
||||
"mengpost-backend/database"
|
||||
"mengpost-backend/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
database.Init(cfg.DBPath)
|
||||
r := router.New(cfg)
|
||||
log.Printf("萌邮 MengPost backend listening on :%s (token=%s)", cfg.Port, cfg.Token)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"mengpost-backend/config"
|
||||
"mengpost-backend/database"
|
||||
"mengpost-backend/router"
|
||||
"mengpost-backend/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 从工作目录加载 .env(KEY=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)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenAuth 校验请求头中的 X-Auth-Token 或 Authorization: Bearer <token>.
|
||||
func TokenAuth(expected string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
got := c.GetHeader("X-Auth-Token")
|
||||
if got == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
got = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
if got == "" || got != expected {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenAuth 校验请求头中的 X-Auth-Token 或 Authorization: Bearer <token>.
|
||||
func TokenAuth(expected string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
got := c.GetHeader("X-Auth-Token")
|
||||
if got == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
got = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
if got == "" || got != expected {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,139 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mengpost-backend/database"
|
||||
)
|
||||
|
||||
// Account 代表一个邮箱账户的完整连接信息.
|
||||
type Account struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password,omitempty"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
UseTLS bool `json:"use_tls"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ListAccounts() ([]Account, error) {
|
||||
rows, err := database.DB.Query(`SELECT id,name,email,smtp_host,smtp_port,imap_host,imap_port,use_tls,created_at FROM accounts ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
var tls int
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Email, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.UseTLS = tls == 1
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetAccount(id int64) (*Account, error) {
|
||||
row := database.DB.QueryRow(`SELECT id,name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls,created_at FROM accounts WHERE id=?`, id)
|
||||
var a Account
|
||||
var tls int
|
||||
if err := row.Scan(&a.ID, &a.Name, &a.Email, &a.Password, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.UseTLS = tls == 1
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func CreateAccount(a *Account) error {
|
||||
tls := 0
|
||||
if a.UseTLS {
|
||||
tls = 1
|
||||
}
|
||||
res, err := database.DB.Exec(
|
||||
`INSERT INTO accounts(name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
a.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateAccount(a *Account) error {
|
||||
tls := 0
|
||||
if a.UseTLS {
|
||||
tls = 1
|
||||
}
|
||||
_, err := database.DB.Exec(
|
||||
`UPDATE accounts SET name=?,email=?,password=COALESCE(NULLIF(?, ''), password),smtp_host=?,smtp_port=?,imap_host=?,imap_port=?,use_tls=? WHERE id=?`,
|
||||
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAccount(id int64) error {
|
||||
_, err := database.DB.Exec(`DELETE FROM accounts WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"mengpost-backend/database"
|
||||
)
|
||||
|
||||
// Account 邮箱账户(basic 为密码;oauth_microsoft 使用刷新令牌).
|
||||
type Account struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password,omitempty"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
UseTLS bool `json:"use_tls"`
|
||||
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,
|
||||
IFNULL(auth_type,'basic'), created_at
|
||||
FROM accounts ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
var tls int
|
||||
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 scanFullAccount(row *sql.Row) (*Account, error) {
|
||||
var a Account
|
||||
var tls int
|
||||
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,auth_type,oauth_refresh_token)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.AuthType, oauth,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
a.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateAccount(a *Account) error {
|
||||
tls := 0
|
||||
if a.UseTLS {
|
||||
tls = 1
|
||||
}
|
||||
if a.AuthType == "" {
|
||||
a.AuthType = AuthTypeBasic
|
||||
}
|
||||
_, err := database.DB.Exec(
|
||||
`UPDATE accounts SET name=?,email=?,password=COALESCE(NULLIF(?, ''), password),smtp_host=?,smtp_port=?,imap_host=?,imap_port=?,use_tls=?,auth_type=?,
|
||||
oauth_refresh_token=COALESCE(NULLIF(?, ''), oauth_refresh_token) WHERE id=?`,
|
||||
a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.AuthType,
|
||||
a.OAuthRefreshToken, a.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateAccountOAuthRefresh(id int64, refresh string) error {
|
||||
_, err := database.DB.Exec(`UPDATE accounts SET oauth_refresh_token=? WHERE id=?`, refresh, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAccount(id int64) error {
|
||||
_, err := database.DB.Exec(`DELETE FROM accounts WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
6
mengpost-backend/models/auth.go
Normal file
6
mengpost-backend/models/auth.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
AuthTypeBasic = "basic"
|
||||
AuthTypeOAuthMicrosoft = "oauth_microsoft"
|
||||
)
|
||||
47
mengpost-backend/models/oauth_pending.go
Normal file
47
mengpost-backend/models/oauth_pending.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"mengpost-backend/database"
|
||||
)
|
||||
|
||||
// ErrOAuthPendingNotFound state 不存在或尚未完成微软回调.
|
||||
var ErrOAuthPendingNotFound = errors.New("oauth state 无效或已过期")
|
||||
|
||||
func InsertOAuthPendingState(state string) error {
|
||||
_, err := database.DB.Exec(`INSERT INTO oauth_microsoft_pending(state) VALUES(?)`, state)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateOAuthPendingTokens(state, refresh, email string) error {
|
||||
res, err := database.DB.Exec(
|
||||
`UPDATE oauth_microsoft_pending SET refresh_token=?, email=? WHERE state=?`,
|
||||
refresh, email, state,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrOAuthPendingNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TakeOAuthPending 取出并删除 pending,用于完成账户创建.
|
||||
func TakeOAuthPending(state string) (refresh, email string, err error) {
|
||||
row := database.DB.QueryRow(
|
||||
`SELECT refresh_token, email FROM oauth_microsoft_pending WHERE state=? AND refresh_token IS NOT NULL AND email IS NOT NULL`,
|
||||
state,
|
||||
)
|
||||
if err := row.Scan(&refresh, &email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", ErrOAuthPendingNotFound
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
_, _ = database.DB.Exec(`DELETE FROM oauth_microsoft_pending WHERE state=?`, state)
|
||||
return refresh, email, nil
|
||||
}
|
||||
@@ -1,50 +1,50 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mengpost-backend/database"
|
||||
)
|
||||
|
||||
// SentLog 记录一次外发邮件的结果.
|
||||
type SentLog struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func AddSentLog(l *SentLog) error {
|
||||
_, err := database.DB.Exec(
|
||||
`INSERT INTO sent_logs(account_id,to_addr,subject,body,status,error) VALUES(?,?,?,?,?,?)`,
|
||||
l.AccountID, l.To, l.Subject, l.Body, l.Status, l.Error,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListSentLogs(accountID int64, limit int) ([]SentLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id,account_id,to_addr,subject,body,status,error,created_at FROM sent_logs WHERE account_id=? ORDER BY id DESC LIMIT ?`,
|
||||
accountID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SentLog
|
||||
for rows.Next() {
|
||||
var l SentLog
|
||||
if err := rows.Scan(&l.ID, &l.AccountID, &l.To, &l.Subject, &l.Body, &l.Status, &l.Error, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mengpost-backend/database"
|
||||
)
|
||||
|
||||
// SentLog 记录一次外发邮件的结果.
|
||||
type SentLog struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func AddSentLog(l *SentLog) error {
|
||||
_, err := database.DB.Exec(
|
||||
`INSERT INTO sent_logs(account_id,to_addr,subject,body,status,error) VALUES(?,?,?,?,?,?)`,
|
||||
l.AccountID, l.To, l.Subject, l.Body, l.Status, l.Error,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListSentLogs(accountID int64, limit int) ([]SentLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id,account_id,to_addr,subject,body,status,error,created_at FROM sent_logs WHERE account_id=? ORDER BY id DESC LIMIT ?`,
|
||||
accountID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SentLog
|
||||
for rows.Next() {
|
||||
var l SentLog
|
||||
if err := rows.Scan(&l.ID, &l.AccountID, &l.To, &l.Subject, &l.Body, &l.Status, &l.Error, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1,45 +1,76 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/config"
|
||||
"mengpost-backend/handlers"
|
||||
"mengpost-backend/middleware"
|
||||
)
|
||||
|
||||
// New 构建所有路由.
|
||||
func New(cfg *config.Config) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Auth-Token"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
r.POST("/api/auth/verify", handlers.VerifyToken(cfg))
|
||||
|
||||
auth := r.Group("/api")
|
||||
auth.Use(middleware.TokenAuth(cfg.Token))
|
||||
{
|
||||
auth.GET("/accounts", handlers.ListAccounts)
|
||||
auth.POST("/accounts", handlers.CreateAccount)
|
||||
auth.PUT("/accounts/:id", handlers.UpdateAccount)
|
||||
auth.DELETE("/accounts/:id", handlers.DeleteAccount)
|
||||
|
||||
auth.GET("/accounts/:id/mailboxes", handlers.ListMailboxes)
|
||||
auth.GET("/accounts/:id/messages", handlers.ListMessages)
|
||||
auth.GET("/accounts/:id/messages/:uid", handlers.GetMessage)
|
||||
auth.POST("/accounts/:id/send", handlers.SendMessage)
|
||||
auth.GET("/accounts/:id/sent", handlers.ListSentLogs)
|
||||
}
|
||||
return r
|
||||
}
|
||||
package router
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengpost-backend/config"
|
||||
"mengpost-backend/handlers"
|
||||
"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(corsConfig(cfg)))
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
r.POST("/api/auth/verify", handlers.VerifyToken(cfg))
|
||||
r.GET("/api/oauth/microsoft/enabled", handlers.MicrosoftOAuthEnabled(cfg))
|
||||
r.GET("/api/oauth/microsoft/callback", handlers.MicrosoftOAuthCallback(cfg))
|
||||
|
||||
auth := r.Group("/api")
|
||||
auth.Use(middleware.TokenAuth(cfg.Token))
|
||||
{
|
||||
auth.GET("/oauth/microsoft/start", handlers.MicrosoftOAuthStart(cfg))
|
||||
auth.POST("/oauth/microsoft/finish", handlers.MicrosoftOAuthFinish())
|
||||
|
||||
auth.GET("/accounts", handlers.ListAccounts)
|
||||
auth.POST("/accounts", handlers.CreateAccount)
|
||||
auth.PUT("/accounts/:id", handlers.UpdateAccount)
|
||||
auth.DELETE("/accounts/:id", handlers.DeleteAccount)
|
||||
|
||||
auth.GET("/accounts/:id/mailboxes", handlers.ListMailboxes)
|
||||
auth.GET("/accounts/:id/messages", handlers.ListMessages)
|
||||
auth.GET("/accounts/:id/messages/:uid", handlers.GetMessage)
|
||||
auth.POST("/accounts/:id/send", handlers.SendMessage)
|
||||
auth.GET("/accounts/:id/sent", handlers.ListSentLogs)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
10
mengpost-backend/services/appcfg.go
Normal file
10
mengpost-backend/services/appcfg.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package services
|
||||
|
||||
import "mengpost-backend/config"
|
||||
|
||||
// AppCfg 供 IMAP/SMTP 刷新微软令牌等使用(main 启动时注入).
|
||||
var AppCfg *config.Config
|
||||
|
||||
func SetAppConfig(c *config.Config) {
|
||||
AppCfg = c
|
||||
}
|
||||
@@ -1,225 +1,258 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-message/mail"
|
||||
imapid "github.com/emersion/go-imap-id"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// MailSummary 是列表页展示的邮件摘要.
|
||||
type MailSummary struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
Date time.Time `json:"date"`
|
||||
Seen bool `json:"seen"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
}
|
||||
|
||||
// MailDetail 邮件详情, 包含正文.
|
||||
type MailDetail struct {
|
||||
MailSummary
|
||||
To []string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
func dial(acc *models.Account) (*client.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort)
|
||||
if acc.UseTLS {
|
||||
return client.DialTLS(addr, &tls.Config{ServerName: acc.IMAPHost})
|
||||
}
|
||||
return client.Dial(addr)
|
||||
}
|
||||
|
||||
// imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID(网易 163/126/yeah 等要求,否则报 Unsafe Login)。
|
||||
func imapSendClientID(c *client.Client) error {
|
||||
ic := imapid.NewClient(c)
|
||||
_, err := ic.ID(imapid.ID{
|
||||
imapid.FieldName: "MengPost",
|
||||
imapid.FieldVersion: "1.0",
|
||||
imapid.FieldVendor: "MengPost",
|
||||
imapid.FieldOS: "Go",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMailboxes 列出账户下可用的邮件夹名称.
|
||||
func ListMailboxes(acc *models.Account) ([]string, error) {
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Login(acc.Email, acc.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := imapSendClientID(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan *imap.MailboxInfo, 20)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.List("", "*", ch) }()
|
||||
var out []string
|
||||
for m := range ch {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchMessages 获取指定邮件夹中最近 n 封邮件摘要.
|
||||
func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummary, error) {
|
||||
if mailbox == "" {
|
||||
mailbox = "INBOX"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Login(acc.Email, acc.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := imapSendClientID(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mbox, err := c.Select(mailbox, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mbox.Messages == 0 {
|
||||
return []MailSummary{}, nil
|
||||
}
|
||||
from := uint32(1)
|
||||
if mbox.Messages > uint32(limit) {
|
||||
from = mbox.Messages - uint32(limit) + 1
|
||||
}
|
||||
seq := new(imap.SeqSet)
|
||||
seq.AddRange(from, mbox.Messages)
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}
|
||||
ch := make(chan *imap.Message, limit)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.Fetch(seq, items, ch) }()
|
||||
var out []MailSummary
|
||||
for msg := range ch {
|
||||
sum := MailSummary{UID: msg.Uid, Mailbox: mailbox}
|
||||
if msg.Envelope != nil {
|
||||
sum.Subject = msg.Envelope.Subject
|
||||
sum.Date = msg.Envelope.Date
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
a := msg.Envelope.From[0]
|
||||
sum.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
|
||||
}
|
||||
}
|
||||
for _, f := range msg.Flags {
|
||||
if f == imap.SeenFlag {
|
||||
sum.Seen = true
|
||||
}
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 最新的在前
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchMessage 根据 UID 获取单封邮件完整内容.
|
||||
func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail, error) {
|
||||
if mailbox == "" {
|
||||
mailbox = "INBOX"
|
||||
}
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Login(acc.Email, acc.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := imapSendClientID(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.Select(mailbox, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seq := new(imap.SeqSet)
|
||||
seq.AddNum(uid)
|
||||
section := &imap.BodySectionName{}
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()}
|
||||
ch := make(chan *imap.Message, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.UidFetch(seq, items, ch) }()
|
||||
msg := <-ch
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("message not found")
|
||||
}
|
||||
d := &MailDetail{MailSummary: MailSummary{UID: msg.Uid, Mailbox: mailbox}}
|
||||
if msg.Envelope != nil {
|
||||
d.Subject = msg.Envelope.Subject
|
||||
d.Date = msg.Envelope.Date
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
a := msg.Envelope.From[0]
|
||||
d.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
|
||||
}
|
||||
for _, a := range msg.Envelope.To {
|
||||
d.To = append(d.To, fmt.Sprintf("%s@%s", a.MailboxName, a.HostName))
|
||||
}
|
||||
}
|
||||
for _, f := range msg.Flags {
|
||||
if f == imap.SeenFlag {
|
||||
d.Seen = true
|
||||
}
|
||||
}
|
||||
r := msg.GetBody(section)
|
||||
if r == nil {
|
||||
return d, nil
|
||||
}
|
||||
mr, err := mail.CreateReader(r)
|
||||
if err != nil {
|
||||
b, _ := io.ReadAll(r)
|
||||
d.Text = string(b)
|
||||
return d, nil
|
||||
}
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
break
|
||||
}
|
||||
switch h := p.Header.(type) {
|
||||
case *mail.InlineHeader:
|
||||
ct, _, _ := h.ContentType()
|
||||
b, _ := io.ReadAll(p.Body)
|
||||
if strings.Contains(ct, "html") {
|
||||
d.HTML = string(b)
|
||||
} else {
|
||||
d.Text = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-message/mail"
|
||||
imapid "github.com/emersion/go-imap-id"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// MailSummary 是列表页展示的邮件摘要.
|
||||
type MailSummary struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
Date time.Time `json:"date"`
|
||||
Seen bool `json:"seen"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
}
|
||||
|
||||
// MailDetail 邮件详情, 包含正文.
|
||||
type MailDetail struct {
|
||||
MailSummary
|
||||
To []string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
func dial(acc *models.Account) (*client.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort)
|
||||
if acc.UseTLS {
|
||||
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)
|
||||
_, err := ic.ID(imapid.ID{
|
||||
imapid.FieldName: "MengPost",
|
||||
imapid.FieldVersion: "1.0",
|
||||
imapid.FieldVendor: "MengPost",
|
||||
imapid.FieldOS: "Go",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMailboxes 列出账户下可用的邮件夹名称.
|
||||
func ListMailboxes(acc *models.Account) ([]string, error) {
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := imapAuthenticate(c, acc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan *imap.MailboxInfo, 20)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.List("", "*", ch) }()
|
||||
var out []string
|
||||
for m := range ch {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchMessages 获取指定邮件夹中最近 n 封邮件摘要.
|
||||
func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummary, error) {
|
||||
if mailbox == "" {
|
||||
mailbox = "INBOX"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := imapAuthenticate(c, acc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mbox, err := c.Select(mailbox, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mbox.Messages == 0 {
|
||||
return []MailSummary{}, nil
|
||||
}
|
||||
from := uint32(1)
|
||||
if mbox.Messages > uint32(limit) {
|
||||
from = mbox.Messages - uint32(limit) + 1
|
||||
}
|
||||
seq := new(imap.SeqSet)
|
||||
seq.AddRange(from, mbox.Messages)
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}
|
||||
ch := make(chan *imap.Message, limit)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.Fetch(seq, items, ch) }()
|
||||
var out []MailSummary
|
||||
for msg := range ch {
|
||||
sum := MailSummary{UID: msg.Uid, Mailbox: mailbox}
|
||||
if msg.Envelope != nil {
|
||||
sum.Subject = msg.Envelope.Subject
|
||||
sum.Date = msg.Envelope.Date
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
a := msg.Envelope.From[0]
|
||||
sum.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
|
||||
}
|
||||
}
|
||||
for _, f := range msg.Flags {
|
||||
if f == imap.SeenFlag {
|
||||
sum.Seen = true
|
||||
}
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 最新的在前
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchMessage 根据 UID 获取单封邮件完整内容.
|
||||
func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail, error) {
|
||||
if mailbox == "" {
|
||||
mailbox = "INBOX"
|
||||
}
|
||||
c, err := dial(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := imapAuthenticate(c, acc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.Select(mailbox, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seq := new(imap.SeqSet)
|
||||
seq.AddNum(uid)
|
||||
section := &imap.BodySectionName{}
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()}
|
||||
ch := make(chan *imap.Message, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.UidFetch(seq, items, ch) }()
|
||||
msg := <-ch
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("message not found")
|
||||
}
|
||||
d := &MailDetail{MailSummary: MailSummary{UID: msg.Uid, Mailbox: mailbox}}
|
||||
if msg.Envelope != nil {
|
||||
d.Subject = msg.Envelope.Subject
|
||||
d.Date = msg.Envelope.Date
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
a := msg.Envelope.From[0]
|
||||
d.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName)
|
||||
}
|
||||
for _, a := range msg.Envelope.To {
|
||||
d.To = append(d.To, fmt.Sprintf("%s@%s", a.MailboxName, a.HostName))
|
||||
}
|
||||
}
|
||||
for _, f := range msg.Flags {
|
||||
if f == imap.SeenFlag {
|
||||
d.Seen = true
|
||||
}
|
||||
}
|
||||
r := msg.GetBody(section)
|
||||
if r == nil {
|
||||
return d, nil
|
||||
}
|
||||
mr, err := mail.CreateReader(r)
|
||||
if err != nil {
|
||||
b, _ := io.ReadAll(r)
|
||||
d.Text = string(b)
|
||||
return d, nil
|
||||
}
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
break
|
||||
}
|
||||
switch h := p.Header.(type) {
|
||||
case *mail.InlineHeader:
|
||||
ct, _, _ := h.ContentType()
|
||||
b, _ := io.ReadAll(p.Body)
|
||||
if strings.Contains(ct, "html") {
|
||||
d.HTML = string(b)
|
||||
} else {
|
||||
d.Text = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
66
mengpost-backend/services/microsoft.go
Normal file
66
mengpost-backend/services/microsoft.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// 微软个人邮箱常见后缀(Outlook / Hotmail / Live 等).
|
||||
var microsoftConsumerSuffixes = []string{
|
||||
"@outlook.com",
|
||||
"@hotmail.com",
|
||||
"@live.com",
|
||||
"@msn.com",
|
||||
}
|
||||
|
||||
// IsMicrosoftConsumerEmail 判断是否为消费者微软邮箱(非 Exchange 自建域).
|
||||
func IsMicrosoftConsumerEmail(email string) bool {
|
||||
e := strings.ToLower(strings.TrimSpace(email))
|
||||
for _, suf := range microsoftConsumerSuffixes {
|
||||
if strings.HasSuffix(e, suf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IMAPTLSConfig 为 IMAP TLS 握手提供 ServerName(微软等场景与证书一致).
|
||||
func IMAPTLSConfig(acc *models.Account) *tls.Config {
|
||||
if acc == nil || !acc.UseTLS {
|
||||
return nil
|
||||
}
|
||||
sn := acc.IMAPHost
|
||||
if IsMicrosoftConsumerEmail(acc.Email) && strings.EqualFold(acc.IMAPHost, "outlook.office365.com") {
|
||||
sn = "outlook.office365.com"
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: sn,
|
||||
}
|
||||
}
|
||||
|
||||
// SMTPTLSConfig 发信使用 587 STARTTLS 时校验证书(微软 smtp.office365.com).
|
||||
func SMTPTLSConfig(acc *models.Account) *tls.Config {
|
||||
if acc == nil || !IsMicrosoftConsumerEmail(acc.Email) {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(acc.SMTPHost, "smtp.office365.com") {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: "smtp.office365.com",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WrapMicrosoftMailErr 为微软邮箱附加常见配置说明(IMAP 开关、基础认证与 OAuth).
|
||||
func WrapMicrosoftMailErr(email string, err error) error {
|
||||
if err == nil || !IsMicrosoftConsumerEmail(email) {
|
||||
return err
|
||||
}
|
||||
hint := "(微软邮箱:网页端需开启 IMAP;OAuth 账户无需应用密码。若仍失败请检查令牌是否过期、Azure 权限是否含 IMAP。)"
|
||||
return fmt.Errorf("%w %s", err, hint)
|
||||
}
|
||||
165
mengpost-backend/services/microsoft_oauth.go
Normal file
165
mengpost-backend/services/microsoft_oauth.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mengpost-backend/config"
|
||||
)
|
||||
|
||||
var msTokenURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||
var msAuthURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
|
||||
|
||||
// MicrosoftOAuthScopes IMAP + SMTP + 刷新令牌.
|
||||
var MicrosoftOAuthScopes = []string{
|
||||
"openid",
|
||||
"email",
|
||||
"offline_access",
|
||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||
"https://outlook.office.com/SMTP.Send",
|
||||
}
|
||||
|
||||
// MicrosoftAuthCodeURL 生成跳转微软登录的 URL.
|
||||
func MicrosoftAuthCodeURL(cfg *config.Config, state string) string {
|
||||
q := url.Values{}
|
||||
q.Set("client_id", cfg.MSClientID)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("redirect_uri", cfg.MSRedirectURL)
|
||||
q.Set("response_mode", "query")
|
||||
q.Set("scope", strings.Join(MicrosoftOAuthScopes, " "))
|
||||
q.Set("state", state)
|
||||
return msAuthURL + "?" + q.Encode()
|
||||
}
|
||||
|
||||
type msTokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
Description string `json:"error_description"`
|
||||
}
|
||||
|
||||
// ExchangeMicrosoftCode 用授权码换令牌并解析邮箱(id_token).
|
||||
func ExchangeMicrosoftCode(ctx context.Context, cfg *config.Config, code string) (email, refresh, access string, accessExpiry time.Time, err error) {
|
||||
form := url.Values{}
|
||||
form.Set("client_id", cfg.MSClientID)
|
||||
if cfg.MSClientSecret != "" {
|
||||
form.Set("client_secret", cfg.MSClientSecret)
|
||||
}
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", cfg.MSRedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var tr msTokenResp
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return "", "", "", time.Time{}, fmt.Errorf("token 响应解析失败: %w", err)
|
||||
}
|
||||
if tr.Error != "" {
|
||||
return "", "", "", time.Time{}, fmt.Errorf("微软 token错误: %s %s", tr.Error, tr.Description)
|
||||
}
|
||||
if tr.RefreshToken == "" {
|
||||
return "", "", "", time.Time{}, fmt.Errorf("未返回 refresh_token,请确认 scope 含 offline_access")
|
||||
}
|
||||
email, err = emailFromIDToken(tr.IDToken)
|
||||
if err != nil || email == "" {
|
||||
return "", "", "", time.Time{}, fmt.Errorf("无法从 id_token 解析邮箱: %w", err)
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
|
||||
return strings.ToLower(strings.TrimSpace(email)), tr.RefreshToken, tr.AccessToken, exp, nil
|
||||
}
|
||||
|
||||
func emailFromIDToken(idt string) (string, error) {
|
||||
if idt == "" {
|
||||
return "", fmt.Errorf("无 id_token")
|
||||
}
|
||||
parts := strings.Split(idt, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("id_token 格式异常")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
UPN string `json:"upn"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if claims.Email != "" {
|
||||
return claims.Email, nil
|
||||
}
|
||||
if claims.PreferredUsername != "" {
|
||||
return claims.PreferredUsername, nil
|
||||
}
|
||||
if claims.UPN != "" {
|
||||
return claims.UPN, nil
|
||||
}
|
||||
return "", fmt.Errorf("claims 中无邮箱字段")
|
||||
}
|
||||
|
||||
// MicrosoftAccessToken 用 refresh_token 换新 access_token(及可能的新 refresh).
|
||||
type MicrosoftAccessToken struct {
|
||||
AccessToken string
|
||||
RefreshToken string // 若微软返回新的则替换存库
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
func RefreshMicrosoftAccess(ctx context.Context, cfg *config.Config, refresh string) (*MicrosoftAccessToken, error) {
|
||||
form := url.Values{}
|
||||
form.Set("client_id", cfg.MSClientID)
|
||||
if cfg.MSClientSecret != "" {
|
||||
form.Set("client_secret", cfg.MSClientSecret)
|
||||
}
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("scope", strings.Join(MicrosoftOAuthScopes, " "))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var tr msTokenResp
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tr.Error != "" {
|
||||
return nil, fmt.Errorf("%s: %s", tr.Error, tr.Description)
|
||||
}
|
||||
out := &MicrosoftAccessToken{
|
||||
AccessToken: tr.AccessToken,
|
||||
Expiry: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
|
||||
}
|
||||
if tr.RefreshToken != "" {
|
||||
out.RefreshToken = tr.RefreshToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// SendMail 通过指定账户发送邮件. body 支持纯文本或 HTML.
|
||||
func SendMail(acc *models.Account, to, subject, body string, html bool) error {
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", acc.Email)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
if html {
|
||||
m.SetBody("text/html", body)
|
||||
} else {
|
||||
m.SetBody("text/plain", body)
|
||||
}
|
||||
d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password)
|
||||
d.SSL = acc.UseTLS && acc.SMTPPort == 465
|
||||
return d.DialAndSend(m)
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// SendMail 通过指定账户发送邮件. body 支持纯文本或 HTML.
|
||||
func SendMail(acc *models.Account, to, subject, body string, html bool) error {
|
||||
if acc.AuthType == models.AuthTypeOAuthMicrosoft {
|
||||
return sendMailMicrosoftOAuth(acc, to, subject, body, html)
|
||||
}
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", acc.Email)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
if html {
|
||||
m.SetBody("text/html", body)
|
||||
} else {
|
||||
m.SetBody("text/plain", body)
|
||||
}
|
||||
d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password)
|
||||
d.SSL = acc.UseTLS && acc.SMTPPort == 465
|
||||
if tc := SMTPTLSConfig(acc); tc != nil {
|
||||
d.TLSConfig = tc
|
||||
}
|
||||
return d.DialAndSend(m)
|
||||
}
|
||||
|
||||
73
mengpost-backend/services/smtp_oauth.go
Normal file
73
mengpost-backend/services/smtp_oauth.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
|
||||
smtpp "github.com/emersion/go-smtp"
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"mengpost-backend/models"
|
||||
)
|
||||
|
||||
// sendMailMicrosoftOAuth 使用 XOAUTH2 + STARTTLS 连接 smtp.office365.com:587.
|
||||
func sendMailMicrosoftOAuth(acc *models.Account, to, subject, body string, html bool) error {
|
||||
if AppCfg == nil {
|
||||
return fmt.Errorf("服务未配置 OAuth")
|
||||
}
|
||||
mt, err := RefreshMicrosoftAccess(context.Background(), AppCfg, acc.OAuthRefreshToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mt.RefreshToken != "" {
|
||||
_ = models.UpdateAccountOAuthRefresh(acc.ID, mt.RefreshToken)
|
||||
}
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", acc.Email)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
if html {
|
||||
m.SetBody("text/html", body)
|
||||
} else {
|
||||
m.SetBody("text/plain", body)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := m.WriteTo(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", acc.SMTPHost, acc.SMTPPort)
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: acc.SMTPHost,
|
||||
}
|
||||
c, err := smtpp.DialStartTLS(addr, tlsCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(NewXOAUTH2Client(acc.Email, mt.AccessToken)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Mail(acc.Email, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Rcpt(to, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
35
mengpost-backend/services/xoauth2.go
Normal file
35
mengpost-backend/services/xoauth2.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
)
|
||||
|
||||
// xoauth2SASL 实现 Microsoft / Google 邮件使用的 XOAUTH2(与 RFC7628 OAUTHBEARER 不同)。
|
||||
type xoauth2SASL struct {
|
||||
username string
|
||||
token string
|
||||
}
|
||||
|
||||
// NewXOAUTH2Client 供 IMAP AUTHENTICATE / SMTP AUTH 使用,username 一般为完整邮箱。
|
||||
func NewXOAUTH2Client(username, accessToken string) sasl.Client {
|
||||
return &xoauth2SASL{
|
||||
username: strings.TrimSpace(username),
|
||||
token: strings.TrimSpace(accessToken),
|
||||
}
|
||||
}
|
||||
|
||||
func (x *xoauth2SASL) Start() (mech string, ir []byte, err error) {
|
||||
// https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
|
||||
s := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.username, x.token)
|
||||
return "XOAUTH2", []byte(s), nil
|
||||
}
|
||||
|
||||
func (x *xoauth2SASL) Next(challenge []byte) ([]byte, error) {
|
||||
if len(challenge) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("xoauth2: %s", string(challenge))
|
||||
}
|
||||
Reference in New Issue
Block a user