package webui import ( "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" ) // TestWebmailRulesAddAndRemove confirms a mailbox owner can create a filter rule for // their own mailbox through the self-service portal and remove it again — the same // underlying CRUD the admin-side page already uses. func TestWebmailRulesAddAndRemove(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "ruler@example.com", domains[0].ID, "ruler-password-1!") cookie := webmailLoginSession(t, app, mailboxID) form := "priority=0&condition_field=subject&condition_op=contains&condition_value=newsletter&action=move_to_folder&action_value=Newsletters" req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("add rule: status=%d body=%s", rec.Code, rec.Body.String()) } rules, err := app.DB.ListRulesForMailbox(mailboxID) if err != nil || len(rules) != 1 { t.Fatalf("expected 1 rule, got %d (err=%v)", len(rules), err) } if rules[0].ConditionValue != "newsletter" || rules[0].ActionValue != "Newsletters" { t.Errorf("unexpected rule: %+v", rules[0]) } // It actually takes effect at delivery time (reusing mailstore.ApplyRules, // exercised in internal/smtpserver's own tests) — here just confirm the list page // renders it and removal works. listReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/rules", nil) listReq.AddCookie(cookie) listRec := httptest.NewRecorder() mux.ServeHTTP(listRec, listReq) if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), "Newsletters") { t.Fatalf("expected the rule listed on the rules page, status=%d", listRec.Code) } rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/"+strconv.FormatInt(rules[0].ID, 10)+"/remove", nil) rmReq.AddCookie(cookie) rmRec := httptest.NewRecorder() mux.ServeHTTP(rmRec, rmReq) if rmRec.Code != http.StatusFound { t.Fatalf("remove rule: status=%d", rmRec.Code) } remaining, err := app.DB.ListRulesForMailbox(mailboxID) if err != nil || len(remaining) != 0 { t.Fatalf("expected no rules left, got %d (err=%v)", len(remaining), err) } } // TestWebmailRulesAddMultiCondition confirms the self-service rule builder's // parallel condition_field/op/value arrays are correctly parsed into a // multi-condition rule with the chosen match type. func TestWebmailRulesAddMultiCondition(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "ruler3@example.com", domains[0].ID, "ruler-password-1!") cookie := webmailLoginSession(t, app, mailboxID) form := url.Values{ "priority": {"0"}, "match_type": {"any"}, "condition_field": {"from", "subject"}, "condition_op": {"contains", "contains"}, "condition_value": {"boss@work.example", "urgent"}, "action": {"mark_as_spam"}, "action_value": {""}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("add multi-condition rule: status=%d body=%s", rec.Code, rec.Body.String()) } rules, err := app.DB.ListRulesForMailbox(mailboxID) if err != nil || len(rules) != 1 { t.Fatalf("expected 1 rule, got %d (err=%v)", len(rules), err) } conditions, matchType := rules[0].Conditions() if matchType != "any" || len(conditions) != 2 { t.Fatalf("expected 2 OR conditions, got matchType=%q conditions=%+v", matchType, conditions) } if rules[0].Action != "mark_as_spam" { t.Fatalf("expected mark_as_spam action, got %q", rules[0].Action) } } // TestWebmailRulesRejectsInvalidInput confirms a malformed rule submission is // rejected rather than silently stored. func TestWebmailRulesRejectsInvalidInput(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "ruler2@example.com", domains[0].ID, "ruler-password-1!") cookie := webmailLoginSession(t, app, mailboxID) // move_to_folder with no destination folder named. form := "priority=0&condition_field=subject&condition_op=contains&condition_value=x&action=move_to_folder&action_value=" req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("status=%d", rec.Code) } rules, err := app.DB.ListRulesForMailbox(mailboxID) if err != nil || len(rules) != 0 { t.Fatalf("expected the invalid rule rejected, got %d rules (err=%v)", len(rules), err) } } // TestWebmailRulesEditUpdatesInPlace confirms submitting the builder form with an // existing rule_id updates that rule (via UpdateRuleMulti) rather than creating a // second one — the same form/endpoint (/rules/save) serves both add and edit. func TestWebmailRulesEditUpdatesInPlace(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "ruler4@example.com", domains[0].ID, "ruler-password-1!") cookie := webmailLoginSession(t, app, mailboxID) ruleID, err := app.DB.CreateRule(mailboxID, 0, "subject", "contains", "old", "delete", "") if err != nil { t.Fatal(err) } form := url.Values{ "rule_id": {strconv.FormatInt(ruleID, 10)}, "name": {"Renamed"}, "priority": {"5"}, "match_type": {"all"}, "condition_field": {"subject"}, "condition_op": {"contains"}, "condition_value": {"new"}, "action": {"mark_read"}, "action_value": {""}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("edit rule: status=%d body=%s", rec.Code, rec.Body.String()) } rules, err := app.DB.ListRulesForMailbox(mailboxID) if err != nil || len(rules) != 1 { t.Fatalf("expected still exactly 1 rule (updated, not duplicated), got %d (err=%v)", len(rules), err) } if rules[0].ID != ruleID || rules[0].Name != "Renamed" || rules[0].Action != "mark_read" || rules[0].ConditionValue != "new" { t.Errorf("unexpected rule after edit: %+v", rules[0]) } } // TestWebmailRulesEditPersistsEnabledCheckbox reproduces a live bug: the edit // builder's "Enabled" checkbox visually reflected a disabled rule's state but // submitting an edit never actually persisted it — UpdateRuleMulti's column list // never included is_active, so editing (with or without touching the checkbox) never // changed enabled/disabled state at all, only the separate toggle button did. func TestWebmailRulesEditPersistsEnabledCheckbox(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "ruler6@example.com", domains[0].ID, "ruler-password-1!") cookie := webmailLoginSession(t, app, mailboxID) ruleID, err := app.DB.CreateRule(mailboxID, 0, "subject", "contains", "x", "delete", "") if err != nil { t.Fatal(err) } if err := app.DB.SetRuleActive(ruleID, mailboxID, false); err != nil { t.Fatal(err) } // Edit the disabled rule with the "Enabled" checkbox checked (is_active=1 present // in the form) — should re-enable it. form := url.Values{ "rule_id": {strconv.FormatInt(ruleID, 10)}, "priority": {"0"}, "match_type": {"all"}, "condition_field": {"subject"}, "condition_op": {"contains"}, "condition_value": {"x"}, "action": {"delete"}, "action_value": {""}, "is_active": {"1"}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("edit rule: status=%d", rec.Code) } rule, err := app.DB.GetRuleByID(mailboxID, ruleID) if err != nil || rule == nil || !rule.IsActive { t.Fatalf("expected rule re-enabled via edit form's checkbox, got %+v (err=%v)", rule, err) } // Edit again with the checkbox omitted entirely (as a real unchecked submits) — should disable it. form.Del("is_active") req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("edit rule: status=%d", rec.Code) } rule, err = app.DB.GetRuleByID(mailboxID, ruleID) if err != nil || rule == nil || rule.IsActive { t.Fatalf("expected rule disabled via edit form's omitted checkbox, got %+v (err=%v)", rule, err) } } // TestWebmailRulesToggleFlipsActive confirms the quick enable/disable toggle button // flips is_active without needing the full edit form. func TestWebmailRulesToggleFlipsActive(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "ruler5@example.com", domains[0].ID, "ruler-password-1!") cookie := webmailLoginSession(t, app, mailboxID) ruleID, err := app.DB.CreateRule(mailboxID, 0, "subject", "contains", "x", "delete", "") if err != nil { t.Fatal(err) } toggle := func() { req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/"+strconv.FormatInt(ruleID, 10)+"/toggle", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("toggle: status=%d", rec.Code) } } toggle() rules, _ := app.DB.ListRulesForMailbox(mailboxID) if rules[0].IsActive { t.Fatalf("expected rule disabled after first toggle, got IsActive=true") } toggle() rules, _ = app.DB.ListRulesForMailbox(mailboxID) if !rules[0].IsActive { t.Fatalf("expected rule re-enabled after second toggle, got IsActive=false") } } // TestWebmailRulesScopedToOwnMailbox confirms one mailbox owner can't remove another // mailbox's rule by guessing its ID. func TestWebmailRulesScopedToOwnMailbox(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() victimID := createTestMailboxWithPassword(t, app, "victim2@example.com", domains[0].ID, "victim-password-1!") attackerID := createTestMailboxWithPassword(t, app, "attacker2@example.com", domains[0].ID, "attacker-password-1!") ruleID, err := app.DB.CreateRule(victimID, 0, "subject", "contains", "x", "delete", "") if err != nil { t.Fatal(err) } attackerCookie := webmailLoginSession(t, app, attackerID) req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/"+strconv.FormatInt(ruleID, 10)+"/remove", nil) req.AddCookie(attackerCookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("status=%d", rec.Code) } stillThere, err := app.DB.ListRulesForMailbox(victimID) if err != nil || len(stillThere) != 1 { t.Fatalf("expected the victim's rule untouched, got %d (err=%v)", len(stillThere), err) } }