diff --git a/internal/mail/client_test.go b/internal/mail/client_test.go new file mode 100644 index 0000000..e8c90d7 --- /dev/null +++ b/internal/mail/client_test.go @@ -0,0 +1,1386 @@ +package mail + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/frenocorp/pop/internal/api" + "github.com/frenocorp/pop/internal/config" +) + +// mockServer wraps an httptest.Server with configurable handlers. +type mockServer struct { + mux *http.ServeMux + server *httptest.Server + handlers sync.Map +} + +func newMockServer(t *testing.T) *mockServer { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + + ms := &mockServer{ + mux: mux, + server: srv, + } + + mux.HandleFunc("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + resolveHandler(ms, w, r) + }) + mux.HandleFunc("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + resolveHandler(ms, w, r) + }) + mux.HandleFunc("POST /api/messages/{id}", func(w http.ResponseWriter, r *http.Request) { + resolveHandler(ms, w, r) + }) + mux.HandleFunc("POST /api/messages/{id}/movetotrash", func(w http.ResponseWriter, r *http.Request) { + resolveHandler(ms, w, r) + }) + mux.HandleFunc("POST /api/messages/{id}/delete", func(w http.ResponseWriter, r *http.Request) { + resolveHandler(ms, w, r) + }) + mux.HandleFunc("POST /api/messages/{id}/send", func(w http.ResponseWriter, r *http.Request) { + resolveHandler(ms, w, r) + }) + + return ms +} + +func (ms *mockServer) URL() string { + return ms.server.URL +} + +func (ms *mockServer) Close() { + ms.server.Close() +} + +func (ms *mockServer) Handle(key string, handler http.HandlerFunc) { + ms.handlers.Store(key, handler) +} + +func resolveHandler(ms *mockServer, w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + if handler, loaded := ms.handlers.Load(key); loaded { + handler.(http.HandlerFunc)(w, r) + return + } + // When POST /api/messages is called, it matches both list and send/draft. + // The generic handler for POST /api/messages catches all unmatched POST /api/messages calls. + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`) +} + +// newTestClient creates a mail.Client backed by a mock server. +func newTestClient(t *testing.T, srv *mockServer) *Client { + t.Helper() + cfg := &config.Config{ + APIBaseURL: srv.URL(), + TimeoutSec: 5, + RateLimitReq: 100, + RateLimitWin: 60, + } + apiClient := api.NewProtonMailClient(cfg) + apiClient.SetAuthHeader("test-token") + return NewClient(apiClient) +} + +// readJSON reads request body and returns parsed map. +func readJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + return m +} + +// writeJSON writes a JSON response. +func writeJSON(t *testing.T, w http.ResponseWriter, code int, v interface{}) { + t.Helper() + w.WriteHeader(code) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// ---------- ListMessages ---------- + +func TestListMessages_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + expected := ListMessagesResponse{ + Total: 2, + Messages: []Message{ + {MessageID: "msg-1", Subject: "Hello", Sender: Recipient{Address: "alice@example.com"}}, + {MessageID: "msg-2", Subject: "World", Sender: Recipient{Address: "bob@example.com"}}, + }, + } + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Page"] != float64(1) { + t.Errorf("expected page 1, got %v", body["Page"]) + } + if body["PageSize"] != float64(25) { + t.Errorf("expected page size 25, got %v", body["PageSize"]) + } + if body["Passphrase"] != "test-pass" { + t.Errorf("expected passphrase test-pass, got %v", body["Passphrase"]) + } + writeJSON(t, w, http.StatusOK, expected) + }) + + resp, err := client.ListMessages(ListMessagesRequest{ + Page: 1, + PageSize: 25, + Passphrase: "test-pass", + }) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if resp.Total != 2 { + t.Errorf("expected 2 messages, got %d", resp.Total) + } + if len(resp.Messages) != 2 { + t.Errorf("expected 2 messages in slice, got %d", len(resp.Messages)) + } + if resp.Messages[0].Subject != "Hello" { + t.Errorf("expected subject Hello, got %s", resp.Messages[0].Subject) + } +} + +func TestListMessages_WithFolderFilter(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Type"] != float64(FolderSent) { + t.Errorf("expected Type=3 (sent), got %v", body["Type"]) + } + writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}}) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Folder: FolderSent, + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } +} + +func TestListMessages_InboxOmitsType(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if _, ok := body["Type"]; ok { + t.Error("Inbox should omit Type field") + } + writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}}) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Folder: FolderInbox, + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } +} + +func TestListMessages_WithStarredFilter(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + starred := true + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Starred"] != true { + t.Errorf("expected Starred=true, got %v", body["Starred"]) + } + writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}}) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Folder: FolderInbox, + Page: 1, + PageSize: 10, + Passphrase: "pass", + Starred: &starred, + }) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } +} + +func TestListMessages_WithReadFilter(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + unread := false + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Read"] != false { + t.Errorf("expected Read=false, got %v", body["Read"]) + } + writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}}) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Folder: FolderInbox, + Page: 1, + PageSize: 10, + Passphrase: "pass", + Read: &unread, + }) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } +} + +func TestListMessages_WithSinceFilter(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + since := int64(1700000000) + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Since"] != float64(since) { + t.Errorf("expected Since=%d, got %v", since, body["Since"]) + } + writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}}) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Folder: FolderInbox, + Page: 1, + PageSize: 10, + Passphrase: "pass", + Since: since, + }) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } +} + +func TestListMessages_APIError(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"Code":401,"Message":"invalid token"}`) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err == nil { + t.Fatal("expected error for 401 response") + } + if !strings.Contains(err.Error(), "invalid token") { + t.Errorf("expected 'invalid token' in error, got: %s", err.Error()) + } +} + +func TestListMessages_BadJSON(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"bad json`) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err == nil { + t.Fatal("expected error for bad JSON") + } + if !strings.Contains(err.Error(), "failed to parse response") { + t.Errorf("unexpected error: %s", err.Error()) + } +} + +// ---------- GetMessage ---------- + +func TestGetMessage_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + expectedMsg := Message{ + MessageID: "msg-42", + Subject: "Test Message", + Sender: Recipient{Address: "sender@example.com"}, + Body: "Decrypted body content", + } + + srv.Handle("POST /api/messages/msg-42", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Passphrase"] != "pass" { + t.Errorf("expected passphrase pass, got %v", body["Passphrase"]) + } + writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": expectedMsg}) + }) + + msg, err := client.GetMessage("msg-42", "pass") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.MessageID != "msg-42" { + t.Errorf("expected msg-42, got %s", msg.MessageID) + } + if msg.Subject != "Test Message" { + t.Errorf("expected subject Test Message, got %s", msg.Subject) + } + if msg.Body != "Decrypted body content" { + t.Errorf("expected body content, got %s", msg.Body) + } +} + +func TestGetMessage_URLEscape(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + msgID := "msg/with/slashes" + srv.Handle("POST /api/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": Message{MessageID: msgID}}) + }) + + msg, err := client.GetMessage(msgID, "pass") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.MessageID != msgID { + t.Errorf("expected %s, got %s", msgID, msg.MessageID) + } +} + +func TestGetMessage_NotFound(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/msg-999", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"Code":404,"Message":"message not found"}`) + }) + + _, err := client.GetMessage("msg-999", "pass") + if err == nil { + t.Fatal("expected error for 404") + } +} + +func TestGetMessage_DecryptBody(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + + svc, _, pass := newTestService(t) + cfg := &config.Config{ + APIBaseURL: srv.URL(), + TimeoutSec: 5, + RateLimitReq: 100, + RateLimitWin: 60, + } + apiClient := api.NewProtonMailClient(cfg) + apiClient.SetAuthHeader("test-token") + client := NewClient(apiClient) + client.SetPGPService(svc) + + // Simulate an encrypted body that the API returns + encryptedBody, err := svc.EncryptBody("Secret message content", pass) + if err != nil { + t.Fatalf("EncryptBody: %v", err) + } + + msgWithEncryptedBody := Message{ + MessageID: "msg-enc", + Subject: "Encrypted", + BodyEnc: encryptedBody, + } + + srv.Handle("POST /api/messages/msg-enc", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": msgWithEncryptedBody}) + }) + + msg, err := client.GetMessage("msg-enc", pass) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.BodyEnc != encryptedBody { + t.Error("encrypted body should be preserved") + } +} + +// ---------- Send ---------- + +func TestSend_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Subject"] != "Test Subject" { + t.Errorf("expected subject Test Subject, got %v", body["Subject"]) + } + if body["HTML"] != true { + t.Error("expected HTML=true") + } + writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": map[string]string{"MessageID": "sent-1"}}) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "Test Subject", + Body: "Plain body", + HTML: true, + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } +} + +func TestSend_WithPGP(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + + svc, _, pass := newTestService(t) + cfg := &config.Config{ + APIBaseURL: srv.URL(), + TimeoutSec: 5, + RateLimitReq: 100, + RateLimitWin: 60, + } + apiClient := api.NewProtonMailClient(cfg) + apiClient.SetAuthHeader("test-token") + client := NewClient(apiClient) + client.SetPGPService(svc) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + // When PGP service is set, BodyEnc should be present instead of Body + if _, hasBody := body["Body"]; hasBody { + t.Error("expected BodyEnc instead of Body when PGP service is set") + } + if body["BodyEnc"] == "" { + t.Error("expected BodyEnc to be set") + } + writeJSON(t, w, http.StatusCreated, map[string]interface{}{}) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "PGP Encrypted", + Body: "Secret content", + Passphrase: pass, + }) + if err != nil { + t.Fatalf("Send with PGP: %v", err) + } +} + +func TestSend_WithCC(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + cc, ok := body["CC"].([]interface{}) + if !ok || len(cc) != 1 { + t.Error("expected CC with 1 recipient") + } + writeJSON(t, w, http.StatusOK, map[string]interface{}{}) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: "to@example.com"}}, + CC: []Recipient{{Address: "cc@example.com"}}, + Subject: "With CC", + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } +} + +func TestSend_WithBCC(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + bcc, ok := body["BCC"].([]interface{}) + if !ok || len(bcc) != 1 { + t.Error("expected BCC with 1 recipient") + } + writeJSON(t, w, http.StatusOK, map[string]interface{}{}) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: "to@example.com"}}, + BCC: []Recipient{{Address: "bcc@example.com"}}, + Subject: "With BCC", + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } +} + +func TestSend_HTTPError(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: ""}}, + Subject: "Bad", + Passphrase: "pass", + }) + if err == nil { + t.Fatal("expected error for 400") + } + // API error is returned directly by Do(), then wrapped by Send() + if !strings.Contains(err.Error(), "invalid recipient") { + t.Errorf("expected 'invalid recipient' in error, got: %s", err.Error()) + } +} + +func TestSend_CreatedStatus(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"Data":{"MessageID":"created-1"}}`) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "Created", + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("Send with 201: %v", err) + } +} + +// ---------- MoveToTrash ---------- + +func TestMoveToTrash_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Error("expected form-urlencoded content type") + } + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "Passphrase=pass") { + t.Errorf("expected Passphrase in form body, got %s", body) + } + w.WriteHeader(http.StatusOK) + }) + + err := client.MoveToTrash("msg-1", "pass") + if err != nil { + t.Fatalf("MoveToTrash: %v", err) + } +} + +func TestMoveToTrash_Error(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "server error") + }) + + err := client.MoveToTrash("msg-1", "pass") + if err == nil { + t.Fatal("expected error for 500") + } + if !strings.Contains(err.Error(), "trash failed") { + t.Errorf("unexpected error: %s", err.Error()) + } +} + +// ---------- PermanentlyDelete ---------- + +func TestPermanentlyDelete_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + err := client.PermanentlyDelete("msg-1") + if err != nil { + t.Fatalf("PermanentlyDelete: %v", err) + } +} + +func TestPermanentlyDelete_Error(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`) + }) + + err := client.PermanentlyDelete("msg-1") + if err == nil { + t.Fatal("expected error for 404") + } + // When API returns parseable error JSON, Do() returns *APIError which is wrapped + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' in error, got: %s", err.Error()) + } +} + +func TestPermanentlyDelete_URLEscape(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + msgID := "msg/with/slashes" + srv.Handle("POST /api/messages/msg/with/slashes/delete", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + err := client.PermanentlyDelete(msgID) + if err != nil { + t.Fatalf("PermanentlyDelete: %v", err) + } +} + +// ---------- SaveDraft ---------- + +func TestSaveDraft_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Type"] != MessageTypeDraft { + t.Errorf("expected Type=%s, got %v", MessageTypeDraft, body["Type"]) + } + if body["Subject"] != "Draft Subject" { + t.Errorf("expected subject Draft Subject, got %v", body["Subject"]) + } + writeJSON(t, w, http.StatusOK, map[string]interface{}{ + "Data": map[string]string{"MessageID": "draft-1"}, + }) + }) + + id, err := client.SaveDraft(Draft{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "Draft Subject", + Body: "Draft body", + }, "pass") + if err != nil { + t.Fatalf("SaveDraft: %v", err) + } + if id != "draft-1" { + t.Errorf("expected draft-1, got %s", id) + } +} + +func TestSaveDraft_WithCC(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if _, ok := body["CC"]; !ok { + t.Error("expected CC field") + } + if _, ok := body["BCC"]; !ok { + t.Error("expected BCC field") + } + writeJSON(t, w, http.StatusOK, map[string]interface{}{ + "Data": map[string]string{"MessageID": "draft-2"}, + }) + }) + + _, err := client.SaveDraft(Draft{ + To: []Recipient{{Address: "to@example.com"}}, + CC: []Recipient{{Address: "cc@example.com"}}, + BCC: []Recipient{{Address: "bcc@example.com"}}, + Subject: "Draft with CC/BCC", + Body: "Body", + }, "pass") + if err != nil { + t.Fatalf("SaveDraft: %v", err) + } +} + +func TestSaveDraft_Error(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`) + }) + + _, err := client.SaveDraft(Draft{ + To: []Recipient{{Address: ""}}, + Subject: "Bad Draft", + Body: "Body", + }, "pass") + if err == nil { + t.Fatal("expected error for 400") + } +} + +func TestSaveDraft_BadJSON(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"bad json`) + }) + + _, err := client.SaveDraft(Draft{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "Bad JSON", + Body: "Body", + }, "pass") + if err == nil { + t.Fatal("expected error for bad JSON") + } + if !strings.Contains(err.Error(), "failed to parse response") { + t.Errorf("unexpected error: %s", err.Error()) + } +} + +// ---------- UpdateDraft ---------- + +func TestUpdateDraft_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Subject"] != "Updated Subject" { + t.Errorf("expected Updated Subject, got %v", body["Subject"]) + } + w.WriteHeader(http.StatusOK) + }) + + err := client.UpdateDraft("draft-1", Draft{ + To: []Recipient{{Address: "updated@example.com"}}, + Subject: "Updated Subject", + Body: "Updated body", + }, "pass") + if err != nil { + t.Fatalf("UpdateDraft: %v", err) + } +} + +func TestUpdateDraft_WithCC(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if _, ok := body["CC"]; !ok { + t.Error("expected CC field in update") + } + w.WriteHeader(http.StatusOK) + }) + + err := client.UpdateDraft("draft-1", Draft{ + To: []Recipient{{Address: "to@example.com"}}, + CC: []Recipient{{Address: "cc@example.com"}}, + Subject: "Updated", + Body: "Body", + }, "pass") + if err != nil { + t.Fatalf("UpdateDraft: %v", err) + } +} + +func TestUpdateDraft_Error(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, `{"Code":409,"Message":"draft locked"}`) + }) + + err := client.UpdateDraft("draft-1", Draft{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "Conflict", + Body: "Body", + }, "pass") + if err == nil { + t.Fatal("expected error for 409") + } + // API error JSON is parsed by Do(), returns *APIError wrapped by UpdateDraft + if !strings.Contains(err.Error(), "draft locked") { + t.Errorf("expected 'draft locked' in error, got: %s", err.Error()) + } +} + +// ---------- SendDraft ---------- + +func TestSendDraft_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/draft-1/send", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Error("expected form-urlencoded content type") + } + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "Passphrase=pass") { + t.Errorf("expected Passphrase in form, got %s", body) + } + w.WriteHeader(http.StatusOK) + }) + + err := client.SendDraft("draft-1", "pass") + if err != nil { + t.Fatalf("SendDraft: %v", err) + } +} + +func TestSendDraft_Error(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/draft-1/send", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "not found") + }) + + err := client.SendDraft("draft-1", "pass") + if err == nil { + t.Fatal("expected error for 404") + } + if !strings.Contains(err.Error(), "send draft failed") { + t.Errorf("unexpected error: %s", err.Error()) + } +} + +// ---------- ListDrafts ---------- + +func TestListDrafts_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + expected := ListMessagesResponse{ + Total: 1, + Messages: []Message{ + {MessageID: "draft-1", Subject: "My Draft", Type: int(FolderDraft)}, + }, + } + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Type"] != float64(FolderDraft) { + t.Errorf("expected Type=2 (draft), got %v", body["Type"]) + } + writeJSON(t, w, http.StatusOK, expected) + }) + + resp, err := client.ListDrafts(1, 10, "pass") + if err != nil { + t.Fatalf("ListDrafts: %v", err) + } + if resp.Total != 1 { + t.Errorf("expected 1 draft, got %d", resp.Total) + } +} + +// ---------- SearchMessages ---------- + +func TestSearchMessages_Success(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + expected := SearchResponse{ + Total: 3, + Messages: []Message{ + {MessageID: "s1", Subject: "Result 1"}, + {MessageID: "s2", Subject: "Result 2"}, + {MessageID: "s3", Subject: "Result 3"}, + }, + } + + srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Query"] != "invoice" { + t.Errorf("expected query invoice, got %v", body["Query"]) + } + if body["Page"] != float64(1) { + t.Errorf("expected page 1, got %v", body["Page"]) + } + if body["PageSize"] != float64(20) { + t.Errorf("expected page size 20, got %v", body["PageSize"]) + } + writeJSON(t, w, http.StatusOK, expected) + }) + + resp, err := client.SearchMessages(SearchRequest{ + Query: "invoice", + Page: 1, + PageSize: 20, + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if resp.Total != 3 { + t.Errorf("expected 3 results, got %d", resp.Total) + } + if len(resp.Messages) != 3 { + t.Errorf("expected 3 messages, got %d", len(resp.Messages)) + } +} + +func TestSearchMessages_EmptyResults(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, SearchResponse{Total: 0, Messages: []Message{}}) + }) + + resp, err := client.SearchMessages(SearchRequest{ + Query: "nonexistent", + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if resp.Total != 0 { + t.Errorf("expected 0 results, got %d", resp.Total) + } +} + +func TestSearchMessages_APIError(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprintf(w, `{"Code":429,"Message":"rate limited"}`) + }) + + _, err := client.SearchMessages(SearchRequest{ + Query: "test", + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err == nil { + t.Fatal("expected error for 429") + } + if !strings.Contains(err.Error(), "rate limited") { + t.Errorf("expected 'rate limited' in error, got: %s", err.Error()) + } +} + +func TestSearchMessages_BadJSON(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `not json at all`) + }) + + _, err := client.SearchMessages(SearchRequest{ + Query: "test", + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err == nil { + t.Fatal("expected error for bad JSON") + } + if !strings.Contains(err.Error(), "failed to parse response") { + t.Errorf("unexpected error: %s", err.Error()) + } +} + +// ---------- Auth Header Propagation ---------- + +func TestAuthHeader_Propagated(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + + cfg := &config.Config{ + APIBaseURL: srv.URL(), + TimeoutSec: 5, + RateLimitReq: 100, + RateLimitWin: 60, + } + apiClient := api.NewProtonMailClient(cfg) + apiClient.SetAuthHeader("my-test-token") + client := NewClient(apiClient) + + var capturedAuth string + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + writeJSON(t, w, http.StatusOK, ListMessagesResponse{}) + }) + + _, _ = client.ListMessages(ListMessagesRequest{ + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + + if capturedAuth != "Bearer my-test-token" { + t.Errorf("expected Bearer my-test-token, got %s", capturedAuth) + } +} + +// ---------- Content-Type Headers ---------- + +func TestContentTypes_JSON(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + if ct != "application/json" { + t.Errorf("expected application/json, got %s", ct) + } + writeJSON(t, w, http.StatusOK, ListMessagesResponse{}) + }) + + _, _ = client.ListMessages(ListMessagesRequest{ + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) +} + +func TestContentTypes_FormUrlEncoded(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + if ct != "application/x-www-form-urlencoded" { + t.Errorf("expected application/x-www-form-urlencoded, got %s", ct) + } + w.WriteHeader(http.StatusOK) + }) + + _ = client.MoveToTrash("msg-1", "pass") +} + +// ---------- Concurrent Access ---------- + +func TestListMessages_Concurrent(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + var mu sync.Mutex + callCount := 0 + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + callCount++ + mu.Unlock() + writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0}) + }) + + const goroutines = 10 + var wg sync.WaitGroup + var results [10]error + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, results[idx] = client.ListMessages(ListMessagesRequest{ + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + }(i) + } + wg.Wait() + + for i, err := range results { + if err != nil { + t.Errorf("goroutine %d: %v", i, err) + } + } + + mu.Lock() + if callCount != goroutines { + t.Errorf("expected %d calls, got %d", goroutines, callCount) + } + mu.Unlock() +} + +// ---------- Folder Name ---------- + +func TestFolder_Name(t *testing.T) { + tests := []struct { + folder Folder + name string + }{ + {FolderInbox, "Inbox"}, + {FolderSent, "Sent"}, + {FolderDraft, "Drafts"}, + {FolderTrash, "Trash"}, + {FolderSpam, "Spam"}, + {Folder(99), "Unknown"}, + } + for _, tt := range tests { + if tt.folder.Name() != tt.name { + t.Errorf("Folder(%d).Name() = %q, want %q", tt.folder, tt.folder.Name(), tt.name) + } + } +} + +// ---------- Recipient DisplayName ---------- + +func TestRecipient_DisplayName(t *testing.T) { + tests := []struct { + r Recipient + expect string + }{ + {Recipient{Name: "Alice", Address: "alice@example.com"}, "Alice "}, + {Recipient{Address: "bob@example.com"}, "bob@example.com"}, + } + for i, tt := range tests { + got := tt.r.DisplayName() + if got != tt.expect { + t.Errorf("case %d: got %q, want %q", i, got, tt.expect) + } + } +} + +// ---------- Message Folder ---------- + +func TestMessage_Folder(t *testing.T) { + tests := []struct { + msg Message + folder Folder + }{ + {Message{Type: int(FolderDraft)}, FolderDraft}, + {Message{Type: int(FolderSent)}, FolderSent}, + {Message{Type: int(FolderInbox)}, FolderInbox}, + {Message{Type: 0}, FolderInbox}, + {Message{Type: 99}, FolderInbox}, + } + for i, tt := range tests { + got := tt.msg.Folder() + if got != tt.folder { + t.Errorf("case %d: got %v, want %v", i, got, tt.folder) + } + } +} + +// ---------- SetPGPService ---------- + +func TestSetPGPService(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + svc, _, _ := newTestService(t) + client.SetPGPService(svc) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + // PGP service should cause BodyEnc instead of Body + if _, hasBody := body["Body"]; hasBody { + t.Error("expected BodyEnc when PGP service is set") + } + writeJSON(t, w, http.StatusCreated, map[string]interface{}{}) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "PGP Test", + Body: "Secret", + Passphrase: "test-passphrase", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } +} + +// ---------- Send without Body ---------- + +func TestSend_WithoutBody(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if _, hasBody := body["Body"]; hasBody { + t.Error("Body should be omitted when empty") + } + if _, hasBodyEnc := body["BodyEnc"]; hasBodyEnc { + t.Error("BodyEnc should be omitted when Body is empty") + } + writeJSON(t, w, http.StatusOK, map[string]interface{}{}) + }) + + err := client.Send(SendRequest{ + To: []Recipient{{Address: "to@example.com"}}, + Subject: "No Body", + Passphrase: "pass", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } +} + +// ---------- Timeout ---------- + +func TestListMessages_Timeout(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + + cfg := &config.Config{ + APIBaseURL: srv.URL(), + TimeoutSec: 1, + RateLimitReq: 100, + RateLimitWin: 60, + } + apiClient := api.NewProtonMailClient(cfg) + apiClient.SetAuthHeader("test-token") + client := NewClient(apiClient) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + writeJSON(t, w, http.StatusOK, ListMessagesResponse{}) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Page: 1, + PageSize: 10, + Passphrase: "pass", + }) + if err == nil { + t.Fatal("expected timeout error") + } +} + +// ---------- Multiple Combined Filters ---------- + +func TestListMessages_CombinedFilters(t *testing.T) { + srv := newMockServer(t) + defer srv.Close() + client := newTestClient(t, srv) + + starred := true + unread := false + since := int64(1700000000) + + srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + body := readJSON(t, r) + if body["Type"] != float64(FolderSent) { + t.Errorf("expected Type=3, got %v", body["Type"]) + } + if body["Starred"] != true { + t.Errorf("expected Starred=true, got %v", body["Starred"]) + } + if body["Read"] != false { + t.Errorf("expected Read=false, got %v", body["Read"]) + } + if body["Since"] != float64(since) { + t.Errorf("expected Since=%d, got %v", since, body["Since"]) + } + writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}}) + }) + + _, err := client.ListMessages(ListMessagesRequest{ + Folder: FolderSent, + Page: 1, + PageSize: 10, + Passphrase: "pass", + Starred: &starred, + Read: &unread, + Since: since, + }) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } +}