diff --git a/backend/controllers/email/contacts.go b/backend/controllers/email/contacts.go new file mode 100644 index 0000000..41ec5b6 --- /dev/null +++ b/backend/controllers/email/contacts.go @@ -0,0 +1,158 @@ +package email + +import ( + "encoding/json" + "net/http" + "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" + log "github.com/sirupsen/logrus" + "xorm.io/xorm" +) + +type deleteContactRequest struct { + Email string `json:"email"` +} + +type trustSenderRequest struct { + Email string `json:"email"` + Trusted bool `json:"trusted"` +} + +// ContactsList 查询当前用户的历史写信收件人列表(多租户严格隔离) +func ContactsList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var contacts []models.UserContact + err := db.Instance.Table(&models.UserContact{}). + Where("user_id = ?", ctx.UserID). + OrderBy("last_used_at DESC"). + Limit(100). + Find(&contacts) + if err != nil { + log.WithContext(ctx).WithError(err).Error("query user contacts failed") + response.NewErrorResponse(response.ServerError, "获取联系人失败", "").FPrint(w) + return + } + + response.NewSuccessResponse(contacts).FPrint(w) +} + +// DeleteContact 删除当前用户的某项历史联系人记录 +func DeleteContact(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var reqData deleteContactRequest + if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil { + response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) + return + } + email := strings.ToLower(strings.TrimSpace(reqData.Email)) + if email == "" { + response.NewErrorResponse(response.ParamsError, "联系人邮箱不能为空", "").FPrint(w) + return + } + + _, err := db.Instance.Table(&models.UserContact{}). + Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email). + Delete(&models.UserContact{}) + if err != nil { + log.WithContext(ctx).WithError(err).Error("delete user contact failed") + response.NewErrorResponse(response.ServerError, "删除联系人失败", "").FPrint(w) + return + } + + response.NewSuccessResponse("ok").FPrint(w) +} + +// TrustSender 将发件人添加至信任白名单,或从中移除 +func TrustSender(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var reqData trustSenderRequest + if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil { + response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) + return + } + email := strings.ToLower(strings.TrimSpace(reqData.Email)) + if email == "" { + response.NewErrorResponse(response.ParamsError, "发件人邮箱不能为空", "").FPrint(w) + return + } + + if reqData.Trusted { + var exists bool + has, err := db.Instance.Table(&models.UserTrustedSender{}). + Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email). + Exist(&models.UserTrustedSender{}) + if err != nil { + log.WithContext(ctx).WithError(err).Error("check trusted sender failed") + response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w) + return + } + exists = has + if !exists { + item := models.UserTrustedSender{ + UserID: ctx.UserID, + Email: email, + CreatedAt: time.Now(), + } + if _, err := db.Instance.Insert(&item); err != nil { + log.WithContext(ctx).WithError(err).Error("insert trusted sender failed") + response.NewErrorResponse(response.ServerError, "添加信任发件人失败", "").FPrint(w) + return + } + } + } else { + if _, err := db.Instance.Table(&models.UserTrustedSender{}). + Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email). + Delete(&models.UserTrustedSender{}); err != nil { + log.WithContext(ctx).WithError(err).Error("remove trusted sender failed") + response.NewErrorResponse(response.ServerError, "取消信任发件人失败", "").FPrint(w) + return + } + } + + response.NewSuccessResponse("ok").FPrint(w) +} + +// RecordUserContacts 异步批量记录发件成功后的收件人地址 +func RecordUserContacts(userID int, recipients []models.UserContact) { + if userID <= 0 || len(recipients) == 0 { + return + } + now := time.Now() + _ = db.Transaction(db.Instance, func(session *xorm.Session) error { + for _, r := range recipients { + email := strings.ToLower(strings.TrimSpace(r.Email)) + if email == "" { + continue + } + var existing models.UserContact + has, err := session.Table(&models.UserContact{}). + Where("user_id = ? AND LOWER(email) = ?", userID, email). + Get(&existing) + if err != nil { + return err + } + if has { + existing.LastUsedAt = now + if r.Name != "" { + existing.Name = r.Name + } + if _, err := session.ID(existing.ID).Cols("name", "last_used_at").Update(&existing); err != nil { + return err + } + } else { + item := models.UserContact{ + UserID: userID, + Email: email, + Name: r.Name, + LastUsedAt: now, + } + if _, err := session.Insert(&item); err != nil { + return err + } + } + } + return nil + }) +} diff --git a/backend/controllers/email/contacts_test.go b/backend/controllers/email/contacts_test.go new file mode 100644 index 0000000..b4e4e4f --- /dev/null +++ b/backend/controllers/email/contacts_test.go @@ -0,0 +1,135 @@ +package email + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/context" + _ "modernc.org/sqlite" + "xorm.io/xorm" +) + +func setupContactsTestDB(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + if err := engine.Sync2(&models.UserContact{}, &models.UserTrustedSender{}); err != nil { + t.Fatal(err) + } + db.Instance = engine +} + +func TestContactsListAndTenantIsolation(t *testing.T) { + setupContactsTestDB(t) + + // User 1 联系人 + _, _ = db.Instance.Insert(&models.UserContact{ + UserID: 1, + Email: "user1_friend@example.com", + Name: "Friend One", + LastUsedAt: time.Now(), + }) + // User 2 联系人(隔离测试) + _, _ = db.Instance.Insert(&models.UserContact{ + UserID: 2, + Email: "user2_secret@example.com", + Name: "Friend Two", + LastUsedAt: time.Now(), + }) + + // 测试 User 1 查询联系人,只能看到自己的 + ctxUser1 := &context.Context{UserID: 1} + req := httptest.NewRequest(http.MethodGet, "/api/v1/email/contacts", nil) + w := httptest.NewRecorder() + ContactsList(ctxUser1, w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp struct { + Code int `json:"code"` + Data []models.UserContact `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + + if len(resp.Data) != 1 { + t.Fatalf("expected 1 contact for user 1, got %d", len(resp.Data)) + } + if resp.Data[0].Email != "user1_friend@example.com" { + t.Fatalf("expected user1_friend@example.com, got %s", resp.Data[0].Email) + } + + // 测试删除联系人 + delReqBody, _ := json.Marshal(deleteContactRequest{Email: "user1_friend@example.com"}) + delReq := httptest.NewRequest(http.MethodPost, "/api/v1/email/contacts/delete", bytes.NewReader(delReqBody)) + delW := httptest.NewRecorder() + DeleteContact(ctxUser1, delW, delReq) + + if delW.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", delW.Code) + } + + // 再次查询应该为空 + w2 := httptest.NewRecorder() + ContactsList(ctxUser1, w2, req) + var resp2 struct { + Code int `json:"code"` + Data []models.UserContact `json:"data"` + } + _ = json.Unmarshal(w2.Body.Bytes(), &resp2) + if len(resp2.Data) != 0 { + t.Fatalf("expected 0 contacts after delete, got %d", len(resp2.Data)) + } + + // 确认 User 2 的联系人未受影响 + var u2Count int + has, err := db.Instance.Table(&models.UserContact{}).Where("user_id = 2").Count(&models.UserContact{}) + if err != nil || has != 1 { + t.Fatalf("user 2 contact was corrupted: count=%d, err=%v", u2Count, err) + } +} + +func TestTrustSenderToggle(t *testing.T) { + setupContactsTestDB(t) + ctx := &context.Context{UserID: 1} + + // 添加信任 + body, _ := json.Marshal(trustSenderRequest{Email: "trusted@example.com", Trusted: true}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/email/trust-sender", bytes.NewReader(body)) + w := httptest.NewRecorder() + TrustSender(ctx, w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + has, err := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ? AND email = ?", 1, "trusted@example.com").Exist(&models.UserTrustedSender{}) + if err != nil || !has { + t.Fatalf("expected trusted sender to exist") + } + + // 取消信任 + body2, _ := json.Marshal(trustSenderRequest{Email: "trusted@example.com", Trusted: false}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/email/trust-sender", bytes.NewReader(body2)) + w2 := httptest.NewRecorder() + TrustSender(ctx, w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w2.Code) + } + + hasAfter, _ := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ? AND email = ?", 1, "trusted@example.com").Exist(&models.UserTrustedSender{}) + if hasAfter { + t.Fatalf("expected trusted sender to be removed") + } +} diff --git a/backend/controllers/email/list.go b/backend/controllers/email/list.go index c4ae321..42917b1 100644 --- a/backend/controllers/email/list.go +++ b/backend/controllers/email/list.go @@ -10,6 +10,8 @@ import ( "git.pcux.cn/xuans/Smail/dto" "git.pcux.cn/xuans/Smail/dto/parsemail" "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/list" "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" @@ -79,6 +81,14 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { response.NewErrorResponse(response.ServerError, "读取邮件列表失败", "").FPrint(w) return } + trustedMap := make(map[string]bool) + var trustedSenders []models.UserTrustedSender + if err := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ?", ctx.UserID).Find(&trustedSenders); err == nil { + for _, item := range trustedSenders { + trustedMap[strings.ToLower(strings.TrimSpace(item.Email))] = true + } + } + items := make([]*emilItem, 0, len(emailList)) for _, email := range emailList { var sender User @@ -99,6 +109,10 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } authentication := parsemail.NewEmailAuthentication(email.SPFCheck == 1, email.DKIMCheck == 1) + dangerous := authentication.Dangerous + if trustedMap[strings.ToLower(strings.TrimSpace(sender.EmailAddress))] { + dangerous = false + } items = append(items, &emilItem{ ID: email.Id, Title: email.Subject, @@ -107,7 +121,7 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { IsRead: email.IsRead == 1, Sender: sender, To: recipients, - Dangerous: authentication.Dangerous, + Dangerous: dangerous, Error: email.Error.String, }) } diff --git a/backend/controllers/email/send.go b/backend/controllers/email/send.go index 8bdd759..206c58f 100644 --- a/backend/controllers/email/send.go +++ b/backend/controllers/email/send.go @@ -282,6 +282,18 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } + // ?????????????????? + var contactList []models.UserContact + for _, recipient := range append(append(e.To, e.Cc...), e.Bcc...) { + if recipient != nil && recipient.EmailAddress != "" { + contactList = append(contactList, models.UserContact{ + Email: recipient.EmailAddress, + Name: recipient.Name, + }) + } + } + RecordUserContacts(ctx.UserID, contactList) + e.MessageId = cast.ToInt64(modelEmail.Id) e.MsgID = modelEmail.MsgID diff --git a/backend/db/init.go b/backend/db/init.go index 37d4d1d..0f1e930 100644 --- a/backend/db/init.go +++ b/backend/db/init.go @@ -146,6 +146,8 @@ func syncTables() error { &models.WebAuthnCredential{}, &models.ProtocolCredential{}, &models.Version{}, + &models.UserContact{}, + &models.UserTrustedSender{}, } for _, table := range tables { if err := Instance.Sync2(table); err != nil { diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index f18bf1c..3287471 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -28,6 +28,7 @@ func DefaultMigrations() []Migration { DefineCustom(16, "expand_user_security_preferences", "expand_user_security_preferences_v1", applyUserSecurityPreferencesSchema), DefineForDialects(17, "create_managed_logs", logStatements()), DefineCustom(18, "expand_operation_log_actor_role", "expand_operation_log_actor_role_v1", applyOperationLogActorRoleSchema), + DefineForDialects(19, "create_user_contacts_and_trusted_senders", userContactStatements()), } } diff --git a/backend/db/migration/registry_test.go b/backend/db/migration/registry_test.go index f3906e6..1f5d84f 100644 --- a/backend/db/migration/registry_test.go +++ b/backend/db/migration/registry_test.go @@ -20,7 +20,7 @@ func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) { t.Fatalf("report = %+v", report) } - for _, table := range []string{"user", "email", "group", "rule", "sessions", "user_email", "version", "external_email", "verification_event", "security_question", "recovery_code", "verification_rate_limit", "verification_cleanup_state", "webauthn_credential", "totp_authenticator", "plugin_secret", "plugin_user_setting", "schema_migration"} { + for _, table := range []string{"user", "email", "group", "rule", "sessions", "user_email", "version", "external_email", "verification_event", "security_question", "recovery_code", "verification_rate_limit", "verification_cleanup_state", "webauthn_credential", "totp_authenticator", "plugin_secret", "plugin_user_setting", "schema_migration", "user_contact", "user_trusted_sender"} { if !sqliteTableExists(t, db, table) { t.Fatalf("table %s was not created", table) } @@ -283,6 +283,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) { 16: "00cc275529e522d93b41e608692fb6e2271f1aa9300ebe020fcaed4d68d0ff76", 17: "1dad66acb5e7fecf6c39f4a78526b994748d90045220f446a2e80c1b5fd881e7", 18: "17622df3406657f199d78a3a1aa1f3a8693baf85aa7707910cc6d6cb44c13ee4", + 19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16", } migrations := DefaultMigrations() if len(migrations) != len(expected) { diff --git a/backend/db/migration/user_contacts.go b/backend/db/migration/user_contacts.go new file mode 100644 index 0000000..33b7460 --- /dev/null +++ b/backend/db/migration/user_contacts.go @@ -0,0 +1,22 @@ +package migration + +func userContactStatements() map[Dialect][]string { + return map[Dialect][]string{ + DialectSQLite: { + `CREATE TABLE IF NOT EXISTS "user_contact" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email" TEXT NOT NULL, "name" TEXT NOT NULL DEFAULT '', "last_used_at" DATETIME NOT NULL, UNIQUE("user_id", "email"))`, + `CREATE INDEX IF NOT EXISTS "idx_user_contact_user_last_used" ON "user_contact"("user_id", "last_used_at")`, + `CREATE TABLE IF NOT EXISTS "user_trusted_sender" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email" TEXT NOT NULL, "created_at" DATETIME NOT NULL, UNIQUE("user_id", "email"))`, + `CREATE INDEX IF NOT EXISTS "idx_user_trusted_sender_user" ON "user_trusted_sender"("user_id", "email")`, + }, + DialectMySQL: { + "CREATE TABLE IF NOT EXISTS `user_contact` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` BIGINT NOT NULL, `email` VARCHAR(255) NOT NULL, `name` VARCHAR(100) NOT NULL DEFAULT '', `last_used_at` DATETIME(6) NOT NULL, UNIQUE KEY `uq_user_contact_user_email` (`user_id`, `email`), KEY `idx_user_contact_user_last_used` (`user_id`, `last_used_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `user_trusted_sender` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` BIGINT NOT NULL, `email` VARCHAR(255) NOT NULL, `created_at` DATETIME(6) NOT NULL, UNIQUE KEY `uq_user_trusted_sender_user_email` (`user_id`, `email`), KEY `idx_user_trusted_sender_user` (`user_id`, `email`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + }, + DialectPostgres: { + `CREATE TABLE IF NOT EXISTS "user_contact" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email" VARCHAR(255) NOT NULL, "name" VARCHAR(100) NOT NULL DEFAULT '', "last_used_at" TIMESTAMPTZ NOT NULL, UNIQUE("user_id", "email"))`, + `CREATE INDEX IF NOT EXISTS "idx_user_contact_user_last_used" ON "user_contact"("user_id", "last_used_at")`, + `CREATE TABLE IF NOT EXISTS "user_trusted_sender" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email" VARCHAR(255) NOT NULL, "created_at" TIMESTAMPTZ NOT NULL, UNIQUE("user_id", "email"))`, + `CREATE INDEX IF NOT EXISTS "idx_user_trusted_sender_user" ON "user_trusted_sender"("user_id", "email")`, + }, + } +} diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index 0d4626f..f4970b6 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -58,6 +58,9 @@ func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/email/detail", "/api/email/detail", http.MethodPost, 256<<10, email.EmailDetail) registerV1MethodRoute(mux, "/api/v1/email/move", "/api/email/move", http.MethodPost, 256<<10, email.Move) registerV1MethodRoute(mux, "/api/v1/email/send", "/api/email/send", http.MethodPost, 40<<20, email.Send) + registerV1MethodRoute(mux, "/api/v1/email/contacts", "/api/email/contacts", http.MethodGet, 0, email.ContactsList) + registerV1MethodRoute(mux, "/api/v1/email/contacts/delete", "/api/email/contacts/delete", http.MethodPost, 16<<10, email.DeleteContact) + registerV1MethodRoute(mux, "/api/v1/email/trust-sender", "/api/email/trust-sender", http.MethodPost, 16<<10, email.TrustSender) registerV1MethodRoute(mux, "/api/v1/settings/modify_password", "/api/settings/modify_password", http.MethodPost, 64<<10, controllers.ModifyPassword) registerV1MethodRoute(mux, "/api/v1/rule/get", "/api/rule/get", http.MethodPost, 4<<10, controllers.GetRule) registerV1MethodRoute(mux, "/api/v1/rule/add", "/api/rule/add", http.MethodPost, 64<<10, controllers.UpsertRule) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 4f485b5..e824fad 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -88,6 +88,9 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/api/email/detail", onlyMethod(http.MethodPost, limitBody(256<<10, contextIterceptor(email.EmailDetail)))) mux.HandleFunc("/api/email/move", onlyMethod(http.MethodPost, limitBody(256<<10, contextIterceptor(email.Move)))) mux.HandleFunc("/api/email/send", onlyMethod(http.MethodPost, limitBody(40<<20, contextIterceptor(email.Send)))) + mux.HandleFunc("/api/email/contacts", onlyMethod(http.MethodGet, contextIterceptor(email.ContactsList))) + mux.HandleFunc("/api/email/contacts/delete", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(email.DeleteContact)))) + mux.HandleFunc("/api/email/trust-sender", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(email.TrustSender)))) mux.HandleFunc("/api/settings/modify_password", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.ModifyPassword)))) mux.HandleFunc("/api/rule/get", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.GetRule)))) mux.HandleFunc("/api/rule/add", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpsertRule)))) diff --git a/backend/models/user_contact.go b/backend/models/user_contact.go new file mode 100644 index 0000000..4e3af78 --- /dev/null +++ b/backend/models/user_contact.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// UserContact 保存单个用户的历史写信收件人,用于写信页自动补全与联系人管理 +type UserContact struct { + ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"` + UserID int `xorm:"user_id bigint not null" json:"-"` + Email string `xorm:"email varchar(255) notnull" json:"email"` + Name string `xorm:"name varchar(100) notnull default('')" json:"name"` + LastUsedAt time.Time `xorm:"last_used_at datetime notnull" json:"last_used_at"` +} + +func (UserContact) TableName() string { return "user_contact" } + +// UserTrustedSender 保存单个用户手动标记信任的发件人白名单,免除安全警告 +type UserTrustedSender struct { + ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"` + UserID int `xorm:"user_id bigint not null" json:"-"` + Email string `xorm:"email varchar(255) notnull" json:"email"` + CreatedAt time.Time `xorm:"created_at datetime created notnull" json:"created_at"` +} + +func (UserTrustedSender) TableName() string { return "user_trusted_sender" } diff --git a/frontend/src/views/Editer/Editer.css b/frontend/src/views/Editer/Editer.css index 28a5b96..4eee9a6 100644 --- a/frontend/src/views/Editer/Editer.css +++ b/frontend/src/views/Editer/Editer.css @@ -306,3 +306,39 @@ padding: 12px 16px; } } + +.contact-option-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.contact-option-info { + display: flex; + align-items: center; + gap: 8px; +} + +.contact-option-name { + font-weight: 600; + color: var(--pm-text-primary); +} + +.contact-option-email { + color: var(--pm-text-secondary); + font-size: 13px; +} + +.contact-del-icon { + font-size: 14px; + color: var(--pm-text-muted); + padding: 4px; + border-radius: 4px; + transition: color 0.15s ease, background 0.15s ease; +} + +.contact-del-icon:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} diff --git a/frontend/src/views/Editer/Editer.js b/frontend/src/views/Editer/Editer.js index c01f613..95edc44 100644 --- a/frontend/src/views/Editer/Editer.js +++ b/frontend/src/views/Editer/Editer.js @@ -6,7 +6,7 @@ import {ElMessage} from 'element-plus' import {onBeforeUnmount, reactive, ref, shallowRef} from 'vue' -import {Close, Paperclip, Position, ArrowDown, Document} from '@element-plus/icons-vue'; +import {Close, Paperclip, Position, ArrowDown, Document, Delete} from '@element-plus/icons-vue'; import lang from '../../i18n/i18n'; @@ -68,8 +68,32 @@ export function usePage() { }) const fileList = reactive([]); + const contactOptions = ref([]); + + const loadContacts = function () { + http.get("/api/v1/email/contacts").then(res => { + if (res.errorNo === 0 && Array.isArray(res.data)) { + contactOptions.value = res.data + } + }).catch(() => {}) + } + + const deleteContact = function (email) { + if (!email) return + http.post("/api/v1/email/contacts/delete", {email: email}).then(res => { + if (res.errorNo === 0) { + ElMessage.success("已从历史联系人中删除") + contactOptions.value = contactOptions.value.filter(c => c.email !== email) + } else { + ElMessage.error(res.errorMsg || "删除失败") + } + }).catch(() => { + ElMessage.error("删除联系人失败") + }) + } const init = function () { + loadContacts() if ( Object.keys(globalStatus.userInfos)==0 || globalStatus.userInfos === null || globalStatus.userInfos == undefined ){ globalStatus.init(()=>{ ruleForm.sender = globalStatus.userInfos.account @@ -228,10 +252,10 @@ export function usePage() { Position, ArrowDown, Document, + Delete, lang, Editor, Toolbar, - i18nChangeLanguage, useRouter, http, useGroupStore, @@ -260,7 +284,10 @@ export function usePage() { send, upload, fileChange, - delFile + delFile, + contactOptions, + loadContacts, + deleteContact } } @@ -271,5 +298,6 @@ export { Toolbar, Editor, Document, - Close + Close, + Delete } diff --git a/frontend/src/views/Editer/Editer.vue b/frontend/src/views/Editer/Editer.vue index 5b0ef61..f0ed284 100644 --- a/frontend/src/views/Editer/Editer.vue +++ b/frontend/src/views/Editer/Editer.vue @@ -59,8 +59,25 @@ + placeholder="请输入收件人地址(输入后按回车,或从下方历史联系人选择)" + > + +
+
+ {{ contact.name }} + {{ contact.email }} +
+ + + +
+
+ @@ -71,7 +88,18 @@ v-model="ruleForm.cc" multiple filterable allow-create :reserve-keyword="false" placeholder="请输入抄送人地址" - > + > + +
+ {{ contact.email }} +
+
+ @@ -82,7 +110,18 @@ v-model="ruleForm.bcc" multiple filterable allow-create :reserve-keyword="false" placeholder="请输入密送人地址" - > + > + +
+ {{ contact.email }} +
+
+ @@ -111,7 +150,7 @@ diff --git a/frontend/src/views/List/List.css b/frontend/src/views/List/List.css index 5e3b79b..ebb41c9 100644 --- a/frontend/src/views/List/List.css +++ b/frontend/src/views/List/List.css @@ -233,3 +233,30 @@ padding-top: 2px; } } + +.mail-status-tags { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.clickable-tag { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.clickable-tag:hover { + transform: scale(1.04); + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25); +} + +.error-dialog-body { + padding: 10px 0; +} + +.error-label { + font-weight: 600; + margin-bottom: 8px; + color: var(--pm-text-primary); +} diff --git a/frontend/src/views/List/List.js b/frontend/src/views/List/List.js index ad8bde2..c80bc5a 100644 --- a/frontend/src/views/List/List.js +++ b/frontend/src/views/List/List.js @@ -2,11 +2,11 @@ import './List.js' import {EpArrowDownBold} from "vue-icons-plus/ep"; -import {Delete, View, Folder, EditPen, Warning, CircleCheck, Switch} from "@element-plus/icons-vue"; +import {Delete, View, Folder, EditPen, Warning, CircleCheck, Switch, Check} from "@element-plus/icons-vue"; import {useRouter} from 'vue-router' -import {ref, watch} from 'vue' +import {ref, watch, computed} from 'vue' import useGroupStore from '../../stores/group' @@ -27,11 +27,23 @@ export function usePage() { const selectedRows = ref([]) + const errorDialogVisible = ref(false) + const currentErrorMsg = ref('') + let tag = groupStore.tag; if (tag === "") { tag = '{"type":0,"status":-1}' } + + const isOutbox = computed(() => { + try { + const parsed = JSON.parse(groupStore.tag || tag || '{}') + return parsed.type === 1 || groupStore.name === lang.outbox || groupStore.name === '已发送' + } catch { + return false + } + }) watch(groupStore, async (newV) => { tag = newV.tag; @@ -110,7 +122,7 @@ export function usePage() { http.post("/api/v1/email/read", {"ids": ids}).then(res => { if (res.errorNo === 0) { updateList() - ElMessage.success('Marked as read'); + ElMessage.success('已标记为已读'); } else { ElMessage.error(res.errorMsg) } @@ -172,6 +184,38 @@ export function usePage() { }) } + const trustSender = function (senderEmail) { + if (!senderEmail) return + ElMessageBox.confirm( + `确定将发件人 "${senderEmail}" 加入信任白名单吗?后续来自该地址的新邮件将不再提示风险。`, + '信任发件人', + { + confirmButtonText: '信任并加入白名单', + cancelButtonText: '取消', + type: 'warning' + } + ).then(() => { + http.post("/api/v1/email/trust-sender", {email: senderEmail, trusted: true}).then(res => { + if (res.errorNo === 0) { + ElMessage.success('已加入信任白名单,风险提示已免除') + // 局部更新视图,避免整页重新刷新 + data.value.forEach(item => { + if (item.sender?.EmailAddress === senderEmail) { + item.dangerous = false + } + }) + } else { + ElMessage.error(res.errorMsg || '操作失败') + } + }) + }).catch(() => {}) + } + + const showError = function (row) { + currentErrorMsg.value = row.error || '未知投递错误' + errorDialogVisible.value = true + } + return { EpArrowDownBold, Delete, @@ -179,11 +223,13 @@ export function usePage() { Folder, EditPen, Warning, + Check, CircleCheck, Switch, useRouter, ref, watch, + computed, useGroupStore, lang, http, @@ -208,7 +254,12 @@ export function usePage() { move, del, rowStyle, - pageChange + pageChange, + isOutbox, + trustSender, + showError, + errorDialogVisible, + currentErrorMsg } } @@ -220,5 +271,6 @@ export { Folder, EpArrowDownBold, EditPen, - Warning + Warning, + Check } diff --git a/frontend/src/views/List/List.vue b/frontend/src/views/List/List.vue index 7bceb91..dc4c51b 100644 --- a/frontend/src/views/List/List.vue +++ b/frontend/src/views/List/List.vue @@ -54,12 +54,6 @@ @@ -71,6 +65,27 @@
{{ scope.row.sender.Name !== '' ? scope.row.sender.Name : scope.row.sender.EmailAddress }}
+ + +
+ + + 疑似不安全 (点击信任) + + + + +
+
{{ scope.row.title }}
{{ scope.row.desc }}
@@ -91,11 +106,22 @@ @current-change="pageChange" /> + + + +
+

错误原因:

+ +
+ +