feat: 优化收发信安全提示与状态标签,增加历史发信收件人管理与租户隔离
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-12 09:37:52 +08:00
parent b65fb4ec6c
commit 492f2f6322
17 changed files with 615 additions and 25 deletions

View 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
})
}

View 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")
}
}

View File

@ -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,
})
}

View File

@ -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

View File

@ -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 {

View File

@ -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()),
}
}

View File

@ -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) {

View 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")`,
},
}
}

View File

@ -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)

View File

@ -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))))

View 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" }

View File

@ -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);
}

View File

@ -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
}

View File

@ -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>

View File

@ -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);
}

View File

@ -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
}

View File

@ -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>