feat(filter): 实现发件人黑白名单数据库迁移、控制器与API路由及收件列表联动
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-13 04:33:42 +08:00
parent fcb64a34bd
commit bbb671c5ab
9 changed files with 498 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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