diff --git a/backend/controllers/email/contacts.go b/backend/controllers/email/contacts.go index 41ec5b6..d7764fd 100644 --- a/backend/controllers/email/contacts.go +++ b/backend/controllers/email/contacts.go @@ -3,12 +3,14 @@ package email import ( "encoding/json" "net/http" + "strconv" "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/address" "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" "xorm.io/xorm" @@ -23,8 +25,23 @@ type trustSenderRequest struct { 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 查询当前用户的历史写信收件人列表(多租户严格隔离) 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 err := db.Instance.Table(&models.UserContact{}). Where("user_id = ?", ctx.UserID). @@ -42,6 +59,10 @@ func ContactsList(ctx *context.Context, w http.ResponseWriter, req *http.Request // DeleteContact 删除当前用户的某项历史联系人记录 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 if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil { response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) @@ -67,6 +88,10 @@ func DeleteContact(ctx *context.Context, w http.ResponseWriter, req *http.Reques // TrustSender 将发件人添加至信任白名单,或从中移除 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 if err := json.NewDecoder(req.Body).Decode(&reqData); err != nil { response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) @@ -78,22 +103,30 @@ func TrustSender(ctx *context.Context, w http.ResponseWriter, req *http.Request) return } + now := time.Now() if reqData.Trusted { - var exists bool + var existing models.UserTrustedSender has, err := db.Instance.Table(&models.UserTrustedSender{}). Where("user_id = ? AND LOWER(email) = ?", ctx.UserID, email). - Exist(&models.UserTrustedSender{}) + Get(&existing) if err != nil { log.WithContext(ctx).WithError(err).Error("check trusted sender failed") response.NewErrorResponse(response.ServerError, "操作失败", "").FPrint(w) return } - exists = has - if !exists { + if has { + 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{ - UserID: ctx.UserID, - Email: email, - CreatedAt: time.Now(), + UserID: ctx.UserID, + Email: email, + FilterType: models.FilterTypeWhitelist, + CreatedAt: now, + UpdatedAt: &now, } if _, err := db.Instance.Insert(&item); err != nil { 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). Delete(&models.UserTrustedSender{}); err != nil { 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 } } diff --git a/backend/controllers/email/contacts_test.go b/backend/controllers/email/contacts_test.go index b4e4e4f..5030494 100644 --- a/backend/controllers/email/contacts_test.go +++ b/backend/controllers/email/contacts_test.go @@ -133,3 +133,107 @@ func TestTrustSenderToggle(t *testing.T) { 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") + } +} diff --git a/backend/controllers/email/list.go b/backend/controllers/email/list.go index 42917b1..1585303 100644 --- a/backend/controllers/email/list.go +++ b/backend/controllers/email/list.go @@ -31,15 +31,17 @@ type emailListResponse struct { } type emilItem struct { - ID int `json:"id"` - Title string `json:"title"` - Desc string `json:"desc"` - Datetime string `json:"datetime"` - IsRead bool `json:"is_read"` - Sender User `json:"sender"` - To []User `json:"to"` - Dangerous bool `json:"dangerous"` - Error string `json:"error"` + ID int `json:"id"` + Title string `json:"title"` + Desc string `json:"desc"` + Datetime string `json:"datetime"` + IsRead bool `json:"is_read"` + Sender User `json:"sender"` + To []User `json:"to"` + Dangerous bool `json:"dangerous"` + IsTrusted bool `json:"is_trusted"` + IsBlacklisted bool `json:"is_blacklisted"` + Error string `json:"error"` } type User struct { @@ -81,11 +83,17 @@ 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 + whitelistMap := make(map[string]bool) + blacklistMap := make(map[string]bool) + var senderFilters []models.UserTrustedSender + if err := db.Instance.Table(&models.UserTrustedSender{}).Where("user_id = ?", ctx.UserID).Find(&senderFilters); err == nil { + for _, item := range senderFilters { + addr := strings.ToLower(strings.TrimSpace(item.Email)) + if item.FilterType == models.FilterTypeBlacklist { + blacklistMap[addr] = true + } else { + whitelistMap[addr] = true + } } } @@ -109,20 +117,27 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } 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 - if trustedMap[strings.ToLower(strings.TrimSpace(sender.EmailAddress))] { + if isBlacklisted { + dangerous = true + } else if isTrusted { dangerous = false } items = append(items, &emilItem{ - ID: email.Id, - Title: email.Subject, - Desc: email.Text.String, - Datetime: email.SendDate.Format("2006-01-02 15:04:05"), - IsRead: email.IsRead == 1, - Sender: sender, - To: recipients, - Dangerous: dangerous, - Error: email.Error.String, + ID: email.Id, + Title: email.Subject, + Desc: email.Text.String, + Datetime: email.SendDate.Format("2006-01-02 15:04:05"), + IsRead: email.IsRead == 1, + Sender: sender, + To: recipients, + Dangerous: dangerous, + IsTrusted: isTrusted, + IsBlacklisted: isBlacklisted, + Error: email.Error.String, }) } diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 23ce153..6be3bf3 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -30,6 +30,7 @@ func DefaultMigrations() []Migration { DefineCustom(18, "expand_operation_log_actor_role", "expand_operation_log_actor_role_v1", applyOperationLogActorRoleSchema), DefineForDialects(19, "create_user_contacts_and_trusted_senders", userContactStatements()), DefineCustom(20, "expand_protocol_credential_ciphertext", "expand_protocol_credential_ciphertext_v1", applyProtocolCredentialCiphertextSchema), + DefineCustom(21, "expand_trusted_sender_blacklist", "expand_trusted_sender_blacklist_v1", applyTrustedSenderBlacklistSchema), } } diff --git a/backend/db/migration/registry_test.go b/backend/db/migration/registry_test.go index 09b3d14..513ba4a 100644 --- a/backend/db/migration/registry_test.go +++ b/backend/db/migration/registry_test.go @@ -62,6 +62,15 @@ func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) { if !exists { 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 { @@ -285,6 +294,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) { 18: "17622df3406657f199d78a3a1aa1f3a8693baf85aa7707910cc6d6cb44c13ee4", 19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16", 20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725", + 21: "ac1a0b3be90128c0e0512d2cec858f8cd7e70abc5ff4734d7113b9674f08dbb0", } migrations := DefaultMigrations() if len(migrations) != len(expected) { diff --git a/backend/db/migration/trusted_sender_blacklist.go b/backend/db/migration/trusted_sender_blacklist.go new file mode 100644 index 0000000..a66493a --- /dev/null +++ b/backend/db/migration/trusted_sender_blacklist.go @@ -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) +} diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index 11764ff..c455a13 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -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/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) + 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/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) @@ -89,6 +92,25 @@ func registerV1MethodRoute(mux *http.ServeMux, path string, legacyPath string, m 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) { get := contextIterceptor(rewriteV1Path("/api/security/credentials/question", controllers.GetSecurityQuestion)) post := limitBody(16<<10, contextIterceptor(rewriteV1Path("/api/security/credentials/question", controllers.SetSecurityQuestion))) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 15d3a76..96c59a8 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -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/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/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/rule/get", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.GetRule)))) mux.HandleFunc("/api/rule/add", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpsertRule)))) diff --git a/backend/models/user_contact.go b/backend/models/user_contact.go index 4e3af78..786d931 100644 --- a/backend/models/user_contact.go +++ b/backend/models/user_contact.go @@ -2,7 +2,7 @@ package models import "time" -// UserContact 保存单个用户的历史写信收件人,用于写信页自动补全与联系人管理 +// 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:"-"` @@ -13,12 +13,19 @@ type UserContact struct { func (UserContact) TableName() string { return "user_contact" } -// UserTrustedSender 保存单个用户手动标记信任的发件人白名单,免除安全警告 +const ( + FilterTypeWhitelist = "whitelist" + FilterTypeBlacklist = "blacklist" +) + +// 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"` + 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"` + 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"` + UpdatedAt *time.Time `xorm:"updated_at datetime updated null" json:"updated_at,omitempty"` } func (UserTrustedSender) TableName() string { return "user_trusted_sender" }