feat(filter): 实现发件人黑白名单数据库迁移、控制器与API路由及收件列表联动
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
fcb64a34bd
commit
bbb671c5ab
@ -3,12 +3,14 @@ package email
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.pcux.cn/xuans/Smail/db"
|
"git.pcux.cn/xuans/Smail/db"
|
||||||
"git.pcux.cn/xuans/Smail/dto/response"
|
"git.pcux.cn/xuans/Smail/dto/response"
|
||||||
"git.pcux.cn/xuans/Smail/models"
|
"git.pcux.cn/xuans/Smail/models"
|
||||||
|
"git.pcux.cn/xuans/Smail/utils/address"
|
||||||
"git.pcux.cn/xuans/Smail/utils/context"
|
"git.pcux.cn/xuans/Smail/utils/context"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
@ -23,8 +25,23 @@ type trustSenderRequest struct {
|
|||||||
Trusted bool `json:"trusted"`
|
Trusted bool `json:"trusted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type senderFilterRequest struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FilterType string `json:"filter_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toggleSenderFilterRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
}
|
||||||
|
|
||||||
// ContactsList 查询当前用户的历史写信收件人列表(多租户严格隔离)
|
// ContactsList 查询当前用户的历史写信收件人列表(多租户严格隔离)
|
||||||
func ContactsList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
func ContactsList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ctx == nil || ctx.UserID <= 0 {
|
||||||
|
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
var contacts []models.UserContact
|
var contacts []models.UserContact
|
||||||
err := db.Instance.Table(&models.UserContact{}).
|
err := db.Instance.Table(&models.UserContact{}).
|
||||||
Where("user_id = ?", ctx.UserID).
|
Where("user_id = ?", ctx.UserID).
|
||||||
@ -42,6 +59,10 @@ func ContactsList(ctx *context.Context, w http.ResponseWriter, req *http.Request
|
|||||||
|
|
||||||
// DeleteContact 删除当前用户的某项历史联系人记录
|
// DeleteContact 删除当前用户的某项历史联系人记录
|
||||||
func DeleteContact(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
func DeleteContact(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ctx == nil || ctx.UserID <= 0 {
|
||||||
|
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
var reqData deleteContactRequest
|
var reqData deleteContactRequest
|
||||||
if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil {
|
if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil {
|
||||||
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
|
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
|
||||||
@ -67,6 +88,10 @@ func DeleteContact(ctx *context.Context, w http.ResponseWriter, req *http.Reques
|
|||||||
|
|
||||||
// TrustSender 将发件人添加至信任白名单,或从中移除
|
// TrustSender 将发件人添加至信任白名单,或从中移除
|
||||||
func TrustSender(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
func TrustSender(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ctx == nil || ctx.UserID <= 0 {
|
||||||
|
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
var reqData trustSenderRequest
|
var reqData trustSenderRequest
|
||||||
if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil {
|
if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil {
|
||||||
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
|
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
|
||||||
@ -78,22 +103,30 @@ func TrustSender(ctx *context.Context, w http.ResponseWriter, req *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
if reqData.Trusted {
|
if reqData.Trusted {
|
||||||
var exists bool
|
var existing models.UserTrustedSender
|
||||||
has, err := db.Instance.Table(&models.UserTrustedSender{}).
|
has, err := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email).
|
Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email).
|
||||||
Exist(&models.UserTrustedSender{})
|
Get(&existing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithContext(ctx).WithError(err).Error("check trusted sender failed")
|
log.WithContext(ctx).WithError(err).Error("check trusted sender failed")
|
||||||
response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w)
|
response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exists = has
|
if has {
|
||||||
if !exists {
|
if existing.FilterType != models.FilterTypeWhitelist {
|
||||||
|
existing.FilterType = models.FilterTypeWhitelist
|
||||||
|
existing.UpdatedAt = &now
|
||||||
|
_, _ = db.Instance.ID(existing.ID).Cols("filter_type", "updated_at").Update(&existing)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
item := models.UserTrustedSender{
|
item := models.UserTrustedSender{
|
||||||
UserID: ctx.UserID,
|
UserID: ctx.UserID,
|
||||||
Email: email,
|
Email: email,
|
||||||
CreatedAt: time.Now(),
|
FilterType: models.FilterTypeWhitelist,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: &now,
|
||||||
}
|
}
|
||||||
if _, err := db.Instance.Insert(&item); err != nil {
|
if _, err := db.Instance.Insert(&item); err != nil {
|
||||||
log.WithContext(ctx).WithError(err).Error("insert trusted sender failed")
|
log.WithContext(ctx).WithError(err).Error("insert trusted sender failed")
|
||||||
@ -106,7 +139,227 @@ func TrustSender(ctx *context.Context, w http.ResponseWriter, req *http.Request)
|
|||||||
Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email).
|
Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email).
|
||||||
Delete(&models.UserTrustedSender{}); err != nil {
|
Delete(&models.UserTrustedSender{}); err != nil {
|
||||||
log.WithContext(ctx).WithError(err).Error("remove trusted sender failed")
|
log.WithContext(ctx).WithError(err).Error("remove trusted sender failed")
|
||||||
response.NewErrorResponse(response.ServerError, "取消信任发件人失败", "").FPrint(w)
|
response.NewErrorResponse(response.ServerError, "添加信任发件人失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.NewSuccessResponse("ok").FPrint(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SenderFiltersList 查询当前用户的发件人黑白名单列表(支持分页、搜索、类型筛选与统计)
|
||||||
|
func SenderFiltersList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ctx == nil || ctx.UserID <= 0 {
|
||||||
|
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := req.URL.Query()
|
||||||
|
filterType := strings.ToLower(strings.TrimSpace(query.Get("filter_type")))
|
||||||
|
keyword := strings.ToLower(strings.TrimSpace(query.Get("keyword")))
|
||||||
|
page, _ := strconv.Atoi(query.Get("page"))
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize, _ := strconv.Atoi(query.Get("page_size"))
|
||||||
|
if pageSize <= 0 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
session := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ?", ctx.UserID)
|
||||||
|
if filterType == models.FilterTypeWhitelist || filterType == models.FilterTypeBlacklist {
|
||||||
|
session = session.And("filter_type = ?", filterType)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
session = session.And("LOWER(email) LIKE ?", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []models.UserTrustedSender
|
||||||
|
total, err := session.OrderBy("created_at DESC").Limit(pageSize, offset).FindAndCount(&list)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("query sender filters failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "查询过滤名单失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
whitelistCount, _ := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
|
Where("user_id = ? AND filter_type = ?", ctx.UserID, models.FilterTypeWhitelist).
|
||||||
|
Count(&models.UserTrustedSender{})
|
||||||
|
blacklistCount, _ := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
|
Where("user_id = ? AND filter_type = ?", ctx.UserID, models.FilterTypeBlacklist).
|
||||||
|
Count(&models.UserTrustedSender{})
|
||||||
|
|
||||||
|
response.NewSuccessResponse(map[string]any{
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
"whitelist_count": whitelistCount,
|
||||||
|
"blacklist_count": blacklistCount,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
}).FPrint(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSenderFilter 添加或更新当前用户的白名单/黑名单
|
||||||
|
func AddSenderFilter(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ctx == nil || ctx.UserID <= 0 {
|
||||||
|
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqData senderFilterRequest
|
||||||
|
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 == "" || !address.IsValidEmailAddress(email) {
|
||||||
|
response.NewErrorResponse(response.ParamsError, "邮箱格式不正确", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filterType := strings.ToLower(strings.TrimSpace(reqData.FilterType))
|
||||||
|
if filterType == "" {
|
||||||
|
filterType = models.FilterTypeWhitelist
|
||||||
|
}
|
||||||
|
if filterType != models.FilterTypeWhitelist && filterType != models.FilterTypeBlacklist {
|
||||||
|
response.NewErrorResponse(response.ParamsError, "名单类别必须为白名单或黑名单", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var existing models.UserTrustedSender
|
||||||
|
has, err := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
|
Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email).
|
||||||
|
Get(&existing)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("check sender filter exists failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if has {
|
||||||
|
existing.FilterType = filterType
|
||||||
|
existing.UpdatedAt = &now
|
||||||
|
if _, err := db.Instance.ID(existing.ID).Cols("filter_type", "updated_at").Update(&existing); err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("update sender filter failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "更新过滤名单失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item := models.UserTrustedSender{
|
||||||
|
UserID: ctx.UserID,
|
||||||
|
Email: email,
|
||||||
|
FilterType: filterType,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: &now,
|
||||||
|
}
|
||||||
|
if _, err := db.Instance.Insert(&item); err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("insert sender filter failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "添加过滤名单失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.NewSuccessResponse("ok").FPrint(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSenderFilter 删除发件人黑白名单条目(支持ID或邮箱地址删除)
|
||||||
|
func DeleteSenderFilter(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ctx == nil || ctx.UserID <= 0 {
|
||||||
|
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqData senderFilterRequest
|
||||||
|
if req.Body != nil {
|
||||||
|
_ = json.NewDecoder(req.Body).Decode(&reqData)
|
||||||
|
}
|
||||||
|
email := strings.ToLower(strings.TrimSpace(reqData.Email))
|
||||||
|
if reqData.ID <= 0 && email == "" {
|
||||||
|
if idVal, err := strconv.Atoi(req.URL.Query().Get("id")); err == nil && idVal > 0 {
|
||||||
|
reqData.ID = idVal
|
||||||
|
}
|
||||||
|
if qEmail := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("email"))); qEmail != "" {
|
||||||
|
email = qEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ?", ctx.UserID)
|
||||||
|
if reqData.ID > 0 {
|
||||||
|
session = session.And("id = ?", reqData.ID)
|
||||||
|
} else if email != "" {
|
||||||
|
session = session.And("LOWER(email) = ?", email)
|
||||||
|
} else {
|
||||||
|
response.NewErrorResponse(response.ParamsError, "请指定要删除的记录ID或发件人邮箱", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := session.Delete(&models.UserTrustedSender{}); err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("delete sender filter failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.NewSuccessResponse("ok").FPrint(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleSenderFilter 快速切换发件人的白名单/黑名单状态
|
||||||
|
func ToggleSenderFilter(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ctx == nil || ctx.UserID <= 0 {
|
||||||
|
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqData toggleSenderFilterRequest
|
||||||
|
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 == "" || !address.IsValidEmailAddress(email) {
|
||||||
|
response.NewErrorResponse(response.ParamsError, "邮箱格式不正确", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var existing models.UserTrustedSender
|
||||||
|
has, err := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
|
Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email).
|
||||||
|
Get(&existing)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("query sender filter for toggle failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetType := strings.ToLower(strings.TrimSpace(reqData.TargetType))
|
||||||
|
if !has {
|
||||||
|
if targetType != models.FilterTypeWhitelist && targetType != models.FilterTypeBlacklist {
|
||||||
|
targetType = models.FilterTypeBlacklist
|
||||||
|
}
|
||||||
|
item := models.UserTrustedSender{
|
||||||
|
UserID: ctx.UserID,
|
||||||
|
Email: email,
|
||||||
|
FilterType: targetType,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: &now,
|
||||||
|
}
|
||||||
|
if _, err := db.Instance.Insert(&item); err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("insert toggled sender filter failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if targetType == "" {
|
||||||
|
if existing.FilterType == models.FilterTypeBlacklist {
|
||||||
|
targetType = models.FilterTypeWhitelist
|
||||||
|
} else {
|
||||||
|
targetType = models.FilterTypeBlacklist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing.FilterType = targetType
|
||||||
|
existing.UpdatedAt = &now
|
||||||
|
if _, err := db.Instance.ID(existing.ID).Cols("filter_type", "updated_at").Update(&existing); err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("update toggled sender filter failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,3 +133,107 @@ func TestTrustSenderToggle(t *testing.T) {
|
|||||||
t.Fatalf("expected trusted sender to be removed")
|
t.Fatalf("expected trusted sender to be removed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSenderFiltersCRUDAndTenantIsolation(t *testing.T) {
|
||||||
|
setupContactsTestDB(t)
|
||||||
|
ctxUser1 := &context.Context{UserID: 1}
|
||||||
|
ctxUser2 := &context.Context{UserID: 2}
|
||||||
|
|
||||||
|
// 1. User 1 添加白名单
|
||||||
|
addBody, _ := json.Marshal(senderFilterRequest{Email: "partner@example.com", FilterType: models.FilterTypeWhitelist})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/email/sender-filters", bytes.NewReader(addBody))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
AddSenderFilter(ctxUser1, w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User 1 添加黑名单
|
||||||
|
addBody2, _ := json.Marshal(senderFilterRequest{Email: "spammer@badsite.com", FilterType: models.FilterTypeBlacklist})
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/email/sender-filters", bytes.NewReader(addBody2))
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
AddSenderFilter(ctxUser1, w2, req2)
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w2.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. User 2 添加名单(用于多租户隔离验证)
|
||||||
|
addBody3, _ := json.Marshal(senderFilterRequest{Email: "secret@user2.com", FilterType: models.FilterTypeWhitelist})
|
||||||
|
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/email/sender-filters", bytes.NewReader(addBody3))
|
||||||
|
w3 := httptest.NewRecorder()
|
||||||
|
AddSenderFilter(ctxUser2, w3, req3)
|
||||||
|
if w3.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w3.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. User 1 查询列表(应有 2 条:1 白 1 黑,统计正确)
|
||||||
|
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/email/sender-filters", nil)
|
||||||
|
listW := httptest.NewRecorder()
|
||||||
|
SenderFiltersList(ctxUser1, listW, listReq)
|
||||||
|
if listW.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", listW.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
List []models.UserTrustedSender `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
WhitelistCount int64 `json:"whitelist_count"`
|
||||||
|
BlacklistCount int64 `json:"blacklist_count"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if listResp.Data.Total != 2 || listResp.Data.WhitelistCount != 1 || listResp.Data.BlacklistCount != 1 {
|
||||||
|
t.Fatalf("unexpected counts: total=%d, white=%d, black=%d",
|
||||||
|
listResp.Data.Total, listResp.Data.WhitelistCount, listResp.Data.BlacklistCount)
|
||||||
|
}
|
||||||
|
for _, item := range listResp.Data.List {
|
||||||
|
if item.Email == "secret@user2.com" {
|
||||||
|
t.Fatalf("tenant isolation violation: user1 saw user2 filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 快速切换(Toggle)白名单 -> 黑名单
|
||||||
|
toggleBody, _ := json.Marshal(toggleSenderFilterRequest{Email: "partner@example.com"})
|
||||||
|
toggleReq := httptest.NewRequest(http.MethodPost, "/api/v1/email/sender-filters/toggle", bytes.NewReader(toggleBody))
|
||||||
|
toggleW := httptest.NewRecorder()
|
||||||
|
ToggleSenderFilter(ctxUser1, toggleW, toggleReq)
|
||||||
|
if toggleW.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", toggleW.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated models.UserTrustedSender
|
||||||
|
has, err := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
|
Where("user_id = ? AND LOWER(email) = ?", 1, "partner@example.com").
|
||||||
|
Get(&updated)
|
||||||
|
if err != nil || !has || updated.FilterType != models.FilterTypeBlacklist {
|
||||||
|
t.Fatalf("expected partner@example.com to be toggled to blacklist, got filter_type=%s", updated.FilterType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 删除黑名单条目
|
||||||
|
delBody, _ := json.Marshal(senderFilterRequest{Email: "spammer@badsite.com"})
|
||||||
|
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/email/sender-filters", bytes.NewReader(delBody))
|
||||||
|
delW := httptest.NewRecorder()
|
||||||
|
DeleteSenderFilter(ctxUser1, delW, delReq)
|
||||||
|
if delW.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", delW.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSpammer, _ := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
|
Where("user_id = ? AND LOWER(email) = ?", 1, "spammer@badsite.com").
|
||||||
|
Exist(&models.UserTrustedSender{})
|
||||||
|
if hasSpammer {
|
||||||
|
t.Fatalf("expected spammer to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 再次确认 User 2 数据完整未受影响
|
||||||
|
hasU2, _ := db.Instance.Table(&models.UserTrustedSender{}).
|
||||||
|
Where("user_id = ? AND LOWER(email) = ?", 2, "secret@user2.com").
|
||||||
|
Exist(&models.UserTrustedSender{})
|
||||||
|
if !hasU2 {
|
||||||
|
t.Fatalf("user 2 data was corrupted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,8 @@ type emilItem struct {
|
|||||||
Sender User `json:"sender"`
|
Sender User `json:"sender"`
|
||||||
To []User `json:"to"`
|
To []User `json:"to"`
|
||||||
Dangerous bool `json:"dangerous"`
|
Dangerous bool `json:"dangerous"`
|
||||||
|
IsTrusted bool `json:"is_trusted"`
|
||||||
|
IsBlacklisted bool `json:"is_blacklisted"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,11 +83,17 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
|||||||
response.NewErrorResponse(response.ServerError, "读取邮件列表失败", "").FPrint(w)
|
response.NewErrorResponse(response.ServerError, "读取邮件列表失败", "").FPrint(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
trustedMap := make(map[string]bool)
|
whitelistMap := make(map[string]bool)
|
||||||
var trustedSenders []models.UserTrustedSender
|
blacklistMap := make(map[string]bool)
|
||||||
if err := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ?", ctx.UserID).Find(&trustedSenders); err == nil {
|
var senderFilters []models.UserTrustedSender
|
||||||
for _, item := range trustedSenders {
|
if err := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ?", ctx.UserID).Find(&senderFilters); err == nil {
|
||||||
trustedMap[strings.ToLower(strings.TrimSpace(item.Email))] = true
|
for _, item := range senderFilters {
|
||||||
|
addr := strings.ToLower(strings.TrimSpace(item.Email))
|
||||||
|
if item.FilterType == models.FilterTypeBlacklist {
|
||||||
|
blacklistMap[addr] = true
|
||||||
|
} else {
|
||||||
|
whitelistMap[addr] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,8 +117,13 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
authentication := parsemail.NewEmailAuthentication(email.SPFCheck == 1, email.DKIMCheck == 1)
|
authentication := parsemail.NewEmailAuthentication(email.SPFCheck == 1, email.DKIMCheck == 1)
|
||||||
|
senderAddr := strings.ToLower(strings.TrimSpace(sender.EmailAddress))
|
||||||
|
isTrusted := whitelistMap[senderAddr]
|
||||||
|
isBlacklisted := blacklistMap[senderAddr]
|
||||||
dangerous := authentication.Dangerous
|
dangerous := authentication.Dangerous
|
||||||
if trustedMap[strings.ToLower(strings.TrimSpace(sender.EmailAddress))] {
|
if isBlacklisted {
|
||||||
|
dangerous = true
|
||||||
|
} else if isTrusted {
|
||||||
dangerous = false
|
dangerous = false
|
||||||
}
|
}
|
||||||
items = append(items, &emilItem{
|
items = append(items, &emilItem{
|
||||||
@ -122,6 +135,8 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
|||||||
Sender: sender,
|
Sender: sender,
|
||||||
To: recipients,
|
To: recipients,
|
||||||
Dangerous: dangerous,
|
Dangerous: dangerous,
|
||||||
|
IsTrusted: isTrusted,
|
||||||
|
IsBlacklisted: isBlacklisted,
|
||||||
Error: email.Error.String,
|
Error: email.Error.String,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ func DefaultMigrations() []Migration {
|
|||||||
DefineCustom(18, "expand_operation_log_actor_role", "expand_operation_log_actor_role_v1", applyOperationLogActorRoleSchema),
|
DefineCustom(18, "expand_operation_log_actor_role", "expand_operation_log_actor_role_v1", applyOperationLogActorRoleSchema),
|
||||||
DefineForDialects(19, "create_user_contacts_and_trusted_senders", userContactStatements()),
|
DefineForDialects(19, "create_user_contacts_and_trusted_senders", userContactStatements()),
|
||||||
DefineCustom(20, "expand_protocol_credential_ciphertext", "expand_protocol_credential_ciphertext_v1", applyProtocolCredentialCiphertextSchema),
|
DefineCustom(20, "expand_protocol_credential_ciphertext", "expand_protocol_credential_ciphertext_v1", applyProtocolCredentialCiphertextSchema),
|
||||||
|
DefineCustom(21, "expand_trusted_sender_blacklist", "expand_trusted_sender_blacklist_v1", applyTrustedSenderBlacklistSchema),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,15 @@ func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) {
|
|||||||
if !exists {
|
if !exists {
|
||||||
t.Fatal("column operation_log.actor_role was not created")
|
t.Fatal("column operation_log.actor_role was not created")
|
||||||
}
|
}
|
||||||
|
for _, column := range []string{"filter_type", "updated_at"} {
|
||||||
|
exists, err := columnExists(context.Background(), db, DialectSQLite, "user_trusted_sender", column)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Fatalf("column user_trusted_sender.%s was not created", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sqliteIndexExists(t *testing.T, db *sql.DB, index string) bool {
|
func sqliteIndexExists(t *testing.T, db *sql.DB, index string) bool {
|
||||||
@ -285,6 +294,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
|
|||||||
18: "17622df3406657f199d78a3a1aa1f3a8693baf85aa7707910cc6d6cb44c13ee4",
|
18: "17622df3406657f199d78a3a1aa1f3a8693baf85aa7707910cc6d6cb44c13ee4",
|
||||||
19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16",
|
19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16",
|
||||||
20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725",
|
20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725",
|
||||||
|
21: "ac1a0b3be90128c0e0512d2cec858f8cd7e70abc5ff4734d7113b9674f08dbb0",
|
||||||
}
|
}
|
||||||
migrations := DefaultMigrations()
|
migrations := DefaultMigrations()
|
||||||
if len(migrations) != len(expected) {
|
if len(migrations) != len(expected) {
|
||||||
|
|||||||
33
backend/db/migration/trusted_sender_blacklist.go
Normal file
33
backend/db/migration/trusted_sender_blacklist.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyTrustedSenderBlacklistSchema(ctx context.Context, exec executor, dialect Dialect) error {
|
||||||
|
filterTypeDefinitions := map[Dialect]string{
|
||||||
|
DialectSQLite: "TEXT NOT NULL DEFAULT 'whitelist'",
|
||||||
|
DialectMySQL: "VARCHAR(20) NOT NULL DEFAULT 'whitelist'",
|
||||||
|
DialectPostgres: "VARCHAR(20) NOT NULL DEFAULT 'whitelist'",
|
||||||
|
}
|
||||||
|
updatedAtDefinitions := map[Dialect]string{
|
||||||
|
DialectSQLite: "DATETIME NULL",
|
||||||
|
DialectMySQL: "DATETIME(6) NULL",
|
||||||
|
DialectPostgres: "TIMESTAMPTZ NULL",
|
||||||
|
}
|
||||||
|
|
||||||
|
ftDef, ok := filterTypeDefinitions[dialect]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unsupported migration dialect %q for filter_type", dialect)
|
||||||
|
}
|
||||||
|
if err := ensureColumn(ctx, exec, dialect, "user_trusted_sender", "filter_type", ftDef); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
upDef, ok := updatedAtDefinitions[dialect]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unsupported migration dialect %q for updated_at", dialect)
|
||||||
|
}
|
||||||
|
return ensureColumn(ctx, exec, dialect, "user_trusted_sender", "updated_at", upDef)
|
||||||
|
}
|
||||||
@ -62,6 +62,9 @@ func registerV1Routes(mux *http.ServeMux) {
|
|||||||
registerV1MethodRoute(mux, "/api/v1/email/contacts", "/api/email/contacts", http.MethodGet, 0, email.ContactsList)
|
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/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/email/trust-sender", "/api/email/trust-sender", http.MethodPost, 16<<10, email.TrustSender)
|
||||||
|
registerV1SenderFiltersRoute(mux)
|
||||||
|
registerV1MethodRoute(mux, "/api/v1/email/sender-filters/delete", "/api/email/sender-filters/delete", http.MethodPost, 16<<10, email.DeleteSenderFilter)
|
||||||
|
registerV1MethodRoute(mux, "/api/v1/email/sender-filters/toggle", "/api/email/sender-filters/toggle", http.MethodPost, 16<<10, email.ToggleSenderFilter)
|
||||||
registerV1MethodRoute(mux, "/api/v1/settings/modify_password", "/api/settings/modify_password", http.MethodPost, 64<<10, controllers.ModifyPassword)
|
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/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)
|
registerV1MethodRoute(mux, "/api/v1/rule/add", "/api/rule/add", http.MethodPost, 64<<10, controllers.UpsertRule)
|
||||||
@ -89,6 +92,25 @@ func registerV1MethodRoute(mux *http.ServeMux, path string, legacyPath string, m
|
|||||||
mux.HandleFunc(path, onlyMethod(method, wrapped))
|
mux.HandleFunc(path, onlyMethod(method, wrapped))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerV1SenderFiltersRoute(mux *http.ServeMux) {
|
||||||
|
get := contextIterceptor(rewriteV1Path("/api/email/sender-filters", email.SenderFiltersList))
|
||||||
|
post := limitBody(16<<10, contextIterceptor(rewriteV1Path("/api/email/sender-filters", email.AddSenderFilter)))
|
||||||
|
del := limitBody(16<<10, contextIterceptor(rewriteV1Path("/api/email/sender-filters", email.DeleteSenderFilter)))
|
||||||
|
mux.HandleFunc("/api/v1/email/sender-filters", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
get.ServeHTTP(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
post.ServeHTTP(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
del.ServeHTTP(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func registerV1CredentialsQuestionRoute(mux *http.ServeMux) {
|
func registerV1CredentialsQuestionRoute(mux *http.ServeMux) {
|
||||||
get := contextIterceptor(rewriteV1Path("/api/security/credentials/question", controllers.GetSecurityQuestion))
|
get := contextIterceptor(rewriteV1Path("/api/security/credentials/question", controllers.GetSecurityQuestion))
|
||||||
post := limitBody(16<<10, contextIterceptor(rewriteV1Path("/api/security/credentials/question", controllers.SetSecurityQuestion)))
|
post := limitBody(16<<10, contextIterceptor(rewriteV1Path("/api/security/credentials/question", controllers.SetSecurityQuestion)))
|
||||||
|
|||||||
@ -92,6 +92,21 @@ func router(mux *http.ServeMux) error {
|
|||||||
mux.HandleFunc("/api/email/contacts", onlyMethod(http.MethodGet, contextIterceptor(email.ContactsList)))
|
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/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/email/trust-sender", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(email.TrustSender))))
|
||||||
|
mux.HandleFunc("/api/email/sender-filters", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
contextIterceptor(email.SenderFiltersList)(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
limitBody(16<<10, contextIterceptor(email.AddSenderFilter))(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
limitBody(16<<10, contextIterceptor(email.DeleteSenderFilter))(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/email/sender-filters/delete", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(email.DeleteSenderFilter))))
|
||||||
|
mux.HandleFunc("/api/email/sender-filters/toggle", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(email.ToggleSenderFilter))))
|
||||||
mux.HandleFunc("/api/settings/modify_password", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.ModifyPassword))))
|
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/get", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.GetRule))))
|
||||||
mux.HandleFunc("/api/rule/add", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpsertRule))))
|
mux.HandleFunc("/api/rule/add", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpsertRule))))
|
||||||
|
|||||||
@ -2,7 +2,7 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// UserContact 保存单个用户的历史写信收件人,用于写信页自动补全与联系人管理
|
// UserContact 保存单个用户的厇史写信收仴人,用于写信页自动补全与遗系人管理
|
||||||
type UserContact struct {
|
type UserContact struct {
|
||||||
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"`
|
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"`
|
||||||
UserID int `xorm:"user_id bigint not null" json:"-"`
|
UserID int `xorm:"user_id bigint not null" json:"-"`
|
||||||
@ -13,12 +13,19 @@ type UserContact struct {
|
|||||||
|
|
||||||
func (UserContact) TableName() string { return "user_contact" }
|
func (UserContact) TableName() string { return "user_contact" }
|
||||||
|
|
||||||
// UserTrustedSender 保存单个用户手动标记信任的发件人白名单,免除安全警告
|
const (
|
||||||
|
FilterTypeWhitelist = "whitelist"
|
||||||
|
FilterTypeBlacklist = "blacklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserTrustedSender 保存单个用户标记的白名单(信任)与黑名单发件人,控制收信安全警告与拒信/风险标议
|
||||||
type UserTrustedSender struct {
|
type UserTrustedSender struct {
|
||||||
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"`
|
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"`
|
||||||
UserID int `xorm:"user_id bigint not null" json:"-"`
|
UserID int `xorm:"user_id bigint not null" json:"-"`
|
||||||
Email string `xorm:"email varchar(255) notnull" json:"email"`
|
Email string `xorm:"email varchar(255) notnull" json:"email"`
|
||||||
|
FilterType string `xorm:"filter_type varchar(20) notnull default('whitelist')" json:"filter_type"`
|
||||||
CreatedAt time.Time `xorm:"created_at datetime created notnull" json:"created_at"`
|
CreatedAt time.Time `xorm:"created_at datetime created notnull" json:"created_at"`
|
||||||
|
UpdatedAt *time.Time `xorm:"updated_at datetime updated null" json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UserTrustedSender) TableName() string { return "user_trusted_sender" }
|
func (UserTrustedSender) TableName() string { return "user_trusted_sender" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user