feat: 优化收发信安全提示与状态标签,增加历史发信收件人管理与租户隔离
Some checks are pending
Security Checks / security (push) Waiting to run
Some checks are pending
Security Checks / security (push) Waiting to run
This commit is contained in:
parent
b65fb4ec6c
commit
492f2f6322
158
backend/controllers/email/contacts.go
Normal file
158
backend/controllers/email/contacts.go
Normal file
@ -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
|
||||
})
|
||||
}
|
||||
135
backend/controllers/email/contacts_test.go
Normal file
135
backend/controllers/email/contacts_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
22
backend/db/migration/user_contacts.go
Normal file
22
backend/db/migration/user_contacts.go
Normal file
@ -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")`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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))))
|
||||
|
||||
24
backend/models/user_contact.go
Normal file
24
backend/models/user_contact.go
Normal file
@ -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" }
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -59,8 +59,25 @@
|
||||
<el-select
|
||||
v-model="ruleForm.receivers"
|
||||
multiple filterable allow-create :reserve-keyword="false"
|
||||
placeholder="请输入收件人地址"
|
||||
></el-select>
|
||||
placeholder="请输入收件人地址(输入后按回车,或从下方历史联系人选择)"
|
||||
>
|
||||
<el-option
|
||||
v-for="contact in contactOptions"
|
||||
:key="contact.email"
|
||||
:label="contact.name ? `${contact.name} <${contact.email}>` : contact.email"
|
||||
:value="contact.email"
|
||||
>
|
||||
<div class="contact-option-item">
|
||||
<div class="contact-option-info">
|
||||
<span class="contact-option-name" v-if="contact.name">{{ contact.name }}</span>
|
||||
<span class="contact-option-email">{{ contact.email }}</span>
|
||||
</div>
|
||||
<el-tooltip content="从历史记录删除" placement="top">
|
||||
<el-icon class="contact-del-icon" @click.stop="deleteContact(contact.email)"><Delete /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
@ -71,7 +88,18 @@
|
||||
v-model="ruleForm.cc"
|
||||
multiple filterable allow-create :reserve-keyword="false"
|
||||
placeholder="请输入抄送人地址"
|
||||
></el-select>
|
||||
>
|
||||
<el-option
|
||||
v-for="contact in contactOptions"
|
||||
:key="'cc-' + contact.email"
|
||||
:label="contact.name ? `${contact.name} <${contact.email}>` : contact.email"
|
||||
:value="contact.email"
|
||||
>
|
||||
<div class="contact-option-item">
|
||||
<span class="contact-option-email">{{ contact.email }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
@ -82,7 +110,18 @@
|
||||
v-model="ruleForm.bcc"
|
||||
multiple filterable allow-create :reserve-keyword="false"
|
||||
placeholder="请输入密送人地址"
|
||||
></el-select>
|
||||
>
|
||||
<el-option
|
||||
v-for="contact in contactOptions"
|
||||
:key="'bcc-' + contact.email"
|
||||
:label="contact.name ? `${contact.name} <${contact.email}>` : contact.email"
|
||||
:value="contact.email"
|
||||
>
|
||||
<div class="contact-option-item">
|
||||
<span class="contact-option-email">{{ contact.email }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
@ -111,7 +150,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePage, Paperclip, Position, ArrowDown, Toolbar, Editor, Document, Close } from './Editer.js'
|
||||
import { usePage, Paperclip, Position, ArrowDown, Toolbar, Editor, Document, Close, Delete } from './Editer.js'
|
||||
|
||||
const {
|
||||
lang,
|
||||
@ -131,7 +170,9 @@ const {
|
||||
editorConfig,
|
||||
handleCreated,
|
||||
fileList,
|
||||
delFile
|
||||
delFile,
|
||||
contactOptions,
|
||||
deleteContact
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -54,12 +54,6 @@
|
||||
<template #default="scope">
|
||||
<div class="status-indicator">
|
||||
<el-badge is-dot class="unread-dot" type="primary" v-if="!scope.row.is_read"></el-badge>
|
||||
<el-tooltip effect="dark" :content="lang.dangerous" placement="top-start" v-if="scope.row.dangerous">
|
||||
<el-icon color="#ef4444"><Warning /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip effect="dark" :content="scope.row.error" placement="top-start" v-if="scope.row.error !== ''">
|
||||
<el-icon color="#ef4444"><Warning /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -71,6 +65,27 @@
|
||||
<div class="mail-sender">
|
||||
{{ scope.row.sender.Name !== '' ? scope.row.sender.Name : scope.row.sender.EmailAddress }}
|
||||
</div>
|
||||
|
||||
<!-- 高辨识度安全与发送状态标签 -->
|
||||
<div class="mail-status-tags" v-if="scope.row.dangerous || isOutbox">
|
||||
<el-tooltip content="点击将该发件人添加至信任白名单,后续免除风险提示" placement="top" v-if="scope.row.dangerous">
|
||||
<el-tag type="danger" effect="dark" size="small" class="clickable-tag" @click.stop="trustSender(scope.row.sender.EmailAddress)">
|
||||
<el-icon><Warning /></el-icon> 疑似不安全 (点击信任)
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
|
||||
<template v-if="isOutbox">
|
||||
<el-tag type="success" effect="light" size="small" v-if="!scope.row.error || scope.row.error === ''">
|
||||
<el-icon><Check /></el-icon> 发送成功
|
||||
</el-tag>
|
||||
<el-tooltip :content="scope.row.error" placement="top" v-else>
|
||||
<el-tag type="danger" effect="light" size="small" class="clickable-tag" @click.stop="showError(scope.row)">
|
||||
<el-icon><Warning /></el-icon> 发送失败 (查看详情)
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mail-subject">{{ scope.row.title }}</div>
|
||||
<div class="mail-snippet">{{ scope.row.desc }}</div>
|
||||
</div>
|
||||
@ -91,11 +106,22 @@
|
||||
@current-change="pageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误详情对话框 -->
|
||||
<el-dialog v-model="errorDialogVisible" title="邮件发送失败详情" width="500px">
|
||||
<div class="error-dialog-body">
|
||||
<p class="error-label">错误原因:</p>
|
||||
<el-alert :title="currentErrorMsg" type="error" :closable="false" show-icon />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="errorDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePage, CircleCheck, Switch, Delete, View, Folder, EpArrowDownBold, EditPen, Warning } from './List.js'
|
||||
import { usePage, CircleCheck, Switch, Delete, View, Folder, EpArrowDownBold, EditPen, Warning, Check } from './List.js'
|
||||
|
||||
const {
|
||||
taskTableDataRef,
|
||||
@ -114,7 +140,12 @@ const {
|
||||
rowStyle,
|
||||
formatShortDate,
|
||||
totalPage,
|
||||
pageChange
|
||||
pageChange,
|
||||
isOutbox,
|
||||
trustSender,
|
||||
showError,
|
||||
errorDialogVisible,
|
||||
currentErrorMsg
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user