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 }