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