完善IMAP标志位和邮件消息标识
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-31 09:32:46 +08:00
parent b396afa90c
commit fc027636c2
5 changed files with 437 additions and 46 deletions

View File

@ -82,11 +82,6 @@ export function usePage() {
const protocolLabel = protocol => ({ imap: 'IMAP', pop3: 'POP3', smtp: 'SMTP' }[protocol] || protocol.toUpperCase())
const loadProtocolCredentials = async () => {
const res = await http.get('/api/v1/security/credentials/protocols')
if (res.errorNo !== 0) throw new Error(res.errorMsg || '读取第三方协议设置失败')
protocolCredentials.value = Array.isArray(res.data) ? res.data : []
}
const setProtocolEnabled = async (item, enabled) => {
const previous = item.enabled

View File

@ -18,6 +18,7 @@ import (
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/array"
@ -398,7 +399,79 @@ func TestDelete(t *testing.T) {
}
func TestAppend(t *testing.T) {
rawMessage := "From: sender@test.domain\r\n" +
"To: testCase@test.domain\r\n" +
"Subject: IMAP APPEND test\r\n" +
"Message-ID: <imap-append-test@test.domain>\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" +
"Appended message body.\r\n"
appendCommand := clientLogin.Append("INBOX", int64(len(rawMessage)), &imap.AppendOptions{
Flags: []imap.Flag{imap.FlagSeen},
})
if _, err := appendCommand.Write([]byte(rawMessage)); err != nil {
t.Fatalf("write APPEND message: %v", err)
}
if err := appendCommand.Close(); err != nil {
t.Fatalf("close APPEND message: %v", err)
}
appendData, err := appendCommand.Wait()
if err != nil {
t.Fatalf("APPEND message: %v", err)
}
if appendData == nil || appendData.UID == 0 || appendData.UIDValidity != uint32(models.GroupNameToCode["INBOX"]) {
t.Fatalf("unexpected APPEND data: %#v", appendData)
}
var saved models.Email
found, err := db.Instance.Where("msg_id = ?", "imap-append-test@test.domain").Get(&saved)
if err != nil {
t.Fatalf("load appended email: %v", err)
}
if !found {
t.Fatal("appended email was not persisted")
}
if saved.Subject != "IMAP APPEND test" || saved.Text.String != "Appended message body.\n" {
t.Fatalf("unexpected appended email: %#v", saved)
}
if saved.Type != 0 || saved.Status != 0 {
t.Fatalf("unexpected appended email classification: type=%d status=%d", saved.Type, saved.Status)
}
var relation models.UserEmail
found, err = db.Instance.Where("email_id = ? and user_id = ?", saved.Id, clientLoginUserID(t)).Get(&relation)
if err != nil {
t.Fatalf("load appended mailbox relation: %v", err)
}
if !found || relation.ID != int(appendData.UID) || relation.IsRead != 1 || relation.Status != 0 {
t.Fatalf("unexpected appended mailbox relation: %#v", relation)
}
invalidMessage := "not an RFC 822 message"
invalidCommand := clientLogin.Append("INBOX", int64(len(invalidMessage)), nil)
if _, err := invalidCommand.Write([]byte(invalidMessage)); err != nil {
t.Fatalf("write invalid APPEND message: %v", err)
}
if err := invalidCommand.Close(); err != nil {
t.Fatalf("close invalid APPEND message: %v", err)
}
if _, err := invalidCommand.Wait(); err == nil {
t.Fatal("invalid APPEND message unexpectedly succeeded")
}
}
func clientLoginUserID(t *testing.T) int {
t.Helper()
var user models.User
found, err := db.Instance.Where("account = ?", "testCase").Get(&user)
if err != nil {
t.Fatalf("load test user: %v", err)
}
if !found || user.ID <= 0 {
t.Fatal("test user was not found")
}
return user.ID
}
func TestSelect(t *testing.T) {
res, err := clientUnLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
@ -512,18 +585,74 @@ func TestFetch(t *testing.T) {
}
}
func TestStore(t *testing.T) {
res, err := clientLogin.Store(
imap.UIDSetNum(1),
&imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{"\\Seen"},
},
&imap.StoreOptions{}).Collect()
if err != nil {
t.Errorf("%+v", err)
if _, err := clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait(); err != nil {
t.Fatal(err)
}
t.Logf("%+v", res)
userID := clientLoginUserID(t)
var relation models.UserEmail
found, err := db.Instance.Where("user_id = ? AND status = ? AND group_id = 0", userID, 0).
OrderBy("id ASC").Get(&relation)
if err != nil {
t.Fatalf("load INBOX relation: %v", err)
}
if !found || relation.ID == 0 {
t.Fatal("no INBOX relation available for STORE")
}
uid := imap.UID(relation.ID)
if _, err := clientLogin.Store(uidSet(uid), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}, &imap.StoreOptions{}).Collect(); err != nil {
t.Fatalf("add \\Seen: %v", err)
}
assertReadState(t, relation.ID, userID, 1)
if _, err := clientLogin.Store(uidSet(uid), &imap.StoreFlags{
Op: imap.StoreFlagsDel,
Silent: true,
Flags: []imap.Flag{imap.FlagSeen},
}, &imap.StoreOptions{}).Collect(); err != nil {
t.Fatalf("delete \\Seen: %v", err)
}
assertReadState(t, relation.ID, userID, 0)
if _, err := clientLogin.Store(uidSet(uid), &imap.StoreFlags{
Op: imap.StoreFlagsSet,
Flags: nil,
}, &imap.StoreOptions{}).Collect(); err != nil {
t.Fatalf("set empty flags: %v", err)
}
assertReadState(t, relation.ID, userID, 0)
}
func uidSet(uid imap.UID) imap.UIDSet {
return imap.UIDSetNum(uid)
}
func assertReadState(t *testing.T, relationID, userID int, expected int8) {
t.Helper()
var relation models.UserEmail
found, err := db.Instance.Where("id = ? AND user_id = ?", relationID, userID).Get(&relation)
if err != nil {
t.Fatalf("load relation read state: %v", err)
}
if !found || relation.IsRead != expected {
t.Fatalf("relation %d read state = %d, want %d", relationID, relation.IsRead, expected)
}
}
func TestMessageIDForEmail(t *testing.T) {
stored := &response.EmailResponseData{Email: models.Email{Id: 42, MsgID: "persisted@example.com"}}
if got := messageIDForEmail(stored); got != "<persisted@example.com>" {
t.Fatalf("stored Message-ID = %q", got)
}
fallback := &response.EmailResponseData{Email: models.Email{Id: 42}}
if got := messageIDForEmail(fallback); got != "<42@test.domain>" {
t.Fatalf("fallback Message-ID = %q", got)
}
}
func TestClose(t *testing.T) {

View File

@ -1,17 +1,31 @@
package imap_server
import (
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/id"
"github.com/emersion/go-imap/v2"
log "github.com/sirupsen/logrus"
"bytes"
"database/sql"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/consts"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/group"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/id"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
"xorm.io/xorm"
)
const maxIMAPAppendBytes = 30 << 20
// Server is a server instance.
//
// A server contains a list of users.
@ -73,8 +87,192 @@ func (s *serverSession) Unsubscribe(mailbox string) error {
}
func (s *serverSession) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
log.WithContext(s.ctx).Errorf("Append Not Implemented")
return nil, nil
if s == nil || s.ctx == nil || s.ctx.UserID <= 0 || s.status < AUTHORIZED {
return nil, &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeAuthenticationFailed,
Text: "Authentication required",
}
}
if r == nil {
return nil, appendBadError("Message content is missing")
}
mailbox = strings.Trim(strings.TrimSpace(mailbox), `"`)
if mailbox == "" {
return nil, appendBadError("Mailbox is missing")
}
groupID, uidValidity, err := resolveAppendMailbox(s.ctx, mailbox)
if err != nil {
return nil, err
}
if r.Size() < 0 || r.Size() > maxIMAPAppendBytes {
return nil, appendTooBigError()
}
rawMessage, err := io.ReadAll(io.LimitReader(r, maxIMAPAppendBytes+1))
if err != nil {
return nil, appendNoError("Read message failed")
}
if len(rawMessage) == 0 {
return nil, appendBadError("Message content is empty")
}
if len(rawMessage) > maxIMAPAppendBytes {
return nil, appendTooBigError()
}
parsed, err := parsemail.NewEmailFromReader(nil, bytes.NewReader(rawMessage), len(rawMessage))
if err != nil {
return nil, appendNoError("Invalid message content")
}
if parsed == nil || parsed.From == nil {
return nil, appendNoError("Message sender is missing")
}
status, emailType := appendMailboxStatus(mailbox)
if emailType == int8(consts.EmailTypeSend) && strings.TrimSpace(parsed.From.EmailAddress) == "" {
return nil, appendNoError("Message sender is missing")
}
domains := config.CurrentMailDomains()
if parsed.MsgID == "" {
if domains.Primary == "" {
return nil, appendNoError("Message ID domain is not configured")
}
parsed.MsgID, err = parsemail.GenerateMsgID(domains.Primary)
if err != nil {
return nil, appendNoError("Generate message ID failed")
}
}
storedFields, err := parsed.EncodeStoredFields()
if err != nil {
return nil, appendNoError("Encode message failed")
}
appendTime := time.Now()
if options != nil && !options.Time.IsZero() {
appendTime = options.Time
}
modelEmail := models.Email{
Type: emailType,
Subject: parsed.Subject,
ReplyTo: storedFields.ReplyTo,
FromName: parsed.From.Name,
FromAddress: parsed.From.EmailAddress,
To: storedFields.To,
Bcc: storedFields.Bcc,
Cc: storedFields.Cc,
Text: sql.NullString{String: string(parsed.Text), Valid: true},
Html: sql.NullString{String: string(parsed.HTML), Valid: true},
Sender: storedFields.Sender,
Attachments: storedFields.Attachments,
Size: len(rawMessage),
Status: status,
SendUserID: s.ctx.UserID,
SendDate: appendTime,
CreateTime: appendTime,
CronSendTime: appendTime,
MsgID: parsed.MsgID,
}
var savedUID int
if err := db.Transaction(db.Instance, func(session *xorm.Session) error {
candidate := modelEmail
candidate.Id = 0
if _, err := session.Insert(&candidate); err != nil {
return fmt.Errorf("insert appended email: %w", err)
}
if candidate.Id <= 0 {
return fmt.Errorf("insert appended email returned no id")
}
relation := models.UserEmail{
UserID: s.ctx.UserID,
EmailID: candidate.Id,
IsRead: appendMessageRead(options),
GroupId: groupID,
Status: status,
}
if _, err := session.Insert(&relation); err != nil {
return fmt.Errorf("insert appended mailbox relation: %w", err)
}
savedUID = relation.ID
return nil
}); err != nil {
return nil, appendNoError("Save message failed")
}
return &imap.AppendData{
UID: imap.UID(savedUID),
UIDValidity: uint32(uidValidity),
}, nil
}
func resolveAppendMailbox(ctx *context.Context, mailbox string) (int, int, error) {
if group.IsDefaultBox(mailbox) {
uidValidity, ok := models.GroupNameToCode[mailbox]
if !ok {
return 0, 0, appendNoError("Mailbox not found")
}
return 0, uidValidity, nil
}
mailboxGroup, err := group.GetGroupByFullPath(ctx, mailbox)
if err != nil {
return 0, 0, appendNoError("Mailbox lookup failed")
}
if mailboxGroup == nil || mailboxGroup.ID <= 0 || mailboxGroup.UserId != ctx.UserID {
return 0, 0, &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeTryCreate,
Text: "No such mailbox",
}
}
return mailboxGroup.ID, mailboxGroup.ID, nil
}
func appendMailboxStatus(mailbox string) (int8, int8) {
switch mailbox {
case "Sent Messages", "Drafts":
if mailbox == "Drafts" {
return consts.EmailStatusDrafts, consts.EmailTypeSend
}
return consts.EmailStatusSent, consts.EmailTypeSend
case "Deleted Messages":
return consts.EmailStatusDel, consts.EmailTypeReceive
case "Junk":
return consts.EmailStatusJunk, consts.EmailTypeReceive
default:
return consts.EmailStatusWait, consts.EmailTypeReceive
}
}
func appendMessageRead(options *imap.AppendOptions) int8 {
if options == nil {
return 0
}
for _, flag := range options.Flags {
if flag == imap.FlagSeen {
return 1
}
}
return 0
}
func appendBadError(text string) error {
return &imap.Error{Type: imap.StatusResponseTypeBad, Text: text}
}
func appendNoError(text string) error {
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: text}
}
func appendTooBigError() error {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeTooBig,
Text: "Message exceeds the maximum size",
}
}
func (s *serverSession) Unselect() error {

View File

@ -91,16 +91,9 @@ func buildEnvelope(email *response.EmailResponseData, traEmail *parsemail.Email)
replyTo = from
}
// Message-ID: use the persisted value so IMAP clients see a stable ID on every sync.
var messageID string
if email.MsgID != "" {
messageID = fmt.Sprintf("<%s>", email.MsgID)
} else {
cfg := config.CurrentConfig()
if cfg == nil || cfg.Domain == "" {
return nil
}
messageID = fmt.Sprintf("<%d@%s>", email.Id, cfg.Domain)
messageID := messageIDForEmail(email)
if messageID == "" {
return nil
}
return &imap.Envelope{
@ -117,6 +110,20 @@ func buildEnvelope(email *response.EmailResponseData, traEmail *parsemail.Email)
}
}
func messageIDForEmail(email *response.EmailResponseData) string {
if email == nil {
return ""
}
if strings.TrimSpace(email.MsgID) != "" {
return fmt.Sprintf("<%s>", strings.TrimSpace(email.MsgID))
}
cfg := config.CurrentConfig()
if cfg == nil || strings.TrimSpace(cfg.Domain) == "" || email.Id <= 0 {
return ""
}
return fmt.Sprintf("<%d@%s>", email.Id, strings.TrimSpace(cfg.Domain))
}
func (s *serverSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
switch numSet.(type) {
case imap.SeqSet:
@ -399,11 +406,11 @@ func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*respons
fmt.Fprintf(&b, "Bcc: %s\r\n", traEmail.BuildBcc2String())
}
case "message-id":
cfg := config.CurrentConfig()
if cfg == nil || cfg.Domain == "" {
messageID := messageIDForEmail(email)
if messageID == "" {
return nil
}
fmt.Fprintf(&b, "Message-ID: <%d@%s>\r\n", email.Id, cfg.Domain)
fmt.Fprintf(&b, "Message-ID: %s\r\n", messageID)
case "content-type":
args := strings.SplitN(string(emailContent), "\r\n", 3)
if len(args) >= 2 {

View File

@ -1,9 +1,12 @@
package imap_server
import (
"fmt"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/del_email"
"git.pcux.cn/xuans/Smail/services/detail"
"git.pcux.cn/xuans/Smail/services/list"
"git.pcux.cn/xuans/Smail/utils/array"
"github.com/emersion/go-imap/v2"
@ -13,9 +16,8 @@ import (
)
func (s *serverSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
if flags.Op == imap.StoreFlagsSet {
return nil
if s == nil || s.ctx == nil || s.ctx.UserID <= 0 || flags == nil {
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Store failed"}
}
var emailList []*response.EmailResponseData
@ -50,28 +52,88 @@ func (s *serverSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, fla
}
}
if array.InArray(imap.FlagSeen, flags.Flags) && flags.Op == imap.StoreFlagsAdd {
seenRequested := array.InArray(imap.FlagSeen, flags.Flags)
deletedRequested := array.InArray(imap.FlagDeleted, flags.Flags)
updateReadState := false
readState := false
switch flags.Op {
case imap.StoreFlagsSet:
updateReadState = true
readState = seenRequested
case imap.StoreFlagsAdd:
updateReadState = seenRequested
readState = true
case imap.StoreFlagsDel:
updateReadState = seenRequested
readState = false
default:
return &imap.Error{Type: imap.StatusResponseTypeBad, Text: "Unsupported STORE operation"}
}
if updateReadState {
for _, data := range emailList {
if err := detail.MakeRead(s.ctx, data.Id, flags.Op == imap.StoreFlagsAdd); err != nil {
if err := updateReadByUID(s.ctx.UserID, data.UeId, readState); err != nil {
log.WithContext(s.ctx).WithError(err).Error("update IMAP message read state failed")
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Store failed"}
}
data.IsRead = 0
if readState {
data.IsRead = 1
}
}
}
if array.InArray(imap.FlagDeleted, flags.Flags) && flags.Op == imap.StoreFlagsAdd {
if deletedRequested && flags.Op == imap.StoreFlagsAdd {
for _, data := range emailList {
s.deleteUidList = append(s.deleteUidList, data.UeId)
}
}
err := del_email.DelByUID(s.ctx, s.deleteUidList)
s.deleteUidList = []int{}
if len(s.deleteUidList) > 0 {
err := del_email.DelByUID(s.ctx, s.deleteUidList)
s.deleteUidList = []int{}
if err != nil {
log.WithContext(s.ctx).WithError(err).Error("delete IMAP message failed")
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Store failed"}
}
}
if err != nil {
log.WithContext(s.ctx).WithError(err).Error("delete IMAP message failed")
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Store failed"}
if !flags.Silent && w != nil {
for _, data := range emailList {
sequence, err := imapUint32(data.SerialNumber)
if err != nil || sequence == 0 {
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Store failed"}
}
message := w.CreateMessage(sequence)
if data.IsRead == 1 {
message.WriteFlags([]imap.Flag{imap.FlagSeen})
} else {
message.WriteFlags(nil)
}
if err := message.Close(); err != nil {
return err
}
}
}
return nil
}
func updateReadByUID(userID, relationID int, read bool) error {
if userID <= 0 || relationID <= 0 {
return fmt.Errorf("invalid mailbox relation")
}
value := int8(0)
if read {
value = 1
}
affected, err := db.Instance.Where("id = ? and user_id = ?", relationID, userID).
Cols("is_read").Update(&models.UserEmail{IsRead: value})
if err != nil {
return fmt.Errorf("update mailbox relation read state: %w", err)
}
if affected != 1 {
return fmt.Errorf("mailbox relation not found")
}
return nil
}