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 @@
错误原因:
+