ultisuite-backend/internal/api/mail/handlers_test.go
R3D347HR4Y 95196f7777 Add mail attachment and draft management features
- Introduced new functionality for managing email attachments and drafts in the mail API.
- Added handlers for listing, uploading, and downloading message attachments in `internal/api/mail/handlers_attachments.go`.
- Implemented draft management endpoints for creating, updating, and deleting drafts in `internal/api/mail/handlers_drafts.go`.
- Created new service methods for handling draft and attachment operations in `internal/api/mail/drafts.go` and `internal/api/mail/storage.go`.
- Added validation and error handling for draft and attachment operations.
- Included unit tests for draft and folder functionalities in `internal/api/mail/drafts_test.go` and `internal/api/mail/folders_test.go`.
- Updated API routes to support new draft and attachment features, enhancing overall mail management capabilities.
2026-05-22 17:14:36 +02:00

571 lines
17 KiB
Go

package mail
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"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"
)
const (
testExternalID = "ext-user-1"
testExternalID2 = "ext-user-2"
testUserID = "user-uuid-1"
)
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) 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) 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) 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) GetAccount(context.Context, string, string) (map[string]any, error) {
return nil, ErrNotFound
}
func (f *fakeMailService) DeleteAccount(context.Context, string, string) error { return 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) ListWebhooks(context.Context, string, query.ListParams) (WebhooksList, error) {
return WebhooksList{}, nil
}
func (f *fakeMailService) CreateWebhook(context.Context, string, *createWebhookRequest, string) (string, error) {
return "", 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 != "acc-1" {
return IdentitiesList{}, ErrAccountNotFound
}
total := int64(1)
return IdentitiesList{
Identities: []map[string]any{{
"id": "id-1", "account_id": "acc-1", "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": "acc-1", "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 != "acc-1" {
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) 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) 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": "acc-1",
"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)
}
}