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

View File

@@ -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})
}

View File

@@ -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})
}
}

View File

@@ -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)
}

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)
}
}