diff --git a/fe/src/views/components/SecuritySettings/SecuritySettings.js b/fe/src/views/components/SecuritySettings/SecuritySettings.js index 01b23a24..527d1b7f 100644 --- a/fe/src/views/components/SecuritySettings/SecuritySettings.js +++ b/fe/src/views/components/SecuritySettings/SecuritySettings.js @@ -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 diff --git a/server/listen/imap_server/imap_server_test.go b/server/listen/imap_server/imap_server_test.go index 99f1838d..99a57f1f 100644 --- a/server/listen/imap_server/imap_server_test.go +++ b/server/listen/imap_server/imap_server_test.go @@ -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: \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 != "" { + 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) { diff --git a/server/listen/imap_server/server.go b/server/listen/imap_server/server.go index 117cc43e..ad7e3df7 100644 --- a/server/listen/imap_server/server.go +++ b/server/listen/imap_server/server.go @@ -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 { diff --git a/server/listen/imap_server/session_fetch.go b/server/listen/imap_server/session_fetch.go index 562a944b..4474791a 100644 --- a/server/listen/imap_server/session_fetch.go +++ b/server/listen/imap_server/session_fetch.go @@ -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 { diff --git a/server/listen/imap_server/session_store.go b/server/listen/imap_server/session_store.go index 871c1a4f..d28d90dc 100644 --- a/server/listen/imap_server/session_store.go +++ b/server/listen/imap_server/session_store.go @@ -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 +}