package imap_server import ( "bufio" "bytes" "fmt" "io" "log" "net/mail" "strings" "time" "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 } // Login authenticates a user by email and password. func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) { user, err := b.stores.Users.Authenticate(username, password) if err != nil { 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 } return &imapUser{ stores: b.stores, id: user.ID, email: email, }, nil } // ---------- imapUser ---------- // imapUser implements backend.User. type imapUser struct { stores *store.Stores id uint email string } // 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 { 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() }