- 封禁规则:达到失败阈值记为一次触发,前 3 次只计数不封禁; 第 4 次起封禁并按档位递增:30分钟(ban_duration_min)→ 3小时 → 3个月 → 半年(上限);封禁过期后保留记录作为升档依据, 成功登录或管理员解封清零 - BanEntry 新增 BanCount(累计触发次数),每 IP 一条记录 upsert, 不再重复建行;RecordAuthFailure 统一 Web/LDAP/SMTP/IMAP/POP3 五处封禁逻辑,原因带档位(如"第1次封禁:登录失败次数过多 (第4次触发,失败5次)") - 黑名单页修复:列表仅显示已封禁或曾封禁记录(原因/到期时间必填), 新增封禁次数列与封禁中/已过期状态徽章,移除清理过期按钮 - 用户封禁页显示第 N 次封禁档位;新增档位升级与列表过滤单测
877 lines
22 KiB
Go
877 lines
22 KiB
Go
package imap_server
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/mail"
|
||
"strings"
|
||
"time"
|
||
|
||
"mail_go/config"
|
||
"mail_go/internal/db"
|
||
"mail_go/internal/mailutil"
|
||
"mail_go/internal/store"
|
||
|
||
"github.com/emersion/go-imap"
|
||
"github.com/emersion/go-imap/backend"
|
||
"github.com/emersion/go-imap/backend/backendutil"
|
||
asgomail "github.com/emersion/go-message/mail"
|
||
"github.com/emersion/go-message/textproto"
|
||
)
|
||
|
||
// ---------- imapBackend ----------
|
||
|
||
// imapBackend implements backend.Backend.
|
||
type imapBackend struct {
|
||
stores *store.Stores
|
||
banCfg config.BanConfig
|
||
port int
|
||
}
|
||
|
||
// Login authenticates a user by email and password.
|
||
func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) {
|
||
clientIP := store.ClientIPFromAddr(connInfo.RemoteAddr)
|
||
now := time.Now()
|
||
|
||
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
|
||
if banned, _ := b.stores.Bans.IsBanned(clientIP); banned {
|
||
b.recordLogin(clientIP, username, false, "IP已被封禁", "认证被拒绝(IP 已封禁)", 0, now)
|
||
return nil, backend.ErrInvalidCredentials
|
||
}
|
||
|
||
user, err := b.stores.Users.Authenticate(username, password)
|
||
if err != nil {
|
||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)
|
||
b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
|
||
b.recordLogin(clientIP, username, false, "用户名或密码错误", "LOGIN 失败", 0, now)
|
||
return nil, fmt.Errorf("invalid credentials: %w", err)
|
||
}
|
||
|
||
email := user.Username + "@"
|
||
domain, err := b.stores.Domains.GetByID(user.DomainID)
|
||
if err == nil {
|
||
email = user.Username + "@" + domain.Name
|
||
}
|
||
|
||
logID := b.recordLogin(clientIP, username, true, "", "LOGIN 成功", 0, now)
|
||
|
||
return &imapUser{
|
||
stores: b.stores,
|
||
id: user.ID,
|
||
email: email,
|
||
logID: logID,
|
||
clientIP: clientIP,
|
||
startedAt: now,
|
||
}, nil
|
||
}
|
||
|
||
// recordLogin 写入一条 IMAP 登录日志,返回新记录的 ID(失败时为 0)。
|
||
func (b *imapBackend) recordLogin(ip, username string, success bool, failReason, detail string, durationMs int64, at time.Time) uint {
|
||
entry := &db.ProtocolLog{
|
||
Protocol: db.ProtocolIMAP,
|
||
Port: b.port,
|
||
ClientIP: ip,
|
||
Username: username,
|
||
Success: success,
|
||
FailReason: failReason,
|
||
Detail: detail,
|
||
DurationMs: durationMs,
|
||
CreatedAt: at,
|
||
}
|
||
if err := b.stores.ProtocolLogs.Create(entry); err != nil {
|
||
log.Printf("IMAP: 写入协议日志失败: %v", err)
|
||
return 0
|
||
}
|
||
return entry.ID
|
||
}
|
||
|
||
// ---------- imapUser ----------
|
||
|
||
// imapUser implements backend.User.
|
||
type imapUser struct {
|
||
stores *store.Stores
|
||
id uint
|
||
email string
|
||
logID uint
|
||
clientIP string
|
||
startedAt time.Time
|
||
}
|
||
|
||
// Username returns the user's email address.
|
||
func (u *imapUser) Username() string {
|
||
return u.email
|
||
}
|
||
|
||
// ListMailboxes returns the standard mailbox list.
|
||
func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) {
|
||
folders := []struct {
|
||
name string
|
||
delimiter string
|
||
attributes []string
|
||
}{
|
||
{"INBOX", "/", nil},
|
||
{"Sent", "/", []string{"\\Sent"}},
|
||
{"Drafts", "/", []string{"\\Drafts"}},
|
||
{"Trash", "/", []string{"\\Trash"}},
|
||
}
|
||
|
||
mailboxes := make([]backend.Mailbox, 0, len(folders))
|
||
for _, f := range folders {
|
||
mailboxes = append(mailboxes, &imapMailbox{
|
||
stores: u.stores,
|
||
user: u,
|
||
name: f.name,
|
||
delimiter: f.delimiter,
|
||
attributes: f.attributes,
|
||
})
|
||
}
|
||
return mailboxes, nil
|
||
}
|
||
|
||
// GetMailbox returns a mailbox by name.
|
||
func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) {
|
||
normalized, ok := canonicalMailboxName(name)
|
||
if !ok {
|
||
return nil, backend.ErrNoSuchMailbox
|
||
}
|
||
|
||
return &imapMailbox{
|
||
stores: u.stores,
|
||
user: u,
|
||
name: normalized,
|
||
delimiter: "/",
|
||
}, nil
|
||
}
|
||
|
||
func canonicalMailboxName(name string) (string, bool) {
|
||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||
case "INBOX":
|
||
return "INBOX", true
|
||
case "SENT":
|
||
return "Sent", true
|
||
case "DRAFTS":
|
||
return "Drafts", true
|
||
case "TRASH":
|
||
return "Trash", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
// CreateMailbox creates a new mailbox (not supported in this version).
|
||
func (u *imapUser) CreateMailbox(name string) error {
|
||
return fmt.Errorf("mailbox creation not supported")
|
||
}
|
||
|
||
// DeleteMailbox deletes a mailbox (not supported in this version).
|
||
func (u *imapUser) DeleteMailbox(name string) error {
|
||
return fmt.Errorf("mailbox deletion not supported")
|
||
}
|
||
|
||
// RenameMailbox renames a mailbox (not supported in this version).
|
||
func (u *imapUser) RenameMailbox(existingName, newName string) error {
|
||
return fmt.Errorf("mailbox rename not supported")
|
||
}
|
||
|
||
// Logout is called when the user session ends.
|
||
func (u *imapUser) Logout() error {
|
||
// 回填会话时长,登录记录在 Login 时已写入
|
||
if u.logID == 0 {
|
||
return nil
|
||
}
|
||
durationMs := time.Since(u.startedAt).Milliseconds()
|
||
if err := u.stores.ProtocolLogs.UpdateDuration(u.logID, durationMs); err != nil {
|
||
log.Printf("IMAP: 更新协议日志失败: %v", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------- imapMailbox ----------
|
||
|
||
// imapMailbox implements backend.Mailbox.
|
||
type imapMailbox struct {
|
||
stores *store.Stores
|
||
user *imapUser
|
||
name string
|
||
delimiter string
|
||
attributes []string
|
||
// deleted tracks messages marked as \Deleted in this session
|
||
deleted map[uint]bool
|
||
}
|
||
|
||
// Name returns the mailbox name.
|
||
func (m *imapMailbox) Name() string {
|
||
return m.name
|
||
}
|
||
|
||
// Info returns mailbox metadata.
|
||
func (m *imapMailbox) Info() (*imap.MailboxInfo, error) {
|
||
attrs := m.attributes
|
||
if attrs == nil {
|
||
attrs = []string{}
|
||
}
|
||
return &imap.MailboxInfo{
|
||
Name: m.name,
|
||
Delimiter: m.delimiter,
|
||
Attributes: attrs,
|
||
}, nil
|
||
}
|
||
|
||
// Status returns mailbox status information.
|
||
func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
|
||
status := imap.NewMailboxStatus(m.name, items)
|
||
status.Flags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"}
|
||
status.PermanentFlags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"}
|
||
|
||
messages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
status.Messages = uint32(len(messages))
|
||
|
||
var unseenCount uint32
|
||
for _, msg := range messages {
|
||
if !msg.IsRead {
|
||
unseenCount++
|
||
}
|
||
}
|
||
status.Unseen = unseenCount
|
||
status.Recent = 0
|
||
maxID, err := m.stores.Mails.MaxIDByUserAndFolder(m.user.id, m.name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
status.UidNext = uint32(maxID + 1)
|
||
status.UidValidity = 1
|
||
|
||
return status, nil
|
||
}
|
||
|
||
// SetSubscribed sets the subscribed status (no-op for now).
|
||
func (m *imapMailbox) SetSubscribed(subscribed bool) error {
|
||
return nil
|
||
}
|
||
|
||
// Check is a no-op checkpoint.
|
||
func (m *imapMailbox) Check() error {
|
||
return nil
|
||
}
|
||
|
||
// ListMessages returns messages matching the sequence set and fetch items.
|
||
func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
|
||
defer close(ch)
|
||
|
||
// Fetch all messages in this mailbox
|
||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(dbMessages) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Build a mapping of sequence number (1-based) to db.Message
|
||
type seqEntry struct {
|
||
seqNum uint32
|
||
msg *db.Message
|
||
}
|
||
entries := make([]seqEntry, len(dbMessages))
|
||
for i := range dbMessages {
|
||
entries[i] = seqEntry{
|
||
seqNum: uint32(i + 1),
|
||
msg: &dbMessages[i],
|
||
}
|
||
}
|
||
|
||
for _, entry := range entries {
|
||
var match bool
|
||
if uid {
|
||
match = seqset.Contains(uint32(entry.msg.ID))
|
||
} else {
|
||
match = seqset.Contains(entry.seqNum)
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
|
||
imapMsg, err := m.buildIMAPMessage(entry.msg, entry.seqNum, items)
|
||
if err != nil {
|
||
log.Printf("IMAP: error building message %d: %v", entry.msg.ID, err)
|
||
continue
|
||
}
|
||
ch <- imapMsg
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// buildIMAPMessage constructs an imap.Message from a db.Message with the requested items.
|
||
func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items []imap.FetchItem) (*imap.Message, error) {
|
||
imapMsg := imap.NewMessage(seqNum, items)
|
||
imapMsg.Uid = uint32(dbMsg.ID)
|
||
imapMsg.Flags = m.getMessageFlags(dbMsg)
|
||
imapMsg.InternalDate = dbMsg.Date
|
||
|
||
rawMsg := messageRawData(dbMsg)
|
||
imapMsg.Size = uint32(len(rawMsg))
|
||
|
||
for _, item := range items {
|
||
switch item {
|
||
case imap.FetchUid, imap.FetchFlags, imap.FetchInternalDate, imap.FetchRFC822Size:
|
||
continue
|
||
case imap.FetchEnvelope:
|
||
hdr, _, err := headerAndBody(rawMsg)
|
||
if err == nil {
|
||
imapMsg.Envelope, _ = backendutil.FetchEnvelope(hdr)
|
||
}
|
||
if imapMsg.Envelope == nil {
|
||
imapMsg.Envelope = m.buildEnvelope(dbMsg)
|
||
}
|
||
case imap.FetchBody, imap.FetchBodyStructure:
|
||
hdr, body, err := headerAndBody(rawMsg)
|
||
if err == nil {
|
||
imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure)
|
||
}
|
||
default:
|
||
section, err := imap.ParseBodySectionName(item)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
hdr, body, err := headerAndBody(rawMsg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
literal, _ := backendutil.FetchBodySection(hdr, body, section)
|
||
imapMsg.Body[section] = literal
|
||
}
|
||
}
|
||
|
||
return imapMsg, nil
|
||
}
|
||
|
||
func messageRawData(msg *db.Message) []byte {
|
||
if msg.RawData != "" {
|
||
return []byte(msg.RawData)
|
||
}
|
||
return buildRawMessage(msg)
|
||
}
|
||
|
||
func headerAndBody(raw []byte) (textproto.Header, io.Reader, error) {
|
||
body := bufio.NewReader(bytes.NewReader(raw))
|
||
hdr, err := textproto.ReadHeader(body)
|
||
return hdr, body, err
|
||
}
|
||
|
||
// getMessageFlags returns IMAP flags for a database message.
|
||
func (m *imapMailbox) getMessageFlags(dbMsg *db.Message) []string {
|
||
flags := make([]string, 0)
|
||
if dbMsg.IsRead {
|
||
flags = append(flags, "\\Seen")
|
||
}
|
||
if dbMsg.IsFlagged {
|
||
flags = append(flags, "\\Flagged")
|
||
}
|
||
if m.deleted != nil && m.deleted[dbMsg.ID] {
|
||
flags = append(flags, "\\Deleted")
|
||
}
|
||
return flags
|
||
}
|
||
|
||
// buildEnvelope constructs an imap.Envelope from a db.Message.
|
||
func (m *imapMailbox) buildEnvelope(dbMsg *db.Message) *imap.Envelope {
|
||
env := &imap.Envelope{
|
||
Date: dbMsg.Date,
|
||
Subject: dbMsg.Subject,
|
||
From: parseAddressList(dbMsg.FromAddr),
|
||
Sender: parseAddressList(dbMsg.FromAddr),
|
||
ReplyTo: parseAddressList(dbMsg.FromAddr),
|
||
To: parseAddressList(dbMsg.ToAddr),
|
||
Cc: parseAddressList(dbMsg.CcAddr),
|
||
MessageId: dbMsg.MessageID,
|
||
}
|
||
return env
|
||
}
|
||
|
||
// SearchMessages returns sequence numbers or UIDs of messages matching the criteria.
|
||
func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
|
||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var results []uint32
|
||
for i, dbMsg := range dbMessages {
|
||
if m.matchesCriteria(&dbMsg, criteria) {
|
||
if uid {
|
||
results = append(results, uint32(dbMsg.ID))
|
||
} else {
|
||
results = append(results, uint32(i+1))
|
||
}
|
||
}
|
||
}
|
||
|
||
return results, nil
|
||
}
|
||
|
||
// matchesCriteria checks if a message matches the given search criteria.
|
||
func (m *imapMailbox) matchesCriteria(msg *db.Message, criteria *imap.SearchCriteria) bool {
|
||
// Check WithFlags (messages must have all these flags)
|
||
for _, flag := range criteria.WithFlags {
|
||
switch flag {
|
||
case "\\Seen":
|
||
if !msg.IsRead {
|
||
return false
|
||
}
|
||
case "\\Flagged":
|
||
if !msg.IsFlagged {
|
||
return false
|
||
}
|
||
case "\\Deleted":
|
||
if m.deleted == nil || !m.deleted[msg.ID] {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check WithoutFlags (messages must NOT have any of these flags)
|
||
for _, flag := range criteria.WithoutFlags {
|
||
switch flag {
|
||
case "\\Seen":
|
||
if msg.IsRead {
|
||
return false
|
||
}
|
||
case "\\Flagged":
|
||
if msg.IsFlagged {
|
||
return false
|
||
}
|
||
case "\\Deleted":
|
||
if m.deleted != nil && m.deleted[msg.ID] {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check date range
|
||
if !criteria.Since.IsZero() && msg.Date.Before(criteria.Since) {
|
||
return false
|
||
}
|
||
if !criteria.Before.IsZero() && !msg.Date.Before(criteria.Before) {
|
||
return false
|
||
}
|
||
|
||
// Check header fields
|
||
if criteria.Header != nil {
|
||
if subject := criteria.Header.Get("Subject"); subject != "" {
|
||
if !strings.Contains(strings.ToLower(msg.Subject), strings.ToLower(subject)) {
|
||
return false
|
||
}
|
||
}
|
||
if from := criteria.Header.Get("From"); from != "" {
|
||
if !strings.Contains(strings.ToLower(msg.FromAddr), strings.ToLower(from)) {
|
||
return false
|
||
}
|
||
}
|
||
if to := criteria.Header.Get("To"); to != "" {
|
||
if !strings.Contains(strings.ToLower(msg.ToAddr), strings.ToLower(to)) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check body text
|
||
for _, text := range criteria.Body {
|
||
bodyText := strings.ToLower(msg.TextBody + " " + msg.HtmlBody)
|
||
if !strings.Contains(bodyText, strings.ToLower(text)) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Check generic text (searches headers + body)
|
||
for _, text := range criteria.Text {
|
||
allText := strings.ToLower(msg.Subject + " " + msg.FromAddr + " " + msg.ToAddr + " " + msg.TextBody + " " + msg.HtmlBody)
|
||
if !strings.Contains(allText, strings.ToLower(text)) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Check NOT criteria
|
||
for _, notCrit := range criteria.Not {
|
||
if m.matchesCriteria(msg, notCrit) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Check OR criteria (at least one must match)
|
||
for _, orPair := range criteria.Or {
|
||
if !m.matchesCriteria(msg, orPair[0]) && !m.matchesCriteria(msg, orPair[1]) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// CreateMessage appends a new message to the mailbox (IMAP APPEND command).
|
||
func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error {
|
||
// Read the message literal
|
||
data, err := io.ReadAll(body)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to read message body: %w", err)
|
||
}
|
||
|
||
// Parse as MIME message
|
||
mr, err := asgomail.CreateReader(bytes.NewReader(data))
|
||
if err != nil {
|
||
return fmt.Errorf("failed to parse MIME message: %w", err)
|
||
}
|
||
|
||
header := mr.Header
|
||
fromAddr := mailutil.FormatAddressList(&header, "From")
|
||
toAddr := mailutil.FormatAddressList(&header, "To")
|
||
ccAddr := mailutil.FormatAddressList(&header, "Cc")
|
||
subject, _ := header.Subject()
|
||
messageID, _ := header.MessageID()
|
||
msgDate, _ := header.Date()
|
||
if msgDate.IsZero() {
|
||
msgDate = date
|
||
}
|
||
if msgDate.IsZero() {
|
||
msgDate = time.Now()
|
||
}
|
||
|
||
var textBody, htmlBody string
|
||
for {
|
||
p, err := mr.NextPart()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
break
|
||
}
|
||
|
||
switch h := p.Header.(type) {
|
||
case *asgomail.InlineHeader:
|
||
contentType, params, _ := h.ContentType()
|
||
buf, _ := io.ReadAll(p.Body)
|
||
// 检测并转换字符集
|
||
charset := ""
|
||
if cs, ok := params["charset"]; ok {
|
||
charset = cs
|
||
}
|
||
decoded := mailutil.DecodeCharset(buf, charset)
|
||
if strings.HasPrefix(contentType, "text/plain") {
|
||
textBody = decoded
|
||
} else if strings.HasPrefix(contentType, "text/html") {
|
||
htmlBody = decoded
|
||
}
|
||
case *asgomail.AttachmentHeader:
|
||
// Attachments from APPEND are not saved in this simple implementation
|
||
}
|
||
}
|
||
|
||
if textBody == "" && htmlBody == "" {
|
||
textBody = string(data)
|
||
}
|
||
|
||
// Determine initial flag state
|
||
isRead := false
|
||
isFlagged := false
|
||
for _, flag := range flags {
|
||
switch flag {
|
||
case "\\Seen":
|
||
isRead = true
|
||
case "\\Flagged":
|
||
isFlagged = true
|
||
}
|
||
}
|
||
|
||
msg := &db.Message{
|
||
UserID: m.user.id,
|
||
MessageID: messageID,
|
||
Folder: m.name,
|
||
FromAddr: fromAddr,
|
||
ToAddr: toAddr,
|
||
CcAddr: ccAddr,
|
||
Subject: subject,
|
||
TextBody: textBody,
|
||
HtmlBody: htmlBody,
|
||
RawData: string(data),
|
||
IsRead: isRead,
|
||
IsFlagged: isFlagged,
|
||
Date: msgDate,
|
||
}
|
||
|
||
if err := m.stores.Mails.Create(msg); err != nil {
|
||
return fmt.Errorf("failed to create message: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// UpdateMessagesFlags modifies flags on messages.
|
||
func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) error {
|
||
if m.deleted == nil {
|
||
m.deleted = make(map[uint]bool)
|
||
}
|
||
|
||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for i, dbMsg := range dbMessages {
|
||
var match bool
|
||
if uid {
|
||
match = seqset.Contains(uint32(dbMsg.ID))
|
||
} else {
|
||
match = seqset.Contains(uint32(i + 1))
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
|
||
flagSet := make(map[string]bool, len(flags))
|
||
for _, flag := range flags {
|
||
flagSet[flag] = true
|
||
}
|
||
|
||
applyFlag := func(flag string, enabled bool) {
|
||
switch flag {
|
||
case "\\Seen":
|
||
_ = m.stores.Mails.MarkReadState(dbMsg.ID, enabled)
|
||
case "\\Flagged":
|
||
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, enabled)
|
||
case "\\Deleted":
|
||
if enabled {
|
||
m.deleted[dbMsg.ID] = true
|
||
} else {
|
||
delete(m.deleted, dbMsg.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
switch op {
|
||
case imap.SetFlags:
|
||
applyFlag("\\Seen", flagSet["\\Seen"])
|
||
applyFlag("\\Flagged", flagSet["\\Flagged"])
|
||
applyFlag("\\Deleted", flagSet["\\Deleted"])
|
||
case imap.AddFlags:
|
||
for flag := range flagSet {
|
||
applyFlag(flag, true)
|
||
}
|
||
case imap.RemoveFlags:
|
||
for flag := range flagSet {
|
||
applyFlag(flag, false)
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// CopyMessages copies messages to another mailbox.
|
||
func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error {
|
||
dest, ok := canonicalMailboxName(dest)
|
||
if !ok {
|
||
return backend.ErrNoSuchMailbox
|
||
}
|
||
|
||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for i, dbMsg := range dbMessages {
|
||
var match bool
|
||
if uid {
|
||
match = seqset.Contains(uint32(dbMsg.ID))
|
||
} else {
|
||
match = seqset.Contains(uint32(i + 1))
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
|
||
// Create a copy in the destination mailbox
|
||
copyMsg := &db.Message{
|
||
UserID: m.user.id,
|
||
MessageID: dbMsg.MessageID,
|
||
Folder: dest,
|
||
FromAddr: dbMsg.FromAddr,
|
||
ToAddr: dbMsg.ToAddr,
|
||
CcAddr: dbMsg.CcAddr,
|
||
Subject: dbMsg.Subject,
|
||
TextBody: dbMsg.TextBody,
|
||
HtmlBody: dbMsg.HtmlBody,
|
||
RawData: dbMsg.RawData,
|
||
IsRead: dbMsg.IsRead,
|
||
IsFlagged: dbMsg.IsFlagged,
|
||
Date: dbMsg.Date,
|
||
}
|
||
if err := m.stores.Mails.Create(copyMsg); err != nil {
|
||
log.Printf("IMAP: failed to copy message %d to %s: %v", dbMsg.ID, dest, err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// MoveMessages moves messages to another mailbox.
|
||
func (m *imapMailbox) MoveMessages(uid bool, seqset *imap.SeqSet, dest string) error {
|
||
dest, ok := canonicalMailboxName(dest)
|
||
if !ok {
|
||
return backend.ErrNoSuchMailbox
|
||
}
|
||
|
||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for i, dbMsg := range dbMessages {
|
||
var match bool
|
||
if uid {
|
||
match = seqset.Contains(uint32(dbMsg.ID))
|
||
} else {
|
||
match = seqset.Contains(uint32(i + 1))
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
if err := m.stores.Mails.MoveToFolder(dbMsg.ID, dest); err != nil {
|
||
log.Printf("IMAP: failed to move message %d to %s: %v", dbMsg.ID, dest, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Expunge permanently removes messages marked as \Deleted.
|
||
func (m *imapMailbox) Expunge() error {
|
||
if m.deleted == nil {
|
||
return nil
|
||
}
|
||
|
||
for msgID := range m.deleted {
|
||
if err := m.stores.Mails.Delete(msgID); err != nil {
|
||
log.Printf("IMAP: failed to expunge message %d: %v", msgID, err)
|
||
}
|
||
}
|
||
m.deleted = make(map[uint]bool)
|
||
return nil
|
||
}
|
||
|
||
// ---------- Helper functions ----------
|
||
|
||
// parseAddressList parses a comma-separated address string into imap.Address slice.
|
||
func parseAddressList(addrStr string) []*imap.Address {
|
||
if addrStr == "" {
|
||
return nil
|
||
}
|
||
|
||
addresses, err := mail.ParseAddressList(addrStr)
|
||
if err != nil {
|
||
// Fallback: treat the whole string as a single address
|
||
return []*imap.Address{{
|
||
MailboxName: addrStr,
|
||
HostName: "",
|
||
}}
|
||
}
|
||
|
||
result := make([]*imap.Address, 0, len(addresses))
|
||
for _, addr := range addresses {
|
||
parts := strings.SplitN(addr.Address, "@", 2)
|
||
mailbox := parts[0]
|
||
host := ""
|
||
if len(parts) > 1 {
|
||
host = parts[1]
|
||
}
|
||
result = append(result, &imap.Address{
|
||
PersonalName: addr.Name,
|
||
MailboxName: mailbox,
|
||
HostName: host,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
|
||
// If RawData is available, it uses the original raw data directly.
|
||
func buildRawMessage(msg *db.Message) []byte {
|
||
// 优先使用原始邮件数据
|
||
if msg.RawData != "" {
|
||
return []byte(msg.RawData)
|
||
}
|
||
|
||
// 降级:从字段重建
|
||
var buf bytes.Buffer
|
||
|
||
// Write headers
|
||
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
|
||
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
||
if msg.CcAddr != "" {
|
||
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
|
||
}
|
||
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
||
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z)))
|
||
if msg.MessageID != "" {
|
||
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
|
||
}
|
||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||
|
||
// Write body
|
||
if msg.HtmlBody != "" && msg.TextBody != "" {
|
||
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
|
||
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||
buf.WriteString("\r\n")
|
||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||
buf.WriteString(msg.TextBody)
|
||
buf.WriteString("\r\n")
|
||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||
buf.WriteString(msg.HtmlBody)
|
||
buf.WriteString("\r\n")
|
||
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||
} else if msg.TextBody != "" {
|
||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||
buf.WriteString(msg.TextBody)
|
||
} else if msg.HtmlBody != "" {
|
||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||
buf.WriteString(msg.HtmlBody)
|
||
} else {
|
||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||
}
|
||
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// buildRawHeader reconstructs just the header portion of a raw RFC822 message.
|
||
func buildRawHeader(msg *db.Message) []byte {
|
||
var buf bytes.Buffer
|
||
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
|
||
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
||
if msg.CcAddr != "" {
|
||
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
|
||
}
|
||
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
||
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z)))
|
||
if msg.MessageID != "" {
|
||
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
|
||
}
|
||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||
if msg.HtmlBody != "" && msg.TextBody != "" {
|
||
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
|
||
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||
} else if msg.TextBody != "" {
|
||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||
} else if msg.HtmlBody != "" {
|
||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||
}
|
||
return buf.Bytes()
|
||
}
|