Files
pop/internal/mail/client.go
Michael Freno bf26cd3ed6 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>
2026-05-14 00:40:24 -04:00

727 lines
19 KiB
Go

package mail
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
"github.com/frenocorp/pop/internal/api"
)
type Client struct {
apiClient *api.ProtonMailClient
baseURL string
pgpService *PGPService
}
func NewClient(apiClient *api.ProtonMailClient) *Client {
return &Client{
apiClient: apiClient,
baseURL: apiClient.GetBaseURL(),
}
}
func (c *Client) SetPGPService(svc *PGPService) {
c.pgpService = svc
}
func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, error) {
body := map[string]interface{}{
"Page": req.Page,
"PageSize": req.PageSize,
"Passphrase": req.Passphrase,
}
if req.Folder != FolderInbox {
body["Type"] = int(req.Folder)
}
if req.Starred != nil {
body["Starred"] = *req.Starred
}
if req.Read != nil {
body["Read"] = *req.Read
}
if req.Since > 0 {
body["Since"] = req.Since
}
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", 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 messages: %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 ListMessagesResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) GetMessage(messageID string, passphrase string) (*Message, error) {
var result struct {
Message Message `json:"Message"`
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to get message: %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)
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result.Message, nil
}
func (c *Client) Send(req SendRequest) error {
payload := map[string]interface{}{
"Type": MessageTypeRegular,
"Passphrase": req.Passphrase,
"Subject": req.Subject,
"HTML": req.HTML,
"To": req.To,
}
if req.Body != "" {
if c.pgpService != nil {
encrypted, err := c.pgpService.EncryptBody(req.Body, req.Passphrase)
if err != nil {
return fmt.Errorf("failed to encrypt message body: %w", err)
}
payload["BodyEnc"] = encrypted
} else {
payload["Body"] = req.Body
}
}
if len(req.CC) > 0 {
payload["CC"] = req.CC
}
if len(req.BCC) > 0 {
payload["BCC"] = req.BCC
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 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 fmt.Errorf("failed to send message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("send failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) MoveToTrash(messageID string, passphrase string) error {
body := map[string]string{
"Passphrase": passphrase,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/%s/trash", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("PUT", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 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 fmt.Errorf("failed to move to trash: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("trash failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) PermanentlyDelete(messageID string) error {
reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("DELETE", reqURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to delete message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
body := map[string]interface{}{
"Type": MessageTypeDraft,
"Passphrase": passphrase,
"Subject": draft.Subject,
"To": draft.To,
"Body": draft.Body,
}
if len(draft.CC) > 0 {
body["CC"] = draft.CC
}
if len(draft.BCC) > 0 {
body["BCC"] = draft.BCC
}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return "", 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 "", fmt.Errorf("failed to save draft: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
var result struct {
Message struct {
MessageID string `json:"MessageID"`
} `json:"Message"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
return result.Message.MessageID, nil
}
func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) error {
body := map[string]interface{}{
"Message": map[string]interface{}{
"Passphrase": passphrase,
"Subject": draft.Subject,
"To": draft.To,
"Body": draft.Body,
},
}
if len(draft.CC) > 0 {
body["Message"].(map[string]interface{})["CC"] = draft.CC
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("PUT", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 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 fmt.Errorf("failed to update draft: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("update failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) SendDraft(messageID string, passphrase string) error {
body := map[string]string{
"Passphrase": passphrase,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 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 fmt.Errorf("failed to send draft: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("send draft failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) ListDrafts(page int, pageSize int, passphrase string) (*ListMessagesResponse, error) {
req := ListMessagesRequest{
Folder: FolderDraft,
Page: page,
PageSize: pageSize,
Passphrase: passphrase,
}
return c.ListMessages(req)
}
func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
body := map[string]interface{}{
"Query": req.Query,
"Page": req.Page,
"PageSize": req.PageSize,
"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/messages/search", 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 search messages: %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 SearchResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
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
}