feat: add Docker deployment and Microsoft OAuth support
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user