feat: implement Milestone 3 integration points

Add comprehensive integration capabilities to Pop CLI:

- Multi-account support with named profiles
- Webhook management with signature verification
- External PGP key management (import/export/encrypt/decrypt/sign/verify)
- CLI plugin system for extensibility
- Complete documentation in README.md

All compilation errors fixed and build verified CLEAN.

Security review delegated to FRE-5202.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-12 17:31:58 -04:00
parent e7e77fcc20
commit bf26cd3ed6
16 changed files with 3566 additions and 85 deletions

View File

@@ -0,0 +1,37 @@
# FRE-4762 Verification Complete
**Issue:** FRE-4762 — Fix API endpoint paths and HTTP methods to match ProtonMail contract
**Status:****DONE**
## Verification Summary
### Review Completed By
- **Reviewer:** Code Reviewer (f274248f-c47e-4f79-98ad-45919d951aa0)
- **Date:** 2026-05-12T03:24:53Z
- **Document:** `/home/mike/code/FrenoCorp/agents/code-reviewer/reviews/FRE-4762-review.md`
### Findings Verified
| Severity | Count | Details |
|----------|-------|---------|
| P1 Critical | 0 | None |
| P2 High | 1 | ListMessages uses POST with method override (non-blocking, known pattern) |
| P3 Minor | 2 | Redundant Body field, UpdateDraft structure |
### Contract Compliance ✅
- ✅ All endpoint paths use `/mail/v4/` prefix
- ✅ HTTP methods properly used (GET, POST, PUT, DELETE)
- ✅ Response structures match API spec
- ✅ Error handling consistent and proper
- ✅ Resource cleanup correct
## Final Disposition
**Status:** `done`
The implementation has been reviewed, approved, and verified against the go-proton-api v4 contract. All acceptance criteria met.
---
*Generated: 2026-05-12T03:35:00Z*

View File

@@ -7,6 +7,8 @@ import (
"io"
"net/http"
"net/url"
"os"
"time"
"github.com/frenocorp/pop/internal/api"
)
@@ -390,3 +392,335 @@ func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
return &result, nil
}
func (c *Client) ListConversations(page int, pageSize int, passphrase string) (*ConversationResponse, error) {
body := map[string]interface{}{
"Page": page,
"PageSize": pageSize,
"Passphrase": passphrase,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/conversations", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-HTTP-Method-Override", "GET")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to list conversations: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result ConversationResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) GetConversation(req GetConversationRequest) (*GetConversationResponse, error) {
body := map[string]interface{}{
"Passphrase": req.Passphrase,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/conversations/%s", c.baseURL, url.QueryEscape(req.ConversationID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-HTTP-Method-Override", "GET")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to get conversation: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result GetConversationResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) BulkDelete(messageIDs []string) (*BulkResponse, error) {
body := map[string]interface{}{
"MessageIDs": messageIDs,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-HTTP-Method-Override", "DELETE")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to bulk delete: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result BulkResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) BulkTrash(messageIDs []string, passphrase string) (*BulkResponse, error) {
body := map[string]interface{}{
"MessageIDs": messageIDs,
"Passphrase": passphrase,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk/trash", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to bulk trash: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result BulkResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) BulkStar(messageIDs []string, starred bool) (*BulkResponse, error) {
body := map[string]interface{}{
"MessageIDs": messageIDs,
"Starred": starred,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk/star", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to bulk star: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result BulkResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) BulkMarkRead(messageIDs []string, read bool) (*BulkResponse, error) {
body := map[string]interface{}{
"MessageIDs": messageIDs,
"Read": read,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk/read", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to bulk mark read: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result BulkResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) ExportMessages(req ExportRequest) ([]ExportedMessage, error) {
var messages []Message
if len(req.MessageIDs) > 0 {
for _, id := range req.MessageIDs {
msg, err := c.GetMessage(id, req.Passphrase)
if err != nil {
return nil, fmt.Errorf("failed to get message %s: %w", id, err)
}
messages = append(messages, *msg)
}
} else if req.Search != "" {
searchReq := SearchRequest{
Query: req.Search,
Page: 1,
PageSize: 100,
Passphrase: req.Passphrase,
}
searchResult, err := c.SearchMessages(searchReq)
if err != nil {
return nil, fmt.Errorf("failed to search messages: %w", err)
}
messages = searchResult.Messages
} else {
listReq := ListMessagesRequest{
Folder: req.Folder,
Page: 1,
PageSize: 100,
Passphrase: req.Passphrase,
}
if req.Since > 0 {
listReq.Since = req.Since
}
listResult, err := c.ListMessages(listReq)
if err != nil {
return nil, fmt.Errorf("failed to list messages: %w", err)
}
messages = listResult.Messages
}
exported := make([]ExportedMessage, 0, len(messages))
for _, msg := range messages {
exp := ExportedMessage{
MessageID: msg.MessageID,
ConversationID: msg.ConversationID,
From: msg.Sender,
To: msg.Recipients,
Subject: msg.Subject,
Body: msg.Body,
Date: msg.CreatedAt.Format(time.RFC3339),
Starred: msg.Starred,
Read: msg.Read,
Attachments: msg.Attachments,
}
exported = append(exported, exp)
}
return exported, nil
}
func (c *Client) ImportMessages(req ImportRequest) (*ImportResponse, error) {
fileData, err := os.ReadFile(req.FilePath)
if err != nil {
return nil, fmt.Errorf("failed to read import file: %w", err)
}
var messages []ExportedMessage
if req.Format == ExportFormatJSON {
if err := json.Unmarshal(fileData, &messages); err != nil {
return nil, fmt.Errorf("failed to parse import file: %w", err)
}
} else {
return nil, fmt.Errorf("unsupported import format: %s (use json)", req.Format.String())
}
if len(messages) == 0 {
return &ImportResponse{Total: 0, ImportedCount: 0}, nil
}
imported := 0
var errors []BulkError
for _, msg := range messages {
sendReq := SendRequest{
To: []Recipient{msg.From.ToRecipient()},
Subject: msg.Subject,
Body: msg.Body,
HTML: msg.HTML,
Passphrase: req.Passphrase,
}
if err := c.Send(sendReq); err != nil {
errors = append(errors, BulkError{
MessageID: msg.MessageID,
Error: err.Error(),
})
continue
}
imported++
}
return &ImportResponse{
ImportedCount: imported,
Total: len(messages),
Errors: errors,
}, nil
}

View File

@@ -32,22 +32,25 @@ func newMockServer(t *testing.T) *mockServer {
server: srv,
}
mux.HandleFunc("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("POST /mail/v4/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) {
mux.HandleFunc("POST /mail/v4/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) {
mux.HandleFunc("GET /mail/v4/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) {
mux.HandleFunc("PUT /mail/v4/messages/{id}", 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) {
mux.HandleFunc("PUT /mail/v4/messages/{id}/trash", 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) {
mux.HandleFunc("DELETE /mail/v4/messages/{id}", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
mux.HandleFunc("POST /mail/v4/messages/{id}", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
@@ -72,8 +75,8 @@ func resolveHandler(ms *mockServer, w http.ResponseWriter, r *http.Request) {
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.
// When POST /mail/v4/messages is called, it matches both list and send/draft.
// The generic handler for POST /mail/v4/messages catches all unmatched POST /mail/v4/messages calls.
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
}
@@ -129,7 +132,7 @@ func TestListMessages_Success(t *testing.T) {
},
}
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -167,7 +170,7 @@ func TestListMessages_WithFolderFilter(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -191,7 +194,7 @@ func TestListMessages_InboxOmitsType(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if _, ok := body["Type"]; ok {
t.Error("Inbox should omit Type field")
@@ -216,7 +219,7 @@ func TestListMessages_WithStarredFilter(t *testing.T) {
client := newTestClient(t, srv)
starred := true
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -242,7 +245,7 @@ func TestListMessages_WithReadFilter(t *testing.T) {
client := newTestClient(t, srv)
unread := false
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -268,7 +271,7 @@ func TestListMessages_WithSinceFilter(t *testing.T) {
client := newTestClient(t, srv)
since := int64(1700000000)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -293,7 +296,7 @@ func TestListMessages_APIError(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"Code":403,"Message":"invalid token"}`)
})
@@ -316,7 +319,7 @@ func TestListMessages_BadJSON(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"bad json`)
})
@@ -348,12 +351,8 @@ func TestGetMessage_Success(t *testing.T) {
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})
srv.Handle("GET /mail/v4/messages/msg-42", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": expectedMsg})
})
msg, err := client.GetMessage("msg-42", "pass")
@@ -377,8 +376,8 @@ func TestGetMessage_URLEscape(t *testing.T) {
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}})
srv.Handle("GET /mail/v4/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": Message{MessageID: msgID}})
})
msg, err := client.GetMessage(msgID, "pass")
@@ -395,7 +394,7 @@ func TestGetMessage_NotFound(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-999", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("GET /mail/v4/messages/msg-999", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"message not found"}`)
})
@@ -437,8 +436,8 @@ func TestGetMessage_DecryptBody(t *testing.T) {
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})
srv.Handle("GET /mail/v4/messages/msg-enc", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": msgWithEncryptedBody})
})
msg, err := client.GetMessage("msg-enc", pass)
@@ -457,7 +456,7 @@ func TestSend_Success(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -496,7 +495,7 @@ func TestSend_WithPGP(t *testing.T) {
client := NewClient(apiClient)
client.SetPGPService(svc)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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 {
@@ -524,7 +523,7 @@ func TestSend_WithCC(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
cc, ok := body["CC"].([]interface{})
if !ok || len(cc) != 1 {
@@ -549,7 +548,7 @@ func TestSend_WithBCC(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
bcc, ok := body["BCC"].([]interface{})
if !ok || len(bcc) != 1 {
@@ -574,7 +573,7 @@ func TestSend_HTTPError(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`)
})
@@ -598,7 +597,7 @@ func TestSend_CreatedStatus(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"Data":{"MessageID":"created-1"}}`)
})
@@ -620,13 +619,13 @@ func TestMoveToTrash_Success(t *testing.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")
srv.Handle("PUT /mail/v4/messages/msg-1/trash", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
t.Error("expected json content type")
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "Passphrase=pass") {
t.Errorf("expected Passphrase in form body, got %s", body)
if !strings.Contains(string(body), `"Passphrase":"pass"`) {
t.Errorf("expected Passphrase in json body, got %s", body)
}
w.WriteHeader(http.StatusOK)
})
@@ -642,7 +641,7 @@ func TestMoveToTrash_Error(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("PUT /mail/v4/messages/msg-1/trash", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "server error")
})
@@ -663,7 +662,7 @@ func TestPermanentlyDelete_Success(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("DELETE /mail/v4/messages/msg-1", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
@@ -678,7 +677,7 @@ func TestPermanentlyDelete_Error(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("DELETE /mail/v4/messages/msg-1", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
})
@@ -699,7 +698,7 @@ func TestPermanentlyDelete_URLEscape(t *testing.T) {
client := newTestClient(t, srv)
msgID := "msg/with/slashes"
srv.Handle("POST /api/messages/msg/with/slashes/delete", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("DELETE /mail/v4/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
@@ -716,7 +715,7 @@ func TestSaveDraft_Success(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -725,7 +724,7 @@ func TestSaveDraft_Success(t *testing.T) {
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"},
"Message": map[string]string{"MessageID": "draft-1"},
})
})
@@ -747,7 +746,7 @@ func TestSaveDraft_WithCC(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if _, ok := body["CC"]; !ok {
t.Error("expected CC field")
@@ -777,7 +776,7 @@ func TestSaveDraft_Error(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`)
})
@@ -797,7 +796,7 @@ func TestSaveDraft_BadJSON(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"bad json`)
})
@@ -822,10 +821,11 @@ func TestUpdateDraft_Success(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("PUT /mail/v4/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"])
msg := body["Message"].(map[string]interface{})
if msg["Subject"] != "Updated Subject" {
t.Errorf("expected Updated Subject, got %v", msg["Subject"])
}
w.WriteHeader(http.StatusOK)
})
@@ -845,9 +845,10 @@ func TestUpdateDraft_WithCC(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("PUT /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if _, ok := body["CC"]; !ok {
msg := body["Message"].(map[string]interface{})
if _, ok := msg["CC"]; !ok {
t.Error("expected CC field in update")
}
w.WriteHeader(http.StatusOK)
@@ -869,7 +870,7 @@ func TestUpdateDraft_Error(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("PUT /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
fmt.Fprintf(w, `{"Code":409,"Message":"draft locked"}`)
})
@@ -895,13 +896,13 @@ func TestSendDraft_Success(t *testing.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")
srv.Handle("POST /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
t.Error("expected json content type")
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "Passphrase=pass") {
t.Errorf("expected Passphrase in form, got %s", body)
if !strings.Contains(string(body), `"Passphrase":"pass"`) {
t.Errorf("expected Passphrase in json, got %s", body)
}
w.WriteHeader(http.StatusOK)
})
@@ -917,7 +918,7 @@ func TestSendDraft_Error(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1/send", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "not found")
})
@@ -945,7 +946,7 @@ func TestListDrafts_Success(t *testing.T) {
},
}
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -978,7 +979,7 @@ func TestSearchMessages_Success(t *testing.T) {
},
}
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])
@@ -1014,7 +1015,7 @@ func TestSearchMessages_EmptyResults(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, SearchResponse{Total: 0, Messages: []Message{}})
})
@@ -1037,7 +1038,7 @@ func TestSearchMessages_APIError(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"Code":429,"Message":"rate limited"}`)
})
@@ -1061,7 +1062,7 @@ func TestSearchMessages_BadJSON(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `not json at all`)
})
@@ -1097,7 +1098,7 @@ func TestAuthHeader_Propagated(t *testing.T) {
client := NewClient(apiClient)
var capturedAuth string
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
writeJSON(t, w, http.StatusOK, ListMessagesResponse{})
})
@@ -1120,7 +1121,7 @@ func TestContentTypes_JSON(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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)
@@ -1140,10 +1141,10 @@ func TestContentTypes_FormUrlEncoded(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("PUT /mail/v4/messages/msg-1/trash", 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)
if ct != "application/json" {
t.Errorf("expected application/json, got %s", ct)
}
w.WriteHeader(http.StatusOK)
})
@@ -1161,7 +1162,7 @@ func TestListMessages_Concurrent(t *testing.T) {
var mu sync.Mutex
callCount := 0
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
callCount++
mu.Unlock()
@@ -1267,7 +1268,7 @@ func TestSetPGPService(t *testing.T) {
svc, _, _ := newTestService(t)
client.SetPGPService(svc)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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 {
@@ -1294,7 +1295,7 @@ func TestSend_WithoutBody(t *testing.T) {
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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")
@@ -1331,7 +1332,7 @@ func TestListMessages_Timeout(t *testing.T) {
apiClient.SetAuthHeader("test-token")
client := NewClient(apiClient)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
writeJSON(t, w, http.StatusOK, ListMessagesResponse{})
})
@@ -1357,7 +1358,7 @@ func TestListMessages_CombinedFilters(t *testing.T) {
unread := false
since := int64(1700000000)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
srv.Handle("POST /mail/v4/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"])

View File

@@ -78,6 +78,13 @@ func (r Recipient) DisplayName() string {
return r.Address
}
func (r Recipient) ToRecipient() Recipient {
return Recipient{
Name: r.Name,
Address: r.Address,
}
}
type Attachment struct {
AttachmentID string `json:"AttachmentID"`
Name string `json:"Name"`
@@ -118,15 +125,17 @@ type ListMessagesResponse struct {
}
type SendRequest struct {
To []Recipient `json:"To"`
CC []Recipient `json:"CC,omitempty"`
BCC []Recipient `json:"BCC,omitempty"`
Subject string `json:"Subject"`
Body string `json:"Body"`
HTML bool `json:"HTML,omitempty"`
ReplyTo []Recipient `json:"ReplyTo,omitempty"`
To []Recipient `json:"To"`
CC []Recipient `json:"CC,omitempty"`
BCC []Recipient `json:"BCC,omitempty"`
Subject string `json:"Subject"`
Body string `json:"Body"`
HTML bool `json:"HTML,omitempty"`
ReplyTo []Recipient `json:"ReplyTo,omitempty"`
InReplyTo string `json:"InReplyTo,omitempty"`
References string `json:"References,omitempty"`
Attachments []Attachment `json:"Attachments,omitempty"`
Passphrase string `json:"Passphrase"`
Passphrase string `json:"Passphrase"`
}
type SearchRequest struct {
@@ -140,3 +149,118 @@ type SearchResponse struct {
Total int `json:"Total"`
Messages []Message `json:"Messages"`
}
// Conversation represents a threaded conversation (email thread)
type Conversation struct {
ConversationID string `json:"ConversationID"`
Subject string `json:"Subject"`
MessageCount int `json:"MessageCount"`
LastMessage *Message `json:"LastMessage"`
Participants []Recipient `json:"Participants"`
}
type ConversationResponse struct {
Total int `json:"Total"`
Conversations []Conversation `json:"Conversations"`
}
type GetConversationRequest struct {
ConversationID string `json:"ConversationID"`
Page int `json:"Page"`
PageSize int `json:"PageSize"`
Passphrase string `json:"Passphrase"`
}
type GetConversationResponse struct {
ConversationID string `json:"ConversationID"`
Subject string `json:"Subject"`
MessageCount int `json:"MessageCount"`
Messages []Message `json:"Messages"`
Participants []Recipient `json:"Participants"`
}
// BulkRequest represents a batch operation on multiple messages
type BulkRequest struct {
MessageIDs []string `json:"MessageIDs"`
Passphrase string `json:"Passphrase"`
}
type BulkResponse struct {
SuccessCount int `json:"SuccessCount"`
Total int `json:"Total"`
Errors []BulkError `json:"Errors,omitempty"`
}
type BulkError struct {
MessageID string `json:"MessageID"`
Error string `json:"Error"`
}
// ExportFormat represents the format for exporting messages
type ExportFormat int
const (
ExportFormatJSON ExportFormat = iota
ExportFormatMBOX
ExportFormatEMail
)
func (f ExportFormat) String() string {
names := map[ExportFormat]string{
ExportFormatJSON: "json",
ExportFormatMBOX: "mbox",
ExportFormatEMail: "eml",
}
if name, ok := names[f]; ok {
return name
}
return "json"
}
// ExportRequest represents a message export request
type ExportRequest struct {
MessageIDs []string `json:"MessageIDs,omitempty"`
Folder Folder `json:"Folder,omitempty"`
Format ExportFormat `json:"Format"`
Since int64 `json:"Since,omitempty"`
Before int64 `json:"Before,omitempty"`
Search string `json:"Search,omitempty"`
Passphrase string `json:"Passphrase"`
}
// ExportedMessage represents a message ready for export
type ExportedMessage struct {
MessageID string `json:"message_id"`
ConversationID string `json:"conversation_id"`
From Recipient `json:"from"`
To []Recipient `json:"to"`
CC []Recipient `json:"cc,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
HTML bool `json:"html"`
Date string `json:"date"`
Starred bool `json:"starred"`
Read bool `json:"read"`
Attachments []Attachment `json:"attachments,omitempty"`
}
// ImportRequest represents a message import request
type ImportRequest struct {
FilePath string `json:"FilePath"`
Format ExportFormat `json:"Format"`
Folder Folder `json:"Folder,omitempty"`
Passphrase string `json:"Passphrase"`
}
type ImportResponse struct {
ImportedCount int `json:"ImportedCount"`
Total int `json:"Total"`
Errors []BulkError `json:"Errors,omitempty"`
}
// DraftAutoSaveConfig holds auto-save settings for drafts
type DraftAutoSaveConfig struct {
Enabled bool `json:"enabled"`
Interval int `json:"interval_seconds"`
LastSaved int64 `json:"last_saved_timestamp"`
}