package mail import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/listunsubscribe" "github.com/ultisuite/ulti-backend/internal/mail/rules" ) const ( testExternalID = "ext-user-1" testExternalID2 = "ext-user-2" testUserID = "user-uuid-1" testMailAccountID = "550e8400-e29b-41d4-a716-446655440000" ) type fakeMailService struct { messages map[string]map[string]any deleted map[string]bool sent []sendMessageRequest } func newFakeMailService() *fakeMailService { return &fakeMailService{ messages: map[string]map[string]any{ "msg-1": { "id": "msg-1", "subject": "Hello", "snippet": "Preview text", "flags": []string{"unread"}, "labels": []string{"inbox"}, }, }, deleted: make(map[string]bool), } } func (f *fakeMailService) ResolveUserID(_ context.Context, externalID string) (string, error) { if externalID != testExternalID { return "", ErrUserNotProvisioned } return testUserID, nil } func (f *fakeMailService) GetMailSettings(_ context.Context, externalID string) (MailSettings, error) { if externalID != testExternalID { return MailSettings{}, ErrUserNotProvisioned } return defaultMailSettings(), nil } func (f *fakeMailService) UpdateMailSettings(_ context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error) { if externalID != testExternalID { return MailSettings{}, ErrUserNotProvisioned } current := defaultMailSettings() if req.Density != nil { current.Density = *req.Density } if req.ThemeMode != nil { current.ThemeMode = *req.ThemeMode } if req.BackgroundID != nil { current.BackgroundID = *req.BackgroundID } if req.InboxSort != nil { current.InboxSort = *req.InboxSort } if req.ReadingPane != nil { current.ReadingPane = *req.ReadingPane } if req.ConversationMode != nil { current.ConversationMode = *req.ConversationMode } return current, nil } func (f *fakeMailService) ListUnifiedFolders(_ context.Context, externalID, _ string, params query.ListParams) (UnifiedFoldersList, error) { if externalID != testExternalID { return UnifiedFoldersList{}, ErrUserNotProvisioned } total := int64(0) return UnifiedFoldersList{Pagination: params.Meta(&total)}, nil } func (f *fakeMailService) CreateUnifiedFolder(_ context.Context, _ string, _ *createUnifiedFolderRequest) (string, error) { return "uf-1", nil } func (f *fakeMailService) UpdateUnifiedFolder(_ context.Context, externalID, _ string, _ *updateUnifiedFolderRequest) error { if externalID != testExternalID { return ErrUserNotProvisioned } return nil } func (f *fakeMailService) DeleteUnifiedFolder(_ context.Context, externalID, _ string) error { if externalID != testExternalID { return ErrUserNotProvisioned } return nil } func (f *fakeMailService) ListMessages(_ context.Context, externalID string, _ MessageListFilter, params query.ListParams) (MessagesList, error) { if externalID != testExternalID { return MessagesList{}, ErrUserNotProvisioned } msgs := make([]map[string]any, 0, len(f.messages)) for id, msg := range f.messages { if f.deleted[id] { continue } msgs = append(msgs, msg) } total := int64(len(msgs)) return MessagesList{ Messages: msgs, Page: params.Page, Pagination: params.Meta(&total), }, nil } func (f *fakeMailService) SendMailtoListUnsubscribe(context.Context, string, string, MailSender) (*listunsubscribe.Mailto, error) { return nil, ErrListUnsubscribeUnavailable } func (f *fakeMailService) GetMessage(_ context.Context, externalID, messageID string) (map[string]any, error) { if externalID != testExternalID { return nil, ErrUserNotProvisioned } if f.deleted[messageID] { return nil, ErrNotFound } msg, ok := f.messages[messageID] if !ok { return nil, ErrNotFound } return msg, nil } func (f *fakeMailService) UpdateLabels(_ context.Context, externalID, messageID string, labels []string) error { if externalID != testExternalID { return ErrNotFound } if f.deleted[messageID] { return ErrNotFound } msg, ok := f.messages[messageID] if !ok { return ErrNotFound } msg["labels"] = labels return nil } func (f *fakeMailService) UpdateFlags(_ context.Context, externalID, messageID string, flags []string) error { if externalID != testExternalID { return ErrNotFound } if f.deleted[messageID] { return ErrNotFound } msg, ok := f.messages[messageID] if !ok { return ErrNotFound } msg["flags"] = flags return nil } func (f *fakeMailService) DeleteMessage(_ context.Context, externalID, messageID string) error { if externalID != testExternalID { return ErrNotFound } if _, ok := f.messages[messageID]; !ok || f.deleted[messageID] { return ErrNotFound } f.deleted[messageID] = true return nil } func (f *fakeMailService) SendMessage(_ context.Context, userID string, req *sendMessageRequest) (string, string, error) { if userID != testUserID { return "", "", ErrAccountNotFound } f.sent = append(f.sent, *req) return "outbox-1", "queued", nil } func (f *fakeMailService) SendOutboxNow(context.Context, string, string) (string, error) { return "", ErrNotFound } func (f *fakeMailService) RescheduleOutbox(context.Context, string, string, time.Time) (string, error) { return "", ErrNotFound } func (f *fakeMailService) CancelScheduledOutbox(context.Context, string, string) (string, error) { return "", ErrNotFound } type outboxFakeService struct { fakeMailService items map[string]string } func newOutboxFakeService() *outboxFakeService { return &outboxFakeService{ fakeMailService: *newFakeMailService(), items: map[string]string{ "outbox-scheduled": "scheduled", "outbox-queued": "queued", }, } } func (f *outboxFakeService) SendOutboxNow(_ context.Context, userID, outboxID string) (string, error) { if userID != testUserID { return "", ErrNotFound } status, ok := f.items[outboxID] if !ok { return "", ErrNotFound } if status != "scheduled" { return "", ErrInvalidOutboxStatus } f.items[outboxID] = "queued" return "queued", nil } func (f *outboxFakeService) RescheduleOutbox(_ context.Context, userID, outboxID string, _ time.Time) (string, error) { if userID != testUserID { return "", ErrNotFound } status, ok := f.items[outboxID] if !ok { return "", ErrNotFound } if status != "scheduled" && status != "queued" { return "", ErrInvalidOutboxStatus } f.items[outboxID] = "scheduled" return "scheduled", nil } func (f *outboxFakeService) CancelScheduledOutbox(_ context.Context, userID, outboxID string) (string, error) { if userID != testUserID { return "", ErrNotFound } status, ok := f.items[outboxID] if !ok { return "", ErrNotFound } if status != "scheduled" { return "", ErrInvalidOutboxStatus } f.items[outboxID] = "cancelled" return "cancelled", nil } func (f *fakeMailService) ListDrafts(context.Context, string, query.ListParams) (DraftsList, error) { return DraftsList{}, nil } func (f *fakeMailService) GetDraft(context.Context, string, string) (map[string]any, error) { return nil, ErrNotFound } func (f *fakeMailService) CreateDraft(context.Context, string, *draftRequest) (string, error) { return "", nil } func (f *fakeMailService) UpdateDraft(context.Context, string, string, *draftRequest) error { return nil } func (f *fakeMailService) DeleteDraft(context.Context, string, string) error { return nil } func (f *fakeMailService) ListAccounts(context.Context, string, query.ListParams) (AccountsList, error) { return AccountsList{}, nil } func (f *fakeMailService) CreateAccount(context.Context, string, *createAccountRequest) (string, error) { return "", nil } func (f *fakeMailService) CreateAccountWithCredential(context.Context, string, *createAccountRequest, credentials.Credential) (string, error) { return "", nil } func (f *fakeMailService) GetAccount(context.Context, string, string) (map[string]any, error) { return nil, ErrNotFound } func (f *fakeMailService) UpdateAccount(context.Context, string, string, *updateAccountRequest) error { return nil } func (f *fakeMailService) CredentialForConnectionTest(context.Context, string, *testAccountRequest) (credentials.Credential, error) { return credentials.Credential{AuthType: credentials.AuthPassword, Username: "u", Password: "p"}, nil } func (f *fakeMailService) DeleteAccount(context.Context, string, string) error { return nil } func (f *fakeMailService) ResanitizeAccountBodies(context.Context, string, string) (ResanitizeBodiesResult, error) { return ResanitizeBodiesResult{}, nil } func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) { return map[string]any{"messages": []any{}}, nil } func (f *fakeMailService) ListRules(context.Context, string, query.ListParams) (RulesList, error) { return RulesList{}, nil } func (f *fakeMailService) CreateRule(context.Context, string, *createRuleRequest) (string, error) { return "", nil } func (f *fakeMailService) UpdateRule(_ context.Context, externalID, ruleID string, _ *updateRuleRequest) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) DeleteRule(_ context.Context, externalID, ruleID string) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) SimulateRule(_ context.Context, externalID string, req *simulateRuleRequest) (any, error) { if externalID != testExternalID { return rules.SimulationResult{}, ErrUserNotProvisioned } if req.Message == nil { return rules.SimulationResult{}, ErrNotFound } matched := false for _, cond := range []struct { field, operator, value string }{ {"from", "contains", "alice"}, {"subject", "contains", "invoice"}, } { fieldValue := "" switch cond.field { case "from": fieldValue = req.Message.From case "subject": fieldValue = req.Message.Subject } if strings.Contains(strings.ToLower(fieldValue), strings.ToLower(cond.value)) { matched = true break } } if !matched { return rules.SimulationResult{Matched: false}, nil } return rules.SimulationResult{ Matched: true, Actions: []rules.SimulatedActionResult{ {ActionResult: rules.ActionResult{Type: "label", Value: "work", OK: true}}, }, }, nil } func (f *fakeMailService) ListWebhooks(context.Context, string, query.ListParams) (WebhooksList, error) { return WebhooksList{}, nil } func (f *fakeMailService) CreateWebhook(context.Context, string, *createWebhookRequest, string, int) (string, error) { return "", nil } func (f *fakeMailService) UpdateWebhook(context.Context, string, string, *updateWebhookRequest, string, int) error { return nil } func (f *fakeMailService) PreviewWebhookTemplate(_ context.Context, _ string, req *previewWebhookRequest) (map[string]any, error) { return map[string]any{"payload": req.BodyTemplate}, nil } func (f *fakeMailService) DeleteWebhook(_ context.Context, externalID, webhookID string) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) ListIdentities(_ context.Context, externalID, accountID string, params query.ListParams) (IdentitiesList, error) { if externalID != testExternalID { return IdentitiesList{}, ErrAccountNotFound } if accountID != testMailAccountID { return IdentitiesList{}, ErrAccountNotFound } total := int64(1) return IdentitiesList{ Identities: []map[string]any{{ "id": "id-1", "account_id": testMailAccountID, "email": "sender@example.com", "name": "Sender", "is_default": true, "signature_html": "", "reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil, }}, Pagination: params.Meta(&total), }, nil } func (f *fakeMailService) GetIdentity(_ context.Context, externalID, identityID string) (map[string]any, error) { if externalID != testExternalID || identityID != "id-1" { return nil, ErrNotFound } return map[string]any{ "id": "id-1", "account_id": testMailAccountID, "email": "sender@example.com", "name": "Sender", "is_default": true, "signature_html": "", "reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil, }, nil } func (f *fakeMailService) CreateIdentity(_ context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) { if externalID != testExternalID || accountID != testMailAccountID { return "", ErrAccountNotFound } if req.Email == "" { return "", ErrAccountNotFound } return "id-new", nil } func (f *fakeMailService) UpdateIdentity(_ context.Context, externalID, identityID string, _ *updateIdentityRequest) error { if externalID != testExternalID || identityID != "id-1" { return ErrNotFound } return nil } func (f *fakeMailService) DeleteIdentity(_ context.Context, externalID, identityID string) error { if externalID != testExternalID || identityID != "id-1" { return ErrNotFound } return nil } func (f *fakeMailService) ListSignatures(_ context.Context, externalID string, params query.ListParams) (SignaturesList, error) { if externalID != testExternalID { return SignaturesList{}, ErrNotFound } total := int64(0) return SignaturesList{Signatures: []map[string]any{}, Pagination: params.Meta(&total)}, nil } func (f *fakeMailService) GetSignature(_ context.Context, externalID, signatureID string) (map[string]any, error) { if externalID != testExternalID { return nil, ErrNotFound } return map[string]any{"id": signatureID, "name": "Sig", "html": "

Hi

", "sort_order": 0}, nil } func (f *fakeMailService) CreateSignature(_ context.Context, externalID string, req *createSignatureRequest) (string, error) { if externalID != testExternalID || req.Name == "" { return "", ErrNotFound } return "sig-new", nil } func (f *fakeMailService) UpdateSignature(_ context.Context, externalID, signatureID string, _ *updateSignatureRequest) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) DeleteSignature(_ context.Context, externalID, signatureID string) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) ListFolders(_ context.Context, externalID, accountID string, params query.ListParams) (FoldersList, error) { if externalID != testExternalID { return FoldersList{}, ErrAccountNotFound } total := int64(0) return FoldersList{Folders: []map[string]any{}, Pagination: params.Meta(&total)}, nil } func (f *fakeMailService) GetFolder(_ context.Context, externalID, folderID string) (map[string]any, error) { if externalID != testExternalID { return nil, ErrNotFound } return map[string]any{"id": folderID, "name": "Inbox", "remote_name": "INBOX", "folder_type": "inbox"}, nil } func (f *fakeMailService) CreateFolder(_ context.Context, userID string, _ *createFolderRequest) (string, error) { if userID != testUserID { return "", ErrAccountNotFound } return "folder-1", nil } func (f *fakeMailService) UpdateFolder(_ context.Context, externalID, folderID string, _ *updateFolderRequest) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) DeleteFolder(_ context.Context, externalID, folderID string) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) ListUserLabels(_ context.Context, externalID string, params query.ListParams) (UserLabelsList, error) { if externalID != testExternalID { return UserLabelsList{}, nil } total := int64(0) return UserLabelsList{Labels: []map[string]any{}, Pagination: params.Meta(&total)}, nil } func (f *fakeMailService) CreateUserLabel(_ context.Context, externalID string, _ *createUserLabelRequest) (string, error) { if externalID != testExternalID { return "", ErrNotFound } return "label-1", nil } func (f *fakeMailService) UpdateUserLabel(_ context.Context, externalID, labelID string, _ *updateUserLabelRequest) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) DeleteUserLabel(_ context.Context, externalID, labelID string) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) ReorderUserLabels(_ context.Context, externalID string, _ *reorderLabelsRequest) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) ReorderUnifiedFolders(_ context.Context, externalID string, _ *reorderUnifiedFoldersRequest) error { if externalID != testExternalID { return ErrNotFound } return nil } func (f *fakeMailService) SearchMessages(_ context.Context, externalID string, _ MessageSearchFilter, params query.ListParams) (MessageSearchResult, error) { if externalID != testExternalID { return MessageSearchResult{}, ErrUserNotProvisioned } msgs := make([]map[string]any, 0, len(f.messages)) for id, msg := range f.messages { if f.deleted[id] { continue } msgs = append(msgs, msg) } total := int64(len(msgs)) return MessageSearchResult{Messages: msgs, Pagination: params.Meta(&total)}, nil } func (f *fakeMailService) ListMessageAttachments(_ context.Context, externalID, messageID string) ([]map[string]any, error) { if externalID != testExternalID { return nil, ErrNotFound } if _, ok := f.messages[messageID]; !ok { return nil, ErrNotFound } return []map[string]any{}, nil } func (f *fakeMailService) MessageAttachmentCIDMap(_ context.Context, externalID, messageID string) (map[string]string, error) { if externalID != testExternalID { return nil, ErrNotFound } if _, ok := f.messages[messageID]; !ok { return nil, ErrNotFound } return map[string]string{}, nil } func (f *fakeMailService) UploadMessageAttachment(context.Context, string, string, string, string, string, bool, io.Reader, int64) (string, error) { return "att-1", nil } func (f *fakeMailService) OpenAttachment(context.Context, string, string) (string, string, int64, bool, io.ReadCloser, error) { return "", "", 0, false, nil, ErrAttachmentNotFound } func (f *fakeMailService) UploadDraftAttachment(context.Context, string, string, string, string, string, bool, io.Reader, int64) (string, error) { return "att-draft-1", nil } func (f *fakeMailService) OpenDraftAttachment(context.Context, string, string, string) (string, string, io.ReadCloser, error) { return "", "", nil, ErrAttachmentNotFound } func newTestMailRouter(svc ServiceAPI) http.Handler { return newTestMailRouterWithClaims(svc, &auth.Claims{ Sub: testExternalID, Email: "user@example.com", }) } func newTestMailRouterWithClaims(svc ServiceAPI, claims *auth.Claims) http.Handler { h := NewHandlerWithService(svc) r := chi.NewRouter() r.Use(middleware.WithTestClaims(claims)) r.Mount("/", h.Routes()) return r } func TestListMessages(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) req := httptest.NewRequest(http.MethodGet, "/messages", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var body MessagesList if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if len(body.Messages) != 1 { t.Fatalf("messages len = %d, want 1", len(body.Messages)) } if body.Messages[0]["id"] != "msg-1" { t.Fatalf("message id = %v", body.Messages[0]["id"]) } } func TestGetMessage(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) t.Run("found", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/messages/msg-1", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var body map[string]any if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body["id"] != "msg-1" { t.Fatalf("id = %v", body["id"]) } }) t.Run("not found", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/messages/missing", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) } }) } func TestSendMessage(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) payload := map[string]any{ "account_id": testMailAccountID, "to": []string{"recipient@example.com"}, "subject": "Test subject", "body_text": "Hello world", } body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/send", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusAccepted, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode body: %v", err) } if resp["id"] != "outbox-1" || resp["status"] != "queued" { t.Fatalf("response = %#v", resp) } if len(svc.sent) != 1 { t.Fatalf("sent count = %d, want 1", len(svc.sent)) } if svc.sent[0].Subject != "Test subject" { t.Fatalf("sent subject = %q", svc.sent[0].Subject) } } func TestUpdateLabels(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) body, err := json.Marshal(map[string]any{"labels": []string{"work", "important"}}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPut, "/messages/msg-1/labels", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String()) } labels, ok := svc.messages["msg-1"]["labels"].([]string) if !ok { t.Fatalf("labels type = %T", svc.messages["msg-1"]["labels"]) } if len(labels) != 2 || labels[0] != "work" || labels[1] != "important" { t.Fatalf("labels = %#v", labels) } } func TestUpdateFlags(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) body, err := json.Marshal(map[string]any{"flags": []string{"read", "starred"}}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPut, "/messages/msg-1/flags", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String()) } flags, ok := svc.messages["msg-1"]["flags"].([]string) if !ok { t.Fatalf("flags type = %T", svc.messages["msg-1"]["flags"]) } if len(flags) != 2 || flags[0] != "read" || flags[1] != "starred" { t.Fatalf("flags = %#v", flags) } } func TestUpdateLabelsCrossUser(t *testing.T) { svc := newFakeMailService() router := newTestMailRouterWithClaims(svc, &auth.Claims{ Sub: testExternalID2, Email: "other@example.com", }) body, err := json.Marshal(map[string]any{"labels": []string{"stolen"}}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPut, "/messages/msg-1/labels", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNotFound, rec.Body.String()) } } func TestDeleteMessage(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) req := httptest.NewRequest(http.MethodDelete, "/messages/msg-1", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String()) } if !svc.deleted["msg-1"] { t.Fatal("message was not marked deleted") } // Subsequent get returns 404. req = httptest.NewRequest(http.MethodGet, "/messages/msg-1", nil) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("get after delete status = %d, want %d", rec.Code, http.StatusNotFound) } } func TestSendOutboxNow(t *testing.T) { svc := newOutboxFakeService() router := newTestMailRouter(svc) t.Run("happy path", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-scheduled/send-now", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusAccepted, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode body: %v", err) } if resp["id"] != "outbox-scheduled" || resp["status"] != "queued" { t.Fatalf("response = %#v", resp) } if svc.items["outbox-scheduled"] != "queued" { t.Fatalf("item status = %q, want queued", svc.items["outbox-scheduled"]) } }) t.Run("invalid status", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-queued/send-now", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusConflict, rec.Body.String()) } }) t.Run("not found", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/outbox/missing/send-now", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) } func TestRescheduleOutbox(t *testing.T) { svc := newOutboxFakeService() router := newTestMailRouter(svc) future := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) t.Run("happy path", func(t *testing.T) { body, err := json.Marshal(map[string]string{"schedule_at": future}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-scheduled/reschedule", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode body: %v", err) } if resp["status"] != "scheduled" { t.Fatalf("response = %#v", resp) } }) t.Run("invalid schedule_at", func(t *testing.T) { body, err := json.Marshal(map[string]string{"schedule_at": "not-a-date"}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-scheduled/reschedule", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) t.Run("invalid status", func(t *testing.T) { svc.items["outbox-sent"] = "sent" body, err := json.Marshal(map[string]string{"schedule_at": future}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-sent/reschedule", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusConflict, rec.Body.String()) } }) } func TestCancelScheduledOutbox(t *testing.T) { svc := newOutboxFakeService() router := newTestMailRouter(svc) t.Run("happy path", func(t *testing.T) { svc.items["outbox-cancel-me"] = "scheduled" req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-cancel-me/cancel", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode body: %v", err) } if resp["status"] != "cancelled" { t.Fatalf("response = %#v", resp) } }) t.Run("invalid status", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-queued/cancel", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusConflict, rec.Body.String()) } }) t.Run("not found", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/outbox/missing/cancel", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) } func TestSimulateRule(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) t.Run("matched inline rule", func(t *testing.T) { body, err := json.Marshal(map[string]any{ "message": map[string]any{ "from": "Alice ", "to": []string{"bob@example.com"}, "subject": "Invoice Q1", }, "rule": map[string]any{ "conditions": []map[string]string{ {"field": "subject", "operator": "contains", "value": "invoice"}, }, "actions": []map[string]string{ {"type": "label", "value": "work"}, }, }, }) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/rules/simulate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp rules.SimulationResult if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode body: %v", err) } if !resp.Matched { t.Fatal("matched = false, want true") } if len(resp.Actions) != 1 || resp.Actions[0].Type != "label" || !resp.Actions[0].OK { t.Fatalf("actions = %#v", resp.Actions) } }) t.Run("no match", func(t *testing.T) { body, err := json.Marshal(map[string]any{ "message": map[string]any{ "from": "bob@example.com", "subject": "Hello", }, "rule": map[string]any{ "conditions": []map[string]string{ {"field": "subject", "operator": "contains", "value": "invoice"}, }, "actions": []map[string]string{ {"type": "label", "value": "work"}, }, }, }) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/rules/simulate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp rules.SimulationResult if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode body: %v", err) } if resp.Matched { t.Fatal("matched = true, want false") } }) t.Run("validation missing message", func(t *testing.T) { body, err := json.Marshal(map[string]any{ "rule": map[string]any{ "conditions": []map[string]string{ {"field": "subject", "operator": "contains", "value": "invoice"}, }, "actions": []map[string]string{ {"type": "label", "value": "work"}, }, }, }) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/rules/simulate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) t.Run("validation missing rule source", func(t *testing.T) { body, err := json.Marshal(map[string]any{ "message": map[string]any{ "subject": "Invoice Q1", }, }) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPost, "/rules/simulate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) } func TestMailSettingsHandlers(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) t.Run("get defaults", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/settings", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var body MailSettings if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Density != "default" || body.ThemeMode != "system" { t.Fatalf("body = %#v", body) } }) t.Run("patch density", func(t *testing.T) { payload, err := json.Marshal(map[string]string{"density": "compact"}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } var body MailSettings if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Density != "compact" { t.Fatalf("density = %q, want compact", body.Density) } }) t.Run("patch invalid", func(t *testing.T) { payload, err := json.Marshal(map[string]string{"density": "invalid"}) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) }