feat: add Docker deployment and Microsoft OAuth support
This commit is contained in:
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