Smail/backend/services/list/imap_search.go
smail 6f79441585 修复审查发现的认证配置邮件协议及前端问题
补齐安全运行时清理、配置候选恢复、泛域名证书事务提交、邮件副本与协议边界、插件隐私和安装回滚回归,并更新修复状态及待验收清单。
2026-09-06 13:02:02 +08:00

579 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package list
import (
"fmt"
"math"
"strings"
"time"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/context"
"github.com/emersion/go-imap/v2"
"github.com/spf13/cast"
)
// IMAPSearchCriteria represents the search criteria for IMAP SEARCH command
type IMAPSearchCriteria struct {
// UID and sequence number sets
UIDs []imap.UIDSet
SeqNums []imap.SeqSet
// Date filters (only date is used, time and timezone are ignored)
Since time.Time // Internal date since
Before time.Time // Internal date before
SentSince time.Time // Sent date since
SentBefore time.Time // Sent date before
// Header field searches
Header []HeaderField // Key-Value pairs for header search
// Content search
Body []string // Search in body
Text []string // Search in headers + body
// Flag filters
Flag []imap.Flag // Messages with these flags
NotFlag []imap.Flag // Messages without these flags
// Size filters
Larger int64 // Messages larger than this (bytes)
Smaller int64 // Messages smaller than this (bytes)
// Logical combinations
Not []IMAPSearchCriteria
Or [][2]IMAPSearchCriteria
}
// HeaderField represents a header field search criterion
type HeaderField struct {
Key string
Value string
}
// IMAPSearchResult represents a search result item
type IMAPSearchResult struct {
UID int // user_email.id as UID
SeqNum int // sequence number
EmailID int // email.id
IsRead int8 // read status
SerialNumber int // serial number
}
// SearchEmails performs IMAP search based on criteria
func SearchEmails(ctx *context.Context, groupName string, criteria *imap.SearchCriteria, snapshot ...[]int) ([]*response.UserEmailUIDData, error) {
// First get the base list for the mailbox
baseList, err := GetUEListByUID(ctx, groupName, 0, 0, nil)
if err != nil {
return nil, err
}
if len(snapshot) > 0 {
sequences := make(map[int]int, len(snapshot[0]))
for index, uid := range snapshot[0] {
sequences[uid] = index + 1
}
filtered := make([]*response.UserEmailUIDData, 0, len(baseList))
for _, item := range baseList {
if sequence := sequences[item.ID]; sequence > 0 {
item.SerialNumber = sequence
filtered = append(filtered, item)
}
}
baseList = filtered
}
if len(baseList) == 0 {
return baseList, nil
}
// If no criteria specified, return all
if criteria == nil || isEmptyCriteria(criteria) {
return baseList, nil
}
// 通配上界来自完整会话快照,不能随其他搜索条件缩小。
var maxUID, maxSequence uint32
for _, item := range baseList {
if number, ok := validIMAPNumber(item.ID); ok {
maxUID = max(maxUID, number)
}
if number, ok := validIMAPNumber(item.SerialNumber); ok {
maxSequence = max(maxSequence, number)
}
}
if len(snapshot) > 0 {
maxUID = 0
maxSequence = uint32(len(snapshot[0]))
for _, uid := range snapshot[0] {
if number, ok := validIMAPNumber(uid); ok {
maxUID = max(maxUID, number)
}
}
}
// Filter by UID sets
if len(criteria.UID) > 0 {
baseList = filterByUIDSets(baseList, criteria.UID, maxUID)
}
// Filter by sequence number sets
if len(criteria.SeqNum) > 0 {
baseList = filterBySeqNumSets(baseList, criteria.SeqNum, maxSequence)
}
// For more complex filters, we need to fetch email data
if needsEmailData(criteria) {
baseList, err = filterWithEmailData(ctx, baseList, criteria)
if err != nil {
return nil, fmt.Errorf("filter email data: %w", err)
}
}
// Filter by flags (is_read status in this implementation)
if len(criteria.Flag) > 0 || len(criteria.NotFlag) > 0 {
baseList = filterByFlags(baseList, criteria.Flag, criteria.NotFlag)
}
// Handle NOT criteria
if len(criteria.Not) > 0 {
for _, notCriteria := range criteria.Not {
baseList, err = applyNotCriteria(ctx, groupName, baseList, &notCriteria, snapshot...)
if err != nil {
return nil, fmt.Errorf("apply NOT criteria: %w", err)
}
}
}
// Handle OR criteria
if len(criteria.Or) > 0 {
baseList, err = applyOrCriteria(ctx, groupName, baseList, criteria.Or, snapshot...)
if err != nil {
return nil, fmt.Errorf("apply OR criteria: %w", err)
}
}
return baseList, nil
}
// isEmptyCriteria checks if the search criteria is empty
func isEmptyCriteria(criteria *imap.SearchCriteria) bool {
return len(criteria.UID) == 0 &&
len(criteria.SeqNum) == 0 &&
criteria.Since.IsZero() &&
criteria.Before.IsZero() &&
criteria.SentSince.IsZero() &&
criteria.SentBefore.IsZero() &&
len(criteria.Header) == 0 &&
len(criteria.Body) == 0 &&
len(criteria.Text) == 0 &&
len(criteria.Flag) == 0 &&
len(criteria.NotFlag) == 0 &&
criteria.Larger == 0 &&
criteria.Smaller == 0 &&
len(criteria.Not) == 0 &&
len(criteria.Or) == 0
}
// needsEmailData checks if we need to load email data for filtering
func needsEmailData(criteria *imap.SearchCriteria) bool {
return !criteria.Since.IsZero() ||
!criteria.Before.IsZero() ||
!criteria.SentSince.IsZero() ||
!criteria.SentBefore.IsZero() ||
len(criteria.Header) > 0 ||
len(criteria.Body) > 0 ||
len(criteria.Text) > 0 ||
criteria.Larger > 0 ||
criteria.Smaller > 0
}
func validIMAPNumber(value int) (uint32, bool) {
if value <= 0 || uint64(value) > math.MaxUint32 {
return 0, false
}
return cast.ToUint32(value), true
}
// filterByUIDSets filters the list by UID sets
func filterByUIDSets(list []*response.UserEmailUIDData, uidSets []imap.UIDSet, upper ...uint32) []*response.UserEmailUIDData {
var maximum uint32
for _, item := range list {
if number, ok := validIMAPNumber(item.ID); ok {
maximum = max(maximum, number)
}
}
if len(upper) > 0 {
maximum = upper[0]
}
result := list
for _, uidSet := range uidSets {
filtered := make([]*response.UserEmailUIDData, 0, len(result))
for _, item := range result {
number, ok := validIMAPNumber(item.ID)
for _, span := range uidSet {
if ok && searchNumberInRange(number, uint32(span.Start), uint32(span.Stop), maximum) {
filtered = append(filtered, item)
break
}
}
}
result = filtered
}
return result
}
// filterBySeqNumSets filters the list by sequence number sets
func filterBySeqNumSets(list []*response.UserEmailUIDData, seqSets []imap.SeqSet, upper ...uint32) []*response.UserEmailUIDData {
var maximum uint32
for _, item := range list {
if number, ok := validIMAPNumber(item.SerialNumber); ok {
maximum = max(maximum, number)
}
}
if len(upper) > 0 {
maximum = upper[0]
}
result := list
for _, seqSet := range seqSets {
filtered := make([]*response.UserEmailUIDData, 0, len(result))
for _, item := range result {
number, ok := validIMAPNumber(item.SerialNumber)
for _, span := range seqSet {
if ok && searchNumberInRange(number, span.Start, span.Stop, maximum) {
filtered = append(filtered, item)
break
}
}
}
result = filtered
}
return result
}
func searchNumberInRange(number, start, stop, maximum uint32) bool {
if start == 0 {
start = maximum
}
if stop == 0 {
stop = maximum
}
if start > stop {
start, stop = stop, start
}
return number >= start && number <= stop
}
// filterByFlags filters by message flags
func filterByFlags(list []*response.UserEmailUIDData, flags []imap.Flag, notFlags []imap.Flag) []*response.UserEmailUIDData {
var result []*response.UserEmailUIDData
for _, item := range list {
match := true
// Check required flags
for _, flag := range flags {
if !hasFlag(item, flag) {
match = false
break
}
}
// Check flags that should NOT be present
if match {
for _, flag := range notFlags {
if hasFlag(item, flag) {
match = false
break
}
}
}
if match {
result = append(result, item)
}
}
return result
}
// hasFlag checks if a message has a specific flag
func hasFlag(item *response.UserEmailUIDData, flag imap.Flag) bool {
switch flag {
case imap.FlagSeen:
return item.IsRead == 1
case imap.FlagDeleted:
return item.IsDeleted == 1
case imap.FlagDraft:
return item.Status == 4
case imap.FlagJunk:
return item.Status == 5
// For flags we don't track, return false
case imap.FlagAnswered, imap.FlagFlagged:
return false
default:
return false
}
}
// filterWithEmailData loads email data and applies filters that need it
func filterWithEmailData(ctx *context.Context, list []*response.UserEmailUIDData, criteria *imap.SearchCriteria) ([]*response.UserEmailUIDData, error) {
if len(list) == 0 {
return list, nil
}
// Get email IDs
var emailIDs []int
ueMap := make(map[int]*response.UserEmailUIDData) // emailID -> UserEmailUIDData
for _, item := range list {
emailIDs = append(emailIDs, item.EmailID)
ueMap[item.EmailID] = item
}
// Fetch emails from database
var emails []models.Email
err := db.Instance.Table("email").In("id", emailIDs).Find(&emails)
if err != nil {
return nil, fmt.Errorf("fetch emails for search: %w", err)
}
// Build email map
emailMap := make(map[int]*models.Email)
for i := range emails {
emailMap[emails[i].Id] = &emails[i]
}
// Filter
var result []*response.UserEmailUIDData
for _, item := range list {
email, ok := emailMap[item.EmailID]
if !ok {
continue
}
visible := *email
if visible.Type == 0 {
visible.Bcc = ""
}
if matchesEmailCriteria(&visible, criteria) {
result = append(result, item)
}
}
return result, nil
}
// matchesEmailCriteria checks if an email matches the search criteria
func matchesEmailCriteria(email *models.Email, criteria *imap.SearchCriteria) bool {
// Date filters (internal date = CreateTime)
if !criteria.Since.IsZero() {
if email.CreateTime.Before(truncateToDate(criteria.Since)) {
return false
}
}
if !criteria.Before.IsZero() {
if !email.CreateTime.Before(truncateToDate(criteria.Before)) {
return false
}
}
// Sent date filters
if !criteria.SentSince.IsZero() {
if email.SendDate.Before(truncateToDate(criteria.SentSince)) {
return false
}
}
if !criteria.SentBefore.IsZero() {
if !email.SendDate.Before(truncateToDate(criteria.SentBefore)) {
return false
}
}
// Size filters
if criteria.Larger > 0 {
if int64(email.Size) <= criteria.Larger {
return false
}
}
if criteria.Smaller > 0 {
if int64(email.Size) >= criteria.Smaller {
return false
}
}
// Header field search
for _, hf := range criteria.Header {
if !matchesHeader(email, hf.Key, hf.Value) {
return false
}
}
// Body search
for _, pattern := range criteria.Body {
if !matchesBody(email, pattern) {
return false
}
}
// Text search (headers + body)
for _, pattern := range criteria.Text {
if !matchesText(email, pattern) {
return false
}
}
return true
}
// truncateToDate removes time component from a time.Time
func truncateToDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// matchesHeader checks if an email matches a header field search
func matchesHeader(email *models.Email, key, value string) bool {
key = strings.ToLower(key)
value = strings.ToLower(value)
switch key {
case "subject":
return strings.Contains(strings.ToLower(email.Subject), value)
case "from":
return strings.Contains(strings.ToLower(email.FromAddress), value) ||
strings.Contains(strings.ToLower(email.FromName), value)
case "to":
return strings.Contains(strings.ToLower(email.To), value)
case "cc":
return strings.Contains(strings.ToLower(email.Cc), value)
case "bcc":
return strings.Contains(strings.ToLower(email.Bcc), value)
case "reply-to":
return strings.Contains(strings.ToLower(email.ReplyTo), value)
case "sender":
return strings.Contains(strings.ToLower(email.Sender), value)
default:
// For unknown headers, we can't match
return false
}
}
// matchesBody checks if an email body matches the pattern
func matchesBody(email *models.Email, pattern string) bool {
pattern = strings.ToLower(pattern)
// Check text body
if email.Text.Valid && strings.Contains(strings.ToLower(email.Text.String), pattern) {
return true
}
// Check HTML body
if email.Html.Valid && strings.Contains(strings.ToLower(email.Html.String), pattern) {
return true
}
return false
}
// matchesText checks if an email (headers + body) matches the pattern
func matchesText(email *models.Email, pattern string) bool {
pattern = strings.ToLower(pattern)
// Check headers
if strings.Contains(strings.ToLower(email.Subject), pattern) {
return true
}
if strings.Contains(strings.ToLower(email.FromAddress), pattern) {
return true
}
if strings.Contains(strings.ToLower(email.FromName), pattern) {
return true
}
if strings.Contains(strings.ToLower(email.To), pattern) {
return true
}
if strings.Contains(strings.ToLower(email.Cc), pattern) {
return true
}
if strings.Contains(strings.ToLower(email.Bcc), pattern) {
return true
}
// Check body
return matchesBody(email, pattern)
}
// applyNotCriteria applies NOT criteria
func applyNotCriteria(ctx *context.Context, groupName string, list []*response.UserEmailUIDData, notCriteria *imap.SearchCriteria, snapshot ...[]int) ([]*response.UserEmailUIDData, error) {
// Get the list of items that match the NOT criteria
matchedList, err := SearchEmails(ctx, groupName, notCriteria, snapshot...)
if err != nil {
return nil, err
}
// Build a set of matched UIDs
matchedUIDs := make(map[int]bool)
for _, item := range matchedList {
matchedUIDs[item.ID] = true
}
// Return items that are NOT in the matched set
var result []*response.UserEmailUIDData
for _, item := range list {
if !matchedUIDs[item.ID] {
result = append(result, item)
}
}
return result, nil
}
// applyOrCriteria applies OR criteria
func applyOrCriteria(ctx *context.Context, groupName string, list []*response.UserEmailUIDData, orCriteria [][2]imap.SearchCriteria, snapshot ...[]int) ([]*response.UserEmailUIDData, error) {
if len(orCriteria) == 0 {
return list, nil
}
// 多个 OR 子句仍属于同一个 SEARCH,子句之间取交集。
currentUIDs := make(map[int]bool)
for _, item := range list {
currentUIDs[item.ID] = true
}
for _, pair := range orCriteria {
pairUIDs := make(map[int]bool)
// Get matches for first condition
matches1, err := SearchEmails(ctx, groupName, &pair[0], snapshot...)
if err != nil {
return nil, err
}
for _, item := range matches1 {
if currentUIDs[item.ID] {
pairUIDs[item.ID] = true
}
}
// Get matches for second condition
matches2, err := SearchEmails(ctx, groupName, &pair[1], snapshot...)
if err != nil {
return nil, err
}
for _, item := range matches2 {
if currentUIDs[item.ID] {
pairUIDs[item.ID] = true
}
}
for uid := range currentUIDs {
if !pairUIDs[uid] {
delete(currentUIDs, uid)
}
}
}
// Build result from original list preserving order
var result []*response.UserEmailUIDData
for _, item := range list {
if currentUIDs[item.ID] {
result = append(result, item)
}
}
return result, nil
}